程序员

深入理解JVM02 - 垃圾收集器与内存分配策略

2019-01-24  本文已影响57人  L2先森

垃圾收集(Garbage Collection,GC)需要完成的3件事情:

JVM01介绍了Java内存运行时区域的各个部分,其中程序计数器、虚拟机栈、本地方法栈3个区域随着线程而生,随线程而灭;而Java堆和方法区则不一样,只有在程序处于运行时才能知道会创建哪些对象,这部分内存的创建和回收都是动态的,垃圾收集器所关注的是这部分内存。

to be or not to be

确定对象”存活“还是”死去“。

引用计数算法

给对象中添加一个引用计数器,每当有一个地方引用它时,计数器值就加1;当引用失效时,计数器值就减1;

任何时刻计数器为0的对象就是不可能再被使用的,这时候变可通知GC收集器回收这些对象。

引用计数算法.png
public class ReferenceCountingGC {
  
        public Object instance = null;

        public static void testGC(){

            ReferenceCountingGC objA = new ReferenceCountingGC ();
            ReferenceCountingGC objB = new ReferenceCountingGC ();

            // 对象之间相互循环引用,对象objA和objB之间的引用计数永远不可能为 0
            objB.instance = objA;
            objA.instance = objB;

            objA = null;
            objB = null;

            System.gc();
    }
}

上述代码最后面两句将objA和objB赋值为null,也就是说objA和objB指向的对象已经不可能再被访问,但是由于它们互相引用对方,导致它们的引用计数器都不为 0,那么垃圾收集器就永远不会回收它们。

但是Java虚拟机里面没有选用引用计数算法来管理内存,其中最主要的原因是它很难解决对象之间相互循环引用的问题。

优点:简单,高效,现在的objective-c用的就是这种算法。
缺点:很难处理循环引用,相互引用的两个对象则无法释放。(需要开发者自己处理)

可达性分析算法

Java虚拟机中,是通过可达性分析(Reachability Analysis)来判定对象是否存活的。

这个算法的基本思路是通过一系列称为“GC Roots”的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链(Reference Chain),当一个对象到GC Roots没有任何引用链项链时,则证明此对象是不可用的。

可达性分析算法.png

GC Roots的对象:

引用

判定对象是否存活(需要回收)都与“引用”有关。Java对引用的概念扩充,将引用分为强引用(Strong Reference),软引用(Soft Reference),弱引用(Weak Reference),虚引用(Phantom Refernce)。

垃圾收集算法

标记清除算法

标记-清除算法分为标记和清除两个阶段。该算法首先从根集合进行扫描,对存活的对象对象标记,标记完毕后,再扫描整个空间中未被标记的对象并进行回收,如下图所示:

标记清除算法.png

标记-清除算法的主要不足有两个:

15479928257361.jpg

复制算法

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

这种算法适用于对象存活率低的场景,比如新生代。这样使得每次都是对整个半区进行内存回收,内存分配时也就不用考虑内存碎片等复杂情况,只要移动堆顶指针,按顺序分配内存即可,实现简单,运行高效。该算法示意图如下所示:

15480247746757.jpg

事实上,现在商用的虚拟机都采用这种算法来回收新生代。因为研究发现,新生代中的对象每次回收都基本上只有10%左右的对象存活,所以需要复制的对象很少,效率还不错。实践中会将新生代内存分为一块较大的Eden空间和两块较小的Survivor空间,每次使用Eden和其中一块Survivor。当回收时,将Eden和Survivor中还存活着的对象一次地复制到另外一块Survivor空间上,最后清理掉Eden和刚才用过的Survivor空间。HotSpot虚拟机默认Eden和Survivor的大小比例是 8:1,也就是每次新生代中可用内存空间为整个新生代容量的90% ( 80%+10% ),只有10% 的内存会被“浪费”。

标记整理算法

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

标记整理算法的标记过程类似标记清除算法,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存,类似于磁盘整理的过程,该垃圾回收算法适用于对象存活率高的场景(老年代),其作用原理如下图所示。

标记整理算法.png

标记整理算法与标记清除算法最显著的区别是:标记清除算法不进行对象的移动,并且仅对不存活的对象进行处理;而标记整理算法会将所有的存活对象移动到一端,并对不存活对象进行处理,因此其不会产生内存碎片。标记整理算法的作用示意图如下:

15480252982828.jpg

分代收集算法

对于一个大型的系统,当创建的对象和方法变量比较多时,堆内存中的对象也会比较多,如果逐一分析对象是否该回收,那么势必造成效率低下。

分代收集算法是基于这样一个事实:不同的对象的生命周期(存活情况)是不一样的,而不同生命周期的对象位于堆中不同的区域,因此对堆内存不同区域采用不同的策略进行回收可以提高 JVM 的执行效率。

当代商用虚拟机使用的都是分代收集算法:新生代对象存活率低,就采用复制算法;老年代存活率高,就用标记清除算法或者标记整理算法。Java堆内存一般可以分为新生代、老年代和永久代三个模块,如下图所示:

15480256054339.jpg

GC算法小结

GC算法小结.png

由于对象进行了分代处理,因此垃圾回收区域、时间也不一样。垃圾回收有两种类型,Minor GC 和 Full GC。

垃圾收集器

如果说垃圾收集算法是内存回收的方法论,那么垃圾收集器就是内存回收的具体实现。下图展示了7种作用于不同分代的收集器,其中用于回收新生代的收集器包括Serial、PraNew、Parallel Scavenge,回收老年代的收集器包括Serial Old、Parallel Old、CMS,还有用于回收整个Java堆的G1收集器。不同收集器之间的连线表示它们可以搭配使用。

15481131132194.jpg

GC日志解读

33.125:[GC [DefNew:3324K->152K(3712K),0.0025925 secs]3324K->152K(11904K),0.0031680 secs]
100.667:[Full GC [Tenured:0K->210K(10240K),0.0149142secs]4603K->210K(19456K),[Perm:2999K->2999K(21248K)],0.0150007 secs][Times:user=0.01 sys=0.00,real=0.02 secs]
15481139383800.jpg

新生代GC(Minor GC):指发生在新生代的垃圾收集动作,因为Java对象大多都具备朝生夕灭的特性,所以Minor GC非常频繁,一般回收速度也比较快。
老年代GC(Major GC/Full GC):指发生在老年代的GC,出现了Major GC,经常会伴随至少一次的Minor GC(但非绝对的,在Parallel Scavenge收集器的收集策略里就有直接进行Major GC的策略选择过程)。Major GC的速度一般会比Minor GC慢10倍以上。

3.5.9 垃圾收集器参数

15481142198157.jpg 15481142322830.jpg

内存分配与回收策略

新生代Minor GC

回顾下垃圾回收算法,通常新生代按照8:1:1(eden space + survivor from space + survivor to space)进行内存划分,新生产的对象会被放到eden space,当eden内存不足时,就会将存活对象移动到survivor区域,如果survivor空间也不够时,就需要从老年代中进行分配担保,将存活的对象移动老年代,这就是一次Minor GC的过程。

15481148676972.jpg
/**
 * VM agrs: -verbose:gc -Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails
 * -XX:SurvivorRatio=8 -XX:+UseSerialGC
 */

public class MinorGCTest {
    private static final int _1MB = 1024 * 1024;

    public static void testAllocation() {
        byte[] allocation1, allocation2, allocation3, allocation4;
        allocation1 = new byte[2 * _1MB];
        allocation2 = new byte[2 * _1MB];
        allocation3 = new byte[2 * _1MB];
        allocation4 = new byte[4 * _1MB];
    }

    public static void main(String[] agrs) {
        testAllocation();
    }
}

代码清单的testAllocation()方法中,尝试分配3个2MB大小和1个4MB大小的对象,在运行时通过-Xms20M、-Xmx20M、-Xmn10M这3个参数限制了Java堆大小为20MB,不可扩展,其中10MB分配给新生代,剩下的10MB分配给老年代。-XX:SurvivorRatio=8决定了新生代中Eden区与一个Survivor区的空间比例是8:1,从输出的结果也可以清晰地看到“eden space 8192K、from space 1024K、to space 1024K”的信息,新生代总可用空间为9216KB(Eden区+1个Survivor区的总容量)。

before MinorGC:

15481150728891.jpg

after MinorGC:

15481150979566.jpg

GC日志:

15482534095722.jpg

解读: [GC (Allocation Failure) [DefNew: 6815K->290K(9216K), 0.0054224 secs] 6815K->6434K(19456K), 0.0054619 secs] [Times: user=0.00 sys=0.00, real=0.01 secs]

结论:

大对象直接进入老年代

创建了一个数组对象allocation,大小为4MB,已经超出PretenureSizeThreshold设置的范围,该对象将直接被分配到老年代中。

/**
 * VM agrs: -verbose:gc -Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails
 * -XX:SurvivorRatio=8 -XX:+UseSerialGC      
 * -XX:PretenureSizeThreshold=3145728
 */

public class TestClass2 {
    private static final int _1MB = 1024 * 1024;
    
    public static void testPretenureSizeThreshold() {
        byte[] allocation;
        allocation = new byte[4 * _1MB];
    }
    /**
     * @param args
     */
    public static void main(String[] args) {
        // TODO Auto-generated method stub
        testPretenureSizeThreshold();
    }

}

VM参数说明:
-XX:PretenureSizeThreshold=3145728 表示 所占用内存大于该值的对象直接分配到老年代,3145728为3MB

15482543601661.jpg

解读: 上述log中未发生GC垃圾回收,同时tenured generation total 10240K, used 4096K,说明老年代大小为10MB,用掉的4MB用来存放allocation对象,即大对象直接进入老年代。

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

创建了3个数组对象,当执行到"allocation3 = new byte[4 * _1MB]; "时,Eden已经被占用了256KB + 4MB,而创建allocation3需要4MB,已经超过Eden的大小8MB,需要先发生一次MinorGC,才能保证有空间存放allocation3。

/**
 * VM agrs: -verbose:gc -Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails
 * -XX:SurvivorRatio=8 -XX:+UseSerialGC -XX:MaxTenuringThreshold=1
 */

public class TestClass3 {
    private static final int _1MB = 1024 * 1024;

    public static void testTenuringThreshold() {
        byte[] allocation1, allocation2, allocation3;
        allocation1 = new byte[_1MB / 4];
        allocation2 = new byte[4 * _1MB];
        allocation3 = new byte[4 * _1MB];
        allocation3 = null;
        allocation3 = new byte[4 * _1MB];
    }

    public static void main(String[] agrs) {
        testTenuringThreshold();
    }
}

VM参数说明:
-XX:MaxTenuringThreshold=1 表示 对象晋升为老年代的年龄阀值为1

15482546980342.jpg

说明:

该段代码创建了3个数组对象,当执行到"allocation3 = new byte[4 * _1MB]; "时,Eden已经被占用了256KB + 4MB,而创建allocation3需要4MB,已经超过Eden的大小8MB,需要先发生一次MinorGC,才能保证有空间存放allocation3

解读:

若将MaxTenuringThreshold改成15(注: 设置下-XX:TargetSurvivorRatio=90),GC log为:

15482572739275.jpg

即 新生代的from space使用空间不为0,对应GC语句为from space 1024K, 52% used

参考

深入理解Java虚拟机:JVM高级特性与最佳实践(第2版)

上一篇下一篇

猜你喜欢

热点阅读