深入理解JVM虚拟机——3. 垃圾回收器与内存分配策略

3.1 概述

垃圾收集(Garbage Collection GC),GC的历史比Java还要久远,1960年诞生于MIT的Lisp是第一门使用内存动态分配和垃圾收集技术的语言。而垃圾回收需要考虑三件事情。

  • 哪些内存需要回收
  • 什么时候回收
  • 如何回收

Java内存的各个区域中,程序计数器虚拟机栈本地方法栈都是线程私有的,也就是随线程诞生或死亡,这三个区域基本在编译期就可以确定。栈帧随方法的进入和退出进行入栈和出栈操作,因此这几个区域的内存分配和回收都具有确定性
Java堆却不同,一个接口中多个实现类需要的内存可能不同,一个方法中多个分支需要的内存也可能不同,而在程序运行期才知道有哪些对象,所以内存的分配和回收也是动态的,垃圾回收器所关注的也是这部分内存


3.2 对象已死么

Java堆几乎存放了所有的对象实例,垃圾回收器在进行回收之前,首先确定的就是哪些对象存活,哪些对象死亡。

3.2.1 引用计数算法

给对象添加一个引用计数器,每当一个地方引用它时,计数器加一;当引用失效时,计数器减一;任何时刻计数器为0的对象不可能再被使用。

这个算法简单有效效率高,然而Java虚拟机并没有使用它,因为它不能解决两个对象循环引用的问题

/**
 * testGC()方法执行后,objA和objB会不会被GC
 */

public class ReferenceCountingGC{
    public Object instance = null;
    private static final int _1MB = 1024*1024;
    private byte[]bigSize= new byte[2*_1MB];
    public static void testGC(){
        ReferenceCountingGC objA = new ReferenceCountingGC();
        ReferenceCountingGC objB = new ReferenceCountingGC();
        objA.instance = objB;
        objB.instance = objA;
        objA = null;
        objB = null;
        System.gc();
    }
    public static void main(String[] args) {
        testGC();
    }
}

代码中两个对象相互引用,导致它们的引用计数不为0,而这个算法就无法回收它们。
而最后的输出中可以看到,GC是将它们回收了,这也从侧面说明虚拟机不是用的引用算法

3.2.2 可达性分析算法

思想是通过一系列的"GC ROOT"的对象作为起始点,从这些节点开始往下搜索,搜索走过的路径称为引用链(Reference Chain),当GC ROOT不可到达一个对象时,这个对象是不可用的。

image

Java语言中可作为GC Roots的对象有以下几种

  • 虚拟机栈引用的对象
  • 方法区中类静态属性引用的对象
  • 方法区中常量引用的对象
  • 本地方法栈中JNI(Native方法)引用的对象

3.2.3 再谈引用

JDK1.2以前,引用的定义很传统,如果reference类型数据中存储的数值代表的是另外一块内存的起始地址,就称这块内存代表是一个引用。

JDK1.2之后对引用的概念做了扩充。

  • 强引用:指普遍存在的类似于Object object = new object(),只要强引用还存在,GC就不会回收
  • 软引用:描述一些还有用但非必需的对象,在系统发生内存溢出异常之前,会把这些对象列进回收范围内,并进行二次回收。如果这次回收后还没有足够的内存则抛出异常。
  • 弱引用:也是用来描述非必需对象的,强度比软引用还要弱,弱引用的对象只存在于下一次垃圾回收之前,GC工作时无论是否有足够的内存都会回收这部分对象。
    ca#### 3.2.4 生存还是死亡

即使是不可达的对象也不是立即死亡,真正死亡至少要经过两次标记过程。

  • 判断finalize(): 对象在可行性分析后发现没有引用链,则进行第一次标记并进行筛选,条件是看对象是否有必要执行finalize方法,对象没有覆盖finalize方法或者虚拟机判断finalize方法已经执行过则两种情况都为没有必要执行
  • 执行finalize(): 若有必要执行,对象会放在F-Queue的队列中,并在一个优先级低的Finalizer线程中去执行它。
  • 自我拯救: 对象在彻底被回收之前可以"拯救自己",对象只要在finalze方法中重新与引用链建立关联。
  • 二次标记: 稍后GC会对F-Queue中的对象进行第二次小规模标记,这时对象若有关联则会被移出"即将回收"的集合,如果这时候对象还未逃脱则会真的被回收

以下代码可做验证

/**
 * 代码演示两点
 * 1.对象可以在被回收前自我拯救
 * 2.自救机会只有一次,因为一个对象的finalize方法只会被系统调用一次
 */
public class FinalizeEscapeGC{
    public static FinalizeEscapeGC SAVE_HOOK = null;
    public void isAive(){
        System.out.println("I'm alive");
    }
    @Override
    protected void finalize()throws Throwable{
        super.finalize();
        System.out.println("finalize method excute");
        FinalizeEscapeGC.SAVE_HOOK = this;
    }
    public static void main(String[] args) throws InterruptedException {
        SAVE_HOOK = new FinalizeEscapeGC();//对象第一次成功拯救自己
        SAVE_HOOK = null;
        System.gc();
        //finalize方法优先级很低,所以先暂停0.5秒等待它
        Thread.sleep(500);
        if(SAVE_HOOK!=null){
            SAVE_HOOK.isAive();
        }else{
            System.out.println("I'm dead");
        }
        //下面再调用一次上面的代码
        SAVE_HOOK = new FinalizeEscapeGC();//对象第一次成功拯救自己
        SAVE_HOOK = null;
        System.gc();
        //finalize方法优先级很低,所以先暂停0.5秒等待它
        //Thread.sleep(500);
        if(SAVE_HOOK!=null){
            SAVE_HOOK.isAive();
        }else{
            System.out.println("I'm dead");
        }
        //只执行一次finalize方法,自救失败
    }
}

结果:
finalize method excute
I’m alive
I’m dead
代码中可以看出finalize方法确实触发了,但是对象却成功逃脱。

3.2.5 回收方法区

方法区中一般不实现垃圾回收,因为它的性价比比较低,相比,在堆中尤其在新生代,常规应用进行一次垃圾收集可以收集70%~95%的空间。

方法区回收分两部分

  • 废弃常量:
    与回收对象类似,例如一个字符常量"abc",当没有一任何一个String对像叫做"abc"也就是没有地方引用这个常量,这时候如果发生内存回收则将它清理。
  • 无用的类:
    满足一下三个条件才算是无用的类
    1. 堆中不存在该类的任何对象
    2. 加载该类的ClassLoader已经被回收
    3. 该类对应的java.lang.class对象没有在任何地方被引用,无法任何地方通过反射访问该类方法。

虚拟机可以对满足以上3个条件的无用类进行回收,这里是可以而不是一定,是否对类进行回收,虚拟机提供了参数进行进行控制。


3.3垃圾收集算法

3.3.1 标记-清除算法

这是最基础的算法,分为"标记"和"清除"两个阶段,首先标记所有需要回收的对象,在标记完成后统一回收所有被标记的对象。

之所以说它是最基础的算法是因为后续的收集算法是基于此改进的,它主要有两个不足

  1. 效率不高 标记和清除效率都不高。
  2. 空间问题 清除标记后会产生大量的空间碎片而无法为大的对象分配空间。

3.3.2 复制算法

将内存按容量划分为大小相等的两块,每次只使用其中一块内存,这块内存用完时就将还存活的对象复制到另一块内存上,再把已使用的内存一次清理掉。

目前商业虚拟机都采用这种算法来回收新生代对象

  • 优势: 简单有效,不用考虑内存碎片等情况,而且效率很高
  • 劣势: 空间缩小到原来的一半,代价很高,空间利用率太小
  • 改进: 新生代对象98%都是"朝夕生死"的,所以不需要1:1,而是将内存分为一块较大的Eden空间和两块较小的Survivor空间,每次使用Eden和其中一块Survivor,当回收时,将对象复制到没有使用那块Survivor上,这样空间利用率可达到90%
  • 注意: 虽然98%的情况下内存都够用,但是会出现Survivor空间不够用的情况,这时就需要以来其他内存来进行分配担保

3.3.3 标记-整理算法

这个算法是根据老年代的特点提出的,标记过程与"标记-清除"算法一致,但后续步骤是先将所有存货对象移至同一端,然后清理标记以外的内存。

3.3.4 分代收集算法

就是根据对象年代不同分配不同算法。

3.4 HotSpot的算法实现

3.4.1 枚举根节点

  • 找引用链: 可作为GC Roots的节点主要在全局性的引用(例如常量或类竞态属性)与执行上下文(例如栈帧中的本地变量表)中,要逐个检查的话需要耗费很多时间。
  • GC停顿: 分析工作必须在一个确保一致性的快照区中进行。因为不可以出现分析过程中对象引用关系还在不停变化当中,所以分析期间整个执行系统看起来像是冻结在某个时间点上。
  • OopMap数据结构: 通过这个数据结构,虚拟机可以在执行系统停顿下来的时候直接得知哪些地方存放着对象的引用。类加载的时候就将对象内对应偏移量的数据类型计算出来,jtl编译过程中也会记录栈和寄存器中哪些位置是引用。

3.4.2 安全点

并不是每一条指令都会生成OopMap,HotSpot只在特定的位置记录了这些信息,这些位置叫做安全点(Safepoint),即程序不能在所有地方都能停顿下来GC只有在安全点才能停顿。

  • 选定标准: 是否具有让程序长时间执行的特征,具有这个特征最明显的就是指令列复用,例如方法调用,循环跳转,异常跳转,具有这些功能的指令才会产生安全点
  • 最近安全点停顿: GC发生时,让所有线程跑到最近安全点的方法有两种
    • 抢先式中断: GC发生时,将所有的线程中断,如果发现有线程中断的地方不在安全点上,就恢复线程,让它跑到安全电上,现在几乎没有虚拟机采用这种方式
    • 主动式中断: GC需要中断线程时,设置一个标志,各个线程执行时主动轮询这个标志,发现中断标志为真时就自己中断挂起,轮询标志的地方和安全点是重合的。

    3.4.3 安全区域

安全区域解决程序不执行的时候,不执行就是没有分配CPU时间,典型的例子就是线程处于sleep状态或blocked状态,线程无法响应中断请求,走到安全点。

安全区域是指在一段代码片段中,引用关系不会发生变化,在这个区域内任意地方开始GC都是安全的。

3.5 垃圾收集器

收集算法是内存回收的方法论,垃圾回收器是内存回收的具体实现

image
上图展示了7种作用于不同分代的收集器,如果两个收集器之间存在连线,就说明它们可以搭配使用,位于上面区域的是年轻代,而下面区域的就是老年代。

3.5.1 Serial收集器

这是最基本最悠久的收集器,这个收集器是一个单线程收集器,它在进行收集工作的时候,必须暂停其他所有工作线程。

虽然这个收集器看起来效率不高,但是到现在为止,它依然是虚拟机运行在client模式下的默认新生代收集器,而它的优点是简单而高效,对于限定单个CPU的环境来说,它能专注于垃圾收集而没有线程交互,所以对于client模式下的虚拟机来说是很好的选择。

3.5.2 ParNew收集器

这个收集器其实就是Serial收集器的多线程版本,除了使用多条线程收集垃圾外,其余行为包括Serial收集器可用的所有控制参数。

ParNew收集器除了多线程之外没有太多创新的地方,但它是运行在Server模式下的新生代首选收集器。

3.5.3 Parallel Scavenge收集器

它的许多特点与ParNew收集器看上去一致,不同的地方在于它关注点在于达到一个可控制的吞吐量。

吞吐量=运行用户代码时间/(运行用户代码时间+垃圾收集时间)
收集器提供了两个参数来控制吞吐量
-XX: MaxGCPauseMillis:控制最大垃圾收集停顿时间
-XX: GCTimeRatio:设置吞吐量大小

3.5.4 SerialOld收集器

顾名思义它是Serial收集器的老年代版本,使用"标记-整理"算法,也是在于给client模式下使用。

而它如果在Server模式下使用还有两大用途

  • 在JDK1.5以前的版本和Parallel Scavenge收集器搭配使用
  • 作为CMS收集器的后备预案,在并发收集发生Concurrent Mode Failure时使用。

3.5.5 Parallel Old收集器

Parallel Scavenge的老年代版本,使用多线程和"标记-整理"算法。

3.5.6 CMS收集器

CMS(Concurrent Map Sweep)收集器是一种以最短回收停顿时间为目标的收集器,通常用于JavaWeb程序上,重视服务响应速度,希望系统时间停顿最短,而它是基于"标记-清除"算法。

整个过程分为四个步骤

  • 初始标记(CMS initial mark)
  • 并发标记(CMS concurrent mark)
  • 重新标记(CMS remark)
  • 并发清除(CMS concurrent sweep)

初始标记只是标记一下GC Roots能直接关联到的对象,速度很快
并发标记是进行GC RootsTracing的过程
重新标记是为了修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分对象
并发清除就是清除标记
并发标记并发清除耗时较长,但是可以跟用户线程一起工作,而初始标记重新标记过程虽然需要单独工作但是耗时较短,所以从总体上来说CMS收集器的内存回收和用户线程是一起并发执行的。


缺点:

  1. CPU资源敏感: CMS默认启动的回收线程数是(CPU数量+3)/4,也就是当CPU在4个以上时,并发回收时垃圾线程占用不少于25% 的资源,并且随CPU数量的增长而下降。但当CPU数量不足四个时,CMS就会占用一半的资源去进行垃圾回收,对程序运行影响很大。
  2. 无法处理浮动垃圾: CMS并发清理阶段程序还在运行着,此时还会不断产生垃圾,而这部分垃圾只能等到下次收集,被称为浮动垃圾,所以在收集过程中还要预留出足够的内存空间给用户线程使用。
  3. 空间碎片的产生: CMS使用标记-清除算法,这个算法会产生大量的空间碎片而导致有空间却没有足够的连续空间来分配对象,CMS提供了一个参数用于在CMS顶不住要进行fullGC时开启内存碎片的整理过程。

3.5.7 G1收集器

G1(Grabage-First)收集器是收集器发展最前沿成果之一,它是一款面向服务端应用的垃圾收集器。

特点
  • 并行与并发: G1使用多个CPU来缩短停顿时间,G1收集器可以通过并发的方式让Java程序继续执行。
  • 分代收集: 分代概念在G1收集器中依然保留,虽然G1可以单独管理整个GC堆,但它能够采用不同的方式去处理新对象以及旧对象以获取更好的效果。
  • 空间整合: G1从整体上来看是标记-整理算法,从局部上来看是基于复制算法,这两种算法都不会产生空间碎片,有利于程序长时间运行。
  • 可预测的停顿: G1除了追求降低停顿时间,还能建立可预测的停顿时间模型,能让使用者明确指定在M毫秒的时间段内,消耗在垃圾收集上的时间不得超过N毫秒。
分区

G1之前的收集器收集范围都是整个新生代或是老年代,而G1将整个Java堆划分为多个独立区域(Region),虽然它还有分代的概念,但两代之间不再是物理隔离的,都是一部分Region的集合。
G1跟踪各个Region里面的垃圾堆积的价值大小,在后台维护一个优先列表,根据允许的收集时间,优先收集价值最大的Region。

运作步骤
  • 初始标记(Initial Marking)
  • 并发标记(Concurrent Marking)
  • 最终标记(Final Mmarking)
  • 筛选回收(Live Data Counting and Evacuating)

G1的步骤和CMS有许多相似之处
初始标记: 标记一下GC Roots能直接关联到的对象,并且修改TAMS(Next Top at Mark Start)的值,让下一阶段用户程序并发运行时,能在正确的Region中创建对象。
并发标记: GC Roots开始可达性分析时,找出存活对象,这阶段耗时较长。
最终标记: 最终标记阶段是为了修正在并发标记期间因用户程序继续运行而导致标记产生变动的那部分标记记录。
筛选回收: 首先对各个Region的回收价值和成本进行排序,根据用户所希望的GC停顿时间来制定回收计划。

其中初始标记耗时很短需要停顿线程来进行,并发标记耗时较长,可以和用户线程并发执行,最终标记可以并行执行但是需要停顿线程,筛选回收虽热可以并发,但是因为回收一部分Region,时间是用户可控制的,而停顿将大幅度提高收集效率。

3.5.8 理解GC日志

阅读GC日志是处理Java虚拟机内存问题的基础技能,它只是一些人为制定的规则。

例如下面这两段GC日志

33.125:[GC[DefNew:3324K->152K(3712K),0.0025925 secs]3324K->152K(11904K),0.0031680 secs] 1 0 0.6 6 7:[F u l l G C[T e n u r e d:0 K->2 1 0 K(1 0 2 4 0 K),0.0 1 4 9 1 4 2 s e c s]4603K->210K(19456K),[Perm:2999K-> 2999K(21248K)],0.0150007 secs][Times:user=0.01 sys=0.00,real=0.02 secs]

最前面的数字“33.125:”和“100.667:”代表了GC发生的时间,这个数字的含义是从Java 虚拟机启动以来经过的秒数。
GC日志开头的GCFullGC是垃圾收集的停顿类型,有Full说明这次GC发生了Stop The World

下面的DefNew,Tenured,Perm表示GC发生的区域,这里显示的区域名称和收集器类型密切相关。

后面方括号内部的的3324K->152K(3712K)含义是 GC前该内存区域已使用容量->GC后该内存区域已使用容量(该内存区域总容量),方括号外部的3324K->152K(11904K)表示GC前Java堆已使用容量->GC后Java堆已使用容量(Java堆总容量)

0.0025925 seces表示该内存区域GC所占用的时间,单位是秒。


3.6 内存分配与回收策略

对象的内存分配往大方向讲就是在堆上分配,对象主要分配在新生代的Eden区,分配规则的细节取决于 垃圾收集器的组合,还有相关参数设置。

3.6.1 对象优先在Eden上分配

大多数情况下,对象在新生代Eden区中分配,当Eden没有足够的空间时,虚拟机发起一次Minor GC(对年轻代的GC)。

代码验证,通过参数限制Java堆大小为20M,10M年轻代,10M老年代,Eden区和一个Survivor区空间比例8:1。

private static final int _1MB = 1024*1024;
/**
*VM参数:-verbose:gc-Xms20M-Xmx20M-Xmn10M-XX:+PrintGCDetails -XX:SurvivorRatio=8
*
*/
public static void testAllocation(){
    allocation1 = new  byte[2*_1MB];
    allocation2 = new  byte[2*_1MB];
    allocation3 = new  byte[2*_1MB];
    allocation4 = new  byte[4*_1MB]; //出现一次Minor GC
}

当分配allocation4时Eden已经占用了6M的空间,不足以分配4M的对象,因此发生MinorGC,GC期间虚拟机又无法将三个2M大小的对象放入Survivor空间(只有1M),所以通过分配担保机制转移到老年代去。

3.6.2 大对象直接进入老年代

所谓大对象是指需要大量连续内存空间的对象,典型的是长字符串和数组,经常出现大对象容易导致内存还有不少空间就提前触发垃圾收集以获取足够的连续空间来安置它。

代码验证

private static final int_1MB=1024*1024/**
*VM参数:-verbose:gc-Xms20M-Xmx20M-Xmn10M-XX:+PrintGCDetails-XX:SurvivorRatio=8
*-XX:PretenureSizeThreshold=3145728
*/
public static void testPretenureSizeThreshold(){
    byte[]allocation;
    allocation=new byte[4*_1MB]//直接分配在老年代中 
}

-XX:PretenureSizeThreshold参数令大于这个参数值的对象直接分配到老年代中。

3.6.3 长期存活的对象进入老年代

虚拟机给每个对象定义了一个年龄计数器,如果对象在Eden中出生并经过第一次Minor GC仍然存活,如果能被Survivor容纳的话就移入,并且对象年龄设为1,熬过一次Minor GC就+1,当到达一定程度移入老年代(默认15)。

private static final int_1MB=1024*1024;
/** 
*VM参数:-verbose:gc-Xms20M-Xmx20M-Xmn10M-XX:+PrintGCDetails-XX:SurvivorRatio=8-XX:MaxTenuringThreshold=1
*-XX:+PrintTenuringDistribution 
*/ 
@SuppressWarnings"unused"public static void testTenuringThreshold(){ 
    byte[]allocation1,allocation2,allocation3;
    allocation1=new byte[_1MB/4];
    //什么时候进入老年代取决于XX:MaxTenuringThreshold设置 
    allocation2=new byte[4*_1MB]; 
    allocation3=new byte[4*_1MB]; 
    allocation3=null;
    allocation3=new byte[4*_1MB];
}

MaxTenuringThreshold=1时,allocation1对象在第二 次GC发生时进入老年代,新生代已使用的内存GC后变成0KB。

3.6.4 动态对象年龄判定

如果survivor空间中相同年龄所有对象大小的总和大于survivor空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代。

private static final int_1MB=1024*1024;
/** 
*VM参数:-verbose:gc-Xms20M-Xmx20M-Xmn10M-XX:+PrintGCDetails-XX:SurvivorRatio=8-XX:MaxTenuringThreshold=15 
*-XX:+PrintTenuringDistribution 
*/ @SuppressWarnings"unused"public static void testTenuringThreshold2(){ 
    byte[]allocation1,allocation2,allocation3,allocation4;
    allocation1=new byte[_1MB/4]; //allocation1+allocation2大于survivo空间一半
    allocation2=new byte[_1MB/4];
    allocation3=new byte[4*_1MB];
    allocation4=new byte[4*_1MB];
    allocation4=null;
    allocation4=new byte[4*_1MB];
}

运行结果中Survivor的空间占用仍然为0%,老年代增加了6%,也就是allocation1和2都进入了老年代。

3.6.5 空间分配担保

发生Minor GC之前虚拟机会检查老年代最大连续空间是否大于新生代所有对象总空间,条件成立就是安全的。

如果不成立,检查HandlePromotionFailure这个值,如果允许则检查最大空间是否大于历次晋升到老年代对象的平均大小,如果大于尝试进行有风险的Minor GC,小于或者不允许则改为Full GC。

猜你喜欢

转载自blog.csdn.net/MoForest/article/details/85054810