我们需要思考GC的3件事
所谓“垃圾”,就是指所有不再存活的对象。在jvm堆里面几乎放着java世界中所有的对象,垃圾收集器在对堆进行收集时需要判断哪些还存活着,哪些已经死了。
常见的判断是否存活有两种方法:引用计数法和可达性分析。
给对象添加一个引用计数器,每当有一个地方引用它时,计数器值就加1,当引用失效时,计数器值就减1,任何时刻计数器为0的对象就是不可能再被使用的。
优点:引用计数算法的实现简单,判定效率也很高,在大部分情况下它都是一个不错的选择
缺点:Java虚拟机并没有选择这种算法来进行垃圾回收,主要原因是它很难解决对象之间的相互循环引用问题。A引用了B,B又引用了A,这样是无法回收A,B的。
主流的商用程序语言(Java,C#)的主流实现中,都是通过可达性分析(Reachability Analysis)来判定对象是否存活的。
算法的基本思想就是通过一系列的称为“GC Roots”的对象作为起始点,
从这些起始点开始向下搜索,所走过的路径称为引用链,
如果一个对象到GC Roots没有任何引用链,那么这个对象是不可用的,就是说,程序中没有谁引用了这个对象,所以可以说从根节点到叶子结点是不可达的。
Java中,以下对象可作为GC Roots对象:
Java中那些不可达的对象就会变成垃圾。?其实就是没有办法再引用到该对象了。
主要有以下情况使对象变为垃圾:
1.对非线程的对象来说,所有的活动线程都不能访问该对象,那么该对象就会变为垃圾。
2.对线程对象来说,满足上面的条件,且线程未启动或者已停止。
我们希望一类对象当内存空间还足够时,能够保留在内存中,如果内存空间回收后还是很紧张则抛弃这些对象。
jdk1.2之后java对引用的概念进行了扩充,分为强引用,软引用,弱引用,虚引用。
强>软>弱>虚
要真正宣告一个对象死亡,至少要经历两次标记过程:如果对象在进行可达性分析后发现没有与GC Roots相连接的引用链,将会被标记一次并且进行一次筛选,筛选的条件是此对象是否有必要执行finalize()方法。
finalize()方法
- 如果一个对象被判定为有必要执行finalize()方法,那么这个对象将会放置在一个叫做F-Queue队列中,并稍后由一个虚拟机自动建立的,低优先级的Finalizer线程去执行它。
- 任何一个对象的finalize()方法只会被系统自动调用一次。
前面说了,Java的内存回收主要实在方法区和Java堆中,Java堆中的新生代,因为新生代的存活时间比较短,所以对新生代进行垃圾回收回收的空间比较大,但是方法区中的永久代则由于可能存活时间较长,所以下一次的垃圾回收回收该对象的可能性没有新生代那么大。所以对永久代的回收效率会大打折扣。但是这部分对象仍然是需要回收。
永久代的垃圾回收包括两部分:废弃常量和无用的类
废弃常量的回收比较好理解,因为只要没有任何对象引用常量池中的某个对象,那么这个对象就会被回收。前面说的是非常量池中的对象,废弃常量回收的是运行时常量池中的对象,所以只需要一次标记就好。
无用的类回收需要满足以下三个条件才可以宣判一个类的“死刑”:
注意上面的“可以”而非“必然”。与对象的回收不同,是否需要对类进行回收,需要设置相关的参数才行。
在大量使用反射、动态代理、CGLib等ByteCode框架、动态生成JSP以及OSGi这类频繁自定义ClassLoader的场景都需要虚拟机具备类卸载的功能,以保证永久代不会溢出。
算法的执行过程与名字一样,先标记所有需要回收的对象,在标记完成后统一回收所有被标记的对象。该算法有两个问题:
复制算法是为了解决标记-清除算法的效率问题的,其思想如下:将可用内存的容量分为大小相等的两块,每次只使用其中的一块,当这一块内存使用完了,就把存活着的对象复制到另外一块上面,然后再把已使用过的内存空间清理掉。
优点:每次都是对整个半区进行内存回收,内存分配时也就不用考虑内存碎片等复杂情况,只要移动堆顶指针,按顺序分配内存即可,实现简单,运行高效。
缺点:算法的代价是将内存缩小为了原来的一半,未免太高了一点。
现在的商业虚拟机都采用这种收集算法来回收新生代,新生代中的对象98%是“朝生夕死”的,所以并不需要按照1∶1的比例来划分内存空间,而是将内存分为一块较大的Eden空间和两块较小的Survivor空间,每次使用Eden和其中一块Survivor。
当回收时,将Eden和Survivor中还存活着的对象一次性地复制到另外一块Survivor空间上,最后清理掉Eden和刚才用过的Survivor空间。
HotSpot虚拟机默认Eden和Survivor0,Survivor1的大小比例是8∶1:1,也就是每次新生代中可用内存空间为整个新生代容量的90%,只有10%的内存会被“浪费”。
当然,90%的对象可回收只是一般场景下的数据,我们没有办法保证每次回收都只有不多于10%的对象存活,当Survivor空间不够用时(例如,存活的对象需要的空间大于剩余一块Survivor的空间),需要依赖其他内存(这里指老年代)进行分配担保(Handle Promotion)。
工作原理如下:
复制收集算法在对象存活率较高时就要进行较多的复制操作,效率将会变低。更关键的是,如果不想浪费50%的空间,就需要有额外的空间进行分配担保,以应对被使用的内存中所有对象都100%存活的极端情况,所以在老年代一般不能直接选用这种算法。
与标记-清除算法过程一样,只不过在标记后不是对未标记的内存区域进行清理,二是让所有的存活对象都向一端移动,然后清理掉边界外的内存。该方法主要用于老年代。
目前商用虚拟机都使用“分代收集算法”,所谓分代就是根据对象的生命周期把内存分为几块,一般把Java堆中分为新生代和老年代,这样就可以根据对象的“年龄”选择合适的垃圾回收算法。
新生代:“朝生夕死”,存活率低,使用复制算法。
老年代:存活率较高,使用“标记-清除”算法或者“标记-整理”算法。
在选择回收算法前,我们先来看一下 Java 堆的结构。
一块 Java 堆空间一般分成三部分,这三部分用来存储三类数据:
也就是说,常规的 Java 堆至少包括了 新生代 和 老年代 两块内存区域,而且这两块区域有很明显的特征:
新生代:存活对象少、垃圾多
老年代:存活对象多、垃圾少
上面讲了判断对象死亡,然后是怎么通过算法来回收这些死去的对象,而现在讲的hotspot是在上面讲的垃圾算法中进行优化,优化了一套自己的垃圾回收算法,当然也只是优化,并不是自己搞出一套什么新东西来。
我们回到标记-清除算法,在清除阶段,为了枚举未被标记的对象,所以需要从根节点(GC Roots)开始查找引用链,这个过程会导致GC停顿,意思就是在GC的时候Java的执行线程都被停顿,好像被冻结在某一个时间点,也叫“Stop the world”。然而目前主流的Java虚拟机都是用准确式GC(所谓准确式GC,即使虚拟机知道内存中的某个位置的数据是什么类型),当“Stop the world”的时候并不需要检查所有的引用位置,虚拟机通过使用OopMap这个数据结构知道哪些地方存放着对象的引用。
类加载完成后,HotSpot将对象内什么偏移量上是什么类型的数据计算出来;在JIT编译过程中,在特定的位置记录下栈和寄存器(程序计数器)中哪些位置是引用。
使用OopMap,虚拟机已经知道哪些位置存放着对象(知道了位置避免全表扫描),从而GC Roots可以迅速的枚举可达对象的引用链。但是问题来了:是不是需要对所有的指令都使用OopMap呢?答案是否定的。实际上,虚拟机只在“特定的位置”记录了对象的引用信息,比如我们使用方法调用或者循环的时候,就会设定这样的位置,如果越过这个位置的继续执行指令,然而程序是不允许因为指令流长度太长而执行过长时间,所以这个“特定位置“就成为了程序是否具有长时间运行的分界点。这个”特定的位置“也称为安全点(SafePoint)。
现在虚拟机有了安全点,于是只会到安全点寻找对象的引用信息,并且在安全点暂停Java执行线程,然而还有一个问题如何在GC发生时让所有线程(不包括JNI调用的线程)都“跑”到最近的安全点上再停顿下来?
在Hotspot使用主动式中断来中断执行线程,其思想如下:当GC的时候不需要直接对线程操作以中断线程,仅仅是设置一个标志,然后让执行线程去轮询这个标志,发现中断标志为真的时候就自己中断线程。需要注意的是,轮询标志的地方与安全点的位置是重合的,另外再加上创建对象需要分配的地方。
现在通过GC Roots和安全点,程序能够在不太长的时间就可以到达安全点,并暂停执行线程。那么如果程序在阻塞(Blocked)或者睡眠(Sleep)的状态的时候,执行线程如何中断呢?
JVM设置了安全区域(Safe Region)。安全区域可以看成是被扩展了的安全点,是指在这个区域内,对象的引用关系不会发生改变,在这个区域内的任何地方开始GC都是安全的。
前面讲了很多,开始是怎么判断对象死亡,然后讲了怎么回收这些垃圾对象,然后是hotspot的算法优化,现在才开始真正开始hotspot里面真正的实现。
hotspot的垃圾收集器有很多,大致分为两种收集器:老年代收集器,新生代收集器。
可以看到垃圾收集器是按对象的分代来划分的,可以用双箭头连接的垃圾收集器表示两者可以配合使用。可以看到新生代垃圾收集器有Serial、ParNew、Parallel Scavenge,G1,属于老年代的垃圾收集器有CMS、Serial Old、Parallel Old和G1.其中的G1是一种既可以对新生代对象也可以对老年代对象进行回收的垃圾收集器。然而,在所有的垃圾收集器中,并没有一种普遍使用的垃圾收集器。在不同的场景下,每种垃圾收集器有各自的优势。
这两个名词都是并发编程中的概念,在谈论垃圾收集器的上下文语境中,它们可以解释如下。
并行(Parallel):指多条垃圾收集线程并行工作,但此时用户线程仍然处于等待状态。
并发(Concurrent):指用户线程与垃圾收集线程同时执行(但不一定是并行的,可能会交替执行),用户程序在继续运行,而垃圾收集程序运行于另一个CPU上。
新生代GC(Minor GC):指发生在新生代的垃圾收集动作,因为Java对象大多都具备朝生夕灭的特性,所以Minor GC非常频繁,一般回收速度也比较快。
老年代GC(Major GC / Full GC):指发生在老年代的GC,出现了Major GC,经常会伴随至少一次的Minor GC(但非绝对的,在Parallel Scavenge收集器的收集策略里就有直接进行Major GC的策略选择过程)。Major GC的速度一般会比Minor GC慢10倍以上。
吞吐量就是CPU用于运行用户代码的时间与CPU总消耗时间的比值,即
吞吐量 = 运行用户代码时间 /(运行用户代码时间 + 垃圾收集时间)。
虚拟机总共运行了100分钟,其中垃圾收集花掉1分钟,那吞吐量就是99%。
Serial收集器是最基本、发展历史最悠久的收集器。它是一种单线程垃圾收集器,这就意味着在其进行垃圾收集的时候需要暂停其他的线程,也就是之前提到的”Stop the world“。虽然这个过程是在用户不可见的情况下把用户正常的线程全部停掉,听起来有点狠,这点是很难让人接受的。Serial、Serial Old收集器的工作示意图如下
尽管由以上不能让人接受的地方,但是Serial收集器还是有其优点的:
通过serial收集器可以看出hotspot的GC实际工作,标记安全点,在安全点开始收集,使用上面提到过的各种算法,把2中算法组合起来,成为一个收集器。
G1收集器运作大致可分为以下几个步骤:问题:老年代与新生代被装进混合装进了一个个小盒子里面了,每个盒子里面既有新生代由于老年代,垃圾回收时就会遇到新的问题。
解决问题:虚拟机都是使用Remembered Set避免GC时全表扫描。G1更狠,在每个Region都有一个与之对应的Rememberd set 。具体操作很复杂就不讲了,讲了也不了解。
Java技术体系中所提倡的自动内存管理最终可以归结为自动化地解决了两个问题:给对象分配内存以及回收分配给对象的内存。
对象的内存分配,往大方向讲,就是在堆上分配,对象主要分配在新生代的Eden区上,如果启动了本地线程分配缓冲,将按线程优先在TLAB上分配。少数情况下也可能会直接分配在老年代中,分配的规则并不是百分之百固定的,其细节取决于当前使用的是哪一种垃圾收集器组合,还有虚拟机中与内存相关的参数的设置。
本文中的内存分配策略指的是Serial / Serial Old收集器下(ParNew / Serial Old收集器组合的规则也基本一致)的内存分配和回收的策略。
大多数情况下,对象在新生代Eden区中分配。当Eden区没有足够空间进行分配时,虚拟机将发起一次Minor GC。虚拟机的参数-XX:PrintGCDetails可以打印内存回收日志。
测试用例请参看:
http://blog.csdn.net/u011080472/article/details/51326003
所谓的大对象是指,需要大量连续内存空间的Java对象,最典型的大对象就是那种很长的字符串以及数组。
大对象对虚拟机的内存分配来说就是一个坏消息(比遇到一个大对象更加坏的消息就是遇到一群“朝生夕灭”的“短命大对象”,写程序的时候应当避免),经常出现大对象容易导致内存还有不少空间时就提前触发垃圾收集以获取足够的连续空间来“安置”它们。
虚拟机提供了一个-XX:PretenureSizeThreshold参数,令大于这个设置值的对象直接在老年代分配。这样做的目的是避免在Eden区及两个Survivor区之间发生大量的内存复制(复习一下:新生代采用复制算法收集内存)。
**注意:**PretenureSizeThreshold参数只对Serial和ParNew两款收集器有效,Parallel Scavenge收集器不认识这个参数,Parallel Scavenge收集器一般并不需要设置。如果遇到必须使用此参数的场合,可以考虑ParNew加CMS的收集器组合。
详细测试用例请参看:《深入理解java虚拟机》94页。
或者http://blog.csdn.net/u011080472/article/details/51326003
为了能更好地适应不同程序的内存状况,虚拟机并不是永远地要求对象的年龄必须达到了MaxTenuringThreshold才能晋升老年代.
动态对象年龄判定:如果在Survivor空间中相同年龄所有对象大小的总和大于Survivor空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代,无须等到MaxTenuringThreshold中要求的年龄。
在发生Minor GC之前,虚拟机会先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果这个条件成立,那么Minor GC可以确保是安全的。如果不成立,则虚拟机会查看HandlePromotionFailure设置值是否允许担保失败。如果允许,那么会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,将尝试着进行一次Minor GC,尽管这次Minor GC是有风险的;如果小于,或者HandlePromotionFailure设置不允许冒险,那这时也要改为进行一次Full GC。
下面解释一下“冒险”是冒了什么风险,前面提到过,新生代使用复制收集算法,但为了内存利用率,只使用其中一个Survivor空间来作为轮换备份,因此当出现大量对象在Minor GC后仍然存活的情况(最极端的情况就是内存回收后新生代中所有对象都存活),就需要老年代进行分配担保,把Survivor无法容纳的对象直接进入老年代。与生活中的贷款担保类似,老年代要进行这样的担保,前提是老年代本身还有容纳这些对象的剩余空间,一共有多少对象会活下来在实际完成内存回收之前是无法明确知道的,所以只好取之前每一次回收晋升到老年代对象容量的平均大小值作为经验值,与老年代的剩余空间进行比较,决定是否进行Full GC来让老年代腾出更多空间。
取平均值进行比较其实仍然是一种动态概率的手段,也就是说,如果某次Minor GC存活后的对象突增,远远高于平均值的话,依然会导致担保失败(Handle Promotion Failure)。如果出现了HandlePromotionFailure失败,那就只好在失败后重新发起一次Full GC。虽然担保失败时绕的圈子是最大的,但大部分情况下都还是会将HandlePromotionFailure开关打开,避免Full GC过于频繁。
上面按照自己的意思和排版做了一遍,并加入了一些自己的想法,大量文字参照了:
http://blog.csdn.net/u011080472/article/category/6214467
《深入理解java虚拟机》