1、計算機中線程不安全問題產生緣由
計算機在執行程序時,每條指令都是在CPU中執行的,執行的過程會涉及到讀取和寫入。程序運行過程當中的臨時數據是存放在主存(物理內存)中的,這就會產生一個問題,因爲CPU的執行速度很快,而從內存讀取數據和向內存寫入數據的過程跟CPU的執行速度相比就慢不少,所以若是任什麼時候候對數據的操做都要經過和內存的交互來進行,就會大大下降指令的執行速度。
爲了解決這個問題,計算機中就有了CPU緩存的概念。在程序的運行過程當中,操做的數據會從內存中複製一份到CPU緩存中,當CPU進行計算的時候就能夠直接從它的緩存中讀取數據和寫入數據,當運算結束以後,再將緩存中的數據刷新到主存中。舉個例子:
CPU在執行這段代碼時,會先從高速緩存中查看是否有t的值,若是有,則直接拿來使用,若是沒有,則會從主存中讀取,讀取以後會複製一份存放在高速緩存中方便下次使用。以後cup進行對t加1操做,而後把數據寫入高速緩存,最後會把高速緩存中的數據刷新到主存中。
這一過程在單線程運行下時沒問題的,單當在多線程的狀況下就會有問題了。在多核CPU中,每條線程可能會運行在不一樣的CPU中,所以每一個線程運行時有本身的CPU緩存。這時就會出現同一個變量在兩個CPU緩存中值不一致的狀況。
例如,兩個線程分別讀取了t的值,假設此時t的值爲0,而且把t的值存到了各自的CPU緩存中,而後線程1對t進行了加1操做,此時t的值爲1,並把t的值寫回到了主存中。但線程2中的CPU緩存仍是0,進行加1操做後,t的值爲1,再把t寫回主存,此時就出現了線程不安全的問題。
對非普通變量進行讀寫的時候,每一個線程先從內存拷貝變量到CPU緩存中。若是計算機有多個CPU,每一個線程可能在不一樣的CPU上被處理,這意味着每一個線程能夠拷貝到不一樣的 CPU cache 中。
2、如何確保線程安全
Java中有幾種機制來確保線程安全,例如Volatile、Synchronized關鍵字,這些機制適用於各類平臺。一般,要保證線程安全,就是指要保證如下三個方面特性的完整和正常:
1.原子性---在對數據進行操做時這個操做時不可分割的,好比a=0這個操做就是個原子操做,a++(其實是a=a+1)這個操做是可分隔的,它就不是一個原子操做。java中使用synchronized、lock、unlock來保證原子性。
2.可見性---一個線程對主內存的修改能及時被其餘線程看到,也就是說,在多線程環境下,某個共享變量若是被其中一個線程給修改了,其餘線程可以當即知道這個共享變量已經被修改了,當其餘線程要讀取這個變量的時候,最終會去內存中讀取,而不是從本身的工做空間中讀取。
好比:使用volatile修飾的變量就具備可見性,volatile修飾的變量不容許內部緩存和指令重排序,即它的操做不通過CPU緩存,而直接修改內存。volatile 保證了新值能當即同步到主內存,以及每次使用前當即從主內存刷新。但普通變量作不到這點,普通變量的值在線程間傳遞均須要經過線程--->工做空間--->主內存這樣的順序來完成。
3.有序性---java使用volatile和synchronized來保證線程之間的有序性,volatile是由於自己包含「禁止執行重排序」的語義,synchronized是由「一個變量在同一時刻只容許一個線程對其進行lock操做」得到的。產生這個問題緣由是虛擬機在執行代碼時,並不會按照咱們事先編寫好的代碼順序來執行,好比下面兩行代碼:
對於這兩行代碼,不管是先執行a=1仍是b=2,都不會對a,b最終的值有任何影響,因此虛擬機在編譯的時候,是有可能對它們重排序的。CPU採用了容許將多條指令不按程序規定的順序分開發送給各相應電路單元處理的機制,假如執行int a = 1須要100ms的時間,而執行int b = 2 須要1ms的時間,而且執行哪句代碼都不會對a,b的最終值產生影響,那固然是先執行b=2這句代碼了。因此,虛擬機在進行代碼編譯優化的時候,對於那些改變順序以後不會對最終變量的值形成影響的代碼,是有可能將他們進行重排序的。
那麼這個時候指令的重排序雖然對值沒有什麼影響,但可能會出現線程安全的問題。
public class NoVisibility {
private static boolean ready;
private static int number;
private static class ReaderThread extends Thread {
@Override
public void run() {
while(!ready) {
Thread.yield();
}
System.out.println(number);
}
}
public static void main(String[] args) {
new ReaderThread().start();
number = 42;
ready = true;
}
}
這段代碼最終打印的必定是42嗎?若是沒有重排序的話,打印的確實會是42,但若是number = 42和ready = true被進行了重排序,顛倒了順序,那麼就有可能打印出0了,而不是42。(由於number的初始值會是0)。在沒有同步的狀況下,編譯器、處理器在運行時均可能對操做的執行順序進行一些意向不到的調整,咱們也沒法肯定代碼的實際執行順序。
若是一個變量被聲明成volatile的話,那麼這個變量不會被重排序,也就是說,虛擬機會保證這個變量以前的代碼必定比它先執行,而以後的代碼必定比它後執行。例如把上面中的number聲明爲volatile,那麼number = 42必定會比ready = true先執行。不過須要注意的是,虛擬機只是保證了這個變量的執行順序,而它以前或以後的代碼執行順序仍是有可能會進行重排序的。
3、volatile原理
Java語言提供了一種稍弱的同步機制,即volatile變量,用來確保將變量的更新操做通知到其餘線程。當把變量聲明爲volatile類型後,編譯器與運行時都會注意到這個變量是共享的,所以不會將該變量上的操做與其餘內存操做一塊兒重排序。volatile變量不會被緩存在寄存器或者對其餘處理器不可見的地方,所以在讀取volatile類型的變量時總會返回最新寫入的值。
在訪問volatile變量時不會執行加鎖操做,所以也就不會使執行線程阻塞,所以volatile變量是一種比sychronized關鍵字更輕量級的同步機制。
當一個變量定義爲 volatile 以後,將具有兩種特性:
1.保證此變量對全部的線程的可見性,這裏的「可見性」,如以前所述,當一個線程修改了這個變量的值,volatile 保證了新值能當即同步到主內存,以及每次使用前當即從主內存刷新。但普通變量作不到這點,普通變量的值在線程間傳遞均須要經過主內存(詳見:Java內存模型)來完成。
2.禁止指令重排序優化。有volatile修飾的變量,賦值後多執行了一個「load addl $0x0, (%esp)」操做,這個操做至關於一個內存屏障(指令重排序時不能把後面的指令重排序到內存屏障以前的位置),只有一個CPU訪問內存時,並不須要內存屏障;(什麼是指令重排序:是指CPU採用了容許將多條指令不按程序規定的順序分開發送給各相應電路單元處理)。
4、volatile 性能:
volatile 的讀性能消耗與普通變量幾乎相同,可是寫操做稍慢,由於它須要在本地代碼中插入許多內存屏障指令來保證處理器不發生亂序執行。
5、volatile並不能代替synchronized
前面提到,volatile和synchronized均可以用來保證線程安全,可是volatile能夠替代synchronized嗎?答案是不行的,volatile只能保證變量的可見性,並不能保證原子性,所以在高併發的狀況對元素進行操做可能會形成混亂。下面來看兩個例子:
/**
* @author 一池春水傾半城
* @date 2019/9/24
*/
public class Volatile {
volatile boolean flag;
void check() {
if (flag == !flag) {
System.out.println("WTF?");
}
}
void swap() {
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
flag = !flag;
}
public static void main(String[] args) {
Volatile thread = new Volatile();
Thread t2 = new Thread() {
@Override
public void run() {
while (true) {
thread.check();
}
}
};
t2.start();
Thread t1 = new Thread() {
@Override
public void run() {
while (true) {
thread.swap();
}
}
};
t1.start();
}
}
打印結果:WTF?
WTF?
WTF?
WTF?
...
...
上面的代碼啓動了兩個線程,一個用來改變flag的值,另外一個用來判斷flag == !flag,按照常理來講flag是不會等於!flag的,可是根據上面打印的結果來看卻出現了這種狀況,這是爲何呢?
這是因爲volatile的機制形成的,
volatile只保證了可見性而沒有保證原子性,也就是說,共享數據會因高併發被同一個數據覆蓋。通俗點講,
多個線程同時改變主內存中的某個值的時候,一個線程改變了這個值,並通知給其餘線程及時更新本身線程內緩衝區的副本,可是因爲線程改變volatile修飾的變量後須要寫入到公共內存中+其餘線程再讀取,這個過程必然會慢於其餘線程寫出的速度,致使其餘線程還沒來得及更新本身副本變量就執行了寫出,致使主內存中的數據被覆蓋。所以在高併發的狀況下不對某個數據的寫入加鎖,即使設置了volatile可見性,依然會出現問題。
而當刪除了flag上的volatile修飾以後,程序則不會輸出任何信息,這個時候是保證了原子性嗎?並無。
t1線程和t2線程在開始運行時都從主存中讀取了flag到各自的工做區中(Cpu緩存),運行過程當中使用的是各自工做區中的flag,不會再去從主存中讀取。所以,t1線程修改的flag只在t1線程的工做空間中生效,而t2並不會讀取到flag的改變,因此就不會出現上面加上volatile修飾的狀況。