What‘s JVM- 垃圾收集器与内存分配策略

Java基础高级进阶篇之Jvm详解(详解篇五十六) 本文系统介绍了JVM的核心知识点,主要包括以下内容: JVM架构概述:包括类加载子系统、运行时数据区、执行引擎和垃圾收集器等核心模块的组成与功能。 类加载机制:详细解析了类加载的七个阶段(加载、验证、准备、解析、链接、初始化、使用),重点说明了加载阶段的字节流来源、验证阶段的四种检查、准备阶段的静态变量初始化规则等关键过程。 运行时数据区:介绍了堆、方法区、虚拟机栈等内存区域的划分与作用。 执行与垃圾回收:简要提及执行引擎的工作机制和垃圾收集器的不同类型。 文章通过图示和代码示例(如类加载过程、静态变量初始 阅读详情

3.1. 对象存在与否

3.1.1. 引用计数算法

  • :green_apple:给对象添加一个计数器,每次引用就把计数器+1;引用失效,计数器-1;当计数器为 0,释放对象。
  • :apple:但是它很难解决对象之间的循环引用问题。

3.1.2. 可达性分析算法

  • 选定一些对象作为根节点,称为 GC Roots,每次从根节点开始遍历,遍历后所有不可达节点(对象)就是不可用的,需要回收。
  • 这个从根节点开始的路径链称为“引用链”。

既然可达性分析判定一个对象是否应该回收取决于根节点是否可达,那么根节点的选取就变得尤为重要。在 Java 中,根节点(GC Roots)可以为如下几种:

  1. 虚拟机栈引用的对象,比如各个线程调用的方法堆栈中的方法参数,局部变量,临时变量。
  2. 方法区中类静态属性引用的对象。
  3. 方法区中常量引用的对象。
  4. 本地方法栈中引用的对象。
  5. JVM 内部的引用,比如基本数据类型对应的 Class 对象,常驻的异常对象。
  6. 所有被同步锁持有的对象。
  7. 反应 JVM 内部情况的 JMXBean,JVMTI 中注册的回调,本地代码缓存等。
  • :pear:除了这些可以固定作为 GC Roots 的对象之外,还有其他对象可以临时性的加入。比如进行区域回收时,可能就需要把那些跨区域引用的对象的对象一块放到 GC Roots 集合进行扫描。

其实可以看到,所谓 GC Roots 更像是活跃对象集合 , 哪些对象是存活的,就可以作为 GC Roots 。

3.1.3. 再谈引用

  • :tangerine:Java 原本对引用的定义和 C 的指针一样:reference 如果存储的值表示一个内存的地址,那么 reference 就是一个对象的引用。
  • :lemon:但是在 JDK2.0 之后,觉得这样的定义有点不妥,于是引入了四种引用类型。

它们分别是:

  1. 强引用就是最传统的定义,就是最基本的 new 对象然后赋值。任何时候,只要存在强引用,对象就不会被回收。
  2. 软引用就是那些非必需的对象,在内存即将用尽时,会把这些对象进行第二次回收,如果还不够,则抛异常。
  3. 弱引用更弱,它撑不过垃圾回收,任何只被弱引用关联的对象都会在垃圾回收时被回收( ThreadLocalMap 的值就是这种类型,为了防止内存泄漏而引入)。
  4. 虚引用只能用来在对象被回收时得到一个通知,仅此而已。

3.1.4. 回收方法区

  • :banana:方法区的垃圾回收主要回收两个部分:废弃的常量和不再使用的类型(多用于动态代理生成的动态类型)。
  • :watermelon:回收常量很简单,就是看是否还存在对它的引用。但是回收类型比较复杂且收益低。

对于类型的回收,需要判断一个类型是否属于不再使用的类,条件会比较苛刻:

  1. 该类及其子类的所有实例都被回收。
  2. 加载该 类的类加载器 已经被回收。
  3. 该类的 Class 对象没有在任何地方被引用 。

在使用了大量的动态代理,反射,CGLib 的地方,类型回收就显得比较重要。

3.2. 垃圾收集算法

  • :grapes:垃圾收集算法有引用计数式垃圾收集和 追踪式垃圾收集 。
  • :strawberry:因为当前主流的垃圾收集都是后者,所以下面的讲解也是围绕后者展开的。

3.2.1. 分代收集理论

  • 目前主流的理论(更多像是经验总结)主要有两个:弱分代假说,强分代假说。
  • :melon:弱分代假说:绝大多数对象都是朝生夕灭的(越往后越容易死亡)。
  • :cherries:强分代假说:熬过越多次垃圾回收的对象越难死亡(越往后越不容易死亡)。

收集器根据这两个假说把 Java 堆划分成了不同区域,根据对象的年龄(指对象熬过垃圾回收的次数)划分出了两个主要的区域: 新生代 和 老年代 。

新生代关注的更多是如何保留少量存活,使用弱分代假说;而老年代则可以使用更低的频率来回收,使用强分代假说。新生代回收之后剩下的对象会逐步晋升代老年代。

  • :peach:跨代引用假说:跨代引用相比于同代引用只占少数。这个理论很容易得到推导:如果老年代引用了新生代,随着引用的存在和老年代对象的长久存活,被它引用的新生代对象也会一直存活然后称为老年代。

因为跨代引用假说的存在,所以可以把老年代划分出不同区域,这样在进行 Minor GC 时,仅仅把这些区域内的老年代加入到 GC Roots,以它们为根节点进行扫描。

3.2.2. 标记-清除算法

  • 标记就是判断对象是否属于垃圾的过程,这个可由可达性分析算法进行标记,在此不再描述。
  • :pineapple:清除就是把标记过的区域清空。

这个算法足够简单,但是它有两个缺点:

  1. 执行效率不稳定,它的执行效率随着堆中需要回收的对象数量增加而下降。
  2. 第二个是 碎片化问题 ,过多的碎片会导致没有足够大的空间分配对象进而触发进一步垃圾收集。

3.2.3. 标记-复制算法

为了解决标记清除算法的执行效率不稳定问题,引入了标记-复制算法。

  • 标记还是判断对象是否需要回收。
  • 复制则是把需要保留的对象复制到另一半区域。

此算法会把堆分为两个区域,其中一个保留,另一个存放对象;每次垃圾回收就把需要存活的对象复制到另一半内存,然后清空整个内存半区,这样这一半就完全空闲,而且所有的存活对象都会整齐地排列在另一半中。下次同样这样操作。

这种算法的缺点显而易见:每次只有一半内存得以使用,未免有点太浪费空间了。

为了解决这个问题,可以把内存划分比例换一下,因为统计发现,绝大多数新生代对象撑不过第一轮垃圾回收。

目前有一种更好的解决方案:把内存划分成两个较小的 Survivor(以下简称 S)和一个较大的 Eden(以下简称 E)。 每次只使用一个 S 和一个 E 来分配内存 。垃圾回收时,把这个 E 和 S 上的存活对象移动到另一个 S 上,然后清空 E 和刚刚那个 S。

凡事总有个例外,如果 S 不够容纳一次 GC 之后的存活对象,就需要老年代的部分区域来存放。这部分实现的安全性由 JVM 担保。

由此可见这个算法主要用于新生代的 GC。

3.2.4. 标记-整理算法

  • :tomato:标记依旧是判断对象是否需要回收。
  • :eggplant:而整理则是把存活对象移动到一端,然后直接清除边界以外的内存区域即可。

这个算法如果用在老年代上的话,估计不是很理想,因为老年代存活对象太多了。而且这个算法在移动对象时,必须暂停用户程序,就像 JOJO 里 Dio 喊出:The World!全世界冻结一样。然后搬运它需要搬运的对象。

是否移动对象有弊有利。移动的好处在于内存空间整齐,方便新空间的申请,且加大系统吞吐量;弊端就是 会因为移动对象而产生过多的停顿 。

还有一种中和式,就是在内存碎片多到无法容忍时进行整理,CMS 便是这样的原理。

3.3. HotSpot 算法细节

3.3.1. 根节点枚举

  • 在 JVM 里,固定可作为根节点的有全局性引用和执行上下文(栈帧中的本地变量表)。
  • 迄今为止, 所有的根节点枚举都是需要暂停用户程序的 。
  • 通过一种称为 OOPMap 的数据结构,JVM 可以快速得知所有引用的位置 。

因为在 类加载时会确定对象偏移量多少的位置上,数据是什么类型 ,加上即时编译时,也会记录栈和寄存器里哪些保存的是引用类型,所以最终可以 直接得到所有引用的位置 。然后把它们加入到 OOPMap,进而选出根节点。

JIT 记录 OOPMap 具体过程:一个线程意味着一个栈,一个栈由多个栈帧组成,一个栈帧对应着一个方法,一个方法里面可能有多个安全点(见下面)。 GC 发生时,程序首先运行到最近的一个安全点停下来,然后更新自己的 OOPMap ,记下栈上哪些位置代表着引用。枚举根节点时,递归遍历每个栈帧的 OOPMap,通过栈中记录的被引用对象的内存地址,即可找到所有的 GC Roots。

OOPMap 还可用作准确式 GC(以下内容为 转载 )。

  1. 保守式 GC 在进行 GC 的时候,会从一些已知的位置(GC Roots)开始扫描内存,扫描到一个数字就判断它是不是可能是指向 GC 堆中的一个指针(这里会涉及上下边界检查(GC 堆得上下界是已知的)、对齐检查(通常分配空间的时候会有对齐要求,假如说是 4 字节对齐,那么不能被 4 整除的数字就肯定不是指针),之类的)。然后一直递归的扫描下去,最后完成可达性分析。这种模糊的判断方法因为无法准确判断一个位置上是否是真的指向 GC 堆中的指针,所以被命名为保守式 GC。这种可达性分析的方式因为不需要准确的判断出一个指针,所以效率快,但是也正因为这种特点,它存在下面两个明显的缺点:
  • 因为是模糊的检查,所以对于一些已经死掉的对象,很可能会被误认为仍有地方引用他们,GC 也就自然不会回收他们,从而引起了无用的内存占用,就是典型的占着茅坑不拉屎,造成资源浪费。
  • 由于不知道疑似指针是否真的是指针,所以它们的值都不能改写;移动对象就意味着要修正指针。换言之,对象就不可移动了。有一种办法可以在使用保守式 GC 的同时支持对象的移动,那就是增加一个间接层,不直接通过指针来实现引用,而是添加一层“句柄”(handle)在中间,所有引用先指到一个句柄表里,再从句柄表找到实际对象。这样,要移动对象的话,只要修改句柄表里的内容即可。但是这样的话引用的访问速度就降低了。Sun JDK 的 Classic VM 用过这种全 handle 的设计,但效果实在算不上好。
  1. 准确式 GC

与保守式 GC 相对的就是准确式 GC,何为准确式 GC?就是我们准确的知道,某个位置上面是否是指针,对于 Java 来说,就是知道对于某个位置上的数据是什么类型的,这样就可以判断出所有的位置上的数据是不是指向 GC 堆的引用,包括栈和寄存器里的数据。

网上看了下说是实现这种要求的方法有好几种,但是在 java 中实现的方式是:从我外部记录下类型信息,存成映射表,在 HotSpot 中把这种映射表称之为 OOPMap,不同的虚拟机名称可能不一样。

实现这种功能,需要虚拟机的解释器和 JIT 编译器支持,由他们来生成 OOPMap。生成这样的映射表一般有两种方式:

  • 每次都遍历原始的映射表,循环的一个个偏移量扫描过去;这种用法也叫“解释式”;
  • 为每个映射表生成一块定制的扫描代码(想像扫描映射表的循环被展开的样子),以后每次要用映射表就直接执行生成的扫描代码;这种用法也叫“编译式”。总而言之,GC 开始的时候,就通过 OOPMap 这样的一个映射表知道,在对象内的什么偏移量上是什么类型的数据,而且特定的位置记录下栈和寄存器中哪些位置是引用。

3.3.2. 安全点

  • 因为在一个程序里,导致引用关系变化的指令非常多,所以不可能为此一一记录,那样会造成过多空间来记录。由此引入安全点。
  • 安全点的意义在于,引用关系时常变化,做不到对于所有引用关系的记录,于是当需要 GC 时就把程序停靠在安全点上,然后统计 GCRoots。

安全点的选择基本遵循“是否具有让程序长时间执行的特征”来选择的,比如循环,异常抛出,方法调用等。只有具有这些特征的指令才会被放置安全点。说白了就是 如果一段代码会长时间执行 (比如循环,或者方法调用),那么我们 不能等到这段代码执行完再添加安全点 , 只能把安全点插入到这段代码内部 ,以此防止它长时间执行影响 GC;比如插入在循环块内最后的位置,方法返回的位置(相当于在方法与方法之间插入)。

另一个问题是,怎么让程序在安全点停下来?有两个方案,抢占式中断和主动式中断。前者基本不再使用,来看看后者。

主动式中断比较简单,在安全点前面添加一个 test 汇编指令,当希望线程停下时,就通过 JVM 把 test 后面的地址设置为不可读,这样就可以触发线程产生自陷异常,被预先注册好的处理程序挂起。

在这里可以看出,所谓的枚举 GC Roots 时发动 The World 能力是通过让线程发生中断来实现的。

线程中断的位置是离它最近的安全点,所以 GC 开始枚举时必须等待所有线程全部跑到最近的安全点才行(此时忽略那些非运行的线程)。因为安全点有很多,所以当 JVM 发动能力后,很快啊!所有还在运行的线程基本马上立刻就停了。

然后更新 OOPMap,进行枚举 GC Roots。

3.3.3. 安全区域

  • :corn:安全点很好的解决了程序运行时的 OOPMap 更新问题,但是如果程序不在运行,而是在阻塞或者被调度离开 CPU 怎么办?此时就需要安全区域。
  • 安全区域指的是在某个代码片段内,引用关系不会发生变化,因此在这个区域任何一个位置进行 GC 都是安全的。

安全区域具体过程:

  • :one:当程序进入安全区域时,首先进行标记,表明自己到了安全区域,此时 GC 就可以忽略这些在安全区域内的程序了;
  • :two:然后当它准备离开时,会判断 GC 是否完成了根节点的枚举,如果未完成,则等待,否则继续执行。

3.3.4. 记忆集

  • 记忆集是为了 解决跨代引用的问题 而引入的,有了记忆集,就可以 知道哪块区域存在跨代引用 。

记忆集一般有这三种:

  1. :one:字长精度:精确代机器字长,就是处理器寻址位数,查看该字是否包含跨代指针。
  2. :two:对象精度:精确到每个对象,查看对象是否含有跨代指针。
  3. :three:卡精度:精确到某个内存区域,查看这个内存区域是否含有跨代指针。

其中,卡精度是使用最多的,它有点像分页,把内存划分成固定大小的区域,然后 总 内 存 大 小 / 每 个 内 存 区 域 大 小 = 卡 表 长 度

如果这块内存区域含有跨代指针,则卡表对应元素为 1,否则为 0。

3.3.5. 写屏障

  • 写屏障的存在是为了实现对卡表的更新,它使用类似 AOP 的技术。

首先,写屏障会对引用赋值代码进行 AOP 切入,使用的是环形通知(Around)。赋值代码之前的部分是 写前屏障 ,而之后的成为 写后屏障 。

在应用了写屏障技术后,JVM 就会为赋值指令生成相应的更新卡表指令。

但是还有一个问题,称为 伪共享问题 (缓存系统中是以缓存行(CacheLine)为单位存储的,当多线程修改互相独立的变量时,如果这些变量共享同一个缓存行,就会无意中影响彼此的性能,这就是伪共享),这个问题在此不再描述。一种简单的解决方案是:不采用无条件的写屏障,而是使用判断-更新操作来进行,每次 更新卡表之前,首先检查卡表的值,如果是 0,则更新为 1,否则不变 。

3.3.6. 并发的可达性分析

  • 根节点枚举可以控制在范围时间内,但是 对堆内对象的标记会随着对象的增加而增加 。
  • 为了保证标记时不会因为引用关系变化而进行错误标记,所以依旧需要发动 The World 能力;如果堆内对象过多的话,就会导致时停过长,影响程序运行。

暂停用户程序的原因是为了保证标记时不会发生引用关系变化,既然是为了标记这段时间引用关系不变,那么可以 生成快照,然后在快照上进行 。

但是有什么办法可以 在生成快照时也不会暂停用户程序或者不会被运行时的用户程序影响快照 呢?

引入了三色标记原理。

首先定义定义三个标记色:

  1. :white_circle:️白色:表示对象 尚未被标记 ,在标记开始之前,所有对象应该都是白色的。如果标记完成还是白色,表明对象不可达。
  2. 灰色:表示对象 已经被 GC 访问过 ,但是 至少还有一个它引用的对象没被标记 。
  3. :black_circle:️黑色:表示对象已被 GC 访问过 且它引用的对象也全部被访问过 ,黑色对象表示可存活。GC Roots 默认黑色。

如果无法保证快照一致性,那么在标记时会出现两种情况:

  1. 原本是应该保留的对象,结果用户程序删除了引用关系,导致它应该被回收却没有回收,反应在图中就是所有指向某个黑色节点的引用全部消失了。
  2. 原本是应该删除的对象,结果用户程序又重新引用了,导致它应该被保留却被回收了,反应在图中就是某个白色节点在扫描完成后突然有了指向它的引用。这种情况比第一种 更加危险 ,因为未回收的对象,可以在下一次 GC 回收,但是如果应该保留的对象被删除了,就会造成空指针异常。

第一种抛弃不说,因为这体现出来的仅仅是回收不及时(下次就会回收)罢了,但是第二种需要格外注意。

对于第二种情况产生的原因,有两个:

  1. 赋值操作添加了一条或多条从黑色对象到白色对象的引用。
  2. 赋值操作删除了全部从灰色对象到某个白色对象的直接或间接引用。

为了解决误删问题,就需要破坏这两个条件。于是引入 增量更新 和 原始快照 。

增量更新:当有黑色对象向白色对象添加引用时,就记录,然后在扫描完成后,以刚刚那些黑色对象为根,再进行一次扫描。

原始快照:当有灰色对象想删除对白色对象的引用时,就记录下来,扫描结束,以灰色对象为根,再来一次扫描。

一句话:增量更新就是你想增加存活对象我就给你记录然后增加;原始快照就是你想删除可能存活的对象我就记录,不给你删,想删吗?等下次 GC 吧!

不论是增量更新还是原始快照,对它们(增量更新或原始快照记录的变化)进行处理 依旧需要暂停用户程序 ,但是因为它们量比较少,所以产生的停顿时间可以暂时忽略。

这是在并发条件下的分析,如果回收器不是并发的,而是直接发动时停能力,那就直接扫描标记,也不存在上述问题。

对了,引用关系的删除和添加的记录操作是通过写屏障实现的。

标记完了,具体的回收行为取决于具体的回收器。

3.5. 经典垃圾收集器

  1. :soccer:️Serial 收集器。负责对新生代进行收集,使用标记-复制算法。它在运行时必须暂停用户程序,因此可能交互体验不太好,也是客户端默认的收集器。但是它是单线程的,所以适合单核处理器或核数较少的情形;加之内存消耗最小,没有线程交互的开销,是一款很经典的收集器。

  1. :basketball:SerialOld 收集器。同样是单线程的,前者收集器的老年代版本。所以使用标记-整理算法。除此之外,它还可以作为 CMS 发生失败时的预选方案(见后述)。

  1. :football:ParNew 收集器。是 Serial 的并行版本,除此之外和 Serial 没什么区别。

  1. :baseball:ParallelScavenge。作用于新生代,多线程,使用标记-复制算法,但是它和 ParNew 的区别在于,它更注重吞吐量而不是延迟。
  2. ParallelOld 收集器。作用于老年代,是 ParallelScavenge 的老年代版本,基于标记-整理算法。

  1. CMS 收集器。关注响应速度,以停顿时间为目标,使用标记-清除算法。

它的实现略微有点复杂,但是它刚好体现了前面讨论的并发的可达性分析原理。整体分为四个步骤:

A. :wrench: 初始标记 。仅仅标记一下 GC Roots 能直接关联到的对象。

B. :hammer: 并发标记 。与用户线程一起执行,遍历整个对象图,同时记录这期间用户程序作出的更改。

C. ⛏ 重新标记 。修正并发标记期间用户程序产生的修改,就是把并发期间标记为不可达的但因为引用关系变化而又可达的对象,重新标记为可达。这一步需要暂停用户程序。但是因为数量少,所以并不会花费很多时间。

D. 并发清除 。回收对象,由于不需要移动对象,所以也是可以于用户程序并行执行的。

但是它也有三个明显的缺点:

一、:no_good|type_1_2:对处理器资源很敏感。

二、:no_good|type_3: 无法处理浮动垃圾 (因为标记是并发的,所以标记过程可能还有垃圾产生,这只能留到下一次回收),导致内存空间被垃圾占用,如果不幸的话,甚至可能导致在并发标记阶段预留的堆大小不够用户程序使用。这样就会触发“并发失败”;此时将会冻结用户线程,使用 SerialOld 进行老年代垃圾收集。

三、:no_good:标记-清除算法会导致 空间碎片 ,此时不得不进行一个 Full GC(整堆和方法区收集)。这样会导致空间整理时产生更长的停顿。

关于浮动垃圾,其产生是因为重新标记只会处理并发标记阶段被记录的变化中从不可达->可达的对象,反之不会处理,所以才会产生浮动垃圾;要么就是并发标记的记录不会记录可达->不可达的变化。总之,要么是没记录新的垃圾,要么是记录了但是没处理新的垃圾。

以上仅为本人理解,为防误导请读者自行谷歌。

  1. :8ball:G1 收集器。G1 是一个里程碑意义的收集器,其重要意义在于开创了局部收集的设计思路和基于 Region 的内存布局。它还是一款主要面向服务端的垃圾收集器。

G1 为了更好地回收,取消了传统的新生代和老年代的概念,回收的范围扩大到了整个堆。最后通过特殊的算法判定哪个区域回收收益最大,组成 回收集 进行回收。

在 Region 中有一类特殊的 Humongous 区域,专门用来存放大对象。大对象可以跨区存储,即使用连续的 Region 来存储。G1 的大多数行为都把 Humongous 当初老年代来处理。

G1 还把 Region 当成最小回收单位,每次优先回收价值最大的 Region。

看一下 G1 的内存布局:

对于 Region 的跨引用问题,G1 的解决方案是进行跨区域记录且使用更加复杂的记忆集,具体表现为:谁指向我和我指向谁;是一个双向的映射集。

G1 还使用 原始快照 (SATB)算法来保证并发标记过程中的引用变化,同时为了保证收集过程中的新对象分配,设置了两个 TAMS 指针,指针上的区域 G1 默认是标记过的,所以新的对象可以分配在指针之上来确保存活。

还有一些其他的问题,可以参考 G1的原理实现

现在来看看 G1 的实际工作过程:

A. :mailbox_closed: 初始标记 。仅仅标记一下 GC Roots 能直接关联到的对象,并设置新的 TAMS 指针的值。此阶段可以借助 Minor GC 同步完成,所以耗时几乎忽略不计。

B. :mailbox: 并发标记 。扫描并标记整个对象图,并使用 SATB 进行记录此过程中的引用变化。

C. :mailbox_with_mail: 最终标记 。暂停用户程序,处理 SATB 记录。

D. :mailbox_with_no_mail: 筛选回收 。重新更新 Region 统计数据并排序,或通过用户自定的策略选定 Region(s)构成回收集,然后把存活 Region 复制到空的 Region(自动整理内存空间)并清空旧的区域,这个移动对象的过程需要暂停用户程序。

用户可以选择停顿时间是 G1 的一个很重要的特性。可以在停顿时间和吞吐量之间取得一个实际的平衡。

同时为了实现原始快照搜索,G1 还使用了写前屏障,不过由于 G1 的写屏障更复杂,所以使用的是异步实现。

3.6. 低延迟垃圾收集器

Shenandoah

和 G1 一样,Shenandoah 支持 Region,支持 Humongous 和回收价值最大的,但是 Shenandoah 支持回收阶段与用户线程并发,默认不使用分代收集,使用连接矩阵而不是记忆集。

连接矩阵很简单,就是一个 M*N 的表格,如果 i 和 j 存在引用关系,那么 i-j 就被打上标记。

来看看 Shenandoah 的工作原理:

  • :car:初始标记:和 G1 一样,找到 GC Roots 可以直接访问到的,需要 时停 。
  • :taxi:并发标记:并发标记,遍历图,找到不可达节点,不需要时停。
  • :blue_car:最终标记:和 G1 一样,处理 SATB 扫描。并统计回收价值最高的 Region,生成回收集。会产生小时段 时停 。
  • :bus:并发清理:清理那些整个 Region 没有一个存活对象的 Region。
  • :trolleybus:并发回收:把回收集中的对象复制到空白 Region 中,通过“转发指针”解决,不需要时停,这也是 Shenandoah 最不同的地方。
  • 初始引用更新:把堆中指向旧对象的引用修正到新的对象上去,这个阶段并不做实际工作,仅仅建立线程集合点并确保所有并发回收阶段的线程完成了对象移动工作,非常短暂的 时停 。
  • :police_car:并发引用更新:与用户线程并发,取决于内存中涉及的引用数量;仅需按照地址序更新引用值。
  • :ambulance:最终引用更新:修正对 GC Roots 的引用,需要 时停 ,取决于 GC Roots 的数量。
  • :fire_engine:并发清理:释放回收集中的 Region。

所谓引用指针。就是给对象头添加一个指针,正常下这个指针指向自己,此方式有点像前面提到的句柄定位。更新对象引用时,仅仅需要更改这一处即可。让每次访问旧对象时,自动转发到新对象位置,这样只要旧对象还在,就会访问到新的对象。

但是转发指针必然存在并发问题,比如收集器线程设置指针,而用户线程访问,还有就是对于对象的写入,只能写入到新对象上。对于并发问题,可以使用 CAS+失败重试解决。

对于转发指针的设置,是在并发回收阶段处理的,然后在最终引用更新更新引用(指针)的值。

要覆盖全部对象访问操作,Shenandoah 必须使用读,写屏障去拦截。

对于读写屏障,尤其是读屏障,性能代价是很大的,因为系统中读操作更多。可以改成基于引用访问屏障来解决,也就是仅拦截引用类型的读写操作,而忽略普通类型。

ZGC

ZGC 是一款基于 Region 内存布局,不设分代(暂时)。使用了读屏障,染色指针和多重内存映射技术来实现可并发的标记-整理算法,以低延迟为目标的一款垃圾收集器。

ZGC 的 Region 分为三个区域:

  • :one:小型 Region:固定为 2MB,用于放置<256KB 的小对象。
  • :two:中型 Region:固定为 32MB,用于放置 256KB<=对象大小<4MB。
  • :three:大型 Region:容量可以变化,但必须是 2MB 的整数倍,用于放置>=4MB 的对象,每个大型 Region 只会放置一个大对象。大型 Region 不会被重分配。

ZGC 的另一个标志性技术是 染色指针 。之前的三色标记法,看起来是标记对象,实则可以是标记引用,什么意思呢?

“让所有指向对象的指针失效可以达到标记对象为不存活的效果。”

一个对象的存活与否,取决于是否有引用指向它,所以如果可以通过设置引用(地址指针)某些 bit 位来指明引用的状态,也是一样的标记作用。如果 指向这个对象的所有指针都被标记为不可用,那么这个对象就是需要回收的 。

此时三色标记成了一开始让每个指针的标记位都为不可达,遍历一遍引用图,把可达的引用标记位设为可达,最后还是不可达的引用指向的对象(指向这个对象的所有引用都不可达)就是需要回收的。

而染色指针就是把记录指针是否可用的机制放在了指针构成上。为什么可以这样?因为目前 Linux 支持的最大虚拟地址空间为 47 位,也就是 128TB,和最大物理地址空间 46 位,也就是 64TB。而在 64 位机中,Linux 保留了高 18 位,如果我们可以缩减堆地址的话,那就会有部分高位怎么也用不到。

所以 ZGC 最高支持 4TB 堆和 64 位机,这样第 43-46 位就可为我们所用(实际上目前 ZGC 支持到了 16TB,这个是后话)。

把 4 位分成:保留位-Remapped 位-Mark1 位-Mark0 位这四种便可以实现把引用状态信息保存在指针中。

染色指针有三大优势:

  1. 某个 Region 一旦被移走就立刻可用,因为移走之后指向这个位置的指针被标记为 Remapped,接下来通过“自愈”技术修复即可。所以下一次访问这里的指针会自动重定向到新的位置。
  2. 是一种可扩展的结构,方便日后提高性能。
  3. 可以大幅减少内存屏障使用的数量。因为 ZGC 不需要记录跨代引用,其一是它可以把这些信息记录在指针里,二是它不支持跨代引用。

但是有一个问题,Java 作为一个进程,直接修改内存指针,OS 是否支持?事实上,因为虚拟地址到物理地址的映射关系,加上 ZGC 使用了空间换时间,使得这个问题得以解决。所谓空间换时间,只是把三(如果算上原本的地址就是四个)个地址映射到了同一个物理地址空间。

首先,OS 在做虚拟->物理空间映射时,把地址后 m 位提出来,把前 64-m 位当成页表索引,找到 n,再把 n+m 拼接到一起(不是加到一起),作为物理地址,n 的范围,m 的值由 OS 和实际内存大小决定。

所以可以知道,如果后 m 位一致,那么多个虚拟地址会映射到同一个物理地址的。 详见 。所以 ZGC 利用这一点,把四个标志位不同的虚拟地址提前申请了,让它们都映射到同一个物理地址,解决了 OS 不支持的问题。

如果把 4 位标示位看成内存分段符,算上原本的,四位标识位,有 0000,0001,0010,0100 四种,所以它们在后 42 位相同的情况下,有 0+4TB,4+4TB,8+4TB,16+4TB 的内存空间起始间隔。

现在来看看实际的工作流程:

  • :watch:️并发标记。做图的可达性分析,类似于 G1 和 Shenandoah 的初始标记,最终标记。 此阶段会更新染色指针的 Mark0 和 Mark1 标志位 。
  • :computer:并发预备重分配。根据特定的查询条件 找出哪些 Region 需要清理 ,并 组成重分配集 。ZGC 通过全堆扫描获取重分配集,替代了 G1 维护记忆集的成本。
  • 并发重分配。这是 ZGC 的 核心阶段 。 首先 把重分配集中的存活对象 复制到新的 Region 中 ,并维护一个转发表,用来记录对象旧地址到对象新地址的映射。因为染色指针的特点,ZGC 可以通过引用(指针值)得知一个对象是否处于重分配集。如果程序 访问处于重分配集中的对象 ,会被内存屏障捕获,然后 转发到新的对象位置去 ,同时 更新引用的值 。这样的话,只有第一次访问被移动的对象会陷入处理,往后都一步到位,这样的话比 Shenandoah 的转发指针负载更小一些。这个称为 ZGC 的“自愈”,一旦某个 Region 里的 对象全部复制完毕 ,便可 释放 ,但是保留转发表,以防其他对旧对象的引用找不到新对象在哪。无论某个旧对象还有多少个引用,都可以通过“自愈”技术解决。
  • :iphone:并发重映射。 修改整堆中指向重分配集中的旧对象的引用 ,这么做的目的是为了让程序不变慢,以及完全修正后释放转发表的收益。

还有一个问题,就是 ZGC 取消了新生代,所以在对象生命周期很短的场景下,可能回收的不会那么及时。

同时还有一个优点,在支持 NUMA 的处理器上,会优先在当前线程所在的 CPU 核心本地缓存上分配,以保证高效的内存访问。

1.1 String字符串深入理解原理 String核心设计思想 阅读详情

相关推荐

SpringBoot+Vue+MySQL 高校学科竞赛平台 完整可运行版(源码+文档)

本项目基于SpringBoot+Vue+MySQL构建高校学科竞赛管理平台,覆盖学生、教师、领队教师、管理员四大角色,实现竞赛发布、报名、刷题、评分、公示等全流程闭环管理。系统架构清晰,技术主流,配套完整源码、数据库脚本、万字论文、开题报告、答辩PPT及部署文档,支持一键运行,适用于毕业设计、课程设计及院系二次开发,真正实现“开箱即用”。

招风的黑耳优快云 295

JVM垃圾回收器之Shenandoah收集器

白色:表示对象尚未被垃圾收集器访问过。显然在可达性分析刚刚开始的阶段,所有的对象都是 白色的,若在分析结束的阶段,仍然是白色的对象,即代表不可达。黑色:表示对象已经被垃圾收集器访问过,且这个对象的所有引用都已经扫描过。黑色的对象代 表已经扫描过,它是安全存活的,如果有其他对象引用指向了黑色对象,无须重新扫描一遍。黑色对 象不可能直接(不经过灰色对象)指向某个白色对象。灰色:表示对象已经被垃圾收集器访问过,但这个对象上至少存在一个引用还没有被扫描过。

IT枫斗者的博客 1242

STC15单片机实战项目软件开发第四讲 - 按键检测(状态机)

软件开发第四讲 - 按键检测(状态机) 一、工具链接 1、Keil c51 UV4 https://yunpan.360.cn/surl_yrIfYYmeRFk 2、STC-ISP下载软件 https://yunpan.360.cn/surl_yxH4CuUUvI3 二、学习板获取方式 方法1:自己设计PCB,自行打板焊接; 方法2:用群主设计的PCB,自行打板焊接; 方法3:向群主购买,淘宝链接...

硬件家园 3049

jvm低延迟垃圾收集器(Shenandoah)

出入:深入理解Java虚拟机:JVM高级特性与最佳实践(第3版) 衡量垃圾收集器的三项最重要的指标是:内存占用(Footprint)、吞吐量(Throughput)和延迟(Latency),三者共同构成了一个“不可能三角”。三者总体的表现会随技术进步而越来越好,但是要在这三个方面同时具有卓越表现的“完美”收集器是极其困难甚至是不可能的,一款优秀的收集器通常最多可以同时达成其中的两项。 内存占用、吞吐量和延迟这三项指标里,延迟成为垃圾收集器最被重视的性能指标。 其原因是随着计算机硬件的发展、性能的提升,我们越

qq_26855461的博客 638

看完让你的RSA提升一个台阶 [GKCTF 2021]RRRRsa

我是,今天我给大家分享一个关于RSA的题目,带大家领略的魅力。今天开始前,我们先学习一下。

m0_62102520的博客 853

Thompson Sampling vs UCB:5个真实业务场景下的算法选型指南

本文深入对比了Thompson Sampling与UCB算法在电商推荐、在线广告、医疗试验、金融定价和游戏调整五大真实业务场景下的表现。通过量化指标分析,为技术决策者提供了清晰的选型指南:Thompson Sampling凭借其概率匹配和贝叶斯框架,在需要快速收敛、处理冷启动及复杂约束的场景中通常更具优势;而UCB则在要求确定性、可解释性和理论保证的场景下表现更佳。

weixin_29181861的博客 153

Java字符串常量池深度解析:内存分布、JDK演进与实战调优

字符串常量池是Java中管理字符串字面量的核心内存机制,其本质是堆内受控的哈希表结构,而非独立内存池。它基于开放寻址法实现,哈希计算依赖对象地址而非内容,直接影响==比较语义和intern()行为。从JDK6永久代到JDK7+堆内存的迁移,彻底改变了GC策略与OOM排查逻辑;而字符串去重(String Deduplication)和分片(Sharding)等现代优化,则进一步提升高并发场景下的稳定性与性能。理解其在JVM内存模型中的真实位置、扩容阻塞特性及与G1/ZGC的协同机制,对解决线上内存泄漏、JSO

weixin_30374009的博客 542

FRM模型五:Brinson业绩归因

利用Brinson对基金组合业绩进行归因分析。

stonesssssss的博客 3009

知识总结 - 常见问题

1. InnoDB存储引擎,三层B+树,单表能存储多少数据MySQl存储单元为1页,1页为16K,即16384Byte非叶子节点存储单元为主键+指针,大小为8+6=14Byte单页可存储16384/14 ≈ 1170个单元,即可存储1170个子节点每个存储单元可标记1页,则前两层可标记1170x1170页第三层叶子节点存储数据,每条数据约1K,单页则可存储16/1=16个数据三层 B+树,单表可存数据约为1170x1170x16=21902400条数据。

yuyehui的博客 834

BEV感知技术全景解析:从纯视觉到多传感器融合的实战指南

本文全面解析了自动驾驶中的BEV感知技术,从纯视觉到多传感器融合的实战应用。文章深入探讨了BEV如何提供“上帝视角”,对比了IPM、Lift-Splat-Shoot及Transformer等核心实现范式,并详细阐述了多传感器融合(尤其是中融合)的工程实践与优势,为开发者提供了从理论到原型搭建的完整指南。

win55的博客 1141

基于STM32设计的智能井盖(Lora组网+4G上云)_423

设计并实现了一种基于 STM32 与 Lora 通信技术的智能井盖监测系统。系统采用 STM32F103C8T6 作为核心控制器,在井盖节点端集成水位检测、有害气体检测、倾斜角度检测以及 GPS 定位等功能模块,对井盖内部环境状态和姿态变化进行实时监测。各井盖节点通过 Lora 无线通信方式组成网络,将采集的数据发送至主机(网关)进行汇聚处理。

720

Java秋招八股之王——拿不到大厂Offer来找我(全文14w字,持续更新)

如果你需要一套八股文,想要一套八股文解决秋招的大部分问题,来看看这篇文章吧~内容包含数据结构、计算机网络、数据库、Redis、消息队列、JavaSE、JVM、JUC、海量数据题目、高并发场景、项目的亮点难点、ssm框架、dubbo框架等等。这是我整个秋招不停面试的总结和反思,最后也是靠它拿到了菜鸟的offer,希望会对后面秋招的朋友起到一些帮助。

qq_40460454的博客 1293

制作你自己的yolov5数据集并进行训练

项目地址:make-your-yolov5_dataset 一、数据集标注软件 labelImg labelMe 精灵标注助手 更多的标注工具你可以去看:深度学习图像标注工具汇总 、 十个最常用深度学习图像/视频数据标注工具 、 百度一下 在这里我选择使用labelImg 二、数据集标注 下载labelImg 解压,直接打开labelimg.exe labelImg标注数据 标注完成后即可获得VOC格式(生成.xml文件)的数据 目录结构: dataset │ ├─Annotations .

年少的勇气已经用完,剩下的就是三思而后行 1万+

InnoDB 内存结构总览:Change Buffer、AHI 与 Log Buffer

本文系统梳理InnoDB内存结构中的ChangeBuffer、Adaptive Hash Index(AHI)与LogBuffer。ChangeBuffer缓存二级索引页不在内存时的变更,减少随机IO,仅在页加载时合并;聚簇索引修改立即执行,因数据本身不可延后。AHI基于高频等值查询自动构建哈希索引,加速定位,仅支持等值操作。LogBuffer为redo log内存缓冲,配合磁盘日志实现崩溃恢复。三者均服务于性能优化,其中ChangeBuffer与AHI共享BufferPool空间,而LogBuffer独立

bksczm的博客 303

Android软键盘系统深度解析:架构、原理与实现

Android软键盘系统深度解析:架构与实现原理 本文系统剖析了Android软键盘的核心工作机制。作为系统级输入服务,软键盘具有双重属性:既是可安装的独立应用,又深度集成于系统框架中。文章详细解读了软键盘的系统架构,包括其与InputMethodManagerService(IMMS)和WindowManagerService(WMS)的交互机制,以及通过InputConnection实现的跨进程通信流程。同时分析了输入法窗口的特殊管理策略、布局调整模式,并深入探讨了输入预测引擎的实现原理,包括词典加载、

保持专注,保持热爱 116

精雕软件(JDpaint) 5.5 绿色版

精雕软件(JDpaint) 5.5 绿色版精雕软件(JDpaint) 5.5 绿色版精雕软件(JDpaint) 5.5 绿色版

第三篇 第 6 课

前置:第三篇第 1 课(stdx 引入),第二篇第 1 课(std.regex)。环境:仓颉 SDK 1.2.0。本文所有代码与输出均逐行实测。

weixin_41618057的博客 527

Statistics and Machine Learning Toolbox

Statistics and Machine Learning Toolbox(带书签),MATLAB统计学习工具

V8 机制、手写核心函数与 TypeScript 类型体操

或固定初始化(如数组先定长、对象先给全字段)保持类型与形状稳定;这些都比"换框架"更基础。只能处理纯数据(丢函数、Date、RegExp、循环引用报错)。生产级深拷贝要处理:原始值短路、循环引用、V8(Chrome / Node 的 JS 引擎)不是"解释一行跑一行"那么简单,它用。图:V8 执行流程——解释执行 + 热点优化,优化失败会 Deopt 回退。JS 内存自动管理,但"自动"不等于"不会漏"。是"固定间隔最多执行一次"。手写 Promise 是"懂异步"的试金石。是"停下后才执行最后一次",

qq_38468358的博客 355

IMSL CNL 7.0.0 (x86-64)Crack

IMSL CNL 7.0.0 (x86-64)Crack intel的算法库破解文件,这是C++版本的破解文件

Devexpress16.2.6破解工具

Devexpress16.2.6破解工具Devexpress16.2.6破解工具Devexpress16.2.6破解工具Devexpress16.2.6破解工具

信创环境下PageOffice文档控件适配与实践

信创适配做到文档控件这一层,拼的已经不是"有没有国产版本",而是同一个功能在两类终端上是否真的等价。等价的标准很具体:同一次点击能得到同样的结果,同一份公文能得到同样的版式,同一个项目不需要维护两套代码。这背后是三件并不轻松的工作:服务端用一套接口同时支撑两类终端,客户端把两种系统与四种芯片的安装包分发清楚,文档内核层由产品侧跟着 WPS 的版本走。浏览器这一层的压力,则被收进了产品自己的窗口里——升级节奏由产品掌握,不再交给运气。

zi_wu_xian的专栏 332

【从零理解 Spring AI · 12】MCP 的两种角色:做客户端还是做服务端

上一篇讲了 MCP 的概念:标准协议、三大角色、M+N 替代 M×N。

nice_lcj520的博客 563

Java 集合容器高频面试题全解:HashMap 树化阈值、1.7 死循环与 ConcurrentHashMap 演进(2026 招聘 JD 真题复盘)

HashMap 树化阈值 8 的泊松分布依据、退化阈值 6 的防抖设计、JDK 1.7 头插成环死循环的排查与修复、1.8 尾插后为何仍线程不安全、ConcurrentHashMap 放弃分段锁的原因与 ForwardingNode 协作扩容、computeIfAbsent 死锁陷阱、大集合找重复的场景题选型,附三层追问链自测。题目来源为 38 期公开招聘 JD 样本与字节、拼多多、建行、美团、快手公开面经。

蒙眼过河 422

spdlog 同步日志库使用详解

spdlog是一个轻量级 C++ 日志库,支持同步日志、异步日志、多线程日志、日志文件轮转和自定义日志格式。对于嵌入式 Linux 应用开发,我建议先使用。它可以帮我们完成:日志级别过滤。时间戳和线程 ID 输出。日志格式化。终端和文件输出。多线程安全写入。日志文件按大小或时间轮转。我们不需要自己实现互斥锁、文件写入、日志格式化和轮转等底层功能。

zengjiajun2006的博客 1121

JavaSE通关手册:数据类型与变量

代码无需额外处理,编译时编译器自动完成转换。

xys_678的博客 231

Java 多态 学习笔记

多态是面向对象编程的重要特性,指同一操作作用于不同对象时产生不同行为。其核心在于“父类引用指向子类对象”,结合方法重写实现运行时动态绑定。多态使代码更灵活、可扩展,如用Person p = new Student();统一处理各类人员注册,无需修改调用逻辑。但多态无法直接调用子类特有方法,需通过instanceof判断并强制转换。编译时看左边(类型),运行时看右边(实际对象),实现解耦与维护便利。

AA_AA114514的博客 368

IDEA 简介:从零认识 IntelliJ IDEA

IDEA 全称,是由 JetBrains 公司开发的一款主流 Java 集成开发环境(IDE)。它集代码编辑、编译、调试、测试、版本控制、构建管理等功能于一体,被大量 Java 开发者视为日常开发的首选工具。所谓 IDE(Integrated Development Environment,集成开发环境),就是把写代码所需的各类工具整合到同一个界面中。相比只用文本编辑器加命令行编译,IDE 能显著减少重复操作,让开发者把精力集中在业务逻辑上。

ztI10的博客 176

Java实现批量线段相交检测(跨积判定+扫描线优化与踩坑复盘)

【代码】Java实现批量线段相交检测(跨积判定+扫描线优化与踩坑复盘)

m0_64476639的博客 341

动态代理两大流派:JDK 代理与子类代理

业务类想加日志,最直白的写法是侵入式修改: 问题很明显:每个方法都要改一遍,改一个业务要动十处。代理模式的解法是:调用方不直接依赖 ,而是依赖一个代理对象。代理对象长得跟目标一模一样,调用它 → 代理先执行增强逻辑 → 再转调真实对象。 关键在于:代理对象的类型是哪来的?这就是"动态"二字的含义:代理类在运行时才诞生。两个角色: 承载增强逻辑, 负责生成代理类。 组装代理并调用: 输出: 业务代码 一行没动,增强逻辑全在 里。这就是 AOP 的味道。 内部四步: 第 2 步 内部:先查 (同一个接口

Android的专栏 603
上一篇: Netty源码分析之Reactor线程模型详解
下一篇: Java进阶——IO流核心模块与基本原理
JavaShark
博客等级 码龄4年 184粉丝 438原创
评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

个

红包个数最小为10个

元

红包金额最低5元

当前余额3.43元 前往充值 >
需支付:10.00元
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付元
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值