基本知識:
可見度:
可見度是一種複雜的屬性,因為可見度中的錯誤總是會違背我們的直覺。通常,我們無法確保執行讀操作的線程能適時地看到其他線程寫入的值,有時甚至是根本不可能的事情。為了確保多個線程之間對記憶體寫入操作的可見度,必須使用同步機制。
可見度,是指線程之間的可見度,一個線程修改的狀態對另一個線程是可見的。也就是一個線程修改的結果。另一個線程馬上就能看到。比如:用volatile修飾的變數,就會具有可見度。volatile修飾的變數不允許線程內部緩衝和重排序,即直接修改記憶體。所以對其他線程是可見的。但是這裡需要注意一個問題,volatile只能讓被他修飾內容具有可見度,但不能保證它具有原子性。比如 volatile int a = 0;之後有一個操作 a++;這個變數a具有可見度,但是a++ 依然是一個非原子操作,也就是這個操作同樣存線上程安全問題。
在 Java 中 volatile、synchronized 和 final 實現可見度。
原子性:
原子是世界上的最小單位,具有不可分割性。比如 a=0;(a非long和double類型) 這個操作是不可分割的,那麼我們說這個操作時原子操作。再比如:a++; 這個操作實際是a = a + 1;是可分割的,所以他不是一個原子操作。非原子操作都會存線上程安全問題,需要我們使用同步技術(sychronized)來讓它變成一個原子操作。一個操作是原子操作,那麼我們稱它具有原子性。java的concurrent包下提供了一些原子類,我們可以通過閱讀API來瞭解這些原子類的用法。比如:AtomicInteger、AtomicLong、AtomicReference等。
在 Java 中 synchronized 和在 lock、unlock 中操作保證原子性。
有序性:
Java 語言提供了 volatile 和 synchronized 兩個關鍵字來保證線程之間操作的有序性,volatile 是因為其本身包含“禁止指令重排序”的語義,synchronized 是由“一個變數在同一個時刻只允許一條線程對其進行 lock 操作”這條規則獲得的,此規則決定了持有同一個對象鎖的兩個同步塊只能串列執行。
下面內容摘錄自《Java Concurrency in Practice》:
下面一段代碼在多線程環境下,將存在問題。 + View code
1 /** 2 * @author zhengbinMac 3 */ 4 public class NoVisibility { 5 private static boolean ready; 6 private static int number; 7 private static class ReaderThread extends Thread { 8 @Override 9 public void run() {10 while(!ready) {11 Thread.yield();12 }13 System.out.println(number);14 }15 }16 public static void main(String[] args) {17 new ReaderThread().start();18 number = 42;19 ready = true;20 }21 }
NoVisibility可能會持續迴圈下去,因為讀線程可能永遠都看不到ready的值。甚至NoVisibility可能會輸出0,因為讀線程可能看到了寫入ready的值,但卻沒有看到之後寫入number的值,這種現象被稱為“重排序”。只要在某個線程中無法檢測到重排序情況(即使在其他線程中可以明顯地看到該線程中的重排序),那麼就無法確保線程中的操作將按照程式中指定的順序來執行。當主線程首先寫入number,然後在沒有同步的情況下寫入ready,那麼讀線程看到的順序可能與寫入的順序完全相反。
在沒有同步的情況下,編譯器、處理器以及運行時等都可能對操作的執行順序進行一些意想不到的調整。在缺乏足夠同步的多線程程式中,要想對記憶體操作的執行春旭進行判斷,無法得到正確的結論。
這個看上去像是一個失敗的設計,但卻能使JVM充分地利用現代多核處理器的強大效能。例如,在缺少同步的情況下,Java記憶體模型允許編譯器對操作順序進行重排序,並將數值緩衝在寄存器中。此外,它還允許CPU對操作順序進行重排序,並將數值緩衝在處理器特定的緩衝中。
Volatile原理
Java語言提供了一種稍弱的同步機制,即volatile變數,用來確保將變數的更新操作通知到其他線程。當把變數聲明為volatile類型後,編譯器與運行時都會注意到這個變數是共用的,因此不會將該變數上的操作與其他記憶體操作一起重排序。volatile變數不會被緩衝在寄存器或者對其他處理器不可見的地方,因此在讀取volatile類型的變數時總會返回最新寫入的值。
在訪問volatile變數時不會執行加鎖操作,因此也就不會使執行線程阻塞,因此volatile變數是一種比sychronized關鍵字更輕量級的同步機制。
當對非 volatile 變數進行讀寫的時候,每個線程先從記憶體拷貝變數到CPU緩衝中。如果電腦有多個CPU,每個線程可能在不同的CPU上被處理,這意味著每個線程可以拷貝到不同的 CPU cache 中。
而聲明變數是 volatile 的,JVM 保證了每次讀變數都從記憶體中讀,跳過 CPU cache 這一步。 當一個變數定義為 volatile 之後,將具備兩種特性:
1.保證此變數對所有的線程的可見度,這裡的“可見度”,如本文開頭所述,當一個線程修改了這個變數的值,volatile 保證了新值能立即同步到主記憶體,以及每次使用前立即從主記憶體重新整理。但普通變數做不到這點,普通變數的值線上程間傳遞均需要通過主記憶體(詳見:Java記憶體模型)來完成。
2.禁止指令重排序最佳化。有volatile修飾的變數,賦值後多執行了一個“load addl $0x0, (%esp)”操作,這個操作相當於一個記憶體屏障(指令重排序時不能把後面的指令重排序到記憶體屏障之前的位置),只有一個CPU訪問記憶體時,並不需要記憶體屏障;(什麼是指令重排序:是指CPU採用了允許將多條指令不按程式規定的順序分開發送給各相應電路單元處理)。 volatile 效能:
volatile 的讀效能消耗與普通變數幾乎相同,但是寫操作稍慢,因為它需要在本地代碼中插入許多記憶體屏障指令來保證處理器不發生亂序執行。
synchronized:
synchronized是針對修飾代碼塊,對象方法,或者類對象(類對象就是修飾靜態方法或者對象)
java的內建鎖:每個java對象都可以用做一個實現同步的鎖,這些鎖成為內建鎖。線程進入同步代碼塊或方法的時候會自動獲得該鎖,在退出同步代碼塊或方法時會釋放該鎖。獲得內建鎖的唯一途徑就是進入這個鎖的保護的同步代碼塊或方法。
java內建鎖是一個互斥鎖,這就是意味著最多隻有一個線程能夠獲得該鎖,當線程A嘗試去獲得線程B持有的內建鎖時,線程A必須等待或者阻塞,直到線程B釋放這個鎖,如果B線程不釋放這個鎖,那麼A線程將永遠等待下去。
java的對象鎖和類鎖:java的對象鎖和類鎖在鎖的概念上基本上和內建鎖是一致的,但是,兩個鎖實際是有很大的區別的,對象鎖是用於對象執行個體方法,或者一個對象執行個體上的,類鎖是用於類的靜態方法或者一個類的class對象上的。我們知道,類的對象執行個體可以有很多個,但是每個類只有一個class對象,所以不同對象執行個體的對象鎖是互不干擾的,但是每個類只有一個類鎖。但是有一點必須注意的是,其實類鎖只是一個概念上的東西,並不是真實存在的,它只是用來協助我們理解鎖定執行個體方法和靜態方法的區別的。
這裡涉及到內建鎖的一個概念(此概念出自java並發編程實戰第二章):對象的內建鎖和對象的狀態之間是沒有內在的關聯的,雖然大多數類都將內建鎖用做一種有效加鎖機制,但對象的域並不一定通過內建鎖來保護。當擷取到與對象關聯的內建鎖時,並不能阻止其他線程訪問該對象,當某個線程獲得對象的鎖之後,只能阻止其他線程獲得同一個鎖。之所以每個對象都有一個內建鎖,是為了免去顯式地建立鎖對象。
類鎖和對象鎖是兩個不一樣的鎖,控制著不同的地區,它們是互不干擾的。同樣,線程獲得對象鎖的同時,也可以獲得該類鎖,即同時獲得兩個鎖,這是允許的。
到這裡,對synchronized的用法已經有了一定的瞭解。這時有一個疑問,既然有了synchronized修飾方法的同步方式,為什麼還需要synchronized修飾同步代碼塊的方式呢。而這個問題也是synchronized的缺陷所在。
synchronized的缺陷:當某個線程進入同步方法獲得對象鎖,那麼其他線程訪問這裡對象的同步方法時,必須等待或者阻塞,這對高並發的系統是致命的,這很容易導致系統的崩潰。如果某個線程在同步方法裡面發生了死迴圈,那麼它就永遠不會釋放這個對象鎖,那麼其他線程就要永遠的等待。這是一個致命的問題。
當然同步方法和同步代碼塊都會有這樣的缺陷,只要用了synchronized關鍵字就會有這樣的風險和缺陷。既然避免不了這種缺陷,那麼就應該將風險降到最低。這也是同步代碼塊在某種情況下要優於同步方法的方面。例如在某個類的方法裡面:這個類裡面聲明了一個對象執行個體,SynObject so=new SynObject();在某個方法裡面調用了這個執行個體的方法so.testsy();但是調用這個方法需要進行同步,不能同時有多個線程同時執行調用這個方法。這時如果直接用synchronized修飾調用了so.testsy();代碼的方法,那麼當某個線程進入了這個方法之後,這個對象其他同步方法都不能給其他線程訪問了。假如這個方法需要執行的時間很長,那麼其他線程會一直阻塞,影響到系統的效能。如果這時用synchronized來修飾代碼塊:synchronized(so){so.testsy();},那麼這個方法加鎖的對象是so這個對象,跟執行這行代碼的對象沒有關係,當一個線程執行這個方法時,這對其他同步方法時沒有影響的,因為他們持有的鎖都完全不一樣。
public class TestSynchronized { public void testone() { synchronized(this) { int i = 5; while( i-- > 0) { System.out.println(Thread.currentThread().getName() + " : " + i); try { Thread.sleep(500); } catch (InterruptedException ie) { } } } } public synchronized void testtwo() { int i = 5; while( i-- > 0) { System.out.println(Thread.currentThread().getName() + " : " + i); try { Thread.sleep(500); } catch (InterruptedException ie) { } } } public static void main(String[] args) { final TestSynchronized myt2 = new TestSynchronized(); Thread thread1= new Thread( new Runnable() { public void run() { myt2.testone(); } }, "testone" ); Thread thread2= new Thread( new Runnable() { public void run() { myt2.testtwo(); } }, "testtwo" ); thread1.start();; thread2.start(); } }
不過這裡還有一種特例,就是上面示範的例子,對象鎖synchronized同時修飾方法和代碼塊,這時也可以體現到同步代碼塊的優越性,如果testone方法同步代碼塊後面有非常多沒有同步的代碼,而且有一個100000的迴圈,這導致testone方法會執行時間非常長,那麼如果直接用synchronized修飾方法,那麼在方法沒執行完之前,其他線程是不可以訪問testtwo方法的,但是如果用了同步代碼塊,那麼當結束代碼塊時就已經釋放了對象鎖,當線程還在執行test1的那個100000的迴圈時,其他線程就已經可以訪問testtwo方法了。
這就讓阻塞的機會或者線程更少。讓系統的效能更優越。
一個類的對象鎖和另一個類的對象鎖是沒有關聯的,當一個線程獲得A類的對象鎖時,它同時也可以獲得B類的對象鎖。