垃圾收集器和内存分配策略
1.概述
为什么虚拟机要进行垃圾回收?
因为Java虚拟机中的内存是有限的,在程序运行中无时无刻不在创建对象,消耗内存,如果不对内存进行回收,就无法解决内存不足的问题,自然程序无法运行持久。
如今内存动态分配与内存回收技术相当成熟,为什么还要了解它?
因为即使内存动态分配和内存回收技术在怎么成熟,也不能保证不会出现内存溢出的情况。当需要排查各种内存溢出、内存泄漏的问题时,当垃圾收集成为系统达到更高并发量的瓶颈时,就要实施必要的监控和调节。
垃圾收集(Garbage Collection,简称GC),垃圾收集的历史远比Java悠久,实现垃圾收集需要思考三个问题:
.哪些内存需要回收?
.什么时候进行垃圾回收?
.如何进行垃圾回收?
在针对Java内存运行时区域的各个部分进行垃圾回收,大体分为两种:
第一种:程序计数器、java虚拟机栈、本地方法栈这三块区域都是伴随线程而生,随线程死亡而灭。栈中的栈帧随着方法的进入和退出而有条不絮的执行栈帧的入栈和出栈操作。每个栈帧分配多少内存在类结构确定下来时就已知。因此这几个区域内存分配和回收都具有确定性,当方法结束或者线程结束,内存自然就跟随着回收。
第二种:Java堆和方法区,这两个区域有很明显的不确定性,比如:一个接口的多个实现类需要的内存会不一样,一个方法的不同条件分支所需要的内存也不尽相同,只有在运行期间,才能够知道具体要创建哪个对象,执行某个方法需要多少内存。这部分内存是动态分配的。垃圾收集器主要关注的正是这一部分的内存管理,下文中内存分配与回收也特指这一部分。
2.对象如何判定死亡
在堆中存放了几乎所有的对象实例,垃圾收集器在堆回收之前首要任务就是判定哪些对象还“存活”着,哪些对象已经“死亡”,下面介绍几种判断对象是否存活的算法:
引用计数算法:简单说就是在对象中加入一个计数器,当该对象被引用时,计数器加一;当引用失效时,计数器减一;当计数器为零时,则表示该对象没有被引用。虽然引用计数算法会消耗一些额外功能进行计数,不过确实效率高。但是主流的java虚拟机都没有选用引用计数算法,原因很简单,看似简单高效的算法,在Java虚拟机中不适用,需要配合大量额外的处理才能正确工作,比如:很难去解决对象之间的相互引用。
原由:如果两个对象相互引用,但是两个对象之外又没有被其他所引用。
看以下代码:
/** * testGC()方法执行后,objA和objB会被GC * @author Aaron * */ public class ReferenceCountingGC { public Object instance=null; private static final int _1MB = 1024*1024; //bigSize作用就是为了占内存 private byte[] bigSize = new byte[2*_1MB]; public static void testGC() { //objA和objB所引用的对象相互引用,然后objA和objB不在指向这两个对象 ReferenceCountingGC objA = new ReferenceCountingGC(); ReferenceCountingGC objB = new ReferenceCountingGC(); objA.instance = objB; objB.instance = objA; objA = null; objB = null; System.gc();//该方法提示虚拟机进行GC } public static void main(String[] args) { ReferenceCountingGC.testGC(); } }
在Debug时设置日志打印路径
运行结果如下图:
上图中:[GC 7997(年轻代回收前总大小)——>672(年轻代垃圾回收后大小)——>249344(年轻代总大小) 0.0022.45 回收消耗时间],通过分析可以看出java虚拟机并没有放弃对(除了对象间相互引用在也没有被引用的)对象进行回收。
可达性分析算法:目前主流的商用程序语言的内存管理系统,都是通过可达性分析算法来判断对象是否存活。基本思路:通过一系列称为“GC Roots”的根对象作为起始节点集,如果某个对象到GC Roots之间没有任何引用链连接,则表明GC Roots到对象是不可达的,证明此对象不能在被使用。如图:
在Java中有一下几种方式可以作为固定的GC Roots去判定到对象的可达性:
1.java虚拟机栈(栈帧中的本地变量表)中引用对象,比如:调用方法区/堆/栈中使用的参数、局部变量、临时变量。
2.方法区中类的静态属性引用的对象,比如:java类中引用类型静态变量。
3.在方法区中常量引用的对象,比如:常量池中引用的。
4.同理 本地方法栈中引用的对象。
5.Java虚拟机中内部引用,比如:基本数据类型对应的class对象、常驻的异常对象、类加载器等。
6.被同步锁(synchronized)持有的锁对象
7.Java虚拟机内部情况的JM XBean、JVMTI中注册的回调(内部实现)、本地代码缓存等
除了以上这些固定的GC Roots集合外,还可能受垃圾收集器的种类、当前回收区域、以及其他对象临时加入,共同构成了GC RootS集合。(对象的引用可能存在内存跨区域的情况,所以GC Roots集合中也要考虑其他区域的存在)
很不幸,在遍历GC Roots集合时,用户程序是出于“静止”状态,当遍历完GC Roots进行可达性分析后,用户程序才会继续。原因想必大家也可以理解:如果用户程序和遍历GC Roots并行,那么用户程序刚创建对象,未对象A分配内存,然后在遍历GC Roots是认为对象A不可达,随后用户程序为对象A创建引用链,最后到清除阶段将对象A回收,造成用户通过引用获取到一个空值,岂不是很懵逼。
3.再谈引用
在JDK1.2之前,Java中的传统引用定义:如果reference类型中的数据中存储的数值代表的是另一块内存的起始地址,就称reference数据代表的是某块内存、某个对象的引用。但是传统的引用定义现在看来有些狭隘,一个对象只有“被引用”和“未引用”两种状态,对于那些“食之无味、弃之可惜”的对象不能够很好描述。比如:我们希望一些对象在内存足够时存放在内存中,当内存不足时,对这些对象进行回收。
在JDK1.2之后,Java引入了四种引用概念,将引用分为强引用(Strongly Reference)、软引用(Soft Reference)、弱引用(Weak Reference)和虚引用(Phantom Reference)。四种引用强度依次降低。
.强引用 只要对象存在被引用关系,无论何时,只要被引用关系还在,就不会被垃圾收集器回收。
.软引用 只被软引用关联的对象,系统将要发生内存溢出前,会把这些对象列入回收范围之中进行第二次回收,如果这次没有回收到足够多的内存,则下一次会对列入的对象进行回收。
.弱引用 强度比软引用要低,只能存活到下一次垃圾回收。
.虚引用 一个对象是否存在虚引用,不会对其生存的时间造成影响,也无法通过这个引用获取对象实例。唯一的目的就是在对象会后是时,引用者能够收到系统通知。
4.对象的生存死亡
可达性分析算法在判定不可达的对象时,也不是“非死不可”,暂时还处于“缓刑”阶段,真正判定一个对象死亡至少要经历两次标记过程:如果对象在进行可达性分析后发现没有与GC Roots相连的引用链,则会进行第一次标记,随后进行一次筛选,筛选条件是对象是否有必要执行finalize()方法,如果对象没有重写finalize()方法,或者说对象的finalize()方法已经被执行过,那么视为没有必要执行finalize()方法。
如果对象被认为有必要执行finalize()方法,那么该对象会被放入一个名F-Queue的队列中去,由虚拟机创建的一个低调度优先级的线程去执行finalize()方法。这里的执行是指线程会触发方法的执行,但不会等待方法执行完毕(比如:方法发生执行时间过长或死循环),如果在finalize()方法执行结束之前,只要与引用链上的任何对象建立链接即可。若非如此,在方法执行完毕后,垃圾收集器会对这些对象进行第二次标记,将对象移出“即将回收”的集合,进行垃圾回收。
根据下面代码分析,更好的理解上述的说明:
public class FinalizeEscapeGC { public static FinalizeEscapeGC SAVE_HOOK = null; public void isAlive() { System.out.println("yes,I am still alive"); } @Override protected void finalize() throws Throwable { super.finalize(); FinalizeEscapeGC.SAVE_HOOK = this; System.out.println("finalize method executed"); } public static void main(String[] args) throws InterruptedException { SAVE_HOOK = new FinalizeEscapeGC(); SAVE_HOOK = null;//让GC Roots到对象不可达 System.gc();//该方法提示虚拟机进行GC Thread.sleep(500);//休眠500毫秒 因为执行F-Queue队列的线程优先级很低 if(SAVE_HOOK != null) { SAVE_HOOK.isAlive(); }else { System.out.println("no,I am dead"); } //下面代码和上面代码一样,但是因为finalize()方法被执行过不会在执行 SAVE_HOOK = null; System.gc(); Thread.sleep(500); if(SAVE_HOOK != null) { SAVE_HOOK.isAlive(); }else { System.out.println("no,I am dead"); } } }
执行结果:
5.回收方法区
有人认为方法区(如HotSpot虚拟机中元空间或者永久代一样)不存在垃圾回收行为,《Java虚拟机规范》中确实没有提及针对方法区的垃圾回收,事实上确实未实现或者未能实现方法区类型卸载的收集器存在,方法区垃圾收集的“性价比”不高,不同于Java堆中新生代中进行一次垃圾回收通常可以回收70%~99%的内存空间。相比之下,方法区的回收条件过于苛刻。
方法区回收主要针对两部分内容:废弃的常量和不在使用的类型。废弃常量的回收和java堆中对象的回收相似。比如:常量池中字面量的回收,假如一个字符串“java”曾经进入过常量池,但是当前系统中没有一个字符串对象的值为“java”,也就是没有任何字符串对象引用“java”常量。如果在这时发生内存回收,而且垃圾收集器认为有必要,则这个“java”常量会被清除常量池。常量池中的其他类(接口)、方法、字段的符号引用也类似。
判定一个类型的不在使用判定条件更为麻烦,需要满足三个条件:
.该类的所有实例都已被回收,Java堆中不存在该类及其任何派生子类的实例。
.加载该类的类加载器已被回收,这个条件除非精心设计替换类加载器的场景,否则很难达成。
.该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。
Java虚拟机被允许满足三个条件的无用类可以允许被回收,但不像对象一样,无用了便被回收。在大量使用反射、动态代理、CGlib等字节码框架,动态生成JSP这些频繁自定义类加载器的场景中,通常需要虚拟机具有类卸载的能力,保证不会对方法区造成过大的压力。