在前一篇大致讲解了JVM的内存结构,在对JVM有一定了解的基础上,接下来进行JVM垃圾收集的学习
垃圾收集器与内存分配策略
1.概述
内存的动态分配与内存回收技术已经很成熟了,了解GC和内存分配:一方面为了当出现内存溢出,内存泄漏的时候排查问题,另一方面垃圾收集会成为实现更高并发量的瓶颈,所以我们需要对这些“自动化”的技术实施必要的监控和调节。
对于
程序计数器、虚拟机栈、本地方法栈这三个内存区域随线程而生,随线程而灭;栈中的栈帧随着方法的进入和退出而有条不紊地执行着进栈和出栈的操作,线程结束则内存被回收。所以
线程私有的内存区域是不需要额外控制的。Java堆和方法区就不一样了,我们只有程序处于运行期间时才知道(多态导致)创建哪些对象,这部分的内存分配和回收都是动态的。
内存的分配和回收指的就是Java堆和方法区部分的内存。
3.2 回收堆中死亡对象
堆中几乎存放着Java世界中所有的对象实例,垃圾收集器回收的内存就是那些已经死了的对象实例的内存。
对象已死表示不可能再被任何途径使用的对象。
下面介绍几种判断对象是否存活的算法:
3.2.1 引用计数法
引用计数法判断对象存活的
通过给对象添加一个引用计数器(在new Object时候初始化计数器为0),每当有一个地方引用(例如,在Object obj = new Object()时,计数器加1)它时,计数器就加1;当引用失效时(例如在方法中局部变量引用了对象,方法执行完成时,就是引用失效),计数器值就减1;任何时刻计数器都为0的对象就是不可能再被使用的。
引用计数法实际上是一个效率很高的算法,实现也简单,但是却存在一个隐患,
对象之间的相互循环引用会导致内存泄漏【内存泄漏这里指的是该内存应该被但没有被回收,导致内存一直被占用】。
如下情况:Java 代码
/** * testGC 方法执行后,objA和objB会不会被GC回收呢? */ public class TestGC{ public Object instance = null; public static void testGC(){ TestGC objA = new TestGC(); TestGC objB = new TestGC(); objA.instance = objB; objB.instance = objA; //图一 objA = null; objB = null; //图二 } //图三 }
如下的几张图正是上面Java代码的内存,在testGC方法执行时,objA和objB为局部变量分别进栈,并且指向堆中的对象实例。
图一
实际上应该Java栈到objB为止,没有别的变量进栈,下同
图中的引用计数器为2
图二 图中的引用计数器为1
图三 图中的引用计数器为1
上面的图示解释了,引用计数法的缺陷,会导致内存泄漏。
3.2.2 根搜索算法
根搜索算法是目前的流行的判断对象算法存活的算法。
此算法的思路是通
过一系列的名为“GCRoots”的对象作为起始点,从这些根节点开始向下搜索,搜索所走过的路径成为引用链(Reference Chain),当一个对象到GC Roots没有任何引用链相连(其实就是数据结构中的图来解释就是,GC Roots到该对象不可达)时。则证明此对象是不可用的。
下图中的Object5,6,7会被回收,在上一例中的图二和图三中的objA和objB就会被回收
3.2.3 Java对引用的改进
在JDK 1.2以前,引用定义很传统;
如果reference类型的数据中存储的数值代表的是另外一块内存的起始地址,就成这块内存代表着一个引用。
JDK1.2之后对引用类型进行了扩充,将引用分为
强引用,软引用,弱引用,虚引用
强引用是指类似于 Object obj = new Object()这类的引用,其实也就是直接存在于Java栈中的引用,只要强引用还存在,垃圾收集器永远不会回收掉被引用的对象;
软引用是指一些还有用但是不是必需的对象,这些对象会进行
二次回收,也就是在内存块溢出的时候,将软引用进行标记,然后进行第一次回收,但是软引用对象没有被回收,如果
第一次回收完成后,还是没有足够的内存,那么才会回收标记的软引用对象。
弱引用是指一些非必需的对象,在下一次垃圾回收时就会被回收。
虚引用更弱,回收他们不会造成任何影响。
3.2.4 根搜索的生存还是死亡(覆盖finalize方法可以拯救一次)
对于根搜索算法,当在发现对象不可达GC Roots时,并不是立即把他们回收了,先
判断是否需要执行finalize()方法【注:如果对象没有覆盖finalize()方法,或者finalize方法已经被虚拟机执行过一次了,那么就视为没必要执行finalize()方法了,直接进行回收】。
如果
判断需要执行finalize()方法,那么这个对象会被放在一个F-Queue的队列中,处于F-Queue队列中的对象
有一次逃脱死亡命运的机会,如果
finalize()方法中只要把
对象重新和reference chain(GC Roots所在的的图)链接上,那么就成功拯救了自己。譬如把自己(this关键字)赋值给某个类变量或者对象的成员变量。
请用javac -className.java
java -className
来执行下面的文件,查看根搜索算法中的自我拯救。
/** 下面的代码演示了两点: 1.对象可以在GC时自我拯救 2.这种自救的机会只有一次,因为一个对象的finalize方法最多只会被系统自动的执行一次 */ public class EscapeGC{ public static EscapeGC SAVE_HOOK = null; public void isAlive() { System.out.println("yes, i am still alive :)"); } @Override protected void finalize() throws Throwable{ super.finalize(); System.out.println("finalize method executed"); EscapeGC.SAVE_HOOK = this;//在这里吧自己赋值给了类变量,拯救了自己 } public static void main(String[] args) throws Throwable { SAVE_HOOK = new EscapeGC(); //对象第一次成功拯救自己 SAVE_HOOK = null; System.gc(); //因为Finalize方法优先级很低,暂停0.5秒,等待它 Thread.sleep(500); if(SAVE_HOOK != null){ SAVE_HOOK.isAlive(); }else{ System.out.println("no, i am dead :("); } //下面这段代码与上面的完全相同,但是这次自救却失败了 SAVE_HOOK = null; System.gc();//一个对象的finalize方法最多会执行一次 //因为Finalize方法优先级很低,暂停0.5秒,等待它 Thread.sleep(500); if(SAVE_HOOK != null){ SAVE_HOOK.isAlive(); }else{ System.out.println("no, i am dead :("); } } }
在介绍完根搜索算法后,堆中对象的死亡判断算法已经介绍完了。接下来介绍一下方法区中的垃圾回收。
3.2.5 回收方法区
方法区(HotSpot虚拟机中的永久代)在Java虚拟机规范中提到,不要求虚拟机在方法区实现垃圾收集,而且方法区的垃圾收集
“性价比”很低。
而在堆中,尤其是新生代中,进行一次垃圾收集一般可以回收
70%~95%的空间,而方法区的垃圾收集效率远低于此。
方法区的垃圾收集主要回收两部分内容:
废弃常量和
无用的类。回收废弃的常量和回收Java堆中的对象很类似【例如,字符串“abc”已经进入常量池,在内存回收时,发现没有任何的String对象引用常量池中的“abc”常量,那么这个时候“abc”会被系统清理出常量池】,常量池中包括类(接口)、方法、字段的
符号引用【参考后面的类文件结构篇】
。
判定一个类是否是“无用的类”相对复杂,需要满足如下条件:
1:该类所有的实例都已经被回收,也就是Java堆中不存在该类的任何实例【因为对象实例的数据有两部分组成:对象实例数据(Java堆中),对象类型数据(方法区中)】。
2:加载该类的Classloader已经被回收【这个还不知道为什么,需要在学习类加载机制之后来补充】。
3:该类对应的java.long.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法【也就是该类的方法都是不能通过反射机制访问的】。
就算满足上面的条件,HotSpot也可以选择不回收这些“无用的类”,要想回收无用的类,需要-Xnoclassgc参数进行控制,如前面所说的,
方法区的垃圾回收是可以选择的。
而对于
大量使用反射、动态代理、GCLib等bytecode框架的场景以及
动态生成JSP的频繁自定义ClassLoader的场景都需要虚拟机具备
类卸载的功能,以
保证永久代(方法区)不会溢出。
3.3 垃圾收集算法
在上面已经学习了怎么判断对象是否存活,接下来就介绍怎么回收这些“死”了的类。
3.3.1 标记-清除算法
图示如下:
下面我们来看下它的缺点,其实了解完它的算法原理,它的缺点就很好理解了。
1、首先,它的缺点就是效率比较低(递归与全堆对象遍历),而且在进行GC的时候,需要停止应用程序,这会导致用户体验非常差劲,尤其对于交互式的应用程序来说简直是无法接受。试想一下,如果你玩一个网站,这个网站一个小时就挂五分钟,你还玩吗?
2、第二点主要的缺点,则是这种方式清理出来的空闲内存是不连续的,这点不难理解,我们的死亡对象都是随即的出现在内存的各个角落的,现在把它们清除之后,内存的布局自然会乱七八糟。而为了应付这一点,JVM就不得不维持一个内存的空闲列表,这又是一种开销。而且在分配数组对象的时候,寻找连续的内存空间会不太好找。
为了完善这种算法的缺点,出现了下面的两种算法
3.3.2 复制算法
复制算法
Version1:
复制算法Version1的思想:将可用内存按照容量划分为大小相等的两块,每次使用其中一块。当其中一块使用完成后,就将还存活的对象复制到另外一块上,然后把之前已经用完的那一块内存清理。缺点是:将可用内存缩小为原来的一半。
复制算法
Version2:
将内存分为了三部分,其中Eden区域占了80%,两个Survivor区域各占10%。使用一个Survivor+Eden区域,即可用内存达到了90%。对象有限分配在Eden区域(伊甸园)。
如图 当Survivor1+Eden区域用完时,进行垃圾收集,这时候将活的对象复制到另外一个Survivor2区域,清理之前被占用的Survivor1+Eden区域,清理并整理完成后,可用的区域是Survivor2+Eden区域。这种算法多用于
回收新生代。
但是会出现
存活对象需要内存>10%的情况,这时候就需要
去老年代内存借内存了。这就是
分配担保。
3.3.3 标记-整理算法
如下图:
这种算法和
标记-清除算法的区别是将
所有存活的对象都像堆内存的一端移动,然后直接清理掉活对象边界以外的内存。
3.3.4 分代收集算法
现在主流的虚拟机的垃圾收集都采用“分代收集”(Generation Collection)算法,这种算法
根据对象存活周期的不同将内存划分为几块,一般把
Java堆分为新生代和老年代,这样就可以
根据各个年代的特点采用最适当的收集算法。
对于
新生代的对象,每次收集都会有大批的对象死去,所以
使用复制算法。
对于
老年代的对象,对象存活率高,没有额外空间进行分配担保,就必须使用“
标记-整理”或者“
标记-清理”算法来回收。
对于咱们判断对象是新生代还是老年代呢,可以设置一个阈值,每次垃圾回收时,存活下来的对象的年代就加1,当年代大于阈值的对象就是老年代的对象。
3.4 垃圾收集器
HotSpot虚拟机实现了7种收集器;
具体细节看看书,不做记忆。
3.5 内存分配与回收策略
3.5.1 对象优先在Eden分配
在
给对象分配堆内存时,
优先是分配在Eden区的,当
Eden区的内存大小不足时,进行
Minor GC【指的是新生代GC,老年代+新生代的GC成为Full/Major GC】,如果这次
垃圾收集发现Survivor区域不够用来存储存活的对象,那么就要用
分配担保机制提前转移到老年代去。如果Survivor区域足够用来存储存活的对象,那么Eden区域就可以清理,用来存新的对象。
3.5.2 大对象直接进入老年代
大对象是指,需要大量连续内存空间的Java对象,例如很长的字符串(图片的Base64)以及数组。大对象的对于虚拟机来说是一个不好的消息,而“短命大对象”更是严重,经常出现大对象容易导致内存还有不少空间的时候,就提前出发垃圾收集。(所以写代码时,尽量避免短命大对象)
可以使用
-XX:PretenureSizeThreshold参数,令大于这个设置值的对象直接在老年代中分配,这样可以避免在Eden区以及两个Survivor区之间发生大量内存的拷贝,而在Survivor区域内存不够时,会将对象copy到老年代【这些都是性能的消耗】。
3.5.3 长期存活的对象将进入老年代
分代收集 概述:虚拟机给每个对象定义了一个
对象年龄(Age)计数器,如果对象在Eden出生并经过第一次Minor GC后仍然存活,并且能被Survivor容纳的话,将被移动到Survivor空间中,并将对象年龄设置为1,
对象在Survivor区中每熬过一次Minor GC,年龄就加1,当
它的年龄增加到一定程度(默认为15岁)时,
就会被晋升到老年代中。对于晋升老年代的年龄阈值,可以通过-XX:MaxTenuringThreshold来设置。与
此类似的还有动态对象年龄判断【算出平均的age, 当大于平均age或者大于多少倍的平均age时,移动到老年代】。
3.5.4 空间分配担保
在发生
Minor GC时,虚拟机会检查之前
每次晋升到老年代的平均内存大小是否大于老年代的剩余空间大小,如果
大于,则改为直接进行一次
Full GC。如果
小于,则查看
HandlePromotionFailure设置是否允许担保失败;如果
允许,那么只会进行
Minor GC,如果
不允许,那么则要进行一次
Full GC。
Eden空间不足的时候,那么这时候需要进行GC,那么是
进行Minor GC还是Full GC呢,Minor GC当然是更快的,但是有风险。
Minor GC完成后
可能【因为可能大量的对象死亡了,Survivor区足够存储存活对象】会把需要一些对象【包括,长期存活年龄大于阈值的对象,Survivor区域存不下的大对象】移动到老年代,而在GC完成之前,并不知道需要占用老年代多大的内存。所以采取了,以之前每次晋升老年代所需占用老年代的平均内存与老年代空闲内存进行比较。如果老年代
剩余空间大于晋升平均内存,则只进行
Minor GC,若
不够,则进行
Full GC。当然,可能出现担保失败的情况,就是在Minor GC后出现了大量需要晋升到老年代的对象,而老年代并没有进行GC,没有足够的空间存放的时候,就担保失败。这时候只好再发起一次Full GC。是否分配担保失败的设置是HandlePromotionFailure。多数情况下是设置为允许的,不然每一次GC都是Full GC,会造成不必要的性能消耗。