垃圾收集(Garbage Collection GC),GC的历史比Java还要久远,1960年诞生于MIT的Lisp是第一门使用内存动态分配和垃圾收集技术的语言。而垃圾回收需要考虑三件事情。
Java内存的各个区域中,程序计数器,虚拟机栈,本地方法栈都是线程私有的,也就是随线程诞生或死亡,这三个区域基本在编译期就可以确定。栈帧随方法的进入和退出进行入栈和出栈操作,因此这几个区域的内存分配和回收都具有确定性。
Java堆却不同,一个接口中多个实现类需要的内存可能不同,一个方法中多个分支需要的内存也可能不同,而在程序运行期才知道有哪些对象,所以内存的分配和回收也是动态的,垃圾回收器所关注的也是这部分内存。
Java堆几乎存放了所有的对象实例,垃圾回收器在进行回收之前,首先确定的就是哪些对象存活,哪些对象死亡。
给对象添加一个引用计数器,每当一个地方引用它时,计数器加一;当引用失效时,计数器减一;任何时刻计数器为0的对象不可能再被使用。
这个算法简单有效效率高,然而Java虚拟机并没有使用它,因为它不能解决两个对象循环引用的问题
/**
* testGC()方法执行后,objA和objB会不会被GC
*/
public class ReferenceCountingGC{
public Object instance = null;
private static final int _1MB = 1024*1024;
private byte[]bigSize= new byte[2*_1MB];
public static void testGC(){
ReferenceCountingGC objA = new ReferenceCountingGC();
ReferenceCountingGC objB = new ReferenceCountingGC();
objA.instance = objB;
objB.instance = objA;
objA = null;
objB = null;
System.gc();
}
public static void main(String[] args) {
testGC();
}
}
代码中两个对象相互引用,导致它们的引用计数不为0,而这个算法就无法回收它们。
而最后的输出中可以看到,GC是将它们回收了,这也从侧面说明虚拟机不是用的引用算法
思想是通过一系列的"GC ROOT"的对象作为起始点,从这些节点开始往下搜索,搜索走过的路径称为引用链(Reference Chain),当GC ROOT不可到达一个对象时,这个对象是不可用的。
Java语言中可作为GC Roots的对象有以下几种
JDK1.2以前,引用的定义很传统,如果reference类型数据中存储的数值代表的是另外一块内存的起始地址,就称这块内存代表是一个引用。
JDK1.2之后对引用的概念做了扩充。
即使是不可达的对象也不是立即死亡,真正死亡至少要经过两次标记过程。
以下代码可做验证
/**
* 代码演示两点
* 1.对象可以在被回收前自我拯救
* 2.自救机会只有一次,因为一个对象的finalize方法只会被系统调用一次
*/
public class FinalizeEscapeGC{
public static FinalizeEscapeGC SAVE_HOOK = null;
public void isAive(){
System.out.println("I'm alive");
}
@Override
protected void finalize()throws Throwable{
super.finalize();
System.out.println("finalize method excute");
FinalizeEscapeGC.SAVE_HOOK = this;
}
public static void main(String[] args) throws InterruptedException {
SAVE_HOOK = new FinalizeEscapeGC();//对象第一次成功拯救自己
SAVE_HOOK = null;
System.gc();
//finalize方法优先级很低,所以先暂停0.5秒等待它
Thread.sleep(500);
if(SAVE_HOOK!=null){
SAVE_HOOK.isAive();
}else{
System.out.println("I'm dead");
}
//下面再调用一次上面的代码
SAVE_HOOK = new FinalizeEscapeGC();//对象第一次成功拯救自己
SAVE_HOOK = null;
System.gc();
//finalize方法优先级很低,所以先暂停0.5秒等待它
//Thread.sleep(500);
if(SAVE_HOOK!=null){
SAVE_HOOK.isAive();
}else{
System.out.println("I'm dead");
}
//只执行一次finalize方法,自救失败
}
}
结果:
finalize method excute
I’m alive
I’m dead
代码中可以看出finalize方法确实触发了,但是对象却成功逃脱。
方法区中一般不实现垃圾回收,因为它的性价比比较低,相比,在堆中尤其在新生代,常规应用进行一次垃圾收集可以收集70%~95%的空间。
方法区回收分两部分
虚拟机可以对满足以上3个条件的无用类进行回收,这里是可以而不是一定,是否对类进行回收,虚拟机提供了参数进行进行控制。
这是最基础的算法,分为"标记"和"清除"两个阶段,首先标记所有需要回收的对象,在标记完成后统一回收所有被标记的对象。
之所以说它是最基础的算法是因为后续的收集算法是基于此改进的,它主要有两个不足
将内存按容量划分为大小相等的两块,每次只使用其中一块内存,这块内存用完时就将还存活的对象复制到另一块内存上,再把已使用的内存一次清理掉。
目前商业虚拟机都采用这种算法来回收新生代对象
这个算法是根据老年代的特点提出的,标记过程与"标记-清除"算法一致,但后续步骤是先将所有存货对象移至同一端,然后清理标记以外的内存。
就是根据对象年代不同分配不同算法。
并不是每一条指令都会生成OopMap,HotSpot只在特定的位置记录了这些信息,这些位置叫做安全点(Safepoint),即程序不能在所有地方都能停顿下来GC只有在安全点才能停顿。
安全区域解决程序不执行的时候,不执行就是没有分配CPU时间,典型的例子就是线程处于sleep状态或blocked状态,线程无法响应中断请求,走到安全点。
安全区域是指在一段代码片段中,引用关系不会发生变化,在这个区域内任意地方开始GC都是安全的。
收集算法是内存回收的方法论,垃圾回收器是内存回收的具体实现
上图展示了7种作用于不同分代的收集器,如果两个收集器之间存在连线,就说明它们可以搭配使用,位于上面区域的是年轻代,而下面区域的就是老年代。
这是最基本最悠久的收集器,这个收集器是一个单线程收集器,它在进行收集工作的时候,必须暂停其他所有工作线程。
虽然这个收集器看起来效率不高,但是到现在为止,它依然是虚拟机运行在client模式下的默认新生代收集器,而它的优点是简单而高效,对于限定单个CPU的环境来说,它能专注于垃圾收集而没有线程交互,所以对于client模式下的虚拟机来说是很好的选择。
这个收集器其实就是Serial收集器的多线程版本,除了使用多条线程收集垃圾外,其余行为包括Serial收集器可用的所有控制参数。
ParNew收集器除了多线程之外没有太多创新的地方,但它是运行在Server模式下的新生代首选收集器。
它的许多特点与ParNew收集器看上去一致,不同的地方在于它关注点在于达到一个可控制的吞吐量。
吞吐量=运行用户代码时间/(运行用户代码时间+垃圾收集时间)
收集器提供了两个参数来控制吞吐量
-XX: MaxGCPauseMillis:控制最大垃圾收集停顿时间
-XX: GCTimeRatio:设置吞吐量大小
顾名思义它是Serial收集器的老年代版本,使用"标记-整理"算法,也是在于给client模式下使用。
而它如果在Server模式下使用还有两大用途
Parallel Scavenge的老年代版本,使用多线程和"标记-整理"算法。
CMS(Concurrent Map Sweep)收集器是一种以最短回收停顿时间为目标的收集器,通常用于JavaWeb程序上,重视服务响应速度,希望系统时间停顿最短,而它是基于"标记-清除"算法。
整个过程分为四个步骤
初始标记只是标记一下GC Roots能直接关联到的对象,速度很快
并发标记是进行GC RootsTracing的过程
重新标记是为了修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分对象
并发清除就是清除标记
并发标记和并发清除耗时较长,但是可以跟用户线程一起工作,而初始标记和重新标记过程虽然需要单独工作但是耗时较短,所以从总体上来说CMS收集器的内存回收和用户线程是一起并发执行的。
缺点:
G1(Grabage-First)收集器是收集器发展最前沿成果之一,它是一款面向服务端应用的垃圾收集器。
G1之前的收集器收集范围都是整个新生代或是老年代,而G1将整个Java堆划分为多个独立区域(Region),虽然它还有分代的概念,但两代之间不再是物理隔离的,都是一部分Region的集合。
G1跟踪各个Region里面的垃圾堆积的价值大小,在后台维护一个优先列表,根据允许的收集时间,优先收集价值最大的Region。
G1的步骤和CMS有许多相似之处
初始标记: 标记一下GC Roots能直接关联到的对象,并且修改TAMS(Next Top at Mark Start)的值,让下一阶段用户程序并发运行时,能在正确的Region中创建对象。
并发标记: GC Roots开始可达性分析时,找出存活对象,这阶段耗时较长。
最终标记: 最终标记阶段是为了修正在并发标记期间因用户程序继续运行而导致标记产生变动的那部分标记记录。
筛选回收: 首先对各个Region的回收价值和成本进行排序,根据用户所希望的GC停顿时间来制定回收计划。
其中初始标记耗时很短需要停顿线程来进行,并发标记耗时较长,可以和用户线程并发执行,最终标记可以并行执行但是需要停顿线程,筛选回收虽热可以并发,但是因为回收一部分Region,时间是用户可控制的,而停顿将大幅度提高收集效率。
阅读GC日志是处理Java虚拟机内存问题的基础技能,它只是一些人为制定的规则。
例如下面这两段GC日志
33.125:[GC[DefNew:3324K->152K(3712K),0.0025925 secs]3324K->152K(11904K),0.0031680 secs] 1 0 0.6 6 7:[F u l l G C[T e n u r e d:0 K->2 1 0 K(1 0 2 4 0 K),0.0 1 4 9 1 4 2 s e c s]4603K->210K(19456K),[Perm:2999K-> 2999K(21248K)],0.0150007 secs][Times:user=0.01 sys=0.00,real=0.02 secs]
最前面的数字“33.125:”和“100.667:”代表了GC发生的时间,这个数字的含义是从Java 虚拟机启动以来经过的秒数。
GC日志开头的GC和FullGC是垃圾收集的停顿类型,有Full说明这次GC发生了Stop The World。
下面的DefNew,Tenured,Perm表示GC发生的区域,这里显示的区域名称和收集器类型密切相关。
后面方括号内部的的3324K->152K(3712K)含义是 GC前该内存区域已使用容量->GC后该内存区域已使用容量(该内存区域总容量),方括号外部的3324K->152K(11904K)表示GC前Java堆已使用容量->GC后Java堆已使用容量(Java堆总容量)。
0.0025925 seces表示该内存区域GC所占用的时间,单位是秒。
对象的内存分配往大方向讲就是在堆上分配,对象主要分配在新生代的Eden区,分配规则的细节取决于 垃圾收集器的组合,还有相关参数设置。
大多数情况下,对象在新生代Eden区中分配,当Eden没有足够的空间时,虚拟机发起一次Minor GC(对年轻代的GC)。
代码验证,通过参数限制Java堆大小为20M,10M年轻代,10M老年代,Eden区和一个Survivor区空间比例8:1。
private static final int _1MB = 1024*1024;
/**
*VM参数:-verbose:gc-Xms20M-Xmx20M-Xmn10M-XX:+PrintGCDetails -XX:SurvivorRatio=8
*
*/
public static void testAllocation(){
allocation1 = new byte[2*_1MB];
allocation2 = new byte[2*_1MB];
allocation3 = new byte[2*_1MB];
allocation4 = new byte[4*_1MB]; //出现一次Minor GC
}
当分配allocation4时Eden已经占用了6M的空间,不足以分配4M的对象,因此发生MinorGC,GC期间虚拟机又无法将三个2M大小的对象放入Survivor空间(只有1M),所以通过分配担保机制转移到老年代去。
所谓大对象是指需要大量连续内存空间的对象,典型的是长字符串和数组,经常出现大对象容易导致内存还有不少空间就提前触发垃圾收集以获取足够的连续空间来安置它。
代码验证
private static final int_1MB=1024*1024; /**
*VM参数:-verbose:gc-Xms20M-Xmx20M-Xmn10M-XX:+PrintGCDetails-XX:SurvivorRatio=8
*-XX:PretenureSizeThreshold=3145728
*/
public static void testPretenureSizeThreshold(){
byte[]allocation;
allocation=new byte[4*_1MB];//直接分配在老年代中
}
-XX:PretenureSizeThreshold参数令大于这个参数值的对象直接分配到老年代中。
虚拟机给每个对象定义了一个年龄计数器,如果对象在Eden中出生并经过第一次Minor GC仍然存活,如果能被Survivor容纳的话就移入,并且对象年龄设为1,熬过一次Minor GC就+1,当到达一定程度移入老年代(默认15)。
private static final int_1MB=1024*1024;
/**
*VM参数:-verbose:gc-Xms20M-Xmx20M-Xmn10M-XX:+PrintGCDetails-XX:SurvivorRatio=8-XX:MaxTenuringThreshold=1
*-XX:+PrintTenuringDistribution
*/
@SuppressWarnings("unused")
public static void testTenuringThreshold(){
byte[]allocation1,allocation2,allocation3;
allocation1=new byte[_1MB/4];
//什么时候进入老年代取决于XX:MaxTenuringThreshold设置
allocation2=new byte[4*_1MB];
allocation3=new byte[4*_1MB];
allocation3=null;
allocation3=new byte[4*_1MB];
}
MaxTenuringThreshold=1时,allocation1对象在第二 次GC发生时进入老年代,新生代已使用的内存GC后变成0KB。
如果survivor空间中相同年龄所有对象大小的总和大于survivor空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代。
private static final int_1MB=1024*1024;
/**
*VM参数:-verbose:gc-Xms20M-Xmx20M-Xmn10M-XX:+PrintGCDetails-XX:SurvivorRatio=8-XX:MaxTenuringThreshold=15
*-XX:+PrintTenuringDistribution
*/ @SuppressWarnings("unused")
public static void testTenuringThreshold2(){
byte[]allocation1,allocation2,allocation3,allocation4;
allocation1=new byte[_1MB/4]; //allocation1+allocation2大于survivo空间一半
allocation2=new byte[_1MB/4];
allocation3=new byte[4*_1MB];
allocation4=new byte[4*_1MB];
allocation4=null;
allocation4=new byte[4*_1MB];
}
运行结果中Survivor的空间占用仍然为0%,老年代增加了6%,也就是allocation1和2都进入了老年代。
发生Minor GC之前虚拟机会检查老年代最大连续空间是否大于新生代所有对象总空间,条件成立就是安全的。
如果不成立,检查HandlePromotionFailure这个值,如果允许则检查最大空间是否大于历次晋升到老年代对象的平均大小,如果大于尝试进行有风险的Minor GC,小于或者不允许则改为Full GC。