深入理解Java虚拟机(二):垃圾收集器与内存分配策略

字数 7628阅读 46

参考博客:https://www.cnblogs.com/parryyang/p/5748711.html

参考博客: https://blog.csdn.net/dongyuxu342719/article/details/78835431

概述

1、内存回收的区域主要在堆和方法区,虚拟机栈和本地方法栈的内存分配与回收具有确定性。

在Java内存运行时区域的各个部分中,程序计数器、虚拟机栈、本地方法栈3个部分是线程私有的,随线程而生,随线程而灭;栈中的栈帧随着方法进入和退出而执行着入栈和出栈的操作。每一个栈帧中分配多少内存基本上在类结构确定下来时就已知,因此这几个区域的内存分配和回收都具备确定性,在这几个区域内就不需要过多的考虑回收的问题,因为方法结束或线程结束时,内存自然就随着回收了。

而在Java堆和方法区则不一样,一个接口中的多个实现类需要的内存可能不一样,一个方法中的多个分支需要的内存可能不一样,我们只有在程序运行期间才能知道会创建哪些对象,这部分内存的分配和回收都是动态的,垃圾收集器所关注的是这部分内存。

2、如何判断哪些对象需要回收?

接下来介绍的引用计数法和可达性分析法,引用计数法具有局限性,不能回收循环引用的对象。

3、如何回收对象?

本章介绍的垃圾收集算法回答了这个问题。现在商用的垃圾回收机制一般使用分代收集算法,年轻代使用复制算法,老年代使用标记整理法。

一、对象已死吗

1、引用计数法

引用计数法是指给对象添加一个引用计数器,每当有一个地方引用它时,计数器值就加1;当引用失效时,计数器值就减1;任何时候计数器值为0就表示不会再被任何对象使用。

客观的说,引用计数法(Reference Counting)的实现简单,判断效率也很高,在大部分情况下都是一个不错的算法。但在主流的Java虚拟机里面没有使用引用计数法来管理内存,主要原因是它很难解决对象之间相互循环引用的问题。

引用计数无法解决下面两个对象相互引用但不可达的问题,但运行代码后发现对象实际上能够被GC。

public class ReferenceCountingGC {  
2.    public Object instance=null;  
3.    private static final int _1MB=1024*1024;  
4.    private byte [] bigSize=new byte[2*_1MB];  
5.      
6.    public static void testGC(){  
7.        ReferenceCountingGC objA=new ReferenceCountingGC();  
8.        ReferenceCountingGC objB=new ReferenceCountingGC();  
9.        objA.instance=objB;  
10.        objB.instance=objA;  
11.        objA=null;  
12.        objB=null;  
13.        System.gc();  
14.    }  
15.    public static void main(String []args){  
16.        testGC();  
17.    }  
18.}  

2、可达性分析算法

在主流的商用程序语言(Java、C#)的主流实现中,都是通过可达性分析(ReachabilityAnalysis)来判断对象是否存活的。这个算法的基本思路是通过一系列称为GC Roots的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链(Reference Chain),当一个对象到GC Roots没有任何引用链(就是从GC Roots到这个对象不可达)时,则证明此对象是不可用的。

在Java语言中,可作为GC Roots的对象包括下面几种:

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

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

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

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

image

二、垃圾收集算法

1、标记-清除法

分为“标记”和“清除”两个阶段:首先标记出需要回收的所有对象,在标记完成后统一回收标记的对象,标记过程就是之前讲的通过引用计数法和可达性分析法进行判定。

它的主要不足有两个:

  • 效率问题:标记和清除的效率都不高,

  • 空间问题:标记清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致在需要分配较大对象时,无法找到连续的内存空间而不得不提前触发另一次垃圾收集动作。

image

2、复制算法

将内存划分为大小相等的两块,每次只使用其中的一块。当这块内存用完了,就将还存活的对象复制到另一块内存上,然后把已使用过的内存空间一次清理掉。

优点:每次只对其中一块进行GC,不用考虑内存碎片的问题,并且实现简单,运行高效

缺点:内存缩小了一半

image

注:现在商用虚拟机都采用这种算法来回收新生代,因为新生代中98%的对象都是朝生夕死的,所以并不需要1:1的比例来划分内存空间,而是将内存分为一块较大的Eden空间和两块较小的Survivor空间,每次使用Eden和一块Survivor空间。当回收时,将Eden和Survivor中开存活的对象一次性复制到另一块Survivor空间上,最后清理掉Eden和刚才使用过的Survivor空间。

HotSpot虚拟机默认Eden和Survivor的大小是8:1,也就是每次新生代可用空间是整个新生代容量的90%(80%+10%),只有10%的内存会被“浪费”。当然,98%可回收只是一般的情况的数据,我们没有办法保证每次回收都只有不多于10%的对象存活,当Survivor空间不够用时,需要依赖其他内存(这里指老年代)进行分配担保(Handle Promotion)。

内存的分配担保类似于银行贷款,如果我们的信誉好。在98%的情况下都能按时偿还,于是银行可能默认我们下次也能按时偿还贷款,只需要有一个担保人能保证如果我不能还款时,可以从他的账户扣钱,那银行就认为没有风险了。内存的分配担保也一样,如果另外一块Survivor空间没有足够空间存放上一次新生代收集下来的存活对象时,这些对象将直接通过分配担保机制进入老年代。

3、标记-整理法

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

根据老年代的特点,有人提出了另外一种“标记-整理”(Mark-Compact)算法,标记过程任然与“标记-清除”算法的标记过程相同,但后续步骤不是对可回收对象进行直接清理,而是让所有活的对象都移动到另一端,然后直接清理掉端边界以外的内存,

image

4、分代收集算法

当前商业虚拟机的垃圾收集都采用“分代收集”(GenerationalCollection)算法,这种算法只是根据对象存活周期的不同将内存划分为几块。一般是把Java堆划分为新生代和老年代,这样就可以根据各个年代的特点采用最适合的收集算法。在新生代中每次都有大量的对象死去,只有少量存活,那就采用复制算法,只需要付出少量存活对象的复制成本就可以完成收集。而老年代中因为对象存活率高、没有额外空间对它们进行分配担保,就必须使用“标记-清理”或“标记-整理”算法来进行回收。

5、HotPot 分代收集算法

image

对象将根据存活的时间被分为:年轻代、年老代、永久代。

年轻代:

所有新生成的对象首先都是放在年轻代的。年轻代的目标就是尽可能快速的收集掉那些生命周期短的对象。年轻代分三个区。一个Eden区,两个Survivor区(一般而言)。大部分对象在Eden区中生成。当Eden区满时,还存活的对象将被复制到Survivor区(两个中的一个),当这个Survivor区满时,此区的存活对象将被复制到另外一个Survivor区。同时,根据程序需要,Survivor区是可以配置为多个的(多于两个),这样可以增加对象在年轻代中的存在时间,减少被放到年老代的可能。

年老代:

在年轻代中经历了N次垃圾回收后仍然存活的对象,就会被放到年老代中。因此,可以认为年老代中存放的都是一些生命周期较长的对象。

持久代:

用于存放静态文件,如Java类、方法等。

Scavenge GC

一般情况下,Eden空间满时,就会触发Scavenge GC,清除Eden区非存活对象,并且把尚且存活的对象移动到Survivor区。然后整理Survivor的两个区。Eden区的GC会频繁进行,速度也很快。

Full GC

对整个堆进行整理,包括Young、Tenured和Perm。Full GC因为需要对整个对进行回收,所以比Scavenge GC要慢,因此应该尽可能减少Full GC的次数。在对JVM调优的过程中,很大一部分工作就是对于FullGC的调节。

三、安全点:Stop the world

1、Stop the world

GC 操作的某些阶段,如可达性分析遍历 GC ROOT 节点找引用链,需要确保在一致性的快照中进行(一致性的意思指分析期间整个执行系统好像冻结在某个时间节点上,不可以出现分析过程中对象引用关系还在不断变化的情况,该点不满足分析的准确性就无法保证)。这是导致GC进行时必须停顿所有的执行线程的其中一个重要原因。(Sun 将这件事情称之为 “Stop the world”。)即使是在号称几乎不会发生停顿的CMS收集器中,枚举根节点时也是必须停顿的。

2、OopMap (*选学)

OopMap:在所有执行线程停顿下来后,虚拟机并不需要逐个检查每个栈的局部变量表来查找引用的位置,而应当有办法直接得知哪些地方存着对象的应用。在执行到某条指令时,栈中什么位置存放了什么变量是确定的,在编译期间虚拟机就可以计算出这些信息。在 HotSpot 的实现中,用一组称为 OopMap 的数据结构记录了某一些执行节点哪些位置是引用,这样,在GC扫描时就可以直接获取这些信息了。

3、SafePoint (*选学)

在 OopMap 的协助下,HotSpot 可以快速且准确地完成 GC Roots 枚举。但是随着程序的运行,引用关系会随之变化,虚拟机不可能为每一个执行节点都生成 OopMap 来记录引用关系,那将需要大量额外的空间,GC的成本将会变得很高。实际上,HotSpot虚拟机也的确没有为每一条指令都生成OopMap,只是在特定位置记录这些信息,这些位置称为“安全点”(Safepoint),即程序执行时并非在所有地方都停顿下来开始GC,只有在到达安全点时才能暂停。

Safepoint既不能选的太少导致GC等待太长时间,也不能过于频繁以至于过分增大运行时的负荷。安全点一般选取在方法调用、循环跳转、异常跳转等让程序长时间执行的指令。

对于Safepoint,另一个需要考虑的问题是如何在GC发生时让所有线程(这里不包括执行JNI调用的线程)都“跑”到最近的安全点上再停顿下来。主动式中断的思想是当GC需要中断线程的时候,不直接对线程操作,仅仅简单的设置一个标志,各线程执行时主动去轮询这个标志,当发现中断标志为真时就自己中断挂起。轮询标志的位置和安全点是重合的,另外再加上创建对象需要分配内存的地方。

4、SafeRegion (*选学)

Safepoint机制保证了程序执行时,在不太长的时间内就会遇到可进入GC的Safepoint。但是程序不执行的时候呢?不执行就是程序没有分配到CPU时间,典型的例子就是线程处于Sleep状态或Blocked状态,这时候线程无法响应JVM的中断请求,“走”到安全的地方再挂起。JVM显然也不会等待线程重新分配CPU时间。对于这种情况就需要安全区域(Safe Region)来解决。

安全区域是指在一段代码片段中引用关系不会发生变化,在这个区域的任意地方开始GC都是安全的。我们也把Safe Region称为扩展的Safepoint。

在线程执行到Safe Region中的代码的时候,首先标识自己进入了Safe Region,这样,当在这段时间里JVM要发起GC时,就不用考虑标识自己为Safe Region状态的线程了。在线程要离开Safe Region时,它要检查系统是否已经完成了根节点枚举()或者整个GC过程如果完成了,那线程就继续执行,否则它就必须等待收到可以安全离开Safe Region的信号为止。

四、垃圾收集器

参考博客:https://blog.csdn.net/tjiyu/article/details/53983650

垃圾收集器是垃圾回收算法(标记-清除算法、复制算法、标记-整理算法)的具体实现,不同商家、不同版本的JVM所提供的垃圾收集器可能会有很在差别,下面主要介绍HotSpot虚拟机中的垃圾收集器。

本节介绍这些收集器的特性、基本原理和使用场景。没有最好的收集器,更没有万能的收集,选择的只能是适合具体应用场景的收集器。

1、垃圾收集器组合

JDK7/8后,HotSpot虚拟机所有收集器及组合(连线),如下图:

image
  • 图中展示了7种不同分代的收集器:

​ Serial、ParNew、Parallel Scavenge、Serial Old、Parallel Old、CMS、G1;

  • 它们所处区域,表明其是属于新生代收集器还是老年代收集器:

    新生代收集器:Serial、ParNew、Parallel Scavenge

    老年代收集器:Serial Old、Parallel Old、CMS

    整堆收集器:G1

  • 两个收集器间有连线,表明它们可以搭配使用

​ Serial/Serial Old、Serial/CMS、ParNew/Serial Old、ParNew/CMS、Parallel Scavenge/Serial Old、Parallel Scavenge/Parallel Old、G1;

  • 其中Serial Old作为CMS出现"Concurrent Mode Failure"失败的后备预案。

2、 Minor GC 和 Full GC

  • Minor GC

​ 又称新生代GC,指发生在新生代的垃圾收集动作;

​ 因为Java对象大多是朝生夕灭,所以Minor GC非常频繁,一般回收速度也比较快;

  • Full GC

​ 又称Major GC或老年代GC,指发生在老年代的GC;

​ 出现Full GC经常会伴随至少一次的Minor GC(不是绝对,Parallel Sacvenge收集器就可以选择设置Major GC策略);

​ Major GC速度一般比Minor GC慢10倍以上;

3、Serial 收集器(*选学)

  • Serial(串行)垃圾收集器是最基本、发展历史最悠久的收集器;JDK1.3.1前是HotSpot新生代收集的唯一选择。

  • 特点: 针对新生代;采用复制算法; 单线程收集;进行垃圾收集时,必须暂停所有工作线程,直到完成。

  • 应用场景:依然是HotSpot在Client模式下默认的新生代收集器;也有优于其他收集器的地方:简单高效(与其他收集器的单线程相比);对于限定单个CPU的环境来说,Serial收集器没有线程交互(切换)开销,可以获得最高的单线程收集效率;在用户的桌面应用场景中,可用内存一般不大(几十M至一两百M),可以在较短时间内完成垃圾收集(几十MS至一百多MS),只要不频繁发生,这是可以接受的。

  • Serial/Serial Old组合收集器运行示意图如下:

image

4、ParNew收集器(*选学)

  • ParNew垃圾收集器是Serial收集器的多线程版本。

  • 除了多线程外,其余的行为、特点和Serial收集器一样;如Serial收集器可用控制参数、收集算法、Stop The World、内存分配规则、回收策略等;两个收集器共用了不少代码;

  • 应用场景:在Server模式下,ParNew收集器是一个非常重要的收集器,因为除Serial外,目前只有它能与CMS收集器配合工作;但在单个CPU环境中,不会比Serail收集器有更好的效果,因为存在线程交互开销。

  • ParNew/Serial Old组合收集器运行示意图如下:
    image

5、Parallel Scavenge收集器(*选学)

  • Parallel Scavenge垃圾收集器因为与吞吐量关系密切,也称为吞吐量收集器(Throughput Collector)。

  • 特点:有一些特点与 ParNew 收集器相似,新生代收集器;采用复制算法;多线程收集。主要特点是:Parallel Scavenge 收集器的目标则是达一个可控制的吞吐量(Throughput),系统通过调节新生老生代空间比例等,来调节吞吐量。

  • Parallel Scavenge 收集器提供了两个参数用来精确控制吞吐量,分别是控制最大垃圾收集停顿时间的-XX:MaxGCPauseMillis,以及直接设置吞吐量大小 -XXGCTimeRatio。停顿时间和吞吐量存在相互制约的关系:GC停顿时间缩短是以牺牲吞吐量和新生代空间来换取的:系统把新生代调小一点,收集300MB新生代肯定比收集500MB快吧,这也直接导致垃圾收集发生的更频繁一些,原来10收集一次,现在变成5秒收集一次,每次停顿70毫秒,停顿时间的确是下降了,但吞吐量也降下来了。

  • 应用场景: 高吞吐量为目标,即减少垃圾收集时间,让用户代码获得更长的运行时间;当应用程序运行在具有多个CPU上,对暂停时间没有特别高的要求时,即程序主要在后台进行计算,而不需要与用户进行太多交互;例如,那些执行批量处理、订单处理、工资支付、科学计算的应用程序;

  • 吞吐量与收集器关注点说明

    (A)、吞吐量(Throughput)

    ​ CPU用于运行用户代码的时间与CPU总消耗时间的比值;

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

    ​ 高吞吐量即减少垃圾收集时间,让用户代码获得更长的运行时间;

    (B)、垃圾收集器期望的目标(关注点)

    (1)、停顿时间

    ​ 停顿时间越短就适合需要与用户交互的程序;

    ​ 良好的响应速度能提升用户体验;

    (2)、吞吐量

    ​ 高吞吐量则可以高效率地利用CPU时间,尽快完成运算的任务;

    ​ 主要适合在后台计算而不需要太多交互的任务;

    (3)、覆盖区(Footprint)

    ​ 在达到前面两个目标的情况下,尽量减少堆的内存空间;

    ​ 可以获得更好的空间局部性;

6、Serial Old收集器(*选学)

  • Serial Old是 Serial收集器的老年代版本;

  • 针对老年代;采用"标记-整理"算法(还有压缩,Mark-Sweep-Compact);单线程收集;

  • 应用场景:主要用于Client模式;而在Server模式有两大用途:(A)、在JDK1.5及之前,与Parallel Scavenge收集器搭配使用(JDK1.6有Parallel Old收集器可搭配);(B)、作为CMS收集器的后备预案,在并发收集发生Concurrent Mode Failure时使用(后面详解);

7、Parallel Old收集器(*选学)

  • Parallel Old垃圾收集器是Parallel Scavenge收集器的老年代版本; JDK1.6中才开始提供;

  • 针对老年代;采用"标记-整理"算法;多线程收集;

  • JDK1.6及之后用来代替老年代的Serial Old收集器;特别是在Server模式,多CPU的情况下;这样在注重吞吐量以及CPU资源敏感的场景,就有了Parallel Scavenge加Parallel Old收集器的"给力"应用组合;

8、CMS收集器(**选学)

  • 并发标记清理(Concurrent Mark Sweep,CMS)收集器也称为并发低停顿收集器(Concurrent Low Pause Collector)或低延迟(low-latency)垃圾收集器;CMS等收集器的关注点是尽可能地缩短垃圾收集时用户线程的停顿时间;

  • 特点:针对老年代;基于"标记-清除"算法(不进行压缩操作,产生内存碎片);以获取最短回收停顿时间为目标;并发收集、低停顿;需要更多的内存;是HotSpot在JDK1.5推出的第一款真正意义上的并发(Concurrent)收集器;第一次实现了让垃圾收集线程与用户线程(基本上)同时工作;

  • 缺点:

    (A)对CPU资源非常敏感:并发收集虽然不会暂停用户线程,但因为占用一部分CPU资源,还是会导致应用程序变慢,总吞吐量降低。

    (B)无法处理浮动垃圾,可能出现"Concurrent Mode Failure"失败:在并发清除时,用户线程新产生的垃圾,称为浮动垃圾;这使得并发清除时需要预留一定的内存空间,不能像其他收集器在老年代几乎填满再进行收集;如果CMS预留内存空间无法满足程序需要,就会出现一次"Concurrent Mode Failure"失败;这时JVM启用后备预案:临时启用Serail Old收集器,而导致另一次Full GC的产生;

    (C)产生大量内存碎片:由于CMS基于"标记-清除"算法,清除后不进行压缩操作;

  • 适用场景:与用户交互较多的场景,希望系统停顿时间最短,注重服务的响应速度;以给用户带来较好的体验;如常见WEB、B/S系统的服务器上的应用

  • CMS收集器运行示意图如下:
    image

9、G1收集器(**选学)

  • G1(Garbage-First)是JDK7-u4才推出商用的收集器;

  • 特点:

    (A)并行与并发:能充分利用多CPU、多核环境下的硬件优势;可以并行来缩短"Stop The World"停顿时间;也可以并发让垃圾收集与用户程序同时进行;

    (B)分代收集,收集范围包括新生代和老年代 :能独立管理整个GC堆(新生代和老年代),而不需要与其他收集器搭配;能够采用不同方式处理不同时期的对象;虽然保留分代概念,但Java堆的内存布局有很大差别;将整个堆划分为多个大小相等的独立区域(Region);新生代和老年代不再是物理隔离,它们都是一部分Region(不需要连续)的集合;

    (C)结合多种垃圾收集算法,空间整合,不产生碎片。从整体看,是基于标记-整理算法;从局部(两个Region间)看,是基于复制算法;这是一种类似火车算法的实现;

    (D)可预测的停顿:低停顿的同时实现高吞吐量G1除了追求低停顿处,还能建立可预测的停顿时间模型;可以明确指定M毫秒时间片内,垃圾收集消耗的时间不超过N毫秒;

  • 应用场景:面向服务端应用,针对具有大内存、多处理器的机器;最主要的应用是为需要低GC延迟,并具有大堆的应用程序提供解决方案;如:在堆大小约6GB或更大时,可预测的暂停时间可以低于0.5秒; 用来替换掉JDK1.5中的CMS收集器;在下面的情况时,使用G1可能比CMS好:

    (1)超过50%的Java堆被活动数据占用;

    (2)对象分配频率或年代提升频率变化很大;

    (3)GC停顿时间过长(长于0.5至1秒)。 是否一定采用G1呢?也未必:如果现在采用的收集器没有出现问题,不用急着去选择G1;如果应用程序追求低停顿,可以尝试选择G1;是否代替CMS需要实际场景测试才知道。

  • G1 收集器运行示意图如下:
    image

五、内存分配与回收策略

  • 对象优先在 Eden 分配

  • 大对象直接进入老年代:所谓的大对象是指,需要大量连续内存空间的Java对象,最典型的大对象就是那种很长的字符串以及数组,经常出现大对象容易导致内存还有不少空间时就提前触发垃圾收集以获取足够的连续内存空间来存放他们。虚拟机提供了一个-XX:PretenureSizeThreshold参数,令大于这个设置值的对象直接在老年代分配。这样做的目的是避免在Eden区以及两个Survivor区之间发生大量的内存复制(新生代使用复制算法收集内存)。

  • 长期存活的对象将进入老年代:虚拟机给每个对象定义了一个对象年龄计数器。如果对象在Eden出生,并经过第一次Minor GC后仍然存活,并且能被Survivor所容纳的话,将被移动到Survivor区,并且对象年龄设置为1。对象在Survivor区中没“熬过”一次Minor GC,年龄就增加1,当它的年龄增加到一定程度(默认15岁),就将会晋升到老年代中。对象晋升到老年代的年龄阀值,可以通过参数-XX:MaxTenuringThreshold设置。

  • 动态对象年龄判定:为了能更好的适应不同程序的内存状况,虚拟机并不是永远地要求对象的年龄必须达到了MaxTenuringThreshold才能晋升到老年代,如果在Survivor空间中相同年龄所有对象大小的总和大于Survivor空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代,无须等到MaxTenuringThreshold中要求的年龄。

  • 空间分配担保:在发生Minor GC之前,虚拟机会首先检查老年代最大可用连续空间是否大于新生代所有对象总空间,如果这个条件成立,那么Minor GC可用确保是安全的。如果不成立,则虚拟机会查看HandlePromotionFailure设置值是否允许担保失败。如果允许,那么会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,将尝试第一次Minor GC,即使这次Minor GC是有风险的;如果小于,或者HandlePromotionFailure设置为不允许冒险,那这时也要改为进行一次Full GC。如果出现HandlePromotionFailure失败,那就只好在失败之后重新发起一次Full GC。虽然担保失败时绕的圈子是最大的,但大部分情况下都还是会将HandlePromotionFailure开关打开,避免Full GC过于频繁。

推荐阅读更多精彩内容