垃圾收集器与内存分配策略
对象已死?
在堆里面存放着Java中几乎所有的对象实例,垃圾收集器在对堆进行垃圾回收的时候,第一件事就是如何判断哪些对象还活着,哪些对象已经死掉了。
引用计数法
在对象中添加一个引用计数器,每当有一个地方引用它时,计数器值就加一;当引用失效了,计数器值就减一;任何时刻计数器为零的对象就是不可以再被使用了。
**引用计数法(Reference Counting)会带来额外的内存空间来进行计数,但原理简单,判定效率高。**但在java
领域却很少使用,主要原因是:看似简单的算法有很多例外的情况要考虑,必须要配合大量额外的处理才能保证正确的工作, 如单纯的引用计数就很难解决对象之间的循环引用问题。
可达性分析算法
java
主要使用的算法即可达性分析算法(Reachability Analysis),这个算法的基本思路就是通过一系列被称为GC Roots
的跟对象作为起始节点集,从这些节点开始,根据引用关系向下搜索,搜索过程所走过的路径称为引用链(Reference Chain)
在java
中,固定可作为GC Roots
的对象包括以下几种:
- 在虚拟机栈(本地变量表)中引用的对象,如各个线程被调用的方法堆栈中使用到的参数、局部变量、临时变量等
- 在方法区中静态属性引用的对象,如
java
类的引用类型静态变量 - 在方法区中常量引用的对象,如字符串常量池(String table)里的引用
- 在本地方法栈中
JNI
(Native方法)引用的对象 java
虚拟机内部的引用,如基本数据类型对应的Class对象,一些常驻的异常对象(如NullPointException
、OutOfMemoryError
)等,还有系统类加载器。- 所有被同步锁(
synchronized
关键字)持有的对象。 - 反映
java
虚拟机内部情况的JMXBean
、JVMTI
中注册的回调、本地代码缓存等
引用
强引用:是最传统的引用,是指程序代码之中普遍存在的引用赋值,即 Object obj = new Object();
这种引用关系。无论任何情况下,只要强引用关系还存在,垃圾收集器就永远不会回收掉被引用的对象。
软引用:用来描述一些还有用、但非必须的对象。只被软引用关联着的对象,在系统将要发生内存溢出异常前,会把这些对象列进回收范围之中进行第二次回收,如果回收了,内存还不够,会抛出内存溢出异常。在JDK1.2
之后提出了SoftReference
类来实现软引用。
虚引用:是最弱的一种引用关系,一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象的实例。为一个对象设置虚引用关联的唯一目的只是为了能在这个对象被收集器回收时收到一个系统通知。在JDK1.2
之后提出了**PhantomReference
类来实现软引用。**
生存还是死亡?
即使在可达性分析算法中判定为不可达的对象,也不是非死不可,这时候它们处于缓刑阶段,要真正宣告一个对象死亡,至少要经历两次标记过程:
- 如果对象在进行可达性分析后发现没有与
GC Roots
相连接的引用链,那它将会被第一次标记,随后进行一次筛选,筛选条件是此对象是否有必要执行finalize()
方法。假如对象没有覆盖finalize()
方法,或者finalize()
方法已经被虚拟机调用过了,那么将视为没有必要执行。 - 如果对象被判定有必要执行
finalize()
方法,那么对象会被放置到一个名为F-Queue的队列中,并稍后由一条虚拟机自动建立的、低调度优先级的Finalizer
线程去执行它的finalize()
方法。
注意:这里的执行finalize()
方法并不会承诺一定会等待它运行结束。(考虑到如果执行finalize()
方法中出现了死循环等错误,很可能会一直等待下去)
案例:
下面代码中,一次成功,一次失败,原因是finalize()
方法只会被系统自动调用一次,如果下一次对象还面临回收,它的finalize()
方法不会再次被执行
/**
* @author Qiao
* @desc
* @create
**/
public class FinalizeEscapeGC {
public static FinalizeEscapeGC SAVE_HOOK = null;
public void isAlive() {
System.out.println("l am still alive!");
}
@Override
protected void finalize() throws Throwable {
super.finalize();
System.out.println("finalize method start");
FinalizeEscapeGC.SAVE_HOOK = this;
}
public static void main(String[] args) throws InterruptedException {
SAVE_HOOK = new FinalizeEscapeGC();
SAVE_HOOK = null;
System.gc();
TimeUnit.MILLISECONDS.sleep(500);
if (SAVE_HOOK != null) {
SAVE_HOOK.isAlive();
}else {
System.out.println("i am dead");
}
// 失败了
SAVE_HOOK = null;
System.gc();
TimeUnit.MILLISECONDS.sleep(500);
if (SAVE_HOOK != null) {
SAVE_HOOK.isAlive();
}else {
System.out.println("i am dead");
}
}
}
回收方法区
方法区中回收条件苛刻,垃圾收集的成果很低
方法区的垃圾收集主要回收两部分内容:废弃的常量和不再使用的类型。如:一个字符串java
曾经进入常量池中,但是当前系统又没有任何一个字符串对象的值是java
。但判定一个常量相对简单,而要判定一个类型是否属于”不再被使用的类“就比较苛刻了。需要满足以下三个条件:
- 该类所有的实例都已经被回收,也就是
java
堆中不存在该类以其任何派生子类的实例。 - 加载该类的类加载器已经被回收。
- 该类对应的
java.lang.Class
对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。
垃圾回收算法
从如何判定对象消亡的角度,可以分为引用计数式垃圾收集(Reference Counting GC
)和追踪式垃圾收集(Tracing GC
),也被称为直接垃圾收集和间接垃圾收集。
分代收集理论
弱分代假说:绝大多数对象都是朝生夕灭的
强分代假说:熬过越多次垃圾收集过程的对象就越难以消亡。
上面两种假说奠定了垃圾收集器一致的设计原则:收集器应该将java
堆划分出不同的区域,然后将回收对象依据其年龄(年龄指熬过垃圾收集过程的次数)分配到不同的区域之中存储。
根据设计原则才分出:Minor GC、Major GC、Full GC
这样的回收欸行了的划分。
针对不同区域的安排与里面存储对象存亡特征相匹配的垃圾收集算法发展出了:标记-复制算法;标记-清除算法;标记-整理算法
但此时存在一个问题,就是不同区域之间存在跨区引用的问题,所以为了解决这个问题,衍生出了第三条法则
跨代引用假说:跨代引用相对于通带引用来说仅占极少数。
**为什么跨代引用仅占极少数呢?**按照前两条假说的逻辑推论:存在互相引用关系的两个对象,是应该倾向于同时生存或者同时消亡的。如果说某个新生代对象在跨代引用老年代中的对象,因为老年代中的对象难以消亡,那么新生代中的对象也会在垃圾收集中多次存活,那么很快就会晋升到老年代中,跨代引用也就消除了。
依据跨代引用假说,我们不应该为了少量的跨代引用而去扫描整个老年代,只需要在新生代上建立一个全局的数据结构(记忆集,Remembered Set),这个接口把老年代划分为若干个小块,标识出老年代的哪一块内存会存在跨代引用。此后发生Minor GC
时,只有包含了跨代引用的小块内存里的对象才会被加入到GCRoots
进行扫描。
优缺点:需要在对象改变引用关系时维护记录数据的正确性,增加一些运行时的开销,但不会去整个扫描老年代了
标记-清除算法
算法分为标记和清除两个阶段:首先要标记出所有需要回收的对象,在标记完成之后,统一回收掉所有被标记的对象,也可以反过来,标记存活的对象,统一回收所有未被标记的对象。标记的过程就是对象是否属于垃圾的判定过程。
缺点:
- 执行效率不稳定,如果大量对象需要被回收,这时必须进行大量的标记和清除工作,导致标记和清除两个过程的执行效率随对象的增长而降低。
- 内存碎片化问题,标记、清除之后会产生大量不连续的内存碎片,空间碎片太多会导致以后程序运行过程中需要分配较大对象时而无法找到足够的连续内存,进而再一次触发垃圾回收动作。
标记-复制算法
为了解决标记-清除算法面对大量可回收对象时执行效率低的问题,提出了标记-复制算法:它将可用内存按容量划分为大小相等的两块,每次只用其中的一块。当这一块的内存用完了,就将还存活的对象复制到另一块上,然后再把已使用的内存空间一次清理掉。如果内存中绝大多数对象是存活的,这种算法会产生大量的内存间复制的开销,而且可用内存降为了原来的一半。
现在商用的虚拟机大多数都优先才采用了这种收集算法去回收新生代,它们根据弱分代假说进行了改进:新生代中90%的对象熬不过第一轮收集,所有并不需要按照1:1
的比例来划分,它们将新生代划分为一块较大的Eden
空间和两块较小的Survivor
空间,每次分配内存只使用Eden
和其中一块Survivor
空间。发生垃圾收集时,将Eden
和Survivor
中仍然存活的对象一次性复制到另外一块Survivor
空间上,然后直接清理Eden
和那块Survivor
的空间。其中Eden占新生代的80%,Survivor占用10%,新生代中只有10%是没用到的,但是没有任何人能保证假说百分百成立,所以还会多出一个担保设计,如果说Survivor的10%空间不够用,那么就需要依赖老年代(大多数是老年代)进行分配担保(类似银行带块的担保人一样),多出来的对象会通过分配担保机制直接进入老年代。
标记-整理算法
标记-整理算法是针对标记-清除算法的改进,如名称一样,是将清除的步骤改为了整理+清除,整理会将所有存活的对象都想内存空间一端进行移动,而后直接清理掉边界以外的内存。
但是如果在老年代中每次回收都会有大量对象存活的区域,移动存活对象并更新所有引用对象的地方,这将会变成一个极其负重的操作,而这种对象移动操作必须全程暂停用户应用程序才能进行,这种暂停用户线程的行为被称为 Stop The World
HotSpot
的算法实现细节
根节点枚举
所有收集器在根节点枚举这一步骤时都是必须暂停用户线程的,所以也会面临着Stop The World的问题。现在可达性分析算法耗时最长的查找引用链的过程已经可以做到与用户线程一起并发,但根节点枚举还是必须在一个能保证一致性的快照中才可以进行。
一致性快照:类似冻结一段时间,在这个时间内根节点集合的对象引用关系不发生变化(如果一直找一直变,那可达性分析根本完成不了)
目前主流的虚拟机使用的都是准确式垃圾收集,当用户线程停下来的时候,并不需要一个不漏的检查所有执行上下文和全局的引用位置,虚拟机应当是有办法直接得到哪些地方存放着对象引用的。在HotSpot
虚拟机中的解决办法是通过一组称为OopMap
的数据结构来达到这个目的。一旦类加载动作完成的时候,HotSpot
就会把对象内什么偏移量上是什么类型的数据计算出来,在即时编译过程中,也会在特定的位置记录下栈里和寄存器里哪些位置是引用。
安全点
HotSpot
中会在特定位置记录了哪些位置是引用,这个特定的位置即为安全点(Safepoint
)。安全点的设定决定了用户程序执行时并非在代码指令流中任意位置都能停顿下来进行垃圾回收,而是强制要求必须执行到达安全点之后才能暂停。
安全点的位置选定是以是否具有让程序长时间执行的特征为标准进行选定的。
**如何在垃圾收集发生时让所有线程都跑到最近的安全点?**有以下两种解决方案:
抢先式中断:不需要线程的执行代码主动配合,在垃圾收集发生时,系统首先把所有用户线程全部中断,如果发现有用户不在安全点上,就恢复它,让它一会在重新中断,直到跑到安全点上。(现在几乎没有虚拟机采用这种方式)
主动式抢断:当垃圾收集需要中断线程的时候,不直接对线程操作,仅仅是设置一个标志位,各个线程执行过程时会不停的主动去轮询这个标志,一旦发现中断标志位真时就自己在最近的安全点上主动挂起。轮询标志和安全点是重合的,另外还要加上所有创建对象和其他需要在java
堆上分配内存的地方,这是为了检查是否将要发生垃圾收集,避免没有足够内存分配新对象。
安全区域
安全区域是为了解决用户程序处于睡眠(sleep
)和阻塞(Blocked
)状态的设计。因为此时线程无法响应虚拟机的中断请求(没有分配处理资源),不能再走到安全点进行挂起。
安全区域是指能够确保在某一段代码片段中,引用关系不会发生变化,所有在这个区域中任意地方开始垃圾收集都是安全的。可以看作是被拉伸的安全点。
**当用户线程执行到安全区域里面的代码时,首先会标识自己已经进入到了安全区域,那么虚拟机发生垃圾回收时就不需要管这些已在安全区域中的线程了。**当线程将要离开安全区域时,他要检查虚拟机是否已经完成了根节点枚举,如果完成了,就当什么事没发生,继续执行;否则会等待收到离开安全区域的信号为止。
记忆集与卡表
记忆集(Remembered Set):解决跨代引用发生对老年代进行全量扫描的数据结构(也就是只对老年代进行部分GC
)。用于记录从非收集区域指向收集区域的指针集合的抽象数据结构(如:老年代指向新生代),可以使用数组实现,数组中记录所有被引用的对象,但这种方式维护成本相当高,而虚拟机只需要判断某一块非收集区域是否存在有指向收集区域的指针就可以了,所以**记忆集中可以选择更为粗狂的记录粒度。**如:
- 字长精度:每个记录精确到一个机器字长,该字包含跨代指针。
- 对象精度:每个记录精确到一个对象,该对象里有字段含有跨代指针。
- 卡精度:每个记录精确到一块内存区域,该区域有对象含有跨代指针。
卡表:即卡精度,也是目前最常用的记忆集实现形式。
卡表最简单的实现形式:字节数组;字节数组CARD_TABLE
的每一个元素都对应标识内存区域中一块特定大小的内存块,这个内存块被称为卡页。HotSpot
中的卡页事2的9次幂,即512字节。一个卡页的内存中通常包含多个对象,只要卡页中有一个或多个对象存在跨代指针,就将卡表的数组元素标识位1,称为变脏,否则为0。在垃圾收集发生时,只要帅选出变脏的元素,就能轻易得出哪些卡页内存在跨代指针,把他们加入GC Roots
中一并扫描。
写屏障
主要是为了解决维护卡表变脏、何时变脏的设计。
**何时变脏?**有其他分代区域中对象引用了本区域对象时,变脏。
**如何变脏?**或者时如何在对象赋值的那一刻去更新卡表?在HotSpot
中通过写屏障来维护卡表状态。
注意:在垃圾收集器中的写屏障和读屏障与内存屏障是不同的
写屏障相当于实现了在引用对象赋值时的AOP
操作,简单来说就是在赋值前后通过写屏障进行卡表的更新。
并发可达性分析
句话说为什么必须在一个能保证一致性的快照上次啊能进行对象图的遍历?
白色:对象尚未被垃圾收集器扫描,代表不可达
灰色:标识对象已经被扫描过了,但这个对象上还有未被扫描的白色元素
黑色:已经被垃圾收集器扫描,且存活下来,如果其他引用指向了黑色,则无需再次扫描,黑色不能不经过灰色指向白色
当且仅当满足以下两个条件时,会产生对象消失问题:
- 赋值器插入了一条或多条从黑色对象到白色对象的新引用。
- 赋值器删除了全部从灰色对象到白色对象的直接或间接引用。