JVM虚拟机-GC 回收机制与分代回收策略
垃圾回收(Garbage Collection,简写为GC)
Java语言开发者比C语言开发者幸福的地方就在于,我们不需要手动释放对象的内存,JVM中的垃圾回收器(Garbage Collector)会为我们自动回收。但是这种幸福是有代价的:一旦这种自动化机制出错,我们又不得不深入理解GC回收机制,甚至需要对这些“自动化”的技术实施必要的监控和调节。
Java内存运行时区域的各个部分,其中程序计数器,虚拟机栈,本地方法栈3个区域随线程而生,随线程而灭;栈中的栈帧随着方法的进入和退出而有条不紊地执行着出栈和入栈操作,这几个区域内不需要过多考虑回收的问题。
而堆和方法区则不同,一个接口中的多个实现类需要的内存可能不一样,一个方法中的多个分支需要的内存也可能不一样,我们只有在程序处于运行期间时才能知道会创建哪些对象,这部分内存的分配和回收都是动态的,垃圾收集器所关注的就是这部分内存。
什么是垃圾
所谓垃圾就是内存中已经没有用的对象。既然是“垃圾回收”,那就必须知道哪些对象是垃圾。Java虚拟机中使用一种叫作“可达性分析”的算法和“引用计数器”算法来决定对象是否可以被回收。
引用计数器算法
引用计数器算法简单概括为:给对象添加一个引用计数器,每当有一个地方引用该对象时,计数器+1,当引用失效时,计数器-1,任何时刻,当计数器为0的时候,该对象不再被引用。
客观的说,引用计数器的实现简单,判定效率也高,大部分场景下是一个不错的选择。但是,当前主流的JVM均没有采用标记清除算法,原因在于,它很难解决对象之间互相循环调用的情况。
可达性分析算法
可达性分析算法是从离散数学中的图论引入的,JVM把内存中所有的对象之间的引用关系看作一张图,通过一组名为“GC Root”的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链,最后通过判断对象的引用链是否可达来决定对象是否可以被回收。如下图所示:
比如上图中,对象A/B/C/D/E与GC Root之间都存在一条直接或者间接的引用链,这也代表它们与GC Root之间是可达的,因此它们是不能被GC回收掉的。而对象M和K虽然被对象J引用到,但是并不存在一条引用链连接它们与GC Root,所以当GC进行垃圾回收时,只要遍历到J/M/K这3个对象,就会将它们回收。
注意:上图中圆形图标虽然标记的是对象,但实际上代表的是此对象在内存中的引用。包括GC Root也是一组引用而非对象。
GC Root对象
在Java中,有以下几种对象可以作为GC Root:
- Java虚拟机栈(局部变量表)中的引用的对象
- 方法区中静态引用指向的对象
- 仍处于存活状态中的线程对象
- Native方法中JNI引用的对象
什么时候回收
不同的虚拟机实现有着不同的GC实现机制,但是一般情况下每一种GC实现都会在以下两种情况下触发垃圾回收。
- Allocation Failure:在堆内存中分配时,如果因为可用的剩余空间不足导致对象内存分配失败,这时系统会触发一次GC。
- System.gc();:在应用层,Java开发工程师可以主动调用此API来请求一次GC。
代码验证GC Root的几种情况
现在我们了解了Java中的GC Root,以及何时触发GC,接下来就通过几个案例来验证GC Root的情况。
-Xms 初始分配JVM的运行时内存大小,如果不指定默认为物理内存的1/64。
比如我们运行如下命令执行HelloWorld程序,从物理内存中分配出200M空间给JVM内存。
java -Xms200m HelloWorld
验证虚拟机栈(栈帧中的局部变量)中引用的对象作为GC Root
运行如下代码:
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("start :");
printMemory();
method();
System.gc();
System.out.println("first GC :");
printMemory();
}
public static void method(){
GCRootLocalVariable g = new GCRootLocalVariable();
System.gc();
System.out.println("second GC :");
printMemory();
}
public static void printMemory(){
System.out.println("free is "+ Runtime.getRuntime().freeMemory()/1024/1024+"M,");
System.out.println("total is "+ Runtime.getRuntime().totalMemory()/1024/1024+"M,");
}
}
打印日志:
start :
free is 237M,
total is 240M,
second GC :
free is 158M,
total is 240M,
first GC :
free is 238M,
total is 240M,
可以看出:
- 当第一次GC时,g作为局部变量,引用了new出的对象(80M),并且它作为GC Root,在GC后并不会被回收。
- 当第二次GC:method()方法执行完后,局部变量g跟随方法消失,不再由引用类型指向该80M对象,所以第二次GC后此80M也会被回收。
**注意:**上面日志后面的实例中,因为有中间变量,所以会有1M左右的误差,但不影响我们分析GC过程。
验证方法区中的静态变量引用的对象作为GC Root
运行如下代码:
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("start:");
printMemory();
GCRootStaticVariable g = new GCRootStaticVariable(4 * _10MB);
g.staticVariable = new GCRootStaticVariable(8*_10MB);
g = null;
System.gc();
System.out.println("GC Finished");
printMemory();
}
public static void printMemory(){
System.out.println("free is "+ Runtime.getRuntime().freeMemory()/1024/1024+"M,");
System.out.println("total is "+ Runtime.getRuntime().totalMemory()/1024/1024+"M,");
}
}
打印日志:
start:
free is 237M,
total is 240M,
GC Finished
free is 158M,
total is 240M,
可以看出:
程序刚开始运行时内存为237M,并分别创建了g对象(40M),同时也初始化g对象内部的静态变量staticVariable对象(80M)。当调用GC时,只有g对象的40M被GC回收掉,而静态变量staticVariable作为GC Root,它引用的80M并不会被回收。
验证活跃线程作为GC Root
运行如下代码:
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("start memory:");
printMemory();
AsyncTask at = new AsyncTask(new GCRootThread());
Thread thread = new Thread(at);
thread.start();
System.gc();
System.out.println("main finished , GC finished");
printMemory();
thread.join();
at = null;
System.gc();
System.out.println("thread finished , GC finished");
printMemory();
}
public static void printMemory(){
System.out.println("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;
}
public void run(){
try{
Thread.sleep(500);
}catch(Exception e){}
}
}
}
打印日志:
start memory:
free is 237M,
total is 240M,
main finished , GC finished
free is 158M,
total is 240M,
thread finished , GC finished
free is 238M,
total is 240M,
可以看出:
程序刚开始时是237M内存,当调用第一次GC时,线程并没有执行结束,并且它作为GC Root,所以它所引用的80M内存并不会被GC回收掉。thread.join();保证线程结束再调用后续代码,所以当调用第二次GC时,线程已经执行完毕并被置为null,这时线程已经被销毁,所以之前它所引用的80M此时会被GC回收掉。
验证成员变量是否可作为GC Root
运行如下代码:
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("start:");
printMemory();
GCRootClassVariable g = new GCRootClassVariable(4 * _10MB);
g.classVariable = new GCRootClassVariable(8*_10MB);
g = null;
System.gc();
System.out.println("GC finished");
printMemory();
}
public static void printMemory(){
System.out.println("free is "+ Runtime.getRuntime().freeMemory()/1024/1024+"M,");
System.out.println("total is "+ Runtime.getRuntime().totalMemory()/1024/1024+"M,");
}
}
打印日志:
start:
free is 237M,
total is 240M,
GC finished
free is 238M,
total is 240M,
可以看出当调用GC时,因为g已经置为null,因此g中的全局变量classVariable此时也不再被GC Root所引用。所以最后g(40M)和classVariable(80M)都会被回收掉。这也表名全局变量同静态变量不同,它不会被当作GC Root。
如何回收垃圾
标记清楚算法(Mark and Sweep GC)
从“GC Roots” 集合开始,将内存整个遍历一次,保留所有可以被GC Roots直接或间接引用到的对象,而剩下的对象都当作垃圾对待并回收,过程分为两步。
- Mark标记阶段:找到内存中所有GC Root对象,只要是和GC Root对象直接或间接相连的则标记为灰色(也就是存活对象),否则标记为黑色(也就是垃圾对象)。
- Sweep清楚阶段:当遍历完所有的GC Root之后,则将标记为垃圾的对象直接清楚。
如下图所示:
- 优点:实现简单,不需要将其对象进行移动。
- 缺点:这个算法需要中断进程内其他组件的执行(stop the world),并且可能产生内存碎片,提高了垃圾回收的频率。
复制算法(Copying)
将现有内存空间分为两块,每次只使用其中一块,在垃圾回收时将正在使用的内存中的存活对象复制到未被使用的内存块中。之后,清楚正在使用的内存块中的所有对象,交换两个内存的角色,完成垃圾回收。
-
复制算法之前,内存分为A/B两块,并且当前只是用内存A,内存的状况如下图所示:
-
标记完之后,所有可达对象都被按次序复制到内存B中,并设置B为当前使用中的内存。内存状况如下图所示:
- 优点:按顺序分配内存即可,实现简单,运行高效,不用考虑内存碎片。
- 缺点:可用的内存大小缩小为原来的一半,对象存活率高时会频繁进行复制。
标记-压缩算法(Mark-Compact)
需要先从根节点开始对所有的可达对象做一次标记,之后,它并不简单地清理未标记的对象,而是将所有的存活对象压缩到内存的一端。最后清理边界外所有的空间。因此标记压缩也分两步完成。
-
Mark标记阶段:找到内存中所有的GC Root对象,只要是和GC Root直接或间接相连的则标记为灰色,否则标记为黑色。
-
Compact压缩阶段:将剩余存货对象压缩到内存的某一端。
- 优点:这种方法既避免了碎片的产生,又不需要两块相同的内存空间,因此,其性价比比较高。
- 缺点:所谓压缩操作,仍需要进行局部的对象移动,所以一定程度上还是降低了效率。
JVM分代回收策略
Java虚拟机根据对象存活的周期不同,把堆内存划分为几块,一般为新生代,老年代,这就是JVM的内存分代策略。
分代回收的中心思想就是:对于新创建的对象会在新生代中分配内存,此区域的对象生命周期一般较短。如果经过多次回收仍然存活下来,则将它们转移到老年代中。
年轻代(Young Generation)
新生成的对象优先存放在新生代中,新生代对象朝生夕死,存活率很低,在新生代中,常规应用进行一次垃圾收集一般可以回收70%~95%的空间,回收效率很高。新生代中因为要进行一些复制操作,所以一般采用的GC回收算法是复制算法。
新生代又可以继续细分为3部分:Eden,Survivor0(简称S0),Survivor1(简称S1)。这三部分按照8:1:1的比例来划分新生代。这三块区域的内存分配过程如下:
绝大多数刚刚被创建的对象会存放在Eden区。如图:
当Eden区第一次满的时候,会进行垃圾回收。首先将Eden区的垃圾对象回收清除,并将存活的对象复制到S0,此时S1是空的。如图:
下一次Eden区满时,再执行一次垃圾回收。此次会将Eden和S0区中所有的垃圾对象清除,并将存活对象复制到S1,此时S0变为空。如图:
如此反复在S0和S1之间切换几次(默认是15次)之后,如果还有存活对象。说明这些对象的生命周期较长,则将它们转移到老年代中。如图:
老年代(Old Generation)
一个对象如果在新生代存活了足够长的时间而没有被清理掉,则会被复制到老年代。老年代的内存大小一般比新生代大,能存放更多的对象。如果对象比较大(比如长字符串或者大数组),并且新生代的剩余空间不足,则这个大对象会直接被分配到老年代上。
老年代因为对象的生命周期较长,不需要过多的赋值操作,所以一般采用标记压缩的回收算法。
注意:对于老年代可能存在这么一种情况,老年代中的对象有时候会引用到新生代对象。这时如果要执行新生代GC,则可能需要查询整个老年代上可能存在引用新生代的情况,这显然是低效的。所以,老年代中维护了一个512byte的card table ,所有老年代对象引用新生代对象的信息都记录在这里。每当新生代发生GC时,只需要检查这个card table 即可,大大提高了性能。
GC Log分析
为了让上层应用开发人员更加方便的调试Java程序,JVM提供了相应的GC日志。在GC执行垃圾回收事件的过程中,会有各种相应的log被打印出来。其中新生代和老年代所打印的日志是有区别的。
- 新生代 GC:这一区域的GC叫做 Minor GC。因为Java对象大多都具备朝夕生死的特性,所以Minor GC非常频繁,一般回收速度也比较快。
- 老年代 GC:发生在这一区域的GC叫做 Major GC 或者 Full GC 。当出现了Major GC,经常会伴随至少一次Minor GC。
**注意:**在有些虚拟机实现中,Major GC和Full GC还是有一些区别的。Major GC只是代表回收老年代的内存,而Full GC则代表回收整个堆中的内存,也就是新生代+老年代。
接下来通过几个案例来分析如何查看GC Log,分析这些GC Log的过程也能再加深对JVM分代策略的理解。
首先我们需要了解几个Java命令的参数:
我们用如下代码,在内存中创建4个byte类型数组来演示内存分配与GC的详细过程。代码如下:
/**
* VM agrs: -Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails
* -XX:SurvivorRatio=8
*/
public class MinorGCTest {
private static final int _1MB = 1024*1024;
public static void main(String[] args) {
testAllocation();
}
public static void testAllocation(){
byte[] a1,a2,a3,a4;
a1 = new byte[2 * _1MB];
a2 = new byte[2 * _1MB];
a3 = new byte[2 * _1MB];
a4 = new byte[1 * _1MB];
}
}
通过上面的参数,可以看出堆内存总大小为20M,其中新生代占10M,剩下的10M会自动分配给老年代。
idea参数设置
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-qKbCDlHl-1586180322594)(C:\Users\大狼狗skr~\AppData\Roaming\Typora\typora-user-images\1586174256464.png)]
执行上述代码打印日志如下:
Heap
PSYoungGen total 9216K, used 8192K [0x00000000ff600000, 0x0000000100000000, 0x0000000100000000)
eden space 8192K, 100% used [0x00000000ff600000,0x00000000ffe00000,0x00000000ffe00000)
from space 1024K, 0% used [0x00000000fff00000,0x00000000fff00000,0x0000000100000000)
to space 1024K, 0% used [0x00000000ffe00000,0x00000000ffe00000,0x00000000fff00000)
ParOldGen total 10240K, used 0K [0x00000000fec00000, 0x00000000ff600000, 0x00000000ff600000)
object space 10240K, 0% used [0x00000000fec00000,0x00000000fec00000,0x00000000ff600000)
Metaspace used 2630K, capacity 4486K, committed 4864K, reserved 1056768K
class space used 282K, capacity 386K, committed 512K, reserved 1048576K
日志中的各字段代表意义如下:
从日志中可以看出:程序执行完后,a1,a2,a3,a4四个对象都被分配在了新生代的Eden区。
如果我们将测试代码中的a4初始化改为a4 = new byte[2*_1MB];则打印日志如下:
Heap
PSYoungGen total 9216K, used 2130K [0x00000000ff600000, 0x0000000100000000, 0x0000000100000000)
eden space 8192K, 26% used [0x00000000ff600000,0x00000000ff814930,0x00000000ffe00000)
from space 1024K, 0% used [0x00000000ffe00000,0x00000000ffe00000,0x00000000fff00000)
to space 1024K, 0% used [0x00000000fff00000,0x00000000fff00000,0x0000000100000000)
ParOldGen total 10240K, used 6664K [0x00000000fec00000, 0x00000000ff600000, 0x00000000ff600000)
object space 10240K, 65% used [0x00000000fec00000,0x00000000ff282390,0x00000000ff600000)
Metaspace used 2631K, capacity 4486K, committed 4864K, reserved 1056768K
class space used 282K, capacity 386K, committed 512K, reserved 1048576K
这是因为在给a4分配内存之前,Eden区已经被占用6M。已经无法再分配出2M来存储a4对象。因此会执行一次MinorGC。并尝试将存活的a1、a2、a3复制到S1区。但是S1区只有1M空间,所以没有办法存储a1、a2、a3任意一个对象。在这种情况下a1、a2、a3将被转移到老年代,最后将a4保存在Eden区。所以最终结果就是:Eden 区占用 2M(a4),老年代占用 6M(a1、a2、a3)。
引用
判断对象是否存活我们是通过GC Roots的引用可达性来判断的。但是JVM中的引用关系并不止一种,而是有四种,根据引用的强度由强到弱,它们分别是:强引用(String Reference),软引用(Soft Reference),弱引用(Weak Reference),虚引用(Phantom Reference)。
平时的项目中,尤其是Android项目,因为有大量的图像对象,使用软引用的场景较多。所以重点看下软引用SoftReference的使用,不当的使用软引用有时也会导致系统异常。
软引用常规使用
代码如下:
/**
*VM options: -Xmx200M
*/
public class SoftReferenceNormal {
static class SoftObject{
byte[] data = new byte[120 * 1024 * 1024];
}
public static void main(String[] args) {
SoftReference<SoftObject> cacheRef = new SoftReference<>(new SoftObject());
System.out.println("第一次GC前,软引用:"+cacheRef.get());
System.gc();
System.out.println("第一次GC后,软引用:"+cacheRef.get());
SoftObject newSo = new SoftObject();
System.out.println("再次分配120M强引用对象后,软引用:"+cacheRef.get());
}
}
执行上述代码,打印日志如下:
第一次GC前,软引用:SoftReferenceNormal$SoftObject@15db9742
第一次GC后,软引用:SoftReferenceNormal$SoftObject@15db9742
再次分配120M强引用对象后,软引用:null
首先通过-Xmx200M将堆内存的最大内存设置为200M.从日志中可以看出,当第一次GC时,内存中还有剩余内存,所以软引用并不会被GC回收。但是当我们再次创建一个120M的强引用时,JVM可用内存已经不够,所以会尝试将软引用给回收掉。
软引用隐藏问题
需要注意的是,被软引用对象关联的对象会自动被垃圾回收器回收,但是软引用对象本身也是一个对象,这些创建的软引用并不会自动被垃圾回收器回收掉。比如如下代码:
/**
* VM options:-Xms4M -Xmx4M -Xmn2M
*/
public class SoftReferenceTest {
public static class SoftObject{
byte[] data = new byte[1024];
}
public static int CACHE_INITIAL_CAPACITY = 100*1024;
public static ReferenceQueue<SoftObject> referenceQueue = new ReferenceQueue<>();
public static Set<SoftReference<SoftObject>> cache = new HashSet<>(CACHE_INITIAL_CAPACITY);
public static void main(String[] args) {
for (int i = 0;i<CACHE_INITIAL_CAPACITY;i++){
SoftObject obj = new SoftObject();
cache.add(new SoftReference<>(obj,referenceQueue));
if (i%10000 == 0){
System.out.println("size of cache:"+cache.size());
}
}
System.out.println("End!");
}
}
上述代码,虽然每一个SoftObject都被一个软引用所引用,在内存紧张时,GC会将SoftObject所占用的1KB回收。但是每一个SoftReference又都被Set强引用。执行上述代码结果如下:
size of cache:1
size of cache:10001
size of cache:20001
Exception in thread "main" java.lang.OutOfMemoryError: GC overhead limit exceeded
限制堆内存大小为4M,最终导致程序崩溃,但是异常的原因并不是普通的堆内存溢出,而是“GC overhead”。之所以会抛出这个错误,是由于虚拟机一直在不断的回收软引用,回收的速度过快,占用的cpu过大(超过98%),并且每次回收掉的内存过小(小于2%),导致最终抛出了这个错误。
这里需要做优化,合适的处理方式时注册一个引用队列,每次循环之后将引用队列中出现的软引用从cache中移除。如下所示:
public class SoftReferenceTest {
public static int removeRefs = 0;
public static class SoftObject{
byte[] data = new byte[1024];
}
public static int CACHE_INITIAL_CAPACITY = 100*1024;
public static ReferenceQueue<SoftObject> referenceQueue = new ReferenceQueue<>();
public static Set<SoftReference<SoftObject>> cache = new HashSet<>(CACHE_INITIAL_CAPACITY);
public static void main(String[] args) {
for (int i = 0;i<CACHE_INITIAL_CAPACITY;i++){
SoftObject obj = new SoftObject();
cache.add(new SoftReference<>(obj,referenceQueue));
clearUselessReferences();
if (i%10000 == 0){
System.out.println("size of cache:"+cache.size());
}
}
System.out.println("End! removed soft referneces="+removeRefs);
}
private static void clearUselessReferences() {
Reference<? extends SoftObject> ref = referenceQueue.poll();
while(ref!=null){
if (cache.remove(ref)){
removeRefs++;
}
ref = referenceQueue.poll();
}
}
}
再次运行后,结果如下:
size of cache:1
size of cache:484
size of cache:1184
size of cache:1514
size of cache:724
size of cache:1424
size of cache:1317
size of cache:964
size of cache:1664
size of cache:504
size of cache:1204
End! removed soft referneces=100657
可以看出优化后,程序可以正常执行完。并且在执行过程中会动态的将集合中的软引用删除。