Java 与 C++ 之间有一堵由内存动态分配和垃圾收集技术所围成的高墙 ---《深入理解Java虚拟机》
我们知道手动管理内存意味着自由、精细化地掌控,但是却极度依赖于开发人员的水平和细心程度。
如果使用完了忘记释放内存空间就会发生内存泄露,再如释放错了内存空间或者使用了悬垂指针则会发生无法预知的问题。
这时候 Java 带着 GC 来了(GC,Garbage Collection 垃圾收集,早于 Java 提出),将内存的管理交给 GC 来做,减轻了程序员编程的负担,提升了开发效率。
所以并不是用 Java 就不需要内存管理了,只是因为 GC 在替我们负重前行。
但是 GC 并不是那么万能的,不同场景适用不同的 GC 算法,需要设置不同的参数,所以我们不能就这样撒手不管了,只有深入地理解它才能用好它。
关于 GC 内容相信很多人都有所了解。我最早得知有关 GC 的知识是来自《深入理解Java虚拟机》,但是有关 GC 的内容单看这本书是不够的。
当时我以为我懂很多了,后来经过了一番教育之后才知道啥叫无知者无畏。
而且过了一段时间很多有关 GC 的内容都说不上来了,其实也有很多同学反映有些知识学了就忘,有些内容当时是理解的,过一段时间啥都不记得了。
大部分情况是因为这块内容在脑海中没有形成体系,没有搞懂前因后果,没有把一些知识串起来。
近期我整理了下 GC 相关的知识点,想由点及面展开有关 GC 的内容,顺带理一理自己的思路,所以输出了这篇文章,希望对你有所帮助。
有关 GC 的内容其实有很多,但是对于我们这种一般开发而言是不需要太深入的,所以我就挑选了一些我认为重要的整理出来,本来还有一些源码的我也删了,感觉没必要,重要的是在概念上理清。
本来还打算分析有关 JVM 的各垃圾回收器,但是文章太长了,所以分两篇写,下篇再发。
本篇整理的 GC 内容不限于 JVM 但大体上还是偏 JVM,如果讲具体的实现默认指的是 HotSpot。
正文
首先我们知道根据 「Java虚拟机规范」,Java 虚拟机运行时数据区分为程序计数器、虚拟机栈、本地方法栈、堆、方法区。
而程序计数器、虚拟机栈、本地方法栈这 3 个区域是线程私有的,会随线程消亡而自动回收,所以不需要管理。
因此垃圾收集只需要关注堆和方法区。
而方法区的回收,往往性价比较低,因为判断可以回收的条件比较苛刻。
比如类的卸载需要此类的所有实例都已经被回收,包括子类。然后需要加载的类加载器也被回收,对应的类对象没有被引用这才允许被回收。
就类加载器这一条来说,除非像特意设计过的 OSGI 等可以替换类加载器的场景,不然基本上回收不了。
而垃圾收集回报率高的是堆中内存的回收,因此我们重点关注堆的垃圾收集。
如何判断对象已成垃圾?
既然是垃圾收集,我们得先判断哪些对象是垃圾,然后再看看何时清理,如何清理。
常见的垃圾回收策略分为两种:一种是直接回收,即引用计数;另一种是间接回收,即追踪式回收(可达性分析)。
大家也都知道引用计数有个致命的缺陷-循环引用,所以 Java 用了可达性分析。
那为什么有明显缺陷的计数引用还是有很多语言采用了呢?
比如 CPython ,由此看来引用计数还是有点用的,所以咱们就先来盘一下引用计数。
引用计数
引用计数其实就是为每一个内存单元设置一个计数器,当被引用的时候计数器加一,当计数器减少为 0 的时候就意味着这个单元再也无法被引用了,所以可以立即释放内存。
如上图所示,云朵代表引用,此时对象 A 有 1 个引用,因此计数器的值为 1。
对象 B 有两个外部引用,所以计数器的值为 2,而对象 C 没有被引用,所以说明这个对象是垃圾,因此可以立即释放内存。
由此可以知晓引用计数需要占据额外的存储空间,如果本身的内存单元较小则计数器占用的空间就会变得明显。
其次引用计数的内存释放等于把这个开销平摊到应用的日常运行中,因为在计数为 0 的那一刻,就是释放的内存的时刻,这其实对于内存敏感的场景很适用。
如果是可达性分析的回收,那些成为垃圾的对象不会立马清除,需要等待下一次 GC 才会被清除。
引用计数相对而言概念比较简单,不过缺陷就是上面提到的循环引用。
那像 CPython 是如何解决循环引用的问题呢?
首先我们知道像整型、字符串内部是不会引用其他对象的,所以不存在循环引用的问题,因此使用引用计数并没有问题。
那像 List、dictionaries、instances 这类容器对象就有可能产生循环依赖的问题,因此 Python 在引用计数的基础之上又引入了标记-清除来做备份处理。
但是具体的做法又和传统的标记-清除不一样,它采取的是找不可达的对象,而不是可达的对象。
Python 使用双向链表来链接容器对象,当一个容器对象被创建时,它被插入到这个链表中,当它被删除时则移除。
然后在容器对象上还会添加一个字段 gc_refs,现在咱们再来看看是如何处理循环引用的:
- 对每个容器对象,将 gc_refs 设置为该对象的引用计数。
- 对每个容器对象,查找它所引用的容器对象,并减少找到的被引用的容器对象的 gc_refs 字段。
- 将此时 gc_refs 大于 0 的容器对象移动到不同的集合中,因为 gc_refs 大于 0 说明有对象外部引用它,因此不能释放这些对象。
- 然后找出 gc_refs 大于 0 的容器对象所引用的对象,它们也不能被清除。
- 最后剩下的对象说明仅由该链表中的对象引用,没有外部引用,所以是垃圾可以清除。
具体如下图示例,A 和 B 对象循环引用, C 对象引用了 D 对象。
最终循环引用的 A 和 B 都能被清理,但是天下没有免费的午餐,最大的开销之一是每个容器对象需要额外字段。
还有维护容器链表的开销。根据 pybench,这个开销占了大约 4% 的减速。
至此我们知晓了引用计数的优点就是实现简单,并且内存清理及时,缺点就是无法处理循环引用,不过可以结合标记-清除等方案来兜底,保证垃圾回收的完整性。
所以 Python 没有解决引用计数的循环引用问题,只是结合了非传统的标记-清除方案来兜底,算是曲线救国。
其实极端情况下引用计数也不会那么及时,你想假如现在有一个对象引用了另一个对象,而另一个对象又引用了另一个,依次引用下去。
那么当第一个对象要被回收的时候,就会引发连锁回收反应,对象很多的话这个延时就凸显出来了。
可达性分析
可达性分析其实就是利用标记-清除(mark-sweep),就是标记可达对象,清除不可达对象。至于用什么方式清,清了之后要不要整理这都是后话。
标记-清除具体的做法是定期或者内存不足时进行垃圾回收,从根引用(GC Roots)开始遍历扫描,将所有扫描到的对象标记为可达,然后将所有不可达的对象回收了。
所谓的根引用包括全局变量、栈上引用、寄存器上的等。
看到这里大家不知道是否有点感觉,我们会在内存不足的时候进行 GC,而内存不足时也是对象最多时,对象最多因此需要扫描标记的时间也长。
所以标记-清除等于把垃圾积累起来,然后再一次性清除,这样就会在垃圾回收时消耗大量资源,影响应用的正常运行。
所以才会有分代式垃圾回收和仅先标记根节点直达的对象再并发 tracing 的手段。
但这也只能减轻无法根除。
我认为这是标记-清除和引用计数的思想上最大的差别,一个攒着处理,一个把这种消耗平摊在应用的日常运行中。
而不论标记-清楚还是引用计数,其实都只关心引用类型,像一些整型啥的就不需要管。
所以 JVM 还需要判断栈上的数据是什么类型,这里又可以分为保守式 GC、半保守式 GC、和准确式 GC。
保守式 GC
保守式 GC 指的是 JVM 不会记录数据的类型,也就是无法区分内存上的某个位置的数据到底是引用类型还是非引用类型。
因此只能靠一些条件来猜测是否有指针指向。比如在栈上扫描的时候根据所在地址是否在 GC 堆的上下界之内,是否字节对齐等手段来判断这个是不是指向 GC 堆中的指针。
之所以称之为保守式 GC 是因为不符合猜测条件的肯定不是指向 GC 堆中的指针,因此那块内存没有被引用,而符合的却不一定是指针,所以是保守的猜测。
我再画一张图来解释一下,看了图之后应该就很清晰了。
前面我们知道可以根据指针指向地址来判断,比如是否字节对齐,是否在堆的范围之内,但是就有可能出现恰好有数值的值就是地址的值。
这就混乱了,所以就不能确定这是指针,只能保守认为就是指针。
因此肯定不会有误杀对象的情况。只会有对象已经死了,但是有疑似指针的存在指向它,误以为它还活着而放过了它的情况发生。
所以保守式 GC 会有放过一些“垃圾”,对内存不太友好。
并且因为疑似指针的情况,导致我们无法确认它是否是真的指针,所以也就无法移动对象,因为移动对象就需要改指针。
有一个方法就是加个中间层,也就是句柄层,引用会先指到句柄,然后再从句柄表找到实际对象。
所以直接引用不需要改变,如果要移动对象只需要修改句柄表即可。不过这样访问就多了一层,效率就变低了。