volatile
先来说说volatie的作用
- 禁止指令重排
- 保证变量的可见性,但是不能保证互斥性
具体实现是采用了内存屏障
在《并发编程艺术》这本书中说到被volatile修饰的变量进行写操作的时候,会多出一行lock前缀的指令,触发两件事
- 将当前处理器的缓存行数据写回到系统内存
- 这个写回到内存的操作会使其他CPU里的缓存了该内存地址的数据无效
对象大小
我们知道java对象头的大小在32系统下面是8B,但是在64位系统下面就是16B,但是在java8里面,默认开启了指针压缩,所以是12B,但是我们都知道是以一个字宽为单位的,所以padding 4B,我们导入个小工具
<dependency>
<groupId>org.apache.lucene</groupId>
<artifactId>lucene-core</artifactId>
<version>4.2.0</version>
</dependency>
然后测试:
class SharingInt {
volatile int value;
}
System.out.println("object size:"+RamUsageEstimator.sizeOf(new Object()));
System.out.println("sharingInt size:"+RamUsageEstimator.sizeOf(new SharingInt()));
结果:
object size:16
sharingInt size:16
可以看出没有实例域的Object是16B,有一个int实例域的SharingInt也是16B,是因为默认开启了指针压缩,int在java中是占用4B,所以12B+4B=16B,这借用一下R大的回复
伪共享
cpu高速缓存中的最小单位是缓存行,它的大小一般为32B,64B,128B,265B,现在电脑最常见的缓存行就64B的。当多个线程访问修改独立的变量的时候,恰好这些变量内存地址很接近,同在一条缓存行上面,由于MESI协议的原因,就会无意之间影响了性能
我们来看一个例子
class SharingInt {
volatile int value;
// long p1, p2, p3, p4, p5, p6;
}
public class CacheLine extends Thread {
private final SharingInt[] shares;
private final int index;
public CacheLine(SharingInt[] shares, int index) {
this.shares = shares;
this.index = index;
}
/**
* maven 导入小工具
* <dependency>
* <groupId>org.apache.lucene</groupId>
* <artifactId>lucene-core</artifactId>
* <version>4.2.0</version>
* </dependency>
*
*/
public static void main(String[] args) throws InterruptedException {
// System.out.println(RamUsageEstimator.sizeOf(new SharingInt()));
for (int i = 0; i < 10; i++) {
test();
}
}
private static void test() throws InterruptedException {
//cpu 并行处理
int size = Runtime.getRuntime().availableProcessors();
SharingInt[] shares = new SharingInt[size];
for (int i = 0; i < size; i++) {
shares[i] = new SharingInt();
}
Thread[] threads = new Thread[size];
for (int i = 0; i < size; i++) {
threads[i] = new CacheLine(shares, i);
}
for (Thread t : threads) {
t.start();
}
long start = System.currentTimeMillis();
for (Thread t : threads) {
t.join();
}
long end = System.currentTimeMillis();
System.out.printf("用时: %dms\n", end - start);
}
@Override
public void run() {
for (int i = 0; i < 100000000; i++) {
shares[index].value++;
}
}
}
代码很简单,N(与CPU核心相同)条线程共享同一个数组,让1~N条线程分别访问同一个数组的不同下标,互不干扰,每个线程循环1亿次读写操作(shares[index].v++)
我的电脑是4核8线程64位的系统,运行结果如下:
用时: 10531ms
用时: 9665ms
用时: 9668ms
用时: 9974ms
用时: 10364ms
用时: 10250ms
用时: 10342ms
用时: 10982ms
用时: 10604ms
用时: 10931ms
然后再去掉SharingInt里面的注释,再跑一遍
用时: 3735ms
用时: 4082ms
用时: 4007ms
用时: 1376ms
用时: 3860ms
用时: 3685ms
用时: 4366ms
用时: 1341ms
用时: 3039ms
用时: 3777ms
为什么会有那么大的差距呢?是因为伪共享的缘故当第一条线程返回index=0的时候
- 假设线程1,线程2分别在Core1,Core2中获取到时间令牌,然后都会加载Cache Line 1,这时候Cache Line 1的状态是S(共享)
- 然后可能线程1先修改了index=0的SharingInt.value,然后Cache Line 1 从 S变为M(修改),然后根据volatile的语义,然后立马把Cache Line 写回到主存,然后Cache Line 1 的状态置从M变为I(无效)
- 然后等到Core2 需要修改index=1的SharingInt.value时,发现Cache Line 1 的状态为I(无效),然后直又从主存读取Cache Line 1进来,然后把状态变为E独享,然后修改value之后,又将Cache Line 刷新回主存。
以上就是MESI缓存一致性协议的工作过程,可以看出一条一样数据被多读进一次CPU 的cache,所以这个操作就消耗了时间
避免伪共享
避免伪共享的两种方式:
1.增大对象的空间,使得需要访问的数据不在同一个Cache Line上面,典型的空间换时间的方法
- 在每个线程添加本地副本,等待完全修改完成后再写回主存
padding:
修改SharingInt
class SharingInt {
volatile int value;
long p1, p2, p3, p4, p5, p6;
}
System.out.println("sharingInt size:"+RamUsageEstimator.sizeOf(new SharingInt()));
sharingInt size:64
这样一个SharingInt对象就填充了一个缓存行了,在java中一个long就是8B,加多6个刚刚好64B,这样子各个线程对相对应的对象修改就不会在不同的缓存行
上面追加之后的结果之后虽然快了很多,但是你会发现有一些1秒多有一些需要3秒多甚至4秒。这就是这种方式的不好之处,因为个人的操作系统或者CPU架构都可能不一样,
java7会优化这种字节追加方式而导致失效,但是查看java8编译的字节码来看,并没有优化掉,但是没有办法稳定下来
以继承的方式避免优化
我们修改一下SharingInt
class Temp{
long p1,p2,p3,p4,p5,p6;
}
class SharingInt extends Temp{
volatile int value;
}
System.out.println("sharingInt size:"+RamUsageEstimator.sizeOf(new SharingInt()));
sharingInt size:72
有人可能会问为什么多了8B,因为是继承关系,子类会多一个Reference类型,Reference类型在java中占4B,然后padding 4B就刚刚好72B
运行结果
用时: 1623ms
用时: 1305ms
用时: 1295ms
用时: 1307ms
用时: 1279ms
用时: 1286ms
用时: 1277ms
用时: 1269ms
用时: 1279ms
用时: 1312ms
虽然稳定了优化但是这样某一天java又进行了一系列的优化也许也不行了,但是在在java8给出了官方的实现
@Contended
在2012年openjdk的JEP-142说到使用这个注解可以自动追加合适的大小padding
这个注解需要是用在用户代码上面(非bootstrap class loader或者extension class loader所加载的类),并且需要添加-XX:-RestrictContended启动参数
我们修改SharingInt
class SharingInt {
@Contended
volatile int value;
}
System.out.println("sharingInt size:"+RamUsageEstimator.sizeOf(new SharingInt()));
sharingInt size:144
我们看到SharingInt被追加128B的padding,在JEP-142中提及
Note that we use 128 bytes, twice the cache line size on most hardware
to adjust for adjacent sector prefetchers extending the false sharing
collisions to two cache lines.
padding的大小定义为目前大多数CPU的Cache Line 大小的2倍,就是128B
分组功能:
There are cases where you want to separate the group of fields that
are experiencing contention with everything else but not pairwise. This
is the usual thing for some of the code updating two fields at once.
While marking both with @Contended would be sufficient, we can optimize
the memory footprint by not applying padding between them. In order to
demarcate these groups, we have the parameter in the annotation
describing the equivalence class for contention group.
意思就是如果两个字段a,b都被一个CPU修改,虽然各自追加padding就足够了,但是jvm可以将a,b字段优化在一个Cache Line上面
我们看一个例子:
class VolatileLong {
@Contended("1")
public volatile long value1 = 0L;
@Contended("1")
public volatile long value2 = 0L;
@Contended("2")
public volatile long value3 = 0L;
@Contended("2")
public volatile long value4 = 0L;
}
public final class ContendedTest implements Runnable {
private final VolatileLong volatileLong;
private final int id;
public ContendedTest(int id,VolatileLong volatileLong) {
this.id = id;
this.volatileLong = volatileLong;
}
//-XX:-RestrictContended
public static void main(final String[] args) throws Exception {
runTest();
}
private static void runTest() throws InterruptedException {
VolatileLong volatileLong = new VolatileLong();
Thread t0 = new Thread(new ContendedTest(1,volatileLong));
Thread t1 = new Thread(new ContendedTest(2,volatileLong));
final long start = System.currentTimeMillis();
t0.start();
t1.start();
t0.join();
t1.join();
System.out.println("用时:" + (System.currentTimeMillis() - start)+"ms");
}
@Override
public void run() {
long i = 500000000;
if (1 == id) {
while (0 != i--) {
volatileLong.value1 = i;
volatileLong.value2 = i;
}
} else if (2 == id) {
while (0 != i--) {
volatileLong.value3 = i;
volatileLong.value4 = i;
}
}
}
}
运行结果:用时:6151ms
代码很简单,两个线程分别对两个long变量赋值,重复5亿次使用了6s的时间
我们将run()方法替换如下再跑一遍
@Override
public void run() {
long i = 500000000;
if (1 == id) {
while (0 != i--) {
volatileLong.value1 = i;
volatileLong.value3 = i;
}
} else if (2 == id) {
while (0 != i--) {
volatileLong.value2 = i;
volatileLong.value4 = i;
}
}
}
运行结果:用时:23963ms
那是使用了@contended
注解分组
- value1,value2被分配到了一条Cache Line
- value3 value4被分配到了一条Cache Line
两条线程相互修改对方的Cache Line,又要从主存里面重新读取最新的数据,所以这件花费了大量的时间
本地变量副本
在JMM(java Memory Model)中,每一个线程都会有一个线程副本,每一次修改完之后不会立马刷新回主存,而是等处理完之后才刷新会主存
我们改一下上面的VolatileLong
class VolatileLong {
@Contended("1")
public long value1 = 0L;
@Contended("1")
public long value2 = 0L;
@Contended("2")
public long value3 = 0L;
@Contended("2")
public long value4 = 0L;
}
我们分别使用两种run方法去执行,两个方法的耗时
第一中run方法
用时:398ms
第二种run方法
用时:2871ms
虽然有差距,但是也没有之前那么严重了,所以使用volatile需要谨慎