垃圾回收
术语:
JVM中的并行,指多个垃圾回收相关线程在操作系统之上并发运行,这里的并行强调的是只有垃圾回收线程工作,Java应用程序都暂停执行,因此ParNew工作的时候一定发生了STW。本书提到的***ParTask(例如G1ParTask)指的就是在这些任务运行的时候应用程序都必须暂停。
JVM中的并发,指垃圾回收相关的线程并发运行(如果启动多个线程),同时这些线程会和Java应用程序并发运行。本书提到的***Concurrent***Thread(例如ConcurrentG1RefineThread)就是指这些线程和Java应用程序同时运行。
·Stop-the-world(STW),直译就是停止一切,在JVM中指停止一切Java应用线程。
·安全点(Safepoint),指JVM在执行一些操作的时需要STW,但并不是任何线程在任何地方都能进入STW,例如我们正在执行一段代码时,线程如何能够停止?设计安全点的目的是,当线程进入到安全点时,线程就会主动停止。
·Mutator,在很多英文文献和JVM源码中,经常看到这个单词,它指的是我们的Java应用线程。Mutator的含义是可变的,在这里的含义是因为线程运行,导致了内存的变化。GC中通常需要STW才能使Mutator暂停。
·记忆集(Remember Set),简称为RSet。主要记录不同代际对象的引用关系。
·Refine,尚未有统一的翻译,有时翻译为细化,但是不太准确,本书中不做翻译。G1中的ConcurrentG1RefineThread主要指处理RSet的线程。
·Evacuation,转移、撤退或者回收,简称为Evac,本书中不做翻译。在G1中指的是发现活跃对象,并将对象复制到新地址的过程。
·回收(Reclaim),通常指的是分区对象已经死亡或者已经完成Evac,分区可以被JVM再次使用。
·GC Root,垃圾回收的根。在JVM的垃圾回收过程中,需要从GC Root出发标记活跃对象,确保正在使用的对象在垃圾回收后都是存活的。
·根集合(Root Set)。在JVM的垃圾回收过程中,需要从不同的GC Root出发,这些GC Root有线程栈、monitor列表、JNI对象等,而这些GC Root就构成了RootSet。
·Full GC,简称为FGC,整个堆的垃圾回收动作。通常Full GC是串行的,G1的FullGC不仅有串行实现,在JDK10中还有并行实现。
·再标记(Remark)。在本书中指的是并发标记算法中,处理完并发标记后,需要更新并发标记中Mutator变更的引用,这一步需要STW。
根据垃圾收集回收的区域不同,垃圾收集主要分为:
通用垃圾回收参数
G1 常用参数
并行(Parallel)与并发(Concurrent)
仅一字之差,
并行指多条垃圾收集线程并行
并发指用户线程与垃圾收集线程并发,程序在继续运行,而垃圾收集程序运行于另一个CPU上。并行指多条垃圾收集线程并行,并发指用户线程与垃圾收集线程并发.
设置GC方式:
Parallel收集器就是Parallel Scanvenge GC。
垃圾收集器
1.Serial GC 串行GC(
新生代)(
复制算法) 收集器
执行时其他线程必须暂停 Stop the world。
它在进行垃圾收集时,必须暂停其他所有的工作线程,直至Serial收集器收集结束为止(“Stop The World”)。在整个扫描和复制过程中均采用单线程的方式来进行,更加适合于单CPU、新生代空间较小及对暂停时间要求不高的应用上。也是client级别(CPU核数小于2或物理内存小于2GB)或32位Windows机器上默认采用的GC方式
触发条件:是否够放新对象
最基本、发展历史最久的收集器,这个收集器是一个采用复制算法的单线程的收集器,单线程一方面意味着它只会使用一个CPU或一条线程去完成垃圾收集工作,另一方面也意味着它进行垃圾收集时必须暂停其他线程的所有工作,直到它收集结束为止。后者意味着,在用户不可见的情况下要把用户正常工作的线程全部停掉,这对很多应用是难以接受的。不过实际上到目前为止,Serial收集器依然是虚拟机运行在Client模式下的默认新生代收集器,因为它简单而高效。用户桌面应用场景中,分配给虚拟机管理的内存一般来说不会很大,收集几十兆甚至一两百兆的新生代停顿时间在几十毫秒最多一百毫秒,只要不是频繁发生,这点停顿是完全可以接受的。
2.ParNew GC 并行GC(
新生代)(
复制算法)(
只有它能与 CMS 收集器配合工作) 收集器
Serial收集器的简单多线程版
触发条件:是否够放新对象
ParNew收集器其实就是Serial收集器的多线程版本,除了使用多条线程进行垃圾收集外,其余行为和Serial收集器完全一样,包括使用的也是复制算法。ParNew收集器除了多线程以外和Serial收集器并没有太多创新的地方,但是它却是Server模式下的虚拟机首选的新生代收集器,其中有一个很重要的和性能无关的原因是,
除了Serial收集器外,目前只有它能与CMS收集器配合工作
(看图)。
CMS收集器是一款几乎可以认为有划时代意义的垃圾收集器,因为它第一次实现了让垃圾收集线程与用户线程基本上同时工作。
ParNew收集器在单CPU的环境中绝对不会有比Serial收集器更好的效果,甚至由于线程交互的开销,该收集器在两个CPU的环境中都不能百分之百保证可以超越Serial收集器。当然,随着可用CPU数量的增加,它对于GC时系统资源的有效利用还是很有好处的。它默认开启的收集线程数与CPU数量相同,在CPU数量非常多的情况下,可以使用-XX:ParallelGCThreads参数来设置垃圾收集的线程数。一般最好与CPU数量相当,避免过多的线程数影响垃圾回收效果。在默认情况下,当CPU数量小于8时,PararllelGCThreads的值等于CPU数量,当CPU数量大于8时,ParallelGCThreads的值等于3+((5xCPU_Count)/8)
3.Parallel Scavenge GC 并行回收GC(
新生代收集器)(
吞吐量优先)收集器
Parallel收集器是虚拟机运行在Server模式下的默认垃圾收集器。
基于标记-整理算法。
ParallelGC与ParNew的区别就是它是吞吐量优先的。
Parallel Scavenge收集器也是一个
并行的
多线程新生代收集器。Parallel Scavenge收集器的特点是它的关注点与其他收集器不同,CMS等收集器的关注点是尽可能缩短垃圾收集时用户线程的停顿时间,而Parallel Scavenge收集器的目标是
达到一个可控制的吞吐量(Throughput)。
停顿时间越短就越适合需要与用户交互的程序,良好的响应速度能提升用户体验。而
高吞吐量则可以高效率地利用CPU时间,尽快完成程序的运算任务,主要适合
在后台运算而不需要太多交互的任务。
扫描和复制时均采用多线程方式进行。
Parallel Scavenge收集器提供了两个参数用于精确控制吞吐量,分别是控制最大垃圾收集停顿时间的-XX:MaxGCPauseMillis参数以及直接设置吞吐量大小的-XX:GCTimeRatio参数。
-XX:MaxGCPauseMillis参数允许的值是一个大于0的毫秒数,收集器将尽力保证内存回收花费的时间不超过用户设定值。不过大家不要异想天开地认为如果把这个参数的值设置得更小一点就能使得系统的垃圾收集速度变得更快,垃圾收集停顿时间缩短是以牺牲吞吐量和新生代空间为代价换取的:系统把新生代调得小一些,收集300MB新生代肯定比收集500MB快,但这也直接导致垃圾收集发生得更频繁,原来10秒收集一次、每次停顿100毫秒,现在变成5秒收集一次、每次停顿70毫秒。停顿时间的确在下降,但吞吐量也降下来了。
-XX:GCTimeRatio参数的值则应当是一个大于0小于100的整数,也就是垃圾收集时间占总时间的比率,相当于吞吐量的倒数。譬如把此参数设置为19,那允许的最大垃圾收集时间就占总时间的5%(即1/(1+19)),默认值为99,即允许最大1%(即1/(1+99))的垃圾收集时间。
由于与吞吐量关系密切,Parallel Scavenge收集器也经常被称作“吞吐量优先收集器”。除上述两个参数之外,Parallel Scavenge收集器还有一个参数-XX:+UseAdaptiveSizePolicy值得我们关注。这是一个开关参数,当这个参数被激活之后,就不需要人工指定新生代的大小(-Xmn)、Eden与Survivor区的比例(-XX:SurvivorRatio)、晋升老年代对象大小(-XX:PretenureSizeThreshold)等细节参数了,虚拟机会根据当前系统的运行情况收集性能监控信息,动态调整这些参数以提供最合适的停顿时间或者最大的吞吐量。这种调节方式称为垃圾收集的
自适应的调节策略(GC Ergonomics)。如果读者对于收集器运作不太了解,手工优化存在困难的话,使用Parallel Scavenge收集器配合自适应调节策略,把内存管理的调优任务交给虚拟机去完成也许是一个很不错的选择。
只需要把基本的内存数据设置好(如-Xmx设置最大堆),然后使用-XX:MaxGCPauseMillis参数(更关注最大停顿时间)或-XX:GCTimeRatio(更关注吞吐量)参数给虚拟机设立一个优化目标,那具体细节参数的调节工作就由虚拟机完成了。
自适应调节策略也是Parallel Scavenge收集器区别于ParNew收集器的一个重要特性。
另外值得注意的一点是,Parallel Scavenge收集器无法与CMS收集器配合使用,所以在JDK 1.6推出Parallel Old之前,如果新生代选择Parallel Scavenge收集器,老年代只有Serial Old收集器能与之配合使用。
-XX:+UseAdaptiveSizePolicy:设置此选项后,并行收集器会自动选择年轻代区大小和相应的Survivor区比例,以达到目标系统规定的最低相应时间或者收集频率等,此值建议使用并行收集器时,一直打开。
JDK 8 是默认开启
-XX:+UseAdaptiveSizePolicy 很多垃圾收集器都可以用
-XX:+PrintAdaptiveSizePolicy 打印自适应收集的大小
Parallel Scavenge收集器提供了几个参数用于精确控制吞吐量和停顿时间:
4.Serial Old(
老年代、持久代)(
标记-整理算法)
收集器 执行时,其他线程必须暂停 Stop the world
基于Mark-Sweep-Compact 实现。 client级别(CPU核数小于2或物理内存小于2GB)或32位Windows机器上默认采用的GC方式
Serial Old是Serial收集器的老年代版本,它同样是一个单线程收集器,使用标记-整理算法。这个收集器的主要意义也是供客户端模式下的HotSpot虚拟机使用。如果在服务端模式下,它也可能有两种用途:一种是在JDK 5以及之前的版本中与Parallel Scavenge收集器搭配使用,另外一种就是作为CMS收集器发生失败时的后备预案,在并发收集发生Concurrent Mode Failure时使用。
5.Parallel Old(
老年代、持久代)(
标记-整理算法)
基于Mark-Compact 实现。 server级别(CPU核数超过2且物理内存超过2GB)的机器(32位Window机器除外)上都是用这种方式作为默认GC.
Parallel Scavenge收集器的老年代版本(
不是ParNew哦!
),也关注吞吐量,使用多线程和“标记-整理”算法。这个收集器在JDK 1.6之后的出现,“吞吐量优先收集器”终于有了比较名副其实的应用组合,在注重吞吐量以及CPU资源敏感的场合,都可以优先考虑Parallel收集器+Parallel Old收集器的组合。
老年代除了Serial Old以外别无选择,所以在Parallel Old诞生以后,
“吞吐量优先”收集器终于有了比较名副其实的应用组合,在
注重吞吐量以及
CPU资源敏感的场合,都可以
优先考虑Parallel Scavenge加Parallel Old收集器。
参数-XX:ParallelGCThreads也可以用与设置垃圾回收时的线程数。
每一次FullGC之前都会伴随一次新生代GC(与串行回收器相比很不同),为了避免将所有回收工作同时交给一次Full GC进行,从而尽可能地缩短一次停顿时间。使用-XX:-ScavengeBeforeFullGC去掉。
JDK8 默认的垃圾收集器就是 Parallel Scavenge + Parallel Old
6.CMS (Cocurrent Mark-Sweep GC(
老年代、持久代)(
标记-清除算法)) 从JDK9开始已经是Deprecated的了
让虚拟机平时多数时间都采用标记-清除算法,暂时容忍内存碎片的存在,直到内存空间的碎片化程度已经大到影响对象分配时,再采用标记-整理算法收集一次,以获得规整的内存空间。
CMS(Concurrent Mark and Swee 并发-标记-清除),是一款基于并发、使用标记清除算法的垃圾回收算法,只针对老年代进行垃圾回收。CMS收集器工作时,尽可能让GC线程和用户线程并发执行,以达到降低STW时间的目的
能与CMS搭配使用的新生代垃圾收集器有Serial收集器和ParNew收集器。
https://docs.oracle/javase/8/docs/technotes/guides/vm/gctuning/cms.html
启动时候由于MetaspaceSize 配置的不好引发的Full GC
CMS(Concurrent Mark Sweep)收集器是一种以
获取最短回收停顿时间为目标的收集器,它非常符合那些集中在互联网站或者B/S系统的服务端上的Java应用,这些应用都非常重视服务的响应速度。从名字上(“Mark Sweep”)就可以看出它是基于
“标记-清除”算法实现的。
CMS收集器工作的整个流程分为以下步骤:
英文单词很重要,因为gclog里的阶段都是这些词。
- Young GC(Minor GC)
- Old GC(Old GC)
- Full GC
- Mixed GC
①Young GC
新生代内存的垃圾收集事件称为 Young GC(又称 Minor GC ) ,当 JVM 无法为新对象分配在新生代内存空间时总会触发 Young GC。 比如 Eden 区占满时,新对象分配频率越高,Young GC 的频率就越高。 Young GC 每次都会引起全线停顿(Stop-The-World ) ,暂停所有的应用线程,停顿时间相对老年代 GC 造成的停顿,几乎可以忽略不计。 YGC垃圾排查 YGC问题排查,又让我涨姿势了!②Old GC 、Full GC、Mixed GC
Old GC: 只清理老年代空间的 GC 事件,只有 CMS 的并发收集 是这个模式。 Full GC: 清理整个堆的 GC 事件,包括新生代、老年代、元空间等 。 Mixed GC: 清理整个新生代以及 部分老年代 的 GC,只有 G1 有这个模式。 GC原因: 一个文档: Java之CMS GC Causes - 掘金 一、 Allocation Failure Allocation Failure happens when there isn't enough free space to create new objects in Young generation. Allocation failures triggers Young GC. On Linux, the JVM can trigger a GC if the kernel notifies there isn't much memory left via mem_notify.- 产生原因:新生代没有足够的空间分配对象。
- 触发GC类型:Young GC。
- 产生原因:CMS GC运行期间,老年代预留的空间不足以分配给新的对象。
- 触发GC类型:Full GC。
- 解决方法:适当调低-XX:CMSInitiatingOccupancyFraction,或者增加老年代内存。
- 产生原因:老年代没有足够的连续空间分配给晋升的对象(即使总可用内存足够大)。
- 触发GC类型:Full GC。
- 解决方法:增加堆内存,特别是新生代内存,尽可能让对象在新生代被回收掉。减少对象的创建,缩短对象的生命周期。
- 产生原因:如果线程执行在JNI临界区时,刚好需要进行GC,此时GC locker将会阻止GC的发生,同时阻止其他线程进入JNI临界区,直到最后一个线程退出临界区时触发一次GC。
- 触发GC类型:GCLocker Initiated GC。
优点:实现简单,判定效率高效,被actionscript3和python中广泛应用。 缺点:无法解决对象之间的相互引用问题。java没有采纳2.可达性分析算法 通过一系列称为“GC Roots”的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链,当一个对象到GCRoots没有任何引用链相连的时候,则证明此对象是不可用的。 在Java技术体系里面,固定可作为GC Roots的对象包括以下几种: · 在虚拟机栈(栈帧中的本地变量表)中引用的对象,譬如各个线程被调用的方法堆栈中使用到的参数、局部变量、临时变量等。 · 在方法区中类静态属性引用的对象,譬如Java类的引用类型静态变量。 · 在方法区中常量引用的对象,譬如字符串常量池(String Table)里的引用。 · 在本地方法栈中JNI(即通常所说的Native方法)引用的对象。 · Java虚拟机内部的引用,如基本数据类型对应的Class对象,一些常驻的异常对象(比如NullPointExcepiton、OutOfMemoryError)等,还有系统类加载器。 · 所有被同步锁(synchronized关键字)持有的对象。 · 反映Java虚拟机内部情况的JMXBean、JVMTI中注册的回调、本地代码缓存等。 除了这些固定的GC Roots集合以外,根据用户所选用的垃圾收集器以及当前回收的内存区域不同,还可以有其他对象“临时性”地加入,共同构成完整GC Roots集合。譬如后文将会提到的分代收集和局部回收(Partial GC),如果只针对Java堆中某一块区域发起垃圾收集时(如最典型的只针对新生代的垃圾收集),必须考虑到内存区域是虚拟机自己的实现细节(在用户视角里任何内存区域都是不可见的),更不是孤立封闭的,所以某个区域里的对象完全有可能被位于堆中其他区域的对象所引用,这时候就需要将这些关联区域的对象也一并加入GC Roots集合中去,才能保证可达性分析的正确性。 目前最新的几款垃圾收集器无一例外都具备了局部回收的特征,为了避免GC Roots包含过多对象而过度膨胀 基于以上,我们可以知道,当当前对象到GCRoot中不可达时候,即会满足被垃圾回收的可能。 一次垃圾回收需要两次扫描 那么是不是这些对象就非死不可,也不一定,此时只能宣判它们存在于一种“缓刑”的阶段,要真正的宣告一个对象死亡。至少要经历两次标记:
第一次:对象可达性分析之后,发现没有与GCRoots相连接,此时会被第一次标记并筛选。 第二次:对象没有覆盖finalize()方法,或者finalize()方法已经被虚拟机调用过,此时会被认定为没必要执行。对象在内存中的状态 1.可达状态:从GCRoots 可以遍历到 2.可恢复状态:刚失去引用。系统调用finalize()方法,如果该方法是一个变量引用它,则变为可达状态,否则不可达状态。 3.不可达状态:垃圾回收 即使在可达性分析算法中判定为不可达的对象,也不是“非死不可”的,这时候它们暂时还处于“缓刑”阶段,要真正宣告一个对象死亡,至少要经历两次标记过程:如果对象在进行可达性分析后发现没有与GC Roots相连接的引用链,那它将会被第一次标记,随后进行一次筛选,筛选的条件是此对象是否有必要执行finalize()方法。假如对象没有覆盖finalize()方法,或者finalize()方法已经被虚拟机调用过,那么虚拟机将这两种情况都视为“没有必要执行”。 如果这个对象被判定为确有必要执行finalize()方法,那么该对象将会被放置在一个名为F-Queue的队列之中,并在稍后由一条由虚拟机自动建立的、低调度优先级的 Finalizer线程去执行它们的finalize()方法。这里所说的“执行”是指虚拟机会触发这个方法开始运行,但并不承诺一定会等待它运行结束。这样做的原因是,如果某个对象的finalize()方法执行缓慢,或者更极端地发生了死循环,将很可能导致F-Queue队列中的其他对象永久处于等待,甚至导致整个内存回收子系统的崩溃。finalize()方法是对象逃脱死亡命运的最后一次机会,稍后收集器将对F-Queue中的对象进行第二次小规模的标记,如果对象要在finalize()中成功拯救自己——只要重新与引用链上的任何一个对象建立关联即可,譬如把自己(this关键字)赋值给某个类变量或者对象的成员变量,那在第二次标记时它将被移出“即将回收”的集合;如果对象这时候还没有逃脱,那基本上它就真的要被回收了。 finalize()已经不推荐使用。 三色标记算法 Java 如何有效地避免OOM:善于利用软引用和弱引用 - Matrix海子 - 博客园 Java中的四种引用 Reference的子类 1.强引用 = 可达状态不能被回收 强引用是最传统的“引用”的定义,是指在程序代码之中普遍存在的引用赋值,即类似“Object obj=new Object()”这种引用关系。无论任何情况下,只要强引用关系还存在,垃圾收集器就永远不会回收掉被引用的对象。 2.软引用 java.lang.ref.SoftReference 只有在内存不足的时候JVM才会回收该对象,可达状态也可能被回收。 软引用可以和一个引用队列(ReferenceQueue)联合使用,如果软引用所引用的对象被JVM回收,这个软引用就会被加入到与之关联的引用队列中。 如何判断内存不足? -XX:SoftRefLRUPolicyMSPerMB从名字看不出什么意思?【官文解读】_liangdu_Zuker的博客-CSDN博客 -XX:SoftRefLRUPolicyMSPerMB 参数 3.弱引用 java.lang.ref.WeakReference 垃圾回收一执行,就会回收 弱引用可以和一个引用队列(ReferenceQueue)联合使用,如果弱引用所引用的对象被JVM回收,这个软引用就会被加入到与之关联的引用队列中。 4.虚引用 java.lang.ref.PhantomReference 必须和引用队列一起使用,用于跟踪对象被垃圾回收的状态 虚引用和前面的软引用、弱引用不同,它并不影响对象的生命周期。在java中用java.lang.ref.PhantomReference类表示。如果一个对象与虚引用关联,则跟没有引用与之关联一样,在任何时候都可能被垃圾回收器回收。要注意的是,虚引用必须和引用队列关联使用,当垃圾回收器准备回收一个对象时,如果发现它还有虚引用,就会把这个虚引用加入到与之 关联的引用队列中。程序可以通过判断引用队列中是否已经加入了虚引用,来了解被引用的对象是否将要被垃圾回收。如果程序发现某个虚引用已经被加入到引用队列,那么就可以在所引用的对象的内存被回收之前采取必要的行动。 虚引用也称为幽灵引用或者幻影引用,它是 最弱 的一种引用关系。 一个对象是否有虚引用的存在, 完全不会对其生存时间构成影响 ,也无法通过虚引用来取得一个对象实例。 为一个对象设置虚引用关联的 唯一目的就是能在这个对象被收集器回收时收到一个系统通知 。 HotSpot算法实现细节 根节点枚举 迄今为止,所有收集器在根节点枚举这一步骤时都是必须暂停用户线程的,因此毫无疑问根节点枚举与之前提及的整理内存碎片一样会面临相似的“Stop The World”的困扰。现在可达性分析算法耗时最长的查找引用链的过程已经可以做到与用户线程一起并发(具体见3.4.6节),但根节点枚举始终还是必须在一个能保障一致性的快照中才得以进行——这里“一致性”的意思是整个枚举期间执行子系统看起来就像被冻结在某个时间点上,不会出现分析过程中,根节点集合的对象引用关系还在不断变化的情况,若这点不能满足的话,分析结果准确性也就无法保证。这是导致垃圾收集过程必须停顿所有用户线程的其中一个重要原因,即使是号称停顿时间可控,或者(几乎)不会发生停顿的CMS、G1、ZGC等收集器,枚举根节点时也是必须要停顿的。 由于目前主流Java虚拟机使用的都是准确式垃圾收集(这个概念在第1章介绍Exact VM相对于Classic VM的改进时介绍过),所以当用户线程停顿下来之后,其实并不需要一个不漏地检查完所有执行上下文和全局的引用位置,虚拟机应当是有办法直接得到哪些地方存放着对象引用的。在HotSpot的解决方案里,是使用一组称为 OopMap的数据结构来达到这个目的。一旦类加载动作完成的时候,HotSpot就会把对象内什么偏移量上是什么类型的数据计算出来,在即时编译(见第11章)过程中,也会在特定的位置记录下栈里和寄存器里哪些位置是引用。这样收集器在扫描时就可以直接得知这些信息了, 并不需要真正一个不漏地从方法区等GC Roots开始查找。 下面代码清单3-3是HotSpot虚拟机客户端模式下生成的一段String::hashCode()方法的本地代码,可以看到在0x026eb7a9处的call指令有OopMap记录,它指明了EBX寄存器和栈中偏移量为16的内存区域中各有一个普通对象指针(Ordinary Object Pointer,OOP)的引用,有效范围为从call指令开始直到0x026eb730(指令流的起始位置)+142(OopMap记录的偏移量)=0x026eb7be,即hlt指令为止。 代码清单3-3 String.hashCode()方法编译后的本地代码 [Verified Entry Point] 0x026eb730: mov %ea x,-0x8000(%esp)…………;; ImplicitNullCheckSt ub slow case0x026eb7a9: call 0x0 26e83e0 ; OopMap{ebx=Oop [16]=Oo p off =142} ; *caload ; - java.lang.String::ha shCod e@48 (line 1489) ; {runtime_call} 0x026eb7ae: push $0x83c5c18 ; {external_word} 0x026eb7b3: call 0x026eb7b8 0x026eb7b8: pusha 0x026eb7b9: call 0x0822bec0 ; {runtime_call} 0x026eb7be: hlt 安全点 在OopMap的协助下,HotSpot可以快速准确地完成GC Roots枚举,但一个很现实的问题随之而来:可能导致引用关系变化,或者说导致OopMap内容变化的指令非常多,如果为每一条指令都生成对应的OopMap,那将会需要大量的额外存储空间,这样垃圾收集伴随而来的空间成本就会变得无法忍受的高昂。 实际上HotSpot也的确没有为每条指令都生成OopMap,前面已经提到,只是在“特定的位置”记录了这些信息,这些位置被称为 安全点(Safepoint)。有了安全点的设定,也就决定了用户程序执行时并非在代码指令流的任意位置都能够停顿下来开始垃圾收集,而是强制要求必须执行到达安全点后才能够暂停。因此,安全点的选定既不能太少以至于让收集器等待时间过长,也不能太过频繁以至于过分增大运行时的内存负荷。安全点位置的选取基本上是以“是否具有让程序长时间执行的特征”为标准进行选定的,因为每条指令执行的时间都非常短暂,程序不太可能因为指令流长度太长这样的原因而长时间执行,“长时间执行”的最明显特征就是指令序列的复用,例如方法调用、循环跳转、异常跳转等都属于指令序列复用,所以只有具有这些功能的指令才会产生安全点。 对于安全点,另外一个需要考虑的问题是,如何在垃圾收集发生时让所有线程(这里其实不包括执行JNI调用的线程)都跑到最近的安全点,然后停顿下来。这里有两种方案可供选择:抢先式中断(Preemptive Suspension)和主动式中断(Voluntary Suspension),抢先式中断不需要线程的执行代码主动去配合,在垃圾收集发生时,系统首先把所有用户线程全部中断,如果发现有用户线程中断的地方不在安全点上,就恢复这条线程执行,让它一会再重新中断,直到跑到安全点上。现在几乎没有虚拟机实现采用抢先式中断来暂停线程响应GC事件。 而主动式中断的思想是当垃圾收集需要中断线程的时候,不直接对线程操作,仅仅简单地设置一个标志位,各个线程执行过程时会不停地主动去轮询这个标志,一旦发现中断标志为真时就自己在最近的安全点上主动中断挂起。轮询标志的地方和安全点是重合的,另外还要加上所有创建对象和其他需要在Java堆上分配内存的地方,这是为了检查是否即将要发生垃圾收集,避免没有足够内存分配新对象。 由于轮询操作在代码中会频繁出现,这要求它必须足够高效。HotSpot使用内存保护陷阱的方式,把轮询操作精简至只有一条汇编指令的程度。下面代码清单3-4中的test指令就是HotSpot生成的轮询指令,当需要暂停用户线程时,虚拟机把0x160100的内存页设置为不可读,那线程执行到test指令时就会产生一个自陷异常信号,然后在预先注册的异常处理器中挂起线程实现等待,这样仅通过一条汇编指令便完成安全点轮询和触发线程中断了。 代码清单3-4 轮询指令 0x01b6d627: call 0x01b2b210 ; OopMap{[60]=Oop off=460} ; *invokeinterface size ; - Client1::main@113 (lin e 23) ; {virtual_call} 0x01b6d62c: nop ; OopMap{[60]=Oop off=461} ; *if_icmplt ; - Client1::main@118 (lin e 23) 0x01b6d62d: test %eax,0x160100 ; {poll} 0x01b6d633: mov 0x50(%esp),%esi 0x01b6d637: cmp %eax,%esi 安全区域 使用安全点的设计似乎已经完美解决如何停顿用户线程,让虚拟机进入垃圾回收状态的问题了,但实际情况却并不一定。安全点机制保证了程序执行时,在不太长的时间内就会遇到可进入垃圾收集过程的安全点。但是,程序“不执行”的时候呢?所谓的程序不执行就是没有分配处理器时间,典型的场景便是用户线程处于Sleep状态或者Blocked状态,这时候线程无法响应虚拟机的中断请求,不能再走到安全的地方去中断挂起自己,虚拟机也显然不可能持续等待线程重新被激活分配处理器时间。对于这种情况,就必须引入安全区域(Safe Region)来解决。 安全区域是指能够确保在某一段代码片段之中,引用关系不会发生变化,因此,在这个区域中任意地方开始垃圾收集都是安全的。我们也可以把安全区域看作被扩展拉伸了的安全点。 当用户线程执行到安全区域里面的代码时,首先会标识自己已经进入了安全区域,那样当这段时间里虚拟机要发起垃圾收集时就不必去管这些已声明自己在安全区域内的线程了。当线程要离开安全区域时,它要检查虚拟机是否已经完成了根节点枚举(或者垃圾收集过程中其他需要暂停用户线程的阶段),如果完成了,那线程就当作没事发生过,继续执行;否则它就必须一直等待,直到收到可以离开安全区域的信号为止。 记忆集 (Remembered Set,RSet) 与卡表 看JVM G1源码分析与调优 这个结构 把老年代划分成若干小块,标识出老年代的哪一块内存会存在跨代引用。此后当发生Minor GC时,只有包含了跨代引用的小块内存里的对象才会被加入到GC Roots进行扫描。虽然这种方法需要在对象改变引用关系(如将自己或者某个属性赋值)时维护记录数据的正确性,会增加一些运行时的开销,但比起收集时扫描整个老年代来说仍然是划算的。 通常有两种方法记录引用关系,第一成为Point Out,第二是Point in(G1使用)。如ObjA.Field=ObjB,对于Point out来说在ObjA的RSet中记录ObjB的地址,对于Point in来说在ObjB的RSet中记录ObjA的地方,这相当于一种反向引用。这两者的区别在于处理有所不同,Point Out记录操作简单,但是需要对RSet做全部扫描;Point In记录操作复杂,但是在标记扫描时直接可以找到有用和无用的对象,不需要额外的扫描,因为RSet里面的对象可以认为就是根对象。 卡表: jvm-卡表_IRON_MAN_KD的博客-CSDN博客_jvm 卡表 卡表帮助记录哪些老年代对象可能指向新生代对象。卡表本质上是一个由JVM管理的字节数组,该数组的每个元素对应于一个老年的中512字节的区域。 Rset G1才有。是每个Region都有的条目,它们会记录指向当前堆区域的外部引用。这意味着G1不需要通过遍历整个堆来寻找指向某个区域的引用,而是只需要检查RSets,然后扫描这些区域来寻找引用。 讲解分代收集理论的时候,提到了为解决对象跨代引用所带来的问题,垃圾收集器在新生代中建立了名为 记忆集(Remembered Set)的数据结构,用以 避免把整个老年代加进GC Roots扫描范围。事实上并不只是新生代、老年代之间才有跨代引用的问题,所有涉及部分区域收集(Partial GC)行为的垃圾收集器,典型的如G1、ZGC和Shenandoah收集器,都会面临相同的问题,因此我们有必要进一步理清记忆集的原理和实现方式,以便在后续章节里介绍几款最新的收集器相关知识时能更好地理解。 记忆集是一种用于记录从非收集区域指向收集区域的指针集合的抽象数据结构。如果我们不考虑效率和成本的话,最简单的实现可以用非收集区域中所有含跨代引用的对象数组来实现这个数据结构,如代码清单3-5所示: 代码清单3-5 以对象指针来实现记忆集的伪代码 Class RememberedSet { Object[] set[OBJE CT_INTERGENERATIONAL_REFERENCE_SIZE];} 这种记录全部含跨代引用对象的实现方案,无论是空间占用还是维护成本都相当高昂。而在垃圾收集的场景中,收集器只需要通过记忆集判断出某一块非收集区域是否存在有指向了收集区域的指针就可以了,并不需要了解这些跨代指针的全部细节。那设计者在实现记忆集的时候,便可以选择更为粗犷的记录粒度来节省记忆集的存储和维护成本,下面列举了一些可供选择(当然也可以选择这个范围以外的)的记录精度: · 字长精度: 每个记录精确到一个机器字长(就是处理器的寻址位数,如常见的32位或64位,这个精度决定了机器访问物理内存地址的指针长度),该字包含跨代指针。 · 对象精度: 每个记录精确到一个对象,该对象里有字段含有跨代指针。 · 卡精度: 每个记录精确到一块内存区域,该区域内有对象含有跨代指针。 其中,第三种“卡精度”所指的是用一种称为“ 卡表”(Card Table)的方式去实现记忆集,这也是目前最常用的一种记忆集实现形式,一些资料中甚至直接把它和记忆集混为一谈。前面定义中提到记忆集其实是一种“抽象”的数据结构,抽象的意思是只定义了记忆集的行为意图,并没有定义其行为的具体实现。卡表就是记忆集的一种具体实现,它定义了记忆集的记录精度、与堆内存的映射关系等。关于卡表与记忆集的关系,读者不妨按照Java语言中HashMap与Map的关系来类比理解。 卡表最简单的形式可以只是一个字节数组,而HotSpot虚拟机确实也是这样做的。以下这行代码是HotSpot默认的卡表标记逻辑: CARD_TABLE [this address >> 9] = 0; 字节数组CARD_TABLE的每一个元素都对应着其标识的内存区域中一块特定大小的内存块,这个内存块被称作“卡页”(Card Page)。一般来说,卡页大小都是以2的N次幂的字节数,通过上面代码可以看出HotSpot中使用的卡页是2的9次幂,即512字节(地址右移9位,相当于用地址除以512)。那如果卡表标识内存区域的起始地址是0x0000的话,数组CARD_TABLE的第0、1、2号元素,分别对应了地址范围为0x0000~0x01FF、0x0200~0x03FF、0x0400~0x05FF的卡页内存块,如图3-5所示。 一个卡页的内存中通常包含不止一个对象,只要卡页内有一个(或更多)对象的字段存在着跨代指针,那就将对应卡表的数组元素的值标识为1,称为这个元素变脏(Dirty),没有则标识为0。在垃圾收集发生时,只要筛选出卡表中变脏的元素,就能轻易得出哪些卡页内存块中包含跨代指针,把它们加入GC Roots中一并扫描。 卡表(CardTable)在CMS中是最常见的概念之一,G1中不仅保留了这个概念,还引入了RSet。卡表到底是一个什么东西?GC最早引入卡表的目的是为了对内存的引用关系做标记,从而根据引用关系快速遍历活跃对象。举个简单的例子,有两个分区,假设分区大小都为1MB,分别为A和B。如果A中有一个对象objA,B中有一个对象objB,且objA.field=objB,那么这两个分区就有引用关系了,但是如果我们想找到分区A,要如何引用分区B?做法有两种:·遍历整个分区A,一个字一个字的移动(为什么以字为单位?原因是JVM中对象会对齐,所以不需要按字节移动),然后查看内存里面的值到底是不是指向B,这种方法效率太低,可以优化为一个对象一个对象地移动(这里涉及JVM如何识别对象,以及如何区分指针和立即数),但效率还是太低。 ·借助额外的数据结构描述这种引用关系,例如使用类似位图(bitmap)的方法,记录A和B的内存块之间的引用关系,用一个位来描述一个字,假设在32位机器上(一个字为32位),需要32KB(32KB×32=1M)的空间来描述一个分区。那么我们就可以在这个对象ObjA所在分区A里面添加一个额外的指针,这个指针指向另外一个分区B的位图,如果我们可以把对象ObjA和指针关系进行映射,那么当访问ObjA的时候,顺便访问这个额外的指针,从这个指针指向的位图就能找到被ObjA引用的分区B对应的内存块。通常我们只需要判定位图里面对应的位是否有1,有的话则认为发生了引用。 以位为粒度的位图能准确描述每一个字的引用关系,但是一个位通常包含的信息太少,只能描述2个状态:引用还是未引用。实际应用中JVM在垃圾回收的时候需要更多的状态,如果增加至一个字节来描述状态,则位图需要256KB的空间,这个数字太大,开销占了25%。所以一个可能的做法位图不再描述一个字,而是一个区域,JVM选择512字节为单位,即用一个字节描述512字节的引用关系。选择一个区域除了空间利用率的问题之外,实际上还有现实的意义。我们知道Java对象实际上不是一个字能描述的(有一个参数可以控制对象最小对齐的大小,默认是8字节,实际上Java在JVM中还有一些附加信息,所以对齐后最小的Java对象是16字节),很多Java对象可能是几十个字节或者几百个字节,所以用一个字节描述一个区域是有意义的。但是我没有找到512的来源,为什么512效果最好?没有相应的数据来支持这个数字,而且这个值不可以配置,不能修改,但是有理由相信512字节的区域是为了节约内存额外开销。按照这个值,1MB的内存只需要2KB的额外空间就能描述引用关系。这又带来另一个问题,就是512字节里面的内存可能被引用多次,所以这是一个粗略的关系描述,那么在使用的时候需要遍历这512字节。 再举一个例子,假设有两个对象B、C都在这512字节的区域内。为了方便处理,记录对象引用关系的时候,都使用对象的起始位置,然后用这个地址和512对齐,因此B和C对象的卡表指针都指向这一个卡表的位置。那么对于引用处理也有可有两种处理方法:·处理的时候会以堆分区为处理单位,遍历整个堆分区,在遍历的时候,每次都会以对象大小为步长,结合卡表,如果该卡表中对应的位置被设置,则说明对象和其他分区的对象发生了引用。具体内容在后文中介绍Refine的时候还会详细介绍。·处理的时候借助于额外的数据结构,找到真正对象的位置,而不需要从头开始遍历。在后文的并发标记处理时就使用了这种方法,用于找到第一个对象的起始位置。在G1除了512字节粒度的卡表之外,还有bitMap,例如使用bitMap可以描述一个分区对另外一个分区的引用情况。在JVM中bitMap使用非常多,例如还可以描述内存的分配情况。 再举一个例子,假设有两个对象B、C都在这512字节的区域内。为了方便处理,记录对象引用关系的时候,都使用对象的起始位置,然后用这个地址和512对齐,因此B和C对象的卡表指针都指向这一个卡表的位置。那么对于引用处理也有可有两种处理方法:·处理的时候会以堆分区为处理单位,遍历整个堆分区,在遍历的时候,每次都会以对象大小为步长,结合卡表,如果该卡表中对应的位置被设置,则说明对象和其他分区的对象发生了引用。具体内容在后文中介绍Refine的时候还会详细介绍。·处理的时候借助于额外的数据结构,找到真正对象的位置,而不需要从头开始遍历。在后文的并发标记处理时就使用了这种方法,用于找到第一个对象的起始位置。在G1除了512字节粒度的卡表之外,还有bitMap,例如使用bitMap可以描述一个分区对另外一个分区的引用情况。在JVM中bitMap使用非常多,例如还可以描述内存的分配情况。 G1在混合收集算法中用到了并发标记。在并发标记的时候使用了bitMap来描述对象的分配情况。例如1MB的分区可以用16KB(16KB×ObjectAlignmentInBytes×8=1MB)来描述,即16KB额外的空间。其中ObjectAlignmentInBytes是8字节,指的是对象对齐,第二个8是指一个字节有8位。即每一个位可以描述64位。例如一个对象长度对齐之后为24字节,理论上它占用3个位来描述这个24字节已被使用了,实际上并不需要,在标记的时候只需要标记这3个位中的第一个位,再结合堆分区对象的大小信息就能准确找出。其最主要的目的是为了效率,标记一个位和标记3个位相比能节约不少时间,如果对象很大,则更划算。这些都是源码的实现细节,大家在阅读源码时需要细细斟酌。 写屏障 我们已经解决了如何使用记忆集来缩减GC Roots扫描范围的问题,但还没有解决卡表元素如何维护的问题,例如它们何时变脏、谁来把它们变脏等。 卡表元素何时变脏的答案是很明确的——有其他分代区域中对象引用了本区域对象时,其对应的卡表元素就应该变脏,变脏时间点原则上应该发生在引用类型字段赋值的那一刻。但问题是如何变脏,即如何在对象赋值的那一刻去更新维护卡表呢?假如是解释执行的字节码,那相对好处理,虚拟机负责每条字节码指令的执行,有充分的介入空间;但在编译执行的场景中呢?经过即时编译后的代码已经是纯粹的机器指令流了,这就必须找到一个在机器码层面的手段,把维护卡表的动作放到每一个赋值操作之中。 在HotSpot虚拟机里是 通过写屏障(Write Barrier)技术维护卡表状态的。先请读者注意将这里提到的“写屏障”,以及后面在低延迟收集器中会提到的“读屏障”与解决并发乱序执行问题中的“内存屏障”区分开来,避免混淆。写屏障可以看作在虚拟机层面对“引用类型字段赋值”这个动作的AOP切面,在引用对象赋值时会产生一个环形(Around)通知,供程序执行额外的动作,也就是说赋值的前后都在写屏障的覆盖范畴内。在赋值前的部分的写屏障叫作写前屏障(Pre-Write Barrier),在赋值后的则叫作写后屏障(Post-Write Barrier)。HotSpot虚拟机的许多收集器中都有使用到写屏障,但直至G1收集器出现之前,其他收集器都只用到了写后屏障。下面这段代码清单3-6是一段更新卡表状态的简化逻辑: 代码清单3-6 写后屏障更新卡表 void oop_field_store(oop* field, oop new_value) { // 引用字段赋值操作 *field = new_value; // 写后屏障,在这里完成卡表状态更新 post_write_barrier(field, new_value);} 应用写屏障后,虚拟机就会为所有赋值操作生成相应的指令,一旦收集器在写屏障中增加了更新卡表操作,无论更新的是不是老年代对新生代对象的引用,每次只要对引用进行更新,就会产生额外的开销,不过这个开销与Minor GC时扫描整个老年代的代价相比还是低得多的。 除了写屏障的开销外,卡表在高并发场景下还面临着“伪共享”(False Sharing)问题。伪共享是处理并发底层细节时一种经常需要考虑的问题,现代中央处理器的缓存系统中是以缓存行(Cache Line)为单位存储的,当多线程修改互相独立的变量时,如果这些变量恰好共享同一个缓存行,就会彼此影响(写回、无效化或者同步)而导致性能降低,这就是伪共享问题。 假设处理器的缓存行大小为64字节,由于一个卡表元素占1个字节,64个卡表元素将共享同一个缓存行。这64个卡表元素对应的卡页总的内存为32KB(64×512字节),也就是说如果不同线程更新的对象正好处于这32KB的内存区域内,就会导致更新卡表时正好写入同一个缓存行而影响性能。为了避免伪共享问题,一种简单的解决方案是不采用无条件的写屏障,而是先检查卡表标记,只有当该卡表元素未被标记过时才将其标记为变脏,即将卡表更新的逻辑变为以下代码所示: if (CARD_TABLE [this address >> 9] != 0) CARD_TABLE [this address >> 9] = 0; 在JDK 7之后,HotSpot虚拟机增加了一个新的参数-XX:+UseCondCardMark,用来决定是否开启卡表更新的条件判断。开启会增加一次额外判断的开销,但能够避免伪共享问题,两者各有性能损耗,是否打开要根据应用实际运行情况来进行测试权衡。 并发的可达性分析 在3.2节中曾经提到了当前主流编程语言的垃圾收集器基本上都是依靠可达性分析算法来判定对象是否存活的,可达性分析算法理论上要求全过程都基于一个能保障一致性的快照中才能够进行分析,这意味着必须全程冻结用户线程的运行。在根节点枚举(见3.4.1节)这个步骤中,由于GC Roots相比起整个Java堆中全部的对象毕竟还算是极少数,且在各种优化技巧(如OopMap)的加持下,它带来的停顿已经是非常短暂且相对固定(不随堆容量而增长)的了。可从GC Roots再继续往下遍历对象图,这一步骤的停顿时间就必定会与Java堆容量直接成正比例关系了:堆越大,存储的对象越多,对象图结构越复杂,要标记更多对象而产生的停顿时间自然就更长,这听起来是理所当然的事情。 要知道包含“标记”阶段是所有追踪式垃圾收集算法的共同特征,如果这个阶段会随着堆变大而等比例增加停顿时间,其影响就会波及几乎所有的垃圾收集器,同理可知,如果能够削减这部分停顿时间的话,那收益也将会是系统性的。 想解决或者降低用户线程的停顿,就要先搞清楚为什么必须在一个能保障一致性的快照上才能进行对象图的遍历?为了能解释清楚这个问题,我们引入三色标记(Tri-color Marking)作为工具来辅助推导,把遍历对象图过程中遇到的对象,按照“是否访问过”这个条件标记成以下三种颜色: · 白色: 表示对象尚未被垃圾收集器访问过。显然在可达性分析刚刚开始的阶段,所有的对象都是白色的,若在分析结束的阶段,仍然是白色的对象,即代表不可达。 · 黑色: 表示对象已经被垃圾收集器访问过,且这个对象的所有引用都已经扫描过。黑色的对象代表已经扫描过,它是安全存活的,如果有其他对象引用指向了黑色对象,无须重新扫描一遍。黑色对象不可能直接(不经过灰色对象)指向某个白色对象。 · 灰色: 表示对象已经被垃圾收集器访问过,但这个对象上至少存在一个引用还没有被扫描过。 关于可达性分析的扫描过程,读者不妨发挥一下想象力,把它看作对象图上一股以灰色为波峰的波纹从黑向白推进的过程,如果用户线程此时是冻结的,只有收集器线程在工作,那不会有任何问题。但如果用户线程与收集器是并发工作呢?收集器在对象图上标记颜色,同时用户线程在修改引用关系——即修改对象图的结构,这样可能出现两种后果。一种是把原本消亡的对象错误标记为存活,这不是好事,但其实是可以容忍的,只不过产生了一点逃过本次收集的浮动垃圾而已,下次收集清理掉就好。另一种是把原本存活的对象错误标记为已消亡,这就是非常致命的后果了,程序肯定会因此发生错误,下面表3-1演示了这样的致命错误具体是如何产生的。 Wilson于1994年在理论上证明了,当且仅当以下两个条件同时满足时,会产生“对象消失”的问题,即原本应该是黑色的对象被误标为白色: · 赋值器插入了一条或多条从黑色对象到白色对象的新引用; · 赋值器删除了全部从灰色对象到该白色对象的直接或间接引用。 因此,我们要解决并发扫描时的对象消失问题,只需破坏这两个条件的任意一个即可。由此分别产生了两种解决方案:增量更新(Incremental Update)和 原始快照(Snapshot At The Beginning,SATB)。 增量更新要破坏的是第一个条件,当黑色对象插入新的指向白色对象的引用关系时,就将这个新插入的引用记录下来,等并发扫描结束之后,再将这些记录过的引用关系中的黑色对象为根,重新扫描一次。这可以简化理解为,黑色对象一旦新插入了指向白色对象的引用之后,它就变回灰色对象了。 原始快照要破坏的是第二个条件,当灰色对象要删除指向白色对象的引用关系时,就将这个要删除的引用记录下来,在并发扫描结束之后,再将这些记录过的引用关系中的灰色对象为根,重新扫描一次。这也可以简化理解为,无论引用关系删除与否,都会按照刚刚开始扫描那一刻的对象图快照来进行搜索。 以上无论是对引用关系记录的插入还是删除,虚拟机的记录操作都是通过写屏障实现的。在HotSpot虚拟机中,增量更新和原始快照这两种解决方案都有实际应用,譬如,CMS是基于增量更新来做并发标记的,G1、Shenandoah则是用原始快照来实现。 二、如何回收
分代收集工作原理 看完这篇垃圾回收,和面试官扯皮没问题了
分代收集理论 当前商业虚拟机的垃圾收集器,大多数都遵循了“分代收集”(Generational Collection)的理论进行设计,分代收集名为理论,实质是一套符合大多数程序运行实际情况的经验法则,它建立在两个分代假说之上: 1)弱分代假说(Weak Generational Hypothesis):绝大多数对象都是朝生夕灭的。 2)强分代假说(Strong Generational Hypothesis):熬过越多次垃圾收集过程的对象就越难以消亡。 这两个分代假说共同奠定了多款常用的垃圾收集器的一致的设计原则:收集器应该将Java堆划分出不同的区域,然后将回收对象依据其年龄(年龄即对象熬过垃圾收集过程的次数)分配到不同的区域之中存储。显而易见,如果一个区域中大多数对象都是朝生夕灭,难以熬过垃圾收集过程的话,那么把它们集中放在一起,每次回收时只关注如何保留少量存活而不是去标记那些大量将要被回收的对象,就能以较低代价回收到大量的空间;如果剩下的都是难以消亡的对象,那把它们集中放在一块,虚拟机便可以使用较低的频率来回收这个区域,这就同时兼顾了垃圾收集的时间开销和内存的空间有效利用。 在Java堆划分出不同的区域之后,垃圾收集器才可以每次只回收其中某一个或者某些部分的区域——因而才有了“Minor GC”“Major GC”“Full GC”这样的回收类型的划分;也才能够针对不同的区域安排与里面存储对象存亡特征相匹配的垃圾收集算法——因而发展出了“标记-复制算法”“标记-清除算法”“标记-整理算法”等针对性的垃圾收集算法。这里笔者提前提及了一些新的名词,它们都是本章的重要角色,稍后都会逐一登场,现在读者只需要知道,这一切的出现都始于分代收集理论。 把分代收集理论具体放到现在的商用Java虚拟机里,设计者一般至少会把Java堆划分为新生代(Young Generation)和老年代(Old Generation)两个区域。顾名思义,在新生代中,每次垃圾收集时都发现有大批对象死去,而每次回收后存活的少量对象,将会逐步晋升到老年代中存放。如果读者有兴趣阅读HotSpot虚拟机源码的话,会发现里面存在着一些名为“*Generation”的实现,如“DefNewGeneration”和“ParNewGeneration”等,这些就是HotSpot的“分代式垃圾收集器框架”。原本HotSpot鼓励开发者尽量在这个框架内开发新的垃圾收集器,但除了最早期的两组四款收集器之外,后来的开发者并没有继续遵循。导致此事的原因有很多,最根本的是分代收集理论仍在不断发展之中,如何实现也有许多细节可以改进,被既定的代码框架约束反而不便。其实我们只要仔细思考一下,也很容易发现分代收集并非只是简单划分一下内存区域那么容易,它至少存在一个明显的困难:对象不是孤立的,对象之间会存在跨代引用。 假如要现在进行一次只局限于新生代区域内的收集(Minor GC),但新生代中的对象是完全有可能被老年代所引用的,为了找出该区域中的存活对象,不得不在固定的GC Roots之外,再额外遍历整个老年代中所有对象来确保可达性分析结果的正确性,反过来也是一样。遍历整个老年代所有对象的方案虽然理论上可行,但无疑会为内存回收带来很大的性能负担。为了解决这个问题,就需要对分代收集理论添加第三条经验法则: 3)跨代引用假说(Intergenerational Reference Hypothesis):跨代引用相对于同代引用来说仅占极少数。 这其实是可根据前两条假说逻辑推理得出的隐含推论:存在互相引用关系的两个对象,是应该倾向于同时生存或者同时消亡的。举个例子,如果某个新生代对象存在跨代引用,由于老年代对象难以消亡,该引用会使得新生代对象在收集时同样得以存活,进而在年龄增长之后晋升到老年代中,这时跨代引用也随即被消除了。 依据这条假说,我们就不应再为了少量的跨代引用去扫描整个老年代,也不必浪费空间专门记录每一个对象是否存在及存在哪些跨代引用,只需在新生代上建立一个全局的数据结构(该结构被称为“记忆集”,Remembered Set),这个结构 把老年代划分成若干小块,标识出老年代的哪一块内存会存在跨代引用。此后当发生Minor GC时,只有包含了跨代引用的小块内存里的对象才会被加入到GC Roots进行扫描。虽然这种方法需要在对象改变引用关系(如将自己或者某个属性赋值)时维护记录数据的正确性,会增加一些运行时的开销,但比起收集时扫描整个老年代来说仍然是划算的。 注意 刚才我们已经提到了“Minor GC”,后续文中还会出现其他针对不同分代的类似名词,为避免读者产生混淆,在这里统一定义: · 部分收集(Partial GC):指目标不是完整收集整个Java堆的垃圾收集,其中又分为: ■ 新生代收集(Minor GC/Young GC):指目标只是新生代的垃圾收集。 ■ 老年代收集(Major GC/Old GC):指目标只是老年代的垃圾收集。 目前只有CMS收集器会有单独收集老年代的行为。另外请注意“Major GC”这个说法现在有点混淆,在不同资料上常有不同所指,读者需按上下文区分到底是指老年代的收集还是整堆收集。 ■ 混合收集(Mixed GC):指目标是收集整个新生代以及部分老年代的垃圾收集。目前只有G1收集器会有这种行为。 · 整堆收集(Full GC):收集整个Java堆和方法区的垃圾收集 1.分代回收 我们从一个object1来说明其在分代垃圾回收算法中的回收轨迹。 1、object1新建,出生于新生代的Eden区域。 2、Eden区满触发minor GC,object1 还存活,移动到Fromsuvivor空间,此时还在新生代。 3、Eden区满触发minor GC,object1 仍然存活,此时会通过复制算法,将object1移动到ToSuv区域,此时object1的年龄age+1。 4、Eden区满触发minor GC,object1 仍然存活,此时survivor中和object1同龄的对象并没有达到survivor的一半,所以此时通过复制算法,将fromSuv和Tosuv 区域进行互换,存活的对象被移动到了Tosuv。 5、Eden区满触发minor GC,object1 仍然存活,此时survivor中和object1同龄的对象已经达到survivor的一半以上(toSuv的区域已经满了),object1被移动到了老年代区域。 6、object1存活一段时间后,发现此时object1不可达GcRoots,而且此时老年代空间比率已经超过了阈值,触发了majorGC(也可以认为是fullGC,但具体需要垃圾收集器来联系),此时object1被回收了。fullGC会触发 stop the world。 1、对象在新生代的分配与回收 由以上的分析可知,大部分对象在很短的时间内都会被回收,对象一般分配在 Eden 区 当 Eden 区将满时,触发 Minor GC 我们之前怎么说来着,大部分对象在短时间内都会被回收, 所以经过 Minor GC 后只有少部分对象会存活,它们会被移到 S0 区(这就是为啥空间大小 Eden: S0: S1 = 8:1:1, Eden 区远大于 S0,S1 的原因,因为在 Eden 区触发的 Minor GC 把大部对象(接近98%)都回收了,只留下少量存活的对象,此时把它们移到 S0 或 S1 绰绰有余)同时对象年龄加一(对象的年龄即发生 Minor GC 的次数),最后把 Eden 区对象全部清理以释放出空间,动图如下 当触发下一次 Minor GC 时,会把 Eden 区的存活对象和 S0(或S1) 中的存活对象(S0 或 S1 中的存活对象经过每次 Minor GC 都可能被回收)一起移到 S1(Eden 和 S0 的存活对象年龄+1), 同时清空 Eden 和 S0 的空间。 若再触发下一次 Minor GC,则重复上一步,只不过此时变成了 从 Eden,S1 区将存活对象复制到 S0 区,每次垃圾回收, S0, S1 角色互换,都是从 Eden ,S0(或S1) 将存活对象移动到 S1(或S0)。也就是说在 Eden 区的垃圾回收我们采用的是 复制算法 ,因为在 Eden 区分配的对象大部分在 Minor GC 后都消亡了,只剩下极少部分存活对象(这也是为啥 Eden:S0:S1 默认为 8:1:1 的原因),S0,S1 区域也比较小,所以最大限度地降低了复制算法造成的对象频繁拷贝带来的开销。 2、对象何时晋升老年代 对象什么时候会被分配到老年代 在以上的新生代中,我们有提到对象的age,对象存活于survivor状态下,不会立即晋升为老生代对象,以避免给老生代造成过大的影响,它们必须要满足以下条件才可以晋升: 1、长期存活对象进入老年代:minor gc 之后,存活于survivor 区域的对象的age会+1,超过(-XX:MaxTenuringThreshold的时候,转移到老年代。 2、动态计算:如果survivor空间中相同年龄所有的对象大小的总和大于survivor空间的(- XX:TargetSurvivorRatio默认50%)一半,年龄大于或等于该年龄的对象就可以直接进入老年代,无须等到-XX:MaxTenuringThreshold中要求的年龄。 3、大量连续内存空间的Java对象(例如大数组)被分配到老年代。 -XX:PretenureSizeThreshold= (字节数)来代表当对象超过多大时就不在新生代分配,而是直接在老年代上分配内存空间。 -XX:PretenureSizeThreshold参数只对Serial和ParNew两款新生代收集器有效,HotSpot的其他新生代收集器,如Parallel Scavenge并不支持这个参数。如果必须使用此参数进行调优,可考虑ParNew加CMS的收集器组合。- 当对象的年龄达到了我们设定的阈值,则会从S0(或S1)晋升到老年代 如图示:年龄阈值设置为 15, 当发生下一次 Minor GC 时,S0 中有个对象年龄达到 15,达到我们的设定阈值,晋升到老年代!
- 大对象 当某个对象分配需要大量的连续内存时,此时对象的创建不会分配在 Eden 区,会直接分配在老年代,因为如果把大对象分配在 Eden 区, Minor GC 后再移动到 S0,S1 会有很大的开销(对象比较大,复制会比较慢,也占空间),也很快会占满 S0,S1 区,所以干脆就直接移到老年代.
- 还有一种情况也会让对象晋升到老年代,即在 S0(或S1) 区相同年龄的对象大小之和大于 S0(或S1)空间一半以上时,则年龄大于等于该年龄的对象也会晋升到老年代。
- 循环的末尾
- 方法返回前
- 调用方法的 call 之后
- 抛出异常的位置 另外需要注意的是由于新生代的特点(大部分对象经过 Minor GC后会消亡), Minor GC 用的是复制算法,而在老生代由于对象比较多,占用的空间较大,使用复制算法会有较大开销(复制算法在对象存活率较高时要进行多次复制操作,同时浪费一半空间)所以根据老生代特点,在老年代进行的 GC 一般采用的是标记整理法来进行回收。
并行和并发
- 并行(Parallel) :指多条垃圾收集线程并行工作,但此时用户线程仍然处于等待状态。
- 并发(Concurrent) :指用户线程与垃圾收集线程同时执行(但不一定是并行的,可能会交替执行),用户程序在继续运行。而垃圾收集程序运行在另一个CPU上。
吞吐量(Throughput)
吞吐量就是 CPU用于运行用户代码的时间与 CPU总消耗时间的比值,即 吞吐量 = 运行用户代码时间 /(运行用户代码时间 + 垃圾收集时间)。假设虚拟机总共运行了100分钟,其中垃圾收集花掉1分钟,那吞吐量就是99%。 垃圾收集器指标 停顿时间是指进行垃圾收集时,用户线程的暂停时间 吞吐量是指用于执行用户线程的时间占总应用时间的比率,对于无需和用户进行交互的纯后台应用来说,停顿时间没那么重要,更看重的是吞吐量的大小,吞吐量越大,说明执行用户线程的时间更长,处理速度就越高。 大多数情况下对 Java 程序进行 GC 调优,主要关注两个目标:- 响应速度(Responsiveness ) : 响应速度指程序或系统对一个请求的响应有多迅速。 比如,用户订单查询响应时间,对响应速度要求很高的系统,较大的停顿时间是不可接受的。调优的重点是在短的时间内快速响应。
- 吞吐量(Throughput ) : 吞吐量关注在一个特定时间段内应用系统的最大工作量。 例如每小时批处理系统能完成的任务数量,在吞吐量方面优化的系统,较长的 GC 停顿时间也是可以接受的,因为高吞吐量应用更关心的是如何尽可能快地完成整个任务,不考虑快速响应用户请求。
| 新生代GC策略 | 年老代GC策略 | 说明 | |
| 组合1 | Serial | Serial Old | Serial和Serial Old都是单线程进行GC,特点就是GC时暂停所有应用线程。 |
| 组合2 | Serial | CMS+Serial Old | CMS(Concurrent Mark Sweep)是并发GC,实现GC线程和应用线程并发工作,不需要暂停所有应用线程。另外,当CMS进行GC失败时,会自动使用Serial Old策略进行GC。 |
| 组合3 | ParNew | CMS | 使用-XX:+UseParNewGC选项来开启。ParNew是Serial的并行版本,可以指定GC线程数,默认GC线程数为CPU的数量。可以使用-XX:ParallelGCThreads选项指定GC的线程数。 如果指定了选项-XX:+UseConcMarkSweepGC选项,则新生代默认使用ParNew GC策略。 |
| 组合4 | ParNew | Serial Old | 使用-XX:+UseParNewGC选项来开启。新生代使用ParNew GC策略,年老代默认使用Serial Old GC策略。 |
| 组合5 | Parallel Scavenge | Serial Old | Parallel Scavenge策略主要是关注一个可控的吞吐量:应用程序运行时间 / (应用程序运行时间 + GC时间),可见这会使得CPU的利用率尽可能的高,适用于后台持久运行的应用程序,而不适用于交互较多的应用程序。 |
| 组合6 | Parallel Scavenge | Parallel Old | Parallel Old是Serial Old的并行版本 |
| 组合7 | G1GC | G1GC | -XX:+UnlockExperimentalVMOptions -XX:+UseG1GC #开启 -XX:MaxGCPauseMillis =50 #暂停时间目标 -XX:GCPauseIntervalMillis =200 #暂停间隔目标 -XX:+G1YoungGenSize=512m #年轻代大小 -XX:SurvivorRatio=6 #幸存区比例 |
| GC组合 | Minor GC | Full GC | 描述 |
| -XX:+UseSerialGC | Serial收集器串行回收 | Serial Old收集器串行回收 | 该选项可以手动指定Serial收集器+Serial Old收集器组合执行内存回收 |
| -XX:+UseParNewGC | ParNew收集器并行回收 | Serial Old收集器串行回收 | 该选项可以手动指定ParNew收集器+Serilal Old组合执行内存回收 |
| -XX:+UseParallelGC | Parallel收集器并行回收 | Serial Old收集器串行回收 | 该选项可以手动指定Parallel收集器+Serial Old收集器组合执行内存回收 |
| -XX:+UseParallelOldGC | Parallel收集器并行回收 | Parallel Old收集器并行回收 | 该选项可以手动指定Parallel收集器+Parallel Old收集器组合执行内存回收 |
| -XX:+UseConcMarkSweepGC | ParNew收集器并行回收 | 缺省使用CMS收集器并发回收,备用采用Serial Old收集器串行回收 | 该选项可以手动指定ParNew收集器+CMS收集器+Serial Old收集器组合执行内存回收。优先使用ParNew收集器+CMS收集器的组合,当出现ConcurrentMode Fail或者Promotion Failed时,则采用ParNew收集器+Serial Old收集器的组合 |
| -XX:+UseConcMarkSweepGC-XX:-UseParNewGC | Serial收集器串行回收 | ||
| -XX:+UseG1GC | G1收集器并发、并行执行内存回收 | 暂无 | |
- 初始标记(CMS initial mark) :仅仅只是标记一下GC Roots能直接关联到的对象,速度很快,需要“ Stop The World ”。
- 并发标记(CMS concurrent mark) :进行 GC Roots Tracing 的过程,在整个过程中耗时最长。本质是在堆上执行三色标记法。
- 预清理 : (CMS concurrent preclean) 清理前准备及控制停顿时间。可以通过 -XX:-CMSPrecleaningEnabled 不进行预清理。预清理是并发的,除了为正式清理做准备和检查,还会尝试控制一次停顿时间。由于重新标记是独占CPU的,如果新生代GC发生后,立即触发一次重新标记,那么一次停顿的时间可能很长。为了避免这种情况,预处理时会刻意等待一次新生代GC的发生,然后根据历史新能数据预测下一次新生代GC可能发生的时间,在当前时间和预测时间的中间时刻进行重新标记。这样可尽量避免新生代GC和重新标记重合,尽可能减少一次停顿的时间。
- 重新标记(CMS remark) :为了修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间一般会比初始标记阶段稍长一些,但远比并发标记的时间短。此阶段也需要“ Stop The World ”。使用卡表来修正可能会在并发标记阶段受Mutator线程(用户线程)影响的标记。
- 并发清除(CMS concurrent sweep):垃圾清理
- 并发重置 (CMS concurrent reset):垃圾回收完成后,重新初始化CMS数据结构和数据,为下一次垃圾回收做好准备。
- 对CPU资源非常敏感 其实,面向并发设计的程序都对CPU资源比较敏感。在并发阶段,它虽然不会导致用户线程停顿,但会因为占用了一部分线程(或者说CPU资源)而导致应用程序变慢,总吞吐量会降低。
- CMS默认启动的并发线程数是(ParallelGCThreads+3)/4 ,ParallelGCThreads表示GC并行时使用的线程数量,如果新生代使用ParNew,那么ParallelGCThreads也就是新生代GC的线程数量。这意味着有4个ParallelGCThreads时,只有1个并发线程,而有两个并发线程时,有5~8个ParallelGCThreads线程。 并发线程数也可以通过-XX:ConsGCThreads或者-XX:ParallelCMSThreads参数手工指定。
- 无法处理浮动垃圾(Floating Garbage) 可能出现“Concurrent Mode Failure”失败而导致另一次Full GC的产生。
- 由于CMS并发清理阶段用户线程还在运行着,伴随程序运行自然就还会有新的垃圾不断产生。 这一部分垃圾出现在标记过程之后,CMS无法再当次收集中处理掉它们,只好留待下一次GC时再清理掉。 这一部分垃圾就被称为 “浮动垃圾” 。也是由于在垃圾收集阶段用户线程还需要运行,那也就还需要预留有足够的内存空间给用户线程使用,因此CMS收集器不能像其他收集器那样等到老年代几乎完全被填满了再进行收集,需要预留一部分空间提供并发收集时的程序运作使用。 JDK 1.5 默认当老年代使用了68%空间后就会被激活,当然这个比例可以通过 -XX:CMSInitiatingOccupancyFraction 来设置,但是如果设置地太高很容易导致在 CMS 运行期间预留的内存无法满足程序要求,会导致 Concurrent Mode Failure 失败,这时会启用 Serial Old 收集器来重新进行老年代的收集,而我们知道 Serial Old 收集器是单线程收集器,这样就会导致 STW 更长了。
- 标记-清除算法导致的空间碎片 CMS是一款基于“标记-清除”算法实现的收集器,这意味着收集结束时会有 大量空间碎片产生 。 空间碎片过多时,将会给大对象分配带来很大麻烦,往往出现老年代空间剩余,但无法找到足够大连续空间来分配当前对象 ,将会触发 Full GC,这会影响应用的性能。当然我们可以开启 -XX:+UseCMSCompactAtFullCollection(默认是开启的),用于在 CMS 收集器顶不住要进行 FullGC 时开启内存碎片的合并整理过程,内存整理会导致 STW,停顿时间会变长,还可以用另一个参数 -XX:CMSFullGCsBeforeCompation 用来设置执行多少次不压缩的 Full GC 后跟着带来一次带压缩的。
- 达到循环次数
- 达到循环执行时间阈值
- 新生代内存使用率达到阈值
- 遍历新生代对象,重新标记
- 根据 GC Roots,重新标记
- 遍历老年代的 Dirty Card,重新标记
①最终标记阶段停顿时间过长问题
CMS 的 GC 停顿时间约 80% 都在最终标记阶段(Final Remark ) ,若该阶段停顿时间过长,常见原因是新生代对老年代的无效引用,在上一阶段的并发可取消预清理阶段中,执行阈值时间内未完成循环,来不及触发 Young GC,清理这些无效引用。 通过添加参数: -XX:+CMSScavengeBeforeRemark。 在执行最终操作之前先触发 Young GC,从而减少新生代对老年代的无效引用,降低最终标记阶段的停顿。 但如果在上个阶段(并发可取消的预清理 ) 已触发 Young GC,也会重复触发 Young GC。②并发模式失败(concurrent mode failure)&晋升失败(promotion failed)问题。 都会触发单线程(也有的书说是ParallelOld)FullGC。
并发模式失败: 当 CMS 在执行回收时,新生代发生垃圾回收,同时老年代又没有足够的空间容纳晋升的对象时,CMS 垃圾回收就会退化成单线程 ( 也有的书说是ParallelOld ) 的 Full GC。 所有的应用线程都会被暂停 ,老年代中所有的无效对象都被回收。 晋升失败: 当新生代发生垃圾回收,老年代 有足够的空间 可以容纳晋升的对象,但是由于 空闲空间的碎片化 ,导致晋升失败,此时会触发单线程( 也有的书说是ParallelOld )且带压缩动作的 Full GC。 并发模式失败和晋升失败都会导致长时间的停顿,常见解决思路如下:- 降低触发 CMS GC 的阈值。 即参数 -XX:CMSInitiatingOccupancyFraction 的值,让 CMS GC 尽早执行,以保证有足够的空间。
- 增加 CMS 线程数,即参数 -XX:ConcGCThreads。 问题:是哪个阶段的线程数?
- 增大老年代空间。
- 让对象尽量在新生代回收,避免进入老年代。
③内存碎片问题
通常 CMS 的 GC 过程基于标记清除算法,不带压缩动作,导致越来越多的内存碎片需要压缩。 常见以下场景会触发内存碎片压缩:- 新生代 Young GC 出现新生代晋升担保失败(promotion failed) )
- 程序主动执行System.gc()
- 并行与并发 G1 能充分利用多CPU、多核环境下的硬件优势,使用多个CPU来缩短“Stop The World”停顿时间,部分其他收集器原本需要停顿Java线程执行的GC动作,G1收集器仍然可以通过并发的方式让Java程序继续执行。
- 分代收集 与其他收集器一样,分代概念在G1中依然得以保留。虽然G1可以不需要其他收集器配合就能独立管理整个GC堆,但它能够采用不同方式去处理新创建的对象和已存活一段时间、熬过多次GC的旧对象来获取更好的收集效果。
- 空间整合 G1从整体来看是基于 “标记-整理” 算法实现的收集器,从局部(两个Region之间)上来看是基于 “复制” 算法实现的。这意味着G1运行期间 不会产生内存空间碎片 ,收集后能提供规整的可用内存。此特性有利于程序长时间运行,分配大对象时不会因为无法找到连续内存空间而提前触发下一次GC。
- 可预测的停顿 这是G1相对CMS的一大优势,降低停顿时间是G1和CMS共同的关注点,但G1除了降低停顿外,还能建立可预测的停顿时间模型,能让使用者明确指定在一个长度为M毫秒的时间片段内,消耗在GC上的时间不得超过N毫秒,这几乎已经是实时Java(RTSJ)的垃圾收集器的特征了。
- 每次 GC 不必都去处理整个堆空间,而是每次只处理一部分 Region,实现大容量内存的 GC。
- 通过计算每个 Region 的回收价值,包括回收所需时间、可回收空间,在有限时间内尽可能回收更多的垃圾对象,把垃圾回收造成的停顿时间控制在预期配置的时间范围内,这也是 G1 名称的由来:Garbage-First。
- 整理更新每个 Region 各自的 RSet(Remember Set,HashMap 结构,记录有哪些老年代对象指向本 Region,key 为指向本 Region 的对象的引用,value 为指向本 Region 的具体 Card 区域,通过 RSet 可以确定 Region 中对象存活信息,避免全堆扫描 ) 。
- 回收不包含存活对象的 Region。
- 统计计算回收收益高(基于释放空间和暂停目标)的老年代分区集合。
①Full GC 问题
G1 的正常处理流程中没有 Full GC,只有在垃圾回收处理不过来(或者主动触发 ) 时才会出现,G1 的 Full GC 就是单线程执行的 Serial old gc,会导致非常长的 STW,是调优的重点,需要尽量避免 Full GC。 常见原因如下:- 程序主动执行 System.gc()
- 全局并发标记期间老年代空间被填满(并发模式失败)
- Mixed GC 期间老年代空间被填满(晋升失败)
- Young GC 时 Survivor 空间和老年代没有足够空间容纳存活对象
- 增大 -XX:ConcGCThreads=n 选项增加并发标记线程的数量,或者 STW 期间并行线程的数量:-XX:ParallelGCThreads=n。
- 减小 -XX:InitiatingHeapOccupancyPercent 提前启动标记周期。
- 增大预留内存 -XX:G1ReservePercent=n,默认值是 10,代表使用 10% 的堆内存为预留内存,当 Survivor 区域没有足够空间容纳新晋升对象时会尝试使用预留内存。
②巨型对象分配
巨型对象区中的每个 Region 中包含一个巨型对象,剩余空间不再利用,导致空间碎片化,当 G1 没有合适空间分配巨型对象时,G1 会启动串行 Full GC 来释放空间。 可以通过增加 -XX:G1HeapRegionSize 来增大 Region 大小,这样一来,相当一部分的巨型对象就不再是巨型对象了,而是采用普通的分配方式。③不要设置 Young 区的大小
原因是为了尽量满足目标停顿时间,逻辑上的 Young 区会进行动态调整。如果设置了大小,则会覆盖掉并且会禁用掉对停顿时间的控制。④平均响应时间设置
使用应用的平均响应时间作为参考来设置 MaxGCPauseMillis,JVM 会尽量去满足该条件,可能是 90% 的请求或者更多的响应时间在这之内, 但是并不代表是所有的请求都能满足,平均响应时间设置过小会导致频繁 GC。 -XX:GCTimeRatio=n -XX:MaxGCPauseMillis=n 用于指定目标最大停顿时间。如果任何一次停顿超过这个设置值,G1就会尝试调整新生代和老年代的比例、调整堆大小、调整晋升年龄等,试图达到预设目标。对于性能调优来说,有事总是鱼和熊掌不可兼得,如果停顿时间缩短,对于新生代来说,这意味着很可能要增加新生代GC的次数。对于老年代来说,为了获得更短的停顿时间,在混合GC时,一次收集的区域数量也会表笑,这样无疑增加了进行Full GC的可能性。 -XX:ParallelGCThreads 设置并行回收时GC的工作线程数量。 -XX:InitiatingHeapOccupancyPercent指定当整个堆使用率达到多少时,触发并发标记周期的执行。默认是45,即当整个堆的占用率达到45%时,执行并发标记周期。InitiatingHeapOccupancyPercent一旦设置,始终都不会被G1修改,这意味着G1不会视图改变这个值来满足MaxGCPauseMillis的目标。如果InitiatingHeapOccupancePercent值设置得偏大,会导致并发周期迟迟得不到启动,那么引起Full GC的可能性也大大增加,反之,一个过小的InitiatingHeapOccupancyPercent值会使得并发标记周期执行的非常频繁,大量GC线程抢占CPU,导致应用程序的性能有所下降。 G1支持字符串去重 字符串去重涉及的参数有: ·参数UseStringDeduplication,默认值为false,打开参数表示允许字符串去重。 ·参数StringDeduplicationAgeThreshold,默认值为3,控制字符串是否参与去重的阈值。 Shenandoah Shenandoah作为第一款不由Oracle(包括以前的Sun)公司的虚拟机团队所领导开发的HotSpot垃圾收集器,不可避免地会受到一些来自“官方”的排挤。在笔者撰写这部分内容时,Oracle仍明确拒绝在OracleJDK 12中支持Shenandoah收集器,并执意在打包OracleJDK时通过条件编译完全排除掉了Shenandoah的代码,换句话说,Shenandoah是一款只有OpenJDK才会包含,而OracleJDK里反而不存在的收集器,“免费开源版”比“收费商业版”功能更多,这是相对罕见的状况。如果读者的项目要求用到Oracle商业支持的话,就不得不把Shenandoah排除在选择范围之外了。 最初Shenandoah是由RedHat公司独立发展的新型收集器项目,在2014年RedHat把Shenandoah贡献给了OpenJDK,并推动它成为OpenJDK 12的正式特性之一,也就是后来的JEP 189。这个项目的目标是实现一种能在任何堆内存大小下都可以把垃圾收集的停顿时间限制在十毫秒以内的垃圾收集器,该目标意味着相比CMS和G1,Shenandoah不仅要进行并发的垃圾标记,还要并发地进行对象清理后的整理动作。 从代码历史渊源上讲,比起稍后要介绍的有着Oracle正朔血统的ZGC,Shenandoah反而更像是G1的下一代继承者,它们两者有着相似的堆内存布局,在初始标记、并发标记等许多阶段的处理思路上都高度一致,甚至还直接共享了一部分实现代码,这使得部分对G1的打磨改进和Bug修改会同时反映在Shenandoah之上,而由于Shenandoah加入所带来的一些新特性,也有部分会出现在G1收集器中,譬如在并发失败后作为“逃生门”的Full GC,G1就是由于合并了Shenandoah的代码才获得多线程Full GC的支持。 那Shenandoah相比起G1又有什么改进呢?虽然Shenandoah也是使用基于Region的堆内存布局,同样有着用于存放大对象的Humongous Region,默认的回收策略也同样是优先处理回收价值最大的Region……但在管理堆内存方面,它与G1至少有三个明显的不同之处,最重要的当然是支持并发的整理算法,G1的回收阶段是可以多线程并行的,但却不能与用户线程并发,这点作为Shenandoah最核心的功能稍后笔者会着重讲解。其次,Shenandoah(目前)是默认不使用分代收集的,换言之,不会有专门的新生代Region或者老年代Region的存在,没有实现分代,并不是说分代对Shenandoah没有价值,这更多是出于性价比的权衡,基于工作量上的考虑而将其放到优先级较低的位置上。最后,Shenandoah摒弃了在G1中耗费大量内存和计算资源去维护的记忆集,改用名为“连接矩阵”(Connection Matrix)的全局数据结构来记录跨Region的引用关系,降低了处理跨代指针时的记忆集维护消耗,也降低了伪共享问题(见3.4.4节)的发生概率。连接矩阵可以简单理解为一张二维表格,如果Region N有对象指向Region M,就在表格的N行M列中打上一个标记,如图3-15所示,如果Region 5中的对象Baz引用了Region 3的Foo,Foo又引用了Region 1的Bar,那连接矩阵中的5行3列、3行1列就应该被打上标记。在回收时通过这张表格就可以得出哪些Region之间产生了跨代引用。 Shenandoah收集器的工作过程大致可以划分为以下九个阶段(此处以Shenandoah在2016年发表的原始论文进行介绍。在最新版本的Shenandoah 2.0中,进一步强化了“部分收集”的特性,初始标记之前还有Initial Partial、Concurrent Partial和Final Partial阶段,它们可以不太严谨地理解为对应于以前分代收集中的Minor GC的工作): · 初始标记 (Initial Marking):与G1一样,首先标记与GC Roots直接关联的对象,这个阶段仍是“Stop The World”的,但停顿时间与堆大小无关,只与GC Roots的数量相关。 · 并发标记 (Concurrent Marking):与G1一样,遍历对象图,标记出全部可达的对象,这个阶段是与用户线程一起并发的,时间长短取决于堆中存活对象的数量以及对象图的结构复杂程度。 · 最终标记 (Final Marking):与G1一样,处理剩余的SATB扫描,并在这个阶段统计出回收价值最高的Region,将这些Region构成一组回收集(Collection Set)。最终标记阶段也会有一小段短暂的停顿。 · 并发清理 (Concurrent Cleanup):这个阶段用于清理那些整个区域内连一个存活对象都没有找到的Region(这类Region被称为Immediate Garbage Region)。 · 并发回收 (Concurrent Evacuation):并发回收阶段是Shenandoah与之前HotSpot中其他收集器的核心差异。在这个阶段,Shenandoah要把回收集里面的存活对象先复制一份到其他未被使用的Region之中。复制对象这件事情如果将用户线程冻结起来再做那是相当简单的,但如果两者必须要同时并发进行的话,就变得复杂起来了。其困难点是在移动对象的同时,用户线程仍然可能不停对被移动的对象进行读写访问,移动对象是一次性的行为,但移动之后整个内存中所有指向该对象的引用都还是旧对象的地址,这是很难一瞬间全部改变过来的。对于并发回收阶段遇到的这些困难,Shenandoah将会通过读屏障和被称为“Brooks Pointers”的转发指针来解决(讲解完Shenandoah整个工作过程之后笔者还要再回头介绍它)。并发回收阶段运行的时间长短取决于回收集的大小。 · 初始引用更新 (Initial Update Reference):并发回收阶段复制对象结束后,还需要把堆中所有指向旧对象的引用修正到复制后的新地址,这个操作称为引用更新。引用更新的初始化阶段实际上并未做什么具体的处理,设立这个阶段只是为了建立一个线程集合点,确保所有并发回收阶段中进行的收集器线程都已完成分配给它们的对象移动任务而已。初始引用更新时间很短,会产生一个非常短暂的停顿。 · 并发引用更新 (Concurrent Update Reference):真正开始进行引用更新操作,这个阶段是与用户线程一起并发的,时间长短取决于内存中涉及的引用数量的多少。并发引用更新与并发标记不同,它不再需要沿着对象图来搜索,只需要按照内存物理地址的顺序,线性地搜索出引用类型,把旧值改为新值即可。 · 最终引用更新 (Final Update Reference):解决了堆中的引用更新后,还要修正存在于GC Roots中的引用。这个阶段是Shenandoah的最后一次停顿,停顿时间只与GC Roots的数量相关。 · 并发清理 (Concurrent Cleanup):经过并发回收和引用更新之后,整个回收集中所有的Region已再无存活对象,这些Region都变成Immediate Garbage Regions了,最后再调用一次并发清理过程来回收这些Region的内存空间,供以后新对象分配使用。 以上对Shenandoah收集器这九个阶段的工作过程的描述可能拆分得略为琐碎,读者只要抓住其中三个最重要的并发阶段(并发标记、并发回收、并发引用更新),就能比较容易理清Shenandoah是如何运作的了。图3-16中黄色的区域代表的是被选入回收集的Region,绿色部分就代表还存活的对象,蓝色就是用户线程可以用来分配对象的内存Region了。图3-16中不仅展示了Shenandoah三个并发阶段的工作过程,还能形象地表示出并发标记阶段如何找出回收对象确定回收集,并发回收阶段如何移动回收集中的存活对象,并发引用更新阶段如何将指向回收集中存活对象的所有引用全部修正,此后回收集便不存在任何引用可达的存活对象了。 学习了Shenandoah收集器的工作过程,我们再来聊一下Shenandoah用以支持并行整理的核心概念——Brooks Pointer。“Brooks”是一个人的名字。1984年,Rodney A.Brooks在论文《Trading Data Space for Reduced Time and Code Space in Real-Time Garbage Collection on Stock Hardware》中提出了使用转发指针(Forwarding Pointer,也常被称为Indirection Pointer)来实现对象移动与用户程序并发的一种解决方案。此前,要做类似的并发操作,通常是在被移动对象原有的内存上设置保护陷阱(Memory Protection Trap),一旦用户程序访问到归属于旧对象的内存空间就会产生自陷中段,进入预设好的异常处理器中,再由其中的代码逻辑把访问转发到复制后的新对象上。虽然确实能够实现对象移动与用户线程并发,但是如果没有操作系统层面的直接支持,这种方案将导致用户态频繁切换到核心态,代价是非常大的,不能频繁使用。 Brooks提出的新方案不需要用到内存保护陷阱,而是在原有对象布局结构的最前面统一增加一个新的引用字段,在正常不处于并发移动的情况下,该引用指向对象自己,如图3-17所示。 从结构上来看,Brooks提出的转发指针与某些早期Java虚拟机使用过的句柄定位(关于对象定位详见第2章)有一些相似之处,两者都是一种间接性的对象访问方式,差别是句柄通常会统一存储在专门的句柄池中,而转发指针是分散存放在每一个对象头前面。 有了转发指针之后,有何收益暂且不论,所有间接对象访问技术的缺点都是相同的,也是非常显著的——每次对象访问会带来一次额外的转向开销,尽管这个开销已经被优化到只有一行汇编指令的程度,譬如以下所示: mov r13,QWORD PTR [r12+r14*8-0x8] 不过,毕竟对象定位会被频繁使用到,这仍是一笔不可忽视的执行成本,只是它比起内存保护陷阱的方案已经好了很多。转发指针加入后带来的收益自然是当对象拥有了一份新的副本时,只需要修改一处指针的值,即旧对象上转发指针的引用位置,使其指向新对象,便可将所有对该对象的访问转发到新的副本上。这样只要旧对象的内存仍然存在,未被清理掉,虚拟机内存中所有通过旧引用地址访问的代码便仍然可用,都会被自动转发到新对象上继续工作,如图3-18所示。 需要注意,Brooks形式的转发指针在设计上决定了它是必然会出现多线程竞争问题的,如果收集器线程与用户线程发生的只是并发读取,那无论读到旧对象还是新对象上的字段,返回的结果都应该是一样的,这个场景还可以有一些“偷懒”的处理余地;但如果发生的是并发写入,就一定必须保证写操作只能发生在新复制的对象上,而不是写入旧对象的内存中。读者不妨设想以下三件事情并发进行时的场景: 1)收集器线程复制了新的对象副本; 2)用户线程更新对象的某个字段; 3)收集器线程更新转发指针的引用值为新副本地址。 如果不做任何保护措施,让事件2在事件1、事件3之间发生的话,将导致的结果就是用户线程对对象的变更发生在旧对象上,所以这里必须针对转发指针的访问操作采取同步措施,让收集器线程或者用户线程对转发指针的访问只有其中之一能够成功,另外一个必须等待,避免两者交替进行。实际上Shenandoah收集器是通过比较并交换(Compare And Swap,CAS)操作来保证并发时对象的访问正确性的。 转发指针另一点必须注意的是执行频率的问题,尽管通过对象头上的Brooks Pointer来保证并发时原对象与复制对象的访问一致性,这件事情只从原理上看是不复杂的,但是“对象访问”这四个字的分量是非常重的,对于一门面向对象的编程语言来说,对象的读取、写入,对象的比较,为对象哈希值计算,用对象加锁等,这些操作都属于对象访问的范畴,它们在代码中比比皆是,要覆盖全部对象访问操作,Shenandoah不得不同时设置读、写屏障去拦截。 之前介绍其他收集器时,或者是用于维护卡表,或者是用于实现并发标记,写屏障已被使用多次,累积了不少的处理任务了,这些写屏障有相当一部分在Shenandoah收集器中依然要被使用到。除此以外,为了实现Brooks Pointer,Shenandoah在读、写屏障中都加入了额外的转发处理,尤其是使用读屏障的代价,这是比写屏障更大的。代码里对象读取的出现频率要比对象写入的频率高出很多,读屏障数量自然也要比写屏障多得多,所以读屏障的使用必须更加谨慎,不允许任何的重量级操作。Shenandoah是本书中第一款使用到读屏障的收集器,它的开发者也意识到数量庞大的读屏障带来的性能开销会是Shenandoah被诟病的关键点之一,所以计划在JDK 13中将Shenandoah的内存屏障模型改进为基于引用访问屏障(Load Reference Barrier)的实现,所谓“引用访问屏障”是指内存屏障只拦截对象中数据类型为引用类型的读写操作,而不去管原生数据类型等其他非引用字段的读写,这能够省去大量对原生类型、对象比较、对象加锁等场景中设置内存屏障所带来的消耗。 最后来谈谈Shenandoah在实际应用中的性能表现,Shenandoah的开发团队或者其他第三方测试者在网上都公布了一系列测试,结果各有差异。笔者在此选择展示了一份RedHat官方在2016年所发表的Shenandoah实现论文中给出的应用实测数据,测试内容是使用ElasticSearch对200GB的维基百科数据进行索引,如表3-2所示。从结果来看,应该说2016年做该测试时的Shenandoah并没有完全达成预定目标,停顿时间比其他几款收集器确实有了质的飞跃,但也并未实现最大停顿时间控制在十毫秒以内的目标,而吞吐量方面则出现了很明显的下降,其总运行时间是所有测试收集器中最长的。读者可以从这个官方的测试结果来对Shenandoah的弱项(高运行负担使得吞吐量下降)和强项(低延迟时间)建立量化的概念,并对比一下稍后介绍的ZGC的测试结果。 Shenandoah收集器作为第一款由非Oracle开发的垃圾收集器,一开始就预计到了缺乏Oracle公司那样富有经验的研发团队可能会遇到很多困难。所以Shenandoah采取了“小步快跑”的策略,将最终目标进行拆分,分别形成Shenandoah 1.0、2.0、3.0……这样的小版本计划,在每个版本中迭代改进,现在已经可以看到Shenandoah的性能在日益改善,逐步接近“Low-Pause”的目标。此外,RedHat也积极拓展Shenandoah的使用范围,将其Backport到JDK 11甚至是JDK 8之上,让更多不方便升级JDK版本的应用也能够享受到垃圾收集器技术发展的最前沿成果。 ZGC( JDK11 ) Java GC:干掉 CMS,ZGC 才是未来 新一代垃圾回收器ZGC的探索与实践 ZGC(The Z Garbage Collector)是 JDK 11中推出的一款低延迟垃圾回收器,它的设计目标包括:- 停顿时间不超过10ms;
- 停顿时间不会随着堆的大小,或者活跃对象的大小而增加;
- 支持8MB~4TB级别的堆(未来支持16TB)。
- FGC过于频繁 :FGC通常是比较慢的,少则几百毫秒,多则几秒,正常情况FGC每隔几个小时甚至几天才执行一次,对系统的影响还能接受。但是,一旦出现FGC频繁(比如几十分钟就会执行一次),这种肯定是存在问题的,它会导致工作线程 频繁被停止 ,让系统看起来一直有卡顿现象,也会使得程序的整体性能变差。
- YGC耗时过长 :一般来说,YGC的总耗时在 几十或者上百毫秒是比较正常的,虽然会引起系统卡顿 几毫秒 或者 几十 毫秒,这种情况 几乎对用户无感知, 对程序的影响可以忽略不计。 但是如果YGC耗时达到了1秒甚至几秒(都快赶上FGC的耗时了),那卡顿时间就会增大,加上YGC本身比较频繁,就会导致比较多的服务超时问题。
- FGC耗时过长 :FGC耗时增加,卡顿时间也会随之增加,尤其对于高并发服务,可能导致FGC期间比较多的超时问题,可用性降低,这种也需要关注。
- YGC过于频繁 :即使YGC不会引起服务超时,但是YGC过于频繁也会降低服务的整体性能, 对于高并发服务也是需要关注的。
"VM Periodic Task Thread"
该线程是JVM周期性任务调度的线程,它由WatcherThread创建,是一个单例对象。该线程在JVM内使用得比较频繁,比如:定期的内存监控、JVM运行状况监控。 "VM Periodic Task Thread" prio=10 tid=0x00007fbea82ae800 nid=0x5fa waiting on condition 可以使用jstat 命令查看GC的情况,比如查看某个进程没有存活必要的引用可以使用命令 jstat -gcutil <pid> 250 7 参数中pid是进程id,后面的250和7表示每250毫秒打印一次,总共打印7次。 这对于防止因为应用代码中直接使用native库或者第三方的一些监控工具的内存泄漏有非常大的帮助。"GC task thread#0 (ParallelGC)"
垃圾回收线程,该线程会负责进行垃圾回收。通常JVM会启动多个线程来处理这个工作,线程名称中#后面的数字也会累加。 "GC task thread#0 (ParallelGC)" prio=5 tid=0x00007fc6b480d000 nid=0x2503 runnable "GC task thread#1 (ParallelGC)" prio=5 tid=0x00007fc6b2812000 nid=0x2703 runnable "GC task thread#2 (ParallelGC)" prio=5 tid=0x00007fc6b2812800 nid=0x2903 runnable "GC task thread#3 (ParallelGC)" prio=5 tid=0x00007fc6b2813000 nid=0x2b03 runnable如果在JVM中增加了 -XX:+UseConcMarkSweepGC 参数将会启用CMS (Concurrent Mark-Sweep)GC Thread方式,以下是该模式下的线程类型:
"Gang worker#0 (Parallel GC Threads)"
原来垃圾回收线程GC task thread#0 (ParallelGC) 被替换为 Gang worker#0 (Parallel GC Threads)。Gang worker 是JVM用于年轻代垃圾回收(minor gc)的线程。 "Gang worker#0 (Parallel GC Threads)" prio=10 tid=0x00007fbea801b800 nid=0x5e4 runnable "Gang worker#1 (Parallel GC Threads)" prio=10 tid=0x00007fbea801d800 nid=0x5e7 runnable"Concurrent Mark-Sweep GC Thread"
并发标记清除垃圾回收器(就是通常所说的CMS GC)线程, 该线程主要针对于年老代垃圾回收。 "Concurrent Mark-Sweep GC Thread" prio=10 tid=0x00007fbea8073800 nid=0x5e8 runnable"Surrogate Locker Thread (Concurrent GC)"
此线程主要配合CMS垃圾回收器来使用,是一个守护线程,主要负责处理GC过程中Java层的Reference(指软引用、弱引用等等)与jvm 内部层面的对象状态同步。 "Surrogate Locker Thread (Concurrent GC)" daemon prio=10 tid=0x00007fbea8158800 nid=0x5ee waiting on condition [0x0000000000000000] java.lang.Thread.State: RUNNABLE 这里以 WeakHashMap 为例进行说明,首先是一个关键点:- WeakHashMap和HashMap一样,内部有一个Entry[]数组;
- WeakHashMap的Entry比较特殊,它的继承体系结构为Entry->WeakReference->Reference;
- Reference 里面有一个全局锁对象:Lock,它也被称为pending_lock,注意:它是静态对象;
- Reference 里面有一个静态变量:pending;
- Reference 里面有一个静态内部类:ReferenceHandler的线程,它在static块里面被初始化并且启动,启动完成后处于wait状态,它在一个Lock同步锁模块中等待;
- WeakHashMap里面还实例化了一个ReferenceQueue列队


发布评论