一.伪共享与缓存行
1.CPU缓存架构
CPU 是计算机的心脏,所有运算和程序最终都要由它来执行。
主内存(RAM)是数据存放的地方,CPU 和主内存之间有好几级缓存,因为即使直接访问主内存也是非常慢的。
CPU的速度要远远大于内存的速度,为了解决这个问题,CPU引入了三级缓存:L1,L2和L3三个级别,L1最靠近CPU,L2次之,L3离CPU最远,L3之后才是主存。速度是L1>L2>L3>主存。越靠近CPU的容量越小。CPU获取数据会依次从三级缓存中查找
当CPU要读取一个数据时,首先从一级缓存中查找,如果没有找到再从二级缓存中查找,如果还是没有就从三级缓存或内存中查找。一般来说,每级缓存的命中率大概都在80%左右,也就是说全部数据量的80%都可以在一级缓存中找到,只剩下20%的总数据量才需要从二级缓存、三级缓存或内存中读取,由此可见一级缓存是整个CPU缓存架构中最为重要的部分。
2.什么是伪共享
计算机系统中为了解决主内存与CPU运行速度的差距,在CPU与主内存之间添加了一级或者多级高速缓冲存储器(Cache),这个Cache一般是集成到CPU内部的,所以也叫 CPU Cache,如下图是两级cache结构
Cache内部是按行存储的,其中每一行称为一个缓存行,缓存行是Cache与主内存进行数据交换的单位,缓存行的大小一般为2的幂次数字节。
当CPU访问某一个变量时候,首先会去看CPU Cache内是否有该变量,如果有则直接从中获取,否者就去主内存里面获取该变量,然后把该变量所在内存区域的一个Cache行大小的内存拷贝到Cache(cache行是Cache与主内存进行数据交换的单位)。由于存放到Cache行的的是内存块而不是单个变量,所以可能会把多个变量存放到了一个cache行。当多个线程同时修改一个缓存行里面的多个变量时候,由于同时只能有一个线程操作缓存行,所以相比每个变量放到一个缓存行性能会有所下降,这就是伪共享。
如上图变量x,y同时被放到了CPU的一级和二级缓存,当线程1使用CPU1对变量x进行更新时候,首先会修改cpu1的一级缓存变量x所在缓存行,这时候缓存一致性协议会导致cpu2中变量x对应的缓存行失效,那么线程2写入变量x的时候就只能去二级缓存去查找,这就破坏了一级缓存,而一级缓存比二级缓存更快。更坏的情况下如果cpu只有一级缓存,那么会导致频繁的直接访问主内存。
3.为何会出现伪共享
伪共享的产生是因为多个变量被放入了一个缓存行,并且多个线程同时去写入缓存行中不同变量。那么为何多个变量会被放入一个缓存行那。其实是因为Cache与内存交换数据的单位就是Cache,当CPU要访问的变量没有在Cache命中时候,根据程序运行的局部性原理会把该变量在内存中大小为Cache行的内存放如缓存行。
4.Java中的伪共享
解决伪共享最直接的方法就是填充(padding),例如下面的VolatileLong,一个long占8个字节,Java的对象头占用8个字节(32位系统)或者12字节(64位系统,默认开启对象头压缩,不开启占16字节)。一个缓存行64字节,那么我们可以填充6个long(6 * 8 = 48 个字节)。
现在,我们学习JVM对象的内存模型。所有的Java对象都有8字节的对象头,前四个字节用来保存对象的哈希码和锁的状态,前3个字节用来存储哈希码,最后一个字节用来存储锁状态,一旦对象上锁,这4个字节都会被拿出对象外,并用指针进行链接。剩下4个字节用来存储对象所属类的引用。对于数组来讲,还有一个保存数组大小的变量,为4字节。每一个对象的大小都会对齐到8字节的倍数,不够8字节部分需要填充。为了保证效率,Java编译器在编译Java对象的时候,通过字段类型对Java对象的字段进行排序,如下表所示。
因此,我们可以在任何字段之间通过填充长整型的变量把热点变量隔离在不同的缓存行中,通过减少伪同步,在多核心CPU中能够极大的提高效率。
最简单的方式
/**
* 缓存行填充父类
*/
public class DataPadding {
//填充 6个long类型字段 8*4 = 48 个字节
private long p1, p2, p3, p4, p5, p6;
//需要操作的数据
private long data;
}
因为JDK1.7以后就自动优化代码会删除无用的代码,在JDK1.7以后的版本这些不生效了
继承的方式
/**
* 缓存行填充父类
*/
public class DataPadding {
//填充 6个long类型字段 8*4 = 48 个字节
private long p1, p2, p3, p4, p5, p6;
}
继承缓存填充类
/**
* 继承DataPadding
*/
public class VolatileData extends DataPadding {
// 占用 8个字节 +48 + 对象头 = 64字节
private long data = 0;
public VolatileData() {
}
public VolatileData(long defValue) {
this.data = defValue;
}
public long accumulationAdd() {
//因为单线程操作不需要加锁
data++;
return data;
}
public long getValue() {
return data;
}
}
这样在JDK1.8中是可以使用的
@Contended注解
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.FIELD, ElementType.TYPE})
public @interface Contended {
String value() default "";
}
Contended注解可以用于类型上和属性上,加上这个注解之后虚拟机会自动进行填充,从而避免伪共享。这个注解在Java8 ConcurrentHashMap、ForkJoinPool和Thread等类中都有应用。我们来看一下Java8中ConcurrentHashMap中如何运用Contended这个注解来解决伪共享问题。以下说的ConcurrentHashMap都是Java8版本。
注意:在Java8中提供了**@sun.misc.Contended来避免伪共享时,在运行时需要设置JVM启动参数-XX:-RestrictContended**否则可能不生效。
缓存行填充的威力
/**
* 缓存行测试
*/
public class CacheLineTest {
/**
* 是否启用缓存行填充
*/
private final boolean isDataPadding = false;
/**
* 正常定义的变量
*/
private volatile long x = 0;
private volatile long y = 0;
private volatile long z = 0;
/**
* 通过缓存行填充的变量
*/
private volatile VolatileData volatileDataX = new VolatileData(0);
private volatile VolatileData volatileDataY = new VolatileData(0);
private volatile VolatileData volatileDataZ = new VolatileData(0);
/**
* 循环次数
*/
private final long size = 100000000;
/**
* 进行累加操作
*/
public void accumulationX() {
//计算耗时
long currentTime = System.currentTimeMillis();
long value = 0;
//循环累加
for (int i = 0; i < size; i++) {
//使用缓存行填充的方式
if (isDataPadding) {
value = volatileDataX.accumulationAdd();
} else {
//不使用缓存行填充的方式 因为时单线程操作不需要加锁
value = (++x);
}
}
//打印
System.out.println(value);
//打印耗时
System.out.println("耗时:" + (System.currentTimeMillis() - currentTime));
}
/**
* 进行累加操作
*/
public void accumulationY() {
long currentTime = System.currentTimeMillis();
long value = 0;
for (int i = 0; i < size; i++) {
if (isDataPadding) {
value = volatileDataY.accumulationAdd();
} else {
value = ++y;
}
}
System.out.println(value);
System.out.println("耗时:" + (System.currentTimeMillis() - currentTime));
}
/**
* 进行累加操作
*/
public void accumulationZ() {
long currentTime = System.currentTimeMillis();
long value = 0;
for (int i = 0; i < size; i++) {
if (isDataPadding) {
value = volatileDataZ.accumulationAdd();
} else {
value = ++z;
}
}
System.out.println(value);
System.out.println("耗时:" + (System.currentTimeMillis() - currentTime));
}
public static void main(String[] args) {
//创建对象
CacheLineTest cacheRowTest = new CacheLineTest();
//创建线程池
ExecutorService executorService = Executors.newFixedThreadPool(3);
//启动三个线程个调用他们各自的方法
executorService.execute(() -> cacheRowTest.accumulationX());
executorService.execute(() -> cacheRowTest.accumulationY());
executorService.execute(() -> cacheRowTest.accumulationZ());
executorService.shutdown();
}
}
不使用缓存行填充测试
/**
* 是否启用缓存行填充
*/
private final boolean isDataPadding = false;
输出
100000000
耗时:7960
100000000
耗时:7984
100000000
耗时:7989
使用缓存行填充测试
/**
* 是否启用缓存行填充
*/
private final boolean isDataPadding = true;
输出
100000000
耗时:176
100000000
耗时:178
100000000
耗时:182
同样的结构他们之间差了 将近 50倍的速度差距
总结
当多个线程同时对共享的缓存行进行写操作的时候,因为缓存系统自身的缓存一致性原则,会引发伪共享问题,解决的常用办法是将共享变量根据缓存行大小进行补充对齐,使其加载到缓存时能够独享缓存行,避免与其他共享变量存储在同一个缓存行。