垃圾收集器与内存分配策略
Java与C++之间有一堵由内存动态分配和垃圾收集技术所围成的“高墙”,墙外面的人想进去,墙里面的想出来。
一、判断对象是否已经死了
1.应用计数算法
给每个对象添加一个引用计数器,每当有一个地方引用它时,计数器的值就加1,当应用失效的时候就减1,任何时刻计数器为0的时候表示对象就是不可用状态。
但是Java虚拟机并没有选择这种算法,原因是很难解决对象之间相互引用的问题。
2.可达性分析算法
通过一系列的称为“GC Root”的对象作为起点,从这些节点开始向下搜索,搜索所走过的路径称为引用链(Reference Chain),当一个对象到GC Root没有任何引用链相连接的时候,证明这个对象是不可用的。
在Java语言中,作为GC Root的对象有如下
①虚拟机栈中引用的对象。
②方法区中类静态属性引用的对象。
③方法区中常量引用的对象。
④本地方法栈中JNI(Native方法)应用的对象。
二、垃圾收集算法
1.标记—清除算法
“标记”:首先标记出所有需要回收的对象。
“清除”:标记完成后统一回收被标记的对象。
不足之处:① 效率问题,标记和清理两个过程效率都不高。②标记清理之后会产生大量不连续的内存碎片。
2.复制算法
将内存分成两个大小相等的两块,每次只使用一块,当这一块内存用完了,就将还存活的对象复制到另一块上,然后把已经使用过的内存空间一次清理掉。
好处:回收是一次性回收,也不用考虑内存碎片等复杂情况。只需要移动堆顶的指针,实现简单,效率高。
坏处:浪费一般内存。
现在商业虚拟机使用的这种算法回收新生代:将内存分为一块较大的Eden空间和两块较小的Survivor空间,每次使用Eden和其中一块Survivor。当回收的时候,将Eden区和Survivor区中还存活的对象一次性的复制到另一块survivor空间上,最后清理Eden区和刚才使用的survivor区。HotSpot默认Eden与Survivor比例是8:1.,所以只有10%空间浪费。当Survivor空间不够用时,需要依赖其他内存(老年代)进行分配担当。
3.标记—整理算法
复制算法在对象存活率比较高的时候会进行比较多的复制操作,效率会变低,所以老年代不适合这种算法。
4.分带收集算法
根据对象活跃周期不同将内存划分为几块:一般把Java堆分为新生代和老生代。
新生代:一般使用复制算法。
老生代:一般使用“标记—清理算法”或者“标记—整理算法”。
三、HotSpot的算法实现
1. 枚举根节点
当进行垃圾回收前的对象可达性分析时,对执行时间的敏感体现在GC停顿上。因为这项分析工作必须在一个能保证一致性的快照中进行——整个分析过程中所有执行都停在某个时间点上。GC停顿是性能瓶颈的重要原因。
使用OoMap实现快速节点枚举。
2.安全点(Safepoint)
并不是每个指令都会创建OoMap,我们会为特定的指令或者说程序特定的位置创建OoMap,例如:方法调用、循环跳转、异常跳转等。
如何将所有线程都跑到安全点停顿:①抢先式中断,在GC发生时,首先把所有线程全部中断,如果发现不在安全点上则恢复线程叫它跑到安全点再停止。
3.安全区域
安全区域表示一段代码,不会引起引用变化。
四、垃圾收集器
1.Serial收集器
用户新生代,使用复制算法。是一个单线程的收集器。它在垃圾回收的时候必须暂停其他所有的工作线程,直到它收集结束。
2.ParNew收集器
就是Serial的多线程版本。它默认开启的收集线程数与CPU的数量相同。
3.Parallel Scavenge收集器
新生代收集器。也是使用复制算法,也是并行多线程收集器。
特点:其他收集器关注点是尽可能的缩短垃圾收集时用户线程的停顿时间,而Parallel Scavenge 目标是达到一个可控制的吞吐量(CPU用户运行用户代码的时间与CPU总时间的比值)。停顿时间越短就会越适合与用户交互的程序,良好的相应速度能提高用户体验,而高吞吐量能够提高CPU的效率,尽快完成任务。
减少等待时间需要减少新生代的内存,GC频率也会增加。
4.Serial Old 收集器。
是Serial 的老年代版本。单线程收集器。CMS收集器的备用方案。使用“标记—整理算法”。
5.Parallel Old收集器
Parallel Scavenge收集器的老年代版本。使用“标记—整理算法”。
6.CMS收集器(Concurrent Mark Sweep)
目标:获取最短回收停顿时间的收集器。
应用:老年代
算法:“标记—清除”算法。
步骤:初始标记,并发标记,重新标记,并发清除。
初始标记:只是标记一下GC Root能直接关联的对象,速度快,需要GC 停顿。
并发标记:进行GC Root Tracing 过程。
重新标记:修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分对象标记记录。需要GC停顿。
并发清除:清除标记的“死亡对象”。
缺点:
①CMS收集器对CPU资源非常敏感
并发阶段会和程序的执行并发使用CPU,CPU资源的利用率就会减少。
②无法处理浮动垃圾
CMS并发清理阶段,用户线程还在执行,期间还有有垃圾不断生成,这部分垃圾在标记之后,所以只能推迟到下次清理。所以需要预留一部分空间提供并发收集时程序运作,如果CMS运行期间预留的空间不足时就会出现“Concurrent Mode Failure”失败,这是虚拟机会启动备用方案:临时启用Serial Old 进行老年代的垃圾回收,这事停顿时间会变长。
③CMS使用的是“标记—清理”算法,会出现大量的空间碎片。
8.理解GC日志
①最前面的数字“33.125“和“100.667”表示GC发生的时间,从Java虚拟机启动以来经过的秒数。
②“[GC” 和“[Full GC”这次垃圾收集停顿类型,不是区分新生代和老年代。如果有“Full”说明发生了Stop-The-World
③“[DefNew”、“[Tenured”、“[Perm”表示GC发生的区域(新生代、老年代、永久代)
④“3324K—>152K(3712K) 0.0025925 ” GC前该区域已使用的容量—>GC后该区域已经使用的容量(该区域的总容量) GC所占用的时间s。
⑤“3324K—>152K(11904K)”表示“GC前Java堆已使用的容量—>GC后Java堆使用的容量(Java堆总容量)”
五、内存分配与回收策略
Java自动内存管理分为:给对象分配空间和回收分配的对象空间。
分配空间:对象主要分配在Eden区,如果启用本地线程分配缓冲将按照线程优先在TLAB上分配。少数情况直接分配到老年代,取决于哪一种垃圾收集器组合还有虚拟机参数相关配置。
1.对象优先在Eden分配
大多数情况下,对象在新生代Eden区分配,当Eden区没有足够空间进行分配时,虚拟机会发起一次Minor GC
-XX:PrintGCDetails 参数打印GC日志。
新生代GC(Minor GC):发生在新生代的垃圾回收动作,因为Java对象大多具备朝生夕灭的特性,所以Minor GC会比较频繁,回收速度也很快。
老年代GC(Full GC 或者 Major GC):发生在老年代,一般发生时会伴随一次Minor GC。速度会比较慢。
2.大对象直接进入老年代
-XX:PretenureSizeThreshold参数配置,大于这个设置值,直接进入老年代。
目的:避免在Eden区及两个Survivor区之间发生大量的内存复制。
3.长期存活的对象将进入老年代
虚拟机给每个对象定义了一个对象年龄(Age)计数器。如果对象在Eden出生并经过一次Minor GC后仍然存活,并且被Survivor收纳,将被复制到Survivor区年龄增加1岁,当年龄增到15岁时就会晋升到老年代。
-XX:MaxTenuringThreshold配置默认年龄。
4.动态对象年龄判定
并不是所有对象必须达到MaxTenuringThreshold值。如果在Survivor空间中相同年龄所有对象大小总和大于Survivor空间的一半,所有大于等于该年龄的对象可以直接进入老年代。
5.空间分配担保
在发生Minor GC之前,虚拟机会先检查老年代最大可用的连续空间是否大于新生代所有对象的总和,如果条件成立那么Minor GC可以确保是安全的。
如果不成立,查看HandlePromotionFailure设置值是否允许担保失败。如果允许,那么会继续检查老年代最大可用连续空间是否大于历次晋级到老年代对象的平均大小。如果大于,将尝试进行一次Minor GC,尽管是冒险的。如果小于或者HandlePromotionFailure设置不冒险,则需要进行一次Full GC。
-XX:-HandlePromotionFailure
如果出现HandlePromotionFailure失败,那只好在失败后重新发起一次Full GC。虽然失败时会绕圈子,但是建议开启避免频繁Full GC。
笔者的话:
深入理解Java虚拟机是一本不错的书,至少在理论上是的。这是我看了第二遍,因为理论的东西不经常看会淡忘,所以这次看的时候我也写了笔记,希望自己之后看的时候能更快抓住重点。同时也希望其他人能通过我的总结快速理解。
这部分的内容基本把面试官喜欢问的问题都已经阐述清楚啦。需要我们能够将对象的内存分配和回收的流程用自己的话说清楚,更重要的是叫别人能理解。