在前面的Java自动内存管理机制(上)和Java自动内存管理机制(下)中介绍了关于JVM的一些基础知识,包括运行时数据区域划分和一些简单的参数配置,而其中也谈到了GC,但是没有深入了解,所以这里开始简单的了解一下GC知识。本篇中主要介绍垃圾收集器回收对象的时候怎样判断对象是否已死和一些垃圾收集算法的概念。
一、GC概述
在Java内存运行时数据区域中,程序计数器、虚拟机栈、本地方法栈是线程私有的,随着线程产生而存在、线程执行结束回收,栈中的栈帧随着方法的执行和退出进行相应的入栈和出栈的操作,每一个栈帧被分配多少空间在概念上基本是在类结构确定下来的时候就已经知道的。因此这几个区域的回收可以说是具备一定的确定性(方法结束或者线程结束的时候,相应的内存就会被回收掉了)。所以同前面介绍的一样,GC的主要区域还是在Java堆上面(某个接口的实现类所需要的内存可能是不一样的,某个方法中的某些分支循环所需要的内存也不一样),这些都是动态变化的,需要在程序开始运行之后才能知道会创建的对象,而这些程序创建之后的动态变化所导致的也就是GC需要动态执行,而Java堆就是GC主关注的区域。
二、GC判断对象是否需要被回收的一些算法
1、引用计数算法
a)引用计数法的简单概念:给对象添加以一个计数器,当一个地方引用到这个对象的时候,这个计数器的值就加1;当引用失效的时候,计数器的值就减1,计数器值为0的对象就是不可以被使用的。
b)关于循环引用的问题,我们可以参考下面的这个例子,其中t1和t2两个对象相互引用,然后在结束的时候将两个对象置为null,引用计数算法不能回收这两个对象,然后我们通过打印GC日志来查看GC的详细信息
package cn.jvm.test; public class Test05 {
public Object instance = null;
public byte[] testArr = new byte[2 * 1024 * 1024];
public static void main(String[] args) {
Test05 t1 = new Test05();
Test05 t2 = new Test05();
t1.instance = t2;
t2.instance = t1;
t1 = null;
t2 = null;
System.gc();
}
}
c)下面是PrintGCDetails的结果,从结果中我们可以看出,GC的时候并没有因为对象之间的循环引用就没有回收他们,也说明虚拟机中不是通过引用计数法来进行判断对象的存活以及是否需要GC的的
[GC (System.gc()) [
PSYoungGen: //GC类型为Parallel Scavenge,为eden+from区
6358K->744K(18432K)] //分别代表年轻带GC前占用内存和GC后占用内存,括号中是年轻代栈中的总内存
6358K->752K(60928K), //代表的是JVM堆内存GC前占用和GC后占用,括号中的是JVM堆所占内存大小
0.0314987 secs] [Times: user=0.00 sys=0.00, real=0.04 secs]
[Full GC //代表GC类型:如果直接是GC代表的是MinorGC,这里面的是FullGC
(System.gc()) [
PSYoungGen: 744K->0K(18432K)] [ //年轻代
ParOldGen: 8K->658K(42496K)] //老年代
752K->658K(60928K),
[Metaspace: 3465K->3465K(1056768K)], 0.0093210 secs] [Times: user=0.03 sys=0.00, real=0.01 secs] Heap
PSYoungGen total 18432K, used 159K [0x00000000eb600000, 0x00000000eca80000, 0x0000000100000000)
eden space 15872K, 1% used [0x00000000eb600000,0x00000000eb627c58,0x00000000ec580000)
from space 2560K, 0% used [0x00000000ec580000,0x00000000ec580000,0x00000000ec800000)
to space 2560K, 0% used [0x00000000ec800000,0x00000000ec800000,0x00000000eca80000)
ParOldGen total 42496K, used 658K [0x00000000c2200000, 0x00000000c4b80000, 0x00000000eb600000)
object space 42496K, 1% used [0x00000000c2200000,0x00000000c22a48c8,0x00000000c4b80000)
Metaspace used 3471K, capacity 4496K, committed 4864K, reserved 1056768K
class space used 381K, capacity 388K, committed 512K, reserved 1048576K
2、可达性分析算法
a)可达性分析算法是采用图论中的思想,算法的基本思想就是通过被称为GC Root的对象作为源点,然后向下搜索(搜索的所经过路径被称为引用链),当一个对象没有任何可达GC Root的引用链的时候(简单而言就是没有可达路径),说明对象是不可用的,会被判定为是可回收的
3、引用
a)可以看出上面的两种算法对于判断对象是否需要被回收都和引用有关系, 在1.2之前,JVM对于引用所指的是:如果reference类型的数据中存储的数值代表的是另一块内存的其实地址,称这块内存代表着一个引用
b)在1.2之后,对于引用的概念进行了扩充,将引用分为:强引用(Strong Reference)、软引用(Soft Reference)、弱引用(Weak Reference)、虚引用(Phantom Reference),下面具体说明一下这四种引用的概念和区别
①强引用(Strong Reference):类似于TestClass testClass = new TestClass()之中的引用,只要这种引用还存在,就不会被GC
②软引用(Soft Reference):描述一些有用但是并非必需的对象,对于软引用的对象而言,只要系统中没有发生OOM异常之前,不会被GC;换而言之就是,在系统将要发生OOM之前,就会把这些引用的对象列进回收范围内进行二次回收,如果此时有足够的内存可以使得系统不发生OOM,就不会抛出异常,如果回收之后没有足够的内存,就会发生内存溢出异常
③弱引用(Weak Reference):同软引用一样,弱引用也是描述一些非必需的对象的,但是如其名其引用的强度也比全引用更弱一些,具体而言就是:被弱引用所引用的对象只能生存到下一次GC之前,当发生GC时候,无论当前内存是否足够,弱引用所引用的对象都会被回收掉
④虚引用(Phantom Reference):虚引用是所有引用中最弱的一种引用,其存在就是为了将关联虚引用的对象在被GC掉之后收到一个通知
4、对象的存活
a)在可达性分析算法中我们说到,如果一个对象没有到达GC Root的引用链,那么这个对象在发生GC时候就会被判定为可回收的对象,但是这种对象也并非是必须要被直接回收掉的,这种时候他们需要经历两个标记的过程,第一次标记过程:如果对象在进行可达性分析之后发现没有与GC ROOT相连接的引用链,那么会被进行一次标记和筛选(筛选的条件是是否有必要执行finalize()方法)
①当对象没有覆盖finalize()方法的时候,或者已经被虚拟机调用过,那么虚拟机会将这两种情况视为“没有必要执行”
②在上面的介绍中,如果虚拟机认为某个对象是有必要执行finalize()方法的时候,这个对象会被放在一个F-Queue等待队列之中,虚拟机会单独开设一个线程Finalize(自动创建、低优先级)执行他们,但是不会等待线程运行结束。
③虚拟机不会等待FInalize线程运行结束的原因:如果某个对象finalize()方法执行的比较缓慢,或者在执行的过程中发生了死循环,那么会使得整个F-Queue对象会处在一个等待的过程中,可能导致整个GC崩溃
b)第二次标记的过程:
在F-Queue队列中,GC会对队列中的每一个对象进行第二次标记,如果对象要想自己不被回收掉,那么就需要将自己与其他任何一个对象建立关系即可,这样就会在第二次标记的过程中移除队列。
c)下面是一个对象修改finalize方法,在首次将要被回收的时候,进行“逃脱”的一个例子,其中Test06.test06 = this这个是修改this指向自己的类变量(静态变量)或者对象的成员变量,会使得在第二次标记的过程中被虚拟机移除F-Queue队列而不被回收
package cn.jvm.test; public class Test06 {
public static Test06 test06 = null; public void print() {
System.out.println("存活");
} @Override
protected void finalize() throws Throwable {
super.finalize();
System.out.println("finalize方法被执行一次");
Test06.test06 = this; //修改this指向自己的类变量(静态变量)或者对象的成员变量,会使得在第二次标记的过程中被虚拟机移除F-Queue队列而不被回收
} public static void main(String[] args) throws Throwable{ test06 = new Test06(); //第一次逃脱回收
test06 = null;
System.gc();
Thread.sleep(500);
if(test06 != null) {
test06.print();
} else {
System.out.println("test对象被回收");
} //第二次想要逃脱GC
test06 = null;
System.gc();
Thread.sleep(500);
if(test06 != null) {
test06.print();
} else {
System.out.println("test对象被回收");
}
}
}
d)这是执行结果
finalize方法被执行一次
存活 //第一次没有被回收,因为第一次在程序中将this赋值给了类变量
test对象被回收 //每一个对象的finalize方法都只会被系统调用一次,第二次虽然执行相同的代码,但是finalize方法不会被再次执行
e)最后补上一点:finalize建议不要使用
三、垃圾收集算法
1、标记清除算法
a)标记-清除算法(Mark-Sweep),分为标记和清除两个过程:首先标记出所有需要回收的对象,在标记完成后统一回收所有标记的对象。但是这种算法也有其不足之处:
①标记和清除的效率都不高;
②标记和清除之后会产生很多不连续的空间碎片,导致后续在分配给大对象内存的时候,无法找到足够大的内存从而导致提前触发GC。
b)一个简易的标记清除算法的执行过程像如下图所示:
2、复制算法
a)复制算法:将可用内存按照容量划分为相等大小的两块,每次只是使用其中的一块,当这一块的内存用完了,就将还存在的对象复制到另外一块上面,然后再将刚刚使用的内存空间清理回收。
b)复制算法的优点:每次对半个内存区域进行回收,所以内存分配的时候也不用考虑内存碎片灯复杂情况,实现简单高效。缺点也很明显,每次只能使用一半的内存空间,代价比较高,下面是一次复制算法的执行过程
c)在HotSpot虚拟机中,在JVM自动内存管理机制(上)里面的堆区中新生代分为eden、from、to三个区域,而新生代中的from和to区域就是使用的复制算法。因为新生代中的对象很大一部分都是“朝生夕死”的,所以不需要按照1:1的比例来划分内存空间,而是划分为eden和较小的两块survivor区域(from和to区域,大小相同),对于eden、from、to区域的比例大概就是8:1:1,所以每次新生代的可使用内存空间大概就是整个新生代空间的90%,浪费的内存比较少。下面使用一个简单的例子通过使用-XX:+PrintGCDetails查看GC日志
package cn.jvm.test; public class Test07 { public static void main(String[] args) { //查看GC信息
System.out.println("没有分配时候");
System.out.println("最大堆内存===" + Runtime.getRuntime().maxMemory());
System.out.println("空闲内存===" + Runtime.getRuntime().freeMemory());
System.out.println("总内存===" + Runtime.getRuntime().totalMemory());
}
}
下面是运行的结果
3、标记整理算法
采用复制算法时,如果存活率比较高的时候就需要进行比较多的复制操作,会降低GC的效率;所以要想提高在老年代的GC效率,在JVM中采用适用于老年代特点的标记整理(Mark-Compact)算法进行GC操作。标记整理算法可以归结为这样的过程:首先标记出所有需要回收的对象,然后让所有的对象都向一端移动,直接处理掉端边界以外的内存。这种方式避免了类似于标记清除算法产生的内存碎片问题。下面是一个标记整理算法的简单实例
4、分代收集算法
分代收集算法的思想比较简答,就是根据对象不同的存活时期将内存划分为不同的区域。在JVM中,将堆区分为新生代和老年代,在新生代中由于对象的创建比较频繁,而且有大量的对象在GC的时候要被回收掉,采用复制算法只需要将少量存活的对象复制到另一块区域就可以。而在老年代中,对象的创建不是那么频繁,对象的存活率也比较高,我们采用标记整理算法来进行回收