Java 中如何判断对象是否是垃圾?不同垃圾回收方法有何区别?

Sherwin.Wei Lv8

Java 中如何判断对象是否是垃圾?不同垃圾回收方法有何区别?

回答重点

主要通过以下两种方式来实现:

1. 引用计数法(Reference Counting):

  • 每个对象维护一个引用计数器,引用计数增加时,计数器加 1,减少时,计数器减 1。当引用计数器为 0 时,说明该对象不再被引用,可以被回收。
  • 优点:实现简单,实时性好。
  • 缺点:无法处理循环引用的问题,两个对象互相引用时,引用计数器永远不会为 0。

2. 可达性分析算法(Reachability Analysis):

  • Java 中垃圾回收主要采用可达性分析算法。通过从一组称为 “GC Roots” 的对象出发,遍历所有可达的对象,凡是无法通过 GC Roots 到达的对象,均被视为垃圾。
  • 优点:能够解决循环引用的问题。
  • 缺点:需要消耗一定的资源进行标记。

扩展知识

GC Roots 的来源

  • 线程栈中的引用:每个线程栈中的局部变量、参数等。
  • 类的静态变量:被类加载器加载后的类会存储在方法区,类的静态变量可以作为 GC Roots。
  • JNI 全局引用:通过 JNI 创建的全局引用可以作为 GC Roots。

引用计数和可达性分析进一步分析

引用计数

引用计数其实就是为每一个内存单元设置一个计数器,当被引用的时候计数器加一,当计数器减少为 0 的时候就意味着这个单元再也无法被引用了,所以可以立即释放内存。

image.png

如上图所示,云朵代表引用,此时对象 A 有 1 个引用,因此计数器的值为 1。

对象 B 有两个外部引用,所以计数器的值为 2,而对象 C 没有被引用,所以说明这个对象是垃圾,因此可以立即释放内存。

由此可以知晓引用计数需要占据额外的存储空间,如果本身的内存单元较小则计数器占用的空间就会变得明显。

其次引用计数的内存释放等于把这个开销平摊到应用的日常运行中,因为在计数为 0 的那一刻,就是释放的内存的时刻,这其实对于内存敏感的场景很适用。

如果是可达性分析的回收,那些成为垃圾的对象不会立马清除,需要等待下一次 GC 才会被清除。

引用计数相对而言概念比较简单,不过缺陷就是上面提到的循环引用。

可达性分析

可达性分析其实就是利用标记-清除(mark-sweep),就是标记可达对象,清除不可达对象。至于用什么方式清,清了之后要不要整理这都是后话。

标记-清除具体的做法是定期或者内存不足时进行垃圾回收,从根引用(GC Roots)开始遍历扫描,将所有扫描到的对象标记为可达,然后将所有不可达的对象回收了。

所谓的根引用包括全局变量、栈上引用、寄存器上的等。

image.png

看到这里大家不知道是否有点感觉,我们会在内存不足的时候进行 GC,而内存不足时也是对象最多时,对象最多因此需要扫描标记的时间也长。

所以标记-清除等于把垃圾积累起来,然后再一次性清除,这样就会在垃圾回收时消耗大量资源,影响应用的正常运行。

所以才会有分代式垃圾回收和仅先标记根节点直达的对象再并发 tracing 的手段。

但这也只能减轻无法根除。

我认为这是标记-清除和引用计数的思想上最大的差别,一个攒着处理,一个把这种消耗平摊在应用的日常运行中。

CPython 使用引用计数,它是如何解决循环引用的问题呢?

首先我们知道像整型、字符串内部是不会引用其他对象的,所以不存在循环引用的问题,因此使用引用计数并没有问题。

那像 List、dictionaries、instances 这类容器对象就有可能产生循环依赖的问题,因此 Python 在引用计数的基础之上又引入了标记-清除来做备份处理。

但是具体的做法又和传统的标记-清除不一样,它采取的是找不可达的对象,而不是可达的对象。

Python 使用双向链表来链接容器对象,当一个容器对象被创建时,它被插入到这个链表中,当它被删除时则移除。

然后在容器对象上还会添加一个字段 gc_refs,现在咱们再来看看是如何处理循环引用的:

  1. 对每个容器对象,将 gc_refs 设置为该对象的引用计数。
  2. 对每个容器对象,查找它所引用的容器对象,并减少找到的被引用的容器对象的 gc_refs 字段。
  3. 将此时 gc_refs 大于 0 的容器对象移动到不同的集合中,因为 gc_refs 大于 0 说明有对象外部引用它,因此不能释放这些对象。
  4. 然后找出 gc_refs 大于 0 的容器对象所引用的对象,它们也不能被清除。
  5. 最后剩下的对象说明仅由该链表中的对象引用,没有外部引用,所以是垃圾可以清除。

具体如下图示例,A 和 B 对象循环引用, C 对象引用了 D 对象。

image.png

为了让图片更加清晰,我把步骤分开截图了,上图是 1-2 步骤,下图是 3-4 步骤。

image.png

最终循环引用的 A 和 B 都能被清理,但是天下没有免费的午餐,最大的开销之一是每个容器对象需要额外字段。

还有维护容器链表的开销。根据 pybench,这个开销占了大约 4% 的减速

至此我们知晓了引用计数的优点就是实现简单,并且内存清理及时,缺点就是无法处理循环引用,不过可以结合标记-清除等方案来兜底,保证垃圾回收的完整性。

所以 Python 没有解决引用计数的循环引用问题,只是结合了非传统的标记-清除方案来兜底,算是曲线救国。

Comments