深入理解 Java 虚拟机-03 垃圾收集

深入理解 Java 虚拟机-03 垃圾收集

垃圾收集(Garbage Collect)也就是常说的 GC,GC 这个概念不是 Java 独有的,在 Java 之前已经有其他语言有 GC 的理念了,1960年诞生于麻省理工学院的 Lisp 是第一门开始使用内存动态分配和垃圾收集技术的语言。

垃圾收集需要考虑以下这些事情:

  • 回收什么区域的内存
  • 如何判断内存可回收
  • 怎么回收

回收什么区域的内存

Java 的堆区域被分为程序计数器、虚拟机栈、堆、本地方法栈和方法区几个部分,其中程序计数器、虚拟机栈和本地方法栈随线程的生命周期运转,每一个栈帧中分配的内存在类结构确定下来时基本已知,所以这几个区域的内存分配和回收都具备确定性,当方法执行结束或线程结束时,内存自然就跟着释放了。

堆和方法区的内存回收则不确定性很大,一个方法执行不同的分支所需要的内存可能都不一样,只有运行期才知道程序究竟会创建多少个对象,垃圾收集器所关注的也是这部分内存该如何管理。

如何判断内存可回收

判断堆内存可回收其实就是认为这片内存所代表的对象已经不会再访问到了,方法区则是主要回收废弃的常量和不再使用的类型。和堆中判断对象可回收相比,方法区判断常量不再被引用及类型不再使用则比较复杂,进行垃圾收集的"性价比"通常也比较低。

判断一个类型不再使用其实是很复杂的,需要满足以下条件:

  • 该类的所有实例都已回收
  • 该类的类加载器也被回收,实际上这点除非是精心设计的场景(OSGI、JSP),通常很难达成
  • 该类对应的 Class 对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法

引用

狭义的引用就是如果 refenence 类型的数据存储的值代表另一块内存的地址,那么该 refenence 就是某个对象的引用。

在这样的定义下,对象只有引用和无引用两种,描述不了其它的情形:譬如我们希望描述一种对象,它在内存足够时就可以存活,内存不足时可以回收,于是 JDK1.2 对引用进行了扩充,将引用分为强引用(Strongly Re-ference)、软引用(Soft Reference)、弱引用(Weak Reference)和虚引用(Phantom Reference)4种,这4种引用强度依次逐渐减弱。

强引用即我们日常中最普遍的引用复制,诸如 Object a = new Object() 这样,只要引用关系还在,无论任何情况下垃圾回收器都不能回收。

软引用是用来描述一些还有用,但非必须的对象。只被软引用关联着的对象,在系统将要发生内存溢出异常前,会把这些对象列进回收范围之中进行第二次回收。

弱引用也是用来描述那些非必须对象,但是它的强度比软引用更弱一些,被弱引用关联的对象只能生存到下一次垃圾收集发生为止。

虚引用也称为"幽灵引用"或者"幻影引用",它是最弱的一种引用关系。一个对象是否有虚引用的 存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。为一个对象设置虚引用关联的唯一目的只是为了能在这个对象被收集器回收时收到一个系统通知。

引用计数法

引用计数法的逻辑非常简单,判定效率也很高,也有一些语言的应用案例,实现就是:每个对象设置一个字段存放被引用的个数,当这个数为0时说明对象不再被使用了。

但是引用计数法无法解决循环依赖的问题,当 A-> B && B->A 时 AB 的引用都至少为1,那么永远无法被回收了,即使它们不会再被访问。

可达性分析法

当前主流的商用语言的内存管理子系统都是通过可达性分析(Reachability Analysis)算法来判定对象是否存活的,基本思路就是通过 一系列称为"GC Roots"的根对象作为起始节点集,从这些节点开始,根据引用关系向下搜索,搜索过程所走过的路径称为"引用链"(Reference Chain),如果某个对象到GC Roots间没有任何引用链相连, 或者用图论的话来说就是从 GC Roots 到这个对象不可达时,则证明此对象是不可能再被使用的。

Java 中可固定作为 GC Roots 的对象包括以下几个部分:

  • 虚拟机栈(栈帧中的本地变量表)中引用的对象,譬如各个线程被调用的方法堆栈中使用到的参数、局部变量、临时变量等。
  • 方法区中类静态属性引用的对象,譬如Java类的引用类型静态变量。
  • 方法区中常量引用的对象,譬如字符串常量池(String Table)里的引用。
  • 虚拟机内部的引用,如基本数据类型对应的 Class 对象,一些常驻的异常对象(比如 NullPointExcepiton、OutOfMemoryError)等,还有系统类加载器。
  • ...

除了这些固定的 GC Roots 集合以外,根据用户所选用的垃圾收集器以及当前回收的内存区域不同,还可以有其他对象"临时性"地加入,共同构成完整 GC Roots 集合。例如在回收某区域时,这个区域的对象被另一区域的对象引用,那么另一区域的这个对象也需要假如到 GC Roots 中。

不可达的对象也不是立刻死亡的,当它被第一次标记时,如果重写了 finilize 方法且 finilize 方法是没有被调用,会被放到一个低优先级的调度队列中执行 finilize() 方法,如果在执行过程中与 GC Roots 中的对象建立了连接,那在第二次标记时它将被移出"即将回收"的集合。任何一个对象的 finalize() 方法都只会被系统自动调用一次,如果对象面临下一次回收,它的 finalize() 方法不会被再次执行。finalize() 并不能等价于 C 语言中的析构函数,由于优先级低且为了避免执行缓慢而导致内存不能回收,甚至不能保证这个方法被执行完成,推荐直接忘掉这个方法。

怎么回收

如何判定对象消亡的角度出发,垃圾收集算法可以划分为"引用计数式垃圾收集"(Reference Counting GC)和"追踪式垃圾收集"(Tracing GC)两大类,这两类也常被称作"直接垃圾收集"和"间接垃圾收集",主流 Java 虚拟机中都是第二类。

分代收集理论

Java 开发应该经常听到诸如新生代、老年代等词语,这些词语来自于分代收集理论。

分代收集理论中有两个重要假说:

  • 弱分代假说(Weak Generational Hypothesis):绝大多数对象都是朝生夕灭的
  • 强分代假说(Strong Generational Hypothesis):熬过越多次垃圾收集过程的对象就越难以消亡

这两个分代假说共同奠定了多款常用的垃圾收集器的一致的设计原则:收集器应该将 Java 堆划分出不同的区域,然后将回收对象依据其年龄(年龄即对象熬过垃圾收集过程的次数)分配到不同的区域之中存储。

堆内存分代之后,垃圾收集器才可以每次只回收其中某一个或者某些部分的区域 ------因而才有了"Minor GC""Major GC""Full GC"这样的回收类型的划分;也才能够针对不同的区域安排与里面存储对象存亡特征相匹配的垃圾收集算法,例如在新生代中可以关注如何保留少量的存活对象,而在老年代关注如何回收少量的死亡对象。------因而发展出了"标记-复制算法""标记-清除算法""标记-整理算法"等针对性的垃圾收集算法。

现在的商用 Java 虚拟机里,设计者一般至少会把 Java 堆划分为新生代 (Young Generation)老年代(Old Generation)两个区域。垃圾收集可以分为部分收集(Partial GC)和整堆收集(Full GC)两大类,部分收集由可以分为Young GC(新生代)、Old GC(老年代) 和 Mix GC(混合收集,G1收集器)。

需要特别考虑的是,对象不是孤立的,对象之间会存在跨代引用。基于跨代引用理论:跨代引用相对于同代引用来说仅占极 少数。(因为如果老年代引用了新生代对象,那么新生代对象大概率也要进入老年代,那么就消除了跨代引用),虚拟机在新生代上建立一个全局的数据结构(该结构被称 为"记忆集",Remembered Set),这个结构把老年代划分成若干小块,标识出老年代的哪一块内存会存在跨代引用。此后当发生Young GC时,只有包含了跨代引用的小块内存里的对象才会被加入到 GC Roots 进行扫描。虽然这种方法需要在对象改变引用关系(如将自己或者某个属性赋值)时维护记录数 据的正确性,会增加一些运行时的开销,但比起收集时扫描整个老年代来说仍然是划算的。

标记-清除(其他算法的基础)

最基础的垃圾收集算法是"标记-清除"(Mark-Sweep)算法,算法分为"标记"和"清除"两个阶段,标记所有待回收的对象,清除所有标记的对象,或者相反操作。

它的主要缺点有两个:第一个是执行效率不稳定,如果 Java 堆中包含大量对象,而且其中大部分是需要被回收的,这时必须进行大量标记和清除的动作,导致标记和清除两个过 程的执行效率都随对象数量增长而降低;第二个是内存空间的碎片化问题,标记、清除之后会产生大量不连续的内存碎片。优点则是没有任何的空间冗余。

(图示来自:深入理解Java虚拟机第3版)

标记-复制(新生代)

1969 年 Fenichel 提出了一种称为"半区复制"(Semispace Copying)的垃圾收集算法,它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。

该算法分配内存时不用考虑 空间碎片的复杂情况,只要移动堆顶指针,按顺序分配即可。这样实现简单,运行高效,不过其缺陷也显而易见,这种复制回收算法的代价是将可用内存缩小为了原来的一半。

现在的商用 Java 虚拟机大多都优先采用了这种收集算法去回收新生代,不过由于新生代大多数对象朝生夕死,并不需要按照1∶1的比例来划分新生代的内存空间。HotSpot 虚拟机的 Serial、ParNew 等新生代收集器把新生代分为一块较大的 Eden 空间和两块较小的 Survivor空间(Appel 式回收),默认空间是 8:1:1,每次分配内存只使用 Eden 和其中一块 Survivor。不过任何人都没有办法百分百保证每次回收都只有不多于10%的对象存活,因此 Appel 式回收还有一个充当罕见情况的"逃生门"的安 全设计,当 Survivor 空间不足以容纳一次 Young GC 之后存活的对象时,就需要依赖其他内存区域(实际上大多就是老年代)进行分配担保(Handle Promotion)。

标记-整理(老年代)

标记-复制算法在对象存活率较高时就要进行较多的复制操作,效率将会降低。更关键的是,如果不想浪费 50% 的空间,就需要有额外的空间进行分配担保,以应对被使用的内存中所有对象都100%存活的极端情况,所以在老年代一般不能直接选用这种算法。

针对老年代对象的存亡特征,1974 年 Edward Lueders 提出了另外一种有针对性的"标记-整 理"(Mark-Compact)算法,其中的标记过程仍然与"标记-清除"算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向内存空间一端移动,然后直接清理掉边界以外的内存

在老年代这种每次回收都有大量对象存活区域,移动存活对象并更新所有引用这些对象的地方将会是一种极为负重的操作,而且这种对象移动操作必须全程暂停用户应用程序才能进行。

但如果跟标记-清除算法那样完全不考虑移动和整理存活对象的话,弥散于堆中的存活对象导致的空间碎片化问题就只能依赖更为复杂的内存分配器和内存访问器来解决。

基于以上两点,是否移动对象都存在弊端,移动则内存回收时会更复杂,不移动则内存分配时会更复杂。移动对象暂停时间较长,不移动对象停顿时间会更短,甚至可以不需要停顿,但从吞吐量上看,不移动则可能由于内存碎片提前触发下一次的 GC,长久来看依然是影响吞吐量的。

基于标记-清除算法的 CMS 收集器平时多数时间都采用标记-清除算法,暂时容忍内存碎片的存在,直到内存空间的碎片化程度已经大到影响对象分配时,再采用标记-整理算法收集一次,以获得规整的内存空间。

相关推荐
六义义3 小时前
java基础十二
java·数据结构·算法
ʚB҉L҉A҉C҉K҉.҉基҉德҉^҉大3 小时前
自动化机器学习(AutoML)库TPOT使用指南
jvm·数据库·python
毕设源码-钟学长3 小时前
【开题答辩全过程】以 基于SpringBoot的智能书城推荐系统的设计与实现为例,包含答辩的问题和答案
java·spring boot·后端
莫问前路漫漫4 小时前
WinMerge v2.16.41 中文绿色版深度解析:文件对比与合并的全能工具
java·开发语言·python·jdk·ai编程
九皇叔叔4 小时前
【03】SpringBoot3 MybatisPlus BaseMapper 源码分析
java·开发语言·mybatis·mybatis plus
挖矿大亨4 小时前
c++中的函数模版
java·c++·算法
dyyx1114 小时前
使用Scikit-learn进行机器学习模型评估
jvm·数据库·python
weixin_499771555 小时前
使用Seaborn绘制统计图形:更美更简单
jvm·数据库·python
a程序小傲5 小时前
得物Java面试被问:RocketMQ的消息轨迹追踪实现
java·linux·spring·面试·职场和发展·rocketmq·java-rocketmq