水滴石穿-JDK8中@Contended注解的意义

本文最后更新于:March 10, 2023 pm

积土成山,风雨兴焉;积水成渊,蛟龙生焉;积善成德,而神明自得,圣心备焉。故不积跬步,无以至千里,不积小流无以成江海。齐骥一跃,不能十步,驽马十驾,功不在舍。面对悬崖峭壁,一百年也看不出一条裂缝来,但用斧凿,能进一寸进一寸,能进一尺进一尺,不断积累,飞跃必来,突破随之。

目录

Java8引入了@Contented这个新的注解来减少伪共享(False Sharing)的发生。使用@Contended来保证被标识的字段或者类不与其他字段出现内存争用。且该注解是从JDK8才开始出现的。

引例

示例一

正常使用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
import java.util.concurrent.CountDownLatch;

/**
* @Author DragonOne
* @Date 2023/2/18 12:35
* @墨水记忆 www.tothefor.com
*/
public class Main {
public static void main(String[] args) throws Exception {
// CountDownLatch是在java1.5被引入的,它是通过一个计数器来实现的,计数器的初始值为线程的数量。
// 每当一个线程完成了自己的任务后,调用countDown方法,计数器的值就会减1。
// 当计数器值到达0时,它表示所有的线程已经完成了任务,然后调用await的线程就可以恢复执行任务了。
// 即:保证多个线程的任务都完成后再继续向下执行
CountDownLatch countDownLatch = new CountDownLatch(2);

NoCacheLineFill[] arr = new NoCacheLineFill[2];
arr[0] = new NoCacheLineFill();
arr[1] = new NoCacheLineFill();

Thread threadA = new Thread(() -> {
for (long i = 0; i < 100_000_000L; i++) {
arr[0].x = i;
}
countDownLatch.countDown();
}, "ThreadA");

Thread threadB = new Thread(() -> {
for (long i = 0; i < 100_000_000L; i++) {
arr[1].x = i;
}
countDownLatch.countDown();
}, "ThreadB");

final long start = System.nanoTime();
threadA.start();
threadB.start();
//等待线程A、B执行完毕
countDownLatch.await();
final long end = System.nanoTime();
System.out.println("耗时:" + (end - start) / 1_000_000 + "毫秒");

}
}

class NoCacheLineFill {
public volatile long x = 1L;
}

输出:

1
耗时:2148毫秒

处理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
import java.util.concurrent.CountDownLatch;

/**
* @Author DragonOne
* @Date 2023/2/18 12:35
* @墨水记忆 www.tothefor.com
*/
public class Main {
public static void main(String[] args) throws Exception {
// CountDownLatch是在java1.5被引入的,它是通过一个计数器来实现的,计数器的初始值为线程的数量。
// 每当一个线程完成了自己的任务后,调用countDown方法,计数器的值就会减1。
// 当计数器值到达0时,它表示所有的线程已经完成了任务,然后调用await的线程就可以恢复执行任务了。
// 即:保证多个线程的任务都完成后再继续向下执行
CountDownLatch countDownLatch = new CountDownLatch(2);

NoCacheLineFill[] arr = new NoCacheLineFill[2];
arr[0] = new NoCacheLineFill();
arr[1] = new NoCacheLineFill();

Thread threadA = new Thread(() -> {
for (long i = 0; i < 100_000_000L; i++) {
arr[0].x = i;
}
countDownLatch.countDown();
}, "ThreadA");

Thread threadB = new Thread(() -> {
for (long i = 0; i < 100_000_000L; i++) {
arr[1].x = i;
}
countDownLatch.countDown();
}, "ThreadB");

final long start = System.nanoTime();
threadA.start();
threadB.start();
//等待线程A、B执行完毕
countDownLatch.await();
final long end = System.nanoTime();
System.out.println("耗时:" + (end - start) / 1_000_000 + "毫秒");

}
}

class NoCacheLineFill {
public volatile long A, B, C, D, E, F, G;
public volatile long x = 1L;
public volatile long a, b, c, d, e, f, g;
}

输出:

1
耗时:675毫秒

📢注意:不同电脑,每次运行可能不一样。但两者之间的差距还是比较大的。

可以看见,上面的两段代码的运行时间差距是比较大的。而起作用的原因就是因为缓存行。

示例二

正常使用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
import sun.misc.Contended;

import java.util.concurrent.CountDownLatch;

/**
* @Author DragonOne
* @Date 2023/2/18 12:35
* @墨水记忆 www.tothefor.com
*/
public class Main {
public static void main(String[] args) throws Exception {
// CountDownLatch是在java1.5被引入的,它是通过一个计数器来实现的,计数器的初始值为线程的数量。
// 每当一个线程完成了自己的任务后,调用countDown方法,计数器的值就会减1。
// 当计数器值到达0时,它表示所有的线程已经完成了任务,然后调用await的线程就可以恢复执行任务了。
// 即:保证多个线程的任务都完成后再继续向下执行
CountDownLatch countDownLatch = new CountDownLatch(2);

NoCacheLineFill[] arr = new NoCacheLineFill[2];
arr[0] = new NoCacheLineFill();
arr[1] = new NoCacheLineFill();

Thread threadA = new Thread(() -> {
for (long i = 0; i < 100_000_000L; i++) {
arr[0].x = i;
}
countDownLatch.countDown();
}, "ThreadA");

Thread threadB = new Thread(() -> {
for (long i = 0; i < 100_000_000L; i++) {
arr[1].x = i;
}
countDownLatch.countDown();
}, "ThreadB");

final long start = System.nanoTime();
threadA.start();
threadB.start();
//等待线程A、B执行完毕
countDownLatch.await();
final long end = System.nanoTime();
System.out.println("耗时:" + (end - start) / 1_000_000 + "毫秒");

}
}

class NoCacheLineFill {
public volatile long x = 1L;
}

输出:

1
耗时:2197毫秒

使用@Contended注解

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
import sun.misc.Contended;

import java.util.concurrent.CountDownLatch;

/**
* @Author DragonOne
* @Date 2023/2/18 12:35
* @墨水记忆 www.tothefor.com
*/
public class Main {
public static void main(String[] args) throws Exception {
// CountDownLatch是在java1.5被引入的,它是通过一个计数器来实现的,计数器的初始值为线程的数量。
// 每当一个线程完成了自己的任务后,调用countDown方法,计数器的值就会减1。
// 当计数器值到达0时,它表示所有的线程已经完成了任务,然后调用await的线程就可以恢复执行任务了。
// 即:保证多个线程的任务都完成后再继续向下执行
CountDownLatch countDownLatch = new CountDownLatch(2);

NoCacheLineFill[] arr = new NoCacheLineFill[2];
arr[0] = new NoCacheLineFill();
arr[1] = new NoCacheLineFill();

Thread threadA = new Thread(() -> {
for (long i = 0; i < 100_000_000L; i++) {
arr[0].x = i;
}
countDownLatch.countDown();
}, "ThreadA");

Thread threadB = new Thread(() -> {
for (long i = 0; i < 100_000_000L; i++) {
arr[1].x = i;
}
countDownLatch.countDown();
}, "ThreadB");

final long start = System.nanoTime();
threadA.start();
threadB.start();
//等待线程A、B执行完毕
countDownLatch.await();
final long end = System.nanoTime();
System.out.println("耗时:" + (end - start) / 1_000_000 + "毫秒");

}
}

class NoCacheLineFill {
@Contended
public volatile long x = 1L;
}

如果想要@Contended注解起作用,需要在启动时添加JVM参数:

1
-XX:-RestrictContended

输出:

1
耗时:721毫秒

CPU缓存机制

CPU是计算机的大脑,所有的程序,最终都要变成CPU指令在CPU中去执行。CPU的计算速度是非常快的,但是,程序必须存储在存储介质中,程序启动之后被加载到内存中才能执行。但是内存的读取速度和CPU的计算速度之间存在非常大的差异。那么为了解决这个计算速度之间的差异,就在CPU上增加了缓存来解决这个问题。通常情况下,CPU是三级缓存结构,如下图:

越靠近CPU的缓存,其容量就越小,但是其速度就越快。所以实际上L1的容量是最小的,这取决于CPU的具体型号。

缓存行

为了提高IO效率,CPU每次从内存读取数据并不是只读取我们需要计算的数据,而是将我们需要的数据周围的64个字节(intel处理器的缓存行是64字节)的数据一次性全部读取到缓存中。这64个字节的数据就称为一个缓存行(Cache line)。即:一个缓存行可以存放多个数据。

  • 缓存一致性是根据缓存行(Cache line)为单元来进行同步的,即缓存中的传输单元为缓存行,CPU核心间交换数据是以缓存行为最小单位的,一个缓存行大小一般为64Byte。
  • 缓存行内的内容一发生变化,就需要进行缓存同步。即使使用的不是同一个数据,但只要他们在同一个缓存行中,就会进行同步。

伪共享

了解伪共享之前,先看看什么是真共享。

  • 真共享:一个蛋糕盒中只有一个大蛋糕,两个人吃。不同CPU的寄存器中都到了同一个变量X。
  • 伪共享:一个蛋糕盒中有两块蛋糕,一人一个。不同CPU的寄存器中用到了不同的变量,一个用到的是X,一个用到的是Y,并且X和Y在同一个缓存行中。

缓存行中的伪共享问题,简单的说就是,当CPU1对缓存行中的数据做了修改时,会通知CPU2,告诉他数据我修改了,你那边作废了,需要重新从内存读取。反之,CPU2对数据做出修改,CPU1也需要重新读取。这样就会导致大量的IO操作,导致性能降低。

伪共享解决办法

为了避免这种现象,我们需要想办法将这两个数据放到不同的缓存行中,这样就可以避免频繁的读取数据,增加性能。一种是:使用额外的字段来对齐缓存行;二种是:使用@Contended注解。

  • 缓存行填充。(示例一)不论如何进行缓存行的划分,包括x在内的连续64Byte,也就是一个缓存行不可能存在另一个变量Y,同样变量Y所在的缓存行不可能存在x,这样就不存在伪共享的情况,他们之间就不需要考虑缓存一致性问题了,也就节省了时间。
  • 使用@Contended注解。(示例二)但启动时需要添加JVM参数:-XX:-RestrictContended。

实际运用

  • LongAdder类的add()方法中,有一个Cell类,该类就是被@Contented修饰的。
  • ConcurrentHashMap的addCount()方法中,有一个CounterCell类,该类同样是被@Contented修饰的。