白话JVM之各种常见垃圾收集器

GC并不是java的衍生物,1960年就提出了垃圾收集的概念,Java垃圾收集器到现在为止25年,各大虚拟机开发公司的工程师慢慢完善,从开始的新生代老年代模式,到现在的region模式,都离不开三个事情:
哪些内存需要回收?
什么时候回收?
如何回收?

从最初的单线程Serial收集器,到现在的ZGC,垃圾收集器的瓶颈不在于处理垃圾的速度,各大虚拟机开发者绞尽脑子处理的就是提升吞吐量和降低延迟,但是这俩似乎看起来是鱼和熊掌。
吞吐量:吞吐量=用户代码运行时间\(用户代码运行时间+垃圾运行收集的时间)吞吐量当然是越高越好,但是jvm中线程资源有限,垃圾运行收集的时间越短,就意味着要倾斜更多的资源给他,所以给用户代码的资源就少,直到STW,所以高吞吐量就代表,停顿时间短。比如10s进行一次垃圾收集,停顿100ms,和5s运行一次停顿70ms(为啥5s停顿一次就是70ms而不是50ms,因为比如GCroots扫描这些时间无论多久进行一次基本都是固定差不多的),吞吐量优先的收集器就会优先选择10s一次,把资源倾斜到垃圾收集上,减少垃圾运行收集的时间。

如何判断已死对象

  • 引用计数法:就是搞个计数器,A被引用就+1,循环引用就不行了,所以java垃圾收集器都不用这个
  • 可达性分析算法:就是找一些需要永远存在的或者长时间存在的对象GC roots,他引用了谁,谁就不能被回收,在java中,有一些是固定作为GC roots的对象:
    1、在虚拟机栈的本地变量表中引用的对象,这个很好理解,我现在线程正在运行,你不能把我用的东西给搞没了。
    2、在方法区中静态属性引用的对象,静态属性干啥的,写过java的都知道,static就是一直在那可以直接调用的,所以被他引用的当然也要留着。
    3、 在方法区中常量引用的对象,比如字符串常量池里引用,
    4、在本地方法栈中JNI(就是native)引用的对象
    5、Java虚拟机内部的引用,类加载器,常驻的异常类等
    6、所有被同步锁(synchronized)持有的对象
    7、反映了java虚拟机内部情况的JMXBean,JVMTI中的注册的回调、本地代码缓存,这块儿自己写代码基本不涉及。
    8、不同的虚拟机可能还会有其他对象临时性加入(分代收集和局部回收会使用到这块儿)

引用

  • 强引用–就是new一个对象就是强引用,只要被引用,就不会被回收,一直到OOM
  • 软引用–用SoftReference建的对象就是软引用,软引用就是在系统快要OOM了,软引用的对象就算被引用,也不管你了,直接回收掉腾出空间
  • 弱引用–用WeakRference建的对象,强度更低,下次垃圾回收就给收掉了
  • 虚引用–用PhantomReference建的对象,又叫幽灵引用、幻影引用,看名字就知道,这引用基本是个假的,就算有个对象,也get不到,目前有用的了解到就是直接内存回收,自己写代码估计用不到。

垃圾收集算法

我个人觉得,在看垃圾算法和各垃圾收集器基本原理之前要明白一个事情,java的垃圾收集算法和垃圾收集器更像是一个经验之果,在不停的测试中发现,大量的对象都是很快消失并且大多数对象都是同生同灭,所以很多对象要不经常被收集,然后剩下的对象基本都是固定不动,这也许就是为什么G1和ZGC中要选择region,为了让垃圾收集更快,吞吐量更高,就需要合理分配资源,在内存使用满足的情况下,每次垃圾收集不要全部扫描,有的地方存储的对象基本都是不需要被清理的,就不去处理他了,把注意力集中在那些,频繁生成频繁消亡的对象区域上,这也是G1和ZGC包括以后主流收集器优先考虑的问题。所以在看垃圾收集算法和垃圾处理器的时候,就要带着这个经验去理解,也许就能真的理解垃圾收集器每一步操作的意义。

  • 分代收集理论
    大多数像我这样的小白最开始看都是了解到了对象头里的GC年龄,然后在新生代from区和to区换来换去,然后换到老年代,这中换来换去的方式就是根据分代收集理论来的,这就是刚上面提到的,因为不同的区域保存的对象的存活时间不一样,为了对他们因地制宜,所以使用分块儿的方式来使用不同的垃圾收集算法,就一开始百度经常看到的MinorGc/YoungGC给新生代使用,MajorGc和OldGc给老年代使用,再深一点就是,新生代使用标记-复制算法,老年代使用标记清-清除算法。

    问题来了,新生代和老年代是分开进行垃圾收集的,如果新生代的对象被老年代的对象引用了呢?只扫描这个新生代,这个对象是游离的,会被清除掉,这时候调用老年代对象不就出问题了?
    这就引入了跨代引用假说:跨代引用相比于同代引用占比是极少的。
    既然极少,就不需要用惯性方式去解决它,只需要专项治疗,然后工程师们就想到在新生代上建立一个全局的数据结构(记忆集),这个结构把老年代逻辑上划分了很多块儿,标记哪一块会被跨代引用,然后新生代MinorGc的时候,被记忆集标记的老年的那一小块儿会被加入到GCroots中,这就是上面说的<如何判断已死对象的>的第八条。

  • 标记-清除
    标记-清除算法在1960年就提出了,这个就很好理解,标记一下哪些要被清掉,标完就清除。缺点就是被清了几次后内存就不连续到了,就好比在一条路上一会儿一个坑。其他算法也是根据这个进化而来

  • 标记-复制
    标记复制就是把一块儿区域的对象标记完以后,把活着的全部挪到另一块儿排排坐,然后把之前那块儿全部清掉就完事了,但是这样也问题啊,第一个问题,我就需要两块儿地方来满足,第二个问题,就是我对象被挪走了,位置变了,就要考虑引用他的那些对象咋办,得告诉他挪到哪了,然后再回过头去看上一篇说的虚拟机栈中的reference,是不是联系上了。

  • 标记-整理
    标记清除导致内存不平整了,标记复制导致需要额外的空间,然后就进化出了标记-整理算法,标记完以后,把位置重新排位,不需要清除的对象挪到前面,然后把后面的对象全部清掉,这个也是需要修改引用的地址的,由于要修改引用地址,所以这里必须STW。

所以每个算法都有利有弊,需要根据收集器的追求不同来选择,要快点收集的(追求低延迟,比如CMS)就用标记清除,要求吞吐量高的,就得一次性清理干净,然后少清理几次(比如Parallel old)的,就会选用标记-整理。所以说,我更觉得垃圾收集器的进化是在对java代码不停的测试分析垃圾生成的统计来针对性进化,也许若干年随着硬件的提升,垃圾收集方式又会颠覆。

HotSpot的算法细节实现

这块儿在看《深入理解JAVA虚拟机的时候》也是一知半解,涉及到汇编指令确对于我这种菜鸟来说实看不懂,大概就涉及几个东西

  • 根节点枚举
    hotspot在类加载的时候就维护了一个OopMaps的数据架构,在String::HashCode的汇编指令中,会把引用关系记录下然后顺着找就行了,大概就这意思,这里以后再补充
  • 安全点
    当STW的时候,咱们小白们都知道是暂停所有线程,但是线程也不能随便暂停啊,都是到安全点后才暂停,安全点也是通过汇编指令完成的,这个汇编指令很简单,只有一行,原理大概就是给线程的运行的代码中插入一个类似于陷阱的东西,然后线程走到这个地方就进入了等待,也是这个时候上面说到的OopMaps的值会进行更改。
  • 安全区域
    安全点解决了正常线程的暂停,但是如果没到安全点就被代码挂起的呢?不能一直等着他到安全点啊,所以又出现了安全区域(不会影响根节点枚举),就是只要这个线程在安全区域内,就进行根节点枚举,当这个线程出了安全区域,就检查根节点枚举是否完成,如果完成了就当无事发生,没完成就给我停下。
  • 记忆集和卡表
    记忆集的作用上面说过了,就是为了记录跨代引用的,卡表是记忆集具体的一种实现方式,记忆集和卡表都关系就好比是Map和HashMap的关系,卡表的最小单元是页,hotSpot的页大小是2的九次幂、512个字节,只要这个字节存在跨代引用就会被加入根节点扫描
  • 写屏障
    卡表的更新必须是独立的,不能被并发操作,所以就引入了写屏障保证卡表的数据正确性。
  • 并发的可达性分析(三色标记法)
    并发的可达性分析字面意思就是,为了让垃圾收集过程和用户代码执行过程可以同时进行,但是又得保证根节点扫描的正确性而引入的,比较经典的就是三色标记法
    -白色:没被垃圾收集器访问过,证明没人引用他,就可以干掉了
    -黑色:被访问过,并且被他引用的也被访问过了,得留着
    -灰色:被访问过,但是被他引用的没被访问过
    所以对象的经历应该是白–>灰–>黑这种波形往前走的。
    此时引入一个问题,并发情况下,有一个对象目前还是白色的,被一个灰色的引用着,按理说呢,下一个要把灰色的变成黑色,然后把白色的变成灰色,但是此时,灰色到白色的引用关系断了,而且这个白色的被之前一个黑色的引用了,黑色的不会被扫描啊,如果这时候放纵不管,就出事了,这个白色的被干掉了。
    基于上面的情况,引入两个概念:
    -增量更新:当黑色对象被插入了新的引用后,变成灰色,然后再扫描这些灰色。
    -原始快照:这块儿作者描述的比较奇怪,我也只能按照自己理解的,就是当删除这个引用后,记录下这个引用,然后回过头在扫描,即使删除了,也当他没删除,毕竟,多删出错,少删能过,这也比较符合周志明博士说的:无论引用关系删除与否,都会按照刚刚开始扫描的那一刻的对象图快照进行搜索

这两个都可以解决并发问题,但是都需要扫描两次,但是第二次扫描的内容大大减少,可以进行STW来降低延迟,CMS是基于增量更新的,G1、Shenandoah是基于原始快照的。

经典垃圾收集器

年轻代的垃圾收集器有Serial,ParNew(Serial的多线程版),Parallel Scavenge
老年代的有CMS,Serial Old, Parallel Old
其中Serial+CMS的搭配和Parew+Serial Old的搭配在jdk9后被取消了

Serial
最早的垃圾收集器,单线程工作,但是必须STW,大内存下肯定不适用了,会有很高的延迟,但是在客户端、小内存模式下效率还是可以的,因为他简单,并且资源消耗最低,适合单核处理器的,采用标记-复制

ParNew
Serial的多线程版,目前只有他能跟CMS配合使用。也采用标记-复制

Parallel Scavenge
这个收集器和ParNew基本一样,但是没法和CMS配合,使用比较少,他更关注吞吐量,所以有-XX:MaxGCPauseMillS参数来设置停顿时间 ,-XX:GCTimeRatio来控制吞吐量大小,也支持自适应配置

Serial Old
Serial老年代版本,可以和Parallel Scavenge配合,也是CMS失败(清理速度比不上分配速度)的备用方案。采用标记整理算法

Parallel Old
jkd6以后有的,Parallel Scavenge的老年代版本。基于标记整理。

CMS
Concurrent Mark Sweep 的目标是获取最短停顿是时间,所以他分为:

  • 初始标记:标记和GC roots直接关联的对象,速度很快,要STW
  • 并发标记:标记剩余对象,和用户线程同时进行
  • 重新标记:采用增量更新,需要STW
  • 并发清除:采用标记-清除,会有浮动垃圾。
    CMS默认启动的回收线程数是(处理器核心数量+3)/4,所以在核心处理器四个以下时存在占用资源较高的问题,CMS在jdk5的时候默认启动阈值时68%,也就是老年代到了68%就会启动,现在时92%,就可能存在收集垃圾的速度比分配的满,就会紧急启动Serial Old并且STW

G1
Garbage First是在jdk7以后有的,采用region分区,化整为零,逐个解决,然后通过停顿预测模型来预测每个region需要的耗费的时间,这样每个region存在的东西又有差异,而且是基于region扫描的,前面所说的跨代引用解决起来也更为麻烦,采用了双向卡表的方式。停顿预测模型也是一个比较复杂的逻辑。流程分为:

  • 初始标记:标记GCroots直接关联的对象,停顿极低
  • 并发标记:接着往下标记,没有停顿
  • 最终标记:采用原始快照的方式,最终标记。需要STW
  • 筛选回收:采用标记-复制的方式,把存活的对象复制到新的region中,清除掉原region。
    G1在已region为最小单位来看,用的是标记-整理,在每个region中,用的是标记-复制。
    G1的不足也是明显的,卡表维护起来很复杂,每个region都要有一个卡表,内存占用严重,大概20%,所以在小内存下,CMS表现更佳。

ZGC收集器
ZGC和G1一样,也是采用region,不过他的region是支持动态创建和销毁的,而且分为大中小三种,其中大型的支持动态变化,必须为2MB的整数倍,并且只能存一个对象。ZGC使用了一种染色指针的东西,这块儿有点复杂,以后再补。

你可能感兴趣的:(小白学JVM,jvm)