【java虚拟机序列】java中的垃圾回收与内存分配策略
在【java虚拟机系列】java虚拟机系列之JVM总述中我们已经详细讲解过java中的内存模型,了解了关于JVM中内存管理的基本知识,接下来本博客将带领大家了解java中的垃圾回收与内存分配策略。
垃圾回收(Garbage Collection,GC)是java语言的一大特色,在Java中,程序员不需要去关心内存动态分配和垃圾回收的问题,这一切都交给了JVM来处理。而在C/C++中是需要程序员主动释放的,而在java中则交给JVM自动完成,既然是交给程序自动执行,那么这里就必须完成以下几件事:
1哪些内存需要回收?(即哪些对象可以被看做是”垃圾“)
2如何回收?(即常用的垃圾回收算法)
3内存分配策略
接下来就按照上述提出的三个疑问一一进行详细讲解。
一哪些内存需要回收?
通过前面的【java虚拟机系列】java虚拟机系列之JVM总述我们知道,java内存区域主要指的是java运行时数据区,在这个内存区域中,程序计数器,java栈,本地方法栈3个区域随着线程的产生而产生,随着线程的消亡而消亡。因此这些地方不需要过多的考虑内存回收,因为线程结束后内存自然也就跟着回收了,而java堆区与方法区则不然,一个接口中的多个实现类需要的内存可能不一样,因此当程序在运行时才能够知道会创建哪些对象,因此这部分内存的回收与分配是动态的,这也是垃圾回收关注的内存区域。
那么堆或方法区中的哪些对象可以被看做是“垃圾”呢?即哪些对象应该被回收呢?
这就涉及到jvm的垃圾判定算法,常用的垃圾判定算法包括:引用计数算法,可达性分析算法。下面一一介绍
引用计数算法:
我们知道java堆中的内存是通过引用来访问的,即每一个堆内存都对应着一个可以访问该内存地址的引用,那么当某个引用指向这块堆内存时,让计数器加1,当指向该堆内存的引用无效时计数器减1,那么很清楚的知道当该对象的计数器为0时,即表示该对象可视为”垃圾“被回收。
通过其原理可以知道,该算法实现简单,判定效率很高,但是目前主流的JVM都没采用该算法来管理内存,最主要的原因是该算法很难解决对象之间的循环引用的情况。举个例子如下:
class TestX{ public TestY y; } class TestY{ public TestX x; } public class Main{ public static void main(String[] args){ X x = new X(); Y y = new Y(); x.y=y; y.x=x;//这两行赋值完成后x与y存在相互引用 x = null; y = null; <span style="white-space:pre"> </span> System.gc();//通知虚拟机回收 } }虽然通过x = null; y = null;两行语句将X与Y的引用置空,表示当前堆中的X与Y无引用指向它们,因此它们已经不能被访问到,按道理应该被垃圾回收器回收,但是因为x与y互相引用,导致x与y的引用计数器都不为0,因此如果采用引用计数器算法的话,那么这两个对象的内存都不能被回收。运行程序,查看运行结果,可以从内存分析看到,事实上这两个对象的内存被回收,这也从侧面说明了当前主流的JVM都不是采用的程序计数器算法作为垃圾判定算法的。
可达性分析算法:
可达性分析算法是java语言所采用判定对象是否存活的算法,该算法的基本思路就是通过一系列的称为“GC Roots”的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链(Reference Chain),当一个对象到GC Roots没有任何引用链相连(用图论的话来说,就是从GC Roots到这个对象不可达)时,则证明此对象是不可用的。如图所示,对象object 5、object 6、object 7虽然互相关联,但是它们到GC Roots是不可达的,所以它们将会被判定为是可回收的对象。
因此该算法的关键是”GC Roots”的对象的选取,在Java语言中,可作为GC Roots的对象包括下面几种:
虚拟机栈(栈帧中的本地变量表)中引用的对象。
方法区中类静态属性引用的对象。
方法区中常量引用的对象。
本地方法栈中JNI(即一般说的Native方法)引用的对象。
注意:在可达性分析算法中,不可达的对象,也并非是“非死不可”的,这时候它们暂时处于“缓刑”阶段,要真正宣告一个对象死亡,至少要经历两次标记过程:如果对象在进行可达性分析后发现没有与GC Roots相连接的引用链,那它将会被第一次标记并且进行一次筛选,筛选的条件是此对象是否需要执行finalize()方法。当对象没有覆盖finalize()方法,或者finalize()方法已经被虚拟机调用过,虚拟机将这两种情况都视为“不需要要执行”。注意任何对象的finalize()方法只会被系统自动执行1次。
如果这个对象被判定为需要执行finalize()方法,那么这个对象将会放置在一个叫做F-Queue的队列之中,并在稍后由一个由虚拟机自动建立的、低优先级的Finalizer线程去执行它。这里所谓的“执行”是指虚拟机会触发这个方法,但并不承诺会等待它运行结束,这样做的原因是,如果一个对象在finalize()方法中执行缓慢,或者发生了死循环,将很可能会导致F-Queue队列中其他对象永久处于等待,甚至导致整个内存回收系统崩溃。因此调用finalize()方法不代表该方法中代码能够完全被执行。
finalize()方法是对象逃脱死亡命运的最后一次机会,稍后GC将对F-Queue中的对象进行第二次小规模的标记,如果对象要在finalize()中成功拯救自己——只要重新与引用链上的任何一个对象建立关联即可,譬如把自己(this关键字)赋值给某个类变量或者对象的成员变量,那在第二次标记时它将被移除出“即将回收”的集合;如果对象这时候还没有逃脱,那基本上它就真的被回收了。从如下代码中我们可以看到一个对象的finalize()被执行,但是它仍然可以存活。
/** * 此代码演示了两点: * 1.对象可以在被GC时自我拯救。 * 2.这种自救的机会只有一次,因为一个对象的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(); System.out.println("finalize mehtod executed!"); FinalizeEscapeGC.SAVE_HOOK = this; } public static void main(String[] args) throws Throwable { SAVE_HOOK = new FinalizeEscapeGC(); //对象第一次成功拯救自己 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方法优先级很低,所以暂停0.5秒以等待它 Thread.sleep(500); if (SAVE_HOOK != null) { SAVE_HOOK.isAlive(); } else { System.out.println("no, i am dead :("); } } }
运行结果如下:
finalize mehtod executed! yes, i am still alive :) no, i am dead :(从运行结果可以看出,SAVE_HOOK对象的finalize()方法确实被GC收集器调用过,且在被收集前成功逃脱了。
另外一个值得注意的地方是,代码中有两段完全一样的代码片段,执行结果却是一次逃脱成功,一次失败,这是因为任何一个对象的finalize()方法都只会被系统自动调用一次,如果对象面临下一次回收,它的finalize()方法不会被再次执行,因此第二段代码的自救行动失败了。
二如何回收?(常用的垃圾回收算法)
常用的垃圾回收算法包括:标记-清除算法,复制算法,标记-整理算法,分代收集算法,下面一一介绍其实现原理。
标记-清除算法(Mark-Sweep):最基础的垃圾回收算法,顾名思义,包括标记与清除两个过程。首先标记出所有需要回收的对象,在标记完成后统一回收所有被标记的对象,它的标记过程其实就是前面介绍过的可达性分析算法的过程。
不足点:
1效率不高,标记和清除两个过程的效率都不高。
2空间利用率不高,标记清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致以后在程序运行过程中需要分配较大对象时,无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。
复制算法:赋值算法是为了解决标记-清除算法的空间利用率不高而改进的,它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。这样使得每次都是对整个半区进行内存回收,内存分配时也就不用考虑内存碎片等复杂情况,只要移动堆顶指针,按顺序分配内存即可,实现简单,运行高效。
现在的商业虚拟机都采用这种收集算法来回收新生代,IBM公司的专门研究表明,新生代中的对象98%是“朝生夕死”的,所以不需要按照1∶1的比例来划分内存空间,而是将内存分为一块较大的Eden空间和两块较小的Survivor空间,每次使用Eden和其中一块Survivor。当回收时,将Eden和Survivor中还存活着的对象一次性地复制到另外一块Survivor空间上,最后清理掉Eden和刚才用过的Survivor空间。算法执行过程如下图所示:
很显然该算法的效率跟存活对象的数目多少有很大的关系,如果存活对象很多,当Survivor空间不够用时,需要依赖其他内存(这里指老年代)进行分配担保(Handle Promotion)。
标记-整理算法(Mark-Compact):复制收集算法在对象存活率较高时就要进行较多的复制操作,效率将会变低。更关键的是,如果不想浪费50%的空间,就需要有额外的空间进行分配担保,以应对被使用的内存中所有对象都100%存活的极端情况,所以在老年代一般不能直接选用这种算法。而是采用标记-整理算法(Mark-Compact)。
该算法的标记过程仍然与“标记-清除”算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存,示意图如下:
分代收集算法(Generational Collection):该算法是当前绝大多数虚拟机采用的垃圾收集算法,该算法是综合考虑上述几种算法的最佳情况,根据对象存活周期的不同将内存划分为几块。一般是把Java堆分为新生代和老年代,这样就可以根据各个年代的特点采用最适当的收集算法。
在新生代中,每次垃圾收集时都发现有大批对象死去,只有少量存活,那就选用复制算法,只需要付出少量存活对象的复制成本就可以完成收集。而老年代中因为对象存活率高、没有额外空间对它进行分配担保,就必须使用“标记—清理”或者“标记—整理”算法来进行回收。
注意,在堆区之外还有一个代就是永久代(Permanet Generation),它用来存储class类、常量、方法描述等。对永久代的回收主要回收两部分内容:废弃常量和无用的类。
三java中的内存分配与回收策略
java的自动内存管理事实上自动的解决了两个问题:给对象分配内存以及回收分配给对象的内存。关于内存回收,前面已经详细介绍过,因此接下来重点讲解java中的内存分配技术。
对象的内存分配,往大方向上讲就是在堆上分配,对象主要分配在新生代的Eden ,少数情况下会直接分配在老年代,分配的规则虽不是百分之百固定的,但也遵循以下几个规则:
1对象优先在Eden分配:
大多数情况下,对象在新生代Eden区中分配。当Eden区没有足够空间进行分配时,虚拟机将发起一次Minor GC。下面是新生代内存区域图:
在GC的过程中,会将Eden Space和From Space中的存活对象移动到To Space,然后将Eden Space和From Survivor进行清理。如果在清理的过程中,To Survivor无法足够来存储某个对象,就会将该对象移动到老年代中。
如果在GC过程中,To Space无法存储某个对象,就会将该对象移动到老年代中。
2大对象直接进入老年代:
所谓的大对象是指需要大量连续存储空间的对象,最常见的大对象如很长的字符串与很大的数组,之所以将大对象直接在老年代分配是为了避免在Eden区与两个Survivor区进行大量的内存复制,注意在新生代采用的是复制算法收集垃圾对象。
3长期存活的对象进入老年代:
前面说过虚拟机采用了分代收集的思想来管理内存,那么内存回收时就必须能识别哪些对象应放在新生代,哪些对象应放在老年代中。为了做到这点,虚拟机给每个对象定义了一个对象年龄(Age)计数器。如果对象在Eden出生且经过第一次Minor GC后仍然存活,且能被Survivor容纳的话将被移动到Survivor空间中,然后将该对象年龄设为1。对象在Survivor区中每“躲过”一次Minor GC,年龄就增加1岁,当它的年龄增加到一定程度(默认为15岁),就将会被移动到老年代中。
以上就是本博客的主要内容,如果读者觉得不错,记得小手一抖,点个赞哦!另外欢迎大家关注我的博客账号哦,将会不定期的为大家分享技术干货,福利多多哦!