JVM进阶之卡表

1. TLAB

堆是JVM中所有线程共享的,因此在其上进行对象内存的分配均需要进行加锁,这也导致了new对象的开销是比较大的。
JVM为了提升内存分配的效率,对于所创建的线程都会分配一块独立的空间TLAB(Thread Loal Allocation Buffer),其大小由JVM根据运行的情况计算而得,在TLAB上分配对象不需要加锁,因此JVM在给线程的对象分配内存时会尽量的在TLAB上分配,在这种情况下JVM中分配内存对象内存的性能和C是一样高效的,但如果对象过大的话则仍然是直接使用堆内存分配。

TLAB仅作用于新生代的Eden Space,因此在编写Java程序时,通常多个小的对象比大的对象分配起来更加高效。

聊完了TLAB后,我们继续看看对象在堆内存中是如何流转的。
所有新创建的Object都会存储在新生代中,如果Eden区的空间耗尽了,JVM则会触发一次Minor GC,来收集新生代的垃圾。存活下来的对象,则会被送到Survivor区。Survivor有两个区,分别叫做from和to,其中to指向的Survivor区是空的,当发生Minor GC时,Eden区和from指向的Survivor区中的存活对象就会被复制到to指向的Survivor区中,然后交换from和to指针。

1.1 分配对象

Java中对象地址操作主要使用了Unsafe调用了C的allocate和free两个方法,分配方法有两种:

  • 空闲链表(free list):通过额外的存储记录空闲的地址,将随机IO变成顺序IO,但带来了额外的空间消耗。
  • 碰撞指针(bump pointer):通过一个指针作为分界点,需要分配内存时,仅需把指针往空闲的一端移动与对象大小相等的距离,分配效率较高,但使用场景有限。
2. 记忆集和卡表

为了解决对象跨代引用所带来的问题,垃圾收集器在新生代中建立了名为记忆集(Remembered Set)的数据结构,用以避免把整个老年代加进GC Roots扫描范围。事实上并不是只是新生代、老年代之间才有跨代引用的问题,所有涉及部分区域收集行为的垃圾收集器,典型如G1,ZGC收集器,都会面临相同的问题,因此我们有必要进一步理清记忆集的原理和实现方式。

**记忆集是一种用于记录从非收集区域指向收集区域的指针集合的抽象数据结构。**下面列举了一些可供选择的记录精度,由高到低依次为:

  • 字长精度:每个记录精确到一个机器字长(就是处理器的寻址位数,如常见的32位或64位,这个精度决定了机器访问物理内存地址的指针长度),该字包含跨代指针。
  • 对象精度:每个记录精确到一个对象,该对象里有字段含有跨代指针。
  • 卡精度:每个记录精确到一块内存区域,该区域内有对象含有跨代指针。

卡精度所指的是用一种称为"卡表"(caed table)的方式实现记忆集,是目前最常用的一种实现方式,卡表与记忆集的关系,类似于Java语言中HashMap与Map的关系。

卡表技术是指:将整个堆划分为一个个指定大小的内存块,这个内存块称为"卡页",卡页大小在HotSpot中默认为512字节,并且维护一个卡表(可以是一个字节数组)。一个卡页的内存中通常包含不止一个对象,只要卡页内有一个(或多个)对象的字段存在着跨代指针,那就将对应卡表的数组元素的值标识为1,称为这个元素变脏(Dirty),没有则标识为0。

有了卡表之后,我们看看其在实际应用中是怎么工作的。比如在进行Minor GC的时候,我们便不用扫描整个老年代对象,而是在卡表中寻求脏卡,并将脏卡中的对象加入到Minor GC的GC Roots里(Minor GC时JVM将从脏卡中的对象查找,作为GC Roots)。当完成所有脏卡的扫描之后,Java虚拟机便会将所有脏卡的标识位清零。

3. CMS卡表技术

我们先来看一下CMS卡表的具体含义:记录老年代区域是否存在新生代对象的引用。

可能大家疑惑为什么只讨论老年代卡表呢?

当进行Minor GC时,我们出于清理新生代区对象的目的,在进行可达性分析过程中,需要判断该对象是否被老年代对象所引用。我们都知道老年代区域比新生代大,如果扫描整个老年代,这是件很消耗性能的事情。而老年代卡表则可以避免老年代的全扫描。

反之,如果要进行Major GC,即要清理老年代中的对象,那么当然也可以存在该对象被新生代对象所引用的情况,然后就需要扫描整个新生代,那么是否需要新生代卡表呢?HotSpot实际上并没有使用新生代卡表,因为新生代其对应的card table部分可能大部分都是dirty的,要把新生代对象当做root,与其扫描其card还不如直接扫描整个新生代。

所以CMS卡表就是老年代中卡表记录指向新生代对象的引用,CMS设计的卡表又称为单向卡表。

4. G1卡表技术

我们知道,G1的堆内存被划分为多个大小相等的Region,不在考虑分代限制,那么Region里面的跨Region引用对象如何解决?
可以明确地是仍然是采用记忆集来处理,不过相对于CMS收集器的应用就复杂多了。它的每个Region都维护有自己的记忆集,这些记忆集会记录下别的Region指向自己的指针,并标记这些指针分别在那些卡页的范围之内。

G1的记忆集在存储结构的本质上是一种哈希表,Key是别的Region的起始地址,Value是一个集合,里面存储的元素是卡表的索引号。这是一种"双向"的卡表结构(卡表是"我指向谁",这种结构还记录了"谁指向我")比原来的卡表实现起来更加复杂,同时由于Region数量比传统收集器的分代数量明显要多得多,因此G1收集器要比其他的传统垃圾收集器有着更高的内存占用负担。根据经验,G1至少要耗费大约相当于Java堆容量10%至20%的额外内存来维持收集器工作。

5. 写屏障

我们解决了如何使用记忆集来缩减GC Roots扫描的问题,但还没解决卡表元素如何维护的问题,例如它们什么时候变脏、谁来把它们变脏等。
这里以CMS卡表为例,如果老年代的卡页中对象引用了新生代的对象,则该卡页就会变脏,变脏时间点原则上应该发生在引用类型字段赋值的那一刻。
在HotSpot虚拟机里是通过写屏障(Write Barrier)技术维护卡表状态的。
只是补充两点:

  1. 这里的写屏障和我们常说的为了解决并发乱序执行问题的"内存屏障"不是一个概念,需要区分开来。
  2. 写屏障可以看作是虚拟机层面对"引用类型字段赋值"这个动作的AOP切面,在引用对象赋值时会产生一个环形通知,供程序执行额外的动作,也就是说赋值的前后都在写屏障的覆盖范畴内。在赋值前的部分的写屏障叫做写前屏障,在赋值后的则叫做写后屏障。

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

写屏障便可精简为下面的伪代码。这里右移9位相当于除以512,Java虚拟机便是通过这种方式从地址映射到卡表中的索引的。最终这段代码会被编译成一条移位指令和一条存储指令。

CARD_TABLE [this address >> 9] = DIRTY;

除了写屏障的开销外,卡表在高并发场景下还面临着“伪共享”(False Sharing) 问题。伪共享是处理并发底层细节时一种经常需要考虑的问题,现代中央处理器的缓存系统中是以缓存行(Cache Line)为单位存储的,当多线程修改互相独立的变量时,如果这些变量恰好共享同一个缓存行,就会彼此影响(写回、无效化或者同步)而导致性能降低,这就是伪共享问题。
假设处理器的缓存行大小为64字节,由于一个卡表元素占1个字节,64个卡表元素将共享同一个缓存行。 这 64 个卡表元素对应的卡页总的内存为32KB(64×512字节) ,也就是说如果不同线程更新的对象正好处于这 32KB 的内存区域内,就会导致更新卡表时正好写入同一个缓存行而影响性能。
为了避免伪共享问题,一种简单的解决方案是不采用无条件的写屏障,而是先检查卡表标记,只有当该卡表元素未被标记过时才将其标记为变脏。
具体实现为:在 JDK 7之后, HotSpot虚拟机增加了一个新的参数-XX:+UseCondCardMark,用来决定是否开启卡表更新的条件判断。
开启会增加一次额外判断的开销,但能够避免伪共享问题,两者各有性能损耗,是否打开要根据应用实际运行情况来进行测试权衡。

你可能感兴趣的:(JVM,jvm,java,算法)