java并发编程之美 学习笔记
什么是伪共享
Cache
为了解决计算机系统中主内存和CPU之间的运行速度差问题,会在CPU和主内存之间添加一级或多级高速缓冲存储器(Cache)
. 这个缓存一般是集成到CPU内部的,所以也叫CPU cache.
缓存行(Cache行)
在cache内是按行存储的,其中每一行称之为一个Cache行
.Cache行是Cache与内存进行数据交换的单位,Cache行的大小一般为2的幂次方字节。
当CPU访问某个变量时,首先会去CPU cache内是否有该变量
- 如果有,则字节使用
- 如果没有,则去主内存中获取该变量,然后把该变量所在
内存区域的一个Cache行大小的内存
复制到cache汇总。(该内存块中可能包含多个变量
)
伪共享
当多线程修改一个cache行的多个变量时,如下图所示:
当线程1对x进行更新时,首先修改cpu1的cache1中变量x所在的缓存行,在缓存一致性协议下,cpu2中cache1中变量x所在的缓存行就会失效。
那么在线程2对变量y进行修改时,就只能从cache2中去寻找y变量。(如果cpu只有一级缓存,则cpu会频繁的访问主内存)。
当多个线程同时修改一个缓存行
里的多个变量
时,只允许一个线程操作缓存行,这个就是伪共享.
为何会出现伪共享
伪共享的产生时因为:多个变量被放入了一个缓存行,并且有多个线程更新该缓存行中的不同变量。
为什么多个变量会放入一个缓存行呢?
因为缓存行是cpu和内存交换的数据单位
,如果cpu中需要访问变量x,而缓存中没有x,则需要到主内存中寻找x,并将x所在内存地址附近的一个缓存行的数据放入缓存。
那么为什么不把cpu和内存的交换单位改变为单个变量的大小
呢,即一个缓存行只有一个变量呢?
先看下面一个例子:
public class Test4Content {
static final int LINE_NUM = 1024;
static final int COLUMN_NUM = 1024;
/**
* 数组中内存是连续的,当访问数组中的第一个元素时,会把后续的若干个元素放入缓存行,
* 这样顺序访问元素时会在缓存中直接命中,而不用去主内存中读取了。
*/
static void cache(){
long[][] arr = new long[LINE_NUM][COLUMN_NUM];
long startTime = System.nanoTime();
for (int i = 0; i < LINE_NUM; i++) {
for (int j = 0; j < COLUMN_NUM; j++) {
arr[i][j] = i*2 + j;
}
}
long endTime = System.nanoTime();
System.out.println("cache time: " +(endTime - startTime) +"ns");
}
/**
* 跳跃式读取,不是顺序的,而cpu缓存是有容量的,当无法将整个数组放到缓存时,就需要从主内存中读取数据了。
*/
static void noCache(){
long[][] arr = new long[LINE_NUM][COLUMN_NUM];
long startTime = System.nanoTime();
for (int i = 0; i < LINE_NUM; i++) {
for (int j = 0; j < COLUMN_NUM; j++) {
//跳跃式读取...
arr[j][i] = i*2 + j;
}
}
long endTime = System.nanoTime();
System.out.println("no cache time: " +(endTime - startTime) +"ns");
}
public static void main(String[] args) {
IntStream.rangeClosed(1,10).forEach(index ->{
cache();
noCache();
System.out.println("- -- --- ---- ----- ------ -------");
});
}
}
部分运行结果截图:
上述例中cache()
方法比nocache()
方法速度上快了不少。
这是因为数组内元素在内存地址上是一段连续的空间,当访问数组第一个元素时,会把后续的若干元素一块放入缓存行,这样顺序访问数组元素时会在缓存中直接命中。
cache()
方法-顺序读取: 当元素在缓存中不存在,会从主内存中读取若干个元素到缓存,而后续的读取可以直接命中缓存。nocache()
方法-跳跃读取:因为它不是顺序读取,每读取一个元素后就需要再次从主内存中读取元素,但是cpu缓存容量是有限的,当缓存满了之后会采取淘汰算法淘汰缓存行
,导致缓存行中的其他元素没有读取到时就已经被淘汰,当使用到该元素时又需要重新从内存中读取。
上面的例子解释了为什么一个缓存行为什么不只存储一个变量:
- 首先cpu缓存容量是有限的
- 在单线程程序中,修改一个缓存行的多个变量(如上例中的数组),会利用数组在内存的连续性,加速程序的运行.
如何解决伪共享
在jdk8之前,一般是通过字节填充
的方式来避免伪共享问题。
字节填充
class FilledLong{
//long 占用8字节
public volatile long value = 0;
//填充了p1-p6 = 6*8 = 48 字节
private long p1,p2,p3,p4,p5,p6;
}
填充了6个字段占用48字节,FilledLong对象头8字节,value占用8字节, 正好凑够64个字节,可以放入一个缓存行。
@sun.misc.Contended注解
jdk8 提供了该注解,用来解决伪共享问题:
//注解在类上
@sun.misc.Contended
static class FilledLong{
public volatile long value = 0;
}
//注解在变量上
public class Thread implements Runnable {
@sun.misc.Contended("tlr")
long threadLocalRandomSeed;
@sun.misc.Contended("tlr")
int threadLocalRandomProbe;
@sun.misc.Contended("tlr")
int threadLocalRandomSecondarySeed;
}
在默认情况下,
@Contended
注解只用于Java核心类,比如rt.jar下的类。
如果用户类路径下的类需要使用这个注解,则需要添加JNM参数:-XX:-RestrictContended
。
填充的宽度默认为128
,要自定义宽度则可以设置-XX:ContendPaddingWidth
参数。