JVM GC 中的重要基础概念

GC和JVM的GC

  据《深入理解Java虚拟机》所考,世界上第一门实现GC的动态语言是MIT的LISP语言,早在Java诞生之前,GC就已经被大量的研究过了。而随着现代编程语言的发展,GC也在不断的发展(其中各式各样的Jvm GC做出了不可磨灭的贡献)。C/C++往往通过对象统一管理的方式(从某种意义上这就是一个GC)避免野指针,并管理内存。Jvm通过GC管理内存,Rust语言实验性的探索“无GC”的特性。
  GC本身是一个非常大的话题,实际上GC管理的并不只是垃圾,GC往往也涉及到整个内存空间的管理,本文只探讨对JVM的GC中的一些重要概念的理解。主要资料参考《深入理解Java虚拟机》(第三版)。

找到垃圾 – 引用计数和垃圾追踪

  如何判断内存中的哪个部分是垃圾?最主要的方式只有两种。第一种是引用计数,即给每个对象维护一个字段,存储引用该对象的引用数。(这个字段一般来说会实现在对象上),比如python语言的PyObject就有类似字段,然后每次赋值操作都将对象引用+1。
  但是单纯使用引用计数无法解决循环引用的问题,会造成内存泄漏。
  Jvm采取另一种方式:垃圾追踪,即从一些称为GC Roots的“根引用”向下遍历引用,只有可以被遍历到的引用才不是垃圾。

GC Roots – 引用追踪的根

  GC Roots的具体定义种类相当繁多。但借助C语言的术语,我们大体可以把GC Roots分称两种来看待。一种是静态的,即由类型对象和常量池中(随着类型加载进入内存的)部分存储的引用;另一种是动态的,即存储在栈上的引用。
  静态GC Roots很好管理,因为静态GC Roots的变化如类型装载,类型卸载等,都需要JVM内存管理的介入,也就是可以认为GC对他们的变化可知的。 但是栈上的引用是会随着用户代码的执行而发生变化的,栈上引用的发生的变化可以是GC不可知的。所以理论上GC Roots的标记过程必须是Stop The World的。

什么是引用? – 引用的两种实现

  指针是C语言最伟大的特性之一,但要实现GC所使用的引用,我们往往还需要额外的工作。

指针实现引用

  使用指针实现引用是最方便也是最高效的方式,同时这种方式也能和本地代码很好的结合。但是指针实现引用的一个问题是:运行时如何判断一段一个字长的数据是一个double、long还是一个指针/引用?Hotspot是使用指针实现的引用,至于这个问题解决方案后面会提到。

句柄实现引用

  句柄实现思路是每次都从称为句柄表的数据结构上查询引用的真实地址。句柄表上每个Value是内存地址,而Key是句柄,交给函数来使用。每个函数维护的句柄表可以反映谁是句柄谁是非引用的数据。但是句柄实现引用最大的问题是每次访问引用都需要通过句柄表转发一次,对于Java这样面向对象的语言来说,其开销是很难接受的。

谁是引用? – 准确式内存管理

  对于静态GC Roots,因为每一个对象的类型信息都保存在内存里(方法区),每个对象的每个字段的类型很容易知道。但是对于栈上的GC Roots来说,尤其是JIT之后的本地代码来说。栈上哪个位置是引用,而哪个位置不是引用就没那么简单判断了。
  要知道这些信息,我们就必须先把这些信息存起来。
  有两种常见思路,第一种是把这些信息直接和存储信息绑定到一起。也就是每存储一个数据,就存储额外的数据来指明这个数据的类型。这种方式在Java字节码和JIT编译上都可以实现,但是对内存的浪费实在是太大了,尤其是考虑到对齐问题。
  第二种方式是把某些指令执行后,下条指令执行前,栈上的引用状态记录到一个数据结构里(栈的状态是编译时可知的。也就是说,对于一个方法或者说函数,每条指令所对应的栈状态是不同的,但是他们可以在编译时确定。),GC运行时动态查询这些信息。Hotspot的虚拟机就是采用这样的方式实现的准确内存管理,该数据结构叫做OopMap。之所以不是每条指令都保存OopMap,是为了节省空间。
  据说存在第三种方式是每个方法把该方法的栈状态查询编译为一个函数,第三方直接调用就可以获得栈上引用的状态,不过主流Jvm应该都不是这种方式。
  还有一种方法是把引用的类型信息放到句柄表中,不过因为主流JVM都不是使用句柄实现引用的,这里就不说了。

如何发现引用的修改? – 写屏障

  “写屏障”是《深入理解Java虚拟机》中的称谓,尽管我觉得这个术语很容易和计算机体系结构中的内存屏障混淆,但是这里还是使用该术语。
  在面向对象语言里,操作引用是一件非常重要的事情。所以其内存管理系统有必要知道用于线程何时操作了引用。这个问题的实现方式很容易想到可以通过AOP(类似python中的装饰器)来解决。在C/C++中, 可以把write_ref使用一个宏定义的方式来实现,每次写引用的前,后,异常时都执行相应的Hook代码。这就是Jvm引用的写屏障。由JIT生成本地代码的时候也可以在编译期展开宏实现写屏障。
  当然,有写就有读。读屏障相当于读取一个引用的AOP。读屏障在面向对象语言中的执行开销是非常恐怖的,不到万不得已不应该使用。

何时开始GC? – 安全点、安全区域和主动中断

  由上文可知,并不是每一个指令都有对应的OopMap, 所以我们需要在有OopMap的代码点才可以进行GC。JVM中的GC都是主动中断的方式。也就是说System.gc()并不会马上暂停用户线程开始gc,只会设置一个标志位。用户线程在每一个有OopMap的指令处停下来轮询标志位。
  x86下的轮询操作借助内存访问陷阱门,使用单条汇编语句实现,尽量减小了开销。
  但是因为GC无法知晓用户线程是否挂起或者阻塞,也不可能等待所有的用户线程到达安全点,所以有安全区的概念,在安全区代码中,用户线程不会修改引用,是安全的gc点。对于安全区的实现,《深入理解Java虚拟机》并未提及太多,我目前也不是很了解。

三色对象 – 并发的可达性分析

  上面讲了,对于GC Roots的标记,必须要stop the world。但是从 GC Roots 出发,我们有办法让GC和用户程序同时运行,并发标记对象的可达性。
  使用白色代表未被标记的对象,灰色代表自己被标记,但是子引用未被标记完全,黑色表示自己和子引用均被标记完全的对象。之所以要使用这三色来标记,是因为增量更新算法会用到对象的颜色:在最终标记阶段(stop the world)所有在并发标记中添加了新引用的黑色对象会被改回灰色重新标记。一致性快照也会记录下所有要从灰色节点删除的引用,最终标记的时候重新标记一次。

有色指针 – 将引用信息直接放在引用里

  三色对象是将对象的颜色和对象关联,考虑到对齐是非常客观的消耗。三色指针将指针指向对象的颜色放在指针中,免去了寻址的时间消耗和对齐的空间消耗,唯一不足是降低了可用的内存空间,但目前64位机器上很少有4TB以上的内存。

清理垃圾 – 内存回收策略

  在已经找到垃圾的前提下,下一步就是清理垃圾了。

直接释放 – 标记清除

  最简单(也最古老)的清除方法就是标记-清除法了。将一块对象标记为垃圾,然后直接将其标记为可用的,就算是清除了。该方法最大的问题是随着运行,会出现越来越多的碎片化空间。

左右互换 – 标记复制

  准备两块内存(不一定是1 : 1大小),每次清理都将其中一块中的存活对象换到另一边。可以避免碎片化垃圾的产生。但是需要移动对象,这意味着必须更新所有引用。

原地整理 – 标记整理

  每次清理垃圾的时候顺道将内存整理了,避免碎片化内存的出现。同样需要移动对象。其跟标记复制最大的区别在于不需要移动一个区域内的所有存活对象,只需要移动一部分存活对象就可以了。

GC优化

  因为GC是自动内存管理语言的性能消耗半边天,所以GC的性能优化是非常重要的部分。

延迟与吞吐量 – GC性能指标

  延迟是指GC造成的Stop the world的时间。在web 服务这种应用类型中,延迟尤为重要(延迟太大甚至有可能出现网络中断)。
  借用计算机体系架构的概念,吞吐量是单位时间内执行指令数。吞吐量消耗可以认为是用户线程和GC线程耗时总和。因为很多时候为了降低延迟,不得不使用会影响用户代码执行效率的方案。对于计算密集型的应用,吞吐量是更合适的指标。

基于以往的对象存活经验 – 分代收集

  弱分代假说:绝大部分对象熬不过第一轮收集。
  强分代假说: 越是熬过了多轮收集的对象越是倾向于长久的停留在内存中。
  跨代引用假说:长久停留在内存中的对象很少会引用短暂停留在内存中的对象。
  以上三个假说是人们在使用GC的过程中总结出来的经验,描述了应用中的绝大多数情况。基于以上三个假说我们往往可以将对象分成至少两代:新生代(快速死亡)和老年代(长期驻留)。并对不同的代执行不同的清除策略,以获得更高的效率。

Appel式回收

  Appel回收是标记-复制算法的一个实现。主要用于大部分对象无法存活的新生代。将新生代分称eden和survivor区域,每次从另外两个区域复制存活对象到其中一个survivor区。也是JDK8以前的Hotspot收集器使用的方法。

哪部分内存中存在跨代引用? – 记忆集卡表

  熟悉操作系统底层的人应该对与这种描述内存区域的方式非常熟悉:仅使用字长的一部分作为指针(低位用0填充)这样一个指针就指向了一个内存块。然后一个字长中就会有空出来的位,这些位可以用来描述这个内存块。Hotspot虚拟机正是使用这种方式来描述一个老年代中的内存块是否存在指向新生代的引用的。这些内存块指针的集合称为卡表。利用写屏障,当我们更新老年代中对象的引用时,需要修改卡表上的相关信息。
  记忆老年代对新生代的引用,实际上是抽象的“记忆集”的功能,卡表只是记忆集的一种实现。

停顿时间模型 – 基于Region的回收

  JDK 7 中出现,JDK8中补全的G1收集器保留了新生代和老年代的分代策略,但内存的分代不再固定,将堆内存视为一系列Region的集合。每次回收根据用户提出的制定不同的回收策略。G1这种回收被叫做Mixed GC。
  为了实现停顿时间模型和基于Region的回收,G1收集器需要为堆内存额外添加很多附加信息(memory footprint)。比如,因为没有了固定的老年代与新生代,就必须为每个Region维护记忆集(当然还是卡表)不过比CMS的卡表复杂得多了。

可并发收集的GC

  Shenandoa简化了G1收集器中卡表数据结构(使用链接矩阵),并实现垃圾收集过程与用户线程的并发,在一定程度上做到了“低延迟”,但是该实现的代价是巨大的,为了实现并发收集,该收集器使用了读屏障,转发指针,以及并发锁。所以最开始发布的收集器在吞吐量上无法和CMS,Parrale等收集器相比的,但是作为开源社区维护的垃圾收集器,其正在不断进步中。
  借助染色指针等技术,ZGC同样实现了并发收集,并且将收集器的延迟降低1 ~ 2个数量级。除了染色指针,ZGC的关键技术点还有全堆扫描(为了避免维护大量的描述堆的数据),指针自愈(为了避免多次触发读屏障),支持非统一内存访问架构。
  Sheandoa和ZGC目前都是不支持分代的,不过两者似乎都有在未来的版本加入分代技术的趋势。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值