🍊 跨代引用
在Java虚拟机中,跨代引用指的是老年代中的对象引用了年轻代中的对象。当进行年轻代的垃圾回收时,需要扫描老年代中所有引用年轻代的对象,在老年代里面做大量的遍历和扫描操作,这是非常消耗性能的。为了优化这个问题,JVM引入了记忆集(Card Table)的抽象数据结构。
记忆集记录了从非收集区域指向收集区域的一个指针集合,这个指针集合是由一组连续的内存卡片(Card)组成的。每个内存卡片的大小是2^18个字节,可以理解为一个内存页面。记忆集能够记录指向年轻代的内存卡片中所有的指针,将这些指针标记为"dirty"。在minor gc时,只需要扫描"dirty"的内存卡片,而不是扫描老年代中所有引用年轻代的对象。
思路比较清晰,现在来看一下这个问题的示例代码:
public class CrossGenerationReferenceExample { public static void main(String[] args) { List<Object> list = new ArrayList<>(); Object obj = new Object(); for (int i = 0; i < 1000000; i++) { list.add(obj); } } }
这个示例代码中的问题在于,对象obj
在年轻代中创建,但是被放到了老年代中的ArrayList
里面。这个时候,当进行年轻代的垃圾回收时,就需要扫描老年代中所有引用年轻代的对象,而且这个ArrayList
还是一个非常大的对象,扫描的效率非常低下。
现在来看一下使用记忆集优化之后的代码:
public class CrossGenerationReferenceExample { public static void main(String[] args) { List<Object> list = new ArrayList<>(); Object obj = new Object(); for (int i = 0; i < 1000000; i++) { list.add(obj); if (i % 20000 == 0) { System.gc(); } } } }
在这个示例代码中,每添加20000个对象就手动执行一次垃圾回收操作。这个操作会强制触发记忆集的更新,即将内存卡片中的指针标记为"dirty"。这样,在下一次垃圾回收时,就只需要扫描"dirty"的内存卡片,提高了垃圾回收的效率。
🍊 逃逸分析
逃逸分析是Java虚拟机优化的重要手段之一,它可以通过静态分析来确定对象的动态作用域,判断一个对象是否会“逃逸”,即被其他方法或线程引用。如果对象不会逃逸,则可以将对象的创建和销毁都放在栈上进行,从而减少在堆上进行内存分配和回收的开销。
逃逸分析有三种程度:从不逃逸、方法逃逸和线程逃逸。这三个由低到高表示不同逃逸的程度。
🎉 栈上分配
在Java虚拟机中,堆中的对象对于所有线程都是可见的,只要持有这个对象的引用,就可以访问到堆中存储的对象数据。虚拟机的垃圾回收子系统会回收堆中不再使用的对象,但是回收动作无论是标记筛选出可回收对象,还是回收和整理内存,都需要耗费大量资源。
如果确定一个对象不会逃逸出线程之外,那让这个对象在栈上分配内存将会是一个很不错的主意,对象所占用的内存空间就可以随栈帧出栈而销毁。在一般应用中,完全不会逃逸的局部对象和不会逃逸出线程的对象所占的比例是很大的,如果能使用栈上分配,那大量的对象就会随着方法的结束而自动销毁,垃圾回收子系统的压力将会下降很多。
栈上分配可以支持方法逃逸,但不能支持线程逃逸。
下面是一个栈上分配的示例代码:
public static void main(String[] args) { for (int i = 0; i < 1000000; i++) { Point p = new Point(1, 2); p.setX(i); p.setY(i); } } class Point { private int x; private int y; public Point(int x, int y) { this.x = x; this.y = y; } public void setX(int x) { this.x = x; } public void setY(int y) { this.y = y; } }
在这个示例代码中,每次循环都创建一个Point
对象,在Point
对象的构造函数中将x
和y
初始化。Point
对象不会逃逸出方法之外,因此可以将它们分配在栈上。
🎉 标量替换
一个数据已经无法再分解成更小的数据来表示了,Java虚拟机中的原始数据类型(int、long等数值类型及reference类型等)都不能再进一步分解了,那么这些数据就可以被称为标量。
一个数据可以继续分解,那它就被称为聚合量。Java中的对象就是典型的聚合量。如果把一个Java对象拆散,根据程序访问的情况,将其用到的成员变量恢复为原始类型来访问,这个过程就称为标量替换。
假如逃逸分析能够证明一个对象不会被方法外部访问,并且这个对象可以被拆散,那么程序真正执行的时候将可能不去创建这个对象,而改为直接创建它的若干个被这个方法使用的成员变量来代替。
将对象拆分后,除了可以让对象的成员变量在栈上(栈上存储的数据,很大机会被虚拟机分配至物理机器的高速寄存器中存储)分配和读写之外,还可以为后续进一步的优化手段创建条件。标量替换可以视作栈上分配的一种特例,实现更简单(不用考虑整个对象完整结构的分配),但对逃逸程度的要求更高,它不允许对象逃逸出方法范围内。
下面是一个标量替换的示例代码:
public static void main(String[] args) { for (int i = 0; i < 1000000; i++) { String s = new String("abc" + i); int length = s.length(); } }
在这个示例代码中,每次循环都创建一个String
对象,并取出它的长度。逃逸分析能够证明String
对象不会逃逸出方法之外,因此可以将对象的成员变量拆分为一个char
数组和一个offset
整型变量。在Java虚拟机内部,会把char
数组和offset
整型变量分别分配在栈上,而不需要在堆上分配String
对象。
总之,逃逸分析是一种非常重要的JVM优化手段,能够让JVM能够更好地利用现代计算机的硬件资源,提高程序的性能。通过栈上分配和标量替换等技术,能够在一定程度上减少对堆内存的使用,从而让垃圾回收的效率更高,进而让程序能够更好地使用计算机的内存和CPU资源。