volatile
先來說說volatie的作用
- 禁止指令重排
- 保證變量的可見性,但是不能保證互斥性
具體實現(xiàn)是采用了內(nèi)存屏障
在《并發(fā)編程藝術(shù)》這本書中說到被volatile修飾的變量進(jìn)行寫操作的時候,會多出一行l(wèi)ock前綴的指令,觸發(fā)兩件事
- 將當(dāng)前處理器的緩存行數(shù)據(jù)寫回到系統(tǒng)內(nèi)存
- 這個寫回到內(nèi)存的操作會使其他CPU里的緩存了該內(nèi)存地址的數(shù)據(jù)無效
對象大小
我們知道java對象頭的大小在32系統(tǒng)下面是8B,但是在64位系統(tǒng)下面就是16B,但是在java8里面,默認(rèn)開啟了指針壓縮,所以是12B,但是我們都知道是以一個字寬為單位的,所以padding 4B,我們導(dǎo)入個小工具
<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()));
結(jié)果:
object size:16
sharingInt size:16
可以看出沒有實例域的Object是16B,有一個int實例域的SharingInt也是16B,是因為默認(rèn)開啟了指針壓縮,int在java中是占用4B,所以12B+4B=16B,這借用一下R大的回復(fù)

偽共享
cpu高速緩存中的最小單位是緩存行,它的大小一般為32B,64B,128B,265B,現(xiàn)在電腦最常見的緩存行就64B的。當(dāng)多個線程訪問修改獨立的變量的時候,恰好這些變量內(nèi)存地址很接近,同在一條緩存行上面,由于MESI協(xié)議的原因,就會無意之間影響了性能
我們來看一個例子
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 導(dǎo)入小工具
* <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核心相同)條線程共享同一個數(shù)組,讓1~N條線程分別訪問同一個數(shù)組的不同下標(biāo),互不干擾,每個線程循環(huán)1億次讀寫操作(shares[index].v++)

我的電腦是4核8線程64位的系統(tǒng),運行結(jié)果如下:
用時: 10531ms
用時: 9665ms
用時: 9668ms
用時: 9974ms
用時: 10364ms
用時: 10250ms
用時: 10342ms
用時: 10982ms
用時: 10604ms
用時: 10931ms
然后再去掉SharingInt里面的注釋,再跑一遍
用時: 3735ms
用時: 4082ms
用時: 4007ms
用時: 1376ms
用時: 3860ms
用時: 3685ms
用時: 4366ms
用時: 1341ms
用時: 3039ms
用時: 3777ms
為什么會有那么大的差距呢?是因為偽共享的緣故當(dāng)?shù)谝粭l線程返回index=0的時候

- 假設(shè)線程1,線程2分別在Core1,Core2中獲取到時間令牌,然后都會加載Cache Line 1,這時候Cache Line 1的狀態(tài)是S(共享)
- 然后可能線程1先修改了index=0的SharingInt.value,然后Cache Line 1 從 S變?yōu)?strong>M(修改),然后根據(jù)volatile的語義,然后立馬把Cache Line 寫回到主存,然后Cache Line 1 的狀態(tài)置從M變?yōu)?strong>I(無效)
- 然后等到Core2 需要修改index=1的SharingInt.value時,發(fā)現(xiàn)Cache Line 1 的狀態(tài)為I(無效),然后直又從主存讀取Cache Line 1進(jìn)來,然后把狀態(tài)變?yōu)?strong>E獨享,然后修改value之后,又將Cache Line 刷新回主存。
以上就是MESI緩存一致性協(xié)議的工作過程,可以看出一條一樣數(shù)據(jù)被多讀進(jìn)一次CPU 的cache,所以這個操作就消耗了時間
避免偽共享
避免偽共享的兩種方式:
1.增大對象的空間,使得需要訪問的數(shù)據(jù)不在同一個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,這樣子各個線程對相對應(yīng)的對象修改就不會在不同的緩存行

上面追加之后的結(jié)果之后雖然快了很多,但是你會發(fā)現(xiàn)有一些1秒多有一些需要3秒多甚至4秒。這就是這種方式的不好之處,因為個人的操作系統(tǒng)或者CPU架構(gòu)都可能不一樣,
java7會優(yōu)化這種字節(jié)追加方式而導(dǎo)致失效,但是查看java8編譯的字節(jié)碼來看,并沒有優(yōu)化掉,但是沒有辦法穩(wěn)定下來
以繼承的方式避免優(yōu)化
我們修改一下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,因為是繼承關(guān)系,子類會多一個Reference類型,Reference類型在java中占4B,然后padding 4B就剛剛好72B
運行結(jié)果
用時: 1623ms
用時: 1305ms
用時: 1295ms
用時: 1307ms
用時: 1279ms
用時: 1286ms
用時: 1277ms
用時: 1269ms
用時: 1279ms
用時: 1312ms
雖然穩(wěn)定了優(yōu)化但是這樣某一天java又進(jìn)行了一系列的優(yōu)化也許也不行了,但是在在java8給出了官方的實現(xiàn)
@Contended
在2012年openjdk的JEP-142說到使用這個注解可以自動追加合適的大小padding
這個注解需要是用在用戶代碼上面(非bootstrap class loader或者extension class loader所加載的類),并且需要添加-XX:-RestrictContended啟動參數(shù)
我們修改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的大小定義為目前大多數(shù)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字段優(yōu)化在一個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;
}
}
}
}
運行結(jié)果:用時:6151ms
代碼很簡單,兩個線程分別對兩個long變量賦值,重復(fù)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;
}
}
}
運行結(jié)果:用時:23963ms
那是使用了@contended注解分組
- value1,value2被分配到了一條Cache Line
- value3 value4被分配到了一條Cache Line
兩條線程相互修改對方的Cache Line,又要從主存里面重新讀取最新的數(shù)據(jù),所以這件花費了大量的時間
本地變量副本
在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方法去執(zhí)行,兩個方法的耗時
第一中run方法
用時:398ms
第二種run方法
用時:2871ms
雖然有差距,但是也沒有之前那么嚴(yán)重了,所以使用volatile需要謹(jǐn)慎