JAVA GC JDK1.8描述

前言


        与C语言不同,Java内存(堆内存)的分配与回收由JVM垃圾收集器自动完成,这个特性深受大家欢迎,能够帮助程序员更好的编写代码,本文以HotSpot虚拟机为例,说一说Java GC的那些事。


Java堆内存


        我们知道Java堆是被所有线程共享的一块内存区域,所有对象实例和数组都在堆上进行内存分配。为了进行高效的垃圾回收,虚拟机把堆内存划分成新生代(Young Generation)、老年代(Old Generation)和永久代(Permanent Generation)3个区域。


JAVA GC JDK1.8描述_第1张图片

新生代


        新生代由 Eden 与 Survivor Space(S0,S1)构成,大小通过-Xmn参数指定,Eden 与 Survivor Space 的内存大小比例默认为8:1,可以通过-XX:SurvivorRatio 参数指定,比如新生代为10M 时,Eden分配8M,S0和S1各分配1M。


        Eden:希腊语,意思为伊甸园,在圣经中,伊甸园含有乐园的意思,根据《旧约·创世纪》记载,上帝耶和华照自己的形像造了第一个男人亚当,再用亚当的一个肋骨创造了一个女人夏娃,并安置他们住在了伊甸园。


        大多数情况下,对象在Eden中分配,当Eden没有足够空间时,会触发一次Minor GC,虚拟机提供了-XX:+PrintGCDetails参数,告诉虚拟机在发生垃圾回收时打印内存回收日志。


Survivor:意思为幸存者,是新生代和老年代的缓冲区域。


        当新生代发生GC(Minor GC)时,会将存活的对象移动到S0内存区域,并清空Eden区域,当再次发生Minor GC时,将Eden和S0中存活的对象移动到S1内存区域。


        存活对象会反复在S0和S1之间移动,当对象从Eden移动到Survivor或者在Survivor之间移动时,对象的GC年龄自动累加,当GC年龄超过默认阈值15时,会将该对象移动到老年代,可以通过参数-XX:MaxTenuringThreshold 对GC年龄的阈值进行设置。


老年代


        老年代的空间大小即-Xmx 与-Xmn 两个参数之差,用于存放经过几次Minor GC之后依旧存活的对象。当老年代的空间不足时,会触发Major GC/Full GC,速度一般比Minor GC慢10倍以上。


永久代


        在JDK8之前的HotSpot实现中,类的元数据如方法数据、方法信息(字节码,栈和变量大小)、运行时常量池、已确定的符号引用和虚方法表等被保存在永久代中,32位默认永久代的大小为64M,64位默认为85M,可以通过参数-XX:MaxPermSize进行设置,一旦类的元数据超过了永久代大小,就会抛出OOM异常。


        虚拟机团队在JDK8的HotSpot中,把永久代从Java堆中移除了,并把类的元数据直接保存在本地内存区域(堆外内存),称之为元空间。


这样做有什么好处?


        有经验的同学会发现,对永久代的调优过程非常困难,永久代的大小很难确定,其中涉及到太多因素,如类的总数、常量池大小和方法数量等,而且永久代的数据可能会随着每一次Full GC而发生移动。


        而在JDK8中,类的元数据保存在本地内存中,元空间的最大可分配空间就是系统可用内存空间,可以避免永久代的内存溢出问题,不过需要监控内存的消耗情况,一旦发生内存泄漏,会占用大量的本地内存。


ps:JDK7之前的HotSpot,字符串常量池的字符串被存储在永久代中,因此可能导致一系列的性能问题和内存溢出错误。在JDK8中,字符串常量池中只保存字符串的引用。


如何判断对象是否存活


        GC动作发生之前,需要确定堆内存中哪些对象是存活的,一般有两种方法:引用计数法和可达性分析法


1、引用计数法


        在对象上添加一个引用计数器,每当有一个对象引用它时,计数器加1,当使用完该对象时,计数器减1,计数器值为0的对象表示不可能再被使用。


引用计数法实现简单,判定高效,但不能解决对象之间相互引用的问题。



public class GCtest {

    private Object instance = null;

    private static final int _10M = 10 * 1 << 20;

    // 一个对象占10M,方便在GC日志中看出是否被回收

    private byte[] bigSize = new byte[_10M];

 

    public static void main(String[] args) {

        GCtest objA = new GCtest();

        GCtest objB = new GCtest();

 

        objA.instance = objB;

        objB.instance = objA;

 

        objA = null;

        objB = null;

 

        System.gc();

    }

}


通过添加-XX:+PrintGC参数,运行结果:

[GC (System.gc()) [PSYoungGen: 26982K->1194K(75776K)] 26982K->1202K(249344K), 0.0010103 secs]


从GC日志中可以看出objA和objB虽然相互引用,但是它们所占的内存还是被垃圾收集器回收了。


2、可达性分析法


        通过一系列称为 “GC Roots” 的对象作为起点,从这些节点开始向下搜索,搜索路径称为 “引用链”,以下对象可作为GC Roots:

        本地变量表中引用的对象

        方法区中静态变量引用的对象

        方法区中常量引用的对象

        Native方法引用的对象


当一个对象到 GC Roots 没有任何引用链时,意味着该对象可以被回收。


JAVA GC JDK1.8描述_第2张图片

在可达性分析法中,判定一个对象objA是否可回收,至少要经历两次标记过程:


        1、如果对象objA到 GC Roots没有引用链,则进行第一次标记。

        2、如果对象objA重写了finalize()方法,且还未执行过,那么objA会被插入到F-Queue队列中,由一个虚拟机自动创建的、低优先级的Finalizer线程触发其finalize()方法。finalize()方法是对象逃脱死亡的最后机会,GC会对队列中的对象进行第二次标记,如果objA在finalize()方法中与引用链上的任何一个对象建立联系,那么在第二次标记时,objA会被移出“即将回收”集合。


看看具体实现


public class FinalizerTest {

    public static FinalizerTest object;

    public void isAlive() {

        System.out.println("I'm alive");

    }

 

    @Override

    protected void finalize() throws Throwable {

        super.finalize();

        System.out.println("method finalize is running");

        object = this;

    }

 

    public static void main(String[] args) throws Exception {

        object = new FinalizerTest();

 

        // 第一次执行,finalize方法会自救

        object = null;

        System.gc();

 

        Thread.sleep(500);

        if (object != null) {

            object.isAlive();

        } else {

            System.out.println("I'm dead");

        }

 

        // 第二次执行,finalize方法已经执行过

        object = null;

        System.gc();

 

        Thread.sleep(500);

        if (object != null) {

            object.isAlive();

        } else {

            System.out.println("I'm dead");

        }

    }

}



执行结果:


method finalize is runningI'm aliveI'm dead


从执行结果可以看出:


第一次发生GC时,finalize方法的确执行了,并且在被回收之前成功逃脱;

第二次发生GC时,由于finalize方法只会被JVM调用一次,object被回收。


当然了,在实际项目中应该尽量避免使用finalize方法。


收集算法


垃圾收集算法主要有:标记-清除、复制和标记-整理。


1、标记-清除算法


对待回收的对象进行标记。


算法缺点:效率问题,标记和清除过程效率都很低;空间问题,收集之后会产生大量的内存碎片,不利于大对象的分配。


2、复制算法


复制算法将可用内存划分成大小相等的两块A和B,每次只使用其中一块,当A的内存用完了,就把存活的对象复制到B,并清空A的内存,不仅提高了标记的效率,因为只需要标记存活的对象,同时也避免了内存碎片的问题,代价是可用内存缩小为原来的一半。


3、标记-整理算法


在老年代中,对象存活率较高,复制算法的效率很低。在标记-整理算法中,标记出所有存活的对象,并移动到一端,然后直接清理边界以外的内存。


对象标记过程


在可达性分析过程中,为了准确找出与GC Roots相关联的对象,必须要求整个执行引擎看起来像是被冻结在某个时间点上,即暂停所有运行中的线程,不可以出现对象的引用关系还在不断变化的情况。


如何快速枚举GC Roots?


GC Roots主要在全局性的引用(常量或类静态属性)与执行上下文(本地变量表中的引用)中,很多应用仅仅方法区就上百兆,如果进行遍历查找,效率会非常低下。


在HotSpot中,使用一组称为OopMap的数据结构进行实现。类加载完成时,HotSpot把对象内什么偏移量上是什么类型的数据计算出来存储到OopMap中,通过JIT编译出来的本地代码,也会记录下栈和寄存器中哪些位置是引用。GC发生时,通过扫描OopMap的数据就可以快速标识出存活的对象。


如何安全的GC?


线程运行时,只有在到达安全点(Safe Point)才能停顿下来进行GC。


基于OopMap数据结构,HotSpot可以快速完成GC Roots的遍历,不过HotSpot并不会为每条指令都生成对应的OopMap,只会在Safe Point处记录这些信息。


所以Safe Point的选择很重要,如果太少可能导致GC等待的时间太长,如果太频繁可能导致运行时的性能问题。大部分指令的执行时间都非常短暂,通常会选择一些执行时间较长的指令作为Safe Point,如方法调用、循环跳转和异常跳转等。


关于Safe Point更多的信息,可以看看这篇文章《 JVM的Stop The World,安全点,黑暗的地底世界 》(http://calvin1978.blogcn.com/articles/safepoint.html)。


发生GC时,如何让所有线程跑到最近的Safe Point再暂停?


当发生GC时,不直接对线程进行中断操作,而是简单的设置一个中断标志,每个线程运行到Safe Point的时候,主动去轮询这个中断标志,如果中断标志为真,则将自己进行中断挂起。


这里忽略了一个问题,当发生GC时,运行中的线程可以跑到Safe Point后进行挂起,而那些处于Sleep或Blocked状态的线程在此时无法响应JVM的中断请求,无法到Safe Point处进行挂起,针对这种情况,可以使用安全区域(Safe Region)进行解决。


Safe Region是指在一段代码片段中,对象的引用关系不会发生变化,在这个区域中的任何位置开始GC都是安全的。


1、当线程运行到Safe Region的代码时,首先标识已经进入了Safe Region,如果这段时间内发生GC,JVM会忽略标识为Safe Region状态的线程;


2、当线程即将离开Safe Region时,会检查JVM是否已经完成GC,如果完成了,则继续运行,否则线程必须等待直到收到可以安全离开Safe Region的信号为止;


垃圾收集器


Java虚拟机规范并没有规定垃圾收集器应该如何实现,用户可以根据系统特点对各个区域所使用的收集器进行组合使用。


JAVA GC JDK1.8描述_第3张图片

上图展示了7种不同分代的收集器,如果两两之间存在连线,说明可以组合使用。


1、Serial收集器(串行GC)


Serial 是一个采用单个线程并基于复制算法工作在新生代的收集器,进行垃圾收集时,必须暂停其他所有的工作线程。对于单CPU环境来说,Serial由于没有线程交互的开销,可以很高效的进行垃圾收集动作,是Client模式下新生代默认的收集器。


2、ParNew收集器(并行GC)


ParNew其实是serial的多线程版本,除了使用多条线程进行垃圾收集之外,其余行为与Serial一样。


3、Parallel Scavenge收集器(并行回收GC)


Parallel Scavenge是一个采用多线程基于复制算法并工作在新生代的收集器,其关注点在于达到一个可控的吞吐量,经常被称为“吞吐量优先”的收集器。


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


Parallel Scavenge提供了两个参数用于精确控制吞吐量:


-XX:MaxGCPauseMillis 设置垃圾收集的最大停顿时间

-XX:GCTimeRatio 设置吞吐量大小


4、Serial Old收集器(串行GC)


Serial Old 是一个采用单线程基于标记-整理算法并工作在老年代的收集器,是Client模式下老年代默认的收集器。


5、Parallel Old收集器(并行GC)


Parallel Old是一个采用多线程基于标记-整理算法并工作在老年代的收集器。在注重吞吐量以及CPU资源敏感的场合,可以优先考虑Parallel Scavenge和Parallel Old的收集器组合。


6、CMS收集器(并发GC)


CMS(Concurrent Mark Sweep)是一种以获取最短回收停顿时间为目标的收集器,工作在老年代,基于“标记-清除”算法实现,整个过程分为以下4步:


  1. 初始标记:这个过程只是标记以下GC Roots能够直接关联的对象,但是仍然会Stop The World;

  2. 并发标记:进行GC Roots Tracing的过程,可以和用户线程一起工作。

  3. 重新标记:用于修正并发标记期间由于用户程序继续运行而导致标记产生变动的那部分记录,这个过程会暂停所有线程,但其停顿时间远比并发标记的时间短;

  4. 并发清理:可以和用户线程一起工作。


CMS收集器的缺点:


  1. 对CPU资源比较敏感,在并发阶段,虽然不会导致用户线程停顿,但是会占用一部分线程资源,降低系统的总吞吐量。

  2. 无法处理浮动垃圾,在并发清理阶段,用户线程的运行依然会产生新的垃圾对象,这部分垃圾只能在下一次GC时收集。

  3. CMS是基于标记-清除算法实现的,意味着收集结束后会造成大量的内存碎片,可能导致出现老年代剩余空间很大,却无法找到足够大的连续空间分配当前对象,不得不提前触发一次Full GC。


JDK1.5实现中,当老年代空间使用率达到68%时,就会触发CMS收集器,如果应用中老年代增长不是太快,可以通过-XX:CMSInitiatingOccupancyFraction参数提高触发百分比,从而降低内存回收次数提高系统性能。


JDK1.6实现中,触发CMS收集器的阈值已经提升到92%,要是CMS运行期间预留的内存无法满足用户线程需要,会出现一次”Concurrent Mode Failure”失败,这是虚拟机会启动Serial Old收集器对老年代进行垃圾收集,当然,这样应用的停顿时间就更长了,所以这个阈值也不能设置的太高,如果导致了”Concurrent Mode Failure”失败,反而会降低性能,至于如何设置这个阈值,还得长时间的对老年代空间的使用情况进行监控。


7、G1收集器


G1(Garbage First)是JDK1.7提供的一个工作在新生代和老年代的收集器,基于“标记-整理”算法实现,在收集结束后可以避免内存碎片问题。


G1优点:


  1. 并行与并发:充分利用多CPU来缩短Stop The World的停顿时间;

  2. 分代收集:不需要其他收集配合就可以管理整个Java堆,采用不同的方式处理新建的对象、已经存活一段时间和经历过多次GC的对象获取更好的收集效果;

  3. 空间整合:与CMS的”标记-清除”算法不同,G1在运行期间不会产生内存空间碎片,有利于应用的长时间运行,且分配大对象时,不会导致由于无法申请到足够大的连续内存而提前触发一次Full GC;

  4. 停顿预测:G1中可以建立可预测的停顿时间模型,能让使用者明确指定在M毫秒的时间片段内,消耗在垃圾收集上的时间不得超过N毫秒。


使用G1收集器时,Java堆的内存布局与其他收集器有很大区别,整个Java堆会被划分为多个大小相等的独立区域Region,新生代和老年代不再是物理隔离了,都是一部分Region(不需要连续)的集合。G1会跟踪各个Region的垃圾收集情况(回收空间大小和回收消耗的时间),维护一个优先列表,根据允许的收集时间,优先回收价值最大的Region,避免在整个Java堆上进行全区域的垃圾回收,确保了G1收集器可以在有限的时间内尽可能收集更多的垃圾。


不过问题来了:使用G1收集器,一个对象分配在某个Region中,可以和Java堆上任意的对象有引用关系,那么如何判定一个对象是否存活,是否需要扫描整个Java堆?其实这个问题在之前收集器中也存在,如果回收新生代的对象时,不得不同时扫描老年代的话,会大大降低Minor GC的效率。


针对这种情况,虚拟机提供了一个解决方案:G1收集器中Region之间的对象引用关系和其他收集器中新生代与老年代之间的对象引用关系被保存在Remenbered Set数据结构中,用来避免全堆扫描。G1中每个Region都有一个对应的Remenbered Set,当虚拟机发现程序对Reference类型的数据进行写操作时,会产生一个Write Barrier暂时中断写操作,检查Reference引用的对象是否处于相同的Region中,如果不是,则通过CardTable把相关引用信息记录到被引用对象所属Region的Remenbered Set中。


你可能感兴趣的:(JAVA GC JDK1.8描述)