JVM可以说是为了Java开发人员屏蔽了很多复杂性,让Java开发的变的更加简单,让开发人员更加关注业务而不必关心底层技术细节,这些复杂性包括内存管理,垃圾回收,跨平台等,今天我们主要看看JVM的垃圾回收机制是怎么运行的,希望能够帮到大家,

哪些对象是垃圾呢?

Java程序运行过程中时刻都在产生很多对象,我们都知道这些对象实例是被存储在堆内存中,JVM的垃圾回收也主要是针对这部分内存,每个对象都有自己的生命周期,在这个对象不被使用时,这个对象将会变成垃圾对象被回收,内存被释放,那么如何判断这个对象不被使用呢?主要有如下两种方法:

引用计数算法

这个方法什么意思呢?就是给每个对象绑定一个计数器,每当指向该对象的引用增加时,计数器加1,相反减少时也会减1,当计数器的值变为0时,该对象就会变成垃圾对象,也就是最终没有任何引用指向该对象。这种方法比较简单,实现起来也容易,但有一个致命缺点,有可能会造成内存泄漏,也就是垃圾对象无法被回收,我们看下面代码,创建两个对象,每个对象的成员变量都持有对方的引用,这就是一个循环引用,形成了一个环,此时虽然两个对象都不再使用了,但每个对象的计数器并不为0,导致无法被回收,那有办法解决吗?当然有,看下面的算法。

class Person {
    public Object object = null;
    public void test(){
       Person person1 = new Person();
       Person person2 = new Person();
       
       person1.object = person2;
       person2.object = person1;
       person1 = null;
       person2 = null;
    }
}
可达性分析算法

知道了上面算法的缺点,那么可达性分析是怎么解决的呢?在堆内存中,JVM定义了一系列GCroots对象,这些对象称为GC时个根对象,沿着这些根对象像链表一样一直往下找,凡是在这个链上的对象都是符合可达性的,否则认为这个对象不可达,那么这个对象就是一个垃圾对象,也就是说垃圾对象和GC根对象没有直接或者间接关联关系,如下图,黄色的对象就是可以被回收的垃圾对象,因为根GC根对象没有任何关联。

理解了可达性分析算法的原理,那么估计有疑问了,哪些对象能作为GCroot对象呢,一起来看一下JVM中对GCroot对象定义的规范。

  1. Java虚拟机栈中引用的对象
  2. 堆中静态属性引用的对象(JDK8以前时方法区中)
  3. 堆中常量引用的对象(JDK8以前是方法区中)
  4. 本地方法(Native方法)栈中引用对象的

垃圾回收算法解读

在确定了哪些垃圾对象可以被回收后,垃圾收集器要做的就是开始回收这些垃圾,那么如何在堆内存中高效的回收这些垃圾对象呢?,加下来我们介绍几种算法思想

标记清除算法

标记清除是一种比较基础的算法,其思想对内存中的所有对象扫描,将垃圾对象进行标记,最后将标记的垃圾对象清除,那么这部分内存就可以使用了,如下图,第一行是回收前的内存状态,第二行是回收后的内存状态,发现了什么?对,就是内存碎片,内存碎片会导致大对象分配失败,假设我们接下来的对象都是使用2M内存,则那个1M就会浪费掉。

标记整理算法

相对标记清除算法,标记整理多了一步,其思想也是对内存中的对象扫描,标记存活对象和垃圾对象,然后将对象移动,使得存活的对象一边,待回收的对象在一边,然后再对待回收对象进行回收,这样就解决了内存碎片问题,但是对象频繁的移动会带来指针地址指向不断发生变化,整理内存碎片会消耗较长时间,引起应用程序的暂停。

分半复制算法

标记整理算法解决了内存碎片问题,但内存整理也带来了新的问题,复制算法能够缓解对象移动的问题,但不能根本上解决,复制算法本质上是空间换时间的一种算法,将内存分为大小相等的两部分, 在其中一部分内存使用完之后,将其中活着的对象移入到另一半内存中,然后将这一半内存清空。这种算法的代价浪费一半的内存,比如8G内存,只有4G是可以使用的。

分代算法(集所有优点,弃缺点)

上面三种算法各有优缺点,但都不能完美的解决垃圾回收中遇到的问题,那能不能将上面三种算法的优点都集合起来形成一种新的组合呢?是的,分代算法就是这样的,我们常用不考虑业务的架构都是耍流氓,那么垃圾回收算法也需要结合对象的生命周期来决定,我们都知道应用程序中大多数对象都是朝生夕死的,分代算法将内存分为年轻代和年老代两个区域,年轻代中采用复制算法,因为年轻代中每次收集时都有大量对象死去,只有少量对象存活,所以采用复制算法这样移动的对象比较少,年老代中采用标记清除算法,年老代中的对象都是存活时间比较长的对象,但当内存碎片比较严重时可以进行一次整理(结合使用),

前面提到复制算法会浪费一半的内存,有没有办法浪费的少一点呢?分代算法在年轻代中是怎么解决呢?首先确定的每次垃圾收集时存活对象总是少量的,年轻代中将内存分成了三部分,Eden区域,Survivor1区,Survivor2区,后两个区域用来存储存活的对象,对象创建时总是在Eden区域,每当Eden区域满了之后,垃圾回收时开始将所有存活的对象放入其中一个Survivor区域,并且将另一个Survivor区域和Eden区域清空,如此,两个Survivor区域只需要少量内存空间,这样就可以充分利用内存了。

JVM垃圾回收器详解

基于上面的垃圾回收算法,有很多的垃圾收集器,JVM规范对于垃圾收集器的应该如何实现没有任何规定,因此不同的厂商、不同版本的虚拟机所提供的垃圾收集器差别较大,这里只看HotSpot虚拟机。

Serial和Serial Old垃圾收集器

Serial收集器历史非常悠久了,它是在新生代上实现垃圾收集的,SerialOld是在老年代上实现垃圾收集的

他们两都是单线程工作的(早期多核发展还不是这么好),它在工作时必须暂停应用程序的线程,也就是会发生Stop The World,直到垃圾回收工作完成

Serial年轻代采用复制算法 ,Serial Old老年代采用标记整理算法,

这种收集器的优点是简单,工作起来非常高效,对于单核CPU来说没有线程切换的开销,专门做自己的事,所以在单核CPU上或者内存较小时非常适用,缺点也很明显,当内存过大时,应用程序暂停无法提供服务,”-XX:+UseSerialGC”这个参数用来开启Serial垃圾收集器。

ParNew垃圾收集器

ParNew是Serial收集器的多线程版本,除了是多线程,其它的都一样(也会发生Stop The World,也是新生代的收集器)。它是目前唯一能够和CMS合作使用的新生代垃圾收集器。

Parallel Scavenge和Parallel Old垃圾收集器

Parallel Scavenge收集器是一个新生代收集器,Parallel Old是一个老年代收集器,前者使用的是复制算法,后者使用的是标记整理算法,他们又都是并行的多线程收集器。

Parallel Scavenge和Parallel Old收集器关注点是吞吐量(如何高效率的利用CPU)所谓吞吐量就是CPU中用于运行用户代码的时间与CPU总消耗时间的比值。

在对CPU (吞吐量)比较敏感的情况下,建议使用这两者结合

CMS(Concurrent Mark Sweep)收集器

重点来了,CMS收集器的目标是获取最短停顿的时间(即GC时应用程序线程暂停的时间最短),它是老年代收集器,基于标记清除算法(产生内存碎片),并发收集(多线程),CMS是HotSpot在JDK1.5推出的第一款真正意义上的并发(Concurrent)收集器;第一次实现了让垃圾收集线程与用户线程(基本上)同时工作;他的应用场景主要是在和用户交互较多的地方使用,减少用户感受到的服务延迟。

CMS收集器的运作过程比较复杂,下面我们仔细了解一下这个过程,看看CMS的优秀设计思想
上面提到CMS是基于标记清除算法,CMS将标记分为了三部分,清除一部分,总共四部分

初始标记

首先这个过程是发生STW的,也就是应用程序线程暂停,其次这个过程是非常短暂的,并且是单线程执行的,这一步的主要做的事情标记GCRoots能直接关联老年代对象,遍历新生代,标记新生代中可达的老年对象

并发标记

这一阶段用户线程是运行的,因为这一阶段应用程序线程还在执行,所有还会持续产生新的对象,这一阶段主要是根据初始阶段标记出来的可达的GCRoots直接关联对象继续递归遍历这些对象的可达对象,但是不会标记产生的新对象,为了避免后续重新扫描老年代,这一阶段会把新产生的对象打一个标记(Dirty脏对象),后续只会扫描这些标记为Dirty的对象

这一阶段耗时最长了,所以在这一阶段用户产生的垃圾对象足够多时(也就是老年代已经无法存储了)就会发生concurrent mode failure,当这一错误出现时CMS就会退化为另一个垃圾会收器(Serial Old)暂停用户线程,单线程回收,这也是CMS缺点之一

预清理

这一阶段用户线程是运行的,主要是处理新生代已经发现的引用,比如在上面的并发阶段,Enen区域分配了一个新的对象M,M引用了老年代的一个对象N,但这个N之前没有被标记为存活,那么此时这个N就会被标记,同时也会把上一阶段的Dirty对象重新标记,这一阶段也可以通过参数CMSPrecleaningEnabled来进行关闭,默认是开启

可中断的预清理

这一阶段用户线程是运行的,该阶段发生有一个前提,就是新生代Eden区域内存使用必须大于2M,这个值可以通过如下参数控制。

CMSScheduleRemarkEdenSizeThreshold

可中断的预处理是什么意思呢?就是这一阶段可以中断,在该阶段主要循环做两件事,一是处理From和To区域的对象,标记可达的老年代对象,二是扫描标记Dirty对象

中断就指的是这个循环是可以中断的,条件有三个:

  1. MSMaxAbortablePrecleanLoops设置循环次数,默认是0,表示无限制
  2. CMSMaxAbortablePrecleanTime设置执行阈值,默认是5秒
  3. CMSScheduleRemarkEdenPenetration,新生代内存使用率到了阈值,默认是50%

    并发重新标记

    这一阶段也是STW的,这个过程也会非常短暂,为什么呢?因为上面并发标记,预清理已经标记了大部分存活对象,这一阶段也是针对上面新产生的对象进行扫描标记,可能产生的新的引用如下

  4. 老年代的新对象被GCRoots引用
  5. 老年代未标记的对象被新生代的对象引用
  6. 老年代已标记的对象增加新引用指向老年代其它未标记的对象
  7. 新生代对象指向老年的代的引用被删除

上述对象中可能有一些已经在Precleaning阶段和AbortablePreclean阶段被处理过,但总存在没来得及处理的,所以还有进行如下的处理

  1. 遍历新生代对象,重新标记
  2. 根据GC Roots,重新标记
  3. 遍历老年代的Dirty,重新标记,这里的Dirty Card大部分已经在clean阶段处理过

这个过程中会遍历所有新生代对象,如果新生代对象较多,可能比较耗时,但是如果上面可中断预处理过程中发生了一次YGC,那么这次遍历就会轻松很多,但是这一次并不可控制,CMS算法中提供了一个参数:CMSScavengeBeforeRemark,默认并没有开启,如果开启该参数,在执行该阶段之前,会强制触发一次YGC,可以减少新生代对象的遍历时间,回收的也更彻底一点。但这个参数也有缺点,利是降低了Remark阶段的停顿时间,弊的是在新生代对象很少的情况下也多了一次YGC,就看运气了。

并发清除

这一阶段用户线程是运行的,同时GC线程开始对为标记的区域做清扫,回收所有的垃圾对象,这一阶段用户线程还会产生新的对象,这一部分变成垃圾对象后,CMS是无法清理的,这一部分垃圾对象也被称为浮动垃圾,这也是CMS缺点之一

内存碎片问题

我们知道CMS是基于标记-清除算法的,CMS只会删除无用对象,会产生内存碎片,那么内存碎片什么时候整理呢?下面这个参数可以配置

-XX:CMSFullGCsBeforeCompaction=n

意思是说在经过n次CMS的GC时,才会做内存碎片整理。如果n等于3,也就是没经过3次后的CMS-GC会进行一次内存碎片整理,这个默认值是0,代表着直到碎片空间无法存储新对象时才会进行内存碎片整理。

还有一种情况,在进行Minor GC时,Survivor Space放不下,对象只能放入老年代,而此时老年代也放不下造成的,多数是由于老年带有足够的空闲空间,但是由于碎片较多,新生代要转移到老年带的对象比较大,找不到一段连续区域存放这个对象导致的,这个时候会发生FullGC,同时进行碎片空间整理。

针对concurrent mode failure解决办法
-XX:CMSInitiatingOccupancyFraction=70
-XX:+UseCMSInitiatingOccupancyOnly

我们都知道了concurrent mode failure产生的原因,那么可以通过上面两个参数来防止这个问题产生 第二个参数是用来指定使用第一个参数的,如果没有第二个参数,则JVM垃圾回收时只有第一次会采用第一个参数,后续会自行调整。

第一个参数代表设定CMS在对内存占用率达到70%的时候开始GC,,这个参数要好不管监控调整以达到一个合适的值,如果过小则gc过于频繁,如果过大则可能产生上面标题的问题(本身这个参数是用来解决这个问题,设置不当可能会引发这个问题)

还有一个参数,这个参数开启后每次FulllGC都会压缩整理内存碎片,默认值是false,不开启

XX:+UseCMSCompactAtFullCollection

大多数情况下不需要设置这两个参数,JVM会自行调优,决定在什么时候GC,除非你觉得你比JVM的自动调优做的好,那么你可以自行调优。

过早提升和提升失败

在 Minor GC 过程中,Survivor Unused 可能不足以容纳 Eden 和另一个 Survivor 中的存活对象, 那么多余的将被移到老年代, 称为过早提升(Premature Promotion),这会导致老年代中短期存活对象的增长, 可能会引发严重的性能问题。 再进一步,如果老年代满了, Minor GC 后会进行 Full GC, 这将导致遍历整个堆, 称为提升失败(Promotion Failure)。
早提升的原因Survivor空间太小,容纳不下全部的运行时短生命周期的对象,如果是这个原因,可以尝试将Survivor调大,否则年轻代生命周期的对象提升过快,导致老年代很快就被占满,从而引起频繁的full gc;对象太大,Survivor和Eden没有足够大的空间来存放这些大对象。
提升失败原因当提升的时候,发现老年代也没有足够的连续空间来容纳该对象。为什么是没有足够的连续空间而不是空闲空间呢?老年代容纳不下提升的对象有两种情况:老年代空闲空间不够用了;老年代虽然空闲空间很多,但是碎片太多,没有连续的空闲空间存放该对象。

查看JDK8默认垃圾收集器

控制台输入如下命令

java -XX:+PrintCommandLineFlags -version

得到结果如下,我们可以看到 -XX:+UseParallelGC 这个参数,这个参数表示JDK8的年轻代使用垃圾收集器为Parallel Scavenge,老年代垃圾收集器为Serial Old

 XX:InitialHeapSize=266390080 
-XX:MaxHeapSize=4262241280 
-XX:+PrintCommandLineFlags 
-XX:+UseCompressedClassPointers -XX:+UseCompressedOops 
-XX:-UseLargePagesIndividualAllocation -XX:+UseParallelGC
java version "1.8.0_191"
Java(TM) SE Runtime Environment (build 1.8.0_191-b12)
Java HotSpot(TM) 64-Bit Server VM (build 25.191-b12, mixed mode)

版权声明:本文为sy270321原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。
本文链接:https://www.cnblogs.com/sy270321/p/12320860.html