原文:https://mp.weixin.qq.com/s/l6dbdilAwUhiqNO0O-K9Wg
極簡計算機發展史
我們知道,計算機CPU和內存的交互是最頻繁的,內存是我們的高速緩存區。而剛開始用戶磁盤和CPU進行交互,CPU運轉速度越來越快,磁盤遠遠跟不上CPU的讀寫速度,才設計了內存,但是隨着CPU的發展,內存的讀寫速度也遠遠跟不上CPU的讀寫速度,因此,爲了解決這一矛盾,CPU廠商在每顆CPU上加入了高速緩存,用來緩解這種症狀,因此,現在CPU同內存交互就變成了下面的樣子。
單核CPU的性能不可能無限制的增長,要想很多的提升新能,需要多個處理器協同工作。 基於高速緩存的存儲交互很好的解決了處理器與內存之間的矛盾,也引入了新的問題:緩存一致性問題。在多處理器系統中,每個處理器有自己的高速緩存,而他們又共享同一塊內存(下文成主存,main memory 主要內存),當多個處理器運算都涉及到同一塊內存區域的時候,就有可能發生緩存不一致的現象。爲了解決這一問題,需要各個處理器運行時都遵循一些協議,在運行時需要用這些協議保證數據的一致性。
緩存一致性協議中最出名的就是Intel 的MESI協議,MESI協議保證了每個緩存中使用的共享變量的副本是一致的。它核心的思想是:當CPU寫數據時,如果發現操作的變量是共享變量,即在其他CPU中也存在該變量的副本,會發出信號通知其他CPU將該變量的緩存設置爲無效狀態,因此當其他CPU需要讀取這個變量時,發現自己緩存中該變量是無效狀態,那麼它就會從內存重新讀取
Java內存模型
Java的內存模型和上面的結構還是挺相似的,此時在看工作內存和主內存關係,從邏輯上,高速緩存對應工作內存,每個線程分配到CPU時間片時,獨自享有高速緩存的使用能力。主內存對應存儲的物理內存。特別注意,這只是邏輯上的對等關係,物理的上具體對應關係十分複雜,這裏不討論。
volatile的作用是什麼
volatile可以保證可見性,有序性,但不能保證原子性
可見性
可見性是指當多個線程訪問同一個變量時,一個線程修改了這個變量的值,其他線程能夠立即看得到修改的值
假如說有2個線程對一個變量data進行操作,線程先會把主內存中的值緩存到工作內存,這樣做的原因和上面提到的高速緩存類似,提高效率
但是這樣會引入新的問題,假如說線程A把data修改爲1,線程A的工作內存data值爲1,但是主內存和線程B的工作內存data值爲0,此時就有可能出現Java併發編程中的可見性問題
舉個例子,如下面代碼,線程A已經將flag的值改變,但是線程B並沒有及時的感知到,導致一直進行死循環
public class Test {
public static boolean flag = false;
public static void main(String[] args) {
new Thread(()->{
while(!flag) {
}
System.out.println("threadB end");
}).start();
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
new Thread(()->{
flag = true;
System.out.println("threadA end");
}).start();
}
}
輸出爲,線程B一直沒有結束
threadA end
但是如果將data定義爲如下形式,線程A對data的變更,線程B立馬能感知到
public static volatile boolean flag = false;
輸出爲
threadA end
threadB end
那麼是如何實現的呢?其實volatile保證可見性的方式和上面提到的緩存一致性協議的原理很類似
-
線程A將工作內存的data更改後,強制將data值刷回主內存
-
如果線程B的工作內存中有data變量的緩存時,會強制讓這個data變量緩存失效
-
當線程B需要讀取data變量的值時,先從工作內存中讀,發現已經過期,就會從主內存中加載data變量的最新值了
放個圖理解的更清楚
有序性
有序性即程序執行的順序按照代碼的先後順序執行
int i = 0;
boolean flag = false;
i = 1; //語句1
flag = true; //語句2
上面代碼定義了一個int型變量,定義了一個boolean類型變量,然後分別對兩個變量進行賦值操作。從代碼順序上看,語句1是在語句2前面的,那麼JVM在真正執行這段代碼的時候會保證語句1一定會在語句2前面執行嗎?不一定,爲什麼呢?這裏可能會發生指令重排序(Instruction Reorder)。
下面解釋一下什麼是指令重排序,一般來說,處理器爲了提高程序運行效率,可能會對輸入代碼進行優化,它不保證程序中各個語句的執行先後順序同代碼中的順序一致,但是它會保證程序最終執行結果和代碼順序執行的結果是一致的。
比如上面的代碼中,語句1和語句2誰先執行對最終的程序結果並沒有影響,那麼就有可能在執行過程中,語句2先執行而語句1後執行。
但是有依賴關係的語句不會進行重排序,如下面求圓面積的代碼
double pi = 4.14 //A
double r = 1.0 //B
double area = pi * r * r //c
程序的執行順序只有下面這2個形式
A->B->C和B->A->C,因爲A和C之間存在依賴關係,同時B和C之間也存在依賴關係。因此最終執行的指令序列中C不能被重排序到A和B前面。
雖然重排序不會影響單個線程內程序執行的結果,但是多線程呢?下面看一個例子
//線程1:
context = loadContext(); //語句1
inited = true; //語句2
//線程2:
while(!inited ){
sleep()
}
doSomethingwithconfig(context);
上面代碼中,由於語句1和語句2沒有數據依賴性,因此可能會被重排序。假如發生了重排序,在線程1執行過程中先執行語句2,而此是線程2會以爲初始化工作已經完成,那麼就會跳出while循環,去執行doSomethingwithconfig(context)方法,而此時context並沒有被初始化,就會導致程序出錯。
從上面可以看出,指令重排序不會影響單個線程的執行,但是會影響到線程併發執行的正確性
當寫雙重檢測鎖定版本的單例模式時,就要用到volatile來保證可見性
public class Singleton {
private volatile static Singleton uniqueInstance;
private Singleton() {}
public static Singleton getInstance() {
if (uniqueInstance == null) {
synchronized (Singleton.class) {
if (uniqueInstance == null) {
uniqueInstance = new Singleton();
}
}
}
return uniqueInstance;
}
}
至於爲什麼要用volatile,看推薦閱讀。
原子性
原子性即一個操作或者多個操作 要麼全部執行並且執行的過程不會被任何因素打斷,要麼就都不執行。
public class Test {
public static volatile int inc = 0;
public static void main(String[] args) {
//新建一個線程池
ExecutorService service = Executors.newCachedThreadPool();
//Java8 lambda表達式執行runnable接口
for (int i = 0; i < 5; i++) {
service.execute(() -> {
for (int j = 0; j < 1000; j++) {
inc++;
}
});
}
//關閉線程池
service.shutdown();
try {
TimeUnit.SECONDS.sleep(2);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("inc = " + inc);
}
}
執行上述代碼結果並不是每次都是5000,表明volatile並不能保證原子性
可能有的朋友就會有疑問,不對啊,上面是對變量inc進行自增操作,由於volatile保證了可見性,那麼在每個線程中對inc自增完之後,在其他線程中都能看到修改後的值啊,所以有5個線程分別進行了1000次操作,那麼最終inc的值應該是1000*5=5000。
這裏面就有一個誤區了,volatile關鍵字能保證可見性沒有錯,但是上面的程序錯在沒能保證原子性。可見性只能保證每次讀取的是最新的值,但是volatile沒辦法保證對變量的操作的原子性。
在前面已經提到過,自增操作是不具備原子性的,它包括讀取變量的原始值、進行加1操作、寫入工作內存。那麼就是說自增操作的三個子操作可能會分割開執行,就有可能導致下面這種情況出現:
假如某個時刻變量inc的值爲10,線程1對變量進行自增操作,線程1先讀取了變量inc的原始值,然後線程1被阻塞了;然後線程2對變量進行自增操作,線程2也去讀取變量inc的原始值,由於線程1只是對變量inc進行讀取操作,而沒有對變量進行修改操作,所以不會導致線程2的工作內存中緩存變量inc的緩存行無效,也不會導致主存中的值刷新,所以線程2會直接去主存讀取inc的值(這個部分小編感覺是海子大佬的筆誤,應該是線程2會直接去工作內存讀取inc的值,因爲工作內存中inc並沒有失效),發現inc的值時10,然後進行加1操作,並把11寫入工作內存,最後寫入主存。
然後線程1接着進行加1操作,由於已經讀取了inc的值(inc++,包括3個操作,1.讀取inc的值,2.進行加1操作,3.寫入新的值),注意此時在線程1的工作內存中inc的值仍然爲10,所以線程1對inc進行加1操作後inc的值爲11,然後將11寫入工作內存,最後寫入主存。
那麼兩個線程分別進行了一次自增操作後,inc只增加了1。
根源就在這裏,自增操作不是原子性操作,而且volatile也無法保證對變量的任何操作都是原子性的。
解決方案:可以通過synchronized或lock,進行加鎖,來保證操作的原子性。也可以通過使用AtomicInteger
應用
前面已經演示過了
1.狀態標記量
2.單例模式中的double check,看推薦閱讀
參考資料
《Java併發編程的藝術》
博客園 海子博客
推薦閱讀