0%

第3章:垃圾收集器与内存分配策略

对象已死吗

引用计数法

很多教科书判断对象是否存活的算法是这样的:给对象中添加一个引用计数器,每当一个地方引用它时,计数器值就加1;当引用失效时,计数器值就减1;任何时刻计数器为0的对象就是不可能再被使用的。客观地说,引用计数法的实现简单,但它很难解决对象之间相互循环引用的问题,所以,主流实现中,并不使用这种方式。

可达性分析

在主流实现中,都是通过可达性分析来判定对象是否存活。这个算法的基本思路就是通过一系列称为“GC Roots”的对象作为起始点,从这些节点开始向下搜索,搜索走过的路径称为“引用链”,当一个对象到GC Roots 没有任何引用链相连时,则证明此对象是不可用的。在Java语言中,可以作为 GC Roots 的对象包括下面几种:

  • 虚拟机栈(栈帧中的本地变量表)中引用的对象。
  • 本地方法栈中JNI引用的对象。
  • 方法区中类静态属性引用的对象。
  • 方法区中常量引用的对象。

再谈引用

JDK 1.2 以前,对象只有引用和被引用两种状态,未能描述那些“食之无味,弃之可惜”的对象,我们希望描述这样一类对象:当内存还足够时,则能保留在内存之中;如果内存空间在GC后还是非常紧张,则可以抛弃这些对象。因此,JDK 1.2以后对引用的概念进行扩充,分为:

  • 强引用。代码中普遍存在的,类似于 Object obj = new Object() ,只要强引用还在,垃圾收集器永远不会回收被引用的对象。

  • 软引用。是用来描述一些还有用,但是并非必需的对象,在系统将要发生内存溢出的异常之前,将会把这些对象列进回收范围之中进行第二次回收,如果这次回收还没有足够的内存,才会抛出内存溢出异常。

  • 弱引用。是用来描述非必需对象的,但是强度比软引用更弱一些,被弱引用关联的对象只能生存到下一次GC发生之前。当GC工作时,无论当前内存是否足够,都会回收掉只被弱引用关联的对象。

  • 虚引用。是最弱的一种引用关系,不会对对象的生存时间构成影响,也无法通过虚引用获得对象实例。为对象设置虚引用的唯一目的就是能在这个对象被收集器回收时收到一个系统通知。

生存还是死亡

即使在可达性分析算法中不可达的对象,也并非“非死不可”的。要真正宣告一个对象的死亡,至少要经历两次标记过程:如果对象在可达性分析后发现没有与GC Roots 相连接的引用链,那么它会被第一次标记,并进行筛选,筛选的条件是此对象是否有必要执行 finalize() 方法,当对象没有覆写 finalize() 方法,或者 finalize() 方法已经被虚拟机调用过,则都没有必要执行;

如果对象判定有必要执行 finalize() 方法,则对象会被放入F-Queue队列中,稍后由低优先级的线程去触发 执行finalize() 方法,但不会承诺等待它运行结束。这样做的原因是防止 finalize 方法过于缓慢或者死循环。finalize() 方法是对象套多死亡命运的最后一次机会,因为稍后GC将对F-Queue中的对象进行第二次标记,如果对象要在finalize 中拯救自己————只要重新与某个引用链上的任意一个对象关联即可,那么在第二次标记的时候它将被移除出“即将回收”的集合;如果这时候对象还没逃脱,那它就会真的被回收了。

说明这一过程的示例代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
public class FinalizeEscapeGC{
public static FinalizeEscapeGC SAVE_HOOK = null;

public void isAlive(){
System.out.println("yes,i am still alive");
}

@Override
protected void finalize() throws Throwable{
super.finalize();
System.out.println("finalize method executed");
FinalizeEscapeGc. SAVE_HOOK = this;
}


public static void main(String[] args) throws Throwable{
SAVE_HOOK = new FinalizeEscapeGC();

//对象第一次成功拯救自己
SAVE_HOOK = null;
System.gc();
//因为finalize方法优先级很低,所以暂停0.5秒等待它
Thread.sleep(500);
if(SAVE_HOOK != null){
SAVE_HOOK.isAlive();
}else{
System.out.println("no,i am dead");
}


//下面这段代码与上面的完全相同,但是这次自救却失败了
SAVE_HOOK = null;
System.gc();
//因为finalize方法优先级很低,所以暂停0.5秒等待它
Thread.sleep(500);
if(SAVE_HOOK != null){
SAVE_HOOK.isAlive();
}else{
System.out.println("no,i am dead");
}
}
}

运行结果:

finalize method executed
yes,i am still alive
no,i am dead

从结果可以看出,SAVE_HOOK 对象的 finalize 方法确实被GC收集器触发过,并且收集前成功逃脱。但两段完全一样的代码,第二次却被回收了,因为finalize 方法只会被自动调用一次,因此第二次不执行了,所以自救失败。

有些教材上认为 finalize 中适合做“关闭外部资源”之类的工作,在了解以上机制之后,可以认为这完全是一种自我安慰,finalize 能做的,try-finally 或其他方法能做得更好,更及时,所以完全可以忘记这个方法存在(它也仅仅只是Java诞生时讨好C/C++程序员的一个妥协)。

垃圾收集算法

标记-清除算法

最基础的收集算法是 标记-清除(Mark-Sweep) 算法,它分为“标记”和“清除”两个阶段:首先标记出需要回收的对象,在标记完成后统一回收被标记的对象。它的主要不足有两个:一是效率问题,标记和清除效率都不高;二是清除后会产生大量不连续的内存碎片。

复制算法

为了解决效率问题,复制算法出现了。它将可用内存分为大小相等的两块,每次只使用其中一块,当这块内存用完了,就将还存活的对象复制到另一块上,然后把当前内存空间一次清理掉。这样每次都是对整个半区回收,内存分配也不会存在碎片。缺点是可用内存缩小了一半,代价太高。

现在商业虚拟机采用这种方式回收新生代。一般将内存分为较大的 Eden 和两块较小的 Survivor 空间(HotSpot中 Eden 与 Survivor大小比例为 8:1),每次使用Eden 和其中一块 Survivor,当内存回收时,将Eden和Survivor中还存活的对象一次性复制到另一块Survivor,最后清理Eden和刚才用过的Survivor空间,因此,每次只有10%的内存“被浪费”。当然这是基于统计新生代的对象生命周期都很短,差不多只有10%的对象会存活,此外,如果超出10%的存活对象,Survivor空间不够时,需要依赖老年代的空间提供担保(Survivor空间存不下的对象直接通过担保机制进入老年代),这是该分配方式能够运行下去的保障。

复制收集算法在对象存活率较高时要进行较多的复制操作,效率将会变低,更关键的是,如果不想浪费50%的空间,就需要有额外的空间进行担保,防止对象100%存活的极端情况。

标记-整理算法

根据老年代特点,有人提出另外一种标记-整理(Mark-Compact)算法,标记过程仍与“标记-清除”算法一样,但后续不是直接清理可回收对象,而是所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存。标记整理算法的示意图如下:

标记-整理算法

关于怎么个移动法,书上没有说明,这里个人做个推测:

1、整个老年代的内存划分为 A 和 B 两个区域,区域 A 的大小是所有还存活对象的总大小,区域 B 大小是是剩余空间。
2、按顺序在 A 中找到第一个还存活对象,将它移动到 A 空间的最前端,接下来找第二个还存活的对象,将其移动到第一个对象的后面
3、以此类推,即使存活的对象在 B 中也一样顺序复制到A中,直至最后填满A空间,之后清除 B 空间。

分代收集算法

商业虚拟机都采用“分代收集”,根据各个年代的特点分别采用适当的收集算法:

  • 在新生代中,每次垃圾收集时都会发现有大批对象死去,只有少量存活(前面提到的统计数据,大部分时候只有 10% 存活),那就选用复制算法。
  • 而老年代中因对象存活率高,没有额外空间对它进行担保,就必须使用“标记-清理”或者“标记-整理”算法进行回收。

CMS 收集器

这是自己添加的这个标题,书中的结构并不是如此,因为 Android ART 虚拟机使用的也是CMS收集方式,所以这里特意抽出来理解,方便理解Android的虚拟机。 CMS (Concurrent Mark Sweep) 收集器,并发的“标记-清除”方式实现的收集器。它是一种以获取最短回收停顿时间为目标的收集器。它的整个过程分为4个步骤:

  • 初始标记
  • 并发标记
  • 重新标记
  • 并发清除

其中,初始标记、重新标记 这两个步骤仍然需要“Stop The World”,初始标记仅仅只是标记一下 GC Roots 能直接关联到的对象,速度很快;并发标记就是进行 GC Roots Tracing 的过程(根据初始标记过程中标识出来的直接关联对象,并发标记存活的对象);重新标记阶段则是为了修正并发标记期间因用户程序继续运行而导致标记产生变动的那一部分对象的标记记录;

由于整个过程中好事最长的 并发标记 和 并发清除 过程收集器线程都可以与用户线程一起工作,所以,从总体上来讲,CMS收集器的内存回收过程是与用户线程一起并发执行的。

CMS 是优秀的收集器,但是它也有3个明显的缺点:

  • CMS 收集器对 CPU 资源非常敏感。与其他并发设计的程序一样,CMS收集器对 CPU 同样敏感,它虽然不会导致用户线程停顿,但是会因为占用了一部分线程而导致应用变慢。

  • CMS 无法处理浮动垃圾(Floating Garbage,个人认为简而言之就是GC过程中用户线程产生的垃圾,类比于你妈妈在打扫房间卫生的时候,你还在继续撕的纸)。

  • 还有最后一个缺点,就是CMS本质还是“标记-清除”算法实现的,这种收集方式在结束后会产生大量的碎片化的空间。

内存分配与回收策略

以下是几条最普遍的内存分配规则,可以通过代码去验证这些规则:

对象优先在 Eden 上分配

大多数情况下,对象在新生代 Eden 区中分配,当 Eden 区没有足够空间时,虚拟机将发起一次 Minor GC (新生代GC)。可以采用以下方式验证:

通过 -Xms20M、-Xmx20M、-Xmn10M 三个参数限制Java 虚拟机堆大小 20MB,不可扩展,并且其中 10M 分配给新生代,剩下的 10MB 分配给老年代,-XX:SurvivorRatio=8决定新生代中 Edun 区与 一个 Survivor区的比例是 8:1,即 Eden 大小 8192K,两个 survivor 空间分别 1024K,所以新生代总共可用空间是 9216K (Eden 区 + 1个Survivor区)。
执行 testAllocation()尝试分配 3个 2M 的大小和1个 4MB 大小的对象。
代码执行完成后,可以发现 新生代Eden区被占用了 4M,而老年代被占用了 6M。
解释:分配前3个对象时,没有压力,均分配在Eden区域,当分配第4个对象时,内存已经不够了(可用空间是 9216K,而 2M*3 + 4M = 10),因此触发一次Minor GC,此时发现这3个对象都存活,并且 survivor 区间不够容纳存活的对象,因此这3个对象被直接通过担保机制提前转移到老年代中;这次GC结束后,第4个对象就可以顺利分配到Eden空间中。

新生代GC(Minor GC):发生在新生代的垃圾收集,因为Java对象大多朝生夕死,所以Minor GC非常频繁,一般回收速度也比较快
老年代GC(Major GC/Full GC):指的是老年代的GC,对于许多收集器而言,这时候都会伴随至少一次的 Minor GC,老年代的GC一般会比 Minor GC 慢10倍以上。

大对象直接进入老年代

所谓大对象是指需要大量连续内存空间的Java对象,最典型的就是很长的字符串以及数组。当然,比大对象更糟糕的是遇到一群朝生夕死的“短命大对象”,这种应当避免。经常出现大对象容易导致内存还有不少空间就提前触发垃圾收集器以获得足够的连续空间来安置它们。

长期存活的对象进入老年代

如果对象在Eden出生并经历过第一次Minor GC 后仍然存活并且能被 Survivor 容纳的话,将被移动到Survivor 中,并且年龄设置为 1,此后,每熬过一次 Minor GC ,对象的年龄就加1,当达到某个阈值(系统一般默认 15),就会被晋升到老年代中。

动态对象年龄判定

为了更好地适应不同程序内存状况,并不总是要求对象必须达到某个年龄才进入老年代。如果 Survivor 中同龄对象达到某个阈值(一般是一半)时,大于或者等于这个年龄的对象就会移动到老年代。

空间分配担保

发生Minor GC 前,虚拟机会检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果条件成立,则 Minor GC 可以确保是安全的,如果不成立,则会判断是否允许担保失败,如果允许担保失败,则最终可能会触发一次Full GC。虽然绕了一圈还是有可能触发 Full GC,但是这种情况相对较少。

谢谢你的鼓励