先放空所有的关于GC的记忆包括网上看的八股文,从0开始我们为什么要有垃圾回收这个过程?
1.物理内存空间有限意味着进程持有的空间也要有限
2.运行过程中会产生全局或长生命周期的内存占用(对象)
3.避免过多占用导致页交换(个人理解)
其实上述都是因为进程申请的内存空间过大导致,所以必须要有合理的机制保障进程占用的内存在一个合理的范围那就需要内存回收。
首先内核对于程序的运行状态是没有感知的,他只知道进程占用了多少,要加载一个新程序需要多少内存,以及对于物理内存空间的把控,所以基本上下文的垃圾回收都是语言自身内存管理的实现。
一、对象存活判断
首先要感知到用户是否还会用到这个实例否则貌然回收会使程序崩溃,常见的有
1.引用计数也就是说引用则加,断开则减
2.可达性分析,进行各个实例
二、回收算法
1.标志-清除
首先是标记,标记会把从根实例触发的(根实例也就是某个引用的最上层比如一个静态的常量实例或者已经装载在方法区的实例)所有的GC对象按照可回收不可回收进行标记,回收掉引用不可达的实例,整个过程会STW,因为在运行中实例时没办法保证都一一访问的,但是这样就会出现一个问题那就是效率太低,为了减少STW的时间引入了三色标记算法
这里引用一张图片:
如图,所有实例被划分为黑(引用)灰(引用,但时子实例引用状态未知)白(未引用)三种状态,最开始的时候所有实例为白色,GCROOT加入灰色队列,然后从灰色队列取出实例标记为黑色,如果有子引用则开始访问子引用加入灰色队列,依次寻找,直到灰色队列为空,剩下的白色实例也就不可达了也就是最后被GC的部分,整个过程中在便利GCROOT的时候会STW,其余过程是异步执行的
细想上面的过程实际是有问题的,假设一个场景那就是在GCROOT的STW结束之后继续进行实例访问,那么如果某个实例的引用状态发生改变那么很容易出现多标和漏标
比如上图D的引用中断那么整个D以后的会继续传递,在本次GC中D后的实例都不会被回收只能等待下次GC这就产生了多标,相对来说危害较小,不会产生程序崩溃。
但是如果发生了漏标比如,B原本引用不可达,但是在D被标黑之后假设D引用了B,但是此时D已经访问完成不会再访问D的子实例,所以就出现了漏标,此时如果回收B那么很可能会程序崩溃。
那么如何避免呢?
那就要引入读写屏障的概念了,其实说白了就是改造指针的赋值过程,如下
//原本
void oop_field_store(oop* field, oop new_value) {
*fieild = new_value // 赋值操作
}
//写屏障
void oop_field_store(oop* field, oop new_value) {
pre_write_barrier(field); // 写屏障-写前屏障
*fieild = new_value // 赋值操作
pre_write_barrier(field); // 写屏障-写后屏障
}
但是这样还不够,具体操作什么样的呢?
①原始快照(Snapshot At The Beginning,SATB)也就是在复制GCROOT时保存引用图的快照,在后续引用改变的时候仍然按照之前图路径进行便利
②增量更新,当变化指针时如果当然没有被遍历到那么加入到后续队列再次便利。
2.复制算法
将空间分为from和to两块,当回收时将堆顶指针移动到to区,从from头开始遍历,活动实例复制到to区,然后记录子实例,之后重复记录活动对象和按照活跃对象的子实例复制的过程,最后清除from区,from区也就变成了下一次的to区。
优点是实现相对简单,但是产生了空间浪费,总是有一般空间时空闲的。且当存活实例较多时复制开销较大导致整体效率变低。
3.标志-压缩
需要扫描三次
①扫描整个堆, 设定forwarding 指针, 即是记录活动对象信息 事先将各对象的指针全部更新到预计要移动到的地址
②更新指针 重写所有活动对象的指针
③移动对象 将活动对象移动到forwarding 指针的引用目标处
详细解释下,就是每个实例有一个forwarding指针,它指向的是新的地址,在首次标记的时候把活跃对象标记出来,计算出该实例预计到达的新地址之后更新各自的根到forwarding 指针,最后把实例移动到forwarding位置,压缩完成,压缩边界外的实例全部清空。
当然算法有不同的应用场景,在各个语言的实际运用,比如JAVA中不同的垃圾回收器等的具体分析,见下节~