Java虚拟机 - 垃圾收集器与内存分配策略

垃圾收集需要完成的三件事情:

  1. 那些内存需要回收
  2. 什么时候回收
  3. 如何回收

确定对象是否存活

引用计数算法

在对象添加一个引用计数器,每当有一个地方引用它时,计数值加一;引用失效时,计数值减一.在任何时刻计数器为0时,表示对象不可能再被使用.

引用计数算法原理简单,判定效率也很高.但是Java虚拟机并没有使用引用计数算法管理内存.如循环引用等很多的特殊情况需要考虑

可达性分析算法

可达性分析算法通过一系列的GC Roots的跟对象作为起始节点集,从这些节点开始根据引用关系乡下搜索,搜索过程所走过的路径称为"引用链",如果某个对象导GC Roots间没有任何的引用链相连,则证明对象不可能再被使用.

可作为GC Roots的对象包括:

  • 在虚拟机栈(栈帧中的本地变量表)中引用的对象,如各个线程被调用的方法堆栈中使用到的参数,局部变量,临时变量等
  • 方法区中静态属性引用的对象,如Java类的引用类型静态变量
  • 在方法区中常量引用的对象,如字符串常量池里的引用
  • 本地方法栈中JNI引用的对象
  • Java虚拟机内部的引用,如基本数据类型对应的Class对象,常驻的异常对象,系统类加载器等
  • 所有被同步锁持有的对象
  • 反应Java虚拟机内部情况的JMXBean,JVMTI中注册的回调,本地缓存代码等
  • 其他临时性的加入

强软弱虚引用

  • 强引用
    强引用时最传统的引用的定义,指在程序代码中普遍存在的引用赋值.无论任何情况下,只要强引用关系还在,垃圾收集器就永远不会回收掉被引用的对象
  • 软引用
    软引用用于描述一些还有用,但非必须的对象.只要被软引用关联着的对象,在系统将要发生内存溢出异常之前,会把这些对象列进回收范围之中进行第二次回收,如果这次回收还没有足够的内存才会抛出内存溢出异常
  • 弱引用
    弱引用也是用来描述那些非必须对象,但是它的强度比软引用更弱,被弱引用关联的对象只能生存到下一次垃圾收集发生为止
  • 虚引用
    虚引用也被称为"幽灵引用"或者"幻影引用",是最弱的一种引用关系.对象是否有虚引用的存在,完全不影响对象的生存时间,也无法通过虚引用取得一个对象实例.唯一目的只是为了能在这个对象被GC时收到一个系统通知.

对象的自救

对象在被标记为垃圾后,并不会被直接回收,而回再经历第二次次标记的过程,条件为是否有必要执行finalize()方法,如果对象没有覆盖finalize()方法或者finalize()方法已经被虚拟机调用过,那么虚拟机将不再执行此方法,会将对象进行回收.而如果判定为有必要执行finalize()方法的话,会将次对象放入一个为F-Queue的队列中,并在稍后由一条由虚拟机自动建立的,低调度低优先级的Finalizer线程去执行它们的finalize()方法,虚拟机并不承诺一定会等待此方法运行结束.如果对象在此方法中拯救了自己(将自己与GC Roots关联上),那么在第二次标记时它将被移出被回收的集合.

finalize() 方法只会执行一次,同时不建议使用此方法,而是使用try-finally


方法区的回收

《Java 虚拟机规范》中提到可以不要求虚拟机在方法区中实现垃圾收集,因为在方法去进行垃圾收集的性价比通常时比较低的.

回收内容包括:

  • 废弃的常量
    某个字符串曾经进入常量池,但是当前系统又没有任何一个字符串对象的值是常量池中的值,如果这时发生内存回收,并且垃圾收集器判断有必要的话,这个在常量池中的字符串将被清理.

  • 不再使用的类型
    判断一个常量是否废弃相对简单,但是判断一个类型是否不再需要被使用则比较苛刻.

    1. 该类的所有实例已经被回收
    2. 加载该类的类加载器已经被回收,通常很难达成
    3. 该来对象的 java.lang.Class对象没有被任何地方引用

    上述条件达成,则此类被允许回收

在大量使用反射,动态代理,CGLib等字节码框架,动态生成JSP以及OSGi这类自定义类加载器的场景中,通常都需要Java虚拟机具备类卸载能力,保证不对方法区造成较大的压力


垃圾收集算法

分代收集理论

  1. 弱分代假说: 绝大多数对象都是朝生夕灭的
  2. 强分代假说: 熬过越多次垃圾收集过程的对象就越难以消亡

两个假说奠定了多款常用垃圾收集器的一致设计原则: 收集器应该将Java堆划分出不同的区域,将回收对象依据年龄(对象熬过垃圾收集过程的次数)分配到不同的区域中存储.

如果对象都是朝生夕灭的,将它们放在一起,只要关注如何保留少量的存活对象,就能以较低的代价回收到大量的空间;如果对象都是难以消亡的,将它们集中在一块,虚拟机使用较低的频率来回收这个区域.

商用Java虚拟机里,设计者一般至少会把Java 堆划分为新生代老年代两个区域.在新生代中,每次垃圾收集由大量的对象死去,每次回收后存活的对象,将会逐步晋升到老年代中存放.

实现分代收集的一个明显困难: 对象之间存在跨代引用

若现在需要进行一次局限于新生代的Minor GC,单新生代中的对象是完全有可能被老年代引用的,为了找出该区域中的存活对象,不得不在固定的GC Roots之外,再额外遍历整个老年代所有对象来保证可达性分析的正确性.反过来也相同.为了提高回收效率,添加了第三条假说法则:

  1. 跨代引用假说: 跨代引用相对于同代引用来说仅占少数

因为如果某个新生代对象被老年代对象引用,那么这个对象再垃圾收集时可以长时间存活,进而晋升到老年代,这时跨代引用也随机被消除.

由于跨代引用是少数,我们就没必要再去扫描整个老年代,也不必浪费空间专门记录每一个对象是否存在及存在哪些跨代引用,只需要在新生代上建立一个全局的记忆集(Remembered Set),这个结构把老年代划分为若干小块,表示出老年代的哪一块内存会存在跨代引用.只有包含了跨代引用的小块内存对象才会被加入GC Roots进行扫描.

这种方法虽然在对象引用关系改变时进行额外维护,但是相较于扫描整个老年代来说是值得的.


标记-清除算法

标记-清除算法分为2步

  1. 标记所有需要回收的对象(标记过程就是对象是否可达的判断)
  2. 统一回收所有被标记的对象

标记清楚算法是最基础的算法,最容易被想到并且实现相对简单.

缺点:

  • 执行效率不稳定
    如果Java堆中包含大量对象,而且大部分是需要被回收的,这时必须进行大量标记和清除的动作,而两个过程的执行效率都会随着对象数量增长降低
  • 内存空间碎片化问题
    标记清除后,会产生大量的不连续的内存碎片,空间碎片太多可能会导致当之后需要分配较大对象时无法找到足够的连续内存而不得不提前触发另外一次垃圾收集动作

image-20210223170658884


标记-复制算法

标记复制算法被简称为"复制算法",它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块.当这一块的内存用完了,就将还存活着的对象复制到另外一块空间上,然后再将之前一块使用过的内存一次清理.如果内存中多数对象都是存活的,这种算法会产生大量的复制对象开销,但是对于多数对象都是可回收的清情况,复制算法就只需要将少量的存活对象进行复制,同时分配内存时也不用考虑空间碎片的问题.这样实现简单且运行高效.

缺点: 可用内存减小为原来的一半,空间浪费大

由于新生代朝生夕灭的特点,通常会考虑使用标记-复制算法.

新生代中的对象有98%熬不过第一轮收集

Apple式回收

根据新生代对象朝生夕灭的特点,提出了一种更优化的半区复制分代策略.Hotspot虚拟机的Serial,ParNew等新生代收集器均采用了这种策略.

  • 具体做法
    把新生代分为一块较大的Eden空间和两块较小的Survivor空间每次分配内存时只使用Eden和其中的一块Survivor.发生垃圾回收时,将Eden和Survivor中仍然存活的对象一次性复制到另外一块Survivor中,然后直接清理掉Eden和之前被使用的Survivor.

    HotSpot虚拟机中Eden:Survivor0:Survivor1 默认为8:1:1,所以有10%的新生代将被"浪费"

    当Survivor空间不足以容纳一次Minor GC之后存活的对象时,就需要依赖其他的内存区域(老年代)进行分配担保.如果一块Survivor空间没有足够的空间存放上一次新生代收集下来的存活对象,这些对象将通过分配担保机制直接进入老年代.
    image-20210223170740129


标记-整理算法

标记-复制算法在对象存活率较高时,会进行较多的复制操作,效率会降低.并且如果不想浪费50%的空间,还需要额外的空间进行分配担保.因此老年代通常不会采用标记-复制算法,而会采用标记-整理算法.

标记-整理算法第一步同标记-清除算法,先把存活对象进行标记,随后,让所有存活的对象都向内存的一端移动,然后直接清除掉边界以外的内存.当老年代回收时大量对象存活时,移动存活对象并更新引用是极重的操作,且这种对象移动将会触发STW

STW: Stop The World, 触发STW将会暂停用户线程

对于标记清除和标记整理算法,在于是否移动存活对象.而是否移动存活对象都存在弊端.

如果移动,那么内存回收时会更复杂,而如果不移动,在内存分配时则更复杂.

所以如果需要低延时,那么不移动对象更划算,而如果需要高吞吐量,那么移动对象更划算.

更好的办法是平时多数采用标记-清除算法,暂时的容忍内存碎片的存在,当内存空间碎片划成都已经大到影响内存分配时,在采用标记-整理算法收集一次,以获得规整的内存空间.CMS收集器面临碎片过多时采用的就是这种处理办法.

image-20210223170750735

经典垃圾收集器

image-20210223214902741

上图中有连线的两个收集器之间可以搭配使用,横线之上表示为新生代收集器,横线下表示为老年代收集器

Serial收集器

Serial收集器是最基础,历史最悠久的收集器,在JDK 1.3.1之前是Hotspot虚拟机唯一选择.

Serial收集器是一个单线程工作的收集器,在它进行垃圾回收时,必须暂停其他工作线程(STW),直至它收集结束.

image-20210223215439715

迄今为止,Serial收集器任然是HotSpot虚拟机运行在客户端模式下的默认新生代收集器,它有着优于其他收集器的地方,就是简单而高效(相比于其他收集器的单线程),对于内存资源受限的环境,它是所有收集器里额外内存消耗最少的.对于单核处理器或处理器核心数较少的环境来说,Serial收集器由于没有线程交互(上下文切换)的开销,自然可以获得最高的单线程收集效率.所以Serial收集器对于运行在客户端模式下的虚拟机来说是一个很好的选择.

ParNew收集器

ParNew收集器实质上是Serial收集器多线程并行版本

image-20210223220059287

ParNew收集器是不少运行在服务端模式下的HotSpot虚拟机首选的新生代收集器.主要原因是除了Serial收集器外目前只有它能与CMS收集器配合工作.ParNew收集器是激活CMS后(使用 -XX:+UserConcMarkSweepGc)的默认新生代收集器,也可以使用-XX:+/-UseParNewGC选择开启或者禁用ParNew GC.ParNew收集器默认开启的线程数与处理器核心数量相同.

Parallel Scavenge收集器

Parallel Scavenge也是一款新生代收集器,同样基于标记-复制算法实现,也是能够并行收集的多线程收集器.

Parallel Scavenge看上去与ParNew非常类似,其特点在于达到一个可控制的吞吐量,高吞吐量可以最高效率的利用处理器资源,尽快完成程序的运算任务.

吞吐量就是处理器用于运行用户代码的时间与处理器总消耗时间的比值,即:

吞吐量=运行用户代码的时间/(运行用户代码的时间+运行垃圾收集的时间)

Parallel Scavenge收集器提供了两个参数用于精确控制吞吐量

  • 控制最大垃圾收集器停顿时间的: -XX:MaxGCPauseMillis
    允许设置一个大于0的值,收集器将尽力保证内存回收时间不超过用户设定值
    设置了过小的值将导致GC效率变低,GC次数将会变多,导致吞吐量下降
  • 直接设置吞吐量大小的: -XX:GCTimeRatio
    参数值允许设置一个大于0小于100的整数,表示垃圾收集时间占总时间的比率,相当于吞吐量的倒数

Parallel Scavenge收集器还提供了一个用于自适应调节策略的参数: -XX:+UseAdaptiveSizePolicy,当这个参数激活后,就不需要人工指定新生代的大小(-Xmn),Eden与Survivor的比例(-XX:SurvivorRatio),晋升老年代对象大小(-XX:PretenureSizeThreshold)等细节参数,虚拟机会根据当前系统的运行情况收集性能监控信息,动态调整这些参数以提供最适合的停顿时间或者最大吞吐量.这个自适应参数也是Parallel Scavenge收集器区别于ParNew收集器的一个重要特性.

Serial Old收集器

Serial Old收集器是Serial收集器的老年代版本,它也是一个单线程的收集器,使用标记-整理算法.Serial Old收集器的主要意义也是供客户端模式下的HotSpot虚拟机使用.

服务端模式下也有两个用途:

  1. 在JDK5 以及以前的版本中与Parallel Scavenge收集器搭配使用
  2. 在CMS收集器发生失败时作为后备方案使用

image-20210223224658510

Parallel Old收集器

Parallel Old收集器是Parallel Scavenge收集器的老年代版本,支持多线程并发收集,基于标记-整理算法实现.

Parallel Old收集器是在JDK6才开始提供的.在此之前,Parallel Scavenge收集器一直处于比较尴尬的地步,新生代如果选择了Parallel Scavenge收集器,则老年代只能选择Serial Old收集器(PS MarkSweep),其他表现比较好的收集器不能与之搭配使用(如CMS).而老年代Serial Old收集器在服务端应用性能上的低效率,使用Parallel Scavenge收集器也不一定能在整体上获得吞吐量最大化的效果.直到Parallel Old收集器出现,吞吐量优先收集器终于有了比较名副其实的搭配组合.

image-20210223225300129

CMS收集器

CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器.较关注服务响应速度,希望系统停顿时间尽可能短,给用户带来更好的交互体验.

CMS收集器基于标记-清除算法实现,整体过程分为4步:

  1. 初始标记(STW)
    初始标记仅仅只是标记一下GC Roots能直接关联到的对象,因此速度很快
  2. 并发标记
    从GC Roots的直接关联对象开始遍历整个对象图的过程,耗时较长,但是不需要停顿用户线程,可以与用户线程一起并发运行
  3. 重新标记(STW)
    为了修正标记期间,因用户程序运行而导致标记产生变动的一部分对象的标记记录(增量更新),这一段通常比初始标记阶段稍长,但是也远比并发标记阶段时间短
  4. 并发清除
    清理删除掉标记阶段判断的已经死亡的对象,可以与用户线程并发运行

image-20210223230040560

CMS收集器是HotSpot虚拟机追求地停顿的第一次成功尝试,但是还远远没有达到完美的程度,如下3个缺点:

  1. 对处理器资源十分敏感
    并发阶段虽然不会暂停用户线程,但是因为占用了一定的处理器计算能力,导致应用变慢,会降低总吞吐量.CMS默认启动的回收线程数是:(处理器核心数量+3)/4.
  2. CMS收集器无法处理"浮动垃圾",可能会出现"Concurrent Mode Failure"失败而导致另一次完全STW的Full GC的产生
    在CMS的并发标记和并发清理阶段,用户线程仍然在运行,此时还有可能产生新的垃圾,而CMS收集器无法在当次收集中处理它们.这一部分产生的垃圾为浮动垃圾.由于垃圾收集阶段程序用户线程还在运行,因此需要为用户线程预留足够的内存空间,供程序使用.在JDK5的默认设置下,CMS收集器当老年代使用了68%的空间后就会被激活,这是一个偏保守的设置,如果是实际应用中老年代增长不快的话可以通过调高-XX:CMSInitiatingOccu-pancyFraction提高CMS的出发百分比,降低内存回收频率以获取更好的性能.
    到JDK6时,CMS收集器的启动阈值默认提升至了92%.但这样又会更容易面临另一种风险,如果CMS运行期间预留的内存无法满足分配新对象的需求时,就会出现并发失败,此时虚拟机将不得不临时启用Serial Old收集器重新进行老年代的收集,这样将会增大停顿时间.
  3. 空间碎片
    由于CMS收集器采用的是标记-清除算法,那么这意味着会产生大量的空间碎片,将会给大对象的分配带来麻烦.CMS提供了-XX:UseCmsCompactAtFullCollection开关参数(默认为开启状态,在JDK9 开始废弃),用于在CMS收集器不得不进行Full GC时临时开启内存碎片的合并整理过程.由于内存整理会导致STW,但是停顿时间又会变长,因此又提供了另外的一个参数:-XX:CMSFullGCsBeforeCompaction,这个参数作用是要求CMS收集器在执行过若干次不整理空间的Full GC之后,下一次进入Full GC前黄斑变性碎片整理(默认为0,表示每次进入Full GC都会进行碎片整理)

G1收集器

Garbage First(G1)收集器是垃圾收集器技术发展历史上的里程碑式的成果,它开创了收集器面向局部收集的设计思路和基于Region的内存布局形式.G1是一款主要面向服务端应用的垃圾收集器.JDK9中,G1成为了默认了服务端模式下的垃圾收集器,CMS被声明为不推荐使用的收集器.

G1收集器面向堆内存任何部分来组成回收集(Collection Set)进行回收,衡量标准不再是它属于哪个分代,而是哪块内存中存放的垃圾数量最多,回收收益最大,这就是G1收集器的Mixed GC模式.

G1不再坚持固定大小以及固定数量的分代区域划分,而是把连续的Java堆划分为多个大小相等的独立区域(Region),每一个Region都可以根据需要,扮演新生代的Eden空间,Survivor空间或者老年代空间.收集器通过对不同角色的Region采取不同的策略去处理,能取得比较好的效果.

Region中还有一类用于存储大对象Humongous区域.G1认为只要对象的大小超过了一个Region容量一半即可判定为大对象.Region大小可以通过-XX:G1HeapRegionSize设定,范围为1MB~32MB,且应该为2的N次幂.如果对象的大小超过了Region的大小,那么将会被存放在多个连续的Humongous Region中.G1的大多数行为都把Humongous Region看作老年代对待.

img

G1收集器将Region作为单次回收的最小单元,每次收集到的内存空间都是Region大小的整数倍,这样可以有计划的避免在整个Java堆中进行全区域的垃圾收集.

G1收集器会跟踪每个Region的垃圾堆积"价值"大小,即回收所获得的空间大小以及回收需要时间的经验值,将价值维护在一个优先级列表,每次根据用户设定的允许收集停顿时间(通过参数-XX:MaxGcPauseMillis指定,默认为200ms),优先处理回收价值收益最大的Region.

G1收集器运作过程:

  1. 初始标记(STW)
    只标记GC Roots能直接关联到的对象,修改TAMS指针的值.耗时很短,需要STW,且是借用Minor GC的时候同步完成.

    TAMS:G1收集器设计了两个名为TAMS的指针,把Region中的一部分空间划分出来用于并发回收过程中新对象的分配.G1收集器默认在指针上分配的对象是存活的,不纳入回收范围.

  2. 并发标记
    从GC Roots开始对堆进行可达性分析,扫描整个堆的对象图,找到需要回收的对象.耗时较长,但是是并发运行.扫描完成后还要重新处理SATB(原始快照)记录下的并发时的引用变动对象.
  3. 最终标记(STW)
    暂停用户线程,处理并发阶段结束后遗留下来的最后的少量的SATB记录.
  4. 筛选回收(STW)
    更新Region的统计数据,对各个Region回收价值和成本进行排序,根据用户设定的期望参数指定回收计划进行回收.将决定回收的Region的存活对象复制到新的Region,清理掉旧的Region.需要暂停用户线程.

G1收集器的4个过程中,3个需要STW,可见并非纯粹的追求低延时,而是"在延迟可控的情况下,追求更高的吞吐量".

image-20210224141325464

与CMS收集器的"标记-清除"算法不同,G1整体来看基于"标记-整理"算法来实现,但是从局部的Region来看又是基于"标记-复制"算法来实现的.但是这两种算法来看,都不会产生内存空间碎片,能提供规整的可用内存.

G1由于在每个Region中都需要维护一个卡表,导致了G1的记忆集可能会占整个堆容量的20%乃至更多的内存空间.

并且由于G1除了使用写后屏障来维护卡表外,为了实现原始快照搜索(SATB)算法,还需要使用写前屏障来跟踪并发时指针的变化情况.所以G1对写屏障的复杂操作要比CMS销毁更多的运算资源.因此CMS的写屏障使用的是同步操作,而G1就不得不将其实现为类似消息队列的结构,将写屏障要做的事情放到队列中去异步处理.

总结:目前小内存应用上CMS的表现大概率会好于G1,Java堆容量平衡点通常在6G~8G.随着G1的不断优化,未来G1的优势将会变得越来越大.

最后修改:2021 年 02 月 24 日
如果觉得我的文章对你有用,请随意赞赏