内存回收策略

Posted

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了内存回收策略相关的知识,希望对你有一定的参考价值。

参考技术A

本文主要内容

本文主要从概念上介绍内存回收及垃圾收集器相关内容,不涉及具体性能调优。

内存回收是程序员永恒的主题,虽然Java虚拟机自动回收内存,但仍存在内存漏泄的可能,需要理解内存回收机制,有助于程序员规避、排查内存泄漏问题。

GC机制,最重要的是三个问题:

对象已死

对象是否已经死亡,可被回收,经常能听到下面这种说法:

引用计数法 :给对象中添加一个引用计数器,每当有一个地方引用时,计数器就加1,当引用失效时,计数器值就减1,任何时候计数器为0的对象就是不可能再被使用的

不过,它存在一个致命缺陷,很难解决循环引用问题,比如对象AB,A引用B,B也引用A,但它们再没有被其它人所引用,AB理应是被回收对象,但它们的引用计数器仍然不为0,导致无法回收。

Java虚拟机不使用此算法来判定对象是否死亡,不过它依然有很多优点,简单。android 中的智能指针即是使用这种方法,不过添加了智能指针强弱引用来解决循环引用问题

可达性分析算法 ,这个算法的基本思路就是通过一系列的名为“GC Roots"的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链,当一个对象到“GC Roots"没有任何引用链相连,则此对象不可用,将被回收

在Java中,可作为“GC Roots"的对象包括以下几种:

引用

在JDK1.2以前,引用的概念为:

JDK1.2之后,引用概念进行了扩充,将引用分为强引用、软引用、弱引用、虚引用四种,四种引用强度依次减弱。

垃圾收集算法

本章将介绍三种垃圾收集算法。

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

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

复制算法 ,为了解决效率问题,一种称为复制的收集算法出现了。它将内存按容量划分为大小相等的两块,每次只使用其中的一块,当这一块内存使用完了,就将还存活着的对象复制到另一块上面,然后再把已使用过的内存空间一次清理掉。每次都是只对其中的一块进行内存回收,内存分配时也就不用考虑内存碎片等复杂情况,只要移动堆顶指针,按顺序分配内存即可,实现简单,运行高效。只是代价高昂,将内存缩小为原来的一半。

现在的商业虚拟机都采用这种算法来回收新生代,新生代中的对象98%是朝生夕死的,所以并不需要按照1:1的比例来划分内存空间,而是将内存分为一块较大的Eden空间和两块较小的Survivor空间,每次使用Eden空间和其中的一块Survivor空间,当回收时,将Eden和Survivor看还存活的对象一次性拷贝到另外一块Survivor空间上,最后清理掉Eden和刚才用过的Survivor空间。

虚拟机默认Eden和Survivor的大小比例是8比1,所以只有10%的空间会被浪费。

如果存活的对象较多而Survivor空间不够用时,需要依赖其它内存(老年代)进行分配担保,如果另外一块Survivor空间没有足够的空间来存放上一次新生代的存活对象,这些对象将直接通过分配担保机制进入老年代

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

根据老年代的特点,提出了“标记整理算法”,过程依然和“标记清除算法”一致,但后续步骤不是直接对可回收对象进行清除,而是让所有存活对象都向一端移动,然后直接清理掉边界以外的内存。

分代收集算法

当前商业虚拟机的垃圾收集都采用分代收集算法。它根据对象存活周期的不同将内存划分为几块。

一般是把Java堆分成新生代和老年代,新生代又分成一块较大的Eden和两块Survivor。根据各个年代的特点采用最适当的收集算法。

在新生代中,每次垃圾回收时都发现有大批对象死去,只有少量存活,那就选用复制算法,只需要付出少量存活对象的复制成本就可以完成收集。而老年代中因为对象存活率罗高,没有额外空间对它进行分配担保,就必须使用标记清理或者标记整理算法。

垃圾收集器

Serial收集器

Serial收集器是最基本、历史最悠久的收集器。顾名思义,这个收集器是一个单线程收集器。单线程的意义并不仅仅说明它只会使用一个CPU或者一条线程去完成垃圾收集工作,更重要的是它在垃圾收集时,必须暂停其他所有的工作线程(Sun将这件事情称之为“Stop the world”),直到它结束

“Stop the world”,非常影响用户体验,虚拟机在后台自动发起和完成的,在用户不可见的情况下把用户的正常工作的线程全部停掉。

虽然Serial收集器出现时间较长,但它依然是Client模式下的默认新生代收集器

ParNew收集器

ParNew收集器其实就是Serial收集器的多线程控制版本,除了使用多条线程进行垃圾收集之外,其它和Serial收集器完全一样。

ParNew收集器是Server模式下虚拟机中的首选的新生代收集器。而且在单CPU环境下,ParNew绝对不会比Serial更高效,甚至由于存在线程交互的开销,在两个CPU的环境中都不一定比Serial更好

Parallel Scavenge收集器

Parallel Scavenge收集器也是一个新生代收集器,它也是使用复制算法,又是并行的多线程收集器,看上去和ParNew一样,但它非常的有特点。

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

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

Serial old收集器

Serial old收集器是Serial收集器的老年代版本,同样是一个单线程收集器,使用标记整理算法,它的主要意义也是被Client模式下的虚拟机使用

Parallel old收集器

Parallel old是Parallel Scavenge收集器的老年代版本,使用多线程和标记整理算法。

CMS收集器

CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器,它很适合互联网站或者B/S系统的服务端上。

从Mark Sweep名字可知,CMS用的是标记清除算法,它的动作过程较之前的复杂一些,整个过程分为4个步骤:

其中初始标记、重新标记这两个步骤仍然需要 “Stop the world”。初始标记只是标记一下GC Roots能直接关联到的对象,速度很快,并发阶段就是进行GC Root Tracing的过程,而重新标记则是为了修正并发标记期间,因用户程序继续运行而导致标记产生变动的那一部分对象的标记记录,重新标记时间略比初始标记长,但远比并发标记时间短。

整个过程最耗时的是并发标记和并发清除,但用户线程和收集器线程一起工作,所以总体上说,CMS收集器的内存回收过程是与用户线程一起并发地执行。

内存分配与回收策略

内存分配与回收策略

  Java技术体系中的自动内存管理最终可以归结为自动化地解决两个问题:给对象分配内存和回收分配给对象的内存。关于内存回收这一点,我们在Java垃圾收集机制中详细介绍了各种回收算法以及JVM中常见的收集器。接下来我们主要看看JVM是如何给对象分配内存的。

  对象的内存分配,往大的方向上说就是在堆上的分配(但也可能经过JIT编译后被拆散为标量类型并间接地栈上分配,这是JIT即时编译器的优化,我们不做介绍),对象主要分配在新生代(Eden和Survivor)的Eden区上。少数情况下也可能会直接分配在老年代(Tenured)中,其分配的细节取决于当前使用的是哪一种垃圾收集器组合,还有虚拟机中与内存相关的参数设置。接下来我们将通过例子讲解几条最普遍的内存分配规则。

对象优先在Eden分配

  大多数情况下,对象在新生代Eden区中分配,当Eden区没有足够的空间进行内存分配时,虚拟机将发起一次Minor GC(新生代内存回收)。其中虚拟机通过参数 -XX:+PrintGCDetails打印垃圾收集器日志参数,告诉虚拟机在发生垃圾收集行为时打印内存回收日志。我们执行代码如下,其中虚拟机参数设置为-verbose:gc -Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails -XX:SurvivorRatio=8。

public class Main {
    private static final int _1MB = 1024 * 1024;
    public static void main(String[] args) {
        /**
         * VM参数:-verbose:gc -Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails -XX:SurvivorRatio=8
         * -Xms20M 设置堆大小为20M   -Xmx20M 避免堆自动扩展         -Xmn10M  设置年轻代大小 
         * -XX:+PrintGCDetails 打印日志信息
         * -XX:SurvivorRatio=8  设置Eden和Survivor大小比值
         */
        // TODO Auto-generated method stub
        byte[] allocation1, allocation2, allocation3, allocation4;
        allocation1 = new byte[2 * _1MB];
        allocation2 = new byte[2 * _1MB];
        allocation3 = new byte[6 * _1MB]; //新生代需要Minor GC,Full GC直接将其分配进OldGen
        allocation4 = new byte[4 * _1MB]; //新生代分配内存
    }

}

//打印日志如下

1、[GC-- [PSYoungGen: 4932K->4932K(9216K)] 11076K->13124K(19456K), 0.0050819 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
2、[Full GC [PSYoungGen: 4932K->2511K(9216K)] [ParOldGen: 8192K->8192K(10240K)] 13124K->10703K(19456K) [PSPermGen: 2551K->2550K(21504K)], 0.0123169 secs] [Times: user=0.05 sys=0.00, real=0.01 secs]
3、Heap
4、PSYoungGen total 9216K, used 6774K [0x00000000ff600000, 0x0000000100000000, 0x0000000100000000)
5、eden space 8192K, 82% used [0x00000000ff600000,0x00000000ffc9d840,0x00000000ffe00000)
6、from space 1024K, 0% used [0x00000000fff00000,0x00000000fff00000,0x0000000100000000)
7、to space 1024K, 0% used [0x00000000ffe00000,0x00000000ffe00000,0x00000000fff00000)
8、ParOldGen total 10240K, used 8192K [0x00000000fec00000, 0x00000000ff600000, 0x00000000ff600000)
9、object space 10240K, 80% used [0x00000000fec00000,0x00000000ff400020,0x00000000ff600000)
10、PSPermGen total 21504K, used 2557K [0x00000000f9a00000, 0x00000000faf00000, 0x00000000fec00000)
11、object space 21504K, 11% used [0x00000000f9a00000,0x00000000f9c7f688,0x00000000faf00000)

  为了解释方便,给日志加上了行号,首先第1行,GC说明发生了垃圾回收。其中[PSYoungGen: 4932K->4932K(9216K)]表示新生代应用Parallel Scavenger收集器,垃圾收集前内存占用4932K,垃圾收集后内存占用4932K,因为allocation1和allocation2对象都还存活;该区域总内存为9216K(9M)。其中11076K->13124K(19456K)表示Java堆内存回收前后占用内存和总内存。其中0.0050819 secs表示执行内存回收时间。其中[Times: user=0.00 sys=0.00, real=0.00 secs],user表示用户态消耗的CPU时间,sys表示内核态消耗的CPU时间,real表示操作开始到结束所经过的墙钟时间。同理第2行表示为:新生代执行了Full GC,内存变化为由4932K变为2511K,说明有2M内存进入老年代;老年代采用Parallel Old收集器,新创建的allocation3同样进入老年代,可以看到老年代内存占用为8192K(8M);永久代(PermGen)内存没有太大变化。第3-11行是程序执行完内存的情况新生代内存总共9M,内存占用6774K(最后allocation4直接分配在新生代中),新生代Eden区8192K,占用82%;from和to区域1024K,占用0%;老年代内存总共10M,占用8M(2M+6M)。其中永久代内存总共21M(21504K),占用2557K。

大对象直接进入老年代

  大对象是指需要大量连续内存空间的Java对象,如字符串以及数组。虚拟机提供参数-XX:PretenureSizeThreshold参数来指定大对象,大于该值的对象都是大对象。如下我们指定大于3M的对象都是大对象,可以从打印日志中看出allocation3直接被分配到老年代中。程序如下:

public class Main {
    private static final int _1MB = 1024 * 1024;
    public static void main(String[] args) {
        /**
         * VM参数:-verbose:gc -Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails -XX:SurvivorRatio=8 -XX:+UseSerialGC -XX:PretenureSizeThreshold=3145728
         * -Xms20M 设置堆大小为20M   -Xmx20M 避免堆自动扩展         -Xmn10M  设置年轻代大小 
         * -XX:+PrintGCDetails 打印日志信息
         * -XX:SurvivorRatio=8  设置Eden和Survivor大小比值
         * --XX:+UseSerialGC 使用Serial + Serial Old收集器组合进行内存回收
         * -XX:PretenureSizeThreshold=3145728  指定超过3M的对象直接进入老年代
         */
        // TODO Auto-generated method stub
        byte[] allocation1, allocation2, allocation3, allocation4;
        allocation1 = new byte[1 * _1MB];
        //allocation2 = new byte[2 * _1MB];
        allocation3 = new byte[6 * _1MB]; //新生代需要Minor GC,Full GC
        //allocation4 = new byte[4 * _1MB];
    }
}

//打印日志如下
Heap
 def new generation   total 9216K, used 2024K [0x00000000f9a00000, 0x00000000fa400000, 0x00000000fa400000)
  eden space 8192K,  24% used [0x00000000f9a00000, 0x00000000f9bfa028, 0x00000000fa200000)
  from space 1024K,   0% used [0x00000000fa200000, 0x00000000fa200000, 0x00000000fa300000)
  to   space 1024K,   0% used [0x00000000fa300000, 0x00000000fa300000, 0x00000000fa400000)
 tenured generation   total 10240K, used 6144K [0x00000000fa400000, 0x00000000fae00000, 0x00000000fae00000)
   the space 10240K,  60% used [0x00000000fa400000, 0x00000000faa00010, 0x00000000faa00200, 0x00000000fae00000)
 compacting perm gen  total 21248K, used 2558K [0x00000000fae00000, 0x00000000fc2c0000, 0x0000000100000000)
   the space 21248K,  12% used [0x00000000fae00000, 0x00000000fb07f9c8, 0x00000000fb07fa00, 0x00000000fc2c0000)
No shared spaces configured.

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

  既然虚拟机采用了分代收集的思想来管理内存,那么内存回收时就必须能识别哪些对象应放在新生代,哪些对象应放在老年代。为了做到这一点,虚拟机给每个对象定义了一个对象年龄计数器。如果对象在Eden出生并经过第一次Minor GC后仍然存活且能被Survivor容纳的话,将被移动到Survivor空间,并且对象年龄设为1,对象在Survivor区中每经过一次Minor GC,年龄就增加1岁,当年龄增加到一定程度(默认是15岁),就会晋升到老年代。对象晋升到老年代的年龄阈值,可以通过参数-XX:MaxTenuringThreshold设置。还是看一段代码和日志信息吧!

public class Main {
    private static final int _1MB = 1024 * 1024;
    public static void main(String[] args) {
        /**
         * VM参数:-verbose:gc -Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails -XX:SurvivorRatio=8 -XX:MaxTenuringThreshold=1 -XX:+PrintTenuringDistribution
         * -Xms20M 设置堆大小为20M   -Xmx20M 避免堆自动扩展         -Xmn10M  设置年轻代大小 
         * -XX:+PrintGCDetails 打印日志信息
         * -XX:SurvivorRatio=8  设置Eden和Survivor大小比值
* -XX:MaxTenuringThreshold 当年龄大于该值时,放入老年代
*/ // TODO Auto-generated method stub byte[] allocation1, allocation2, allocation3, allocation4; allocation1 = new byte[_1MB / 4]; allocation2 = new byte[4 * _1MB]; allocation3 = new byte[5 * _1MB]; //新生代执行Minor GC,将allocation1移入Survivor,将allocation2移入老年代 allocation3 = null; allocation3 = new byte[4 * _1MB]; //直接在新生代上分配空间 } } //打印日志信息 [GC[DefNew Desired survivor size 524288 bytes, new threshold 1 (max 1) - age 1: 737904 bytes, 737904 total : 5188K->720K(9216K), 0.0044078 secs] 5188K->4816K(19456K), 0.0044833 secs] [Times: user=0.02 sys=0.00, real=0.02 secs] [GC[DefNew Desired survivor size 524288 bytes, new threshold 1 (max 1) - age 1: 136 bytes, 136 total : 5925K->0K(9216K), 0.0028463 secs] 10021K->4816K(19456K), 0.0029261 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] Heap def new generation total 9216K, used 4260K [0x00000000f9a00000, 0x00000000fa400000, 0x00000000fa400000) eden space 8192K, 52% used [0x00000000f9a00000, 0x00000000f9e28fd0, 0x00000000fa200000) from space 1024K, 0% used [0x00000000fa200000, 0x00000000fa200088, 0x00000000fa300000) to space 1024K, 0% used [0x00000000fa300000, 0x00000000fa300000, 0x00000000fa400000) tenured generation total 10240K, used 4816K [0x00000000fa400000, 0x00000000fae00000, 0x00000000fae00000) the space 10240K, 47% used [0x00000000fa400000, 0x00000000fa8b4050, 0x00000000fa8b4200, 0x00000000fae00000) compacting perm gen total 21248K, used 2558K [0x00000000fae00000, 0x00000000fc2c0000, 0x0000000100000000) the space 21248K, 12% used [0x00000000fae00000, 0x00000000fb07fa00, 0x00000000fb07fa00, 0x00000000fc2c0000) No shared spaces configured.

  如上中的日志信息中,第一个红色字体(5188K->720K(9216K))说明将allocation1放入了Survivor区,第二个红色字体(5925K->0K)说明将新生代内存清空,其中allocation3对象的值为null,直接清除内存,而处于Survivor区中的allocation1直接移入老年代。最后堆中内存分配情况为allocation1和allocation2都在老年代,而allocation3在新生代中。大家可自行调大-XX:MaxTenuringThreshold的值,再次观察日志信息。

动态对象年龄判定

  为了能更好地适应不同程序的内存状况,虚拟机并不是永远地要求对象的年龄必须到达MaxTenuringThreshold才能晋升到老年代,如果Survivor空间中相同年龄对象大小的总和大于Survivor空间的一半,年龄大于等于该年龄的对象就可以直接进入老年代,无需等到MaxTenuringThreshold中要求的年龄。

public class Main {
    private static final int _1MB = 1024 * 1024;
    public static void main(String[] args) {
        /**
         * VM参数:-verbose:gc -Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails -XX:SurvivorRatio=8 -XX:MaxTenuringThreshold=15 -XX:+PrintTenuringDistribution -XX:+UseSerialGC
         * -Xms20M 设置堆大小为20M   -Xmx20M 避免堆自动扩展         -Xmn10M  设置年轻代大小 
         * -XX:+PrintGCDetails 打印日志信息
         * -XX:SurvivorRatio=8  设置Eden和Survivor大小比值
         */
        // TODO Auto-generated method stub
        byte[] allocation1, allocation2, allocation3, allocation4;
        allocation1 = new byte[_1MB / 4];
        allocation2 = new byte[_1MB / 4];
        allocation3 = new byte[4 * _1MB];
        allocation4 = new byte[4 * _1MB];  //Minor GC 将allocation1和allocation2放入Survivor,将allocation3放入老年代,allocation4放入新生代
        allocation4 = null;
        allocation4 = new byte[4 * _1MB];
    }
}

//打印日志
[GC[DefNew
Desired survivor size 524288 bytes, new threshold 1 (max 15)
- age   1:    1000064 bytes,    1000064 total
: 5280K->976K(9216K), 0.0055251 secs] 5280K->5072K(19456K), 0.0055829 secs] [Times: user=0.00 sys=0.02, real=0.02 secs] 
[GC[DefNew
Desired survivor size 524288 bytes, new threshold 15 (max 15)
- age   1:        136 bytes,        136 total
: 5236K->0K(9216K), 0.0017304 secs] 9332K->5072K(19456K), 0.0017616 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
Heap
 def new generation   total 9216K, used 4260K [0x00000000f9a00000, 0x00000000fa400000, 0x00000000fa400000)
  eden space 8192K,  52% used [0x00000000f9a00000, 0x00000000f9e28fd0, 0x00000000fa200000)
  from space 1024K,   0% used [0x00000000fa200000, 0x00000000fa200088, 0x00000000fa300000)
  to   space 1024K,   0% used [0x00000000fa300000, 0x00000000fa300000, 0x00000000fa400000)
 tenured generation   total 10240K, used 5072K [0x00000000fa400000, 0x00000000fae00000, 0x00000000fae00000)
   the space 10240K,  49% used [0x00000000fa400000, 0x00000000fa8f4060, 0x00000000fa8f4200, 0x00000000fae00000)
 compacting perm gen  total 21248K, used 2558K [0x00000000fae00000, 0x00000000fc2c0000, 0x0000000100000000)
   the space 21248K,  12% used [0x00000000fae00000, 0x00000000fb07fa10, 0x00000000fb07fc00, 0x00000000fc2c0000)
No shared spaces configured.

空间分配担保

  在发生Minor GC之前,虚拟机会先检查老年代最大可用连续空间是否大于新生代所有内存空间,如果条件成立,那么Minor GC可以确保是安全的。如果不成立,虚拟机会查看HandlePromotionFailure设置值是否允许担保失败。如果允许那么会继续检查老年代最大可用连续内存空间是否大于历次晋升到老年代对象的平均大小,如果大于,将尝试进行一次Minor GC,但本次Minor GC存在风险;如果小于或者HandlePromotionFailure设置不允许冒险,那这时也要改为进行一次Full GC,让老年代腾出更多的空间。但是在JDK6 Update24之后,HandlePromotionFailure参数将不会影响到虚拟机的空间分配担保策略,观察OpenJDK中的源码可以发现虽然还定义了该参数,但是代码中已不使用它了。JDK6 Update24之后的规则变为只要老年代的连续空间大于新生代对象总大小或者历次晋升的平均大小就会进行Minor GC,否则将进行Full GC。

以上是关于内存回收策略的主要内容,如果未能解决你的问题,请参考以下文章

内存分配与回收策略

内存分配与回收策略

深入理解_JVM内存管理内存分配和回收策略06

JVM-内存分配与回收策略

浅谈java内存分配和回收策略

Redis原理篇之通信协议和内存回收