03、调优实战 - 垃圾回收算法
1. 为什么需要垃圾回收
在系统运行时,新生成的对象是优先分配在堆内存中的新生代,随着新生成的对象越来越多,新生代就会装不下了,这个时候就需要垃圾回收来把“垃圾”对象给回收掉,释放内存空间。
那在需要垃圾回收的时候,哪些对象才是“垃圾”对象呢?
在主流虚拟机中采用的是“可达性分析算法”来判断哪些对象能够回收,哪些对象不能回收的。
1.1 可达性分析算法
通过一系列称为“GC Roots”的对象作为起始点,从这些节点向下搜索,搜索所走过的路径称为引用链(Refrence Chain),当一个对象到GC Roots没有任何引用链相连(图论的话来说,这个对象到GC Roots不可达)时,证明此对象是不可用的,即可被回收的。
另外一种表述:将一系列的GC Roots作为初始的存活对象集合(live set),然后从该集合出发,探索所有能够被该集合引用到的对象,并将其加入到该集合,这个过程称为标记(mark);最终未被探索到的对象便是死亡的,即可被回收的。
图中object5,6,7到GC Roots不可达,所以会被判断成可回收对象。
什么是GC Roots?
在Java语言中,可作为GC Roots的对象:
1、 虚拟机栈(栈帧中的本地变量表)中引用的对象;(局部变量)
2、 方法区中类静态属性引用的对象;(静态变量)
3、 方法区中常量引用的对象(static final);(成员变量不行,因为成员变量是存储在堆内存的对象中的,和对象共存亡)
4、 本地方法栈中JNI(Native方法)引用的对象;
5、 活着的线程;
这里有一篇文章进行了不错的证明: [【证】:那些可作为GC Roots的对象][GC Roots]
1.2 Java中的引用:
传统定义:如果reference类型的数据中存储的数值代表的是另外一块内存的起始地址,就称这块内存代表这一个引用。
JDK1.2后对引用进行了扩展,分为强引用、软引用、弱引用、虚引用:
1.2.1 强引用(Strong Reference):
在程序中普遍存在,类似于“Object obj = new Object()”这类的引用,只要强引用还在(即obj还指向Object对象),GC永远不会回收被引用的对象。
当手动置空 obj=null; 这个对象在下次GC运行的时候被回收。
示例:
public class Kafka {
public static ReplicaManager replicaManager = new ReplicaManager();
}
1.2.2 软引用(Soft Reference):
描述一些还有用但并非必须的对象,通常用于缓存,JDK1.2后,提供了SoftReference类来实现软引用。
正常情况下垃圾回收是不会回收软引用对象的;软引用对象的回收时机:
当发生GC时,JVM虚拟机取决于软引用对象是否新创建或者最近被使用过,来判断是否回收;
在JVM虚拟机发生OOM异常时,所有软引用对象都会被回收;
如果软引用对象被一个强引用指向,即使发生OOM时,也不会被回收;
示例
public class Kafka {
public static SoftReference<ReplicaManager> replicaManager = new SoftReference<ReplicaManager>(ReplicaManager());
}
回收时机:clock - timestamp `<= freespace * SoftRefLRUPolicyMSPerMB
- clock - timestamp:软引用对象多久没有被访问过了;
- freespace(MB):JVM中的空闲内存空间;
- SoftRefLRUPolicyMSPerMB(JVM参数):每MB的空闲内存允许SoftReference对象存活多久;
示例
当前JVM里的空闲空间有1000M,SoftRefLRUPolicyMSPerMB 默认值为1000毫秒,则现在允许 SoftReference对象存活 1000*1=1000秒;
1.2.3 弱引用(Weak Reference):
描述一些非必须对象,强度比软引用更弱一点,被弱引用关联的对象只能生存到下一次垃圾回收发生之前(即跟没有引用一样,只要发生垃圾回收,都会把这个对象回收掉)。当前GC工作时,无论内存是否足够,都会回收掉弱引用关联的对象。JDK1.2后,提供了WeakReference类来实现软引用。
示例
public class WeakReferenceDemo {
public static WeakReference<String> weakReference1;
public static void main(String[] args) {
test1();
//可以输出hello值;因为此时已无强引用指向对象,但是未进行gc,所以弱引用仍持有对象
System.out.println("未进行gc时,只有弱引用指向value内存区域:" + weakReference1.get());
//执行GC,回收弱引用
System.gc();
//弱引用被释放掉了,所以引用的对象也被回收了
System.out.println("进行gc时,只有弱引用指向value内存区域:" + weakReference1.get());
}
public static void test1() {
String hello = new String("value");
weakReference1 = new WeakReference<>(hello);
System.gc();
//此时gc不会回收弱引用,因为字符串"value"仍然被hello对象强引用
System.out.println("进行gc时,强引用与弱引用同时指向value内存区域:" + weakReference1.get());
}
}
// output:
进行gc时,强引用与弱引用同时指向value内存区域:value
未进行gc时,只有弱引用指向value内存区域:value
进行gc时,只有弱引用指向value内存区域:null
说明:
当有强引用指向value的时候,即使进行GC,弱引用不会被释放,引用的对象不会被回收;
当没有强引用指向value的时候,此时执行GC,弱引用会被释放,引用的对象会被回收;
WeakHashMap:
介绍:WeakHashMap的键是弱键,当某个键不再正常使用时,会被从WeakHashMap中自动移除;即对于一个给定的键,其映射的存在并不阻止垃圾回收器对该键的丢弃,这就使该键成为可终止的,然后被回收,某个键被回收时,它对应的键值对也就从映射中有效地移除了。
实现原理:通过WeakReference和ReferenceQueue实现,WeakHashMap的key是弱键,即是WeakReference类型的,ReferenceQueue是一个队列,它会保存被GC回收的弱键。
- 新建WeakHashMap,将键值对添加到WeakHashMap中。WeakHashMap是通过数组table保存Entry(单向链表的键值对);
- 当某弱键不再被其他对象引用并被GC回收时,这个弱键同时会被添加到ReferenceQueue中;
- 当下一次需要操作WeakHashMap时,会先同步table和queue,table中保存了全部的键值对,queue中保存了已经被GC回收的键值对;同步它们,即是删除table中废弃的键值对;
1.2.4 虚引用(Phantom Reference):
最弱的一种引用关系,对象是否有虚引用的存在,完全不会对生存时间够成影响,也无法通过虚引用来取得一个对象实例。
唯一目的是能在对象被GC回收时收到一个系统通知(这就可以实现在对象被回收时做一些事情了:如DirectByteBuffer的Cleaner机制);JDK1.2后,提供了PhantomReference类来实现虚引用。
1.3 对象的真正死亡:
在可达性分析算法中不可达的对象(没有GC Roots引用的对象),是一定马上就被回收吗?
不是的,一个对象真正的死亡,还要经过两次标记的过程:
如果对象在可达性分析后发现没有与GC Roots相连接的引用链,那它会被第一次标记并且进行一次筛选,筛选的条件是此对象是否还有必要执行 finalize() 方法;没有必要执行的条件是:
-
对象没有覆盖finalize()方法;
-
finalize()方法 已经被虚拟机调用过;
-
如果这个对象有必要执行finalize()方法(实现了finalize()方法),这个对象会被放置在一个F-Queue队列中,并由一个低优先级的Finalizer线程执行它。
-
finalize()方法是对象逃脱死亡的最后一次机会,如果对象要在finalize()中拯救自己,只需要重新与引用链上的任何一个对象建立关联即可,比如把自己(this)赋值给某个类变量或者对象成员变量。那在第二次标记时会被移出F-Queue队列中。
示例
public class ReplicaManager {
public static ReplicaManager instance;
@override
protected void finalize() throws Throwable {
ReplicaManager.instance = this;
}
}
- 如果对象还没有在此方法中逃脱,就会被回收了;
由于此方法运行代价高昂,不确定性大,无法保证各个对象的调用顺序,所以完全不推荐使用此方法来拯救对象。
所以也可以在finalize()方法中,实现一些在对象回收前的自定义操作;(Java官方不推荐)
1.4 回收方法区:
方法区(如HotSpot虚拟机中的元空间或者永久代)的垃圾回收主要回收两部分内容:废弃常量和无用的类。
回收废弃常量:与回收Java堆中的对象非常类似。以常量池中的字面量的回收 为例,例如一个字符串“abc”已经进入了常量池中,但是当前系统中没有任何String对象是“abc”,也就是说没有任何String对象引用常量池中的这个“abc”对象,当这个时候发生垃圾回收,必要的话,这个常量就会被清理出常量池。常量池中的其他类(接口)、方法、字段的符号引用都是这样。
回收无用的类:条件比判断“废弃常量”复杂许多,需要同时满足下面三个条件:
- 该类所有的实例都已经被回收,也就是Java堆中不存在该类的任何实例;
- 加载该类的ClassLoader都已被回收;
- 该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。
在大量使用反射,动态代理,CGLIB等ByteCode框架、动态生成JSP以及OSGI这类频繁自定义ClassLoader的场景都需要虚拟机具备类卸载功能,以保证永久代不会溢出。
2. 垃圾回收算法
2.1 标记-清除算法
标记-清除算法分为两个二个阶段:首先标记出所有需要回收的对象;在标记完成后统一回收所有被标记的对象。
缺点
空间问题:标记清除之后会产生大量不连续的内存碎片,导致以后程序运行过程需要分配较大对象的时候,无法找到足够连续的内存,而不得不提前触发垃圾回收动作;
分配效率低:如果是连续的内存空间,可以通过指针加法的方式来做分配;而对于不连续的空闲列表,JVM需要逐个访问列表中的项来查找能够放入新建对象的空闲内存;
2.2 复制算法
将可用内存按照容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了需要进行回收时,就标记出需要存活的对象,然后将这些需要存活的对象复制到另外一块空白内存中(复制的时候可以紧凑地连续排列),然后把已使用的内存空间一次性清理掉;现在的商业虚拟机都是采用这种复制算法来回收新生代。
优点:每次对整个半区进行内存回收,不会导致内存碎片的问题,实现简单,运行高效;
缺点:将内存缩小为原来的一半,对内存的使用效率太低;
2.2.1 复制算法的优化:Eden区和Survivor区
根据IBM公司的研究表明:新生代中的对象98%都是“朝生夕死”的(即存活时间很短,存活对象占比很小),所以并不需要按照1:1的比例来划分内存空间,而是将内存划分为一块较大的Eden区和两块较小的Survivor区,每次使用Eden区和一块Survivor区(用于存放上次Minor GC后还存活的对象)。HotSpot虚拟机默认Eden区和Survivor区的大小是8:1,这样每次新生代中的可用内存空间只有10%会被浪费。
示例(假设Eden区有800M内存,则相当于有900M的内存可以使用):
当发生回收时:将Eden区和刚才使用的Survivor区还存活着的对象(此时存活的对象非常少)一次性的复制到另外一块Survivor空间上,然后清理掉Eden区和使用过的Survivor区。
接着,新对象继续分配在Eden区,另外那块Survivor区继续保存Minor GC后存活的对象,并始终保持有一块Survivor区是空的,这样一直循环使用三块内存区域。
为什么需要两块 Survivor区?
假设只有一块 Survivor区:
当发生第一次YGC的时候,回收Eden区,并将存活对象被放入了这块Survivor区;
在发生下一次YGC时,回收Eden区和这块Survivor区,此时Eden区和这块Survivor区都可能有存活对象;
- 第一,如果此处不做处理,等到后面几次YGC后,Survivor区不够了才移到老年代;多次的进入Survivor区,会导致内存区域不连续,内存利用率下降;
- 第二,如果直接将Survivor区的存活对象移到老年代,将会导致老年代空间迅速增大,并且这些对象很可能下一次就该被回收的;(也就是说老年代放入了很多低年龄的对象)
- 所以如果有两块Survivor区的话,可以保证内存区域连续,并且在Survivor区中对象的相互复制,可以增加年龄,实现年龄阈值判断;
当然,我们没有办法保证每次回收都只有不多于10%的对象存活,当另外的Survivor空间不够时,就需要依赖其他内存(老年代)进行分配担保。如果另外一块Survivor没有足够的空间存放上一次新生代收集下来的存活对象时,这些对象将直接通过分配担保机制进入老年代。
2.2.2 优化的复制算法下Minor GC的过程
- GC开始时,对象只会存在于Eden区和From Survivor区,To Survivor区是空的(作为保留区域)。
- GC进行时,Eden区中所有存活的对象都会被复制到To Survivor区,而在From Survivor区中,仍存活的对象会根据它们的年龄值决定去向,年龄值达到年龄阈值(默认为15,JVM参数“-XX:MaxTenuringThreshold”)的对象会被移到老年代中,没有达到阈值的对象会被复制到To Survivor区(每复制一次,年龄加1);接着清空Eden区和From Survivor区,新生代中存活的对象都在To Survivor区。
- 接着,From Survivor区和To Survivor区会进行交换,把To Survivor区中的对象复制到From Survivor区中,也就是新的From Survivor区就是上次GC的To Survivor区。总之,不管怎样都会保证To Survivor区在一轮GC之后是空的。
- GC时当To Survivor区没有足够的空间存放新生代存活的对象时,需要依赖老年代进行分配担保,将这些对象存放在老年代中。
2.3 标记-整理算法
复制收集算法在对象存活率较高时就要进行较多的复制操作,效率将会变低,另外还需要额外的空间进行担保,以应对使用的内存中所有对象都100%存活的极端情况,所以在老年代一般不能直接使用此算法。
“标记-整理”算法的标记过程仍然与“标记-清除”算法一样,但后续步骤不是直接对可回收的对象进行清理,而是让所有存活的对象都向一端移动(压缩),然后清理掉端边界以外的内存。
缺点:
压缩算法的性能开销;
HotSpot虚拟机中CMS垃圾回收器回收老年代采用的就是 标记-整理算法。
2.4 分代收集算法
当前商业虚拟机的垃圾收集都采用“分代收集”算法,根据对象存活周期的不同将内存划分为几块。一般是把Java堆划分为新生代和老年代,再根据各个年代的特点来采用最适合的收集算法。
在新生代中,每次垃圾收集都发现有大量对象死去,只有少量存活,就采用复制算法,只需要付出少量存活对象的复制成本就可以完成收集;而老年代中因为对象存活率较高,没有额外空间进行分配担保,就必须使用“标记-清除”算法或者“标记-整理”算法来进行回收。