深入理解Java虚拟机学习笔记——二、垃圾收集器与内存分配策略

时间:2023-01-02 12:45:29

1、对象存活判定算法

  • 引用计数算法

给对象添加一个引用计数器,当有其他对象引用它时,计数器加1;当引用失效时,计数器减1。任何时刻计数器为0的对象就是不可能在被使用的。引用计数算法实现简单,判定效率也很高,但是很难解决对象间相互循环引用的问题。

  • 可达性分析算法

通过一系列被称为“GC Roots”的对象作为起点,从这些节点向下搜索,搜索所走过的路径被称为引用链,当一个对象到GC Roots没有任何引用链相连时,则证明此对象是不可用的。

深入理解Java虚拟机学习笔记——二、垃圾收集器与内存分配策略

如图所示,虽然object5、object6、object7虽然互相有关联,但是它们到GC Roots是不可达的,所以它们将会被判定为是可回收的对象。

无论是通过引用计数算法判断对象的引用数量,还是可达性分析算法判断对象的引用链是否可达,判定对象是否存活都与“引用”有关。引用可以分为强引用、软引用、弱引用和虚引用4种:

    • 强引用就是指在程序中普遍存在的,类似“Object obj = new Object()”这类的引用,只要强引用还在,垃圾收集器就永远不会回收掉被引用的对象。
    • 软引用是用来描述 一些还有用但非必需的对象。对于软引用关联着的对象,在系统将要发生内存溢出异常之前,将会把这些对象列进回收范围之中进行第二次回收。如果这次回收还是没有足够的内存,才会抛出内存溢出异常。
    • 弱引用也是用来描述非必需对象的,但是它的强度比软引用还要弱一些,被弱引用关联的对象只能生存到下一次垃圾收集发生之前。当垃圾收集器开始工作时,无论当前内存是否足够,都会回收掉只被弱引用关联的对象。
    • 虚引用也被称为幽灵引用或幻影引用,它是最弱的引用关系。一个对象是否有虚引用完成不会影响其生存周期,也无法通过虚引用来获取一个对象实例。

方法区的回收:方法区主要回收两部分,废弃常量和无用的类。

    • 废弃常量:常量池中的常量如果被没有任何一个对象引用,那么这个常量就叫做废弃常量。
    • 无用类:同时满足以下三个条件的类才会被称为无用类,被垃圾收集器回收:
①该类的所有实例都已被回收,也就是说Java堆中不存在该类的任何实例。

②加载该类的ClassLoader已经被回收。

③该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射机制访问该类的方法。

虚拟机可以对满足以上三个条件的无用类进行回收,注意是“可以”进行回收,而不是和对象一样,不使用了就必然被垃圾收集器所回收。

Hotspot提供了-Xnoclassgc参数进行控制。

2、垃圾收集算法

  • 标记—清除(Mark-Sweep)算法
该算法分为“标记”和“清除”两个阶段:首先标记出所需要回收的对象,在标记完成后统一回收所有被标记的对象。标记—清除算法是最基础的算法,后续的收集算法都是基于这种思路并对其不足进行改进而得到的。该收集算法有两个缺点:
① 标记和清除两个过程效率都不高。
② 标记清除过后,会产生大量的不连续的内存碎片。空间碎片太多可能会导致在以后程序运行过程中,当需要给较大对象分配内存时,无法找到足够大的连续内存而不得不提前触发另一次垃圾收集动作。
标记—清除算法的执行过程如下图所示:
深入理解Java虚拟机学习笔记——二、垃圾收集器与内存分配策略

  • 复制(Copying)算法
复制算法的出现为了解决效率问题。复制算法是将内存划分成两块大小相等的区域,每次只使用其中的一块区域。当一块内存区域用完后,就将该区域内还存活着的对象复制到另外一块区域内,然后将将已使用过的内存空间一次性全部清理掉。该算法同时解决了标记—清除算法会产生内存碎片的问题,缺点是可用内存只有原来的一半。复制算法的执行过程如下图所示:
深入理解Java虚拟机学习笔记——二、垃圾收集器与内存分配策略

Hotspot虚拟机采用复制算法来回收新生代,但是由于新生代中99%的对象都是“朝生夕死”,所以并不需要按照1:1的比例来划分内存空间。而是将内存空间划分成一块较大的Eden空间和两块叫小的Survivor空间,每次使用Eden和其中的一块Survivor。当进行垃圾回收时,将Eden和其中一块Survivor中还存活着的对象一次性复制到另一块未使用的Survisor中,最后清理掉Eden和已使用过的Survivor。Hotspot虚拟机默认Eden和Survivor的大小比例是8:1,也就是说新生代中可用内存空间占整个新生代容量的90%,只有10%的内存空间会被“浪费”。因为并无法保证Survivor空间足够使用,当Survivor空间不足时,就需要老年代来进行分配担保。意思就是,如果未使用的Survivor空间不足以存放上一次新生代垃圾回收之后存活下来的对象,就需要通过分配担保机制分配到老年代中。

  • 标记—整理(Mark-Compact)算法

复制算法在对象存活较多的情况下就会进行较多的复制操作,效率将会变低。更重要的是,如果不想浪费50%的内存空间,就需要额外的空间进行分配担保,以应对被使用内存中所有对象都是100%存活的极端情况,所以老年代中一般不是复制算法。

标记—整理算法的标记过程与标记—清除算法一样,当标记完需要被回收的对象后,将依然存活的对象都向一端移动,然后直接清除掉端边界以外的所有内存。标记—整理算法的过程如下:

深入理解Java虚拟机学习笔记——二、垃圾收集器与内存分配策略

  • 分代收集算法

Hotspot虚拟采用分代收集算法来清理垃圾,这种算法是根据对象存活周期的不同将内存划分为老年代和新生代,这样就可以根据各个年代的特点使用适当的垃圾收集算法。在新生代中,由于对象“朝生夕死”的特点,选用复制算法,只需要付出少量存活对象的复制成本就可以完成收集;因为老年代中对象的存活率较高、没有额外空间对其进行担保,就必须使用标记—清除算法或标记—整理算法来进行回收。


3、Hotspot的算法实现

在第一节和第二节介绍了对象存活算法和垃圾收集算法,Hotspot在实现这些算法时,必须对算法的执行效率进行严格的考量,才能保证虚拟机的高效运行。

  • 枚举根节点
以可达性分析中从GC Roots节点查找引用链为例,可作为GC Roots的节点主要是全局性的引用(如:常量或静态属性)与执行上下文(如:栈帧中的本地变量表),现在很多应用仅仅是方法区就有几百兆,如果要逐个检查其中的引用,那么必然会消耗大量的时间。
可达性分析对执行时间的敏感度还体现在GC停顿上,因为可达性分析算法必须在一个能够确保一致性的快照中进行。“一致性”的意思是指在分析过程中整个执行系统就像被冻结在了某个时间点上,不允许出现对象关系引用出现变化,该点如果不满足,则无法保证分析结果的正确性。这点是导致GC进行时必须停顿所有Java线程(Stop The World)的一个重要原因。即使是在号称几乎不会发生停顿的CMS收集器中,枚举根节点时也必须停顿。
由于当前主流虚拟机都是采用准确式GC,当执行系统停顿以后,并不需要一个不漏的检查所有的全局性引用和执行上下文。在Hotspot中,使用OopMap来存放对象的引用以达到在扫描时可以直接得到所需信息。

  • 安全点
在OopMap的协助下,Hotspot可以快速且准确的完成GC Roots枚举。但是,这也可能导致引用关系变化,或者说OopMap变化指令非常多,如果为每一条指令都生成对应的OopMap,那么将会消耗大量的额外空间,GC的成本会变高。因此Hotspot只会在“特定的位置”记录这些信息,这些位置被称为“安全点”(Safe Point),即程序在执行时并不是在所有位置都停顿下来进行GC操作,只有在到达安全点时才会暂停。Safe Point既不能选择的太少以至于让GC的等待时间过长,也不能太多以至于增大运行时的负荷。
安全点的选择:以“是否具有让程序长时间执行”的特征进行选择。程序中每条指令的执行时间都是非常短暂的,因此程序不可能因为指令流长度过长这个原因而长时间运行,所以“长时间运行”的特征就是指令序列复用,如方法调用、循环跳转、异常跳转等,具备这些功能的指令才会产生Safe Point。
线程在GC发生时到达安全点才停顿下来的方法:
① 抢先式中断(Preemptive Suspension)

不需要线程的执行代码主动配合,在GC发生时,首先将所有的线程全部中断执行,如果发现有线程中断的地方不在安全点上,则恢复这些线程以便使其执行到安全点上。

② 主动式中断(Voluntary Suspension)

当GC需要中断线程时,不直接对线程操作,而是简单的设置一个标志,每个线程在执行时主动轮询该该标志,当发现中断标志为真时就主动中断挂起。可以设置轮询标志的地方有安全点和创建对象时分配内存的地方。

  • 安全区域

安全点看似完美解决了如何进入GC的问题,但是当程序为分配CPU时间(线程处于Sleep状态或Blocked状态),这个时候线程无法响应JVM的中断请求,也就无法进入安全点中断挂起。JVM显然不可能等待线程重新分配CPU时间,这个时候就需要安全区域(Safe Region)来解决。
安全区域是指在一段代码片段中,对象的引用关系不会发生变化。在这个区域中的任意地方进行GC都是安全的,也可以将安全区域(Safe Region)看做是安全点(Safe Point)的扩展。

4、垃圾收集器

如果说垃圾收集算法是内存回收的方法论,那么垃圾收集器就是内存回收的具体实现。Hotspot虚拟机提供的所有垃圾收集器如下图所示:
深入理解Java虚拟机学习笔记——二、垃圾收集器与内存分配策略
上图展示了7中作用于不同分代的收集器,如果两个收集器之间有连线,说明它们可以搭配使用。

  • Serial收集器

Serial收集器是一个单线程的收集器,“单线程”并不是指仅仅只会使用一个CPU或者一个线程去完成垃圾收集工作,更重要的是它在进行垃圾收集时,必须暂停其他所有的工作,直到垃圾收集结束。Serial和Serial Old收集器的运行过程如下图所示:
深入理解Java虚拟机学习笔记——二、垃圾收集器与内存分配策略

  • ParNew收集器

ParNew收集器其实就是Serial收集器的多线程版本,除了使用多条线程进行垃圾收集外,其余行为包括Serial收集器可用的所有控制参数(如:-XX:SurvivorRatio、-XX:PretenureSizeThreshold、-XX:HandlePromotionFailure等)、收集算法、Stop The World、对象分配规则、回收策略等都与Serial收集器完全一样。ParNew收集器的运行过程如下图所示:
深入理解Java虚拟机学习笔记——二、垃圾收集器与内存分配策略

  • Parallel Scavenge收集器

Paralle Scavenge收集器的特点是它的关注点与其他收集器不同,CMS等收集器的关注点是尽可能缩短垃圾收集时用户线程的停顿时间,而Parallel收集器的目标则是达到一个可控的吞吐量(所以Parallel Scavenge收集器也叫做吞吐量优先收集器)。所谓吞吐量是指CPU运行用户代码的时间与CPU总消耗时间的比值,即吞吐量=用户代码运行时间/(用户代码运行时间+垃圾收集时间)。Parallel Scavenge提供了两个参数来精准控制吞吐量:
① -XX:MaxGCPauseMillis:最大垃圾收集停顿时间
MaxGCPauseMillis的值是一个大于0的毫秒数,收集器会尽可能的保证内存回收花费的时间不超过设定值。该值并不是越小越好,因为GC的停顿时间是以牺牲吞吐量和新生代空间换取的:将新生代调小一些,收集300M的新生代肯定比收集500M的快,这也直接导致了垃圾收集操作发生的更频繁。停顿时间下降的同时也导致了吞吐量的下降。
② -XX:GCTimeRatio:设置吞吐量大小
GCTimeRatio的值是一个大于0且小于100的整数,也就是垃圾收集时间占总时间的比率,相当于吞吐量的倒数。默认值是99,就是允许最大1%(即1/(1+99))的垃圾收集时间。

除了以上两个参数外,Parallel Scavenge还提供了-XX:UseAdaptivePolicy参数。该参数是一个开关参数,当打开该参数后,就不需要手动指定新生代大小(-Xmn)、Eden和Survivor区的大小比例(-XX:SurvivorRatio)、晋升老年代的对象年龄(-XX:PretenureSizeThreshold)等细节参数了,虚拟机会根据当前系统的运行情况收集性能监控信息,动态的调整这些参数信息已提供最短停顿时间或者最大吞吐量,这种调节方式称为GC自适应调节策略。

  • Serial Old收集器

Serial Old收集器是Serial收集器的老年代版本,也是一个单线程收集器,使用标记—整理算法。该收集器的主要作用给在Client模式下的虚拟机使用。如果是在Server模式下使用,那么它主要还有两大用途:
① 在JDK 1.5及以前版本中与Parallel Scavenge收集器搭配使用。
② 作为CMS收集器的后备收集器,在并发收集发生Concurrent Mode Failure时使用。

Serial Old收集器的工作过程如下图所示:

深入理解Java虚拟机学习笔记——二、垃圾收集器与内存分配策略

  • Parallel Old收集器

Parallel Old收集器是Parallel Scavenge收集器的老年代版本,使用多线程和标记—整理算法。Parallel Old收集器的工作过程如下图所示:
深入理解Java虚拟机学习笔记——二、垃圾收集器与内存分配策略

  • CMS(Concurrent Mark Sweep)收集器

CMS收集器是一种以获取最短回收停顿时间为目标的收集器。从名字可以看出CMS收集器是基于标记—清除算法实现的并发收集、低停顿的垃圾收集器,所以也被称为并发收集低停顿收集器,其运行过程可以分为4个阶段:
① 初始标记
② 并发标记
③ 重新标记
④ 并发清除
其中,初始标记和重新标记仍然需要Stop The World。初始标记仅仅是标记一下GC Roots能直接关联到的对象,并发标记阶段就是进行GC Roots Tracing的过程,重新标记是为了修正在并发标记阶段由于用户程序继续运行而导致标记发生变化的那一部分对象的标记记录。由于整个垃圾收集过程耗时最长的并发标记和并发清除过程可以与用户线程并发执行,所以从总体上来说,CMS收集器的内存回收过程是与用户线程并发执行的。CMS收集器的运行过程如下图所示:
深入理解Java虚拟机学习笔记——二、垃圾收集器与内存分配策略
CMS收集器的缺点:
① CMS收集器默认启动的回收线程数是(CPU数量+3)/4,当CPU数量在4个以上时,并发进行垃圾回收的线程数不少于25%的CPU资源,并随着CPU的增加而下降。但当CPU的数量少于4个时,CMS对用户程序的影响可能变得很大。
② CMS收集器无法回收浮动垃圾,可能出现Concurrent Mode Failure而导致另一次Full GC的发生。由于在并发清除阶段,用户的程序同时也在不停的运行,伴随着程序的运行自然会产生新的垃圾,这部分垃圾出现在标记过程之后,所以CMS收集器无法在当次收集中处理他们,只能等到下一次GC时再清理它们。这部分垃圾就被称为浮动垃圾。因为在并发清除阶段,用户的程序也在不停的运行,所以CMS收集器不能像其他收集器那样等到老年代空间基本被占满后在进行垃圾回收,而是需要预留一部分空间给并发清除阶段运行的用户程序使用。JDK 1.5默认的CMS收集器启动阈值是68%,而在JDK 1.6以后调整为了92%,因此可能会出现Concurrent Mode Failure失败。这是就会临时启用Serial Old收集器来重新进行老年代的垃圾收集工作。可以使用-XX:CMSInitiatingOccupancyFraction参数来设置CMS收集器的启动阈值。
③ 由于CMS是一款基于标记—清除算法的收集器,所以会导致内存碎片。因此CMS收集器提供了-XX:UseCMSCompactAtFullCollection用于在CMS收集器顶不住要进行Full GC时开启内存碎片的合并整理过程。同时还提供了-XX:CMSFullGCBeforeCompaction用于设置在进行了多少次不压缩的Full GC后进行一次带压缩的Full GC。

  • G1收集器

G1(Garbage First)收集器是一款面向服务端应用的垃圾收集器。与其他垃圾收集器相比,G1收集器有如下特点:
  • 并行与并发
G1能够充分的利用多CPU、多核环境下的硬件优势,使用多个CPU来缩短Stop-The-World停顿的时间。
  • 分代收集
分代收集的概念在G1中得以保留,虽然G1可以不需要其他收集器的配合就能独立管理整个GC堆,但它能够采用不同的方式去处理新建的对象和已经存活一段时间的对象、熬过多次GC的旧对象以获取更好的收集效果。
  • 空间整合
与CMS的标记—清除算法不同,G1从整体上看是标记—整理算法实现的收集器,从局部(两个Region之间)看是基于复制算法的实现。
  • 可预测的停顿
低停顿是CMS收集器和G1收集器共同的关注点,但是相比较CMS收集器,G1除了追求低停顿,还能建立可预测的停顿时间模型,能让使用者明确指定在长度为M毫秒的时间片段内,消耗在垃圾收集上的时间不得超过N毫秒。

在G1收集器之前,其他垃圾收集器进行收集的范围是整个新生代和老年代。而在使用G1收集器时,是讲整个Java队划分成若干个大小相等的独立区域(Region),虽然还保留着新生代还老年代的概念,但是新生代和老年代不再试物理隔离的,而是作为一部分Region的集合。

G1收集器之所以能建立可预测的停顿时间模型,是因为G1跟踪各个Region里的垃圾堆积的价值大小(回收后可获得的空间的小以及回收所需花费的时间),在后台维护一个优先列表,每个根据允许的收集时间,优先回收价值最大的Region。

G1收集器的运行大致可以划分为以下几个操作:

① 初始标记

② 并发标记

③ 最终标记

④ 筛选回收

深入理解Java虚拟机学习笔记——二、垃圾收集器与内存分配策略

5、内存分配与回收策略

Java体系中所提倡的自动内存管理最终可以归结为自动化的解决两个问题:给对象分配内存以及回收分配给对象的内存。回收分配给对象的内存在此之前已经讲过了,也就是垃圾回收。本节会注重的将给对象分配内存。
  • 对象优先在Eden分配
大多数情况下,对象在新生代的Eden中分配内存。当Eden区没有足够内存时,虚拟机将发起一次Minor GC。
  • 大对象直接进入老年代
所谓大对象是指需要大量连续内存空间Java对象,最典型的大对象就是那种很长的字符串以及数组。虚拟机提供了-XX:PretenureSizeThreshold参数,使大于设置值的对象直接在老年代中分配。这样做的目的是为了避免在Eden区和两个Survivor区中发生大量的内存复制。
  • 长期存活的对象将进入老年代
为了区分哪些对象应该放在新生代,哪些对象应该放在老年代,虚拟机给每个对象定义了一个对象年龄计数器。如果对象出生在Eden区并经历过一次Minor GC后依然存活,并且能够被Survivor容纳的话,将被移动到Survivor区,对象的年龄设为1,之后每经历一次Minor GC,对象的年龄就加1。当年龄增加到一定程度(默认15),就将晋升到老年代中。虚拟机提供了-X:MaxTenuringThreshold参数来设置对象晋升为老年代的数值。
  • 动态年龄判断
为了能够更好的适应不同程序的内存状况,虚拟机并不是永远的要求对象的年龄必须得到指定的阈值才会晋升为老年代,如果Survivor中年龄相同的所有对象大小的总和大于Survivor空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代,而不用等到MaxTenuringThreshold所要求的年龄。
  • 空间分配担保
在Minor GC发生之前,虚拟机会先检查老年代最大可用的连续空间是否大于新生代所有对象的总空间。如果大于,那么Minor GC可以确保是安全的。如果小于,虚拟机会查看HandlePromotionFailure参数的设置值是否允许担保失败。如果允许,那么会继续检查老年代中最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于则尝试着进行一次Minor GC,尽管这次Minor GC是有风险的;如果小于,或者HandlePromotionFailure设置不允许冒险,则改为Full GC。