03-垃圾回收(1)
- 哪些内存需要回收?
- 什么时候回收?
- 如何回收?
1. HotSpot 对象#
1.1 对象的创建#
Java 是一门面向对象的编程语言,Java 程序运行过程中无时无刻都有对象被创建出来。在语言层面上,创建对象通常(例外:复制、反序列化)仅仅是一个 new 关键字而已,而在虚拟机中,对象(文中讨论的对象限于普通 Java 对象,不包括数组和 Class 对象等)的创建又是怎样一个过程呢?
(1)当 Java 虚拟机遇到一条字节码 new 指令时,首先将去检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已被加载、解析和初始化过。如果没有,那必须先执行相应的类加载过程,在后面的虚拟机类加载机制中将探讨这部分细节。
(2)在类加载检查通过后,接下来虚拟机将为新生对象分配内存。对象所需内存的大小在类加载完成后便可完全确定(如何确定将在 1.2 中介绍),为对象分配空间的任务实际上便等同于把一块确定大小的内存块从 Java 堆中划分出来。
- 假设 Java 堆中内存是绝对规整的,所有被使用过的内存都被放在一边,空闲的内存被放在另一边,中间放着一个指针作为分界点的指示器,那所分配内存就仅仅是把那个指针向空闲空间方向挪动一段与对象大小相等的距离,这种分配方式称为“指针碰撞”(Bump The Pointer)。
- 但如果 Java 堆中的内存并不是规整的,已被使用的内存和空闲的内存相互交错在一起,那就没有办法简单地进行指针碰撞了,虚拟机就必须维护一个列表,记录上哪些内存块是可用的,在分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的记录,这种分配方式称为“空闲列表”(Free List)。
选择哪种分配方式由 Java 堆是否规整决定,而 Java 堆是否规整又由所采用的垃圾收集器是否带有空间压缩整理(Compact)的能力决定。因此,当使用 Serial、ParNew 等带压缩整理过程的收集器时,系统采用的分配算法是指针碰撞,既简单又高效;而当使用 CMS 这种基于清除(Sweep)算法的收集器时,理论上就只能采用较为复杂的空闲列表来分配内存。
(3)除如何划分可用空间之外,还有另外一个需要考虑的问题:对象创建在虚拟机中是非常频繁的行为,即使仅仅修改一个指针所指向的位置,在并发情况下也并不是线程安全的,可能出现正在给对象 A 分配内存,指针还没来得及修改,对象 B 又同时使用了原来的指针来分配内存的情况。
- 一种是对分配内存空间的动作进行同步处理 —— 实际上虚拟机是采用 CAS 配上失败重试的方式保证更新操作的原子性;
- 另外一种是把内存分配的动作按照线程划分在不同的空间之中进行,即每个线程在 Java 堆中预先分配一小块内存,称为本地线程分配缓冲(Thread Local Allocation Buffer,TLAB),哪个线程要分配内存,就在哪个线程的本地缓冲区中分配,只有本地缓冲区用完了,分配新的缓存区时才需要同步锁定。虚拟机是否使用 TLAB,可以通过
-XX:+/-UseTLAB
参数来设定。
(4)内存分配完成之后,虚拟机必须将分配到的内存空间(但不包括对象头)都初始化为零值,如果使用了 TLAB 的话,这一项工作也可以提前至 TLAB 分配时顺便进行。这步操作保证了对象的实例字段在 Java 代码中可以不赋初始值就直接使用,使程序能访问到这些字段的数据类型所对应的零值。
(5)接下来,Java 虚拟机还要对对象进行必要的设置,例如这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码(实际上对象的哈希码会延后到真正调用 Object::hashCode()
方法时才计算)、对象的 GC 分代年龄等信息。这些信息存放在对象的对象头(Object Header)之中。根据虚拟机当前运行状态的不同,如是否启用偏向锁等,对象头会有不同的设置方式。
(6)在上面工作都完成之后,从虚拟机的视角来看,一个新的对象已经产生了。但是从 Java 程序的视角看来,对象创建才刚刚开始 —— 构造函数,即 Class 文件中的 <init>()
方法还没有执行,所有的字段都为默认的零值,对象需要的其他资源和状态信息也还没有按照预定的意图构造好。一般来说(由字节码流中 new 指令后面是否跟随 invokespecial 指令所决定,Java 编译器会在遇到 new 关键字的地方同时生成这两条字节码指令,但如果直接通过其他方式产生的则不一定如此),new 指令之后会接着执行 <init>()
方法,按照程序员的意愿对对象进行初始化,这样一个真正可用的对象才算完全被构造出来。
1.2 对象的内存布局#
在 HotSpot 虚拟机里,对象在堆内存中的存储布局可以划分为三个部分:对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)。
1.2.1 对象头#
HotSpot 虚拟机对象的对象头部分包括两类信息:① 对象自身的运行时数据;② 类型指针。
第一类是用于存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等,这部分数据的长度在 32 位和 64 位的虚拟机(未开启压缩指针)中分别为 32 个比特和 64 个比特,官方称它为“Mark Word”。
对象需要存储的运行时数据很多,其实已经超出了 32、64 位 Bitmap 结构所能记录的最大限度,但对象头里的信息是与对象自身定义的数据无关的额外存储成本,考虑到虚拟机的空间效率,Mark Word 被设计成一个有着动态定义的数据结构,以便在极小的空间内存储尽量多的数据,根据对象的状态复用自己的存储空间。
例如在 32 位的 HotSpot 虚拟机中,如对象未被同步锁锁定-01的状态下,Mark Word 的 32 个比特存储空间中的 25 个比特用于存储对象哈希码,4 个比特用于存储对象分代年龄,2 个比特用于存储锁标志位,1 个比特固定为 0,在其他状态(轻量级锁定-00、重量级锁定-10、GC 标记-11、可偏向-01)下对象的存储内容如表所示。
对象头的另外一部分是类型指针,即对象指向它的类型元数据的指针,Java 虚拟机通过这个指针来确定该对象是哪个类的实例。并不是所有的虚拟机实现都必须在对象数据上保留类型指针,换句话说,查找对象的元数据信息并不一定要经过对象本身,这点我们会在下一节具体讨论。
此外,如果对象是一个 Java 数组,那在对象头中还必须有一块用于记录数组长度的数据,因为虚拟机可以通过普通 Java 对象的元数据信息确定 Java 对象的大小,但是如果数组的长度是不确定的,将无法通过元数据中的信息推断出数组的大小。
1.2.2 实例数据#
实例数据部分是对象真正存储的有效信息,即我们在程序代码里面所定义的各种类型的字段内容,无论是从父类继承下来的,还是在子类中定义的字段都必须记录起来。这部分的存储顺序会受到虚拟机分配策略参数(-XX:FieldsAllocationStyle
参数)和字段在Java 源码中定义顺序的影响。HotSpot 虚拟机默认的分配顺序为 longs/doubles、ints、shorts/chars、bytes/booleans、oops(Ordinary Object Pointers,OOPs),从以上默认的分配策略中可以看到,相同宽度的字段总是被分配到一起存放,在满足这个前提条件的情况下,在父类中定义的变量会出现在子类之前。如果 HotSpot 虚拟机的 +XX:CompactFields
参数值为 true(默认就为 true),那子类之中较窄的变量也允许插入父类变量的空隙之中,以节省出一点点空间。
1.2.3 对齐填充#
这并不是必然存在的,也没有特别的含义,它仅仅起着占位符的作用。由于 HotSpot 虚拟机的自动内存管理系统要求对象起始地址必须是 8 字节的整数倍,换句话说就是任何对象的大小都必须是 8 字节的整数倍。对象头部分已经被精心设计成正好是 8 字节的倍数(1 倍或者 2 倍),因此,如果对象实例数据部分没有对齐的话,就需要通过对齐填充来补全。
1.3 对象的访问定位#
创建对象自然是为了后续使用该对象,我们的 Java 程序会通过栈上的 reference 数据来操作堆上的具体对象。由于 reference 类型在《Java 虚拟机规范》里面只规定了它是一个指向对象的引用,并没有定义这个引用应该通过什么方式去定位、访问到堆中对象的具体位置,所以对象访问方式也是由虚拟机实现而定的,主流的访问方式主要有使用「句柄」和「直接指针」两种:
- 如果使用「句柄」访问的话,Java 堆中将可能会划分出一块内存来作为句柄池,reference 中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自具体的地址信息,其结构如图所示。
- 如果使用「直接指针」访问的话,Java 堆中对象的内存布局就必须考虑如何放置访问类型数据的相关信息,reference 中存储的直接就是对象地址,如果只是访问对象本身的话,就不需要多一次间接访问的开销,如图所示。
这两种对象访问方式各有优势,使用句柄来访问的最大好处就是 reference 中存储的是稳定句柄地址,在对象被移动(垃圾收集时移动对象是非常普遍的行为)时只会改变句柄中的实例数据指针,而 reference 本身不需要被修改。
使用直接指针来访问最大的好处就是速度更快,它节省了一次指针定位的时间开销,由于对象访问在 Java 中非常频繁,因此这类开销积少成多也是一项极为可观的执行成本,就本书讨论的主要虚拟机 HotSpot 而言,它主要使用第二种方式进行对象访问(有例外情况,如果使用了 Shenandoah 收集器的话也会有一次额外的转发),但从整个软件开发的范围来看,在各种语言、框架中使用句柄来访问的情况也十分常见。
2. 判断“对象已死”?#
在堆里面存放着 Java 世界中几乎所有的对象实例,垃圾收集器在对堆进行回收前,第一件事情就是要确定这些对象之中哪些还“存活”着,哪些已经“死去”(“死去”即不可能再被任何途径使用的对象)了。
2.1 引用计数算法#
很多教科书判断对象是否存活的算法是这样的:在对象中添加一个引用计数器,每当有一个地方引用它时,计数器值就加一;当引用失效时,计数器值就减一;任何时刻计数器为零的对象就是不可能再被使用的。笔者面试过很多应届生和一些有多年工作经验的开发人员,他们对于这个问题给予的都是这个答案。
客观地说,引用计数算法(Reference Counting)虽然占用了一些额外的内存空间来进行计数,但它的原理简单,判定效率也很高,在大多数情况下它都是一个不错的算法。
但是,在 Java 领域,至少主流的 Java 虚拟机里面都没有选用引用计数算法来管理内存,主要原因是,这个看似简单的算法有很多例外情况要考虑,必须要配合大量额外处理才能保证正确地工作,譬如单纯的引用计数就很难解决对象之间相互循环引用的问题。
对象 A 和 B 都有字段 instance,赋值令 objA.instance = objB 及 objB.instance = objA,除此之外,这两个对象再无任何引用,实际上这两个对象已经不可能再被访问,但是它们因为互相引用着对方,导致它们的引用计数都不为零,引用计数算法也就无法回收它们。
2.2 可达性分析算法#
当前主流的商用程序语言(Java、C#,上溯至前面提到的古老的 Lisp)的内存管理子系统,都是通过「可达性分析(Reachability Analysis)算法」来判定对象是否存活的。
这个算法的基本思路就是通过一系列称为“GC Roots”的根对象作为起始节点集,从这些节点开始,根据引用关系向下搜索,搜索过程所走过的路径称为“引用链”(Reference Chain),如果某个对象到 GC Roots 间没有任何引用链相连,或者用图论的话来说就是从 GC Roots 到这个对象不可达时,则证明此对象是不可能再被使用的。
在 Java 技术体系里面,固定可作为 GC Roots 的对象包括以下几种:
- 在虚拟机栈(栈帧中的本地变量表)中引用的对象,譬如各个线程被调用的方法堆栈中使用到的参数、局部变量、临时变量等。
- 在方法区中类静态属性引用的对象,譬如 Java 类的引用类型静态变量。
- 在方法区中常量引用的对象,譬如字符串常量池(String Table)里的引用。
- 在本地方法栈中 JNI(即通常所说的 Native 方法)引用的对象。
- Java 虚拟机内部的引用,如基本数据类型对应的 Class 对象,一些常驻的异常对象(比如 NullPointExcepiton、OutOfMemoryError)等,还有系统类加载器。
- 所有被同步锁(synchronized 关键字)持有的对象。
- 反映 Java 虚拟机内部情况的 JMXBean、JVMTI 中注册的回调、本地代码缓存等。
除了这些固定的 GC Roots 集合以外,根据用户所选用的垃圾收集器以及当前回收的内存区域不同,还可以有其他对象“临时性”地加入,共同构成完整 GC Roots 集合。譬如后文将会提到的分代收集和局部回收(Partial GC),如果只针对 Java 堆中某一块区域发起垃圾收集时(如最典型的只针对新生代的垃圾收集),必须考虑到内存区域是虚拟机自己的实现细节(在用户视角里任何内存区域都是不可见的),更不是孤立封闭的,所以某个区域里的对象完全有可能被位于堆中其他区域的对象所引用,这时候就需要将这些关联区域的对象也一并加入 GC Roots 集合中去,才能保证可达性分析的正确性。
3. 五种引用#
无论是通过引用计数算法判断对象的引用数量,还是通过可达性分析算法判断对象是否引用链可达,判定对象是否存活都和“引用”离不开关系。
在 JDK 1.2 版之前,Java 里面的引用是很传统的定义:如果 reference 类型的数据中存储的数值代表的是另外一块内存的起始地址,就称该 reference 数据是代表某块内存、某个对象的引用。
这种定义并没有什么不对,只是现在看来有些过于狭隘了,一个对象在这种定义下只有“被引用”或者“未被引用”两种状态,对于描述一些“食之无味,弃之可惜”的对象就显得无能为力。譬如我们希望能描述一类对象:当内存空间还足够时,能保留在内存之中,如果内存空间在进行垃圾收集后仍然非常紧张,那就可以抛弃这些对象 —— 很多系统的缓存功能都符合这样的应用场景。
在 JDK 1.2 版之后,Java 对引用的概念进行了扩充,将引用分为强引用(Strongly Reference)、软引用(Soft Reference)、弱引用(Weak Reference)和虚引用(Phantom Reference)4 种,这 4 种引用强度依次逐渐减弱。
3.1 强引用#
强引用是最传统的“引用”的定义,是指在程序代码之中普遍存在的引用赋值,即类似“Object obj=new Object()”这种引用关系。
无论任何情况下,只要强引用关系还存在,垃圾收集器就永远不会回收掉被引用的对象。即使当前内存空间不足,JVM 也不会回收它,而是抛出 OutOfMemoryError 错误,使程序异常终止。
如果想中断强引用和某个对象之间的关联,可以显式地将引用赋值为 null,这样一来的话,JVM 在合适的时间就会回收该对象。
3.2 软引用#
3.2.1 说明#
软引用是用来描述一些还有用,但非必须的对象。只被软引用关联着的对象,在系统将要发生内存溢出异常前,会把这些对象列进回收范围之中进行第二次回收,如果这次回收还没有足够的内存,才会抛出内存溢出异常。
换句话说,如果一个对象只具有软引用,则内存空间足够,垃圾回收器就不会回收它;如果内存空间不足了,就会回收这些对象的内存。只要垃圾回收器没有回收它,该对象就可以被程序使用。在 JDK 1.2 版之后提供了 SoftReference 类来实现软引用。
SoftReference 的特点是它的一个实例保存对一个 Java 对象的软引用,该软引用的存在不妨碍垃圾收集线程对该 Java 对象的回收。也就是说,一旦 SoftReference 保存了对一个 Java 对象的软引用后,在垃圾线程对这个 Java 对象回收前,SoftReference 类所提供的 get() 方法返回 Java 对象的强引用。另外,一旦垃圾线程回收该 Java 对象之后,get() 方法将返回 null。
3.2.2 使用#
对于下面代码的这个 MyObject 对象,有两个引用路径,一个是来自 SoftReference 对象的软引用,一个来自变量 aRef 的强引用,所以这个 MyObject 对象是「强可及对象」。
MyObject aRef = new MyObject();
SoftReference aSoftRef = new SoftReference(aRef);
随即,我们可以结束 aRef 对这个 MyObject 实例的强引用:aRef = null;
。此后,这个 MyObject 对象成为了「软可及对象」。如果垃圾收集线程进行内存垃圾收集,并不会因为有一个 SoftReference 对该对象的引用而始终保留该对象。
Java 虚拟机的垃圾收集线程对软可及对象和其他一般 Java 对象进行了区别对待:软可及对象的清理是由垃圾收集线程根据其特定算法按照内存需求决定的。也就是说,垃圾收集线程会在虚拟机抛出 OutOfMemoryError 之前回收软可及对象,而且虚拟机会尽可能优先回收长时间闲置不用的「软可及对象」,对那些刚刚构建的或刚刚使用过的“新”软可及对象会被虚拟机尽可能保留。在回收这些对象之前,我们可以通过:MyObject anotherRef = (MyObject) aSoftRef.get();
重新获得对该实例的强引用。而回收之后,调用 get() 方法就只能得到 null 了。
3.2.3 with 引用队列#
可以将「软引用」和一个「引用队列(ReferenceQueue)」联合使用,如果软引用所引用的对象被垃圾回收器回收,Java 虚拟机就会把这个软引用加入到与之关联的引用队列中。
作为一个 Java 对象,SoftReference 对象除了具有保存软引用的特殊性之外,也具有 Java 对象的一般性。所以,当软可及对象被回收之后,虽然这个 SoftReference 对象的 get() 方法返回 null,但这个 SoftReference 对象已经不再具有存在的价值,需要一个适当的清除机制,避免大量 SoftReference 对象带来的内存泄漏。
在 java.lang.ref 包里还提供了 ReferenceQueue。如果在创建 SoftReference 对象的时候,使用了一个ReferenceQueue 对象作为参数提供给 SoftReference 的构造方法,如:
ReferenceQueue<byte[]> queue = new ReferenceQueue<>();
List<SoftReference<byte[]>> list = new ArrayList<>();
for (int i = 0; i < 5; i++) {
SoftReference<byte[]> ref = new SoftReference<>(new byte[_4MB], queue);
list.add(ref);
}
那么当这个 SoftReference 所软引用的 byte[] 被垃圾收集器回收的同时,ref 所强引用的 SoftReference 对象会被列入ReferenceQueue。也就是说,ReferenceQueue 中保存的是 Reference 对象,而且是已经失去了它所软引用的对象的 Reference 对象。另外从 ReferenceQueue 这个名字也可以看出,它是一个队列,当我们调用它的 poll() 方法的时候,如果这个队列中不是空队列,那么将返回队列前面的那个 Reference 对象。
在任何时候,我们都可以调用 ReferenceQueue 的 poll() 方法来检查是否有它所关心的「非强可及对象」被回收。如果队列为空,将返回一个 null,否则该方法返回队列中前面的一个 Reference 对象。利用这个方法,我们可以检查哪个 SoftReference 所软引用的对象已经被回收。于是我们可以把这些失去所软引用的对象的 SoftReference 对象清除掉。常用的方式为:
SoftReference ref = null;
while ((ref = (EmployeeRef) queue.poll()) != null) {
list.remove(ref);
}
3.2.4 实现内存敏感的高速缓存#
首先,我们看一个雇员信息查询系统的实例。我们将使用一个 Java 语言实现的雇员信息查询系统查询存储在磁盘文件或者数据库中的雇员人事档案信息。
作为一个用户,我们完全有可能需要回头去查看几分钟甚至几秒钟前查看过的雇员档案信息(同样,我们在浏览 WEB 页面的时候也经常会使用“后退”按钮)。这时我们通常会有 2 种程序实现方式:
一种是把过去查看过的雇员信息保存在内存中,每一个存储了雇员档案信息的 Java 对象的生命周期贯穿整个应用程序始终;
另一种是当用户开始查看其他雇员的档案信息的时候,把存储了当前所查看的雇员档案信息的 Java 对象结束引用,使得垃圾收集线程可以回收其所占用的内存空间,当用户再次需要浏览该雇员的档案信息的时候,重新构建该雇员的信息。
很显然,第一种实现方法将造成大量的内存浪费,而第二种实现的缺陷在于即使垃圾收集线程还没有进行垃圾收集,包含雇员档案信息的对象仍然完好地保存在内存中,应用程序也要重新构建一个对象。我们知道,访问磁盘文件、访问网络资源、查询数据库等操作都是影响应用程序执行性能的重要因素,如果能重新获取那些尚未被回收的 Java 对象的引用,必将减少不必要的访问,大大提高程序的运行速度。
利用 JVM 垃圾收集机制的特性以及前述的垃圾对象重获方法,我们通过一个雇员信息查询系统的小例子来说明如何构建一种高速缓存器来避免重复构建同一个对象带来的性能损失。我们将一个雇员的档案信息定义为一个 Employee 类。
import java.lang.ref.ReferenceQueue;
import java.lang.ref.SoftReference;
import java.util.Hashtable;
public class EmployeeCache {
// 一个 Cache 实例
static private EmployeeCache cache;
// 用于 Chche 内容的存储(value不是直接引用具体Employee,而是引用一个软引用!)
// Hashtable.value -> EmployeeRef(SoftEeference) -> Employee
private Hashtable<String, EmployeeRef> employeeRefs;
// 垃圾 Reference 的队列
private ReferenceQueue<Employee> q;
// 继承 SoftReference,使得每一个实例都具有可识别的标识(该标识与其在HashMap内的key相同)
private class EmployeeRef extends SoftReference<Employee> {
private String _key = "";
public EmployeeRef(Employee em, ReferenceQueue<Employee> q) {
super(em, q);
_key = em.getID();
}
}
// 构建一个缓存器实例
private EmployeeCache() {
employeeRefs = new Hashtable<String, EmployeeRef>();
q = new ReferenceQueue<Employee>();
}
// 取得缓存器实例
public static EmployeeCache getInstance() {
if (cache == null) {
cache = new EmployeeCache();
}
return cache;
}
// 以软引用的方式对一个Employee对象的实例进行引用并保存该引用
private void cacheEmployee(Employee em) {
cleanCache();// 清除垃圾引用
EmployeeRef ref = new EmployeeRef(em, q);
employeeRefs.put(em.getID(), ref);
}
// 依据所指定的ID号,重新获取相应Employee对象的实例
public Employee getEmployee(String ID) {
Employee em = null;
// 缓存中是否有该Employee实例的软引用,如果有,从软引用中取得。
if (employeeRefs.containsKey(ID)) {
EmployeeRef ref = (EmployeeRef) employeeRefs.get(ID);
em = (Employee) ref.get();
}
// 如果没有软引用,或者从软引用中得到的实例是null,
// 则重新构建一个实例,并保存对这个新建实例的软引用
if (em == null) {
em = new Employee(ID);
System.out.println("Retrieve From EmployeeInfoCenter. ID=" + ID);
this.cacheEmployee(em);
}
return em;
}
// 清除那些所软引用的Employee对象已经被回收的EmployeeRef对象
private void cleanCache() {
EmployeeRef ref = null;
while ((ref = (EmployeeRef) q.poll()) != null) {
employeeRefs.remove(ref._key);
}
}
// 清除Cache内的全部内容
public void clearCache() {
cleanCache();
employeeRefs.clear();
System.gc();
System.runFinalization();
}
}
3.3 弱引用#
「弱引用」也是用来描述那些非必须对象,但是它的强度比「软引用」更弱一些,被「弱引用」关联的对象只能生存到下一次垃圾收集发生为止。当垃圾收集器开始工作,无论当前内存是否足够,都会回收掉只被「弱引用」关联的对象。在 JDK 1.2 版之后提供了 WeakReference 类来实现「弱引用」。
功能上和软引用如出一辙,和软引用最大的不同就是软引用是在内存不足的时候,GC 扫描到才会回收此处内存。而在有些场景(如:短时间缓存某些次要数据) 中使用「弱引用」比「软引用」更加合理,虽然软引用同样可以解决因为强引用导致内存无法回收的问题,但有个前提条件就是需要内存不足的时候才可以,这就没有「弱引用」来得实在:存在「弱引用」的对象,每次 JVM 进行垃圾回收时,该对象都会被回收。
3.4 虚引用#
3.4.1 说明#
虚引用也称为“幽灵引用”或者“幻影引用”,它是最弱的一种引用关系。一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,甚至无法通过虚引用来取得一个对象实例。
为一个对象设置虚引用关联的唯一目的只是跟踪垃圾回收过程,在这个对象被收集器回收时收到一个系统通知。在 JDK 1.2 版之后提供了 PhantomReference 类来实现虚引用。
当垃圾回收器准备回收一个对象时,如果发现它还有虚引用,就会在垃圾回收后,将这个虚引用加入引用队列,在其关联的虚引用出队前,不会彻底销毁该对象。 所以可以通过检查引用队列中是否有相应的虚引用来判断对象是否已经被回收了。
3.4.2 应用#
使用虚引用的目的就是为了得知对象被 GC 的时机,所以可以利用虚引用来进行销毁前的一些操作,比如说资源释放等。这个虚引用对于对象而言完全是无感知的,有没有完全一样,但是对于虚引用的使用者而言,就像是待观察的对象的把脉线,可以通过它来观察对象是否已经被回收,从而进行相应的处理。
事实上,虚引用有一个很重要的用途就是用来做堆外内存(就是直接内存)的释放,DirectByteBuffer 就是通过虚引用来实现堆外内存的释放的。
由于 JVM 自动内存管理的范围是堆内存,而直接内存是在堆内存之外(其实是内存映射文件),所以直接内存的分配和回收都是由 Unsafe 类去操作,Java 在申请一块直接内存之后,会在堆内存分配一个对象保存这个堆外内存的引用,这个对象是被垃圾收集器管理的,一旦这个对象被回收,相应的用户线程会收到通知并对直接内存进行清理工作。
3.5 终结器引用#
3.5.1 自救行动#
即使在可达性分析算法中判定为不可达的对象,也不是“非死不可”的,这时候它们暂时还处于“缓刑”阶段,要真正宣告一个对象死亡,至少要经历 2 次标记过程:如果对象在进行可达性分析后发现没有与 GC Roots 相连接的引用链,那它将会被第一次标记;随后进行一次筛选,筛选的条件是此对象是否有必要执行 finalize() 方法。
假如对象没有覆盖 finalize() 方法,或者 finalize() 方法已经被虚拟机调用过,那么虚拟机将这两种情况都视为“没有必要执行”。
如果这个对象被判定为确有必要执行 finalize() 方法,那么该对象将会被放置在一个名为「F-Queue」的队列之中,并在稍后由一条由虚拟机自动建立的、低调度优先级的「Finalizer 线程」去执行它们的 finalize() 方法。这里所说的“执行”是指虚拟机会触发这个方法开始运行,但并不承诺一定会等待它运行结束。
这样做的原因是,如果某个对象的 finalize() 方法执行缓慢,或者更极端地发生了死循环,将很可能导致「F-Queue」队列中的其他对象永久处于等待,甚至导致整个内存回收子系统的崩溃。finalize() 方法是对象逃脱死亡命运的最后一次机会,稍后收集器将对「F-Queue」中的对象进行第二次小规模的标记,如果对象要在 finalize() 中成功拯救自己 —— 只要重新与引用链上的任何一个对象建立关联即可,譬如把自己(this 关键字)赋值给某个类变量或者对象的成员变量,那在第二次标记时它将被移出“即将回收”的集合;如果对象这时候还没有逃脱,那基本上它就真的要被回收了。
另外一个值得注意的地方就是,任何一个对象的 finalize() 方法都只会被系统自动调用一次(逃脱成功),如果对象面临下一次回收,它的 finalize() 方法不会被再次执行(必会逃脱失败)。
3.5.2 Finalizer#
首先我们看看 FinalReference(终结器引用) 在 JDK 里的实现:
class FinalReference<T> extends Reference<T> {
public FinalReference(T referent, ReferenceQueue<? super T> q) {
super(referent, q);
}
}
该类访问权限是 package 的,这也就意味着不能直接去对其进行扩展,但是 JDK 里对此类进行了扩展实现 java.lang.ref.Finalizer
,而此类的访问权限也是 package 的,并且是 final 的,意味着真的不能被扩展了,接下来的重点围绕 java.lang.ref.Finalizer
展开。
final class Finalizer extends FinalReference<Object> {
private static ReferenceQueue<Object> queue = new ReferenceQueue<>();
private static Finalizer unfinalized = null;
private static final Object lock = new Object();
private Finalizer next = null, prev = null;
// private:私有的构造器意味着在外面无法自己构建这类对象
// finalizee参数:表示FinalReference指向的对象引用
private Finalizer(Object finalizee) {
// 被封装的对象和全局的「F-Queue」
super(finalizee, queue);
// 调用add方法:将当前对象插入到「Finalizer 对象链」里,链里的对象
// 和Finalizer类静态相关联,言外之意是在这个链里的对象都无法被gc掉
// 除非将这种引用关系剥离掉(因为Finalizer类无法被unload)
add();
}
private void add() {
synchronized (lock) {
if (unfinalized != null) {
this.next = unfinalized;
unfinalized.prev = this;
}
unfinalized = this;
}
}
// Invoked by VM,JVM 就是通过调用此方法将对象封装为
// Finalizer,同时将对象加入到「Finalizer 对象链」
static void register(Object finalizee) {
new Finalizer(finalizee);
}
类其实有挺多的修饰,比如 final,abstract,public 等等,如果一个类有 final 修饰,我们就说这个类是一个 final 类,上面列的都是语法层面我们可以显示标记的,在 JVM 里其实还给类标记其他一些符号,比如 finalizer,表示这个类是一个 finalizer 类(为了和 java.lang.ref.Fianlizer
类进行区分,下文要提到的 finalizer 类的地方都说成 f 类),GC 在处理这种类的对象的时候要做一些特殊的处理,如在这个对象被回收之前会调用一下它的 finalize 方法。
3.5.3 f 类#
(1) 如何判断一个类是不是一个 f 类?
protected void finalize() throws Throwable { }
在 Object 类里定义了一个名为 finalize 的空方法,这意味着 Java 世界里的所有类都会继承这个方法,甚至可以覆写该方法,并且根据方法覆写原则,如果子类覆盖此方法,方法访问权限都是至少是 protected 级别的,这样其子类就算没有覆写此方法也会继承此方法。
而判断当前类是否是一个 f 类的标准并不仅仅是当前类是否含有一个参数为空,返回值为 void 的名为 finalize 的方法,而另外一个要求是 finalize 方法体必须非空!因此我们的 Object 类虽然含有一个 finalize 方法,但是并不是一个 f 类,Object 的对象在被 gc 回收的时候其实并不会去调用它的 finalize 方法。
需要注意的是我们的类在被加载过程中其实就已经被标记为是否为 f 类了(遍历所有方法,包括父类的方法,只要有一个非空方法体的参数为空返回 void 的 finalize 方法就认为是一个 f 类)。
Finalizer 继承 FinalReference 类,FinalReference 继承 Reference 类,对象最终会被封装为 Finalizer 对象,通过看上面源码会发现 Finalizer 的构造方法是不对外暴露的,所以我们无法自己创建 Finalizer 对象,而 FinalReference 是由 JVM 自动封装的。所以,便引出下一个问题:
(2) f 类的对象何时传到 Finalizer.register 方法?
对象的创建其实是被拆分成多个步骤的,比如 A a=new A(2)
这样一条语句对应的字节码如下:
0: new #1 // class A
3: dup
4: iconst_2
5: invokespecial #11 // Method "<init>":(I)V
先执行 new 分配好对象空间,然后再执行 invokespecial 调用构造函数,JVM 里其实可以让用户选择在这两个时机中的任意一个将当前对象传递给 Finalizer.register 方法来注册到「Finalizer 对象链」里,这个选择依赖于 RegisterFinalizersAtInit 这个 VM 参数是否被设置,默认值为 true,也就是在调用构造函数返回之前调用 Finalizer.register 方法,如果通过 -XX:-RegisterFinalizersAtInit
关闭了该参数,那将在对象空间分配好之后就将这个对象注册进去。
另外需要提一点的是当我们通过 clone 的方式复制一个对象的时候,如果当前类是一个 f 类,那么在 clone 完成的时候将调用 Finalizer.register 方法进行注册。
(3) hotspot 如何实现 f 类对象在构造函数执行完毕后调用 Finalizer.register?
这个实现比较有意思,在这里简单提一下,我们知道一个构造函数执行的时候,会去调用父类的构造函数,主要是为了能对继承自父类的属性也能做初始化,那么任何一个对象的初始化最终都会调用到 Object 的空构造函数里(任何空的构造函数其实并不空,会含有 3 条字节码指令,如下代码所示),为了不对所有的类的构造函数都做埋点调用 Finalizer.register 方法,hotspot 的实现是在 Object 这个类在做初始化的时候将构造函数里的 return 指令替换为 _return_register_finalizer 指令,该指令并不是标准的字节码指令,是 hotspot 扩展的指令,这样在处理该指令的时候调用 Finalizer.register 方法,这样就在侵入性很小的情况下完美地解决了这个问题。
0: aload_0
1: invokespecial #21 // Method java/lang/Object."<init>":()V
4: return
3.5.3 f 类对象的 GC#
(1) FinalizerThread 线程
在 Finalizer 类的 clinit 方法(静态块)里我们看到它会创建了一个 FinalizerThread 的守护线程,这个线程的优先级并不是最高的,意味着在 cpu 很紧张的情况下其被调度的优先级可能会受到影响。
private static class FinalizerThread extends Thread {
private volatile boolean running;
FinalizerThread(ThreadGroup g) {
super(g, "Finalizer");
}
public void run() {
if (running)
return;
running = true;
for (;;) {
try {
Finalizer f = (Finalizer) queue.remove();
f.runFinalizer(); // --- 剥离
} catch (InterruptedException x) {
continue;
}
}
}
}
static {
ThreadGroup tg = Thread.currentThread().getThreadGroup();
for (ThreadGroup tgn = tg; tgn != null; tg = tgn, tgn = tg.getParent());
Thread finalizer = new FinalizerThread(tg);
finalizer.setPriority(Thread.MAX_PRIORITY - 2);
finalizer.setDaemon(true);
finalizer.start();
}
这个线程主要就是从 queue 里取 Finalizer 对象,然后执行该对象的 runFinalizer 方法,这个方法主要是将 Finalizer 对象从「Finalizer 对象链」里剥离出来,这样意味着下次 GC 发生的时候就可能将其关联的 f 对象 GC 掉了,最后将这个 Finalizer 对象关联的 f 对象传给了一个 native 方法 invokeFinalizeMethod。
private void runFinalizer() {
synchronized (this) {
if (hasBeenFinalized()) return;
remove();
}
try {
Object finalizee = this.get();
if (finalizee != null && !(finalizee instanceof java.lang.Enum)) {
invokeFinalizeMethod(finalizee);
/* Clear stack slot containing this variable, to decrease
the chances of false retention with a conservative GC */
finalizee = null;
}
} catch (Throwable x) { }
super.clear();
}
static native void invokeFinalizeMethod(Object o) throws Throwable;
其实 invokeFinalizeMethod 方法就是调了这个 f 对象的 finalize 方法,看到这里大家应该恍然大悟了,整个过程都串起来了。
(2) f 对象的 finalize 方法抛出异常会导致 FinalizeThread 退出吗?
不知道大家有没有想过如果 f 对象的 finalize 方法抛了一个没捕获的异常,这个 FinalizerThread 会不会退出呢,细心的读者看上面的代码其实就可以找到答案,在 runFinalizer 方法里对 Throwable 的异常都进行了捕获,因此不可能出现 FinalizerThread 因异常未捕获而退出的情况。
(3) f 对象的 finalize 方法会执行多次吗?
如果我们在 f 对象的 finalize 方法里重新将当前对象赋值出去,变成可达对象,当这个 f 对象再次变成不可达的时候还会被执行 finalize 方法吗?答案是否定的,因为在执行完第一次 finalize 方法之后,这个 f 对象已经和之前的 Finalizer 对象关系剥离了,也就是下次 GC 的时候不会再发现 Finalizer 对象指向该 f 对象了,自然也就不会调用这个 f 对象的 finalize 方法了。
(4) Finalizer 对象何时被放到 ReferenceQueue 里?
除了这里要说的环节之外,整个过程大家应该都比较清楚了。
当 GC 发生的时候,GC 算法会判断 f 类对象是不是只被 Finalizer 类引用(f 类对象被 Finalizer 对象引用,然后放到「Finalizer 对象链」),如果这个类仅仅被 Finalizer 对象引用的时候,说明这个对象在不久的将来会被回收了,现在可以执行它的 finalize 方法了。
于是会将这个 Finalizer 对象放到 Finalizer 类的 ReferenceQueue 里!但是这个 f 类对象其实并没有被回收,因为 Finalizer 这个类还对他们持有引用,在 GC 完成之前,JVM 会调用 ReferenceQueue 里的 lock 对象的 notify 方法(当 ReferenceQueue 为空的时候,FinalizerThread 线程会调用 ReferenceQueue 的 lock 对象的 wait 方法直到被 JVM 唤醒),此时就会执行上面 FinalizeThread 线程里看到的其他逻辑了。
3.5.4 客观评价#
上面的过程基本对 Finalizer 的实现细节进行完整剖析了,Java 里我们看到有构造函数,但是并没有看到析构函数一说,Finalizer 其实是实现了析构函数的概念,我们在对象被回收前可以执行一些『收拾性』的逻辑,应该说是一个特殊场景的补充,但是这种概念的实现给我们的 f 对象生命周期以及 GC 等带来了一些影响:f 对象因为 Finalizer 的引用而变成了一个临时的强引用,即使没有其他的强引用了,还是无法立即被回收,f 对象至少经历两次 GC 才能被回收,因为只有在 FinalizerThread 执行完了 f 对象的 finalize 方法的情况下才有可能被下次 GC 回收,而有可能期间已经经历过多次 GC 了,但是一直还没执行 f 对象的 finalize 方法。
CPU 资源比较稀缺的情况下,FinalizerThread 线程有可能因为优先级比较低而延迟执行 f 对象的 finalize 方法,因为 f 对象的 finalize 方法迟迟没有执行,有可能会导致大部分 f 对象进入到 old 分代,此时容易引发 old 分代的 GC,甚至 FullGC,GC 暂停时间明显变长,f 对象的 finalize 方法被调用了,但是这个对象其实还并没有被回收,虽然可能在不久的将来会被回收。
3.6 引用小结#
(1) Reference
引用对象的抽象基类。此类定义了常用于所有引用对象的操作。因为引用对象是通过与垃圾回收器的密切合作来实现的,所以不能直接为此类创建子类。
void clear() // 清除此引用对象
boolean enqueue() // 将此引用对象添加到引用对象已向其注册的队列(如果有)
T get() // 返回此引用对象的指示对象
boolean isEnqueued() // 由程序或垃圾回收器通知是否已将此引用对象加入队列
(2) 四种引用小结
(3) 引用队列
引用队列可以配合软引用、弱引用及幽灵引用使用,当引用的对象将要被 JVM 回收时,会将其加入到引用队列中。所以,我们可以通过引用队列来了解 JVM 垃圾回收情况。
(4) 终结器引用
- 无需手动编码(除了重写 finalize 方法外,啥都不用做,都是 JVM 做的), 其内部配合引用队列使用。
- 在 GC 时, 终结器引用入队。由「Finalizer 线程」通过终结器引用找到被引用对象并调用它的 finalize() 方法,第二次 GC 时才能回收被引用对象(像上文说的,也有可能会在 finalize() 方法中完成自救)。
4. 垃圾收集算法#
4.1 分代收集理论#
当前商业虚拟机的垃圾收集器,大多数都遵循了“分代收集”(Generational Collection)的理论进行设计,分代收集名为理论,实质是一套符合大多数程序运行实际情况的经验法则,它建立在两个分代假说之上:
[1] 弱分代假说(Weak Generational Hypothesis):绝大多数对象都是朝生夕灭的。
[2] 强分代假说(Strong Generational Hypothesis):熬过越多次垃圾收集过程的对象就越难以消亡。
[3] 跨代引用假说(Intergenerational Reference Hypothesis):跨代引用相对于同代引用来说仅占极少数。(下面会解释)
这两个分代假说共同奠定了多款常用的垃圾收集器的一致的设计原则:收集器应该将「Java 堆」划分出不同的区域,然后将回收对象依据其年龄(年龄即对象熬过垃圾收集过程的次数)分配到不同的区域之中存储。
显而易见,如果一个区域中大多数对象都是朝生夕灭,难以熬过垃圾收集过程的话,那么把它们集中放在一起,每次回收时只关注如何保留少量存活而不是去标记那些大量将要被回收的对象,就能以较低代价回收到大量的空间;如果剩下的都是难以消亡的对象,那把它们集中放在一块,虚拟机便可以使用较低的频率来回收这个区域,这就同时兼顾了垃圾收集的时间开销和内存的空间有效利用。
把分代收集理论具体放到现在的商用 Java 虚拟机里,设计者一般至少会把「Java 堆」划分为新生代(Young Generation)和老年代(Old Generation)两个区域。顾名思义,在新生代中,每次垃圾收集时都发现有大批对象死去,而每次回收后存活的少量对象,将会逐步晋升到老年代中存放。
在「Java 堆」划分出不同的区域之后,垃圾收集器才可以每次只回收其中某一个或者某些部分的区域 —— 因而才有了“Minor GC”“Major GC”“Full GC”这样的回收类型的划分;也才能够针对不同的区域安排与里面存储对象存亡特征相匹配的垃圾收集算法 —— 因而发展出了“标记-复制算法”、“标记-清除算法”、“标记-整理算法”等针对性的垃圾收集算法。
如下解释 [3] 跨代引用假说:
假如要现在进行一次只局限于新生代区域内的收集(MinorGC),但新生代中的对象是完全有可能被老年代所引用的,为了找出该区域中的存活对象,不得不在固定的 GC Roots 之外,再额外遍历整个老年代中所有对象来确保可达性分析结果的正确性,反过来也是一样。遍历整个老年代所有对象的方案虽然理论上可行,但无疑会为内存回收带来很大的性能负担。为了解决这个问题,就需要对分代收集理论添加第 3 条经验法则:跨代引用假说(跨代引用相对于同代引用来说仅占极少数)。
这其实是可根据前两条假说逻辑推理得出的隐含推论:存在互相引用关系的两个对象,是应该倾向于同时生存或者同时消亡的。举个例子,如果某个新生代对象存在跨代引用,由于老年代对象难以消亡,该引用会使得新生代对象在收集时同样得以存活,进而在年龄增长之后晋升到老年代中,这时跨代引用也随即被消除了。
依据这条假说,我们就不应再为了少量的跨代引用去扫描整个老年代,也不必浪费空间专门记录每一个对象是否存在及存在哪些跨代引用,只需在新生代上建立一个全局的数据结构(该结构被称为“记忆集”,Remembered Set),这个结构把老年代划分成若干小块,标识出老年代的哪一块内存会存在跨代引用。此后当发生 MinorGC 时,只有包含了跨代引用的小块内存里的对象才会被加入到 GC Roots 进行扫描。虽然这种方法需要在对象改变引用关系(如将自己或者某个属性赋值)时维护记录数据的正确性,会增加一些运行时的开销,但比起收集时扫描整个老年代来说仍然是划算的。
4.2 名词释义#
整堆收集(Full GC):收集整个 「Java 堆」和方法区的垃圾收集。
部分收集(Partial GC):指目标不是完整收集整个「Java 堆」的垃圾收集,其中又分为:
- 新生代收集(Minor GC/Young GC):指目标只是新生代的垃圾收集。因为大多数 Java 对象存活率都不高,所以 MinorGC 非常频繁,一般回收速度也比较快。
- 老年代收集(Major GC/Old GC):指目标只是老年代的垃圾收集。Major GC 的速度一般要比 MinorGC 慢 10 倍以上。目前只有 CMS 收集器会有单独收集老年代的行为。
- 混合收集(Mixed GC):指目标是收集整个新生代以及部分老年代的垃圾收集。目前只有 G1 收集器会有这种行为。
【补充】
(1)如何判断一个类是无用的类?
方法区主要回收的是无用的类,那么如何判断一个类是无用的类的呢?
类需要同时满足下面 3 个条件才能算是 “无用的类” :
- 该类所有的实例都已经被回收,也就是 Java 堆中不存在该类的任何实例;
- 加载该类的 ClassLoader 已经被回收;
- 该类对应的 java.lang.Class 对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。
(2)对象内存分配流程
(3)对象动态年龄判断机制
当前放对象的 Survivor 区域里,一批对象的总大小大于这块 Survivor 区域内存大小的 50%(-XX:TargetSurvivorRatio
可以指定),那么此时大于等于这批对象年龄最大值的对象,就可以直接进入老年代了,例如 Survivor 区域里现在有一批对象,年龄1+年龄2+年龄n的多个年龄对象总和超过了 Survivor 区域的 50%,此时就会把年龄n(含)以上的对象都放入老年代。这个规则其实是希望那些可能是长期存活的对象,尽早进入老年代。对象动态年龄判断机制一般是在 MinorGC 之后触发的。
(4)老年代空间分配担保机制
当 Survivor 空间不足以容纳一次 MinorGC 之后存活的对象时,就需要依赖其他内存区域(实际上大多就是老年代)进行分配担保(Handle Promotion)。也即只要〈老年代的剩余可用空间〉大于〈新生代对象的总大小/历次晋升到老年代的对象的平均大小〉就进行 MinorGC,否则 FullGC。
4.3 GC 算法#
4.3.1 标记-复制算法#
新生区是类的诞生、成长、消亡的区域,一个类在这里产生,应用,最后被垃圾回收器收集,结束生命。新生区又分为两部分: 伊甸区(Eden space) 和幸存者区(Survivor pace),所有的对象都是在伊甸区被 new 出来的。幸存区有两个:0 区(Survivor 0 space) 和 1 区(Survivor 1 space)。内存大小为 8:1:1。
“标记-复制算法”,常被简称为“复制算法”,现在的商用 Java 虚拟机大多都优先采用了这种收集算法去回收「新生代」。
HotSpot JVM 把年轻代分为了 3 部分:1 个 Eden 区和 2 个 Survivor 区(分别叫 From 和 To)。默认比例为 8:1:1,一般情况下,新创建的对象都会被分配到 Eden 区(一些大对象特殊处理),这些对象经过第一次 Minor GC 后,如果仍然存活,将会被移到 Survivor 区。对象在 Survivor 区中每熬过一次 Minor GC,年龄就会增加 1 岁,当它的年龄增加到一定程度时,就会被移动到年老代中。因为年轻代中的对象基本都是朝生夕死的(98% 以上),所以在年轻代的垃圾回收算法使用的是【复制算法】。
复制算法的基本思想就是将内存分为两块(Eden&From,To),每次只用其中一块,当这一块内存用完,就将还活着的对象复制到另外一块上面。每次都是针对整个半区进行内存回收,分配内存时也就不用考虑有空间碎片的复杂情况,只要移动堆顶指针,按顺序分配即可。
当对象在 Eden ( 包括一个 Survivor 区域,这里假设是 From 区域) 出生后,在经过一次 Minor GC 后,如果对象还存活,并且能够被另外一块 Survivor 区域所容纳 (上面已经假设为 From 区域,这里应为 To 区域,即 To 区域有足够的内存空间来存储 Eden 和 From 区域中存活的对象 ),则使用【复制算法】将这些仍然还存活的对象复制到另外一块 Survivor 区域 (即 To 区域 ) 中,然后清理所使用过的 Eden 以及 Survivor 区域 (即 From 区域 ),并且将这些对象的年龄设置为 1,以后对象在 Survivor 区每熬过一次 Minor GC,就将对象的年龄 + 1,当对象的年龄达到某个值时 (默认是 15 岁),这些对象就会成为老年代。
-XX:MaxTenuringThreshold 年龄阈值,设置对象在新生代中存活的次数
Minor GC 会把 Eden 中的所有活的对象都移到 Survivor 区域中,但如果 Survivor 区中放不下,那么剩下的活的对象就被移到 Old generation 中,也即一旦收集后,Eden 是就变成空的了。
在 GC 开始的时候,对象只会存在于 Eden 区和名为“From”的 Survivor 区,Survivor 区“To”是空的。紧接着进行 GC,Eden 区中所有存活的对象都会被复制到“To”,而在“From”区中仍存活的对象会根据他们的年龄值来决定去向:年龄达到一定值的对象会被移动到年老代中,没有达到阈值的对象会被复制到“To”区域。
经过这次 GC 后,Eden 区和 From 区已经被清空。这个时候,“From”和“To”会交换他们的角色,也就是新的“To”就是上次 GC 前的“From”,新的“From”就是上次 GC 前的“To”。不管怎样,都会保证名为 To 的 Survivor 区域是空的。Minor GC 会一直重复这样的过程,直到“To”区被填满,当“To”区被填满之后,会将所有对象移动到年老代中(分配担保,下文详释)。
因为 Eden 区对象一般存活率较低(有 98% 对象熬不过第一轮收集),一般地,使用两块 10% 的内存作为空闲和活动区间,而另外 80% 的内存,则是用来给新建对象分配内存的。一旦发生 GC,将 10% 的 From 活动区间与另外 80% 中存活的 Eden 对象转移到 10% 的 To 空闲区间,接下来,将之前 90% 的内存全部释放,以此类推。
复制算法它的缺点也是相当明显:
- 它浪费了一半的内存,空间浪费未免太多了一点。
- 如果对象的存活率很高,我们可以极端一点,假设是 100% 存活,那么我们需要将所有对象都复制一遍,并将所有引用地址重置一遍。复制这一工作所花费的时间,在对象存活率达到一定程度时,将会变的不可忽视。 所以从以上描述不难看出,复制算法要想使用,最起码对象的存活率要非常低才行,而且最重要的是,我们必须要克服 50% 内存的浪费。
4.3.2 标记-清除算法#
老年代一般是由 标记清除(Mark-Sweep) 或者是标记清除与标记整理的混合实现。
如它的名字一样,算法分为“标记”和“清除”两个阶段:首先标记出所有需要回收的对象,在标记完成后,统一回收掉所有被标记的对象,也可以反过来,标记存活的对象,统一回收所有未被标记的对象。标记过程就是对象是否属于垃圾的判定过程。
当堆中的有效内存空间(Available Memory)被耗尽的时候,就会停止整个程序(也被称为 Stop the world),然后进行两项工作,第一项则是“标记”,第二项则是“清除”。
- 标记:从引用根节点开始标记所有被引用的对象。标记的过程其实就是遍历所有的 GC Roots,然后将所有 GC Roots 可达的对象标记为存活的对象。
- 清除:遍历整个堆,把未标记的对象清除。
用通俗的话解释一下【标记/清除算法】,就是当程序运行期间,若可以使用的内存被耗尽的时候,GC 线程就会被触发并将程序暂停,随后将依旧存活的对象标记一遍,最终再将堆中所有没被标记的对象全部清除掉,接下来便让程序恢复运行。
后续的收集算法大多都是以“标记-清除算法”为基础,对其缺点进行改进而得到的。它的主要缺点有两个:
- 执行效率不稳定,如果 Java 堆中包含大量对象,而且其中大部分是需要被回收的,这时必须进行大量标记和清除的动作,导致标记和清除两个过程的执行效率都随对象数量增长而降低;
- 内存空间的碎片化问题,标记、清除之后会产生大量不连续的内存碎片,因为我们的死亡对象都是随机的出现在内存的各个角落的,现在把它们清除之后,内存的布局自然会乱七八糟。而为了应付这一点,JVM 就不得不维持一个内存的空闲列表,这又是一种开销。而空间碎片太多可能会导致当以后在程序运行过程中需要分配较大对象时无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。
4.3.3 标记-整理算法#
针对老年代对象的存亡特征,提出了另外一种有针对性的“标记-整理”(Mark-Compact)算法。
其中的标记过程仍然与“标记-清除”算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向内存空间一端移动,然后直接清理掉边界以外的内存。
可以看到,标记的存活对象将会被整理,按照内存地址依次排列,而未被标记的内存会被清理掉。如此一来,当我们需要给新对象分配内存时,JVM 只需要持有一个内存的起始地址即可,这比维护一个空闲列表显然少了许多开销。
“标记-清除算法”与“标记-整理算法”的本质差异在于:前者是一种非移动式的回收算法,而后者是移动式的。是否移动回收后的存活对象是一项优缺点并存的风险决策。
- 如果移动存活对象,尤其是在老年代这种每次回收都有大量对象存活区域,移动存活对象并更新所有引用这些对象的地方将会是一种极为负重的操作,而且这种对象移动操作必须全程暂停用户应用程序才能进行,像这样的停顿被最初的虚拟机设计者形象地描述为“Stop The World”。
- 但如果跟“标记-清除算法”那样完全不考虑移动和整理存活对象的话,弥散于堆中的存活对象导致的空间碎片化问题就只能依赖更为复杂的内存分配器和内存访问器来解决。譬如通过“分区空闲分配链表”来解决内存分配问题(计算机硬盘存储大文件就不要求物理连续的磁盘空间,能够在碎片化的硬盘上存储和访问就是通过硬盘分区表实现的)。内存的访问是用户程序最频繁的操作,甚至都没有之一,假如在这个环节上增加了额外的负担,势必会直接影响应用程序的吞吐量。
基于以上两点,是否移动对象都存在弊端,移动则内存回收时会更复杂,不移动则内存分配时会更复杂。
4.3.4 标记-清除-整理算法#
是一种“和稀泥式”解决方案,它可以不在内存分配和访问上增加太大额外负担。做法是让虚拟机平时多数时间都采用“标记-清除算法”,暂时容忍内存碎片的存在,直到内存空间的碎片化程度已经大到影响对象分配时,再采用“标记-整理算法”收集一次,以获得规整的内存空间。前面提到的基于“标记-清除算法”的 CMS 收集器面临空间碎片过多时采用的就是这种处理办法。
- 是 Mark-Sweep 和 Mark-Compact 的结合。
- 和 Mark-Sweep 一致,当进行多次 GC 后才 Compact(减少移动对象的成本)。
4.3.5 算法小结#
难道就没有一种最优算法吗? 无,没有最好的算法,只有最合适的算法 → GC 分代收集算法!针对不同的区域,安排与里面存储对象存亡特征相匹配的垃圾收集算法,进而发展出“标记-复制算法”、“标记-清除算法”、“标记-整理算法”等针对性的垃圾收集算法。
5. 相关 VM 参数#
(1)参数
(2)示例
(3)关于元空间的 JVM 参数有 2 个:
-
-XX:MaxMetaspaceSize: 设置元空间最大值, 默认是 -1, 即不限制, 或者说只受限于本地内存大小。
-
-XX:MetaspaceSize: 指定元空间触发 FullGC 的初始阈值(元空间无固定初始大小), 以字节为单位,默认是 21M,达到该值就会触发;
FullGC 进行类型卸载, 同时收集器会对该值进行调整: 如果释放了大量的空间, 就适当降低该值; 如果释放了很少的空间, 那么在不超过 -XX:MaxMetaspaceSize
(如果设置了的话) 的情况下, 适当提高该值。这个跟早期 JDK 版本的 -XX:PermSize
参数意思不一样,-XX:PermSize
代表永久代的初始容量。
由于调整元空间的大小需要 Full GC,这是非常昂贵的操作,如果应用在启动的时候发生大量 Full GC,通常都是由于永久代/元空间发生了大小调整,基于这种情况,一般建议在 JVM 参数中将 MetaspaceSize 和 MaxMetaspaceSize 设置成一样的值,并设置得比初始值要大,
对于 8G 物理内存的机器来说,一般我会将这两个值都设置为 256M。
-Xss
设置越小 count 值越小,说明一个线程栈里能分配的栈帧就越少,但是对 JVM 整体来说能开启的线程数会更多。
(4)JVM 内存参数大小该如何设置(e.g. 日均百万级订单交易系统如何设置 JVM 参数)?
(5)能否对 JVM 调优,使其几乎不发生 FullGC?
尽可能让对象都在新生代里分配和回收,尽量别让太多对象频繁进入老年代,避免频繁对老年代进行垃圾回收,同时给系统充足的内存大小,避免新生代频繁的进行垃圾回收。
‐Xms3072M ‐Xmx3072M ‐Xmn2048M ‐Xss1M ‐XX:MetaspaceSize=256M ‐XX:MaxMetaspaceSize=256M ‐XX:SurvivorRatio=8
这样就降低了因为「对象动态年龄判断原则」导致的对象频繁进入老年代的问题,其实很多优化无非就是让短期存活的对象尽量都留在 survivor 里,不要进入老年代,这样在 MinorGC 的时候这些对象都会被回收,不会进到老年代从而导致 FullGC。
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· AI与.NET技术实操系列(二):开始使用ML.NET
· 记一次.NET内存居高不下排查解决与启示
· 探究高空视频全景AR技术的实现原理
· 理解Rust引用及其生命周期标识(上)
· 浏览器原生「磁吸」效果!Anchor Positioning 锚点定位神器解析
· DeepSeek 开源周回顾「GitHub 热点速览」
· 物流快递公司核心技术能力-地址解析分单基础技术分享
· .NET 10首个预览版发布:重大改进与新特性概览!
· AI与.NET技术实操系列(二):开始使用ML.NET
· 单线程的Redis速度为什么快?