JVM 之垃圾回收 - 相关名词解释

文章目录写时复制 Hotspot 虚拟机 Snapshot-At-The-Beginning (SATB)Remembered Set(RSet)概念卡表(Card Table)配置每次扫描的 Card 数量 & q......

写时复制(copy-on-write)是众多 UNIX 操作系统用到的内存优化的方法。比如在 Linux 系统中使用 fork() 函数复制进程时,大部分内存空间都不会被复制,只是复制进程,只有在内存中内容被改变时才会复制内存数据。 但是如果使用标记清除算法,这时内存会被设置标志位,就会频繁发生不应该发生的复制。

另外,关于标记清除的变形,还有一种叫做标记压缩(Mark and Compact)的算法,它不是将被标记的对象清除,而是将它们不断压缩。

HotSpot 的正式发布名称为 "Java HotSpot Performance Engine",是 Java 虚拟机的一个实现,包含了服务器版和桌面应用程序版。

G1 使用的是 SATB 标记算法,主要应用于垃圾收集的并发标记阶段,解决了 CMS 垃圾收 集器重新标记阶段长时间 Stop The World(STW) 的潜在风险。其算法全称是 Snapshot At The Beginning,由字面理解,是垃圾回收器开始时活着的对象的一个快照。

它是通过 “根集合” 穷举可达对象得到的,穷举过程中采用了三色标记法:

  • 白:对象没有被标记到,标记阶段结束后,会被当做垃圾回收掉。
  • 灰:对象被标记了,但是它所在的 Field 还没有被标记或标记完(可达对象还未被标记)。
  • 黑:对象被标记了,且它的所有 Field 也被标记完了。

所以,漏标的情况只会发生在白色对象中,且满足以下任意一个条件:

  • 并发标记时,应用线程给一个黑色对象的引用类型字段赋值了该白色对象
  • 并发标记时,应用线程删除所有灰色对象到该白色对象的引用

SATB 利用 write barrier 将所有即将被删除的引用关系的旧引用记录下来,最后以这些旧引用为根 Stop The World 地重新扫描一遍即可避免漏标问题。 因此 G1 Remark 阶段 Stop The World 与 CMS 了的 remark 有一个本质上的区别,那就是这个暂停只需要扫描有 write barrier 所追中对象为根的对象, 而 CMS 的 remark 需要重新扫描整个根 集合,因而 CMS remark 有可能会非常慢。

RSet 全称是 Remembered Set,是辅助 GC 过程的一种结构,典型的空间换时间工具,和 Card Table 有些类似。

G1 收集器中,Region 之间的对象引用以及其他收集器中的新生代和老年代之间的对象引用是使用 Remembered Set 来避免扫描全堆。

G1 中每个 Region 都有一个与之对应的 Remembered Set,虚拟机发现程序对 Reference 类型数据进行写操作时,会产生一个 Write Barrier(写屏障)暂时中断写操作(虽然写屏障使得应用线程增加了一些性能开销,但 Minor GC 变快了许多,整体的垃圾收集效率也提高了许多,通常应用的吞吐量也会有所改善),检查 Reference 引用的对象是否处于不同的 Region 之间 (在分代中就是检查老年代中的对象是否引用了新生代的对象),如果是便通过 CardTable(卡表)把相关引用信息记录到被引用对象所属的 Region 的 Remembered Set 中。

当内存回收时,在 GC 根节点的枚举范围加入 Remembered Set 即可保证不对全局堆扫描也不会有遗漏。

为了支持高频率的新生代的回收,虚拟机使用一种叫做卡表(Card Table)的数据结构,卡表作为一个比特位的集合,每一个比特位可以用来表示年老代的某一区域中的所有对象是否持有新生代对象的引用。当老年代中的某个区域持有了新生代对象的引用时, JVM 就把这个区域对应的 Card 所在的位置标记为 dirty(bit 位设置为 1)。

这样新生代在 GC 时,可以不用花大量的时间扫描所有年老代对象,来确定每一个对象的引用关系,而可以先扫描卡表,只有卡表的标记位为 1 时,才需要扫描给定区域的年老代对象。而卡表位为 0 的所在区域的年老代对象,一定不包含有对新生代的引用。这样子可以提高效率减少 MinorGC 的停顿时间。

如上图,卡表中每一个位表示年老代 4K 的空间,卡表记录未 0 的年老代区域没有任何对象指向新生代,只有卡表位为 1 的区域才有对象包含新生代引用,因此在新生代 GC 时,只需要扫描卡表位为 1 所在的年老代空间。使用这种方式,可以大大加快新生代的回收速度。

配置每次扫描的 Card 数量

在 JVM 中, 一个 Card 覆盖的默认大小是 512 字节, 在多个线程并行收集时, JVM 通过 ParGCCardsPerStrideChunk 参数设置每个线程每次扫描的 Card 数量,默认是 256。

相当于是把老年代分成许多 strides,每个线程每次扫描一个 stride,每个 stride 大小为 512*256 = 128K。

如果你的老年代大小为 4G, 那总共有 4G/128K=32K 个 Strides。多线程在扫描这么多的 strides 时就涉及到调度和分配的问题, stride 数量太多就会导致线程在 stride 之间切换的开销增加,进而导致 GC 暂停时间增长。

因此 JVM 提供了 ParGCCardsPerStrideChunk 这个参数来配置每个 stride 对应的 card 数量,这个数量要根据实际的业务场景进行调优, 网上一般流传 3 个魔术数字: 32768、4K 和 8K。

例如,配置每次扫描的 Card 数量: (UnlockDiagnosticVMOptions:解锁任何额外的隐藏参数)

-XX:+UnlockDiagnosticVMOptions
-XX:ParGCCardsPerStrideChunk=4096复制代码

这个值不能设置的太大,因为 GC 线程需要扫描这个 stride 中老年代对象持有的新生代对象的引用,如果只有少量引用新生代的对象那就导致浪费了很多时间在根本不需要扫描的对象上。 (在 JVM 调优过程中,没有一个参数的值完美的,只有经过不断的调优过程,慢慢的摸索到适合自己应用的最佳参数范围,除非应用对 YGC 的耗时特别敏感,不到万不得已,不用优化该参数,默认的 256 也适合大部分情况。但是随着现在机器内存的扩大,适当的增大该参数值(4K),也是可以的)

虚拟机遇到一条 new 指令时,首先去检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已被加载、解析和初始化过,如果没有,那必须先执行相应的类加载过程。

在类加载检查通过后为新生对象分配内存。内存分配方式有两种:

(1)指针碰撞

指针碰撞的前提是 Java 堆是绝对规整的,有用的和空闲各自放在一边,中间放着一个指针作为分界点指示器,所分配内存就仅仅是把那个指针向空闲空间那边挪动一段与对象大小相等的距离。在使用 Serial, 和 ParNew 等收集器时候使用的是指针碰撞。

(2)空闲列表

如果 Java 堆不是规整的,已使用的内存和空闲的内存相互交错,虚拟机就必须维护一个列表,记录了哪些是可用的内存,在分配的时候从列表中找到一块足够大的空间分配给对象实例,并更新列表上的记录。在使用 CMS 这种基于 Mark-Sweep(标志 - 清除)算法的收集器时,通常采用空闲列表。

在 minor gc 过程中,survivor(幸存者)的剩余空间不足以容纳 eden(伊甸园)及当前在用的 survivor 区间存活对象,只能将容纳不下的对象移到年老代 (promotion),而此时年老代满了无法容纳更多对象,通常伴随 full gc,因而导致的 promotion failure。 这种情况通常需要增加年轻代大小,尽量让新生对象在年轻代的时候尽量清理掉。

如果 CMS 预留内存空间无法满足程序需要,就会出现一次 "Concurrent Mode Failure" 失败;

这时 JVM 启用后备预案:临时启用 Serail Old 收集器(为什么不启用 Parallel old? 这样并行回收的停顿时间会更短。 stackoverflow.com/questions/3… ),而导致另一次 Full GC 的产生;

这样的代价是很大的,此时 JVM 将采用停顿的方式进行 Full gc,整个 gc 时间会相当可观,完全违背了采用 CMS GC 的初衷,所以 CMSInitiatingOccupancyFraction 不能设置得太大。

出现该现象的原因主要是由于 cms 的无法处理浮动垃圾(Floating Garbage)引起的。 (出现此现象的原因主要有两个:一个是在年老代被用完之前不能完成对无引用对象的回收;一个是当新空间分配请求在年老代的剩余空间中无法得到满足(比如在年老代申请大空间对象))

这个跟 cms 的机制有关。cms 的并发清理阶段,用户线程还在运行,因此不断有新的垃圾产生,而这些垃圾不在这次清理标记的范畴里头,cms 无法在本次 gc 清除掉,这些就是浮动垃圾。

由于这种机制,cms 年老代回收的阈值不能太高,否则就容易预留的内存空间很可能不够 (因为本次 gc 同时还有浮动垃圾产生),从而导致 concurrent mode failure 发生。

-XX:CMSInitiatingOccupancyFraction

要避免此现象,可以降低触发 CMS 的阀值,即参数 - XX:CMSInitiatingOccupancyFraction 的值,该值代表老年代堆空间的使用率,通常 JDK 默认值是 68;可以选择调低到 50 或者以下(指设定 CMS 在对老年代占用率达到 50% 的时候开始 GC),让 CMS GC 尽早执行,以保证有足够的空间

-XX:+UseCMSInitiatingOccupancyOnly

有一个需要注意的点,仅仅设置 CMSInitiatingOccupancyFraction 的值的值表示第一次 CMS 收集按照该比例收集,后面 JVM 将会自动进行调节。配置该参数使用的还有一个参数。 -XX:+UseCMSInitiatingOccupancyOnly 可以设置 true 和 false,默认为 false。

将 - XX+UseCMSInitiatingOccupancyOnly 值设置为 true 来命令 JVM 不基于运行时收集的数据来启动 CMS 垃圾收集周期。而是,当该标志被开启时,JVM 通过 CMSInitiatingOccupancyFraction 的值进行每一次 CMS 收集,而不仅仅是第一次。

然而,请记住大多数情况下,JVM 比我们自己能作出更好的垃圾收集决策。因此,只有当我们充足的理由 (比如测试) 并且对应用程序产生的对象的生命周期有深刻的认知时,才应该使用该标志。 不建议开启该属性值。

(1)、停顿时间

  • 停顿时间越短就适合需要与用户交互的程序;
  • 良好的响应速度能提升用户体验;

(2)、吞吐量

  • 高吞吐量则可以高效率地利用 CPU 时间,尽快完成运算的任务;
  • 主要适合在后台计算而不需要太多交互的任务;

(3)、覆盖区(Footprint)

  • 在达到前面两个目标的情况下,尽量减少堆的内存空间;

  • 可以获得更好的空间局部性;

  • CPU 用于运行用户代码的时间与 CPU 总消耗时间的比值;

    • 即吞吐量 = 运行用户代码时间 /(运行用户代码时间 + 垃圾收集时间);
    • 比如,虚拟机总共运行了 100 分钟,其中垃圾收集花掉 1 分钟,那吞吐量就是 99%。
  • 高吞吐量即减少垃圾收集时间,让用户代码获得更长的运行时间;

  • JVM 在后台自动发起和自动完成的,在用户不可见的情况下,把用户正常的工作线程全部停掉,即 GC 停顿;

  • 会带给用户不良的体验;

Jvm 有 client 和 server 两个版本,分别针对桌面应用程序和服务端应用做了相应的优化,client 版本加载速度较快,server 版本加载速度较慢但运行起来较快。

简而言之:client 版本启动快,server 版本运行快。

由于服务器的 CPU、内存和硬盘都比客户端机器强大,所以程序部署后,都应该以 server 模式启动,获取较好的性能。

小提示:可以通过运行: java -version 来查看 jvm 默认工作在什么模式。

Minor GC

  • 又称新生代 GC(Young GC),指发生在新生代的垃圾收集动作;
  • 因为 Java 对象大多是朝生夕灭,所以 Minor GC 非常频繁,一般回收速度也比较快;

Full GC

  • 一般很多人称 Major GC 或老年代 GC,指发生在老年代的 GC;但是实际上, Full GC 指的是一次完整 GC。
  • 出现 Full GC 经常会伴随至少一次的 Minor GC,原因就是减轻 Full GC 的负担。(不是绝对,Parallel Sacvenge 收集器就可以选择设置 Major GC 策略);
  • Major GC 速度一般比 Minor GC 慢 10 倍以上;

Minor GC 和 Major GC 是俗称,在 Hotspot JVM 实现的 Serial GC, Parallel GC, CMS, G1 GC 中大致可以对应到某个 Young GC 和 Old GC 算法组合;

Major GC 通常是跟 full GC 是等价的,收集整个 GC 堆。但因为 HotSpot VM 发展了这么多年,外界对各种名词的解读已经完全混乱了,当有人说 “Major GC” 的时候一定要问清楚他想要指的是上面的 Full GC 还是 Old GC。

程序中主动调用 System.gc()强制执行的 GC 为 Full GC。

  • 新生代收集器:Serial、ParNew、Parallel Scavenge;
  • 老年代收集器:Serial Old、Parallel Old、CMS;
  • 整堆收集器:G1;

如果某个系统支持两个或者多个动作(Action)同时存在,那么这个系统就是一个并发系统。 如果某个系统支持两个或者多个动作同时执行,那么这个系统就是一个并行系统。

在并发程序中可以同时拥有两个或者多个线程。这意味着,如果程序在单核处理器上运行,那么这两个线程将交替地换入或者换出内存。 这些线程是同时 “存在” 的——每个线程都处于执行过程中的某个状态。 如果程序能够并行执行,那么就一定是运行在多核处理器上。 此时,程序中的每个线程都将分配到一个独立的处理器核上,因此可以同时运行。

可以得出结论:“并行”概念是 “并发” 概念的一个子集。 也就是说,编写一个拥有多个线程或者进程的并发程序,但如果没有多核处理器来执行这个程序,那么就不能以并行方式来运行代码,只能称为并发。 看下图来进行理解: (Erlang 之父 Joe Armstrong 用该图解释了并发与并行的区别)

并发是两个队列交替,但是只能使用一台咖啡机,并行是两个队列可以同时使用两台咖啡机 (任何属于冯诺依曼结构体系的计算机(经典计算机,目前应该除了实验中的量子计算机,都是属于该范畴的),其中的 CPU(或者说核)必然是串行执行指令的。所以在任何单 CPU 机器上,是不存在严格意义上,或者说狭义上的并行的--在指令级别的严格意义上的并行,是指在一个足够小的时刻,可以允许大于一条的指令在执行。)

要很好的理解这个图,请记住下面几点: 一定要把图中的一个 queues 来对应一个任务, 队列中的每一个人对应这个任务的步骤, 并发和并行的共同前提肯定是有多个任务要处理 (也就是图中的队列数量大于等于 2) 再进一步区分:

并发强调了其实现的前提是: 要处理的任务必须是可分步骤的 (队列中的人数大于等于 2); 并行强调了其实现的前提是: 必须是多个处理器 (咖啡机的数量大于等于 2).

生活中的一个例子

  • 你吃饭吃到一半,电话来了,你一直到吃完了以后才去接,这就说明你不支持并发也不支持并行。
  • 你吃饭吃到一半,电话来了,你停了下来接了电话,接完后继续吃饭,这说明你支持并发。
  • 你吃饭吃到一半,电话来了,你一边打电话一边吃饭,这说明你支持并行。

并发的关键是你有处理多个任务的能力,不一定要同时。 并行的关键是你有同时处理多个任务的能力。 在这里,将吃饭和接电话理解为两个任务。

并行(Parallel)

  • 指多条垃圾收集线程并行工作,但此时用户线程仍然处于等待状态;
  • 如 ParNew、Parallel Scavenge、Parallel Old;

并发(Concurrent)

  • 指用户线程与垃圾收集线程同时执行(但不一定是并行的,可能会交替执行);
  • 户程序在继续运行,而垃圾收集程序线程运行于另一个 CPU 上;
  • 如 CMS、G1(也有并行);