垃圾回收机制和内存分代策略
垃圾回收(Garbage Collection,简称GC),Java语言与C语言相比,我们不需要手动释放对象的内存,JVM中的垃圾回收器(Garbage Collection)会为我们自动回收。但是这种方便是有代价的:一旦这种自动化机制出错,我们又不得不去深入理解GC回收机制,甚至需要对这些“自动化”的技术实施必要的监控和调节。
我们了解到Java内存运行时区域的各个部分,其中程序计数器,虚拟机栈,本地方法栈3个区域随着线程生而生,灭而灭;栈中的栈帧随着方法的进入和退出有条不紊地执行着出栈和入栈操作,这几个区域内不需要过多考虑回收问题。(线程私有)
而堆和方法区不同,一个接口中的多个实现类需要的内存可能不一样,一个方法中的多个分支需要的内存也可能不一样,我们只有在程序处于运行期间时才能知道会创建哪些对象,这部分内存的分配和回收都是动态的 ,GC关注的就是这部分内存。
垃圾就是内存中已经没有用的对象。既然是“垃圾回收”,那就必须知道哪些对象是垃圾。Java虚拟机中使用一种叫做 “可达性分析”的算法来决定对象是否可以被回收 。
可达性分析算法是从离散数学中的图论引入的,JVM把内存中所有的对象之间的引用关系看作一张图, 通过一组名为“GC Root”的对象作为起始点 ,从这些节点开始向下搜索 ,搜索所走过的路径称为引用链 ,最后通过判断对象的引用链是否可达来决定对象是否可以被回收。如图:
上图中,对象A/B/C/D/E与GC Root之间都存在一条直接或间接的引用链,这也代表它们与GC Root之间是可达的,因此它们是不能被GC回收掉的。而对象M,K虽然被J引用到,但是并不存在一条引用链链接它们与GC Root,所以当GC进行垃圾回收时,只要遍历到J/K/M这3个对象,就会将他们回收。
上面原型虽然标记的是对象,但实际上代表是此对象在内存中的引用。包括GC Root也是一组引用而并非对象。
在Java中,有以下几种对象可以作为GC Root:
不同的虚拟机实现有着不同的GC实现机制,但是一般情况下每一种GC实现都会在以下两种情况下出发垃圾回收:
首先需要了解一个执行Java命令时的参数
-Xms 初始分配JVM运行时的内存大小,如果不指定默认为物理内存的1/64
比如我们运行如下命令执行HelloWorld程序,从物理内存中分配出200M空间分配给JVM内存。
java -Xms200m HelloWorld
public class GCRootLocalVariable {
private int _10MB = 10 * 1024 * 1024;
private byte[] memory = new byte[8 * _10MB];
public static void main(String[] args){
System.out.println("开始时:");
printMemory();
method();
System.gc();
System.out.println("第二次GC完成");
printMemory();
}
public static void method() {
GCRootLocalVariable g = new GCRootLocalVariable();
System.gc();
System.out.println("第一次GC完成");
printMemory();
}
/**
* 打印出当前JVM剩余空间和总的空间大小
*/
public static void printMemory() {
System.out.print("free is " + Runtime.getRuntime().freeMemory()/1024/1024 + " M, ");
System.out.println("total is " + Runtime.getRuntime().totalMemory()/1024/1024 + " M, ");
}
}
开始时:
free is 242 M, total is 245 M,
第一次GC完成
free is 163 M, total is 245 M,
第二次GC完成
free is 243 M, total is 245 M,
可以看出:
public class GCRootStaticVariable{
private static int _10MB = 10 * 1024 * 1024;
private byte[] memory;
private static GCRootStaticVariable staticVariable;
public GCRootStaticVariable(int size) {
memory = new byte[size];
}
public static void main(String[] args){
System.out.println("程序开始:");
printMemory();
GCRootStaticVariable g = new GCRootStaticVariable(4 * _10MB);
g.staticVariable = new GCRootStaticVariable(8 * _10MB);
// 将g置为null, 调用GC时可以回收此对象内存
g = null;
System.gc();
System.out.println("GC完成");
printMemory();
}
/**
* 打印出当前JVM剩余空间和总的空间大小
*/
public static void printMemory() {
System.out.print("free is " + Runtime.getRuntime().freeMemory()/1024/1024 + " M, ");
System.out.println("total is " + Runtime.getRuntime().totalMemory()/1024/1024 + " M, ");
}
}
程序开始:
free is 242 M, total is 245 M,
GC完成
free is 163 M, total is 245 M,
可以看出:
程序开始运行时内存为242M,并分别创建了g对象(40M),同时也初始化g对象内部的静态变量staticVariable对象(80M)。当调用GC时,只有g对象的40M被GC回收掉了,而静态变量作为GC Root,他引用的80M并不会被回收。
public class GCRootThread{
private int _10MB = 10 * 1024 * 1024;
private byte[] memory = new byte[8 * _10MB];
public static void main(String[] args) throws Exception {
System.out.println("开始前内存情况:");
printMemory();
AsyncTask at = new AsyncTask(new GCRootThread());
Thread thread = new Thread(at);
thread.start();
System.gc();
System.out.println("main方法执行完毕,完成GC");
printMemory();
thread.join();
at = null;
System.gc();
System.out.println("线程代码执行完毕,完成GC");
printMemory();
}
/**
* 打印出当前JVM剩余空间和总的空间大小
*/
public static void printMemory() {
System.out.print("free is " + Runtime.getRuntime().freeMemory()/1024/1024 + " M, ");
System.out.println("total is " + Runtime.getRuntime().totalMemory()/1024/1024 + " M, ");
}
private static class AsyncTask implements Runnable {
private GCRootThread gcRootThread;
public AsyncTask(GCRootThread gcRootThread){
this.gcRootThread = gcRootThread;
}
@Override
public void run() {
try{
Thread.sleep(500);
} catch(Exception e){}
}
}
}
开始前内存情况:
free is 242 M, total is 245 M,
main方法执行完毕,完成GC
free is 163 M, total is 245 M,
线程代码执行完毕,完成GC
free is 243 M, total is 245 M,
可以看到:
程序开始时是242M内存,当调用第一次GC时线程并没有执行结束,并且它作为GC Root,所以它所引用的80M内存并不会被GC回收掉。thread.join()保证线程结束后再调用后续代码,所以当调用第二次GC时, 线程已经执行完毕并被置为null ,这时线程已经被销毁,所以之前它所引用的80M此时会被GC回收掉。
public class GCRootClassVariable{
private static int _10MB = 10 * 1024 * 1024;
private byte[] memory;
private GCRootClassVariable classVariable;
public GCRootClassVariable(int size){
memory = new byte[size];
}
public static void main(String[] args){
System.out.println("程序开始:");
printMemory();
GCRootClassVariable g = new GCRootClassVariable(4 * _10MB);
g.classVariable = new GCRootClassVariable(8 * _10MB);
g = null;
System.gc();
System.out.println("GC完成");
printMemory();
}
/**
* 打印出当前JVM剩余空间和总的空间大小
*/
public static void printMemory() {
System.out.print("free is " + Runtime.getRuntime().freeMemory()/1024/1024 + " M, ");
System.out.println("total is " + Runtime.getRuntime().totalMemory()/1024/1024 + " M, ");
}
}
程序开始:
free is 242 M, total is 245 M,
GC完成
free is 243 M, total is 245 M,
对比第二个静态变量的例子,可以看到:
当调用GC时,因为g已经置为Null,因此g中的全局变量classVariable此时也不再被GC Root所引用。所以最后g(40M)和classVariable(80M)都会被回收掉。 这也表明全局变量同静态变量不同,它不会被当作GC Root 。
注意:
上面的几种情况往往也是内存泄漏发生的场景。假设下我们将各个Test类换成Android中的Activity的话,将导致Activity无法被系统回收,而一个Activity中的数据往往是较大的,因此内存泄露导致Activity无法回收还是比较致命的。
垃圾收集算法的实现涉及大量的程序细节,各家虚拟机厂商对其实现细节各不相同,所以这里只是介绍下几种算法的思想以及优缺点 。
从“GC Roots”集合开始,将内存整个遍历一次,保留所有可以被GC Roots直接或间接引用到的对象,而剩下的对象都当作垃圾对待并回收,过程分为两步:
将现有的内存空间分为两块,每次只使用其中一块,在垃圾回收时将正在使用的内存中存活对象复制到未被使用的内存块中,交换两个内存的角色,完成垃圾回收。
复制算法之前,内存分为A/B两块,并且当前只使用内存A,内存的状态如图
标记完之后,所有可达对象都被按次序复制到内存B中,并设置B为当前使用中的内存。内存状态如图
需要先从根节点开始对所有可达对象做一次标记,之后,它并不是简单地清理未标记的对象,而是将所有的存活对象压缩到内存的一端。最后,清理边界外所有的空间。因此标记压缩也分为两步完成:
Java虚拟机根据对象存活的周期不同,把堆内存划为几块,一般分为:新生代 ,老年代 ,这就是JVM的内存分代策略 。
注意:在HotSpot中还有一个永久代。
分代回收的中心思想就是:对于新创建的对象会在新生代中分配内存,此区域的对象生命周期一般较短。如果经过多次回收仍然活下来,则将它们转移到老年代中。
新生成的对象优先存放在新生代中,新生代对象朝生夕死,存活率很低,在新生代中,常规应用进行一次垃圾回收一般可以回收70%~95%的空间,回收效率很高。新生代中因为要进行一些复制操作,所以一般采用的GC回收算法是复制算法。
新生代又可继续细分为3部分:Eden、Survivor0(简称S0)、Survivor1(简称S1)。这3部分按照8:1:1的比例来划分新生代。这3块区域的内存分配过程如下:
绝大多数刚刚被创建的对象会存放在Eden区。
当Eden区第一次满的时候,会进行GC。首先将Eden区的垃圾对象清除,并将存活的对象复制到S0,此时S1是空的。
下一次Eden区满时,再执行一次GC。此时会将Eden和S0区中所有垃圾对象清除,并将存活对象复制到S1,此时S0变为空。
如此反复在S0和S1之间切换几次(默认15)之后,如果还有存活对象。说明这些对象的声明周期较长,则将他们转移到老年代中。
一个对象如果在新生代存活了足够长的时间而没有被清理掉,则会被复制到老年代。老年代的内存大小一般比新生代大,能存放更多的对象。如果对象比较大(长字符串或大数组),并且新生代的剩余空间不足,则这个大对象会直接被分配到老年代上。
我们可以使用 -XX:PretenureSizeThreshold
来控制直接升入老年代的对象大小,大于这个值的对象会直接分配到老年代上。老年代因为对象的生命周期较长,不需要过多的复制操作,所以一般采用标记压缩的回收算法。
注意:老年代可能存在这么一种情况,老年代中的对象有时候会引用到新生代对象。这时如果要执行新生代GC,则可能需要查询整个老年代上可能存在引用新生代的情况,这显然是低效的。所以,老年代中维护了一个512byte的card table,所有老年代对象引用新生代对象的信息都记录在这里。每当新生代发生GC时,只需要检查这个card table即可,大大提升了性能。
上面提到过,判断对象是否存活我们是通过GC Roots的引用可达性来判断的。但是JVM中的引用关系并不止一种,而是四种!根据引用强度由强到弱 ,分别是:强引用(Strong Reference),软引用(Soft Reference),弱引用(Weak Reference),虚引用(Phantom Reference)
引用 | GC回收时机 | 示例 |
---|---|---|
强引用 | 如果一个对象具有强引用,GC绝对不会回收它 | Object a = new Object(); |
软引用 | 在内存实在不足时 | SoftReference |
弱引用 | 第一次GC时,如果遍历到此弱引用,则将其回收 | WeakReference |
虚引用 | 无法通过虚引用来获取一个对象的示例 | 不会使用 |
在Android项目中有大量的图像(Bitmap)对象,使用软引用的场景较多。所以看下软引用SoftReference的使用,不当的软引用使用也会导致系统异常。
执行上述代码,打印日志如下:
首先通过-Xmx将堆最大内存设置为200M。从日志中可以看出,当第一次GC时,内存中还有剩余可用内存,所以软引用不会被GC回收。但是当我们再次创建一个120M的强引用时,JVM可用内存已经不够,所以会尝试将软引用给回收掉。
需要注意的是,被软引用对象关联的对象会自动被GC回收,但是软引用对象本身也是一个对象,这些创建的软引用并不会被GC回收掉,如下:
上述代码中,虽然每一个SoftObject都被一个软引用所引用,在内存紧张时,GC会将SoftObject所占用的1KB回收。但是每一个SoftReference又都被Set所引用(强引用)。执行结果如下:
限制堆内存大小为4M,最终程序崩溃,但是异常原因并不是普通的堆内存溢出,而是“GC overhead”。之所以抛出这个错误,是由于虚拟机一直在不断回收软引用,回收进行的速度过快,占用的cpu过大(超过98%),并且每次回收掉的内存过小(小于2%),导致最终抛出了这个错误。
需要做优化,合适的处理方式是注册一个引用队列,每次循环之后将引用队列中出现的软引用对象从cache中移除。如下:
再次运行结果如下:
可以看出优化后,程序正常执行完。并且在执行过程中会动态的将集合中的软引用删除 。