目录
简介
false-sharing 的由来
怎么解决?
使用 JOL 分析
Contended 在 JDK9 中的问题
padded 和 unpadded 性能对比
Contended 在 JDK 中的使用
总结
简介
现代 CPU 为了提升性能都会有自己的缓存结构, 而多核 CPU 为了同时正常工作, 引入了 MESI, 作为 CPU 缓存之间同步的协议. MESI 虽然很好, 但是不当的时候用也可能导致性能的退化.
到底怎么回事呢? 一起来看看吧.
false-sharing 的由来
为了提升处理速度, CPU 引入了缓存的概念, 我们先看一张 CPU 缓存的示意图:
CPU 缓存是位于 CPU 与内存之间的临时数据交换器, 它的容量比内存小的多但是交换速度却比内存要快得多.
CPU 的读实际上就是层层缓存的查找过程, 如果所有的缓存都没有找到的情况下, 就是主内存中读取.
为了简化和提升缓存和内存的处理效率, 缓存的处理是以 Cache Line(缓存行) 为单位的.
一次读取一个 Cache Line 的大小到缓存.
在 Mac 系统中, 你可以使用 sysctl machdep.CPU.cache.linesize 来查看 cache line 的大小.
在 Linux 系统中, 使用 getconf LEVEL1_DCACHE_LINESIZE 来获取 cache line 的大小.
本机中 cache line 的大小是 64 字节.
考虑下面一个对象:
- public class CacheLine {
- public long a;
- public long b;
- }
很简单的对象, 通过之前的文章我们可以指定, 这个 CacheLine 对象的大小应该是 12 字节的对象头 + 8 字节的 long+8 字节的 long+4 字节的补全, 总共应该是 32 字节.
因为 32 字节 < 64 字节, 所以一个 cache line 就可以将其包括.
现在问题来了, 如果是在多线程的环境中, thread1 对 a 进行累加, 而 thread2 对 b 进行累加. 会发生什么情况呢?
第一步, 新创建出来的对象被存储到 CPU1 和 CPU2 的缓存 cache line 中.
thread1 使用 CPU1 对对象中的 a 进行累计.
根据 CPU 缓存之间的同步协议 MESI(这个协议比较复杂, 这里就先不展开讲解), 因为 CPU1 对缓存中的 cache line 进行了修改, 所以 CPU2 中的这个 cache line 的副本对象将会被标记为 I(Invalid) 无效状态.
thread2 使用 CPU2 对对象中的 b 进行累加, 这个时候因为 CPU2 中的 cache line 已经被标记为无效了, 所以必须重新从主内存中同步数据.
大家注意, 耗时点就在第 4 步. 虽然 a 和 b 是两个不同的 long, 但是因为他们被包含在同一个 cache line 中, 最终导致了虽然两个线程没有共享同一个数值对象, 但是还是发送了锁的关联情况.
怎么解决?
那怎么解决这个问题呢?
在 JDK7 之前, 我们需要使用一些空的字段来手动补全.
- public class CacheLine {
- public long actualValue;
- public long p0, p1, p2, p3, p4, p5, p6, p7;
- }
像上面那样, 我们手动填充一些空白的 long 字段, 从而让真正的 actualValue 可以独占一个 cache line, 就没有这些问题了.
但是在 JDK8 之后, java 文件的编译期会将无用的变量自动忽略掉, 那么上面的方法就无效了.
还好, JDK8 中引入了 sun.misc.Contended 注解, 使用这个注解会自动帮我们补全字段.
使用 JOL 分析
接下来, 我们使用 JOL 工具来分析一下 Contended 注解的对象和不带 Contended 注解的对象有什么区别.
- @Test
- public void useJol() {
- log.info("{}", ClassLayout.parseClass(CacheLine.class).toPrintable());
- log.info("{}", ClassLayout.parseInstance(new CacheLine()).toPrintable());
- log.info("{}", ClassLayout.parseClass(CacheLinePadded.class).toPrintable());
- log.info("{}", ClassLayout.parseInstance(new CacheLinePadded()).toPrintable());
- }
注意, 在使用 JOL 分析 Contended 注解的对象时候, 需要加上 -XX:-RestrictContended 参数.
同时可以设置 - XX:ContendedPaddingWidth 来控制 padding 的大小.
- INFO com.flydean.CacheLineJOL - com.flydean.CacheLine object internals:
- OFFSET SIZE TYPE DESCRIPTION VALUE
- 0 4 (object header) 05 00 00 00 (00000101 00000000 00000000 00000000) (5)
- 4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0)
- 8 4 (object header) d0 29 17 00 (11010000 00101001 00010111 00000000) (1518032)
- 12 4 (alignment/padding gap)
- 16 8 long CacheLine.valueA 0
- 24 8 long CacheLine.valueB 0
- Instance size: 32 bytes
- Space losses: 4 bytes internal + 0 bytes external = 4 bytes total
- INFO com.flydean.CacheLineJOL - com.flydean.CacheLinePadded object internals:
- OFFSET SIZE TYPE DESCRIPTION VALUE
- 0 4 (object header) 05 00 00 00 (00000101 00000000 00000000 00000000) (5)
- 4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0)
- 8 4 (object header) d2 5d 17 00 (11010010 01011101 00010111 00000000) (1531346)
- 12 4 (alignment/padding gap)
- 16 8 long CacheLinePadded.b 0
- 24 128 (alignment/padding gap)
- 152 8 long CacheLinePadded.a 0
- Instance size: 160 bytes
- Space losses: 132 bytes internal + 0 bytes external = 132 bytes total
我们看到使用了 Contended 的对象大小是 160 字节. 直接填充了 128 字节.
Contended 在 JDK9 中的问题
sun.misc.Contended 是在 JDK8 中引入的, 为了解决填充问题.
但是大家注意, Contended 注解是在包 sun.misc, 这意味着一般来说是不建议我们直接使用的.
虽然不建议大家使用, 但是还是可以用的.
但如果你使用的是 JDK9-JDK14, 你会发现 sun.misc.Contended 没有了!
因为 JDK9 引入了 JPMS(Java Platform Module System), 它的结构跟 JDK8 已经完全不一样了.
经过我的研究发现, sun.misc.Contended, sun.misc.Unsafe,sun.misc.Cleaner 这样的类都被移到了 jdk.internal.** 中, 并且是默认不对外使用的.
那么有人要问了, 我们换个引用的包名是不是就行了?
import jdk.internal.vm.annotation.Contended;
抱歉还是不行.
- error: package jdk.internal.vm.annotation is not visible
- @jdk.internal.vm.annotation.Contended
- ^
- (package jdk.internal.vm.annotation is declared in module
- java.base, which does not export it to the unnamed module)
好, 我们找到问题所在了, 因为我们的代码并没有定义 module, 所以是一个默认的 "unnamed" module, 我们需要把 java.base 中的 jdk.internal.vm.annotation 使 unnamed module 可见.
要实现这个目标, 我们可以在 javac 中添加下面的 flag:
--add-exports java.base/jdk.internal.vm.annotation=ALL-UNNAMED
好了, 现在我们可以正常通过编译了.
padded 和 unpadded 性能对比
上面我们看到 padded 对象大小是 160 字节, 而 unpadded 对象的大小是 32 字节.
对象大了, 运行的速度会不慢呢?
实践出真知, 我们使用 JMH 工具在多线程环境中来对其进行测试:
- @State(Scope.Benchmark)
- @BenchmarkMode(Mode.AverageTime)
- @OutputTimeUnit(TimeUnit.NANOSECONDS)
- @Fork(value = 1, jvmArgsPrepend = "-XX:-RestrictContended")
- @Warmup(iterations = 10)
- @Measurement(iterations = 25)
- @Threads(2)
- public class CacheLineBenchMark {
- private CacheLine cacheLine= new CacheLine();
- private CacheLinePadded cacheLinePadded = new CacheLinePadded();
- @Group("unpadded")
- @GroupThreads(1)
- @Benchmark
- public long updateUnpaddedA() {
- return cacheLine.a++;
- }
- @Group("unpadded")
- @GroupThreads(1)
- @Benchmark
- public long updateUnpaddedB() {
- return cacheLine.b++;
- }
- @Group("padded")
- @GroupThreads(1)
- @Benchmark
- public long updatePaddedA() {
- return cacheLinePadded.a++;
- }
- @Group("padded")
- @GroupThreads(1)
- @Benchmark
- public long updatePaddedB() {
- return cacheLinePadded.b++;
- }
- public static void main(String[] args) throws RunnerException {
- Options opt = new OptionsBuilder()
- .include(CacheLineBenchMark.class.getSimpleName())
- .build();
- new Runner(opt).run();
- }
- }
上面的 JMH 代码中, 我们使用两个线程分别对 A 和 B 进行累计操作, 看下最后的运行结果:
从结果看来虽然 padded 生成的对象比较大, 但是因为 A 和 B 在不同的 cache line 中, 所以不会出现不同的线程去主内存取数据的情况, 因此要执行的比较快.
Contended 在 JDK 中的使用
其实 Contended 注解在 JDK 源码中也有使用, 不算广泛, 但是都很重要.
比如在 Thread 中的使用:
比如在 ConcurrentHashMap 中的使用:
其他使用的地方: Exchanger,ForkJoinPool,Striped64.
感兴趣的朋友可以仔细研究一下.
总结
Contented 从最开始的 sun.misc 到现在的 jdk.internal.vm.annotation, 都是 JDK 内部使用的 class, 不建议大家在应用程序中使用.
这就意味着我们之前使用的方式是不正规的, 虽然能够达到效果, 但是不是官方推荐的. 那么我们还有没有什么正规的办法来解决 false-sharing 的问题呢?
有知道的小伙伴欢迎留言给我讨论!
来源: https://www.cnblogs.com/flydean/p/jvm-contend-false-sharing.html