JVM探究之 —— 垃圾回收

垃圾收集(Garbage Collection,GC),大部分人都把这项技术当作Java语言的伴生产物。事实上,GC的历史比Java久远,1960年诞生于MIT的Lisp是第一门真正使用内存动态分配和垃圾收集技术的语言。当Lisp还在胚胎时期时,人们就在思考GC须要完成的3件事情:html

  • 哪些内存须要回收?
  • 何时回收?
  • 如何回收?

Java内存运行时区域的各个部分,其中程序计数器、虚拟机栈、本地方法栈3个区域随线程而生,随线程而灭;栈中的栈帧随着方法的进入和退出而有条不紊地执行着出栈和入栈操做。每个栈帧中分配多少内存基本上是在类结构肯定下来时就已知的(尽管在运行期会由JIT编译器进行一些优化,但在本章基于概念模型的讨论中,大致上能够认为是编译期可知的),所以这几个区域的内存分配和回收都具有肯定性,在这几个区域内就不须要过多考虑回收的问题,由于方法结束或者线程结束时,内存天然就跟随着回收了。而 Java堆和方法区则不同,一个接口中的多个实现类须要的内存可能不同,一个方法中的多个分支须要的内存也可能不同,咱们只有在程序处于运行期间时才能知道会建立哪些对象,这部份内存的分配和回收都是动态的,垃圾收集器所关注的是这部份内存。java

1. 判断对象是否已经死亡

在堆里面存放着Java世界中几乎全部的对象实例,垃圾收集器在对堆进行回收前,第一件事情就是要肯定这些对象之中哪些还“存活”着,哪些已经“死去”。算法

1.1 引用计数算法

给对象中添加一个引用计数器,每当有一个地方引用它,计数器就加 1;当引用失效,计数器就减 1;任什么时候候计数器为 0 的对象就是不可能再被使用的。数组

这个方法实现简单,效率高,可是目前主流的虚拟机中并无选择这个算法来管理内存,其最主要的缘由是它很难解决对象之间相互循环引用的问题。 所谓对象之间的相互引用问题,以下面代码所示:除了对象 objA 和 objB 相互引用着对方以外,这两个对象之间再无任何引用。可是他们由于互相引用对方,致使它们的引用计数器都不为 0,因而引用计数算法(Reference Counting)没法通知 GC 回收器回收他们。缓存

public class ReferenceCountingGc {
    Object instance = null;
    public static void main(String[] args) {
        ReferenceCountingGc objA = new ReferenceCountingGc();
        ReferenceCountingGc objB = new ReferenceCountingGc();
        objA.instance = objB;
        objB.instance = objA;
        objA = null;
        objB = null;

    }
}

1.2 可达性分析算法

这个算法的基本思路就是经过一系列的称为“GC Roots”的对象做为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链(Reference Chain),当一个对象到GC Roots没有任何引用链相连(用图论的话来讲,就是从GC Roots到这个对象不可达)时,则证实此对象是不可用的。Java是经过可达性分析(Reachability Analysis)来断定对象是否存活的。安全

在Java语言中,可做为GC Roots的对象包含如下几种:post

  • 虚拟机栈(栈帧中的本地变量表)中引用的对象。(能够理解为:引用栈帧中的本地变量表的全部对象)
  • 方法区中静态属性引用的对象(能够理解为:引用方法区该静态属性的全部对象)
  • 方法区中常量引用的对象(能够理解为:引用方法区中常量的全部对象)
  • 本地方法栈中(Native方法)引用的对象(能够理解为:引用Native方法的全部对象)

能够理解为:测试

  • 第一种是虚拟机栈中的引用的对象,咱们在程序中正常建立一个对象,对象会在堆上开辟一块空间,同时会将这块空间的地址做为引用保存到虚拟机栈中,若是对象生命周期结束了,那么引用就会从虚拟机栈中出栈,所以若是在虚拟机栈中有引用,就说明这个对象仍是有用的,这种状况是最多见的。
  • 第二种是咱们在类中定义了全局的静态的对象,也就是使用了static关键字,因为虚拟机栈是线程私有的,因此这种对象的引用会保存在共有的方法区中,显然将方法区中的静态引用做为GC Roots是必须的。
  • 第三种即是常量引用,就是使用了static final关键字,因为这种引用初始化以后不会修改,因此方法区常量池里的引用的对象也应该做为GC Roots。
  • 第四种是在使用JNI技术时,有时候单纯的Java代码并不能知足咱们的需求,咱们可能须要在Java中调用C或C++的代码,所以会使用native方法,JVM内存中专门有一块本地方法栈,用来保存这些对象的引用,因此本地方法栈中引用的对象也会被做为GC Roots。

1.3 再谈引用

不管是经过引用计数算法判断对象的引用数量,仍是经过可达性分析算法判断对象的引用链是否可达,断定对象是否存活都与“引用”有关。在JDK 1.2之前,Java中的引用的定义很传统:若是reference类型的数据中存储的数值表明的是另一块内存的起始地址,就称这块内存表明着一个引用。优化

在JDK1.2以后,Java对引用的概念作了扩充,将引用分为:url

  • 强引用(Strong Reference)
  • 软引用(Soft Reference)
  • 弱引用(Weak Reference)
  • 虚引用(Phantom Reference)

这四种引用从上到下,依次减弱。

  • 强引用就是指在程序代码中广泛存在的,相似 Object obj = new Object() 这相似的引用,只要强引用在,垃圾搜集器永远不会搜集被引用的对象。也就是说,当内存空间不足,JVM宁愿抛出 OutOfMemoryError 错误,使程序异常终止,也不会靠随意回收具备强引用的对象来解决内存不足问题。
  • 软引用是用来描述一些有用但并非必需的对象,在Java中用java.lang.ref.SoftReference类来表示。对于软引用关联着的对象,只有在内存不足的时候JVM才会回收该对象。所以,这一点能够很好地用来解决OOM的问题,而且这个特性很适合用来实现缓存:好比网页缓存、图片缓存等。
  • 弱引用也是用来描述非必需对象的,当JVM进行垃圾回收时,不管内存是否充足,都会回收被弱引用关联的对象。在java中,用java.lang.ref.WeakReference类来表示。
  • 虚引用和前面的软引用、弱引用不一样,它并不影响对象的生命周期。在java中用java.lang.ref.PhantomReference类表示。若是一个对象与虚引用关联,则跟没有引用与之关联同样,在任什么时候候均可能被垃圾回收器回收。要注意的是,虚引用必须和引用队列关联使用,当垃圾回收器准备回收一个对象时,若是发现它还有虚引用,就会把这个虚引用加入到与之关联的引用队列中。程序能够经过判断引用队列中是否已经加入了虚引用,来了解被引用的对象是否将要被垃圾回收。若是程序发现某个虚引用已经被加入到引用队列,那么就能够在所引用的对象的内存被回收以前采起必要的行动。

须要注意的是:在程序设计中通常不多使用弱引用与虚引用,使用软引用的状况较多,这是由于软引用能够加速 JVM 对垃圾内存的回收速度,能够维护系统的运行安全,防止内存溢出(OutOfMemory)等问题的产生。

1.4 可达性分析算法中对象死亡过程

即便在可达性分析法中不可达的对象,也并不是是“非死不可”的,这时候它们暂时处于“缓刑阶段”,要真正宣告一个对象死亡,至少要经历两次标记过程;可达性分析法中不可达的对象被第一次标记而且进行一次筛选,筛选的条件是此对象是否有必要执行 finalize 方法。当对象没有覆盖 finalize 方法,或 finalize 方法已经被虚拟机调用过期,虚拟机将这两种状况视为没有必要执行。

被断定为须要执行的对象将会被放在一个叫作F-Queue的队列之中进行第二次标记,除非这个对象与引用链上的任何一个对象创建关联,不然就会被真的回收。

1.5 判断一个常量是废弃常量

JDK1.7 及以后版本的 JVM 已经将运行时常量池从方法区中移了出来,在 Java 堆(Heap)中开辟了一块区域存放运行时常量池。

假如一个字符串“abc”已经进入了常量池中,可是当前系统没有任何一个String对象是叫作“abc”的,换句话说,就是没有任何String对象引用常量池中的“abc”常量,也没有其余地方引用了这个字面量,若是这时发生内存回收,并且必要的话,这个“abc”常量就会被系统清理出常量池。常量池中的其余类(接口)、方法、字段的符号引用也与此相似。

1.6 判断“无用的类”

断定一个类是不是“无用的类”的条件则相对苛刻许多。类须要同时知足下面3个条件才能算是“无用的类”:

  • 该类全部的实例都已经被回收,也就是Java堆中不存在该类的任何实例。
  • 加载该类的ClassLoader已经被回收。
  • 该类对应的java.lang.Class对象没有在任何地方被引用,没法在任何地方经过反射访问该类的方法。

虚拟机能够对知足上述 3 个条件的无用类进行回收,这里说的仅仅是“能够”,而并非和对象同样不使用了就会必然被回收。是否对类进行回收,HotSpot虚拟机提供了-Xnoclassgc参数进行控制,还可使用-verbose:class以及-XX:+TraceClassLoading、-XX:+TraceClassUnLoading查看类加载和卸载信息。

2. 内存分配与回收策略

Java技术体系中的自动内存管理分为对象内存回收和内存分配。这里研究一下对象内存分配的问题。

对象的内存分配,往大方向讲,就是在堆上分配(但也可能通过JIT编译后被拆散为标量类型并间接地栈上分配),对象主要分配在新生代的Eden区上,若是启动了本地线程分配缓冲,将按线程优先在TLAB上分配。少数状况下也可能会直接分配在老年代中,分配的规则并非百分之百固定的,其细节取决于当前使用的是哪种垃圾收集器组合,还有虚拟机中与内存相关的参数的设置。

回顾一下以前在 JVM探究之 —— Java内存区域 提到的Java堆:Java 堆是垃圾收集器管理的主要区域,所以也被称做GC 堆(Garbage Collected Heap)。从垃圾回收的角度,因为如今收集器基本都采用分代垃圾收集算法,因此 Java 堆还能够细分为:新生代和老年代:再细致一点,年轻代能够划分为:Eden 空间、From Survivor (S0)、To Survivor (S1) 空间等。进一步划分的目的是更好地回收内存,或者更快地分配内存。

上图所示的 eden 区、s0("From") 区、s1("To") 区都属于新生代,tentired 区属于老年代。大部分状况,对象都会首先在 Eden 区域分配,在一次新生代垃圾回收后,若是对象还存活,则会进入 s1("To"),而且对象的年龄还会加 1(Eden 区->Survivor 区后对象的初始年龄变为 1),当它的年龄增长到必定程度(默认为 15 岁),就会被晋升到老年代中。对象晋升到老年代的年龄阈值,能够经过参数 -XX:MaxTenuringThreshold 来设置。通过此次GC后,Eden区和"From"区已经被清空。这个时候,"From"和"To"会交换他们的角色,也就是新的"To"就是上次GC前的“From”,新的"From"就是上次GC前的"To"。无论怎样,都会保证名为To的Survivor区域是空的。Minor GC会一直重复这样的过程,直到“To”区被填满,"To"区被填满以后,会将全部对象移动到年老代中。

2.1 对象优先在Eden分配

首先了解一下常见的 Minor GC和Full GC的区别:

  • 新生代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倍以上。

在大多数状况下,对象在新生代Eden区中分配。当Eden区没有足够空间进行分配时,虚拟机将发起一次Minor GC。

虚拟机提供了-XX:+PrintGCDetails这个收集器日志参数,告诉虚拟机在发生垃圾收集行为时打印内存回收日志,而且在进程退出的时候输出当前的内存各区域分配状况。

/**
 * Eden区对象内存分配测试
 * -Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails
 */
public class EdenAllocationTest {
    private static final int _1MB =1024*1024;

    public static void main(String[] args) {
        byte[] allocation1, allocation2,allocation3,allocation4;
//        allocation1=new byte[2*_1MB];
//        allocation2=new byte[2*_1MB];
//        allocation3=new byte[2*_1MB];

    }
}

如上面代码中,在运行时经过-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区的总容量)。

 

 分配两个2MB的对象以后,空间使用状况以下:

 能够看到,eden区新增使用空间 2*2*1MB(1024KB) = 4096KB,Eden区剩余空间大小为 8192KB-6620KB=1572KB < 2MB,此时再分配一个2MB的对象会怎么样呢?

能够看到,当给allocation3对象分配内存时发生一次Minor GC,此次GC的结果是新生代6456KB变为750KB,而总内存占用量则几乎没有减小(由于allocation一、allocation2对象都是存活的,虚拟机几乎没有找到可回收的对象)。此次GC发生的缘由是给allocation3分配内存的时候,剩余空间已不足以分配allocation3所需的2MB内存,所以发生Minor GC。GC期间虚拟机又发现已有的2个2MB大小的对象所有没法放入Survivor空间(Survivor空间只有1MB大小),因此只好经过分配担保机制提早转移到老年代去。老年代上的空间足够存放 allocation1和allocation2对象,因此不会出现 Full GC。执行 Minor GC 后,后面分配的对象若是可以存在 eden 区的话,仍是会在 eden 区分配内存。

2.2 大对象直接进入老年代

大对象是指,须要大量连续内存空间的Java对象,如:字符串以及数组。

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

/**
 * 大对象老年代分配测试 PretenureSizeThreshold
 * -Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails -XX:+UseSerialGC -XX:PretenureSizeThreshold=3145728
 * 说明:
 * -XX:+UseSerialGC 使用SerialGC
 * -XX:PretenureSizeThreshold=3145728 设置PretenureSizeThreshold为3MB,这个参数不能像-Xmx之类的参数同样直接写3MB
 */
public class PretenureSizeThresholdTest {
    private static final int _1MB =1024*1024;
    public static void main(String[] args) {
        byte[] allocation1;
        allocation1=new byte[4*_1MB];
    }
}

须要注意的是:PretenureSizeThreshold参数只对Serial和ParNew两款收集器有效,Parallel Scavenge收集器不认识这个参数,Parallel Scavenge收集器通常并不须要设置。若是遇到必须使用此参数的场合,能够考虑ParNew加CMS的收集器组合。下面示例代码经过 -XX:UseSerialGC 来指定JVM使用Serial垃圾收集器演示。

从上面代码中,能够看出当给 allocation1对象分配空间时, Eden空间几乎没有被使用,而老年代的10MB空间被使用了4MB,allocation1对象直接就分配在老年代中,这是由于PretenureSizeThreshold被设置为3MB(就是3145728,这个参数不能像-Xmx之类的参数同样直接写3MB),所以超过3MB的对象都会直接在老年代进行分配。

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

既然虚拟机采用了分代收集的思想来管理内存,那么内存回收时就必须能识别哪些对象应放在新生代,哪些对象应放在老年代中。为了作到这点,虚拟机给每一个对象定义了一个对象年龄(Age)计数器。

若是对象在Eden出生并通过第一次Minor GC后仍然存活,而且能被Survivor容纳的话,将被移动到Survivor空间中,而且对象年龄设为1。对象在Survivor区中每“熬过”一次Minor GC,年龄就增长1岁,当它的年龄增长到必定程度(默认为15岁),就将会被晋升到老年代中。对象晋升老年代的年龄阈值,能够经过参数-XX:MaxTenuringThreshold设置。

/**
 * 对象晋升老年代的年龄阈值测试 XX:MaxTenuringThreshold
 * -Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails -XX:+UseSerialGC
 * -XX:MaxTenuringThreshold=1 -XX:+PrintTenuringDistribution
 * 说明:
 * -XX:+UseSerialGC   使用SerialGC
 * -XX:MaxTenuringThreshold=1  年龄阈值,对象每熬过一次Minor GC,它的age会加1,age达到此值对象就会晋升老年代
 * -XX:+PrintTenuringDistribution   输出对象年龄
 */
public class MaxTenuringThresholdTest {
    private static final int _1MB =1024*1024;
    public static void main(String[] args) {
        byte[] allocation1,allocation2,allocation3;
        allocation1=new byte[_1MB/4];
        allocation2=new byte[4*_1MB];
        allocation3=new byte[4*_1MB];  //首次GC
        allocation3=null;
        allocation3=new byte[4*_1MB];  //第二次GC
    }
}

设置JVM参数-XX:MaxTenuringThreshold=1来查看执行结果:

能够看出,上面执行结果中的allocation1对象须要256KB内存,在第一次GC时Survivor空间能够容纳。当MaxTenuringThreshold=1时,allocation1对象在第二次GC发生时进入老年代,新生代已使用的内存GC后很是干净地变成0KB。

 

TODO:MaxTenuringThreshold=15

 

2.4 动态对象年龄断定

为了能更好地适应不一样程序的内存情况,虚拟机并非永远地要求对象的年龄必须达到了MaxTenuringThreshold才能晋升老年代,若是在Survivor空间中相同年龄全部对象大小的总和大于Survivor空间的一半(-XX:TargetSurvivorRatio=50 即:50%),年龄大于或等于该年龄的对象就能够直接进入老年代,无须等到MaxTenuringThreshold中要求的年龄。

JVM究竟是如何来计算S区对象晋升到Old区的呢? 首先介绍另外一个重要的JVM参数: -XX:TargetSurvivorRatio:一个计算指望s区存活大小(Desired survivor size)的参数。默认值为50,即50%。 当一个S区中全部的age对象的大小若是大于等于Desired survivor size,则从新计算threshold,以age和MaxTenuringThreshold二者的最小值为准。

 

 

2.5 空间分配担保

在发生Minor GC以前,虚拟机会检查老年代最大可用的连续空间是否大于新生代全部对象的总空间,

  • 若是大于,则这次Minor GC是安全的;
  • 若是小于,则虚拟机会查看HandlePromotionFailure设置值是否容许担保失败。若是HandlePromotionFailure=true,那么会继续检查老年代最大可用连续空间是否大于历次晋升到老年代的对象的平均大小,若是大于,则尝试进行一次Minor GC,但此次Minor GC依然是有风险的;若是小于或者HandlePromotionFailure=false,则改成进行一次Full GC。

上面提到了Minor GC依然会有风险,是由于新生代采用复制收集算法,假如大量对象在Minor GC后仍然存活(最极端状况为内存回收后新生代中全部对象均存活),而Survivor空间是比较小的,这时就须要老年代进行分配担保,把Survivor没法容纳的对象放到老年代。老年代要进行空间分配担保,前提是老年代得有足够空间来容纳这些对象,但一共有多少对象在内存回收后存活下来是不可预知的,所以只好取以前每次垃圾回收后晋升到老年代的对象大小的平均值做为参考。使用这个平均值与老年代剩余空间进行比较,来决定是否进行Full GC来让老年代腾出更多空间。

取平均值仍然是一种几率性的事件,若是某次Minor GC后存活对象陡增,远高于平均值的话,必然致使担保失败,若是出现了分配担保失败,就只能在失败后从新发起一次Full GC。虽然存在发生这种状况的几率,但大部分时候都是可以成功分配担保的,这样就避免了过于频繁执行Full GC。

相关文章
相关标签/搜索