天天看点

深入理解JVM——内存回收与GC算法

目录

哪些内存需要回收

一、判断对象是否存活

什么时候回收

如何回收

1. 标记 - 清除算法

2.标记-复制算法

3. 标记 - 整理算法

GC的历史比Java久远,1960年诞生于MIT的Lisp是第一门真正使用内存动态分配和垃圾收集技术的语言。当Lisp还在胚胎时期时,人们就在思考GC需要完成的3件事情:

  • 哪些内存需要回收?
  • 什么时候回收?
  • 如何回收?

内存区域中的程序计数器、虚拟机栈、本地方法栈这3个区域随着线程而生,线程而灭;栈中的栈帧随着方法的进入和退出而有条不紊地执行着出栈和入栈的操作,每个栈帧中分配多少内存基本是在类结构确定下来时就已知的。在这几个区域不需要过多考虑回收的问题,因为方法结束或者线程结束时,内存自然就跟着回收了。

而Java堆和方法区则不同,一个接口中的多个实现类需要的内存可能不同,一个方法中的多个分支需要的内存也可能不一样,我们只有在程序处于运行期间时才能知道会创建哪些对象,这部分内存的分配和回收都是动态的,GC关注的也是这部分内存。

哪些内存需要回收

在堆中存放着 Java 中几乎所有对象的实例,那么已经"死去"(没有引用,不可能再被使用)的对象当然是需要回收的。

一、判断对象是否存活

1. 引用计数法

  • 原理:给对象添加一个引用计数器,每当有地方引用时计数器加 1,引用失效时减 1。当该对象引用为 0 时,判定对象失效
  • 优点:实现简单,判定效率高
  • 缺点:很难解决对象之间循环引用的问题

2. 可达性分析法

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

深入理解JVM——内存回收与GC算法

Java 中可作为 GC Roots 的对象:

  • 虚拟机栈(栈帧中的本地变量表)中引用的对象;
  • 方法区中类静态属性引用的对象;
  • 方法区中常量引用的对象;
  • 本地方法栈中 JNI(Native 方法)引用的对象。
  • 所有被同步锁(synchronized关键字) 持有的对象
  • Java虚拟机内部的引用, 如基本数据类型对应的Class对象, 一些常驻的异常对象(比如

    NullPointExcepiton、 OutOfMemoryError) 等, 还有系统类加载器。

什么时候回收

当 JVM 经过可达性分析法筛选出实效对象时,并不是马上清除,而是进行标记并判断是否回收:

  1. 判断对象是否覆盖了 finalize() 方法,任何一个对象的finalize()方法都只会被系统自动调用一次, 如果对象面临下一次回收, 它的finalize()方法不会被再次执行。
  2. 执行 F-Queue 队列中的 finalize() 方法

    由虚拟机自动建立一个优先级较低的线程去执行 F-Queue 中的 finalize() 方法,这里的执行只是触发这些方法并不保证会等待它执行完毕。如果 finalize() 方法作了耗时操作,虚拟机会停止执行并将该对象清除。 

  3. 对象销毁或重生

    在 finalize() 方法中,将 this 赋值给某一个引用,那么该对象就重生了。如果没有引用,该对象会被回收。

方法区的内存回收

Java 虚拟机规范中说不需要方法区实现垃圾收集,因为方法区中存放的都是一些生命周期较长的类信息、常量、静态变量。方法区就像是堆的老年代,每次垃圾回收只有少量垃圾被清除。方法区的垃圾收集主要回收两部分内容: 废弃的常量和不再使用的类型。

  • 废弃的常量:

    当前系统中没有任何对象引用常量池中的该常量,则是废弃常量

  • 废弃的类:

    该类所有实例都被回收;

    加载该类的 ClassLoader 已经被回收;

    该类对应的 Class 对象没有引用,也无法通过反射访问该类的方法。

如何回收

通过上文了解到垃圾收集、内存回收的主要区域是 Java 堆,JVM 回收的对象是那些没有引用的对象、常量、类等。要注意的是 JVM 筛选出需要清除的对象时并不是马上进行回收,而是进行标记并判断是否覆写 finalize() 方法,然后再依据一定规则进行 GC。

当前商业虚拟机的垃圾收集器, 大多数都遵循了“分代收集”(Generational Collection)的理论进行设计, 分代收集名为理论, 实质是一套符合大多数程序运行实际情况的经验法则, 它建立在两个分代假说之上:

1) 弱分代假说(Weak Generational Hypothesis) : 绝大多数对象都是朝生夕灭的。

2) 强分代假说(Strong Generational Hypothesis) : 熬过越多次垃圾收集过程的对象就越难以消亡。

分代收集并非只是简单划分一下内存区域那么容易, 它至少存在一个明显的困难: 对象不是孤立的, 对象之间会存在跨代引用。为了解决这个问题, 就需要对分代收集理论添加第三条经验则:

3) 跨代引用假说(Intergenerational Reference Hypothesis) : 跨代引用相对于同代引用来说仅占极少数。

  • 部分收集(Partial GC) : 指目标不是完整收集整个Java堆的垃圾收集, 其中又分为:
  • 新生代收集(Minor GC/Young GC) : 指目标只是新生代的垃圾收集。
  • 老年代收集(Major GC/Old GC) : 指目标只是老年代的垃圾收集。 目前只有CMS收集器会有单独收集老年代的行为。 
  • 混合收集(Mixed GC) : 指目标是收集整个新生代以及部分老年代的垃圾收集。 目前只有G1收集器会有这种行为。
  • 整堆收集(Full GC) : 收集整个Java堆和方法区的垃圾收集。

1. 标记 - 清除算法

最基础的收集算法是"标记 - 清除"算法,之所以说它是最基础的是因为它逻辑简单、使用简便,而且后续的收集算法大多基于这种算法的不足而优化的。如它的名字一样, 算法分为“标记”和“清除”两个阶段: 首先标记出所有需要回收的对象, 在标记完成后, 统一回收掉所有被标记的对象, 也可以反过来, 标记存活的对象, 统一回收所有未被标记的对象。

它的主要缺点有两个:

  • 第一个是执行效率不稳定, 如果Java堆中包含大量对 象, 而且其中大部分是需要被回收的, 这时必须进行大量标记和清除的动作, 导致标记和清除两个过程的执行效率都随对象数量增长而降低;
  • 第二个是内存空间的碎片化问题, 标记、 清除之后会产生大量不连续的内存碎片, 空间碎片太多可能会导致当以后在程序运行过程中需要分配较大对象时无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。

标记-清除算法的执行过程如图所示

深入理解JVM——内存回收与GC算法

2.标记-复制算法

标记-复制算法常被简称为复制算法。 为了解决标记-清除算法面对大量可回收对象时执行效率低

的问题, 1969年Fenichel提出了一种称为“半区复制”(Semispace Copying) 的垃圾收集算法, 它将可用内存按容量划分为大小相等的两块, 每次只使用其中的一块。 当这一块的内存用完了, 就将还存活着的对象复制到另外一块上面, 然后再把已使用过的内存空间一次清理掉。 如果内存中多数对象都是存活的, 这种算法将会产生大量的内存间复制的开销, 但对于多数对象都是可回收的情况, 算法需要复制的就是占少数的存活对象, 而且每次都是针对整个半区进行内存回收, 分配内存时也就不用考虑有空间碎片的复杂情况, 只要移动堆顶指针, 按顺序分配即可。

深入理解JVM——内存回收与GC算法
  • 优点:简答高效,内存相对整齐
  • 缺点:

    1.将内存分为一半,代价略高。

    2.如果对象存活率高,需要复制的对象比较多,产生效率问题。

  • 优化:

    在新生代中,由于大量的对象都是"朝生夕死",也就是说一次垃圾收集后存活对象较少,因此我们可以把内存划分为三块:Eden、Survior1、Survior2,大小比例为 8:1:1。分配内存时只使用 Eden + Survior1,当这里的内存将满时,JVM 会出发一次 MinorGC,清除掉废弃对象,并将存活对象复制到另一块 Survior2 中。那么接下来就使用 Eden + Survior2 进行内存分配。

    通过这种方式只需浪费 10% 的内存空间即可实现复制清除算法,同时避免了内存碎片的问题。

3. 标记 - 整理算法

深入理解JVM——内存回收与GC算法
  • 原理:标记过程与 "标记 - 清除" 算法相同,但后续不是直接对可回收对象进行清理,而是让所有存活对象都向一端移动,然后直接清理掉一端边界外的内存。

 标记-清除算法与标记-整理算法的本质差异在于前者是一种非移动式的回收算法,而后者是移动

式的。 是否移动回收后的存活对象是一项优缺点并存的风险决策:

 如果移动存活对象, 尤其是在老年代这种每次回收都有大量对象存活区域, 移动存活对象并更新

所有引用这些对象的地方将会是一种极为负重的操作, 而且这种对象移动操作必须全程暂停用户应用程序才能进行, 这就更加让使用者不得不小心翼翼地权衡其弊端了, 像这样的停顿被最初的虚拟机设计者形象地描述为“Stop The World”。

但如果跟标记-清除算法那样完全不考虑移动和整理存活对象的话, 弥散于堆中的存活对象导致的

空间碎片化问题就只能依赖更为复杂的内存分配器和内存访问器来解决。 譬如通过“分区空闲分配链表”来解决内存分配问题(计算机硬盘存储大文件就不要求物理连续的磁盘空间, 能够在碎片化的硬盘上存储和访问就是通过硬盘分区表实现的)。内存的访问是用户程序最频繁的操作,甚至都没有之一,假如在这个环节上增加了额外的负担,势必会直接影响应用程序的吞吐量。