JVM总结之GC

哪些内存需要回收

在Java堆中存放着几乎所有的对象实例,垃圾收集器在对堆进行回收前,第一件事情就是要知道哪些对象还“存活着”,哪些对象已经”死去“。

引用计数算法

引用计数法的实现:给对象中添加一个引用计数器,每当有一个地方引用它时,计数器就加1,当引用失效时,计数器就减1,只要计数器为0的对象就是不可能被使用的。

这个算法实现简单,效率也很高,但是当存活对象中,存在相互引用的时候,这算法就解决不了。所以Java中的GC并没有采用引用计数法来管理内存。(后面例子分析会根据GC日志看出相互引用的对象被回收了)

可达性分析算法

以GC Roots对象作为起始点,从这些节点依次向下搜索,如果当前对象到GC Roots没有任何的路径相连(对象不可达)时,那么,当前对象没有引用。

在Java中,以下对象可作为GC Roots:

  1. Java虚拟机栈(栈帧中的本地变量表)中引用的对象;
  2. 本地方法栈中引用的对象;
  3. 方法区中的常量引用的对象;
  4. 方法区中的静态属性引用的对象。

当这些对象不可达的时候,也并不是就可以宣告这个对象就死亡了。还有对象的最后一次自我救赎——finalize。

finalize是一个方法名,Java允许使用finalize方法在垃圾收集器将对象从内存中清除出去之前做必要的清理工作。在这个操作中如果对象被重新引用,对象就可以活过来了。

判断对象是否有必要执行该方法主要有以下两个依据:

  • 对象有没有覆盖finalize方法;
  • 对象已覆盖finalize方法,检查finalize方法是否被虚拟机调用过,如果已被调用,就不需要再次执行。

如果该对象有必要执行finalize方法,则该被对象将被放置到F-Queue中,由虚拟机的单独线程Finalizer执行。

注意:finalize方法只能被执行一次,如果面临下一次回收,finalize方法将不会被执行。

何时回收

young gc

对于 young gc,触发条件似乎要简单很多,当 eden 区的内存不够时,就会触发young gc。

full gc

1. old gen 空间不足

当创建一个大对象、大数组时,eden 区不足以分配这么大的空间,会尝试在old gen 中分配,如果这时 old gen 空间也不足时,会触发 full gc,为了避免上述导致的 full gc,调优时应尽量让对象在 young gc 时就能够被回收,还有不要创建过大的对象和数组。

2. 统计得到的 young gc 晋升到 old gen的对象平均总大小大于old gen 的剩余空间

当准备触发一次 young gc时,会判断这次 young gc 是否安全,这里所谓的安全是当前老年代的剩余空间可以容纳之前 young gc 晋升对象的平均大小,或者可以容纳 young gen 的全部对象,如果结果是不安全的,就不会执行这次 young gc,转而执行一次 full gc

3. perm gen 空间不足

如果有perm gen的话,当系统中要加载的类、反射的类和调用的方法较多,而且perm gen没有足够空间时,也会触发一次 full gc

4. ygc出现 promotion failure

promotion failure 发生在 young gc 阶段,即 cms 的 ParNewGC,当对象的gc年龄达到阈值时,或者 eden 的 to 区放不下时,会把该对象复制到 old gen,如果 old gen 空间不足时,会发生 promotion failure,并接下去触发full gc

如何回收

标记-清除算法

标记清除算法分为”标记“和”清除“两个阶段:首先标记处所有需要回收的对象,在标记完成后统一回收所有被标记的对象。

它的不足有两个:

  1. 效率问题,标记和清除两个过程的效率都不高。
  2. 空间问题,标记清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致以后的程序在运行过程中需要分配较大对象时,无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。

复制算法

为了解决效率问题,”复制“算法出现了,将可用的内存划分为两块,每次只使用其中一块, 当这一块的内存用完了,就将还存活着的对象复制到另一块上面,然后再把已使用过的内存空间一次清理掉。这样就不用考虑内存碎片等复杂情况。

现在的商业虚拟机都采用这这种收集算法来回收新生代。根据研究表明,新生代中98%的对象都是”朝生夕死“的,所以新生代中将内存分为一块较大的Eden空间和两块较小的Survivor空间(from和to),每次使用Eden和其中一块Survivor,当回收时,将Eden和刚才用过的Survivor中还存活的对象一次性的复制到另外一个Survivor空间上,最后清理掉Eden和刚才用过的Survivor空间。

Hotspot虚拟机默认使用Eden和Survivor的大小比例是8:1,也就是Eden占8,form和to各占1。

当Survivor空间不够的时候,需要依赖老年代进行分配担保。

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

标记-整理算法

标记整理算法的“标记”过程和标记-清除算法一致,只是后面并不是直接对可回收对象进行整理,而是让所有存活的对象都向一段移动,然后直接清理掉端边界以外的内存。

分代收集算法

当前商业虚拟机的垃圾收集都采用”分代收集“算法,其主要思想是将Java堆分为新生代和老年代,这样就可以根据各个年代的特点采用最适合的收集算法。

在新生代采用复制算法,上面已经讲过了。而老年代因为对象存活率高,没有额外空间为它进行分配担保,就必须使用”标记清理“或者”标记整理“算法来进行回收。

例子分析(查看GC日志)

GC日志是一个很重要的工具,它准确记录了每一次的GC的执行时间和执行结果,通过分析GC日志可以优化堆设置和GC设置,或者改进应用程序的对象分配模式。

JVM的GC日志的主要参数包括如下几个:

  • -XX:+PrintGC 输出GC日志
  • -XX:+PrintGCDetails 输出GC的详细日志
  • -XX:+PrintGCTimeStamps 输出GC的时间戳(以基准时间的形式)
  • -XX:+PrintGCDateStamps 输出GC的时间戳(以日期的形式,如 2013-05-04T21:53:59.234+0800)
  • -XX:+PrintHeapAtGC 在进行GC的前后打印出堆的信息
  • -Xloggc:../logs/gc.log 日志文件的输出路径

引用计数算法

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 main(String[] args) {
        ReferenceCountingGC objA = new ReferenceCountingGC();
        ReferenceCountingGC objB = new ReferenceCountingGC();
        objA.instance = objB;
        objB.instance = objA;

        objA = null;
        objB = null;

        //假设在这行发生了GC,objA和ojbB是否被回收
        System.gc();
    }

}

设置JVM运行参数:

-XX:+PrintGCDetails
-XX:+PrintHeapAtGC
-XX:+PrintGCDateStamps
-XX:+PrintTenuringDistribution
-verbose:gc
-Xloggc:gc.log

得到GC日志,我们分析当调用System.gc()的时候,objA和ojbB是否被回收。

2017-07-01T12:23:12.844-0800: 0.268: [Full GC (System.gc()) [PSYoungGen: 624K->0K(38400K)] [ParOldGen: 8K->530K(87552K)] 632K->530K(125952K), [Metaspace: 3043K->3043K(1056768K)], 0.0059705 secs] [Times: user=0.01 sys=0.00, real=0.01 secs] 
Heap after GC invocations=2 (full 1):
 PSYoungGen      total 38400K, used 0K [0x0000000795580000, 0x0000000798000000, 0x00000007c0000000)
  eden space 33280K, 0% used [0x0000000795580000,0x0000000795580000,0x0000000797600000)
  from space 5120K, 0% used [0x0000000797600000,0x0000000797600000,0x0000000797b00000)
  to   space 5120K, 0% used [0x0000000797b00000,0x0000000797b00000,0x0000000798000000)
 ParOldGen       total 87552K, used 530K [0x0000000740000000, 0x0000000745580000, 0x0000000795580000)
  object space 87552K, 0% used [0x0000000740000000,0x0000000740084858,0x0000000745580000)
 Metaspace       used 3043K, capacity 4494K, committed 4864K, reserved 1056768K
  class space    used 336K, capacity 386K, committed 512K, reserved 1048576K
}

从日志中[PSYoungGen: 624K->0K(38400K)]可以看出虚拟机并没有因为a和b互相引用就不回收它们,这也说明虚拟机并不是通过引用计数算法来判断对象是否存在引用的。

新生代MInor GC

通过此例可以分析JVM的内存分配和回收策略:对象优先在Eden分配。

public class TestAllocation {

    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];  // 出现一次Minor GC
    }

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

设置JVM运行参数:

-verbose:gc
-Xms20M
-Xmx20M
-Xmn10M
-XX:+PrintGCDetails
-XX:SurvivorRatio=8

参数解释:在运行时通过-Xms20M、-Xmx20M、-Xmn10M这三个参数限制了Java堆的大小为20M,不可扩展,其中10MB分配给新生代,剩下的10MB分配给老年代。XX:SurvivorRatio=8决定了新生代的中Eden区和一个Survivor区的空间比例为8:1。

运行结果:

Heap
 PSYoungGen      total 9216K, used 7799K [0x00000007bf600000, 0x00000007c0000000, 0x00000007c0000000)
  eden space 8192K, 95% used [0x00000007bf600000,0x00000007bfd9dda0,0x00000007bfe00000)
  from space 1024K, 0% used [0x00000007bff00000,0x00000007bff00000,0x00000007c0000000)
  to   space 1024K, 0% used [0x00000007bfe00000,0x00000007bfe00000,0x00000007bff00000)
 ParOldGen       total 10240K, used 4096K [0x00000007bec00000, 0x00000007bf600000, 0x00000007bf600000)
  object space 10240K, 40% used [0x00000007bec00000,0x00000007bf000010,0x00000007bf600000)
 Metaspace       used 3114K, capacity 4494K, committed 4864K, reserved 1056768K
  class space    used 342K, capacity 386K, committed 512K, reserved 1048576K

执行testAllocation()中分配allocation4对象的语句时会发生一次MinorGC,这次GC的结果是新生代6651KB变为148KB,而总内存占用量则几乎没有减少(因为allocation1、allocation2、allocation3三个对象都是存活的,虚拟机几乎没有占到可回收的对象)。这次GC发生的原因是给allocation4分配内存的时候,发现Eden已经被占用了6MB,剩余空间已不足以分配allocation4所需的4MB内存,一次发生了MInorGC。GC期间虚拟机又发现已有的3个2MB所需的对象全部无法放入Survivor空间(Survivor空间只有1MB大小),所以只能通过分配担保机制提前转移到老年代中去。

所以这次GC的结果就是4MB的allocation4对象顺利分配在Eden中,Survivor空闲,老年代被占用6MB(被allocation1、allocation2、allocation3占用)。

调优

GC优化是迫不得已才采用的手段,多数导致GC问题的Java应用,都不是因为参数设置不当,而是代码问题。所以在实际使用中,分析GC情况优化代码比优化GC参数要多得多。

GC优化的目的有两个

  1. 将转移到老年代的对象数量降低到最小;
  2. 减少full GC的执行时间;

为了达到上面的目的,一般地,你需要做的事情有:

  1. 减少使用全局变量和大对象;
  2. 调整新生代的大小到最合适;
  3. 设置老年代的大小为最合适;
  4. 选择合适的GC收集器

参考资料:

《深入理解Java虚拟机》

posted @ 2017-07-05 14:19  aheizi  阅读(509)  评论(0编辑  收藏  举报