JVM——垃圾收集策略
GC的基本问题
什么是GC?
GC 是 garbage collection 的缩写,意思是垃圾回收——把内存(特别是堆内存)中不再使用的空间释放掉;清理不再使用的对象。
为什么要GC?
堆内存是各个线程共享的空间,不能无节制的使用。服务器运行的时间通常都很长。累积的对象也会非常多。这些对象如果不做任何清理,任由它们数量不断累加,内存很快就会耗尽。所以GC就是要把不使用的对象都清理掉,把内存空间空出来,让项目可以持续运行下去。
如何判断对象已死?(GC触发的条件)
引用计数算法
简单来说,引用计数算法就是在对象中添加一个引用计数器,每当有一个地方引用它时,计数器值就加一;当引用失效时,计数器值就减一;任何时刻计数器为零的对象就是不可能再被使用的。
但这时会出现一个问题,即当两个对象相互进行引用时,也就意味着,它永远都无法”死亡“,就无法进行回收。
Java虚拟机并不是通过引用计数算法来判断对象是否存活的。
可达性分析算法
通过一系列被称为 “GC Roots” 的对象作为起始点,从这些节点向下搜索,搜索走过的路径叫引用链。当一个对象到 GC Roots 没有任何引用链相连时,证明此对象不可用,可被判定为 “死亡” 。
在 Java 语言中,可作为 GC Roots 的对象包括:
-
在虚拟机栈(栈帧中的本地变量表)中引用的对象,譬如各个线程被调用的方法堆栈中使用到的 参数、局部变量、临时变量等。
-
在方法区中类静态属性引用的对象,譬如Java类的引用类型静态变量。
-
在方法区中常量引用的对象,譬如字符串常量池(String Table)里的引用。
-
在本地方法栈中JNI(即通常所说的Native方法)引用的对象。
-
Java虚拟机内部的引用,如基本数据类型对应的Class对象,一些常驻的异常对象(比如 NullPointExcepiton、OutOfMemoryError)等,还有系统类加载器。
-
所有被同步锁(synchronized关键字)持有的对象。
-
反映Java虚拟机内部情况的JMXBean、JVMTI中注册的回调、本地代码缓存等。
再谈引用
引用大至分为四种:
- 强引用:new出来的对象 内存溢出也不会进行回收
- 软引用:只被软引用关联着的对象,在系统将要发生内存溢出异常前,会把这些对象列进回收范围之中进行第二次回收,如果这次回收还没有足够的内存, 才会抛出内存溢出异常。
- 弱引用:只要有垃圾收集就会回收
- 虚引用:回收时收到系统通知
生存还是死亡?(是否真正进行回收)
即使在可达性分析算法中判定为不可达的对象,也不是“非死不可”的,这时候它们暂时还处于“缓 刑”阶段,要真正宣告一个对象死亡,至少要经历两次标记过程:
- 第一次标记:是否有与GC Roots相连接的引用链
- 第二次筛选:此对象是否有必要执行finalize()方法(判断对象能否通过finalize()方法实现自我拯救(避免被回收 )
finalize()是java.lang.Object类的方法,所有对象默认继承这个方法。方法体内是空的,说明如果子类不重写这个方法,那么不执行任何逻辑。
回收方法区(永久代)
永久代的垃圾收集主要回收两部分内容:废弃常量和无用的类。
废弃常量回收比较简单,就是看是否仍有其他地方引用了此字面量,若无引用,则被回收。而判断一个类是否需要被回收条件比较苛刻,需要同时满足下面三个条件才能算是“无用的类”:
- 该类所有的实例都已经被回收,也就是java堆中不存在该类的任何实例
- 加载该类的ClassLoader已经被回收
- 该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法
分代收集理论
弱分代假说(新生代)
绝大多数对象都是朝生夕灭的。
强分代假说(老年代)
熬过越多次垃圾收集过程的对象就越难以消亡。
垃圾收集器的设计原则
将Java堆根据年龄(年龄即对象熬过垃圾收集过程的次数)划分出不同的区域进行存储。当一个对象存活时间越长,年龄越大,我们就将其移动到老年代区域,这样将新生代和老年代分开管理。每次回收时只关注如何保留少量存活而不是去标记那些大量将要被回收的对 象,就能以较低代价回收到大量的空间;
跨代引用假说
跨代引用相对于同代引用来说仅占极少数。
当新生代和老年代互相引用,是应该倾向于同时生存或者同时消亡的。如果某个新生代对象存在跨代引用,由于老年代对象难以 消亡,该引用会使得新生代对象在收集时同样得以存活,进而在年龄增长之后晋升到老年代中,这时 跨代引用也随即被消除了。
如何记录每一个对象是否存在及存在哪些跨代引用?
在新生代上建立一个全局的数据结构(记忆集),这个结构把老年代划分成若干小块,标识出老年代的哪一块内存会存在跨代引用。跨代引用的小块内存里的对象才会被加入到GC Roots进行扫描。
垃圾收集算法
标记-清除算法(老年代)
首先标记出需要回收的对象,在标记完成后统一回收掉所有的被标记对象。
缺点:
- 执行效率不稳定:标记和清除两个过 程的执行效率都随对象数量增长而降低;
- 内存空间的碎片化问题:标记、清除之后会产生大 量不连续的内存碎片,空间碎片太多可能会导致当以后在程序运行过程中需要分配较大对象时无法找 到足够的连续内存而不得不提前触发另一次垃圾收集动作。
标记-复制算法(新生代)
它将可用内存按需要分成两块(实际情况不一定2块,块的大小比例不一定),每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。
新生代通常使用复制算法,因为新生代中对象大多 “朝生夕灭”,每次垃圾收集时有大量对象死去,只有少量存活,复制算法能高效地将少量存活对象复制到另一块空间,实现快速回收。
优点:每次只对其中一块进行GC,不用考虑内存碎片的问题,并且实现简单,运行高效。(适用于新生代,效率高)
缺点:内存缩小了一半。复制的目标空间需要依赖其他空间进行分配担保(将超出的部分直接放入到老年代区域中)。效率随对象存活率升高而降低:当对象存活率较高时,需要进行较多复制操作,效率将会变低。
Appel式回收
把新生代分为一块较大的Eden空间和两块较小的 Survivor空间(8:1:1),每次分配内存只使用Eden和其中一块Survivor。
发生垃圾搜集时,将Eden和Survivor中仍 然存活的对象一次性复制到另外一块Survivor空间上,然后直接清理掉Eden和已用过的那块Survivor空间。
当Survivor空间不足以容纳一次Minor GC之后存活的对象时,就需要依赖其他内存区域(将超出的部分直接放入到老年代区域中)。
标记-整理算法(老年代)
使用“标记-整理”算法:先标记,再把所有存活的对象向一端移动,然后直接清理端边界意外的内存。
老年代中对象存活率高,没有额外空间对其进行分配担保。如果在老年代使用复制算法,由于存活对象多,复制操作会比较频繁,效率低下且成本较高。
优点:不会像复制算法、效率随对象存活率升高而变低。不会像标记-清除算法,产生内存碎片(因为清除前,进行了整理,存活对象都集中到空间一侧)。
缺点:主要是效率问题:除像标记-清除算法的标记过程外,还多了需要整理的过程,效率更低。