JavaGc常见机制

对象创建流程

栈内分配

JVM通过逃逸分析,确定该对象不会被外部访问,如果不会逃逸可以将该对象在栈内分配内存,这样该对象所占用的内存空间就可以随栈帧出栈而销毁,就减轻了垃圾回收的压力

逃逸分析

通过逃逸分析确定该对象不会被外部访问,并且对象可以被进一步分解时,JVM不会创建该对象,而是将该对象成员变量分解若干个被这个方法使用的成员变量所代替,这些代替的成员变量在栈帧或寄存器上分配空间,这样就不会因为没有一大块连续空间导致对象内存不够分配。

标量替换

通过逃逸分析确定对象不会被外部访问,并且对象可以被进一步分解时,JVM不会创建该对象,而是将该对象成员变量分解若干个被这个方法使用的成员变量代替,这些代替的成员变量在栈帧或寄存器上分配空间,这样就不会因为没有一大块连续空间导致对象内存不够

同步消除

线程同步本身是一个相对耗时的过程,如果逃逸分析能够确定一个变量不糊逃逸出线程,无法被其他线程访问,那么这个变量的读写就肯定不会有竞争,对这个变量实施的同步措施也就可以安全的消除掉

大对象直接进入老年代

大对象就是需要大量连续内存空间的对象(比如:字符串、数组)。JVM参数 -XX:PretenureSizeThreshold 可以设置大对象的大小,如果对象超过设置大小会直接进入老年代,不会进入年轻代,这个参数只在 Serial 和ParNew两个收集器下 有效。

TLAB

如何保证分配内存中的线程安全

Thread Local Allocation Buffer,本地线程分配缓冲,把内存分配的动作按照线程划分在不同的空间之中进行,即每个线程在JAva堆中预留先分配一小块内存

java对象的分配过程如何保证线程安全?

因为堆是线程之间共享的,如果在并发场景中,俩个线程先后把对象的引用指向了同一个内存区域怎么办?

为了解决这个问题,对象内存分配过程就必须进行同步,但是,无论使用哪种方案(比如CAS),都会影响内存的分配效率。然后对于java来说,对象的分配是高频操作。

由此HotSpot虚拟机采用了这个方案:每个线程在java堆中先分配一小份内存,然后在给对象分配内存的时候,直接在自己的这块私有内存中进行分配,当这部分用完之后,再分配新的私有内存

什么是TLAB

TALB是虚拟机在内存的eden区划分出来的一块专用空间,是线程专属的。在启用TLAB情况下,当线程被创建时,虚拟机会为每个线程分配一块TLAB空间,只给当前线程使用,这样每个线程都有一个单独空间,如果需要分配内存,就在自己的空间上分配,这样就不存在竞争的情况,可以大大提高分配效率

所以说,因为有了TLAB技术,堆内存并不是完完全全的线程共享,其中eden区中还是有一部分空间分配给线程独显的。

注意:这里 TLAB 的线程独享是针对于分配动作,至于读取、垃圾回收等工作是线程共享的,而且在使用上也没什么区别。

也就是说,虽然每个线程在初始化时都会去堆内存中申请一块 TLAB,并不是说这个 TLAB 区域的内存其他线程就完全无法访问了,其他线程的读取还是可以的,只不过无法在这个区域中分配内存而已。

并且,在TLAB分配之后,并不影响对象的移动和回收,也就是说,虽然对象刚开始可能通过TLAB分配内存放在eden区,但是还是会被垃圾回收或者被移到s区和老年区

还有一点需要注意的是,我们说 TLAB 是在 eden 区分配的,因为 eden 区域本身就不太大,而且 TLAB 空间的内存也非常小,默认情况下仅占有整个 eden 空间的 1%。所以,必然存在一些大对象是无法在 TLAB 直接分配。遇到 TLAB 中无法分配的大对象,对象还是可能在 eden 区或者老年代等进行分配的,但是这种分配就需要进行同步控制,这也是为什么我们经常说:小的对象比大的对象分配起来更加高效。

问题

主要问题就是因为 TLAB 空间太小导致的。

比如一个线程的 TLAB 空间有 100KB,其中已经使用了 80KB,当需要再分配一个 30KB 的对象时,就无法直接在 TLAB 中分配,遇到这种情况时有两种处理方案:

  1. 直接在堆内存中对该对象进行内存分配。
  2. 废弃当前的 TLAB,重新申请 TLAB 空间再次进行内存分配。

方案 1 的话,如果 TLAB 只剩下 1KB 的空间了,那么后续的大多数对象都需要在堆内存中分配,方案 2 的话,有可能会有频繁的废弃 TLAB 申请 TLAB 的情况。TLAB 内存自己从堆中进行分配时也是需要并发控制的,而频繁的分配 TLAB 就失去了 TLAB 的意义了。

为了解决这个问题,虚拟机定义了一个 refill_waste 的值,这个值可以翻译为”最大浪费空间“。

当 TLAB 剩余空间不足时,

  1. 若请求分配的内存大于 refill_waste,会选择在堆内存中分配。
  2. 若请求分配的内存小于 refill_waste,会选择废弃当前的 TLAB,重新创建 TLAB 进行对象内存分配。

前面的例子中,TLAB总空间100KB,使用了80KB,剩余20KB,如果设置的refill_waste的值为25KB,那么如果新对象的内存大于25KB,则直接堆内存分配,如果小于25KB,则会废弃掉之前的那个TLAB,重新分配一个TLAB空间,给新对象分配内存。

长期存活的对象将进入老年代

虚拟机给每个对象一个对象年龄(Age)计数器, 如果对象在 Eden 出生并经过第一次 Minor GC 后仍然能够存活,并且能被 Survivor 容纳的话,将被移动到 Survivor 空间中,并将对象年龄设为1。对象在 Survivor 中每熬过一次 MinorGC,年龄就增加1岁,当它的年龄增加到一定程度 (默认为15岁,CMS收集器默认6岁,不同的垃圾收集器会略微有点不同),就会被晋升到老年代中。

对象动态年龄判断

当前放对象的Survivor区域里(其中一块区域,放对象的那块s区),一批对象的总大小大于这块Survivor区域内存大小的 50%(-XX:TargetSurvivorRatio可以指定),那么此时大于等于这批对象年龄最大值的对象,就可以直接进入老年代了, 例如Survivor区域里现在有一批对象,年龄1+年龄2+年龄n的多个年龄对象总和超过了Survivor区域的50%,此时就会把年龄n(含)以上的对象都放入老年代。对象动态年龄判断机制一般是在minor gc之后触发的。

老年代空间分配担保机制

年轻代每次minor gc之前JVM都会计算下老年代剩余可用空间。如果用空间小于年轻代里现有的所有对象大小之和(包括垃圾对象) ,就会看老年代的可用内存大小是否大于之前每一次minor gc后进入老年代的对象的平均大小。 如果结果是小于,那么就会触发一次Full gc,对老年代和年轻代一起回收一次垃圾, 如果回收完还是没有足够空间存放新的对象就会发生"OOM"异常。

三色标记

初始标记

并发标记

  • 白色:表示对象没被收集器访问过
  • 黑色,代表对象以及被收集器访问过,切这个对象所有的引用都扫描过
  • 灰色:表示对象呗收集器访问过,但这个对象至少还存在一个引用没有被扫描过

出现漏标的俩个必要条件

  1. 有对象的引用关系被删除
  2. 删除的对象又被重新引用

有俩种解决方案

  • 增量更新:档黑色对象插入新的指向白色对象的引用关系时,就将这个新插入的引用记录下来,等并发扫描结束后,再将这些记录过的引用关系中的黑色对象为跟,重新扫描一次。这可以简化理解为,黑色对象 一旦插入了白色对象的引用后,它就变回灰色对象了。
  • 原始快照:STAB 当灰色对象要删除指向白色对象的引用关系时,就将这个要删除的引用记录下来,在并发扫描结束后,再将浙西诶记录过的引用关系中的灰色对象重新为跟,重新扫描一次。这也可以简化理解为,无论引用关系删除与否,都会按照开始扫描那一刻 的对象图快照进行搜索。

不同垃圾收集器的选择

  • CMS写屏障+增量更新
  • G1,Shenandoah 写屏障+STAB
  • ZGC 读屏障

个人理解: G1 一般用于大内存的机器,内存 8G 至百G级别, 存放的对象更多,自然要扫描的对象更多,如果采用效率低的增量更新方式,效率下降的更严重,所以采用效率较高的 STAB,虽然会产生一些浮动垃圾,但是对于大内存机器来说,影响不大。

CMS 适用于内存较小的机器,多用于 4G 到 8G 的机器上,无论采用 STAB 还是 CMS, 效率上无法拉开明显差距,而增量更新不会产生浮动垃圾,对内存更小的机器来说,内存的使用更敏感,自然采用增量更新的方式

写屏障

我们已经解决了如何使用记忆集来缩减GC Root扫描范围的问题,但还没有解决卡表如何维护的问题,例如它们何时变脏,谁来把它们边脏等。

卡表元素何时变脏是很明确的--有其他分代区域中对象引用了本区域的对象时,其对应的卡表元素就应该变脏,即如何在对象赋值那一刻去更新维护卡表呢?假如是解释执行的字节码,那相对好处理,虚拟机负责每条字节码指令的执行,有充分的介入空间;但在编译执行的场景中呢?经过编译后的代码已经是纯粹的机器指令流了,这就必须找到一个在机器码层面的手段,把维护卡表的动作放到每一个赋值操作中。

在hotSport虚拟机里是通过写屏障(Write Barrier)技术维护卡表状态的。主力提到的写屏障和低延迟收集器中会提到的读屏障,与解决并发乱序执行问题中的内存屏障区分开来,避免混淆,写屏障可以看做在虚拟机层面对引用类型字段赋值这个动作的AOP切面,在引用对象赋值时会产生一个环形的通知,供程序执行额外的动作,也就是说赋值的前后都在写屏障的范围覆盖内。在赋值前的写屏障叫做写前屏障(Pre-Write Barrier)在赋值后的则叫做写后屏障(Post-Write Barrier)

Hotspot虚拟机的许多收集器中都有使用到写屏障,但直至G1收集器出现之前,其他收集器都只用到了写后屏障。

应用写屏障后,虚拟机就会为所有赋值操作生成相应的指令,一旦收集器在屏障中增加了更新卡表的操作,无论更新的是不是老年代对新生代对象的引用,每次只要对引用进行更新,就会产生额外的开销,不过这个开销与monor GC时扫描整个老年代的代价相比还是低得多。

伪共享

除了写屏障的开销外,卡表在高并发下还面临着为共享(False Sharing)问题。为共享是处理器并发底层细节一种经常需要考虑的问题,线代中央处理器的缓存系统中是以缓存行(Cache Line)为单位存储的,当多线程修改相互独立的变量时,如果这些变量恰好共享同一个缓存行,就会彼此影响(写回,无效化,或者同步)而导致性能降低,这就是伪共享问题

假设处理处理器的缓存行大小为64字节,由于一个卡表元素占一个字节,64个卡表元素将共享同一个缓存行。这64个卡表元素对应的卡页总的内存为32KB(54*512字节)也就是说如果不同线程更新的对象正好处于这32KB的内存区域内,就会导致更新卡表是正好写入同一个缓存行而影响性能。为了避免伪共享的问题,一种简单的解决方案是不在用无条件的写屏障,而是先检查卡表标记,只有当卡表元素未被标记过的时才将其标记变脏,即将卡表更新的逻辑变为如下代码

if(CARD_TABLE[this address >> 9]!=0)
    CARD_TABLE[this address >> 9]=0

在JDK7后hotspot虚拟机增加了一个新的参数-XX:+UseCondCardMark用来决定是否开启卡表的更新条件判断。俩者各有性能损耗,是否打开要根据实际运行情况来进行测试权衡。

Last modification:January 4, 2023
如果觉得我的文章对你有用,请随意赞赏