JVM:堆、方法区
一、堆
- 概念:堆用于存储对象和数组,主要分为新生代和老年代,新生代又细分为伊甸园区、幸存者 0 区(S0)和幸存者 1 区(S1)
- 内存设置:可用 -Xmx 和 -Xms 设置堆内存大小,-Xmx 为堆内存最大值,-Xms 是初始大小。若不设置,默认初始大小为物理内存的 1/64,最大为 1/4。超出最大内存,JVM 抛出内存溢出异常
- 新生代与老年代:新对象先存于伊甸园区,GC 后存活对象移至幸存者区,在 S0 和 S1 间移动。多次 GC 后仍存活的对象进入老年代。默认新生代与老年代大小比例为 1:2,可通过 -XX:NewRatio 调整。新生代中,伊甸园区和两个幸存者区默认比例为 8:1:1,可通过 -XX:SurvivorRatio 调整
- 对象分配:新对象(非大对象)先入伊甸园区,满后触发 Minor GC。首次 GC 时,伊甸园区存活对象移至 S0 或 S1 区,对象年龄加 1。后续每次 GC,存活对象在 S0 和 S1 间切换,默认对象年龄达 15 进入老年代。老年代满触发 Major GC(Full GC),若仍无法分配内存则抛出异常
- GC 分类:Minor GC 回收新生代;Major GC 主要回收老年代,实际中常伴随 Full GC;Full GC 会回收整个堆,包括新生代和老年代
- 分代目的:分代是为优化 GC 效率,有针对性回收,避免全堆回收耗时过长。多数对象在伊甸园区分配,大对象直接进入老年代
- TLAB:JVM 为每个线程在伊甸园区分配 TLAB,避免多线程对象分配时的竞争问题,保证线程安全。若 TLAB 空间不足,线程会加锁获取伊甸园区空闲区域以扩容
二、方法区
- 方法区是所有线程共享的。自 Java 8 开始,元空间成为其一种实现,它使用本地内存,不再受 JVM 堆内存限制,但仍受 JVM 管理
- Java 8 及以后,可通过 -XX:MetaspaceSize(初始默认大小)和 -XX:MaxMetaspaceSize(最大大小)设置其大小,超出最大大小会抛出内存溢出异常
- 内部结构:包含类信息(类的定义,如属性、方法定义及方法字节码,还有类的继承关系)、静态变量、运行时常量池(存放类中的常量、符号引用等)、JIT 即时编译器编译后的代码缓存
- GC 时方法区垃圾会被回收。常量无引用时会被回收;类需同时满足三个条件才会被回收:该类所有实例被销毁、该类的 java.lang.Class 对象未被引用、加载该类的类加载器已被回收
三、其它相关知识
(1)TLAB(Thread Local Allocation Buffer)
- 堆内存对象分配的线程安全问题:在多线程环境下,如果多个线程同时在堆的 Eden 区进行对象分配,可能会出现线程安全问题。因为堆是线程共享的内存区域,当多个线程同时尝试修改 Eden 区的空闲内存指针时,就可能导致数据不一致。例如,线程 A 和线程 B 同时读取到空闲内存指针指向地址 X,线程 A 在该地址创建了一个对象,然后将空闲内存指针向后移动;但线程 B 并不知道指针已经移动,仍然在地址 X 处创建对象,这就会造成内存覆盖和数据混乱
- TLAB 避免线程安全问题的原理:
- 线程私有缓存区域:TLAB 是在 Eden 区内为每个线程单独分配的一块私有缓存区域。每个线程在自己的 TLAB 中进行对象分配,就好像每个线程都有自己的 “专属领地”。由于 TLAB 是线程私有的,一个线程对其 TLAB 内的内存操作不会影响其他线程的 TLAB,因此避免了多个线程同时访问和修改同一内存区域的情况,从根本上解决了线程安全问题
- 减少锁竞争:如果没有 TLAB,线程在堆上分配对象时可能需要加锁来保证线程安全,频繁的加锁和解锁操作会带来较大的性能开销。而使用 TLAB 后,线程在自己的 TLAB 内分配对象无需加锁,只有当 TLAB 空间不足需要重新分配 TLAB 时,才需要同步锁定 Eden 区以获取新的 TLAB 空间,大大减少了锁竞争,提高了对象分配的效率
- 示例说明:假设有两个线程 Thread1 和 Thread2 同时运行,JVM 为它们分别分配了 TLAB1 和 TLAB2。当 Thread1 需要创建一个新对象时,它会直接在 TLAB1 中分配内存,不会影响 Thread2 的 TLAB2;同理,Thread2 在 TLAB2 中进行对象分配时也不会受到 Thread1 的干扰。只有当 TLAB1 或 TLAB2 空间不足时,线程才会去申请新的 TLAB 空间,并且在这个过程中会进行同步操作,以确保多个线程不会同时修改 Eden 区的空闲内存指针
(2)逃逸分析
3.2.1逃逸分析的概念
- 方法逃逸:一个对象在方法中被创建,但是它的引用被传递出了该方法,可能被其他方法使用。例如,在方法中创建对象并将其作为返回值返回,或者将对象的引用赋值给类的成员变量等
- 线程逃逸:一个对象的引用可以被多个线程访问到,比如将对象的引用存储在静态变量或者共享的集合中
3.2.2栈上分配
如果逃逸分析的结果表明一个对象不会发生逃逸,也就是该对象的引用不会超出当前方法的作用域,那么 JVM 可以选择将这个对象分配到栈上,而不是堆上
- 原理:栈上分配的对象会随着方法的结束而自动销毁,不需要垃圾回收器进行回收,这样可以减少堆内存的压力,也避免了垃圾回收带来的性能开销
- 示例:
public class StackAllocationExample {public static void main(String[] args) {for (int i = 0; i < 1000000; i++) {createObject();}}public static void createObject() {// 这里创建的对象未发生逃逸Point point = new Point(1, 2); } }class Point {private int x;private int y;public Point(int x, int y) {this.x = x;this.y = y;} }
在上述代码中,createObject 方法里创建的 Point 对象没有发生逃逸,JVM 就可能将其分配到栈上
3.2.3同步省略(锁消除)
当逃逸分析发现一个对象不会发生线程逃逸时,那么对该对象的同步操作(加锁)就可以被消除
- 原理:因为对象不会被其他线程访问,所以对它进行同步操作是没有必要的,JVM 会在编译时自动将这些不必要的同步代码去掉,从而减少了同步带来的性能开销
- 示例:
public class SyncEliminationDemo {public void test() {// 创建局部对象,不会发生线程逃逸Object lock = new Object();synchronized (lock) {System.out.println("执行同步块");}} }
在上述代码中,lock对象是test方法的局部变量,仅在该方法内使用,不会被其他线程访问。JVM 编译时,经逃逸分析确定lock无线程逃逸风险,会消除synchronized同步块,优化为:
public class SyncEliminationDemo {public void test() {Object lock = new Object();System.out.println("执行同步块");}
}
如此,避免了同步操作带来的性能开销,提升了程序执行效率
3.2.4标量替换
如果一个对象不会发生逃逸,JVM 可以不创建这个对象,而是将对象的成员变量分解成一个个独立的标量(基本数据类型)来代替
- 原理:将对象拆分成标量后,这些标量可以直接在栈上分配和操作,避免了对象创建和访问的开销,进一步提高了性能
- 示例:
public class ScalarReplacementExample {public static void main(String[] args) {alloc();}public static void alloc() {// 这里的 Point 对象可能会被进行标量替换Point point = new Point(1, 2); int x = point.x;int y = point.y;System.out.println(x + y);} }class Point {int x;int y;public Point(int x, int y) {this.x = x;this.y = y;} }
在 alloc 方法中,Point 对象没有发生逃逸,JVM 可能会将 Point 对象拆分成 x 和 y 两个标量,直接在栈上分配和使用