《深入理解Java虚拟机:JVM高级特性与最佳实践》笔记(二)
垃圾收集器与内存分配策略
- 哪些内存需要回收?
- 什么时候回收?
- 如何回收?
引用计数算法(Reference Counting)
实现简单,判定效率高,但主流的Java虚拟机里面都没有选用引用计数算法进行内存管理,主要原因就是很难解决对象之间相互循环引用的问题。
可达性分析算法(Reachability Analysis)
基本思路:通过一系列称为“GC Roots”的对象作为起始点,从这些节点开始自顶向下搜索,搜索所走过的路径称为引用链(Reference Chain),当一个对象到GC Roots没有任何引用链相连(图论的话来说,就是从GC Roots到这个对象不可达)时,则证明此对象是不可用的。
Java中,可作为GC Roots的对象包括:虚拟机栈(栈帧中的本地变量表)中引用的对象、方法区中类静态属性引用的对象、方法区中常量引用的对象、本地方法栈中JNI(即一般说的Native方法)引用的对象。
再谈引用
JDK1.2之后,Java对引用的概念进行了扩充,将引用分为强引用(Strong Reference)、软引用(Soft Reference)、弱引用(Weak Reference)、虚引用(Phantom Reference)4种,这四种引用强度依次减弱。
- 强引用类似
Object obj = new Objecg()这类,只要有就不会被回收。 - 软引用用来描述一些还有用但并非必需的对象。在内存溢出之际,会把这些对象列进回收范围进行第二次回收。如果回收后还不够,才会抛出内存溢出异常。
- 弱引用也是描述非必须对象的,但比软引用更弱。垃圾收集器工作时即使内存够用也会回收。
- 虚引用是最弱的一种引用关系。一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。被虚引用关联的对象呗收集器回收时会收到一个系统通知
生存还是死亡
可达性分析算法中不可达的对象,被第一次标记且进行一次筛选,筛选出有必要执行finalize()方法的对象。当对象没覆盖finalize()方法或者finalize()方法已经被虚拟机调用过,则不再执行。
如果需要执行finalize()方法,对象将会放置在F-Queue队列中,并在稍后由一个由虚拟机自动建立的、低优先级的Finalizer线程去执行它。但并不承诺会等待它运行结束。
如果对象在finalize()方法中重新与引用链上的对象建立关联,则在第二次标记时被移出“即将回收”的集合。
finalize()方法运行代价高昂,不确定性打且无法保证各个对象的调用顺序,非常不建议使用。
回收方法区
常量池中字面量的回收:没有任何对象引用常量池中的字面量
类的回收:该类所有实例都已经被回收,加载该类的CLassLoader已经被回收,该类对应的java.lang.Class对象没有任何地方被引用,无法在任何地方反射访问该类方法。
垃圾回收算法
- 标记-清除(Mark-Sweep)算法:效率不高,产生大量不连续的内存碎片
- 复制(Copying)算法:效率高,但内存可用量减少。用来回收新生代。复制时Survivor空间不够时需要依赖老年代进行分配担保(Handler Promotion)
- 标记-整理(Mark-Compact)算法:标记后不直接清理,而是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存。
- 分代收集算法:分新生代和老年代,根据各年代特点采用合适的收集算法
HotSpot的算法实现
枚举根节点时必须要停顿所有Java执行线程(Sun将这件事情称为“Stop The World”)。
安全点(Safepoint)的选定基本上是以程序“是否具有让程序长时间执行的特征”为标准进行选定的。“长时间执行”的最明显特征就是指令序列复用。另一个需要考虑的是如何在GC发生时让所有的线程(这里不包括执行JNI调用的线程)都run到最近的安全点再停顿下来。
抢先式中断(Preemptive Suspension)
不需要线程的主动配合,GC时首先把所有线程全部中断,如果有线程中断的地方不在安全点上,就恢复线程让它run到安全点上。目前几乎没有虚拟机实现这种方式来响应GC。
主动式中断(Voluntary Suspension)
不直接对线程操作,而是设置一个标志,各个线程执行时会主动轮询这个标志,发现标志为true时就自己中断挂起。轮询标志的地方和安全点是重合的,另外加上创建对象需要分配内存的时候。
如果线程处于Sleep或者Blocked状态,这时候线程无法响应JVM的中断请求,此时需要安全区域(Safe Region)来解决
安全区域是指在一段代码中,引用关系不会发生变化。在这个区域任意地方开始GC都是安全的。线程执行到安全区域时,JVM发起GC时就不用管标志自己为安全区域的线程了。在线程要离开安全区域时,需要检查系统是否完成了根节点枚举,若没完成就必须等待直到收到可以安全离开Safe Region的信号为止。
垃圾收集器
以下基于JDK1.7 Update 14之后的Hotspot虚拟机
Serial收集器
单线程,进行垃圾收集时必须暂停其他所有的工作线程直到收集结束。虚拟机运行在Client模式下默认的新生代收集器,因为简单而高效(相比其他收集器的单线程)。
ParNew收集器
Serial收集器的多线程版本,众多运行在Server模式下的虚拟机中首选的新生代收集器,一个与性能无关但是很重要的原因是,除了Serial收集器外,目前只有它能与CMS收集器配合工作。
Parallel Scavenge
新生代收集器,使用复制算法,多线程并行收集器。目标是达到一个可控制的吞吐量(Throughput)。也经常称为“吞吐量优先”收集器。
提供两个参数,分别是控制最大垃圾收集停顿时间的-XX:MAxGCPauseMillis参数以及直接设置吞吐量大小的-XX:GCTimeRatio参数。
MAxGCPauseMillis设定的停顿时间缩短是牺牲吞吐量和新生代空间为代价的。
GCTimeRatio是垃圾收集时间占总时间的比率,相当于吞吐量的倒数。默认值99,就是允许最大1%(即1/(1+99))的垃圾收集时间。
除上述两个参数外,还有一个-XX:+UseAdaptiveSizePolicy值得关注。这是一个开关参数,打开后就不需要手工指定新生代的大小(-Xmn)、Eden与Survivor区的比例(-XX:SurvivorRatio)、晋升老年代对象年龄(-XX:PretenureSizeTreshold)等细节参数了,虚拟机会根据当前系统的运行情况收集性能监控信息,动态调整这些参数以提供最合适的停顿时间或者最大吞吐量,这种调节方式称为GC自适应的调节策略(GC Ergonomics)。也是Parallel Scavenge收集器与ParNew收集器的一个重要区别。
Serial Old收集器
Serial收集器的老年代版本,单线程收集器,使用“标记-整理”算法。主要意义也是在于给Client模式下的虚拟机使用。
如果在Server模式下,那么它主要还有两大用途:一用途是在JDK1.5以及之前的版本中与Parallel Scavenge收集器搭配使用,另一种用途就是作为CMS收集器的后备预案,在并发收集器发生Concurrent Mode Failure时使用。
Parallel Old收集器
Parallel Old是Parallel Scavenge收集器的老年代版本,使用多线程和“标记-整理”算法。在JDK1.6中才开始提供,在此之前,新生代的Parallel Scavenge收集器一直处于比较尴尬的状态。因为如果新生代选择了Parallel Scavenge收集器,老年代除了Serial Old(PS MarkSweep)收集器外别无选择。由于单线程的老年代手机中无法充分利用服务器多CPU的处理能力,在老年代很大而且硬件比较高级的环境中,这种组合的吞吐量甚至还不一定有ParNew加CMS的组合给力。
直到Parallel Old收集器出现后,在注重吞吐量以及CPU资源敏感的场合,都可以考虑Parallel Scavenge加Parallel Old收集器。
CMS收集器
CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。目前很大一部分的Java应用集中在互联网或者B/S系统的服务端上,这类应用尤其重视服务器的响应速度,希望系统停顿时间最短,以给用户带来较好的体验,CMS收集器就非常符合这类应用的需求。
基于“标记-清除”算法,运作过程相对更复杂,分4个步骤:
- 初始标记(CMS initial mark)
- 并发标记(CMS concurrent mark)
- 冲洗标记(CMS remark)
- 并发清除(CMS concurrent sweep)
其中初始标记、重新标记这两步仍然需要“Stop The World”。初始标记仅仅只是标记一下GC Roots能直接关联到的对象,速度很快,并发标记阶段就是进行GC Roots Tracing的过程,而重新标记阶段则是为了修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间一般会必初始标记阶段稍长一些,但远比并发标记的时间短。
由于整个过程中耗时最长的并发标记和并发清楚过程收集器线程都可以与用户线程一起工作,所以从总体上来说,CMS收集器的内存回收过程是与用户线程一起并发执行的。Sun公司的一些官方文档中也称之为并发低停顿收集器(Concurrent Low Pause Collector)。但仍有以下3个明显缺点:
- CMS收集器对CPU资源非常敏感。在并发阶段,虽然不会导致用户线程停顿,但是会因为占用了一部分线程(或者说CPU资源)而导致应用程序变慢,总吞吐量会降低。CMS默认启动的回收线程数是(CPU数量+3)/4,也就是4个以上CPU时,并发回收时垃圾收集线程不少于25%的CPU资源,并随着CPU数量增加而下降。但当CPU不足4个时,CMS对用户程序的影响就可能变得很大。为了应付这种情况,虚拟机提供了一种称为“增量式并发收集器”(Incremental Concurrent Mark Sweep/i-CMS)的CMS收集器的变种,所做的事情和单CPU年代PC机操作系统使用抢占式来模拟多任务机制的思想一样,就是在并发标记、清理的时候让GC线程、用户线程交替运行,尽量减少GC线程独占资源的时间,这样整个垃圾收集的过程会更长,但对用户程序的影响就会显得少一些。实践证明,增量式的CMS收集器效果很一般,目前版本i-CMS已经被声明为“deprecated”。
- CMS收集器无法处理浮动垃圾(Floating Garbage),可能出现“Concurrent Mode Failure”失败而导致另一次Full GC的产生。由于CMS并发清理阶段用户线程还在运行着,同事也在不断产生新的垃圾,只好留待下一次GC时再清理掉。也是由于垃圾收集阶段用户线程还需要运行,所以需要预留足够的内存空间给用户线程使用。在JDK1.5的默认设置下,CMS收集器在老年代使用了68%的空间后就会被激活,在JDK1.6中,启动阈值已经提升至92%。要是在CMS运行期间无法满足程序的需要,就会出现一次“Concurrent Mode Failure”失败,这时虚拟机将启动后备预案:临时启用Serial Old收集器来重新进行老年代的垃圾收集,这样停顿时间就很长了。所以说参数
-XX:CMSInitiatingOccupancyFraction设置太高反而降低了性能。 - CMS是一款基于“标记-清除”算法实现的收集器,这意味着收集结束时会有大量空间碎片产生。空间碎片过多时,将会给大对象分配带来很大麻烦,老年代还有很大空间剩余,但无法找到足够大的连续空间,不得不提前触发一次Full GC。为了解决这个问题,CMS收集器提供了一个
-XX:+UseCMSCompactAtFullCollection开关参数(默认开启),用于在CMS收集器定不住要进行Full GC时开启内存碎片的合并整理过程,该过程无法并发,所以停顿时间不得不变长。CMS还提供另一个参数-XX:CMSFullGCsBeforeCompaction,这个参数是用于设置多少次不压缩的Full GC后,跟着带来一次带压缩的(默认为0,表示每次进入Full GC时都进行碎片整理)。
G1收集器
G1(Garbage-First)收集器是一款面向服务端应用的垃圾收集器。具备如下特点:
- 并行与并发:G1能充分利用多CPU、多核环境下的硬件优势,使用多个CPU或者CPU核心来缩短Stop-The-World停顿时间,部分其他收集器原本需要停顿Java线程执行的GC动作,G1收集器仍然可以通过并发的方式让Java程序继续执行。
- 分代收集:G1从整体来看是基于“标记-整理”算法实现的的收集器,从局部(两个Region)上来看是基于“复制”算法实现的,两种算法都意味着G1运作期间不会产生内存空间碎片。该特性利于程序长时间运行。
- 可预测的停顿:让使用者明确指定在一个长度为M毫秒的时间片段内,消耗在垃圾收集上的时间不得超过N毫秒,这几乎已经是实时Java(RTSJ)的垃圾收集器的特征了。
使用G1收集器时,Java堆的内存布局与其他收集器有很大差别,它将整个Java堆划分为多个大小相等的独立区域(Region),虽然还保留有新生代和老年代的概念,但新生代和老年代不再是物理隔离的了,它们都是一部分Region(不需要连续)的集合。
G1收集器之所以能建立可预测的停顿时间模型,是因为它可以有计划地避免在整个Java堆中进行全区域的垃圾收集。G1跟踪各个Region里面的垃圾堆积的价值大小,在后台维护一个优先列表,每次根据允许的收集时间,优先回收价值最大的Region。
在G1收集器中,Region之间的对象引用以及其他收集器中的新生代与老年代之间的对象引用都是使用Remembered Set来避免全堆扫描。
如果不计算维护Remembered Set的操作,G1收集器的运作大致可划分为以下几个步骤:
- 初始标记(Initial Marking)
- 并发标记(Concurrent Marking)
- 最终标记(Final Marking)
- 筛选回收(Live Data Counting and Evacuation)
初始标记阶段仅仅只是标记一下GC Roots能直接关联到的对象,并且修改TSMS(Next Top at Mark Start)的值,让下一阶段用户程序并发运行时,能在正确可用的Region中创建新对象,这阶段需要停顿线程,但耗时很短。并发标记阶段是从GC Root开始对堆中对象进行可达性分析,找出存活的对象,这阶段耗时较长,但可与用户程序兵法执行。而最终标记阶段则是为了修正在并发标记期间因用户程序继续运作而导致标记产生变动的那一部分标记记录,虚拟机将这段时间对象变化记录在线程Remembered Set Logs里面,最终标记阶段需要把Remembered Set Logs的数据合并到Remembered Set中,这阶段需要停顿线程,但是可并行执行。最后在筛选回收阶段首先对各个Region的回收价值和成本进行排序,根据用户所期望的GC停顿时间来制定回收计划,从Sun公司透露出来的信息来看,这个阶段其实也可以做到与用户程序一起并发执行,但是因为只回收一部分Region,时间是用户可控制的,而且停顿用户线程将大幅提高手机效率。
理解GC日志
每个收集器的日志格式都可以不一样,但虚拟机的设计者为了方便用户阅读,将各个收集器的日志都维持一定的共性。
最前面的数字代表了GC发生的时间,这个数字的含义是从Java虚拟机启动以来经过的秒数。
开头的“[GC”和“[Full GC”说明了这次垃圾收集的停顿类型,而不是用来区分新生代GC还是老年代GC的。如果有“[Full GC”说明这次GC是发生了Stop-The-World的。如果是调用System.gc()方法所触发的手机,那么这里将显示“[Full GC(System)”。
接下来的“[DefNew”、“[Tenured”、“[Perm”表示GC发生的区域,这里显示的区域名称与使用的GC收集器是密切相关的。
后面方括号内部的“3324K->152K(3712K)”含义是“GC前该内存区域已使用容量->GC后该内存区域已使用容量(该内存区域总容量)”。而方括号之外的“3324K->152K(11904K)”表示“GC前Java堆已使用容量->GC后Java堆已使用容量(Java堆总容量)”。
再往后,“0.0025925secs”表示该内存区域GC所占用的时间,单位是秒。有的收集器会给出更具体的时间数据,如user、sys和real等,与Linux的time命令所输出的时间含义一致。
内存分配与回收策略
几条在使用Client模式虚拟机的内存分配规则:
- 对象优先在Eden分配。当Eden区没有足够空间进行分配时,虚拟机将发起一次Minor GC(新生代GC)。Major GC(老年代GC)的速度一般会比Minor GC慢10倍以上。
- 大对象直接进入老年代。 典型的大对象就是那种很长的字符串以及数组。写程序时应当避免出现一群“朝生夕灭”的“短命大对象”,否则导致内存还有不少空间时就要提前触发垃圾收集。
- 长期存活的对象将进入老年代。虚拟机给每个对象定义了一个对象年龄(Age)计数器。对象在Survivor区每经历一次Minor GC,年龄就增加1岁,当年龄增加到一定程度(默认15岁),就会被晋升到老年代。
- 动态对象年龄判断。如果Survivor空间中相同年龄所有对象大小的综合大于Survivor空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代。
- 空间分配担保。在发生Minor GC之前,虚拟机会先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果这个条件成立,那么Minor GC可以确保是安全的。如果不成立则会查看设置是否允许担保失败。如果允许,那么会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,将尝试进行一次Minor GC,尽管这次Minor GC是有风险的;如果小于,或者设置不允许冒险,那这时也要改为进行一次Full GC。
本博客所有文章除特别声明外,均采用 CC BY-SA 4.0 协议 ,转载请注明出处!