Skip to content

JVM内存模型

内存结构

JVM内存结构是怎样的?

答案:

JVM内存
├── 线程共享
│   ├── 堆(Heap)
│   │   ├── 新生代(Young Generation)
│   │   │   ├── Eden区
│   │   │   ├── Survivor0区(From)
│   │   │   └── Survivor1区(To)
│   │   └── 老年代(Old Generation)
│   └── 方法区(Method Area)/ 元空间(Metaspace)
│       ├── 类信息
│       ├── 常量池
│       ├── 静态变量
│       └── JIT编译后的代码
└── 线程私有
    ├── 程序计数器(Program Counter)
    ├── 虚拟机栈(VM Stack)
    │   └── 栈帧(Stack Frame)
    │       ├── 局部变量表
    │       ├── 操作数栈
    │       ├── 动态链接
    │       └── 方法出口
    └── 本地方法栈(Native Method Stack)

各个区域的作用?

答案:

1. 程序计数器(Program Counter)

  • 记录当前线程执行的字节码行号
  • 线程私有
  • 唯一不会OOM的区域

2. 虚拟机栈(VM Stack)

  • 存储方法调用的栈帧
  • 每个方法对应一个栈帧
  • 线程私有
  • 可能抛出StackOverflowError(栈深度超限)或OOM(无法分配内存)

栈帧结构

java
public int add(int a, int b) {
    int c = a + b;
    return c;
}

// 栈帧包含:
// 1. 局部变量表:a, b, c
// 2. 操作数栈:计算a+b
// 3. 动态链接:指向运行时常量池的方法引用
// 4. 方法出口:返回地址

3. 本地方法栈(Native Method Stack)

  • 为Native方法服务
  • 线程私有

4. 堆(Heap)

  • 存储对象实例和数组
  • 线程共享
  • GC的主要区域
  • 可能抛出OOM

堆内存分代

新生代(Young):老年代(Old) = 1:2(默认)
Eden:Survivor0:Survivor1 = 8:1:1(默认)

5. 方法区(Method Area)/ 元空间(Metaspace)

  • JDK7及之前:永久代(PermGen),在堆中
  • JDK8及之后:元空间(Metaspace),在本地内存
  • 存储类信息、常量、静态变量、JIT代码
  • 可能抛出OOM

为什么要分代?

答案: 根据对象存活时间的不同,采用不同的GC策略,提高GC效率。

分代假说

  1. 弱分代假说:大部分对象朝生夕死
  2. 强分代假说:熬过多次GC的对象难以消亡

优势

  • 新生代:对象存活率低,使用复制算法
  • 老年代:对象存活率高,使用标记-清除或标记-整理算法

对象创建

对象的创建过程?

答案:

1. 类加载检查

2. 分配内存
   ├── 指针碰撞(内存规整)
   └── 空闲列表(内存不规整)

3. 初始化零值

4. 设置对象头
   ├── Mark Word(哈希码、GC年龄、锁信息)
   └── 类型指针

5. 执行<init>方法
   ├── 执行父类构造方法
   ├── 初始化实例变量
   └── 执行构造方法

代码示例

java
User user = new User("Tom", 20);

// 1. 检查User类是否已加载
// 2. 在堆中分配内存
// 3. 将内存初始化为零值(name=null, age=0)
// 4. 设置对象头信息
// 5. 执行User的<init>方法
//    - 调用Object的构造方法
//    - 初始化name="Tom", age=20
//    - 执行User的构造方法体

对象的内存布局?

答案:

对象内存布局
├── 对象头(Header)
│   ├── Mark Word(8字节)
│   │   ├── 哈希码(HashCode)
│   │   ├── GC分代年龄
│   │   ├── 锁状态标志
│   │   └── 线程持有的锁
│   └── 类型指针(4/8字节)
│       └── 指向类元数据
├── 实例数据(Instance Data)
│   └── 对象的字段数据
└── 对齐填充(Padding)
    └── 保证对象大小是8字节的倍数

示例

java
public class User {
    private int age;      // 4字节
    private String name;  // 4字节(引用)
}

// 对象大小:
// 对象头:12字节(Mark Word 8 + 类型指针 4)
// 实例数据:8字节(age 4 + name引用 4)
// 对齐填充:4字节(保证总大小是8的倍数)
// 总计:24字节

对象的访问方式?

答案:

1. 句柄访问

栈 → 句柄池 → 堆中对象

      方法区类型数据
  • 优点:对象移动时只需修改句柄,栈中引用不变
  • 缺点:多一次指针定位

2. 直接指针(HotSpot使用)

栈 → 堆中对象 → 方法区类型数据
  • 优点:速度快,少一次指针定位
  • 缺点:对象移动时需要修改栈中引用

类加载

类加载的过程?

答案:

1. 加载(Loading)
   - 通过类的全限定名获取二进制字节流
   - 将字节流转换为方法区的运行时数据结构
   - 在堆中生成Class对象

2. 验证(Verification)
   - 文件格式验证
   - 元数据验证
   - 字节码验证
   - 符号引用验证

3. 准备(Preparation)
   - 为类变量分配内存
   - 设置初始值(零值)

4. 解析(Resolution)
   - 将符号引用转换为直接引用

5. 初始化(Initialization)
   - 执行类构造器<clinit>()
   - 初始化类变量和静态代码块

示例

java
public class Test {
    private static int a = 1;  // 准备阶段:a=0,初始化阶段:a=1
    private static final int b = 2;  // 准备阶段:b=2(常量直接赋值)

    static {
        System.out.println("静态代码块");  // 初始化阶段执行
    }
}

类加载器有哪些?

答案:

类加载器
├── 启动类加载器(Bootstrap ClassLoader)
│   └── 加载<JAVA_HOME>/lib下的核心类库
├── 扩展类加载器(Extension ClassLoader)
│   └── 加载<JAVA_HOME>/lib/ext下的扩展类库
├── 应用程序类加载器(Application ClassLoader)
│   └── 加载classpath下的应用类
└── 自定义类加载器(Custom ClassLoader)
    └── 用户自定义的类加载器

什么是双亲委派模型?

答案: 类加载器收到加载请求时,先委派给父加载器加载,父加载器无法加载时才自己加载。

流程

1. 应用程序类加载器收到请求

2. 委派给扩展类加载器

3. 委派给启动类加载器

4. 启动类加载器尝试加载
   - 成功:返回Class对象
   - 失败:返回给扩展类加载器

5. 扩展类加载器尝试加载
   - 成功:返回Class对象
   - 失败:返回给应用程序类加载器

6. 应用程序类加载器尝试加载
   - 成功:返回Class对象
   - 失败:抛出ClassNotFoundException

优点

  • 避免类的重复加载
  • 保护核心类库不被篡改

示例

java
// 自定义String类
package java.lang;

public class String {
    // 自定义实现
}

// 加载时会委派给启动类加载器
// 启动类加载器加载JDK的String类
// 自定义的String类不会被加载

如何打破双亲委派?

  • 重写loadClass()方法
  • 使用线程上下文类加载器
  • OSGI模块化

内存分配

对象优先在Eden区分配?

答案:

java
// -Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails
public class Test {
    public static void main(String[] args) {
        byte[] b1 = new byte[2 * 1024 * 1024];  // 2MB,分配在Eden
        byte[] b2 = new byte[2 * 1024 * 1024];  // 2MB,分配在Eden
        byte[] b3 = new byte[2 * 1024 * 1024];  // 2MB,分配在Eden
        byte[] b4 = new byte[4 * 1024 * 1024];  // 4MB,Eden不足,触发Minor GC
    }
}

大对象直接进入老年代?

答案: 大对象(需要大量连续内存的对象)直接分配在老年代,避免在Eden和Survivor之间复制。

java
// -XX:PretenureSizeThreshold=3145728(3MB)
byte[] b = new byte[4 * 1024 * 1024];  // 4MB,直接进入老年代

长期存活的对象进入老年代?

答案: 对象在Survivor区每熬过一次Minor GC,年龄+1,当年龄达到阈值(默认15)时,晋升到老年代。

java
// -XX:MaxTenuringThreshold=15(默认)

对象年龄

新创建:年龄0,在Eden区
第1次GC:年龄1,进入Survivor
第2次GC:年龄2,在Survivor
...
第15次GC:年龄15,进入老年代

动态对象年龄判定?

答案: 如果Survivor区中相同年龄所有对象大小的总和大于Survivor空间的一半,年龄大于或等于该年龄的对象直接进入老年代。

java
// Survivor区大小:1MB
// 年龄1的对象:300KB
// 年龄2的对象:300KB
// 年龄3的对象:300KB
// 年龄1+2的对象总和:600KB > 500KB(Survivor的一半)
// 年龄2及以上的对象直接进入老年代

练习题

  1. 堆和栈的区别?
  2. 方法区和永久代、元空间的关系?
  3. 为什么JDK8要用元空间替代永久代?
  4. 如何判断一个对象是否可以被回收?

Released under the MIT License.