JVM-内存相关

JVM 内存相关知识

内存分配

image-20230408202911552

  1. 指针碰撞(CAS 来解决并发问题)
  2. 空闲列表(连续的内存区域)

逃逸分析

在方法体中创建对象,如果该对象被方法体其他变量引用到,叫方法逃逸,被外部线程访问到叫线程逃逸

优化点:

  1. 同步消除:如果一个对象被逃逸分析发现只能被一个线程所访问,那对于这个对象的操作可以不同步
  2. 栈上分配:如果确定一个对象不会逃逸出线程之外,那让这个对象在栈上分配内存将会是一个很不错的主意,对象所占用的内存空间就可以随栈帧出栈而销毁
  3. 标量替换:如果一个对象被逃逸分析发现不会被外部方法访问,并且这个对象可以拆散,那么程序真正执行的时候将可能不去创建这个对象,而改为直接创建它的若干个比这个方法使用的成员变量来代替. 例: 假设一个对象包含了三个基本变量字段, 则这个对象即为聚合量. 三个基本变量为标量

担保机制

  1. Minor GC之前检查 老年代最大连续空间区域的大小是否大于新生代所有对象的大小
    1. 如果小于, 检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小
      1. 如果小于, 触发 Major GC / Full GC
      2. 其他则 Minor GC
    2. 其他 触发 Major GC / Full GC

栈上分配

对象直接在线程栈帧中进行分配,而不在堆中分配。它主要是为了解决多线程对象分配的低效问题,通过在栈上分配内存,避免了多线程之间的冲突,提高了对象的分配效率。但要注意的是,其只能分配较小对象,并且该对象必须不被其他对象线程引用. 线程结束便自动销毁

前提: 逃逸分析不会逃逸

TLAB(Thread Local Allocation Buffer)

空闲列表. 划一块区域给一个线程,这样每个线程只需要在自己的那亩地申请对象内存,不需要争抢热点指针. 类似发号器. 默认为 eden 区的 1%

缺点:

  1. TLAB通常很小,所以放不下大对象
  2. Eden空间够的时候,再次申请TLAB没问题 ; 但是如果不够,Heap的Eden区要开始GC
  3. TLAB允许浪费空间,导致Eden区空间不连续,积少成多

PLAB(Promotion Local Allocation Buffers)

在对象晋升到 Survivor 区或老年代的时候,提升对象的分配效率. 每个线程都有自己的 PLAB

对齐填充

空间不够 8 的倍数, 会自动填充至 8 的倍数

原因:

  1. CPU向内存读写数据的单位为8字节
  2. CPU可以原子地操作一个对齐的内存块(8 字节)
  3. 尽量分配在同一个缓存行
  4. 尽量不分配在同一个缓存行(解决伪共享)

GCRoots

  • 虚拟机栈中引用的对象
  • 方法区中引用的对象
  • 方法区中常量引用的对象
  • 本地方法栈中JNI引用的对象
  • Java虚拟机内部的引用(类型对应的Class对象, 常驻的异常对象等等)
  • 所有被synchronized持有的对象
  • 反映 Java 虚拟机内部情况的 JMXBean, JVMTI中注册的回调, 本地代码缓存等等

三色标记

  1. 黑色(black) : 节点被遍历完成, 而且子节点都遍历完成
  2. 灰色(gray) : 当前正在遍历的节点, 而且子节点还没有遍历
  3. 白色(white) : 还没有遍历到的节点, 即灰色节点的子节点

漏标

img

同时存在以下两种情况才会产生漏标(因为如果新增引用, 那么一定是删除了引用才会漏标, 如果没有删除, 那就是属于新分配对象, 会自动标记存活. 如果删除引用, 那么一定是新增了引用才会漏标, 如果没有新增, 那就说明确实是垃圾)

  1. 新增了一条或者多条黑色对象到白色对象的引用
  2. 删除了全部从灰色对象到白色对象的直接引用或者间接引用

跨代引用

堆空间通常被划分为新生代和老年代. 由于新生代的垃圾收集通常很频繁, 如果老年代对象引用了新生代的对象, 那么回收新生代的话, 需要跟踪从老年代到新生代的所有引用, 所以要避免每次YGC 时扫描整个老年代, 减少开销

记忆集(Remembe Set)

img

记录新生代对老年代的引用, 避免 YGC 时扫整个老年代

RSet 记录了其他 Region 中的对象引用本 Region 中对象的关系, 属于 points-into 结构(谁引用了我的对象). RSet 的价值在于使得垃圾收集器不需要扫描整个堆找到谁引用了当前分区中的对象, 只需要扫描 RSet 即可

卡表(Card Table)

卡表的意思就是将一个 Region 区分为若干个 card, 组成的一张 card 表, 可以将其理解为数组 card[i], Rset 的存储结构就相当于 hashMap, key 值为引用当前 Region 区的另外一个 Region区 的内存地址, value 就是另外一个 Region 区中引用本 Region 区的 card 的索引值, 意思就是 GC 可以通过 Rset 快速找到是哪个 Region 的哪个 card 对本 Region 进行了引用

写屏障(write barrier)

类似 aop, 分为前置写屏障和后置写屏障

增量更新(Incremental update)

使用后置写屏障(post write-barrier)记录新引用 解决 黑色对象 重新引用了 该白色对象(即新增引用, 破坏条件 1)

当一个黑色的对象获得一个新的引用关系的时候, 就将这个引用关系记录下来, 在后面的重新标记阶段将这些产生了引用关系的黑色对象变为灰色, 并且重新开始一个扫描过程, 如此一来保证了新的引用也会被标记为黑色

例 a.b = null -> a.b = b 此时会将 b 记录下来

起始快照(SATB - snapshot at the begin)

使用前置写屏障(pre write-barrier)记录旧引用 解决 灰色对象 断开了 白色对象的引用(即删除引用, 破坏条件 2)

当一个灰色对象删除了对一个白色对象引用的时候, 就将这个删除的引用记录下来(推送到队列中 satb_mark_queue), 并发标记结束之后, 还会将这些原本的灰色对象按照删除之前的引用重新扫描一次, 这就保证了整个扫描是按照扫描刚开始的图来进行扫描

例: a.b = b -> a.b = null 此时会将 b 记录下来

安全点和安全区域

安全点

用户线程暂停, GC 线程要开始工作, 但是要确保用户线程暂停的这行字节码指令是不会导致引用关系的变化. 所以JVM 会在字节码指令中, 选一些指令, 作为“安全点”, 比如方法调用, 循环跳转, 异常跳转等, 一般是这些指令才会产生安全点. 为什么它叫安全点, 是这样的, GC 时要暂停业务线程, 并不是抢占式中断(立马把业务线程中断)而是主动是中断. 主动式中断是设置一个标志, 这个标志是中断标志, 各业务线程在运行过程中会不停的主动去轮询这个标志, 一旦发现中断标志为True,就会在自己最近的“安全点”上主动中断挂起

安全区域

img

业务线程都不执行(业务线程处于Sleep 或者是Blocked 状态), 那么程序就没办法进入安全点, 对于这种情况, 就必须引入安全区域. 安全区域是指能够确保在某一段代码片段之中, 引用关系不会发生变化, 因此, 在这个区域中任意地方开始垃圾收集都是安全的. 我们也可以把安全区城看作被扩展拉伸了的安全点

实际在线程在进入 Sleep/Blocked 时, 会标记自己进入安全区, 结束此状态时, 也会标识自己结束安全区, 并检查是否在 gc 状态, 如果在 gc 状态, 则会线程挂起, 使 gc 结束之后再执行业务逻辑

CMS

image-20230407013450538

card table

底层是位图(bitmap), RememberSet 的实现. 每一位映射一块内存区域. 存在跨带引用当发生写操作时, 写屏障会将此内存区域快映射位标记为 1. YGC 时直接遍历卡表为 1 的内存区域, 判读是否有对年轻代的引用 即可. YGC 结束时, 会重置卡表复原为 0(如果有引用, 仍然为 1)

代表了老年代对新生代的引用. 当 YGC 时, 无需遍历整个老年代来确定引用, 只需要便利此卡表变化的内存区域即可

mod-union table

底层是位图(bitmap). 每一位映射一块内存区域. 存在跨带引用当发生写操作时, 写屏障会将此内存区域快映射位标记为 1. YGC 时直接遍历卡表为 1 的内存区域, 判读是否有对年轻代的引用 即可. YGC 结束时, 会重置卡表复原为 0(如果有引用, 仍然为 1). 因此会将卡表的信息同步到 mon-union table, 方便老年代 gc时遍历

代表了老年代对新生代的引用. 当 老年代 GC 时, 无需遍历整个年轻代来确定引用, 只需要遍历此脏区域即可

过程

  1. 初始标记(Initial Mark: STW)
    1. 标记 GCRoots 直接关联的对象
  2. 并发标记(Concurrent Mark: 此时新分配的对象都会标记为存活)
    1. 从 GCRoots 直接关联的对象开始遍历标记存活对象
  3. 并发预清理(Concurrent Preclean)
    1. 处理并发标记过程中, 增量变化对象. 遍历标记存活对象(只会处理一次)
  4. 可中止的并发预清理(Concurrent Abortable Preclean)
    1. 死循环执行并发预清理, 直到达到终止条件(循环次数, 最长等待时长, eden 区使用率达到阈值). 尽量等到一次 YGC, 可以使得后续 remark 时需要扫描的脏表更少, 提高性能
  5. 重新标记(Final Remark: STW)
    1. 遍历 GCRoots(非已标记), 循环遍历
    2. 查找由于并发收集器完成跟踪对象后 Java 应用程序线程更新对象而被并发标记阶段遗漏的对象
  6. 并发清理(Concurrent Sweep)
    1. 并发清理非存活对象(内存块)
  7. 重置(Concurrent Reset)
    1. 处理 card table 和 mod-union table 打标等其他情况

为什么最终标记还需要遍历 GCRoots

部分写操作无法使用写屏障(GCRoot), 因此重新标记, 保证存活的对象一定被标记

实际 card table 加 mod-union table 就已经足够了

例: astore_X. 为什么不为 astore_X 添加写屏障, R 大认为是栈和年轻代属于数据快速变化的区域, 对于这些区域使用写屏障的收益比较差

并发清理时如何保证不会将用户线程新生成的对象清除

gc 期间, 所有新分配的对象, 都会直接标记为 存活

内存碎片问题

默认 UseCMSCompactAtFullCollection. 在 full gc 时会压缩

CMSFullGCsBeforeCompaction 控制多少次 full gc 会压缩

什么时候会执行压缩空间

只有当 full gc 时才会压缩空间

是否存在漏标

图片来自网络

存在, 通过写屏障解决. 会将 b 标记为 dirty

例: c 未 mark, c 就会被漏标

  1. a.b = a1
  2. mark a.b, mark a1
  3. a.b = c

是否存在多标(浮动垃圾)

img

存在. 多标的垃圾留到下一次 gc(浮动垃圾)

例: a.b = a1

  1. a.b = a1
  2. mark
  3. a.b = null

常见错误

  1. concurrent mode failure: 由于 cms 存在并发标记阶段, cms 需要在回收时预留部分空间作为分配及新生代的担保, 分配使用. 当在老年代分配空间失败会触发 concurrent mode failure 并执行 fullGC 或 serial old gc
  2. Promotion failed: 新生代GC时会根据历史晋升到老年代的大小, 预估本次老年代是否能够容纳新生代对象晋升到老年代. 如果预估足够, 实际不够将会引发Promotion failed异常
  3. OutOfMemoryError: CMS垃圾收集器发现大部分时间都浪费在GC上就会抛出 OutOfMemoryError 异常, 具体为98%的时间在GC但回收不到2%的空间

G1

堆被分成大约 2000 个区域. 最小大小为 1Mb, 最大大小为 32Mb. 蓝色区域保存老年代对象, 绿色区域保存新生代对象. 还有第四种类型的对象称为 Humongous 区域. 这些区域旨在容纳大小为标准区域 50% 或更大的对象. 新生代内存由一组不连续的区域组成. 这样可以在需要时轻松调整大小

G1 比起 ParallelOld 和 CMS 需要消耗更多的内存, 因为 G1 有部分内存消耗于记账(accounting)上, 如 Remembered Sets 和 Collection Sets

堆空间

img

Region

img

包含五个指针: bottom, preTAMS(pre-top-at-mark-start), nextTAMS(next-top-at-mark-start), top和end

G1 的并发标记阶段使用两个 bitmap

bottom: 该 Region 的开始 内存地址
end: 该 Region 的结束内存地址
top: 该 Region 的当前分配指针

即 [bottom, top) 是当前该 Region 已用的部分, [top, end) 是尚未使用的可分配空间

preTAMS: 上一次并发标记开始时top的位置
nextTAMS: 本次并发标记开始时top的位置
preTAMS 到 nextTAMS 是上次并发标记开始到本次并发标记开始这个过程中新对象分配的空间

prevBitmap: 记录第 n-1 轮并发标记所得的对象存活状态. 由于第 n-1 轮并发标记已经完成, 所以这个 bitmap 的信息可以直接使用
nextBitmap: 录第 n 轮并发标记的结果. 这个 bitmap 是当前将要或正在进行的并发标记的结果, 尚未完成, 所以还不能使用

  1. [bottom, prevTAMS):这部分里的对象存活信息可以通过 prevBitmap 来得知.
  2. [prevTAMS, nextTAMS):这部分里的对象在第 n-1 轮并发标记是隐式存活的.
  3. [nextTAMS, top):这部分里的对象在第 n 轮并发标记是隐式存活的

SATB

img

  1. 假设第n轮标记开始, 将该Region的top指针赋值给nextTAMS. 在并发标记期间, 所有分配的对象都会存在在[nextTAMS, top], SATB可以确保所有对象都是活的
  2. 并发标记结束时, 将nextTAMS赋值给preTAMS, SATB会给[bottom, preTAMS]创建一个快照Bitmap, 则所有的垃圾对象都可以通过该bitmap找到
  3. 第n+1轮并发标记, 和第n轮一样

A阶段, 初始标记阶段, 通过STW, 将Region的top赋值给nextTAMS;

A-B阶段, 并发标记阶段;

B阶段, 是并发标记结束阶段. 并发标记阶段生成的新对象都会放在[nextTAMS, top]之间, 这边对象被定义为“隐式对象”, 同时nextBitmap也记录了bottom到nextTAMS之间标记对象的地址;

C阶段, 是垃圾清理阶段. 会交换prevBitmap和nextBitmap, 同时清理[bottom, preTAMS]之间标记后的垃圾, 对应的“隐式对象”, 在下个阶段才会被标记清理.

Remember Sets

Remember Sets

每个小堆区都有一个 RSet, 用于记录进入该区块的对象引用(如区块 A 中的对象引用了区块 B, 区块 B 的 Rset 需要记录这个信息) , 它用于实现收集过程的并行化以及使得区块能进行独立收集. 总体上 Remembered Sets 消耗的内存小于 5%. 底层实现为 后置写屏障(logging write-barrier)

底层三种结构:

  1. 稀疏表:一个其他 Region 引用当前 Region 中 Card 的集合被放在一个数组里面, Key:Region 地址 Value:card 地址数组
  2. 细粒度:一个 Region 地址链表, 共同维护当前 Region 中所有 card 的一个 BitMap 集合, 该 card 被引用了就设置对应 bit 为1, 并且还维护一个对应 Region 对当前 Region 中 card 索引数量
  3. 粗粒度:所有 Region 形成一个 bitMap, 如果有 Region 对当前 Region 有指针指向, 就设置其对应的 bit 为 1

如果有Rset的数据结构退化成了粗粒度的时候, 要对Region进行回收的时候, 就必须对Region进行全扫描才能正确回收, 这样就大大增大了G1垃圾回收器的工作量, 降低了效率. 为了追求效率一般Young代Region不会有RSet, 因为维护Rset需要消耗不少性能, 而年轻代快速回收的特性, 带来了大量的浪费

Collection Sets

将要被回收的小堆区集合. GC 时, 在这些小堆区中的对象会被复制到其他的小堆区中, 总体上 Collection Sets 消耗的内存小于 1%

收集集合(CSet)代表每次GC暂停时回收的一系列目标分区. 在任意一次收集暂停中, CSet所有分区都会被释放, 内部存活的对象都会被转移到分配的空闲分区中. 因此无论是年轻代收集, 还是混合收集, 工作的机制都是一致的. 年轻代收集CSet只容纳年轻代分区, 而混合收集会通过启发式算法, 在老年代候选回收分区中, 筛选出回收收益最高的分区添加到CSet中

CSet根据两种不同的回收类型分为两种不同CSet

  1. CSet of Young Collection. 只专注回收 Young Region 跟 Survivor Region
  2. 则会通过RSet计算Region中对象的活跃度, 只有活跃度高于这个阈值的才会准入CSet

YGC(STW)

活动对象被疏散(即复制或移动) 到一个或多个幸存者区域. 如果满足老化阈值, 一些对象将被提升到老年代区域. 此阶段是多个线程并行处理

并发标记周期

  1. 初始标记(Initial Mark STW)
    1. 标记幸存者区域(根区域) , 这些区域可能引用了旧一代中的对象
  2. 根区域扫描(Root Region Scan)
    1. 因为先进行了一次 Minor GC, 所以当前年轻代只有 Survivor 区有存活对象, 它被称为根引用区. 扫描 Survivor 区到老年代的引用, 该阶段必须在下一次 Minor GC 发生前结束. 因为在并发标记的过程中迁移对象会造成很多麻烦, 所以这个阶段不能发生年轻代收集, 如果中途 Eden 区真的满了, 也要等待这个阶段结束才能进行 Minor GC
  3. 并发标记 (Concurrent Marking)
    1. 扫描并标记整个堆中所有存活的对象, GC 线程与用户线程同时执行, 并且收集各个 Region 的存活对象信息. 使用写屏障来记录那些发生变化的旧引用的值
  4. 最终标记 (Remark, STW)
    1. 扫描剩下的 satb_mark_queue, 标记那些在并发标记阶段发生变化的对象
  5. 清除 (Cleanup, 重在统计, 部分STW)
    1. 这个阶段是为后面的混合回收周期做准备的, 该阶段会统计小堆区中所有存活的对象, 并将小堆区进行排序, 以提升 GC 的效率. 如果发现完全没有活对象的 Region, 就会将其整体回收到可分配 Region 列表中, 清除空 Region. 这个阶段有一部分是并发的, 例如空堆区的回收, 还有大部分用于存活率计算, 这部分需要一个短暂的 STW, 以不受用户线程的影响
      1. 对活动对象和完全空闲区域执行统计(STW)
      2. 清理 Remember Sets(STW)
      3. 空堆区的回收

混合回收周期(STW)

即最终的复制阶段. 年轻代和老年代同时被回收, 老年代区域是根据它们的活跃度来选择的.

它负责把一部分 Region 里的活对象拷贝到空 Region 里去(并行拷贝) , 然后回收原本的 Region 的空间. Evacuation 阶段可以自由选择任意多个 Region 来独立收集以构成回收集(collection set, 简称 CSet) , CSet 中 Region 的选定依赖于停顿预测模型, 该阶段并不拷贝所有包含活对象的 Region, 只选择收益高的少量 Region 来拷贝, 这种暂停的开销是(在一定范围内) 可控的

logging write-barrier

为了尽量减少write barrier对应用mutator性能的影响, G1将一部分原本要在barrier里做的事情挪到别的线程上并发执行.
实现这种分离的方式就是通过logging形式的write barrier:mutator只在barrier里把要做的事情的信息记(log) 到一个队列里, 然后另外的线程从队列里取出信息批量完成剩余的动作.

以SATB write barrier为例, 每个Java线程有一个独立的, 定长的SATBMarkQueue, mutator在barrier里只把old_value压入该队列中. 一个队列满了之后, 它就会被加到全局的SATB队列集合SATBMarkQueueSet里等待处理, 然后给对应的Java线程换一个新的, 干净的队列继续执行下去.

以 RSet 为例, 每次引用类型字段赋值之后都要经过一堆复杂的步骤来更新Rset, G1采用了和SATB同样的思路, 将这一步交给了异步线程来完成

并发标记(concurrent marker) 会定期检查全局SATB队列集合的大小. 当全局集合中队列数量超过一定阈值后, concurrent marker就会处理集合里的所有队列:把队列里记录的每个oop都标记上, 并将其引用字段压到标记栈(marking stack) 上等后面做进一步标记.

G1 为什么采用 SATB 而不是增量更新

减少 remark 阶段时长, remark 阶段只需扫描 satb_mark_queue 即可

因为 G1 是分 Region 回收, 加上 G1 本身更关注的是垃圾, 增量更新的情况, 每次 remark 都要重新对增量对象进行扫描. 而 SATB 不关心新增的, 只关心删除. 相对来讲找到垃圾的概率更大, 再对比 Region 的 rset, 更方便找到垃圾清除. 效率更高

是否存在漏标情况, 怎么解决

存在

在并发的情况下, 当一个线程扫描对象A, 对象A有索引:A->B,A->C, 其中线程T1, 扫描完B在扫描C的状态中, 此时有个线程T2把索引B改动, 改成A->D, 把A设置为灰色, 此时T1把C扫描完了, 把A设置为黑色. 这时我们就发现黑色对象A, 下面就会有一个白色对象D未扫描

写屏障会将删除的引用放入 satb_mark_queue, remark 阶段会将此队列中的队列重新扫描, 扫描Region中的RSet, 如果RSet 没有记录其他Region对这个对象的索引, 自己内部也没有, 那么这个对象就是一个可回收的垃圾对象

G1 是否会出发 Full GC

会 晋升担保失败(Evacuation Failure)

  1. 从年轻代分区拷贝存活对象时, 无法找到可用的空闲分区
  2. 从老年代分区转移存活对象时, 无法找到可用的空闲分区
  3. 分配巨型对象时在老年代无法找到足够的连续分区

引用

A Generational Mostly-concurrent Garbage Collector

Garbage-First Garbage Collection

JVM-G1垃圾回收器:G1回收流程(Rset, CSet, SATB)


JVM-内存相关
https://gallrax.github.io/2023/03/27/JVM-内存相关/
作者
Gallrax
发布于
2023年3月27日
许可协议