JVM原理解读——垃圾回收
1、垃圾判断算法
1.1、引用计数法(已废弃)
记录对象被引用的次数,当引用指向该对象时计数+1,取消指向该对象时计数-1。当对象被引用次数为0时,判定该对象可回收
引用计数法无法回收循环引用的对象,造成内存泄漏泄漏问题,所以被废弃
1.2、可达性分析
将一些堆外指向堆内的引用(GC Roots)作为根,从根开始探索所有可访问到的对象,并将他们标记为存活,后续再根据不同的垃圾回收算法进行垃圾对象的回收
可作为GC Root的对象包括但不限于:
- 栈中的引用的对象
- 方法区中的静态变量和常量
- 本地方法引用的对象
2、引用类型
2.1、强引用(Strong Reference)
强引用是最普通的引用,只有程序跳出了引用的作用域,或强制将强引用弱化(赋值为null),JVM才会回收它,否则当内存不足时,JVM会抛出OOM异常
Object object = new Object();
2.2、软引用(Soft Reference)
只有在内存不足时,才会回收的引用对象
SoftReference<Object> reference = new SoftReference<>(new Object());
2.3、弱引用(Weak Reference)
只要发生gc,就会被回收
当你希望在不影响对象生命周期的情况下使用对象,你可以使用弱引用
public static void main(String[] args) throws InterruptedException {
Object o = new Object();
WeakReference<Object> reference = new WeakReference(o);
o = null;
System.gc();
Thread.sleep(100);
if(reference.get() != null){
System.out.println("对象还没被回收,做些操作吧");
}else {
System.out.println("对象被回收了,就啥也别干了");
}
}
2.4、虚引用(Phantom Reference)
随时会被回收,必须与引用队列一起使用,用于在对象回收时做些操作
其实虚引用基本用不到
public static void main(String[] args) throws InterruptedException {
ReferenceQueue<Object> queue = new ReferenceQueue<>();
Object o = new Object();
WeakReference<Object> reference = new WeakReference(o,queue);
o = null;
System.gc();
Thread.sleep(100);
if(queue.poll().equals(reference)){
System.out.println("对象被回收了,做些其他操作");
}
}
2、垃圾回收算法
2.1、标记—清除算法
将死亡对象所占内存标记为空闲内存并将内存地址记录空闲链表(free list)中,并在新建对象时分配给它
由于堆内对象的内存必须连续,这种方式会造成内存碎片(浪费),导致堆内总空间足够但是无法分配内存的情况
2.2、标记—整理算法
回收时把所有对象都往空间头部移动,以整理出一段连续的内存空间
解决了内存碎片化的问题,但是对象移动的成本太高
2.3、标记—复制算法
将内存区域分为from和to两部分,其中from指向的区域用来分配内存,垃圾回收时将from区存活对象复制到to区,并交换两个区的指针,保证from永远指向被分配的内存
同样解决了内存碎片化问题,但是内存利用率较低
2.4、分代收集算法
将堆空间分为新生代和老年代,分别执行不同的垃圾回收算法
2.4.1、新生代
主要存放新创建的对象,又被分为Eden区和大小相同的两个Survivor区,两区比例是根据生成对象的速率动态调整的
动态配置对应-XX:+UsePSAdaptiveSurvivorSizePolicy参数,默认启用,也可以通过-XX:SurvivorRatio=设置Eden和Survivor的比例
新生代垃圾回收执行标记—复制算法,将Eden区和Survivor-from的存活对象复制到Survivor-to中,再交换from和to的指针。选择此算法时因为大部分对象存活期较短
新生代的晋升条件:
- 对象晋升到老年代的复制次数通过-XX:+MaxTenuringThreshold参数设置,默认为15
- Survivor区的占用达到一定比例时,较高复制次数的对象也会晋升,通过-XX:TargetSurvivorRatio调整,默认50
- Survivor区不够时,会跳过Survivor区,直接将新生代对象直接晋升到老年代
- 超出某个大小的对象会直接分配在老年代,通过-XX:PretenureSizeThreshold设置,默认是0,表示首选Eden区分配
- 动态对象年龄判定,由一些不受JVM参数控制的一些内部组合条件控制,了解即可
2.4.2、老年代
主要存放经历了多次gc依然存活下来的对象,老年代回收选择标记—整理算法
2.4.3、卡表
新生代和老年代的回收时机一般是独立的,而引用实际上是可以是跨代的。新生代可达性分析时,为了找到被老年代引用的新生代且不触发整个老年代的扫描,JVM以512字节为一卡将堆划分,在每个实例变量的写操作时将是否引用新生代的结果维护在卡表中,当新生代可达性分析时,会同时将卡表中的脏卡(引用新生代的对象)放入GC Root中,减少了非脏卡老年代扫描的无谓消耗
卡表虽然解决了跨代引用的问题,但是又会因为并发修改缓存行导致同步阻塞和缓存无效的问题(伪共享),为了根据环境调整卡表的写入方式,可以通过-XX:+UseCondCardMark=true配置JVM在每次写卡表时不写已经标识的卡表,虽然增加了判断开销,但是减少了并发阻塞的开销
2.4.4、线程本地分配缓存区(TLAB)
全称Thread Local Allocation Buffer
为减少多线程下申请Eden区公共空间的锁竞争,线程会在创建时向Eden区申请1%的空间作为自己线程专用的空间,维护一些小对象的生命周期,超过TLAB大小的对象还是会被直接分配在Eden区的公共空间
3、垃圾收集器
垃圾收集器遵循上述的可达性分析算法和分代收集算法
3.1、年轻代垃圾收集器
3.1.1、Serial收集器(了解即可)
单线程gc,导致用户程序频繁暂停,单线程环境下性能良好,一般用在swing这种客户端
3.1.2、ParNew收集器(了解即可)
由多个线程并行收集,提高收集的效率
3.1.3、Parallel Scavenge收集器
jdk1.8默认的新生代收集器,同样多线程并行收集
相比其他垃圾收集器,他更关注控制吞吐量( 运 行 代 码 时 间 / ( 运 行 代 码 时 间 + 垃 圾 收 集 时 间 ) 运行代码时间/(运行代码时间+垃圾收集时间) 运行代码时间/(运行代码时间+垃圾收集时间))而不是降低正常stw的时间
可以通过-XX:MaxGCPauseMillis设置最大停顿时间,默认200ms,通过-XX:GCTimeRatio设置gc时间占总时间的比率(吞吐量的倒数)
通过开启-XX:+UseAdaptiveSizePolicy可以让他动态调节策略
3.2、老年代垃圾收集器
3.2.1、Serial Old收集器(了解即可)
Serial的老年代版本
3.2.2、CMS收集器(了解即可)
在jdk9中已经被deprecated,在jdk14中已经被removed
CMS也操作新生代,只不过对老年代作用更大,所以放在了老年代来说
以降低stw时间为目标,使得用户线程和gc线程可以并行执行,降低stw的延迟感受
很多人将CMS作为一个知识点,但其实他不常用,且有很多劣势,最终导致他被废弃:
- 由于老年代使用的标记-清除算法会产生内存碎片,在新生代对象晋升或分配担保,且老年代空间不足时,会产生Concurrent Mode Failure,CMS会退化为Serial Old
- 在gc时需要预留空间来分配浮动垃圾(即gc过程中产生的可回收对象)
- 占用过多cpu资源
- 高度可配置,导致学习和使用的成本过大
3.2.3、Parallel Old收集器
jdk1.8默认的收集器,Parallel Scavenge的老年代版本
3.3、G1收集器
全称GarbageFirst,同时作用在年轻代和老年代上,是jdk9以后默认的主流收集器
G1将堆切分成若干区域(Region),并优先回收垃圾最多的Region
年轻代在物理上不再区分Eden和Survivor区
3.3.1、RSet、CSet和dirty card queue
每个Region由若干RSet组成,用来存储“引用了当前Region中的对象的老年代对象”,可以为空
dirty card queue用来缓存脏卡的信息,由专门的GC线程负责收集,异步更新RSet
CSet基于RSet实现,用于存放待回收的对象
3.3.2、回收过程
3.3.2.1、年轻代回收(Young GC)
- 扫描根:收集GC Roots和RSet
- 更新RSet:由于Region中的RSet是异步更新,导致可达性分析时的RSet不是实时的,需要以dirty card queue为数据源再更新一次
- 处理RSet:GC Roots和RSet为根进行可达性分析
- 复制回收:使用标记复制算法,将Region中(逻辑上的Eden区)的对象复制到逻辑上的Survivor区,并清理Eden区,同时向老年代晋升一些多次存活的对象
3.3.2.2、混合回收(Mixed GC)
当堆内存占用达到一定比例,混合回收会被启动,比例通过-XX:InitiatingHeapOccupancyPercent调整,默认45%
- 初始标记(Initial Mark):启动条件达成时,初始标记阶段等待与年轻代的根扫描一同执行,复用年轻代扫描的GC Roots,只标记GC Roots直接可达的对象,该阶段会产生stw(与年轻代的根扫描共用一段停顿时间)
- 根区域扫描(Root Region Scan):扫描Survivor区并标记其中在初始标记阶段标记出来的、引用了老年代对象的对象,该阶段可以和用户线程并行
- 并发标记(Concurrent Mark):在初始标记的基础上,进行可达性分析,耗时较长,但是可以和用户线程并行。同时在该阶段,当引用关系发生变化时,G1会使用pre-write barrier将变化记录到队列中,为Remark做准备
- 再次标记(Remark):对当前版本的GC Roots再次进行可达性分析,只不过区间是并发标记阶段记录的发生了引用变化的对象。该阶段会stw,以保证再次标记的准确性
- 清理(Cleanup):回收全是垃圾对象的Region,有存活对象的region会在下个阶段有选择性地处理
- 拷贝存活对象(Evacuation):根据停顿预测模型,限制CSet大小,并选择年轻代的所有region,和老年代中垃圾较多的region执行对应的收集算法,在达到用户规定的停顿时间的同时(-XX:MaxGCPauseMillis),使回收的作用最大化