JVM 垃圾回收算法细节
目录
前言
GC Root 可达性分析
根节点枚举
安全点
安全区域
记忆集与卡表
写屏障
并行的可达性分析
前言
学习了几种垃圾收集算法之后, 我们再来看看它们在具体实现上有什么细节之处,我们所能看到的理论很简单,但是实现起来那就是另外一回事了。
GC Root 可达性分析
我们之前讲的,可达性分析,就是一个非常不错的,用于查看一个对象是否需要被消灭(从内存中删除)的方法,也可以说看一个对象是否存活的方法。
可达性分析法从GC Root开始,往下遍历,根据引用的关系向下搜索,搜索过的路径被称为引用链,如下:
图中的连线你可以理解为引用链,这个结构类似树形结构,不在这个任何一个引用链上的对象,就可以被判定为一个死亡的对象。
那么前面也提到过,哪些东西可以作为GC Root呢?我们来仔细思考一下,而不是直接翻阅别的文档,例如一个线程执行到了方法调用的这段代码之后,就会产生一个新的函数栈帧,然后在被调用的方法中,会生成局部变量,这个局部变量可能是一个引用变量,如下:
public class Demo {
// 定义一个方法,该方法接受一个Person类型的引用变量
public static void func() {
// person的引用
Person person = new Person("Alice", 30);
System.out.println("Name: " + person.getName());
System.out.println("Age: " + person.getAge());
}
public static void main(String[] args) {
introducePerson(person);
}
}
其中调用func()的时候,就创建了对应的函数栈帧和局部变量表,这个局部变量表中就包含了引用变量"person", 此时,person这个引用变量就可以作为一个GC Root,那么此时"new Person("Alice", 30); ", 也就是在内存中真实存在的实例对象,它就被当前的person引用变量作为GC Root的引用链连接着,因此在还没有出栈的时候,person引用变量指向的对象一直是存活的,直到方法出栈,引用变量person被释放,那么就没有变量引用这个person真实的对象了,因此也就没有跟任何引用链相关联,因此在不久后就会被垃圾收集器回收。
当然不只是局部变量,还有如下:
- java虚拟机战中的引用的对象:参数,临时变量,包括局部变量
- 方法区中引用的对象,例如字符串常量池
- 本地方法栈同java虚拟机栈
- 。。。。不一一列举
根节点枚举
从上述的可行性分析的角度来看,我们思考一个问题:
- 我如何知道一个对象是否没有任何引用链与之关联?
其实可以想到,我们把所有的可以作为GC Root的东西拿出来放在一个list或者一个集合,我们称之为GC Roots, 或者GC Root List(Set), 然后去遍历这个集合中的每一个引用链,如果发现存在引用关系,那么他就是存在引用链与之关联,反之即为死亡对象。
但是这样做必然存在一个问题,那就是这样的遍历速度会非常慢,尽管你已经知道要在哪里找了,但是查找的过程依然不是一个简单的过程,现在的java程序,做大了光是方法区就有几百上千兆,里面的类和常量更是不计其数。逐个检查以这里为起源的引用链就要花费不少力气。我们把这种暴力遍历的行为称之为根节点枚举
不光如此,加上你在遍历的时候,如果用户线程是运行的状态,那么几乎是必然会产生变量引用改变的问题,你对一个对象进行了根节点枚举,此时发现它存在相关引用链,因此判定为存活,但是在用户线程运行期间,他可能会因为方法出栈而被取消引用,从而丧失当前的引用链关系,从而变成下一次GC的对象。那么当前的GC统计结果也就不正确了。
因此根节点枚举,这个步骤中,用户线程必然是停止的,这也是导致垃圾收集必然会停顿的所有用户线程的一个重要原因。
如何解决?请思考片刻,然后继续往下看。
正向找很难找,为什么不逆向试试?上上届我们学过,在java中,编译的所占的内存是固定的,并且我可以在编译的时候,就知道哪里存存在某个对象的引用变量,列举一个例子,那现在有一个Person类,如下:
public class Person {
private String name;
private int age;
public Person(String name, int age) {
this.name = name;
this.age = age;
}
// 假设还有其他方法...
}
在编译一个方法的时候,发现这个方法里面存在一个对Person对象的引用变量,因此它就会对其进行记录, 我们称之为OopMap,如下:
指令位置 | 寄存器/栈槽 | 引用类型 |
0x0120 | 栈槽0 | Person |
这个记录告诉我们,在指令位置为0x0120的时候,栈槽0中,存储了一个Person类型的引用变量,这里的指令位置是指的编译后产生的字节码中的偏移量,在垃圾手机的时候,当JVM需要进行可达性分析的时候,它会查找当前线程执行到的字节码的位置,并检查是否与之有对应的OopMap。例如在运行的时候你需要确定堆中的一个person的对象是否存在引用链,你就可以去扫描OopMap,发现指令位置为0x0120的地方存在对Person的引用,一旦你找到了包含0x0120
指令的方法接下来就需要分析该方法的局部变量表(Local Variables)和操作数栈(Operand Stack)。
扫描每个线程的虚拟机栈,找到对应的栈帧中的存在的对person对象中的引用。进行扫描即可,所以避免了为了一个对象的存活去扫描整个GC Root List;
安全点
前面提到可以使用OopMap来极大的缩小停顿的时间,但是引用的变化会引起OopMap的频繁更新和新增,那么就会额外消耗内存,在进行垃圾回收之前,JVM需要确保没有线程在对对象进行修改,以避免在回收过程中错误地标记或删除仍在使用的对象。安全点允许JVM暂停所有线程,从而确保垃圾回收器能够安全地执行。
在HotSpot JVM中,安全点主要在以下位置设置:
- 方法调用:在方法调用之前和之后插入安全点,确保方法调用过程中的状态一致性。
- 循环末尾:对于长时间运行的循环,在循环的末尾设置安全点,以减少线程长时间无法进入安全点导致的GC停顿时间过长。
- 异常处理:在异常抛出和处理过程中设置安全点,确保异常处理过程中的状态一致性。
- 同步块:在进入和退出同步块时设置安全点,保护同步块内的资源不被非法访问或修改。
如何让所有的线程都到安全点的地方去?
有的线程可能到达了自己的安全点,有的线程可能没有到达自己的安全点,此时有两种方案可以选择:
- 抢占式: 不需要线程的执行代码主动去配合,在垃圾收集发生时,系统首先把所有用户线程全部中断,如果发现有用户线程中断的地方不在安全点上,就恢复这条线程执行,让它一会再重新中断,直到跑到安全点上
- 主动式:不直接对线程,各个线程执操作,仅仅简单地设置一个标志位行过程时会不停地主动去轮询这个标志,一旦发现中断标志为真时就自己在最近的安全点上主动中断挂起
轮询这个操作必须足够高效(因为涉及到很多线程),HotSpot使用内存保护陷阱的方式来实现。
安全区域
现在所有的线程都到达自己的安全点了,那么就进入了GC的状态了。但是实际情况缺不一定,想想为什么,学过多线程的都知道,如果一个线程处于阻塞或者是睡眠状态,那么这个线程就无法轮询当前的标志位,也就无法得知自己是否需要在下一个安全点停下来。不能再继续走到安全的地方去挂断自己,显然也不可能等待被阻塞的线程被重新激活然后分配处理器时间。
就必须使用另外一种方法解决它。那就是安全区域,一个线程进入安全区域之后,是不允许操作对象和其引用的,因此即使没有响应垃圾收集到安全点挂断自己,也可以在其中进行垃圾收集。收集完毕之后,就会收到信号,线程就可以离开这个安全区域。
记忆集与卡表
为了解决对象的跨代引用的问题,垃圾收集器在新生代中建立了名为记忆集的数据结构,避免为了一个对象存在跨代引用而将老年代整个加入GC Roots,记忆集是一种,记录从非收集区域指向手机区域的指针集合的数据结构。
比如我们可以将存在跨代引用的老年代对象的引用归为一个集合,然后如果需要回收的对象,只需要扫描这个记忆集,就可以快速知道哪些对象存在跨代引用,这种记录的方式会非常消耗内存,因此在垃圾手机的时候,只需要通过记忆集,来得知某一块内存区域中存在老年代的引用新生代的情况即可。
当然你还有其他的精度可以选择:
- 字长精度 : 每个记录精确到一个字长, 该字包含了跨代指针
- 对象精度: 每个记录精确到一个对象, 该对象里面有跨代的指针
- 卡精度: 也就是每个记录精确到一个内存区域,
我们只用扫描特定的返回即可,不必一个对象一个对象的搜索.
我们详细说说卡表的形式. 卡表的最简单的形式只是一个字节数组, 每一个字节数组的每一个元素都对应标识着一个内存块, 这个内存块也被称为卡页, 每个卡页一般是2的N次幂的字节数, 如果每个卡页的大小为512字节的话, 如果卡表的表示区域的起始地址是0x0000的话, 那么一个卡页就会往后偏移512字节的大小, 卡页1对应的内存的地址范围就为0x0000 ~ 0x01FFF. 以此类推:
这一个卡页里面, 通常不只是包含一个对象, 同时, 这个卡页标记的内存区域, 只要有一个对象存在跨代引用, 那么对应的标志位就会被置为1. 没有则标为0, 在GC的时候, 也就只会扫描卡表标志位为1的卡页, 从而避免了扫描整个老年代.
写屏障
从最开始的对象的可达性分析, 通过OopMap来避免扫描整个GC Roots的方法减少根节点枚举的时间, 到后面的进行GC的时候为了避免用户线程的执行导致的枚举的状态信息数据变脏从而影响数据一致性, 到现在的通过安全区域防止没有接收到中断指令的线程修改对象引用.
上届我们讲述了记忆集和课表, 其作用就是为了快速的找出那些跨代引用的老年代对象, 从而避免将整个老年代加入到GC Roots中进行扫描.
卡表示记忆集的一种实现, 它将内存分文为卡页, 然后通过标记区域的方式, 来说明某个区域是否可能包含跨代引用的对象.
但是已经生成的卡表, 其中已经尽数标记了内存区域, 但是如果在标记的过程中, 未收集区域的对象持有并修改了它自己的对收集区域的对象的引用, 例如一个对象obj在老年代中, 它本来引用了新生代的 a对象. 但是a对象被标记为回收, 此时obj所在的卡页就会被标记为1(存在跨代引用), 但是如果在扫描的时候, 这个引用被更改, 此时恰好obj所在的卡页没有任何的跨代引用了, 此时卡页的标记应该被置为1, 但是真实情况是不变.
接下来我们的问题来了, 我们该怎么阻止对象赋值的那一刻去更新维护卡表?
HotSpot通过写屏障来维护卡表状态, 在JVM中,特别是采用分代收集的垃圾收集算法时,写屏障被用于维护卡表(Card Table)的状态。卡表是一种数据结构,用于记录哪些内存区域(称为卡页)包含跨代引用。当一个对象(可能位于老年代)的字段被赋予了一个指向另一个代(如新生代)对象的引用时,写屏障会被触发。这个写屏障会检查并更新卡表,将包含跨代引用的卡页标记为脏。这样,在后续的垃圾收集过程中,垃圾收集器就可以只扫描这些被标记为脏的卡页,而无需扫描整个非收集区域,从而提高垃圾收集的效率
并行的可达性分析
这里的并行指的是用户线程和GC线程
我们开头讲了可达性分析, 一个对象在做可达性分析的时候, 通过找是否有引用链与这个对象相关联, 来标记一个对象是否已经死亡, 但是如果在数据量过大的时候, 这种扫描就会成为一中性能屏障, 此时你想要穷举每一个对象的引用链, 是很消耗性能的行为, 同时用户线程必须暂停, 防止在运行的过程中修改了引用导致错误的标记
然后引入了OopMap来减少其寻找时间.
但是我们后面的几款虚拟机, 其实有提到过, 他们在进行可达性分析的时候一般是标记阶段, 如下: CMS
可以在标记阶段进行用户线程的执行? 原来不是说用户线程运行的时候, 会修改引用导致错误的标记?
想要解决用户线程的停顿, 你就必须对用户线程在GC中做出的引用修改进行一个容错的措施. 例如在进行错误的标记后如何进行补救? 其实我们可以参考Redis这个单线程模型中, 进行RDB全量持久化的时候的策略:
首先Redis会fork一个子进程进行内存数据的全量初始化, 但是这个时候, 处理线程其实是没有停止工作的, 这个时候处理线程一边工作, 持久化进程一边将内存中已经有的数据进行持久化, 那么问题来了在工作线程处理的时候, 不断有新的kv写入, 此时该怎么办? 那当然是建立一个新的缓冲区, 用于接收新到来的kv, 然后在子进程持久化玩内存中已经有的数据的时候, 就同步写入缓冲区中的数据
可达性分析的时候其实也是如此, 对GC时候, 对修改了的引用进行记录, 然后做出补救措施, 重新标记, 这样就保证了数据的一致性.
java虚拟机一般有两种方法来解决并发的时候一致性问题:
- 增量更新, 也就是上述的方法, 此方法一般用于往GC Root中新增对象引用的情况
- 原始快照: 适用于删除某个对象的引用的时候, 记录这个被删除的引用, 也就是只要被删除了之后, 就会重新扫描一次.