第3章 垃圾回收器与内存分配策略

2018-12-25  本文已影响0人  过来摸摸头丶

1.概述

为什么要去了解GC堆和内存分配?

答案很简单:当需要排查各种内存溢出、内存泄漏问题时,当垃圾回收器称为系统达到更高并发量的瓶颈时,我们需要对这些"自动化"的技术实施必要的监控和调节。

程序计数器、虚拟机栈、本地方法栈跟随着线程的生命周期;栈中的栈帧随着方法的进入和退出执行着入栈和出栈操作。每个一个栈帧中分配多少内存基本上在类结构确定下来时就已知。(虽然在运行期会由JIT编译期进行优化,但基于概念模型的讨论中,大体上认为编译期可知),因此,这几个区域的内存分配和回收都是确定的,不需要考虑回收问题,因为方法结束或者线程结束时,内存就跟着回收了。

Java堆和方法区不一样,一个接口中的多个实现类需要的内存可能不一样,一个方法中的多个分支需要的内存也可能不一样,我们只有在程序处于运行期间才能知道会创建哪些对象。

2.对象死了吗?

堆里存放着几乎所有的Java对象实例,GC回收前,必须确定哪些对象还"存活",哪些已经"死去"(即不可能再被任何途径使用的对象)。

2.1 引用计数算法

原理:给对象添加一个引用计数器,每当有一个地方引用它时,计数器值加1;当引用失效时,计数器值减1;任何时刻计数器为0当对象是不可能在被使用的。

问题:Java虚拟机没有选引用计数算法管理内存,因为它很难解决对象之间循环引用的问题。

看下面一个简单例子:

/**
 * testGC()方法执行后,objA和objB会不会被GC?
 **/
public class ReferenceCountingGC {

    public Object instance = null;

    private static final int _1MB = 1024 * 1024;

    /**
     * 这个成员属性的唯一意义就是占点内存,以便能在GC日志中看清楚是否被回收过
     */
    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();
    }

}

运行结果:

GC日志中包含"4603K->210K",意味着即使这两个对象相互引用,还是回收了它们。说明虚拟机不是通过引用计数算法来判断对象是否存活的。

2.2 可达性分析算法

Java、C#等都是通过可达性分析来判定对象是否存活的。

基本思想:通过一系列称为"GC Roots"的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为"引用链",当一个对象到GC Roots没任何引用链相连(用图的话就是不可达),说明此对象是不可用的。

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

2.3 引用

这两种无论是哪种方法,判定对象是否存活都与引用有关。

我们希望能这样描述一个对象:当内存空间足够时,则能保留在内存中;当内存空间在进行垃圾回收后还非常紧张,则抛弃这些对象。

于是引用就分为了强引用、软引用、弱引用、虚引用。4中引用强度依次逐渐减弱。

2.4 对象生存或死亡

即使可达性分析算法中不可达的对象,也并非"非死不可的"。要真正宣告一个对象的死亡至少要经历两次标记过程:

如果对象在进行可达性分析后发现没有与GC Roots相连的引用链,它会被第一次标记并且进行一次筛选,筛选条件是此对象是否有必要执行finalize()方法。

如果对象没有覆盖finalize()方法或finalize()方法已经被虚拟机调用过,虚拟机将这两种情况视为"没有必要执行"。

如果这个对象有必要执行finalize()方法,那么这个对象将会被放置在一个叫做F-Queue的队列中,并稍后由一个虚拟机自动建立的、低优先级的Finalizer线程取执行它。

从下面中可以看到,一个对象的finalize()被执行,但是它仍然可以存活。

/**
 * 此代码演示了两点:
 * 1。对象可以在被GC时自我拯救
 * 2。这种自救的机会只有一次,因为一个对象的finalize()方法最多只会被系统自动调用一次
 **/
public class FinalizeEscapeGC {

    public static FinalizeEscapeGC SAVE_HOOK = null;

    public void isAlive(){

        System.out.println("yes, i am still alive :)");
    }

    public void finalize() throws Throwable{

        super.finalize();
        System.out.println("finalize method executed!");
        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.isAlive();
        }
        else {
            System.out.println("no, i am dead :(");
        }

        //下面代码和上面的完全相同,但是拯救失败
        SAVE_HOOK = null;
        System.gc();
        //因为finalize方法优先级很低,所以暂停0.5秒以等待它
        Thread.sleep(500);
        if (SAVE_HOOK != null){
            SAVE_HOOK.isAlive();
        }
        else {
            System.out.println("no, i am dead :(");
        }
    }
}

运行结果:
finalize method executed!
yes, i am still alive :)
no, i am dead :(

需要注意的是:作者并不鼓励大家使用这种方式拯救对象。相反建议尽量避免使用它,因为它不是C/C++的析构函数,而是Java刚诞生为了使C/C++程序员更容易接受它所做的一个妥协。它运行代价高,不确定性大,无法保证各个对象的调用顺序。

finalize()能做的所有工作,使用try-finally或其他方式都可以做的更好,更及时。

2.5 回收方法区

Java虚拟机在方法区确实可以不实现垃圾收集,方法区中进行垃圾回收的"性价比"一般比较低。

在堆中,尤其是在新生代中,常规应用进行一次垃圾收集一般可以回收70%-95%的空间。

永久代的垃圾回收效率远低于此,它主要回收:废弃常量和无用的类。

以常量池中的字面量的回收为例,加入一个字符串"abc"没有任何一个String对象引用常量池中的"abc"常量,也没有其他地方引用了这个字面量。如果这时发生内存回收,而且有必要的话,这个"abc"常量就会被系统清理出常量池。常量池中的其他类(接口)、方法、字段的符号引用也是如此。

而判断一个类是否是"无用的类"的条件则相对苛刻许多:

虚拟机满足这3个条件的无用类可以进行回收,"可以"并不是不使用了就必然会回收。是否对类进行回收,HotSpot虚拟机提供了-Xnoclassgc参数进行控制,还可以使用-verbose:class以及-XX:+TraceClassLoading、-XX:+TraceClassUnLoading查看类加载和卸载信息。

3.垃圾回收算法

3.1标记-清除算法

首先标记出所有需要回收的对象,在标记完成后统一回收。标记过程其实就是上一节介绍的。

不足之处:

标记-清除算法示意图

3.1复制算法

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

代价是:将内存缩小为原来的一半!执行过程如下图:

复制算法示意图

IBM研究表明,新生代的对象98%是"朝生夕死"的。所以将内存分为一块较大的Eden空间和两块较小的Survivor空间,每次使用Eden和其中一块Survivor。回收时,将Eden和Survivor中存活的对象一次性复制到另一块Survivor空间,然后清理掉Eden和刚才用过的Survivor空间。HotSpot虚拟机默认大小比例是8:1。

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

3.3 标记-整理算法

过程和标记-清除一样,但后续步骤不是直接对可回收的对象进行清理,而是让所有存活的对象都像一端移动,然后直接清理掉端边界以外的内存。如下图所示:

标记-整理算法示意图

3.4 分代回收算法

根据对象存活周期的不同将内存划分为几块。一般是把Java堆分为新生代和老年代。在新生代每次垃圾回收都发现大批对象死去,就选用复制算法,只需要付出少量存活对象的复制成本就可以完成回收;老年代中因为对象存活率高,没有额外的空间对它进行分配担保,使用"标记-清理"或"标记-整理"。

4. HotSpot算法实现

4.1 枚举根节点

以GC Roots节点引用链为例,可作为GC Roots的节点主要在全局性的引用(如常量和类静态属性)与执行上下文(如栈帧中的本地变量表)中,现在很多应用仅仅方法区就数百兆,如果逐个排查里面的引用,必然会消耗很多时间。

另外,可达性分析会有GC停顿,因为这项分析工作必需在一个能确保一致性的快照中进行。

目前主流的Java虚拟机使用的是准确式GC。并不需要一个不漏的检查完所有执行上下文和全局的引用位置,在HotSpot实现中,是使用一组称为OopMap的数据结构,这样GC在扫描时可以直接得知这些信息。

准确式GC:就是让JVM知道内存中某位置数据的类型什么。比如当前内存位置中的数据究竟是一个整型变量还是一个引用类型。这样JVM可以很快确定所有引用类型的位置,从而更有针对性的进行GC roots枚举。

OopMap:在类加载完成时,HotSpot把对象内偏移量上是什么类型的数据计算出来,在JIT编译过程中,也会在特定位置记录下栈和寄存器中哪些位置是应用。

4.2 安全点

实际上,HotSpot没有为每条指令都生成OopMap,只是在"特定位置"记录这些信息,这些位置称为安全点。即程序执行时并非在所有地方都停顿下来开始GC,只有在到达安全点才暂停。

安全点的选定不能太少:让GC等待时间太长;也不能太过于频繁:过分增大运行时的负荷。

是否产生安全点的特征是:是否具有让程序长时间执行的特征。每条指令执行的时间都很短暂,程序不太可能引用指令流长度太长而长时间运行,长时间运行特征是指令序列复用,如方法调用、循环跳转、异常跳转等。这些功能才会产生安全点。

GC时让所有线程都跑到最近的安全点上停顿下来的方案:

抢先式中断:不需要线程的执行代码主动配合,GC发生时,首先把所有线程全部中断,如果发现有线程中断的地方不在安全点,就恢复线程,让它"跑"到安全点上。这个方案几乎没有虚拟机用。

主动式中断:GC需要中断线程时,不直接对线程操作,只是设置一个标志,各个线程主动轮询这个标志,发现中断标志为真就自己中断挂起。轮询标志对地方和安全点时重合的,另外再加上创建对象需要分配内存的地方。

4.3 安全区域

安全点机制保证了程序执行时,在不太长的时间内就会遇到可进入的GC的安全点。但,程序不执行呢?(线程处于Sleep或Blocked状态)

针对上述情况需要安全区域来解决。安全区域指在一段代码中,引用关系不会发生变化。在这个区域中的任意地方开始GC都是安全的。

5.垃圾回收器

(基于JDK1.7 Update14之后的HotSpot虚拟机)

HotSpot虚拟机的垃圾回收器

上图中,如果两个收集器有连线,说明可以搭配使用。

5.1 Serial收集器

是一个单线程的收集器,但这不仅仅说明它只会使用一个CPU或一条收集线程去完成垃圾收集工作,重要的是它垃圾收集时,必须暂停其他所有的工作线程,直到它收集结束。

它实际上时由虚拟机在后台自动发起和自动完成的,在用户不可见的情况下把用户正常工作的现场全部停掉。下图是Serial/Serial Old收集器的运行过程。

Serial/Serial Old收集器示意图

现在的收集器正在为用户线程的停顿时间不断缩短,但仍没有办法完全消除。但Serial收集器还是虚拟机默认的新生代收集器。因为它简单、高效!用户桌面场景中,停顿时间可以控制在几十毫秒最多100毫秒以内,只要不频繁发生,还是可以接受的。

5.2 ParNew收集器

ParNew收集器是Serial收集器的多线程版本。工作过程如下图所示:

ParNew收集器示意图

ParNew收集器除了多线程收集外,其他和Serial差不多,但它有一个优势是:只有它能与CMS(Concurrent Mark Sweep)收集器配合工作。

ParNew收集器在CPU数量少的时候肯定没有Serial效果好,但是现在CPU越来越多。

注意:
并行:指多条垃圾收收集器并行工作,但此时用户线程仍然处于等待状态
并发:指用户线程与垃圾收集器线程同时执行(但不一定是并行的,可能会交替执行),用户程序在继续运行,而垃圾收集程序运行在另一CPU上。

5.3 Paraller Scavenge收集器

是一个新生代收集器,使用复制算法,又并行的多线程收集器。和ParNew有什么不同?

CMS等收集器关注的是:尽可能缩短垃圾收集时用户线程的停顿时间,而Parallel Scavenge收集器目的是达到一个可控制的吞吐量。

吞吐量=运行用户代码时间/(运行用户代码时间+垃圾收集时间),如虚拟机总共运行100分钟,垃圾收集1分钟,吞吐量就是99%

它GC停顿时间的缩短是以牺牲吞吐量和新生代空间来换取的:系统把新生代调小,收集300MB肯定比收集500MB快,也导致垃圾收集发生频繁一些。原来10秒收集一次,每次停顿100ms,现在5秒收集一次,每次停顿70ms。

5.4 Serial Old收集器

Serial的老年代版本,单线程收集器,使用"标记-整理"算法。工作流程如下图:

Serial/Serial Old收集器示意图

5.5 Paralle Old收集器

是Parallel Scavenge收集器的老年代版本。使用多线程和"标记-整理"算法。工作流程图如下:

Parallel Scavenge/Parallel Old收集器示意图

5.6 CMS收集器

是一种获取最短回收停顿时间为目标的收集器。使用"标记-清除"算法,不过它的运作过程相对前面的收集器来说更复杂一些:

其中,初始标记、重新标记两个步骤需要"Stop The World"。

初始标记只是标记一下GC Roots能直接关联到的对象,速度很快,并发标记阶段就是进行GC Roots Tracing的过程,而重新标记阶段是为了修正并发标记期间因用户程序继续运作而导致标记产生变动的那部分对象的标记记录,这个阶段的停顿时间一般会比初始标记阶段稍长,但远比并发标记时间短。

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

Concurrent Mark Sweep收集器示意图

缺点:

5.7 G1收集器

是当今收集器技术发展的最前沿成果之一。是一款面向服务端应用的垃圾收集器。与其他GC收集器相比,有如下特点:

G1之前的其他收集器收集的范围都是整个新生代或老年代,而G1不再是这样。它将整个Java对划分为多个大小相等的独立区域(Region),虽然海保留有新生代和老年代概念,但新生代和老年代不再是物理隔离的,它们是一部分Region的集合。

一个对象分配在某个Region中,它并非只能被本Region中的其他对象引用,而是可以与整个Java堆任意的对象发生引用关系。那么做可达性判定对象是否存活的时候,还得扫描整个Java堆吗?

Region之间的对象引用以及其他收集器的新生代与老年代之间的对象引用,虚拟机都是使用Remembered Set来避免全堆扫描。G1中每个Region都有一个对用的Remembered Set,虚拟机发现程序在堆Reference类型的数据进行写操作时,会产生一个Write Barrier暂时中断写操作,检查Reference引用的对象是否处于不同Region之中(分代收集器的例子中就是检查老年代中的对象是否引用了新生代的对象)。如是是,通过CardTable把相关信息记录到被引用对象所属的Region的Remembered Set中。在进行内存回收的时候,只需要扫描Remembered Set。

如果不算维护Remembered Set的操作,G1收集器大致划分为以下几个步骤:

工作流程如下图:

G1收集器运行示意图

5.8 理解GC日志

每一种收集日志形式都是由它们自身的实现决定的。

如下图:

GC日志

最前面的数字:代表GC发生的时间。含义是从虚拟机启动以来经过的秒数。

[GC和[Full GC说明垃圾收集的停顿类型,如果有Full,说明这次GC发生了"stop the world"。

[DefNew、[Tenured、[Perm表示GC发生的区域。

后面方括号内部"3324k->152k(3712k)"含义是GC前该内存区域已使用容量->GC后该内存区域使用容量(该内存区域总容量)。

方括号后面的"3324k->152k(11904k)"含义是GC前Java堆已使用容量->GC后Java堆已使用容量(Java堆总容量)。

再往后,"0.0025925 secs"表示该内存区域GC所占用的时间,单位是秒。有的是具体数据如"[Times:user=0.01 sys=0.00,real=0.02 secs]",user、sys、real与linux的time命令输出的含义一致,分别代表用户态消耗的CPU时间、内核态消耗的CPU时间和操作从开始到结束所经过的墙钟时间。

5.9 垃圾回收器参数总结

垃圾回收器相关参数1 垃圾回收器相关参数2

6.内存分配和回收策略

6.1 对象优先在Eden分配

通过下图来介绍:

新生代Minor GC

testAllocation()中分配了3个2MB和1个4MB大小的对象;
-Xms20M、-Xmx20M、-Xmn10M3个参数限制了Java堆大小为20MB,不可扩展,10MB分配给新生代,10MB给老年代;
-XX:SurvivorRatio=8决定新生代中Eden区与一个Survivor空间比例8:1,从输出结果可以看到"eden space 8192k、from space 1024k、to space 1024k",新生代总可用空间为9216KB。(Eden区+1个Survivor区)

在分配allocation4的时候发生了Minor GC,新生代6651KB变为148KB,总内存占用没有减少。原因是给allocation4分配内存时,发现Eden已经占用了6MB,剩余空间不足,因此Minor GC:GC期间虚拟机发现已有的3个2MB大小的对象都无法放入Survivor空间,只好通过分配担保机制提前转移到老年代。

6.2 大对象直接进入老年代

虚拟机提供-XX:PretenureSizeThreshold参数,大于这个设置值的对象直接在老年代中分配:避免在Eden区及两个Survivor区之间发生大量的内存复制。

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

为了做到可用区分存活时间,虚拟机给每个对象定义了一个对象年龄计数器。

对象在Eden出生经过第一次Minor GC后仍然存活,并且能被Survivor容纳的话,就被移到Survivor空间中,并且对象年龄设为1;对象在Survivor区每"熬过"一次Minor GC,年龄计数器+1,当加到一定值时(默认15),会被晋升到老年代。

6.4 动态对象年龄判定

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

6.5 空间分配担保

发生Minor GC前,虚拟机会先检查老年代最大可用的连续空间是否大于新生代所有对象空间,如果条件成立,Minor GC确保是安全的。如果不成立,则虚拟机会查看HandlePromotionFailure设置值是否允许担保失败。如果允许,会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,会进行Minor GC;如果小于,HandlePromotionFailure设置不允许冒险,会进行一次Full GC。

JDK1.6 Update 24后,HandlePromotionFailure不在被使用。规则变为只要老年代的连续空间大于新生代对象总大小或者历次晋升的平均大小就会进行Minor GC,否则将Full GC。

上一篇下一篇

猜你喜欢

热点阅读