java虚拟机之垃圾回收机制

5 篇文章 0 订阅

一.需要回收的内存区域

程序计数器、虚拟机栈、本地方法栈 3 个区域随线程生灭(因为是线程私有),栈中的栈帧随着方法的进入和退出而有条不紊地执行着出栈和入栈操作。而 Java 堆和方法区则不一样,一个接口中的多个实现类需要的内存可能不一样,一个方法中的多个分支需要的内存也可能不一样,我们只有在程序处于运行期才知道那些对象会创建,这部分内存的分配和回收都是动态的,垃圾回收期所关注的就是这部分内存。

二.垃圾收集算法

主要完成两个功能:检测出垃圾对象;回收垃圾对象占用的空间并归还给程序。

1.如何检测出垃圾对象?

(1)引用计数算法

原理:堆中的每一个对象有一个引用计数,当一个对象被创建,并把指向该对象的引用赋值给一个变量时,引用计数置为1,当再把这个引用赋值给其他变量时,引用计数加1,当一个对象的引用超过了生命周期或者被设置为新值时,对象的引用计数减1,任何引用计数为0的对象都可以被当成垃圾回收。当一个对象被回收时,它所引用的任何对象计数减1,这样,可能会导致其他对象也被当垃圾回收。

问题

jdk从1.2开始增加了多种引用方式:软引用、弱引用、虚引用,且在不同引用情况下程序应进行不同的操作。如果我们只采用一个引用计数法来计数无法准确的区分这么多种引用的情况。

如果一个对象A持有对象B,而对象B也持有一个对象A,那发生了类似操作系统中死锁的循环持有,这种情况下A与B的counter恒大于1,会使得GC永远无法回收这两个对象。

在代码objA = null 和 objB = null 之前,内存结构示意图如下:

在代码objA = null 和 objB = null 之后,内存结构示意图如下:

(2)可达性分析算法

原理:程序把所有的引用关系看作一张图,从一个节点GC ROOT开始,寻找对应的引用节点,找到这个节点以后,继续寻找这个节点的引用节点,当所有的引用节点寻找完毕之后,剩余的节点则被认为是没有被引用到的节点,即无用的节点。

一般来说,如下情况的对象可以作为GC Roots:

  • 虚拟机栈(栈桢中的本地变量表)中的引用的对象

  • 方法区中的类静态属性引用的对象

  • 方法区中的常量引用的对象

  • 本地方法栈中JNI(Native方法)的引用的对象

下面给出一个GCRoots的例子,如下图,为GCRoots的引用链。

由图可知,obj8、obj9、obj10都没有到GCRoots对象的引用链,即便obj9和obj10之间有引用链,他们还是会被当成垃圾处理,可以进行回收。

2.引用

无论是通过引用计数法判断对象引用数量,还是通过可达性分析法判断对象的引用链是否可达,判定对象的存活都与“引用”有关。JDK1.2以后,Java对引用的概念进行了扩充,将引用分为强引用、软引用、弱引用、虚引用四种(引用强度逐渐减弱)

(1)强引用

以前我们使用的大部分引用实际上都是强引用,这是使用最普遍的引用。如果一个对象具有强引用,那就类似于必不可少的生活用品,垃圾回收器绝不会回收它。当内存空间不足,Java虚拟机宁愿抛出OutOfMemoryError错误,使程序异常终止,也不会靠随意回收具有强引用的对象来解决内存不足问题。

(2)软引用(SoftReference)

如果一个对象只具有软引用,那就类似于可有可无的生活用品。如果内存空间足够,垃圾回收器就不会回收它,如果内存空间不足了,就会回收这些对象的内存。只要垃圾回收器没有回收它,该对象就可以被程序使用。软引用可用来实现内存敏感的高速缓存。软引用可以和一个引用队列(ReferenceQueue)联合使用,如果软引用所引用的对象被垃圾回收,JAVA虚拟机就会把这个软引用加入到与之关联的引用队列中。

(3)弱引用(WeakReference)

如果一个对象只具有弱引用,那就类似于可有可无的生活用品。弱引用与软引用的区别在于:只具有弱引用的对象拥有更短暂的生命周期。在垃圾回收器线程扫描它 所管辖的内存区域的过程中,一旦发现了只具有弱引用的对象,不管当前内存空间足够与否,都会回收它的内存。不过,由于垃圾回收器是一个优先级很低的线程, 因此不一定会很快发现那些只具有弱引用的对象。

弱引用可以和一个引用队列(ReferenceQueue)联合使用,如果弱引用所引用的对象被垃圾回收,Java虚拟机就会把这个弱引用加入到与之关联的引用队列中。

(4)虚引用(PhantomReference)

"虚引用"顾名思义,就是形同虚设,与其他几种引用都不同,虚引用并不会决定对象的生命周期。如果一个对象仅持有虚引用,那么它就和没有任何引用一样,在任何时候都可能被垃圾回收。

虚引用主要用来跟踪对象被垃圾回收的活动。虚引用与软引用和弱引用的一个区别在于: 虚引用必须和引用队列(ReferenceQueue)联合使用。当垃 圾回收器准备回收一个对象时,如果发现它还有虚引用,就会在回收对象的内存之前,把这个虚引用加入到与之关联的引用队列中。程序可以通过判断引用队列中是 否已经加入了虚引用,来了解被引用的对象是否将要被垃圾回收。程序如果发现某个虚引用已经被加入到引用队列,那么就可以在所引用的对象的内存被回收之前采取必要的行动。特别注意,在程序设计中一般很少使用弱引用与虚引用,使用软引用的情况较多,这是因为软引用可以加速JVM对垃圾内存的回收速度,可以维护系统的运行安全,防止内存溢出(OutOfMemory)等问题的产生

3.不可达的对象并非“非死不可”

即使在可达性分析法中不可达的对象,也并非是“非死不可”的,这时候它们暂时处于“缓刑阶段”,要真正宣告一个对象死亡,至少要经历两次标记过程;可达性分析法中不可达的对象被第一次标记并且进行一次筛选,筛选的条件是此对象是否有必要执行 finalize 方法。当对象没有覆盖 finalize 方法,或 finalize 方法已经被虚拟机调用过时,虚拟机将这两种情况视为没有必要执行。被判定为需要执行的对象将会被放在一个队列中进行第二次标记,除非这个对象与引用链上的任何一个对象建立关联,否则就会被真的回收。

4.如何判断一个常量是废弃常量

运行时常量池主要回收的是废弃的常量。那么,我们如何判断一个常量是废弃常量呢?

假如在常量池中存在字符串 "abc",如果当前没有任何String对象引用该字符串常量的话,就说明常量 "abc" 就是废弃常量,如果这时发生内存回收的话而且有必要的话,"abc" 就会被系统清理出常量池。

5.如何判断一个类是无用的类

方法区主要回收的是无用的类,那么如何判断一个类是无用的类的呢?

判定一个常量是否是“废弃常量”比较简单,而要判定一个类是否是“无用的类”的条件则相对苛刻许多。类需要同时满足下面3个条件才能算是 “无用的类”

该类所有的实例都已经被回收,也就是 Java 堆中不存在该类的任何实例。

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

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

虚拟机可以对满足上述3个条件的无用类进行回收,这里说的仅仅是“可以”,而并不是和对象一样不使用了就会必然被回收。

6.垃圾收集算法

  • 标记清除算法

  • 复制算法

  • 标记整理算法

  • 分收代集算法

(1)标记清除算法

首先标记出所有需要回收的对象,使用可达性分析算法判断一个对象是否为可回收,在标记完成后统一回收所有被标记的对象。下图是算法具体的一次执行过程后的结果对比。

说明:1.效率问题,标记和清除两个阶段的效率都不高。2.空间问题,标记清除后会产生大量不连续的内存碎片,以后需要给大对象分配内存时,会提前触发一次垃圾回收动作。

(2)复制算法

将内存分为两等块,每次使用其中一块。当这一块内存用完后,就将还存活的对象复制到另外一个块上面,然后再把已经使用过的内存空间一次清理掉。图是算法具体的一次执行过程后的结果对比。

说明:1.无内存碎片问题。2.可用内存缩小为原来的一半。 3.当存活的对象数量很多时,复制的效率很慢。

(3)标记整理算法

标记过程还是和标记 - 清除算法一样,之后让所有存活的对象都向一端移动,然后直接清理掉边界以外的内存,标记 - 整理算法示意图如下:

说明:1.无需考虑内存碎片问题。

(4)分代收集算法

把堆分为新生代和老年代,然后根据各年代的特点选择最合适的回收算法。在新生代基本上都是朝生暮死的,生存时间很短暂,因此可以采拥标记 - 复制算法,只需要复制少量的对象就可以完成收集。而老年代中的对象存活率高,也没有额外的空间进行分配担保,因此必须使用标记 - 整理或者标记 - 清除算法进行回收。

三、垃圾收集器

1、Serial收集器:

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

2、ParNew收集器:

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

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

3、Parallel Scavenge收集器:

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

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

(1)-XX:MaxGCPauseMillis:控制最大垃圾收集停顿时间,是一个大于0的毫秒数。

(2)-XX:GCTimeRatio:直接设置吞吐量大小,是一个大于0小于100的整数,也就是程序运行时间占总时间的比率,默认值是99,即垃圾收集运行最大1%(1/(1+99))的垃圾收集时间。

(3)支持自适应的GC调节策略。它还提供一个参数:-XX:+UseAdaptiveSizePolicy,这是个开关参数,打开之后就不需要手动指定新生代大小(-Xmn)、Eden与Survivor区的比例(-XX:SurvivorRation)、新生代晋升年老代对象年龄(-XX:PretenureSizeThreshold)等细节参数,虚拟机会根据当前系统运行情况收集性能监控信息,动态调整这些参数以达到最大吞吐量。

4、Serial Old收集器:

Serial Old是Serial收集器的老年代版本,使用单线程执行和“标记-整理”算法。主要用途是作为CMS收集器的后备垃圾收集方案,在并发收集发生 Concurrent Mode Failure 的时候,临时启动Serial Old收集器重新进行老年代的垃圾收集。

5、Parallel Old收集器:

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

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

6、CMS收集器:

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

(1)初始标记:需要停顿所有用户线程,初始标记仅仅是标记出GC ROOTS能直接关联到的对象,速度很快。

(2)并发标记:进行GC ROOTs可达性分析算法阶段,判定对象是否存活,和用户线程一起工作,不需要暂停工作线程。

(3)重新标记:修正并发标记期间,因用户程序继续运行而导致标记产生变动的那一部分对象的标记记录。需要停顿所有用户线程,停顿时间会被初始标记阶段稍长,但比并发标记阶段要短。

(4)并发清除:清除GC Roots不可达对象,和用户线程一起工作,不需要暂停工作线程。

整个过程中耗时最长的并发标记和并发清除过程中,收集器线程都可以与用户线程一起工作,所以整体来说,CMS收集器的内存回收过程是与用户线程一起并发执行的。

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

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

(2)CMS收集器无法处理浮动垃圾。由于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之后,跟着来一次碎片整理过程。

7、G1收集器:

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

  • 基于“标记-整理”算法实现,不会产生内存碎片。

  • 可以在不牺牲吞吐量前提下,精确控制停顿时间,实现低停顿垃圾回收

其他特点:

  • 并行性: 回收期间, 可由多个线程同时工作, 有效利用多核cpu资源;

  • 并发性: 与应用程序可交替执行, 部分工作可以和应用程序同时执行,

  • 分代GC: 分代收集器,同时兼顾年轻代和老年代。他能够采用不同的方式去处理新创建的对象和已经存活了一段时间、熬过了多次GC的对象,以便获取更好的GC效果。

(2)垃圾收集原理:

G1 收集器不采用传统的新生代和老年代物理隔离的布局方式,仅在逻辑上划分新生代和老年代,将整个堆内存划分为2048个大小相等的独立内存块Region,每个Region是逻辑连续的一段内存,具体大小根据堆的实际大小而定,整体被控制在 1M - 32M 之间,且为2的N次幂(1M、2M、4M、8M、16M和32M),并使用不同的Region来表示新生代和老年代,G1不再要求相同类型的 Region 在物理内存上相邻,而是通过Region的动态分配方式实现逻辑上的连续。

G1收集器通过跟踪Region中的垃圾堆积情况,每次根据设置的垃圾回收时间,回收优先级最高的区域,避免整个新生代或整个老年代的垃圾回收,使得stop the world的时间更短、更可控,同时在有限的时间内可以获得最高的回收效率。

通过区域划分和优先级区域回收机制,确保G1收集器可以在有限时间获得最高的垃圾收集效率。

(3)G1的收集过程:

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

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

四、答疑

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

1、为什么要有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区。假设现在只有一个survivor区,我们来模拟一下流程:

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

我绘制了一幅图来表明这个过程。其中色块代表对象,白色框分别代表Eden区(大)和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区是经过权衡之后的最佳方案。

  • 0
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值