【Java虚拟机】垃圾回收机制与垃圾收集器

垃圾回收机制

垃圾回收过程

JVM内存区域的程序计数器,虚拟机栈、本地方法栈的生命周期是和线程同步的,随着线程的销毁而自动释放内存,所以只有堆和方法区需要GC。方法区主要是针对常量池的回收和对类型的卸载,堆区针对的是不再使用的对象进行回收内存空间。我们常说的GC一般指的是堆区的垃圾回收,堆内存空间可以进一步划分新生代和老年代,新生代会发生Minor GC,老年代会发生Full GC。

JVM把年轻代分成三部分:一个Eden区和两个Survivor区(即From区和To区),比例为8:1:1。当Eden区没有足够的内存空间给对象分配内存时,虚拟机会发起一次Minor GC,在GC开始的时候,对象会存在Eden和From区,To区是空的。进行GC时,Eden区存活的对象会被复制到To区,From区存活的对象会根据年龄值决定去向,达到阈值(默认15)的对象会被移动到老年代中,没有达到阈值的对象会被复制到To(但有可能存在没有达到阈值就从Survivor区直接移动到老年代的情况:在进行GC的时候会对Survivor中的对象进行判断,Survivor空间中年龄相同的对象的总和大于等于Survivor空间一半的话,年龄大于或等于该年龄的对象就会被复制到老年代;在把Eden区的对象复制到To区的时候,To可能已经满了,这个时候Eden中的对象就会被直接复制到老年代中)。这时Eden区和From区已经被清空了。接下来From区和To区交换角色,以保证To区在GC开始时是空的。Minor GC会一直重复这样的过程,直到To区被填满,To被填满之后,会将所有对象移动到老年代中。如果老年代内存空间不足,则会触发一次Full GC。

确认对象是否存活

垃圾收集器在对堆进行回收前,首先要确定对象是否还存活,判断对象是否存活主要有两种算法:引用计数算法和可达性分析算法。

(1) 引用计数算法:对象创建时,给对象添加一个引用计数器,每当有一个地方引用到它时,计数器值加1;引用失效时,计数器值减1;当计数值值为0时,这个对象就是不可能再被引用的。
(2) 可达性分析算法:以“GC Roots”对象为起点,从这些节点向下搜索,搜索所走过的路径称为引用链,当一个对象到GC Roots没有任何引用链相连接时,则证明此对象是不可用的。

GC Roots对象包括:

  • 虚拟机栈(栈帧中的本地变量表)中引用的对象;
  • 本地方法栈中JVM(Native)引用的对象;
  • 方法区中类静态属性引用的对象;
  • 方法区中常量引用的对象。

四种对象引用类别:(关联强度向下递减)

  • 强引用:GC不会回收强引用的对象。
  • 软引用:系统在发生内存溢出异常之前,会把这些对象列进回收范围之中,进行第2次回收。(如果内存不紧张,这类对象可以不回收;如果内存紧张,这类对象就会被回收)
  • 弱引用:被弱引用关联的对象,只能生存到下一次垃圾收集之前。
  • 虚引用:目的是能在对象被回收时收到一个系统通知。

对象的回收经历

目前最普遍使用的判断对象是否存活的算法是可达性分析算法,对象在真正死亡,需要经历两个阶段:

(1) 可达性分析后,没有与GC Roots相连接的引用链,会被第一次标记并筛选。如果对象没有覆盖finalize()方法或已经调用finalize()方法,则不会调用finalize()方法。否则则对象会被放在F-Queue队列中,等待线程执行finalize()方法。

(2) 若对象想要存活下来,finalize()方法是最后的机会,只需在finalize()方法中重新与引用链上的对象相关联,否则,GC对F-Queue队列进行第二次小规模标记后对象真正死亡。

垃圾收集算法

确认对象已经不可达之后,在触发GC时就要对这类对象进行回收,常见的GC算法如下:

(1) 标记-清除算法:首先标记出所有需要回收的对象,在标记完成后统一回收所有被标记的对象。缺点:效率低,会产生大量不连续的内存碎片。

(2) 复制算法:将可用内存划分成大小相等的两块,每次只使用其中的一块,当这块的内存用完时,就将还存活的对象复制到另一块内存中,然后再把原来的内存空间清理掉。缺点:内存缩小为原来的一半。

(3) 标记-整理算法:首先标记出需要回收的对象,接着将所有存活的对象都向一端移动,然后清理掉端边界以外的内存。

(4) 分代收集算法:根据各个年龄代的特点选择合适的收集算法。在新生代中,每次垃圾收集都有大量的对象死去,因此采用复制算法。老年代中,因为对象的存活率高,没有额外的空间对他进行担保,因此使用“标记-清除”和“标记-整理”算法。

对象内存分配策略

为了避免频繁发生GC,JVM在为对象分配内存时也定义了一套策略:

(1) 对象优先在Eden分配:当Eden没有足够的空间进行分配时,虚拟机将发起一次Minor GC。

(2) 大对象直接进入老年代:避免Eden区及两个Survivor区之间发生大量的内存复制。

(3) 长期存活的对象将进入老年代:对象在Eden区出生,并经过一次Minor GC后仍存活,年龄加1,若年龄超过阈值(默认15),则被晋升到老年代。

(4) 动态年龄判断:Survivor空间中相同年龄所有对象大小大于Survivor空间的一半,年龄大于或等于该年龄的对象直接进入老年代。

(5) 空间分配担保:Minor GC前,虚拟机会检查老年代最大可用连续空间是否大于新生代所有对象总空间,若成立,则Minor GC是安全的。若不成立,则检查是否允许担保失败,如果允许,检查老年代最大可用连续空间是否大于历次晋升到老年代的平均大小,大于,则尝试进行Minor GC;如果小于或者不允许冒险,则Full GC。

垃圾收集器

Serial收集器

Serial收集器是一个新生代收集器,使用复制算法。由于是单线程执行的,所以在进行垃圾收集时,必须暂停其他所有的用户线程(Stop the world),对于限定单个CPU的环境来说,由于没有线程切换的开销,可以获得最高的单线程收集效率。

是Jvm Client模式下默认的新生代收集器。

ParNew收集器

ParNew收集器其实就是Serial收集器的多线程版本,除了使用多线程进行垃圾收集之外,其余行为包括Serial收集器完全一样,包括控制参数、收集算法、Stop The World、对象分配规则、回收策略等一样。

在多核CPU上,回收效率会高于Serial收集器;反之在单核CPU, 效率会不如Serial收集器。ParNew收集器默认开启和CPU数目相同的线程数,可以通过-XX:ParallelGCThreads参数来限制垃圾收集器的线程数;

ParNew收集器是许多运行在Server模式下的虚拟机中首选新生代收集器,主要原因是,除Serial收集器之外,目前只有ParNew它能与CMS收集器配合工作。

Parallel Scavenge收集器

Parallel Scavenge收集器是新生代收集器,使用复制算法,并行多线程收集。Parallel Scavenge收集器的特点是它的关注点与其他收集器不同,CMS等收集器的关注点是尽可能地缩短垃圾收集时用户线程的停顿时间,而Parallel Scavenge收集器的目标则是达到一个可控制的吞吐量。(吞吐量= 程序运行时间/(程序运行时间 + 垃圾收集时间),虚拟机总共运行了100分钟。其中垃圾收集花掉1分钟,那吞吐量就是99%)。高吞吐量可以最高效率地利用CPU时间,尽快完成程序的运算任务,主要适用于在后台不需要太多交互的任务。

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

  • -XX:MaxGCPauseMillis:控制最大垃圾收集停顿时间,是一个大于0的毫秒数。
  • -XX:GCTimeRation:直接设置吞吐量大小,是一个大于0小于100的整数,也就是程序运行时间占总时间的比率,默认值是99,即垃圾收集运行最大1%(1/(1+99))的垃圾收集时间。
  • 支持自适应的GC调节策略。它还提供一个参数:-XX:+UseAdaptiveSizePolicy,这是个开关参数,打开之后就不需要手动指定新生代大小(-Xmn)、Eden与Survivor区的比例(-XX:SurvivorRation)、新生代晋升年老代对象年龄(-XX:PretenureSizeThreshold)等细节参数,虚拟机会根据当前系统运行情况收集性能监控信息,动态调整这些参数以达到最大吞吐量。

Serial Old收集器

Serial Old是Serial收集器的老年代版本,使用单线程执行和“标记-整理”算法。

主要用途:client模式下默认的老年代垃圾收集器。在server模式下主要还有两大用途:一个是在JDK1.5及之前的版本中与Parallel Scavenge收集器搭配使用,另外一个就是作为CMS收集器的后备垃圾收集方案,在并发收集发生 Concurrent Mode Failure的时候,临时启动Serial Old收集器重新进行老年代的垃圾收集。

Parallel Old收集器

Parallel Old是Parallel Scavenge收集器的老年代版本,jdk1.6之后开始提供,使用多线程和“标记-整理”算法。

在JDK1.6之前,新生代使用Parallel Scavenge收集器只能搭配年老代的Serial Old收集器,只能保证新生代的吞吐量优先,无法保证整体的吞吐量,Parallel Old正是为了在年老代同样提供吞吐量优先的垃圾收集器,如果系统对吞吐量要求比较高,可以优先考虑新生代Parallel Scavenge和年老代Parallel Old收集器的搭配策略。

CMS收集器

CMS(Concurrent Mark Sweep)收集器应用于老年代,采用多线程和“标记-清除”算法实现的,实现真正意义上的并发垃圾收集器,是一种以获取最短回收停顿时间为目标的收集器。整个收集过程大致分为4个步骤,如下图所示:

(1)初始标记(CMS initial mark):需要停顿所有用户线程,初始标记仅仅是标记出GC ROOTS能直接关联到的对象,速度很快。
(2)并发标记(CMS concurrent mark):进行GC ROOTS 根搜索算法阶段,会判定对象是否存活,和用户线程一起工作,不需要暂停工作线程。
(3)重新标记(CMS remark):为了修正并发标记期间,因用户程序继续运行而导致标记产生变动的那一部分对象的标记记录。需要停顿所有用户线程,停顿时间会被初始标记阶段稍长,但比并发标记阶段要短。
(4)并发清除(CMS concurrent sweep):清除GC Roots不可达对象,和用户线程一起工作,不需要暂停工作线程。
     整个过程中耗时最长的并发标记和并发清除过程中,收集器线程都可以与用户线程一起工作,所以整体来说,CMS收集器的内存回收过程是与用户线程一起并发执行的。

CMS收集器的虽然真正意义上实现了并发收集以及低停顿,但CMS还远远达不到完美,主要有四个显著缺点:

(1)CMS收集器对CPU资源非常敏感。在并发阶段,虽然不会导致用户线程停顿,但是会占用CPU资源而导致引用程序变慢,总吞吐量下降。CMS默认启动的回收线程数是:(CPU数量+3) / 4。

(2)CMS收集器无法处理浮动垃圾,可能出现“Concurrent Mode Failure“,失败后而导致另一次Full  GC的产生。由于CMS并发清理阶段用户线程还在运行,伴随程序的运行自热会有新的垃圾不断产生,这一部分垃圾出现在标记过程之后,CMS无法在本次收集中处理它们,只好留待下一次GC时将其清理掉。这一部分垃圾称为“浮动垃圾”。

(3)由于在垃圾收集阶段用户线程还需要运行,即需要预留足够的内存空间给用户线程使用,因此CMS收集器不能像其他收集器那样等到老年代几乎完全被填满了再进行收集,需要预留一部分内存空间提供并发收集时的程序运作使用。在默认设置下,CMS收集器在老年代使用了68%的空间时就会被激活,也可以通过参数-XX:CMSInitiatingOccupancyFraction的值来提供触发百分比,以降低内存回收次数提高性能。要是CMS运行期间预留的内存无法满足程序其他线程需要,就会出现“Concurrent Mode Failure”失败,这时候虚拟机将启动后备预案:临时启用Serial Old收集器来重新进行老年代的垃圾收集,这样停顿时间就很长了。所以说参数-XX:CMSInitiatingOccupancyFraction设置的过高将会很容易导致“Concurrent Mode Failure”失败,性能反而降低。

(4)CMS是基于“标记-清除”算法实现的收集器,会产生大量不连续的内存碎片。空间碎片太多时,如果无法找到一块足够大的连续内存存放对象时,将不得不提前触发一次Full GC。为了解决这个问题,CMS收集器提供了一个-XX:UseCMSCompactAtFullCollection开关参数,用于在Full  GC之后增加一个碎片整理过程,还可通过-XX:CMSFullGCBeforeCompaction参数设置执行多少次不压缩的Full  GC之后,跟着来一次碎片整理过程。

G1收集器

1、G1(Garbage First)收集器是JDK1.7提供的一个新收集器,与CMS收集器相比,最突出的改进是:

  • 基于“标记-整理”算法实现,不会产生内存碎片。
  • 可以非常精确控制停顿时间,在不牺牲吞吐量前提下,实现低停顿垃圾回收。

其他特点:

  • 并行性: 回收期间, 可由多个线程同时工作, 有效利用多核cpu资源;
  • 并发性: 与应用程序可交替执行, 部分工作可以和应用程序同时执行,
  • 分代GC: 分代收集器,同时兼顾年轻代和老年代。他能够采用不同的方式去处理新创建的对象和已经存活了一段时间、熬过了多次GC的对象,以便获取更好的GC效果。

2、垃圾收集原理

G1收集器并不采用新生代和老年代物理隔离的传统布局方式(仅在逻辑上划分新生代和老年代),而是将整个堆内存划分为2048个大小相等(具体大小根据堆的实际大小而定)的独立内存块Region,每个Region是逻辑连续的一段内存,整体被控制在1M、2M、4M、8M、16M和32M,总之是2的幂次方。G1收集器跟踪Region中的垃圾堆积情况,并在后台维护一个优先级列表,每次根据设置的垃圾回收时间,回收优先级最高的区域,这样可以避免整个新生代或整个老年代的垃圾回收,使得stop the world的时间更短、更可控,同时在有限的时间内可以获得最高的回收效率。区域划分和优先级区域回收机制,确保G1收集器可以在有限时间获得最高的垃圾收集效率。

3、G1的收集过程

如果不考虑维护Remembered Set的操作,可以分为上图4个步骤(与CMS较为相似),其中初始标记、并发标记、最终标记跟CMS收集器相同,只有第四阶段的筛选回收有些区别。

筛选回收:首先排序各个Region的回收价值和成本,然后根据用户期望的GC停顿时间来制定回收计划, 最后按计划回收一些价值高的Region中垃圾对象。

JVM的新生代除了Eden区,为什么还设置两个Survivor区?

为什么要有Survivor区?

设置Survivor区的意义在哪里?

如果没有Survivor,Eden区每进行一次Minor GC,存活的对象就会被送到老年代,老年代很快被填满,触发Full GC。老年代的内存空间远大于新生代,进行一次Full GC消耗的时间比Minor GC长得多。频发的Full GC消耗的时间是非常可观的,这一点会影响大型程序的执行和响应速度,更不要说某些连接会因为超时发生连接错误了。

那在没有Survivor的情况下,有没有什么解决方案可以避免上述情况:

方案 优点 缺点
增加老年代空间 更多存活对象才能填满老年代。降低Full GC频率 随着老年代空间加大,一旦发生Full GC,执行所需要的时间更长
减少老年代空间 Full GC所需时间减少 老年代很快被存活对象填满,Full GC频率增加

显而易见,没有Survivor的话,上述两种解决方案都不能从根本上解决问题。

我们可以得到第一条结论:Survivor的存在意义,就是减少被送到老年代的对象,进而减少Full GC的发生,Survivor的预筛选保证,只有经历16次Minor GC还能在新生代中存活的对象,才会被送到老年代。

为什么要设置两个Survivor区?

设置两个Survivor区最大的好处就是解决了碎片化。

为什么一个Survivor区不行?

假设现在只有一个survivor区,我们来模拟一下流程: 刚刚新建的对象在Eden中,一旦Eden满了,触发一次Minor GC,Eden中的存活对象就会被移动到Survivor区。这样继续循环下去,下一次Eden满了的时候,问题来了,此时进行Minor GC,Eden和Survivor各有一些存活对象,如果此时把Eden区的存活对象硬放到Survivor区,很明显这两部分对象所占有的内存是不连续的,也就导致了内存碎片化。 

我绘制了一幅图来表明这个过程。其中色块代表对象,白色框分别代表Eden区(大)和Survivor区(小)。

一个Survivor区带来碎片化

碎片化带来的风险是极大的,严重影响JAVA程序的性能。堆空间被散布的对象占据不连续的内存,最直接的结果就是,堆中没有足够大的连续内存空间,接下去如果程序需要给一个内存需求很大的对象分配内存,就会由于内存不足触发Minor GC了。

那么如果建立两块Survivor区呢?刚刚新建的对象在Eden中,经历一次Minor GC,Eden中的存活对象就会被移动到第一块From survivor区,Eden被清空;等Eden区再满了,就再触发一次Minor GC,Eden和From区中的存活对象又会被复制送入第二块To survivor区中(这个复制算法保证了To区中来自From和Eden两部分的存活对象占用连续的内存空间,避免了碎片化的发生)。From和Eden被清空,然后下一轮From survivor与To survivor交换角色,如此循环往复。如果对象的复制次数达到16次,该对象就会被送到老年代中。
两块Survivor避免碎片化

上述机制最大的好处就是,整个过程中,永远有一个survivor是空的,另一个非空的survivor无碎片。

那么,Survivor为什么不分更多块呢?比方说分成三个、四个、五个?显然,如果Survivor区再细分下去,每一块的空间就会比较小,很容易导致Survivor区满,因此,我认为两块Survivor区是经过权衡之后的最佳方案。

 

参考:

 

posted @ 2021-12-20 22:30  残城碎梦  阅读(47)  评论(0编辑  收藏  举报