垃圾收集机制与内存分配策略

727 阅读15分钟

Java 语言与其他编程语言有一个非常突出的特点,自动化内存管理机制。而这种机制离不开高效率的垃圾收集器(Garbage Collection)与合理的内存分配策略,这也是本篇文章将要描述的两个核心点。

引一句周志明老师对 Java 中的内存管理机制的描述:

Java 与 C++ 之间有一堵有内存动态分配和垃圾收集技术所围成的「高墙」,墙外面的人想进去,墙里面的人却想出来。

各有各的优势,没有谁会替代谁,只是应用在不同的场合下,谁更适合而已。

可达性分析算法

Java 中使用「可达性分析算法」来判定堆中的垃圾,但是很多其他的编程语言都采用「引用计数算法」判断对象是否依然存活。例如,Python,C++ 以及一些游戏脚本语言就采用的「引用计数算法」来判定对象的存活与否。

引用计数算法:给每一个引用对象增加一个计数器,每当有一个地方引用了该对象,就使该对象的计数器加一,每当一个引用失效时就使该计数器减一。当进行垃圾判定的时候,如果某个对象的计数器为零即说明了该对象无人引用,是垃圾。

这种算法设计简单,效率高,但 Java 里为什么没有采用呢?

主要是引用计数算法存在一个很致命的问题,循环引用。我们看一段代码:

public class A {
    private B bRef;

    public B getbRef() {
        return bRef;
    }

    public void setbRef(B bRef) {
        this.bRef = bRef;
    }
}
public class B {
    private A aRef;

    public A getaRef() {
        return aRef;
    }

    public void setaRef(A aRef) {
        this.aRef = aRef;
    }
}

产生循环引用:

public static void main(String[] args){
    A obj1 = new A();
    B obj2 = new B();
    obj1.setbRef(obj2);
    obj2.setaRef(obj1);
    
    obj1 = null;
    obj2 = null;
}

他们的内存布局如下:

image

依照引用计数算法,栈中 obj1 对堆中 A 的对象有一个引用,因此计数器增一,obj2 对堆中 B 的对象有一个引用,计数器增一。然后这两个对象中的字段又互相引用了,各自的计数器增一。

然后我们让 obj1 和 obj2 分别失去对堆中的引用,按照常理来说,堆中的这两个对象已经无用了,应该被回收内存。但是你会发现,采用引用计数算法的程序语言不会回收这两个对象的内存空间,因为它们内部互相引用,计数器都不为零。

这就是「循环引用」问题,引用计数算法是无法辨别堆中的这两个对象已经无用了,所以程序中如果大量互相引用的代码,收集器将无法回收这部分无用的垃圾,即产生内存泄露问题。

但是,如果上述逻辑由 Java 语言实现,运行结果会告诉你,GC 回收了这部分垃圾。看看 GC 日志:

image

粗糙点来说,原先堆中的两个对象加上堆中一些其他对象总共占用了 2302K 内存空间,经过 GC 后,显然这两个对象所占的内存空间被释放了。

既然如此,那么 Java 采用的「可达性分析算法」是如何避免这一类问题的呢?

可达性分析算法:从「GC Roots」为起始点,遍历引用链,所有能够直接或者间接被「GC Roots」引用的对象都判定为存活,其他所有对象都将在 GC 工作时被回收。

那么这些根结点(GC Roots)的如何选择将直接决定了 GC 收集效率的高低。Java 中,规定以下的对象可以作为 GC Roots:

  • 虚拟机栈中引用的对象
  • 方法区中类属性引用的对象
  • 方法区常量引用的对象
  • 本地方法栈中 Native 方法引用的对象

整体上来看,这几种对象都是随时可能被使用的,不能轻易释放,或者说,这些对象的存活性极高,所以它们关联着的对象都不能被回收内存。

HotSpot 中可达性算法的实现

可达性分析的第一步就是枚举出所有的根结点(GC Roots),然后才能去遍历标记所有不可达对象。而实际上,HotSpot 的实现并没有按序枚举所有的虚拟机栈,方法区等区域进行根结点查找,而是使用了 OopMap 这种数据结构来实现枚举操作的。

堆中的每个对象在自己的类型信息中都保存有一个 OopMap 结构,记录了对象内引用类型的偏移量,也就是说,通过该对象可以得到该对象内部引用的所有其他对象的引用。

对于虚拟机栈来说,编译器会在每个方法的某些特殊位置使用 OopMap 记录当前时刻栈中哪些位置存放有引用。

于是 GC 在进行可达性分析的时候,无需遍历所有的栈和方法区,只需要遍历一下各个线程当前的 OopMap 即可完成根结点枚举操作,接着递归标记可达对象就行了。

理解了 HotSpot 是如何枚举根结点的,那么对于安全点这个概念就很好理解了,所有生成 OopMap 更新的位置就叫做安全点。当系统发起 GC 请求的时候,需要中断所有线程的活动,而并不是线程的任何状态下都适合 GC 的,必须在停下来之前完成 OopMap 的更新,这样会方便 GC 枚举跟结点。

所以,我们说线程收到中断请求的时候,需要「跑」到最近的安全点才能停下,这是因为安全点的位置会完成 OopMap 的更新,以保证各个位置的对象引用关系不再改变。(你想啊,GC 根据 OopMap 进行根结点枚举,离上一次 OopMap 你已经做了一大堆事情了,改变了栈上很多对象的引用关系,难道你在停下来被 GC 之前不应该把你所做的这些操作记录下来吗?不然 GC 哪知道哪些对象已经不用了,哪些对象你又重新引用了?)

那安全区域又是一个什么样的概念呢?

安全区域是指,一段代码的执行不会更改引用关系,这段代码所处的范围可以理解为一个区域,某个线程在这个区域中执行的时候,只要标志自己进入了安全区域,就不用理会系统发起的 GC 请求而可以继续运行。

程序离开安全区域之前,会检查系统是否已经完成了 GC 过程,如果没有则等待,否则「走」出安全区域,继续执行后续指令。

安全区域实际上是安全点的一个扩展,安全区域中运行的线程可以与 GC 垃圾收集线程并发工作,这是它最大的一个特点。

四大引用

Java 里的引用本质上类似于 C 语言中的指针,变量中的值是内存中另一块的地址,而并非实际的数据。Java 中有四种引用,它们各自有不同的生命范围。

  • 强引用,类似于 String s = new String(); 这类引用,s 就是一种强引用,只要 s 通过这种方式强引用堆中对象,GC 永远都不能回收被引用的对象的内存
  • 软引用,用于描述某些还有用但并非必必需的对象,某次 GC 操作后,如果内存还是不足以用于当前分配,也就是即将发生内存溢出,那么将回收所有软引用所占用的内存空间
  • 弱引用,用于描述一些非必需的对象引用,当垃圾收集器工作时,不论当前内存空间是否充足,都会回收这一部分内存空间
  • 虚引用,又称幽灵引用,这是一种最弱的引用,即便 GC 没有工作,我也无法拿到这类引用指向的对象了

除了强引用,其他的三类引用实际中很少使用,关于它们的测试代码,将随着本篇文章一起脱管在我的 GitHub 上,感兴趣的可以去 fork 回去运行一下,此处不再赘述。

垃圾收集算法

垃圾收集算法的实现是很复杂的,并且不同平台的虚拟机也有着不同的实现,但是单看收集算法本身而言,还是相对容易理解的。

标记-清除算法

标记清除算法实现思路包含两个阶段,第一个阶段,根据可达性分析算法标记所有不可达的「垃圾」,第二阶段,直接释放这些对象所占用的内存空间。

image

但是,它的缺点也很明显,做一次清除操作至少要遍历两次堆,一次用于标记,一次用于清除。并且整个堆内存会存在大量的内存碎片,一旦遇到大对象,将无法提供连续的内存空间而不得不提前触发一次 Full GC。

复制算法

复制算法将内存划分为两份大小相等的块,每次只使用其中的一块,当系统发起 GC 收集动作时,将当前块中依然存活的对象全部复制到另一块中,并整块的释放当前块所占内存空间。

image

这种算法不需要挨个去遍历清除,整体上释放内存,相对而言,效率是提高了,但是需要浪费一半的内存空间,有点浪费。

根据 IBM 公司的研究表明,「新生代」中的对象往往都是「朝生夕死」的,也就是说,我们完全没有必要舍掉一半的内存用于转移 GC 后存活的对象,因为活着的对象很少。

主流的商业虚拟机都采用复制算法对新生代进行垃圾收集,但是却将内存划分三个块,一块较大的 Eden 区和两块较小的 Survivor 区。

image

Eden 和 From 区域用于分配新生代对象的内存空间,当发生 Minor GC 的时候,虚拟机会将 Eden 和 From 中所有存活的对象全部移动到 To 区域并释放 Eden 和 From 的内存空间。

这样不仅解决了效率问题,也解决了空间浪费的问题,但是存在的问题是,如果不巧,某次 Minor GC 后,活着的对象很多,To 区放不下怎么办?

虚拟机的做法是,将这些对象往老年代晋升,具体的后文详细介绍。

标记-整理算法

标记整理算法一般用在老年代,它在标记清除算法的基础上,增加了一个步骤用于对将所有存活着的对象往一端移动以解决内存碎片问题。这种算法适用于老年代的垃圾回收,因为老年代的对象存活性高,每次只需要移动很少的次数即能完成垃圾的清理。

image

垃圾收集器

从可达性分析算法判定哪些对象不可达,标记为「垃圾」,到回收算法实现内存的释放操作,这些都是理论,而垃圾收集器才是这些算法的实际实现。虚拟机中使用不同的垃圾收集器收集不同分代中的「垃圾」,每种垃圾收集器都具有各自的特点,也适用于不同的场合,需要适时组合使用。但并不是任意的两个收集器都能组合工作的:

image

可以看到,新生代主要有三款收集器,老年代也有三款收集器,G1(Garbage First)是一款号称能一统所有分代的收集器,当然还不成熟。

收集器很多,本文限于篇幅不可能每一个都详细的介绍,只能简单的描述一下各个收集器的特点和优劣之处。

  • Serial:新生代的单线程垃圾收集器,适用于单 CPU,待收集内存不大的场景下,速度快高效率,是客户端模式下虚拟机首选的新生代收集器
  • ParNew:是 Serial 收集器的多线程版本,适用于多 CPU 多线程下的垃圾收集,是服务端虚拟机的首选收集器
  • Parallel Sacenge:类似于 ParNew,但却是一个注重吞吐量的收集器,可以显式指定收集器达到什么层次的吞吐量
  • Serial Old:Serial 的老年代版本,采用的标记整理算法收集垃圾
  • Parallel Old:Parallel 的老年代版本
  • CMS:这是一款基于标记清除算法收集新生代的收集器,主要特点是,低停顿时间,容易产生浮动垃圾

关于垃圾收集器的细节内容,很多,文章中不可能描述清楚,大家可以参阅相关书籍及论文进行学习。

内存分配策略

Java 对象的内存都分配在堆中,准确来说,新生的对象都分配在新生代的 Eden 区中,如果 Eden 区域不足以存放一些对象的时候,系统将发起一次 Minor GC 清除并复制依然存活的对象到 Survivor 区,一旦 Survivor 区域不够存放,将通过内存担保机制将这些对象移入老年代。下面我们用代码具体看一看:

//VM:-XX:+PrintGCDetails -Xms10M -Xmx10M -Xmn5M
//限制了 10M 的堆内存,其中新生代和老年代分别占 5M
byte[] buffer = new byte[2 * 1024 * 1024];

image

新生代收集器默认 Eden 与 Survivor 的比例为是 8:1。这里我们看到新生代已使用空间 4032K,其中一部分是我们两兆的字节数组,其余的是一些系统的对象内存分配。

如果我们还要再分配一兆大小的内存空间呢?

//VM:-XX:+PrintGCDetails -Xms10M -Xmx10M -Xmn5M
byte[] buffer = new byte[2 * 1024 * 1024];
byte[] buffer1 = new byte[1 * 1024 * 1024];

image

虚拟机首先会检查一下新生代还能不能再分出一兆的内存空间出来,发现不能,于是发起 MinorGC 回收新生代堆空间,并将依然存活的对象复制到另一块 Survivor 空间(to),发现 512K 根本放不下 buffer,于是通过担保机制将 buffer 送入老年代,接着为 buffer1 分配一兆的内存空间。

接着,我们来看看这个担保机制是怎样的?

当实际发生 MinorGC 之前,虚拟机会查看老年代最大可用的连续空间是否能容纳新生代当前所有对象,因为它假设此次 MinorGC 后,新生代所有对象都能够存活下来。

如果条件能够成立,虚拟机认为此次 GC 毫无风险,将直接进行 MinorGC 对新生代进行垃圾回收,否则虚拟机会去查看 HandlePromotionFailure 参数设置的值是否允许「担保失败」。

如果允许,那么虚拟机将继续判断老年代最大可用连续空间是否大于历届晋升过来的新生代对象的平均大小

如果大于,那么虚拟机将冒着风险去进行 MinorGC 操作,否则将改为一次 FullGC。

取平均值的这种概率方法能大概率的保证安全担保,但也不乏担保失败的情况出现,一旦担保失败,虚拟机将发起 FullGC 对整个堆进行扫描回收。看一段代码:

//VM:-XX:+PrintGCDetails -Xms10M -Xmx10M -Xmn5M
//系统对象占用大约 2M 堆空间
byte[] buffer = new byte[1 * 1024 * 1024];
byte[] buffer1 = new byte[1 * 1024 * 1024];
//此时新生代所剩下的空间大约 512K
byte[] buffer2 = new byte[1 * 1024 * 1024];

当我们的 buffer 和 buffer1 分配进 Eden 区之后,新生代剩下不足一兆的内存空间,但是当我们分配一个一兆的字节数组时,系统查看老年代空间为 5M 能够容纳新生代所有存活对象(4M 左右),于是直接发起 MinorGC,回收了新生代中部分对象并尝试着将活着的对象复制到 to 区块中。

显然,to 区域不能容纳这么多对象,于是全部晋升进入老年代。

接着为 buffer2 分配 1M 内存空间在 Eden 区,GC 日志如下:

image

可以看到,buffer 和 buffer1 已经被担保进入老年代了,而 buffer2 则被分配在了新生代中。MinorGC 之前,新生代中大约 4M 的对象在 MinorGC 后只剩下 504K 了,其中 2M 左右的对象被担保进入了老年代,还有一部分则被回收了内存。

总结一下,本篇文章介绍了虚拟机判定垃圾的「可达性分析算法」,几种垃圾回收算法,还简单的描述不同垃圾收集器各自的特点及应用场景。最后我们通过一些代码了解了虚拟机是如何分配内存给新生对象的。

总的来说,这只能算做一篇科普类文章,帮助你了解相关概念,其他的相关深入细节之处,还有待深入学习。


文章中的所有代码、图片、文件都云存储在我的 GitHub 上:

(https://github.com/SingleYam/overview_java)

欢迎关注微信公众号:扑在代码上的高尔基,所有文章都将同步在公众号上。

image