深度揭秘垃圾回收底层,这次让你彻底弄懂她(上)

简介: 深度揭秘垃圾回收底层,这次让你彻底弄懂她(上)

Java 与 C++ 之间有一堵由内存动态分配和垃圾收集技术所围成的高墙 ---《深入理解Java虚拟机》

我们知道手动管理内存意味着自由、精细化地掌控,但是却极度依赖于开发人员的水平和细心程度。

如果使用完了忘记释放内存空间就会发生内存泄露,再如释放错了内存空间或者使用了悬垂指针则会发生无法预知的问题。

这时候 Java 带着 GC 来了(GC,Garbage Collection 垃圾收集,早于 Java 提出),将内存的管理交给 GC 来做,减轻了程序员编程的负担,提升了开发效率。

所以并不是用 Java 就不需要内存管理了,只是因为 GC 在替我们负重前行。

但是 GC 并不是那么万能的,不同场景适用不同的 GC 算法,需要设置不同的参数,所以我们不能就这样撒手不管了,只有深入地理解它才能用好它。

关于 GC 内容相信很多人都有所了解。我最早得知有关 GC 的知识是来自《深入理解Java虚拟机》,但是有关 GC 的内容单看这本书是不够的。

当时我以为我懂很多了,后来经过了一番教育之后才知道啥叫无知者无畏。


image.png

而且过了一段时间很多有关 GC 的内容都说不上来了,其实也有很多同学反映有些知识学了就忘,有些内容当时是理解的,过一段时间啥都不记得了。

大部分情况是因为这块内容在脑海中没有形成体系,没有搞懂前因后果,没有把一些知识串起来

近期我整理了下 GC 相关的知识点,想由点及面展开有关 GC 的内容,顺带理一理自己的思路,所以输出了这篇文章,希望对你有所帮助。

有关 GC 的内容其实有很多,但是对于我们这种一般开发而言是不需要太深入的,所以我就挑选了一些我认为重要的整理出来,本来还有一些源码的我也删了,感觉没必要,重要的是在概念上理清。

本来还打算分析有关 JVM 的各垃圾回收器,但是文章太长了,所以分两篇写,下篇再发。

本篇整理的 GC 内容不限于 JVM 但大体上还是偏 JVM,如果讲具体的实现默认指的是  HotSpot。


正文


首先我们知道根据 「Java虚拟机规范」,Java 虚拟机运行时数据区分为程序计数器、虚拟机栈、本地方法栈、堆、方法区。

image.png

而程序计数器、虚拟机栈、本地方法栈这 3 个区域是线程私有的,会随线程消亡而自动回收,所以不需要管理。

因此垃圾收集只需要关注堆和方法区。

而方法区的回收,往往性价比较低,因为判断可以回收的条件比较苛刻。

比如类的卸载需要此类的所有实例都已经被回收,包括子类。然后需要加载的类加载器也被回收,对应的类对象没有被引用这才允许被回收。

就类加载器这一条来说,除非像特意设计过的 OSGI 等可以替换类加载器的场景,不然基本上回收不了。

而垃圾收集回报率高的是堆中内存的回收,因此我们重点关注堆的垃圾收集


如何判断对象已成垃圾?


既然是垃圾收集,我们得先判断哪些对象是垃圾,然后再看看何时清理,如何清理。

常见的垃圾回收策略分为两种:一种是直接回收,即引用计数;另一种是间接回收,即追踪式回收(可达性分析)。

大家也都知道引用计数有个致命的缺陷-循环引用,所以 Java 用了可达性分析。

那为什么有明显缺陷的计数引用还是有很多语言采用了呢?

比如 CPython ,由此看来引用计数还是有点用的,所以咱们就先来盘一下引用计数。


引用计数


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

image.png

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

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

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

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

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

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


那像 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

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

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

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

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


image.png

image.png

其实极端情况下引用计数也不会那么及时,你想假如现在有一个对象引用了另一个对象,而另一个对象又引用了另一个,依次引用下去。

那么当第一个对象要被回收的时候,就会引发连锁回收反应,对象很多的话这个延时就凸显出来了。

image.png


可达性分析


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

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

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

image.png

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

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

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

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

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

而不论标记-清楚还是引用计数,其实都只关心引用类型,像一些整型啥的就不需要管。

所以 JVM 还需要判断栈上的数据是什么类型,这里又可以分为保守式 GC、半保守式 GC、和准确式 GC。


保守式 GC

保守式 GC 指的是 JVM 不会记录数据的类型,也就是无法区分内存上的某个位置的数据到底是引用类型还是非引用类型。

因此只能靠一些条件来猜测是否有指针指向。比如在栈上扫描的时候根据所在地址是否在 GC 堆的上下界之内,是否字节对齐等手段来判断这个是不是指向 GC 堆中的指针。

之所以称之为保守式 GC 是因为不符合猜测条件的肯定不是指向 GC 堆中的指针,因此那块内存没有被引用,而符合的却不一定是指针,所以是保守的猜测。

我再画一张图来解释一下,看了图之后应该就很清晰了。

image.png

前面我们知道可以根据指针指向地址来判断,比如是否字节对齐,是否在堆的范围之内,但是就有可能出现恰好有数值的值就是地址的值。

这就混乱了,所以就不能确定这是指针,只能保守认为就是指针。

因此肯定不会有误杀对象的情况。只会有对象已经死了,但是有疑似指针的存在指向它,误以为它还活着而放过了它的情况发生。

所以保守式 GC 会有放过一些“垃圾”,对内存不太友好。

并且因为疑似指针的情况,导致我们无法确认它是否是真的指针,所以也就无法移动对象,因为移动对象就需要改指针

有一个方法就是加个中间层,也就是句柄层,引用会先指到句柄,然后再从句柄表找到实际对象。

所以直接引用不需要改变,如果要移动对象只需要修改句柄表即可。不过这样访问就多了一层,效率就变低了。







相关文章
|
27天前
|
存储 算法 Java
Java内存管理深度剖析与优化策略####
本文深入探讨了Java虚拟机(JVM)的内存管理机制,重点分析了堆内存的分配策略、垃圾回收算法以及如何通过调优提升应用性能。通过案例驱动的方式,揭示了常见内存泄漏的根源与解决策略,旨在为开发者提供实用的内存管理技巧,确保应用程序既高效又稳定地运行。 ####
|
19天前
|
存储 算法 Java
Java内存管理深度解析####
本文深入探讨了Java虚拟机(JVM)中的内存分配与垃圾回收机制,揭示了其高效管理内存的奥秘。文章首先概述了JVM内存模型,随后详细阐述了堆、栈、方法区等关键区域的作用及管理策略。在垃圾回收部分,重点介绍了标记-清除、复制算法、标记-整理等多种回收算法的工作原理及其适用场景,并通过实际案例分析了不同GC策略对应用性能的影响。对于开发者而言,理解这些原理有助于编写出更加高效、稳定的Java应用程序。 ####
|
26天前
|
监控 算法 Java
Java虚拟机垃圾回收机制深度剖析与优化策略####
【10月更文挑战第21天】 本文旨在深入探讨Java虚拟机(JVM)中的垃圾回收机制,揭示其工作原理、常见算法及参数调优技巧。通过案例分析,展示如何根据应用特性调整GC策略,以提升Java应用的性能和稳定性,为开发者提供实战中的优化指南。 ####
40 5
|
1月前
|
缓存 算法 Java
本文聚焦于Java内存管理与调优,介绍Java内存模型、内存泄漏检测与预防、高效字符串拼接、数据结构优化及垃圾回收机制
在现代软件开发中,性能优化至关重要。本文聚焦于Java内存管理与调优,介绍Java内存模型、内存泄漏检测与预防、高效字符串拼接、数据结构优化及垃圾回收机制。通过调整垃圾回收器参数、优化堆大小与布局、使用对象池和缓存技术,开发者可显著提升应用性能和稳定性。
49 6
|
5月前
|
缓存 安全 算法
Java内存模型深度解析与实践应用
本文深入探讨Java内存模型(JMM)的核心原理,揭示其在并发编程中的关键作用。通过分析内存屏障、happens-before原则及线程间的通信机制,阐释了JMM如何确保跨线程操作的有序性和可见性。同时,结合实例代码,展示了在高并发场景下如何有效利用JMM进行优化,避免常见的并发问题,如数据竞争和内存泄漏。文章还讨论了JVM的垃圾回收机制,以及它对应用程序性能的影响,提供了针对性的调优建议。最后,总结了JMM的最佳实践,旨在帮助开发人员构建更高效、稳定的Java应用。
|
5月前
|
监控 算法 Java
深入理解Java虚拟机:垃圾收集机制的演变与最佳实践
【7月更文挑战第14天】本文将带领读者穿梭于JVM的心脏——垃圾收集器,探索其设计哲学、实现原理和性能调优。我们将从早期简单的收集算法出发,逐步深入到现代高效的垃圾收集策略,并分享一些实用的调优技巧,帮助开发者在编写和维护Java应用时做出明智的决策。
58 3
|
6月前
|
存储 算法 Java
性能优化:Java垃圾回收机制深度解析 - 让你的应用飞起来!
Java垃圾回收自动管理内存,防止泄漏,提升性能。GC分为标记-清除、复制、标记-整理和分代收集等算法。JVM内存分为堆、方法区等区域。常见垃圾回收器有Serial、Parallel、CMS和G1。调优涉及选择合适的GC、调整内存大小和使用参数。了解和优化GC能提升应用性能。
151 3
|
5月前
|
存储 算法 Java
Java内存管理深度解析
在Java的世界中,内存管理是一块基石,它支撑着整个应用程序的运行。本文将深入探讨Java的内存管理机制,包括堆、栈、方法区的概念及其在内存中的角色和作用。我们将通过实际案例和数据,分析Java如何自动进行内存分配和垃圾回收,以及这些操作对程序性能的影响。文章还将介绍一些常见的内存泄漏场景和避免策略,帮助开发者更好地理解并优化他们的Java应用。
105 0
|
7月前
|
存储 算法 Java
精华推荐 | 【JVM深层系列】「GC底层调优专题」一文带你彻底加强夯实底层原理之GC垃圾回收技术的分析指南(GC原理透析)
精华推荐 | 【JVM深层系列】「GC底层调优专题」一文带你彻底加强夯实底层原理之GC垃圾回收技术的分析指南(GC原理透析)
108 0
|
7月前
|
开发框架 算法 Java
C垃圾回收:原理与代码实践揭秘
C垃圾回收:原理与代码实践揭秘
60 0
下一篇
DataWorks