引用计数其实就是为每一个内存单元设置一个计数器,当被引用的时候计数器加一,当计数器减少为 0 的时候就意味着这个单元再也无法被引用了,所以可以立即释放内存。
优点:
缺点:
Python 没有解决引用计数的循环引用问题,只是结合了非传统的标记-清除方案来兜底(它采取的是找不可达的对象,而不是可达的对象),算是曲线救国。
可达性分析其实就是利用标记-清除(mark-sweep),就是标记可达对象,清除不可达对象。
标记-清除具体的做法是定期或者内存不足时进行垃圾回收,从根引用(GC Roots)开始遍历扫描,将所有扫描到的对象标记为可达,然后将所有不可达的对象回收了。
GC Roots:
其实GC Roots也很好理解,有两个特点:一是语义上是活的,还有用的;二是可以方便获取的。
方法区的类对象的静态数据和final数据作为GC roots很好理解,它们要长期存在,并且在类加载的时候就可以确定内存位置
获取GC roots最主要的部分在解决如何快速找到JVM栈的栈帧的局部变量表中的局部变量所引用的对象
缺点:
标记-清除等于把垃圾积累起来,然后再一次性清除,这样就会在垃圾回收时消耗大量资源,影响应用的正常运行--Stop The World。
为什么会Stop The World?
分析标记的工作必须在一个能保障一致性的快照中进行——这里“一致性”的意思是整个分析期间整个执行系统看起来就像被冻结在某个时间点上,不可以出现分析过程中,对象引用关系还在不断变化的情况,这点不满足的话分析结果准确性就无法保证。
所以才会有分代式垃圾回收和仅先标记根节点直达的对象再并发 tracing 的手段。
标记-清除和引用计数的思想上最大的差别,一个攒着处理,一个把这种消耗平摊在应用的日常运行中。
而不论标记-清楚还是引用计数,其实都只关心引用类型,像一些整型啥的就不需要管。
所以 JVM 还需要判断栈上的数据是什么类型,这里又可以分为保守式 GC、半保守式 GC、和准确式 GC。
目前主流的JVM使用的都是准确式 GC,即清晰的知晓对象的类型和栈上的引用类型
可以在指针上打标记来表明类型;或者在外部记录类型信息形成一张映射表
HotSpot 用的就是映射表,这个表叫 OopMap。
在 HotSpot实现中,使用一组OopMap的数据结构来记录对象引用,在类加载完成的时候,HotSpot就把对象内什么偏移量上是什么类型的数据计算出来,在JIT编译过程中,也会在特定的位置记录下栈里和寄存器里哪些位置是引用。GC的标记开始的时候,直接从OopMap就可以获得GC roots。OopMap记录了特定时刻栈上(内存)和寄存器(CPU)的哪些位置是引用,通过这些引用就可以找到堆中的对象,这些对象就是GC roots。 而不需要一个一个的去判断某个内存位置的值是不是引用。
在线程运行到特定位置 safepoint时,OopMap会收集数据,也就是运行到了safepoint的时候,是一个相对稳定的状态的,线程的一些状态可以被确定,比如记录OopMap的状态,从而确定GC Roots的信息,使JVM可以安全的进行一些操作,比如开始GC。
safepoint指的特定位置主要有:
问题一:如何让GC发生时,让所有线程都跑到最近的安全点上再停顿下来?
有两种方案可供选择:
抢先式中断(Preemptive Suspension)
抢先式中断不需要线程的执行代码主动去配合,在GC发生时,首先把所有线程全部中断,如果发现有线程中断的地方不在安全点上,就恢复线程,让它跑到安全点上。现在几乎没有虚拟机实现采用抢先式中断来暂停线程响应GC事件。
主动式中断(Voluntary Suspension)
主动式中断的思想是当GC需要中断线程的时候,不直接对线程操作,仅仅简单地设置一个标志位,当java线程运行到safepoint的时候主动检查这个标志位,如果标志被设置则线程中断挂起,如果标志没有被设置则继续执行。
问题二:对于处于Sleep或Blocked状态的线程无法响应JVM的中断请求,到不了安全点去中断挂起线程,是如何处理的?
对于这种情况,就需要安全区域(Safe Region)来解决。安全区域是指在一段代码片段之中,引用关系不会发生变化。在这个区域中任意地方开始GC都是安全的。我们也可以把Safe Region看作是被扩展了的Safepoint。
在线程执行到Safe Region里面的代码时,首先标识自己已经进入了Safe Region,那样当这段时间里JVM要发起GC,就不用管标识自己为Safe Region状态的线程了。在线程要离开Safe Region时,它要检查系统是否已经完成了GC Roots遍历(或者是整个GC过程),如果完成了,那线程就继续执行,否则它就必须等待直到收到可以安全离开Safe Region的信号为止。
JVM维护了一个数据结构,记录了所有的线程,所以它可以快速检查所有线程的状态。当有GC请求时,所有进入到safepoint的Java线程会在一个Thread_Lock锁阻塞,直到当JVM操作完成后,VM释放Thread_Lock,阻塞的Java线程才能继续运行。
有了OopMap就可以快速获得GC roots,接着就可以开始标记了。标记的基本思路就是遍历一个有向图,节点是对象,边是引用。不同的垃圾收集器实现的标记算法也不一样。
分为两个阶段:
标记阶段:tracing 阶段,从GC Roots开始遍历对象图,标记所遇到的每个对象。
清除阶段:扫描堆中的对象,将为标记的对象作为垃圾回收。
清除不会移动和整理内存空间,一般都是通过空闲链表(双向链表)来标记哪一块内存空闲可用,使用标记-清除算法会导致一个情况:内存碎片。
这会使得明明总的内存是够的,但是申请内存就是不足。而且在申请内存的时候也有点麻烦,需要遍历链表查找合适的内存块,会比较耗时。
所以会有多个空闲链表的实现,也就是根据内存分块大小组成不同的链表,比如分为大分块链表和小分块链表,这样根据申请的内存分块大小遍历不同的链表,加快申请的效率。
标记的方式一般有两种:
一是标记对象头。但是这种方式有个比较大的缺点是对写时复制不兼容,等于每次GC都需要修改对象,清楚对象需要遍历整个堆来扫描对象。
二是位图标记法。就是将堆的内存某个块用一个位来标记。就像我们的内存是一页一页的,堆中的内存可以分成一块一块,而对象就是在一块,或者多块内存上。
根据对象所在的地址和堆的起始地址就可以算出对象是在第几块上,然后用一个位图中的第几位在置为 1 ,表明这块地址上的对象被标记了。而且用位图标记法不仅可以利用写时复制,遍历清除对象也根据高效。
但是不论是标记对象头还是利用位图,标记-清除的碎片问题还是处理不了。因此就引出了标记-复制和标记-整理。
首先这个算法会把堆分为两块,一块是 From、一块是 To。
对象只会在 From 上生成,发生 GC 之后会找到所有存活对象,然后将其复制到 To 区,之后整体回收 From 区。
再将 To 区和 From 区身份对调,即 To 变成 From , From 变成 To。
可以看到内存的分配是紧凑的,不会有内存碎片的产生。
不需要空闲链表的存在,直接移动指针分配内存,效率很高。
对 CPU缓存亲和性高,因为从根开始遍历一个节点,是深度优先遍历,把关联的对象都找到,然后内存分配在相近的地方。
这样根据局部性原理,一个对象被加载了那它所引用的对象也同时被加载,因此访问缓存直接命中。
当然它也是有缺点的,因为对象的分配只能在 From 区,而 From 区只有堆一半大小,因此内存的利用率是 50%。
其次如果存活的对象很多,那么复制的压力还是很大的,会比较慢。
当然我上面描述的是深度优先就是递归调用,有栈溢出风险,还有一种 Cheney 的 GC 复制算法,是采用迭代的广度优先遍历。
标记-整理其实和标记-复制差不多,区别在于复制算法是分为两个区来回复制,而整理不分区,直接整理。
算法思路还是很清晰的,将存活的对象往边界整理,也没有内存碎片,也不需要复制算法那样腾出一半的空间,所以内存利用率也高。
缺点就是需要对堆进行多次搜索,毕竟是在一个空间内又标记,又移动的,所以整体而言花费的时间较多,而且如果堆很大的情况,那么消耗的时间将更加突出。
至此相信你对标记-清除、标记-复制和标记-整理都清晰了,让我们再回到刚才提到的分代收集。
参考文章
https://mp.weixin.qq.com/s/nY6vL5MlUXY1lfnIvNHMnw
https://blog.csdn.net/hellozhxy/article/details/80649586
https://juejin.cn/post/6844903941805703181#heading-0