本文基于 OpenJDK 8 ~ 14 的版本
JEP 142 内容
用于将某个或者某些需要多线程读取和修改的 field 进行缓存行填充。同时由于 Java 8 之前对于缓存行填充的方式,比较繁琐且不够优雅,还有可能缓存行大小不一的问题,所以这个 JEP 中引入了 @Contended
注解。
什么是缓存行填充以及 False Sharing
CPU 缓存结构:
CPU 只能直接处理寄存器中的数据,从上面这些缓存读取,其实就是从这些缓存复制数据到寄存器。就像数据库和缓存关系相似,存在L1缓存,L2缓存,L3缓存来缓存内存中的数据。 级别越小,CPU访问越快:
上面说读取其实就是从这些缓存复制数据到寄存器,从内存读取数据也是一样,从内存复制到缓存中。但是这个复制,并不是一个字节一个字节复制的,而是一行一行复制的,这个行就是 缓存行 。 缓存行: CPU缓存并不是将内存数据一个一个的缓存起来,而是每次从内存中取出一行内存,称为缓存行(Cache Line),对于我的电脑,缓存行长度是 64 Bytes:
对于Java,举个例子,假设 X 和 Y 两个volatile的 long 变量(Java中占用 8 Bytes),他们两个内存相邻,而且加起来的长度小于 64 Bytes,那么他们就很可能会被同时加载在同一个缓存行之中。volatile的作用就是当一个线程更新某个volatile声明的变量时,会通知其他的cpu使缓存失效,从而其他cpu想要做更新操作时,需要从内存重新读取数据。而且 Java 考虑到缓存行大小,做了 8 Bytes 对齐,所以基本不会发生一个缓存行加载正好不够 X 或者 Y 变量大小的问题。在 X,Y被加载到同一个缓存行的时候,就会发生 False Sharing 的问题,导致性能下降。
假设有两个线程分别访问并修改X和Y这两个变量,X和Y恰好在同一个缓存行上,这两个线程分别在不同的CPU上执行。那么每个CPU分别更新好X和Y时将缓存行刷入内存时,发现有别的修改了各自缓存行内的数据,这时缓存行会失效,从L3中重新获取。这样的话,程序执行效率明显下降。 为了减少这种情况的发生,其实就是避免X和Y在同一个缓存行中,可以主动添加一些无关变量将缓存行填充满,比如在X对象中添加一些变量,让它有64 Byte那么大,正好占满一个缓存行。这个操作被称为 缓存行填充
一般框架填充方式 与 需要缓存行填充的场景
可以参考的框架有很多很多,这里举两个例子,一个是高性能缓存框架 Disruptor,另一个是高性能缓存框架 Caffeine,他们都是针对缓存队列的使用,一个是环形队列,一个是普通队列。通过这两个框架了解缓存行填充的使用。
Disruptor 缓存行填充应用举例
Disruptor 结构:
每个RingBuffer是一个环状队列,队列中每个元素可以理解为一个槽。在初始化时,RingBuffer规定了总大小,就是这个环最多可以容纳多少槽。这里Disruptor规定了,RingBuffer大小必须是2的n次方。这里用了一个小技巧,就是将取模转变为取与运算。在内存管理中,我们常用的就是取余定位操作。如果我们想在Ringbuffer定位,一般会用到某个数字对Ringbuffer的大小取余。如果是对2的n次方取余,则可以简化成:
m % 2^n = m & ( 2^n - 1 )
Producer会向这个RingBuffer中填充元素,填充元素的流程是首先从RingBuffer读取下一个Sequence,之后在这个Sequence位置的槽填充数据,之后发布。 这个 Sequence 类,其中的 value 这个 field, 就是其中保存的值。这个值的修改,就涉及到了 false sharing 的问题。因为:
- 环形 Buffer 中的相邻的 Sequence 内存地址也是相邻的,因为底层实现就像一个数组。
- 如果没有缓存行填充,那么极有可能,更新当前这个 Sequence 的线程对应的缓存行,将相邻的其他 Sequence里面的值也读取了出来,导致其他生产者线程需要重新读取其他的 Sequence。这个在多生产者的场景里面比较常见
所以,需要对于这个 Sequence 里面的 value,进行缓存行填充。代码里是怎么实现的呢,从下面的类继承图,可以看出:
Caffeine 应用举例
Caffeine 是一个高性能的并且拥有 Java 8 新特性的本地缓存框架。在 Spring Boot 2.0以上的版本甚至已经将 Caffeine 作为标准的缓存框架。在大多数场景下可以替换 guava cache 成为首选的本地缓存方案。同时,还提供了一个对于本地缓存任务队列,针对多个生产者,一个消费者的任务队列的工具类,这个类就是我们现在要讨论的 SingleConsumerQueue
.
一个队列,涉及到并发修改,那么肯定队列头还有队列尾是需要并发修改访问的地方,至于 value,由于每次都是新生成的包装对象,所以内存并不会在一起,不予考虑。但是队列头尾是肯定内存在一起的,为了提高效率,用缓存行填充来避免队列头尾在同一个缓存行中:
final class SCQHeader {
abstract static class PadHead<E> extends AbstractQueue<E> {
long p00, p01, p02, p03, p04, p05, p06, p07;
long p10, p11, p12, p13, p14, p15, p16;
}
/** Enforces a memory layout to avoid false sharing by padding the head node. */
abstract static class HeadRef<E> extends PadHead<E> {
//队列头
@Nullable Node<E> head;
}
abstract static class PadHeadAndTail<E> extends HeadRef<E> {
long p20, p21, p22, p23, p24, p25, p26, p27;
long p30, p31, p32, p33, p34, p35, p36;
}
/** Enforces a memory layout to avoid false sharing by padding the tail node. */
abstract static class HeadAndTailRef<E> extends PadHeadAndTail<E> {
static final long TAIL_OFFSET = UnsafeAccess.objectFieldOffset(HeadAndTailRef.class, "tail");
//队列尾
@Nullable volatile Node<E> tail;
void lazySetTail(Node<E> next) {
UnsafeAccess.UNSAFE.putOrderedObject(this, TAIL_OFFSET, next);
}
boolean casTail(Node<E> expect, Node<E> update) {
return UnsafeAccess.UNSAFE.compareAndSwapObject(this, TAIL_OFFSET, expect, update);
}
}
}
@Contended
注解与 JVM 源码分析
通过上面的例子,我们发现,每次做缓存行填充,我们需要生成一大堆无用字段,影响代码整洁,而且未来涉及到字段重排序规则可能修改,这样会影响我们升级,所以,在 JEP 142 中,提供了 @Contended
这个注解,这个注解在 JDK 库源码中也被广泛利用,例如 ForkJoinPool 的 WorkQueue 任务队列,其中的每一个字段都是高并发访问都需要填充。但是需要注意的是,JDK 库源码中的 @Contended
注解是生效的,你应用中的默认启动参数是不生效的,需要加上:
-XX:-RestrictContended
通过这个参数关闭RestrictContended
这个状态位。
考虑到这么做不太优雅,所以 Caffeine 虽然是利用了 Java 8 的新特性,但是并没有使用这个注解。
@Contended
注解代码:
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.FIELD, ElementType.TYPE})
public @interface Contended {
/**
* The (optional) contention group tag.
* This tag is only meaningful for field level annotations.
*
* @return contention group tag.
*/
String value() default "";
}
可以看到我们可以在 class 上使用这个注解,代表这个 class 的每一个 field 都需要单独的缓存行填充,防止彼此 false sharing。也可以在某个 field 上面使用,只对这个 field 进行缓存行填充。同时在 field 上面使用的时候,可以指定 group。同一个 group 下的所有 field 会被填充成尽量可以同一个缓存行读取。例如,filed1 和 field2 会被一起修改并访问,field 3 和 field 4 会被一起修改和访问,那么 field 1 和 field 2 用同一个 group, field 3 和 field 4 用同一个group, 对于 field 1 和 field 2 的读取修改,与对于 field 3 和 field 4 的读取修改,之间不会有 false sharing
对应的 JDK 源码 src/hotspot/share/classfile/classFileParser.cpp
:
AnnotationCollector::ID
AnnotationCollector::annotation_index(const ClassLoaderData* loader_data,
const Symbol* name) {
const vmSymbols::SID sid = vmSymbols::find_sid(name);
// 对于系统类加载器还有系统匿名类,设置 privileged 为 true
const bool privileged = loader_data->is_the_null_class_loader_data() ||
loader_data->is_platform_class_loader_data() ||
loader_data->is_anonymous();
//...
switch (sid) {
//...
case vmSymbols::VM_SYMBOL_ENUM_NAME(jdk_internal_vm_annotation_Contended_signature): {
if (_location != _in_field && _location != _in_class) {
break;
}
//如果没有启用Contended,或者 RestrictContended 这个 JVM Boolean Flag (就是对应上面提到的启动参数)为 Ture 并且不是 JDK 内部类,不处理 @Contended 字段
if (!EnableContended || (RestrictContended && !privileged)) {
break; // honor privileges
}
return _jdk_internal_vm_annotation_Contended;
//...
}
}
}
static void parse_annotations(const ConstantPool* const cp,
const u1* buffer, int limit,
AnnotationCollector* coll,
ClassLoaderData* loader_data,
TRAPS) {
// 指定 Contended Group
if (AnnotationCollector::_jdk_internal_vm_annotation_Contended == id) {
//默认 group index 为0
u2 group_index = 0; // default contended group
if (count == 1
&& s_size == (index - index0) // match size
&& s_tag_val == *(abase + tag_off)
&& member == vmSymbols::value_name()) {
group_index = Bytes::get_Java_u2((address)abase + s_con_off);
if (cp->symbol_at(group_index)->utf8_length() == 0) {
group_index = 0; // default contended group
}
}
coll->set_contended_group(group_index);
}
}
通过编译后的结构,对比@Contended
与变量填充
定义一个类:
@Data
@AllArgsConstructor
public class VolatileLong {
@Contended("group1")
private volatile long value1;
@Contended("group2")
private volatile long value2;
@Contended("group3")
private volatile long value3;
@Contended("group4")
private volatile long value4;
}
这里需要注意,对于 Java 9 以上的版本,编译和运行时需要增加参数:
--add-exports=java.base/jdk.internal.vm.annotation=ALL-UNNAMED
将 @Contended
注解所在的模块暴露出来,同时如果你是用 IDEA,由于IDEA 编译的时候会自动在 javac 后面添加 --release
这个参数,这个参数不允许 --add-export
等所有打破 Java 模块化的参数,所以需要关闭 IDEA 传递这个参数,通过如下配置关闭:
设置-XX:+RestrictContended
(这其实就是默认的,加不加都行),忽略Contended查看编译后对象内存结构:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 12 (object header) N/A
12 4 (alignment/padding gap)
16 8 long VolatileLong.value1 N/A
24 8 long VolatileLong.value2 N/A
32 8 long VolatileLong.value3 N/A
40 8 long VolatileLong.value4 N/A
Instance size: 48 bytes
Space losses: 4 bytes internal + 0 bytes external = 4 bytes total
设置-XX:-RestrictContended
,不忽略Contended查看编译后对象内存结构:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 12 (object header) N/A
12 4 (alignment/padding gap)
16 120 long VolatileLong.value1(contended, group = group1) N/A
136 120 long VolatileLong.value2(contended, group = group2) N/A
256 120 long VolatileLong.value3(contended, group = group3) N/A
376 120 long VolatileLong.value4(contended, group = group4) N/A
Instance size: 596 bytes
Space losses: 4 bytes internal + 0 bytes external = 4 bytes total
其中的每个value field都是这么填充的:
这样就算是最坏的情况下,比如缓存行从 p1 开始,或者缓存行从某一个 value 开始,如上图,都不会载入其他的value。
对比测试@Contended
性能
private static final int ITER = 1000000000;
public static void main(String[] args) throws RunnerException, InterruptedException {
VolatileLong volatileLong = new VolatileLong(0, 0, 0, 0);
Runnable update1 = () -> {
for (int i = 0; i < ITER; i++) {
volatileLong.setValue1(i);
}
};
Runnable update2 = () -> {
for (int i = 0; i < ITER; i++) {
volatileLong.setValue2(i);
}
};
Runnable update3 = () -> {
for (int i = 0; i < ITER; i++) {
volatileLong.setValue3(i);
}
};
Runnable update4 = () -> {
for (int i = 0; i < ITER; i++) {
volatileLong.setValue4(i);
}
};
Thread[] threads = new Thread[10];
long start = System.currentTimeMillis();
for (int i = 0; i < threads.length; i++) {
threads[i] = (i & 2) == 0 ? new Thread(update1) : new Thread(update2);
threads[i].start();
}
for (int i = 0; i < threads.length; i++) {
threads[i].join();
}
System.out.println("1 elapsed: " + (System.currentTimeMillis() - start));
start = System.currentTimeMillis();
for (int i = 0; i < threads.length; i++) {
threads[i] = (i & 2) == 0 ? new Thread(update3) : new Thread(update4);
threads[i].start();
}
for (int i = 0; i < threads.length; i++) {
threads[i].join();
}
System.out.println("2 elapsed: " + (System.currentTimeMillis() - start));
start = System.currentTimeMillis();
for (int i = 0; i < threads.length; i++) {
threads[i] = (i & 2) == 0 ? new Thread(update1) : new Thread(update3);
threads[i].start();
}
for (int i = 0; i < threads.length; i++) {
threads[i].join();
}
System.out.println("3 elapsed: " + (System.currentTimeMillis() - start));
start = System.currentTimeMillis();
for (int i = 0; i < threads.length; i++) {
threads[i] = (i & 2) == 0 ? new Thread(update2) : new Thread(update4);
threads[i].start();
}
for (int i = 0; i < threads.length; i++) {
threads[i].join();
}
System.out.println("4 elapsed: " + (System.currentTimeMillis() - start));
}
对于开启 Contended 结果:
1 elapsed: 13140
2 elapsed: 11185
3 elapsed: 10124
4 elapsed: 11749
对于未开启 Contended, 出现 false sharing 的结果:
1 elapsed: 19021
2 elapsed: 18758
3 elapsed: 19140
4 elapsed: 19287
可见,性能还是有显著差别的。
结论
- 多线程修改内存相邻的不同 field,可能会发生 false sharing,影响性能。
- 可以通过类继承,无用field方式进行缓存行填充,避免 false sharing。但是这种方式,限制了缓存行必须小于等于 64 bytes(这个可以自己实现,大部分缓存行是 64 bytes)。同时产生了很多无用代码
- Java 8 引入了
@Contended
,简化代码,灵活缓存行配置,适应不同的缓存行大小 - 但是这个注解必须增加额外的 JVM 启动参数,否则不生效,所以公共框架类代码还是自己实现缓存行填充,我们项目使用的时候可以考虑使用这个注解。