在多線程編程中,線程安全問題是一個最爲關鍵的問題,其核心概念就在於正確性,即當多個線程訪問某一共享、可變數據時,始終都不會導致數據破壞以及其他不該出現的結果。而所有的併發模式在解決這個問題時,採用的方案都是序列化訪問臨界資源 。在 Java 中,提供了兩種方式來實現同步互斥訪問:synchronized 和 Lock。本文針對 synchronized 內置鎖 詳細討論了其在 Java 併發 中的應用,包括它的具體使用場景(同步方法、同步代碼塊、實例對象鎖 和 Class 對象鎖)、可重入性 和 注意事項。
一. 線程安全問題
在單線程中不會出現線程安全問題,而在多線程編程中,有可能會出現同時訪問同一個 共享、可變資源 的情況,這種資源可以是:一個變量、一個對象、一個文件等。特別注意兩點,
- 共享: 意味着該資源可以由多個線程同時訪問;
- 可變: 意味着該資源可以在其生命週期內被修改。
所以,當多個線程同時訪問這種資源的時候,就會存在一個問題:
由於每個線程執行的過程是不可控的,所以需要採用同步機制來協同對對象可變狀態的訪問。
舉個 數據髒讀 的例子:
//資源類 class PublicVar { public String username = "A"; public String password = "AA"; //同步實例方法 public synchronized void setValue(String username, String password) { try { this.username = username; Thread.sleep(5000); this.password = password; System.out.println("method=setValue " +"\t" + "threadName=" + Thread.currentThread().getName() + "\t" + "username=" + username + ", password=" + password); } catch (InterruptedException e) { e.printStackTrace(); } } //非同步實例方法 public void getValue() { System.out.println("method=getValue " + "\t" + "threadName=" + Thread.currentThread().getName()+ "\t" + " username=" + username + ", password=" + password); } } //線程類 class ThreadA extends Thread { private PublicVar publicVar; public ThreadA(PublicVar publicVar) { super(); this.publicVar = publicVar; } @Override public void run() { super.run(); publicVar.setValue("B", "BB"); } } //測試類 public class Test { public static void main(String[] args) { try { //臨界資源 PublicVar publicVarRef = new PublicVar(); //創建並啓動線程 ThreadA thread = new ThreadA(publicVarRef); thread.start(); Thread.sleep(200);// 打印結果受此值大小影響 //在主線程中調用 publicVarRef.getValue(); } catch (InterruptedException e) { e.printStackTrace(); } } }/* Output ( 數據交叉 ): method=getValue threadName=main username=B, password=AA method=setValue threadName=Thread-0 username=B, password=BB */
由程序輸出可知,雖然在寫操作進行了同步,但在讀操作上仍然有可能出現一些意想不到的情況,例如上面所示的 髒讀。發生 髒讀 的情況是在執行讀操作時,相應的數據已被其他線程 部分修改 過,導致 數據交叉 的現象產生。
這其實就是一個線程安全問題,即多個線程同時訪問一個資源時,會導致程序運行結果並不是想看到的結果。這裏面,這個資源被稱爲:臨界資源。也就是說,當多個線程同時訪問臨界資源(一個對象,對象中的屬性,一個文件,一個數據庫等)時,就可能會產生線程安全問題。
不過,當多個線程執行一個方法時,該方法內部的局部變量並不是臨界資源,因爲這些局部變量是在每個線程的私有棧中,因此不具有共享性,不會導致線程安全問題。
二. 如何解決線程安全問題
實際上,所有的併發模式在解決線程安全問題時,採用的方案都是 序列化訪問臨界資源 。即在同一時刻,只能有一個線程訪問臨界資源,也稱作 同步互斥訪問。換句話說,就是在訪問臨界資源的代碼前面加上一個鎖,當訪問完臨界資源後釋放鎖,讓其他線程繼續訪問。
在 Java 中,提供了兩種方式來實現同步互斥訪問:synchronized 和 Lock。本文主要講述 synchronized 的使用方法.
三. synchronized 同步方法或者同步塊
在瞭解 synchronized 關鍵字的使用方法之前,我們先來看一個概念:互斥鎖,即 能到達到互斥訪問目的的鎖。舉個簡單的例子,如果對臨界資源加上互斥鎖,當一個線程在訪問該臨界資源時,其他線程便只能等待。
在 Java 中,可以使用 synchronized 關鍵字來標記一個方法或者代碼塊,當某個線程調用該對象的synchronized方法或者訪問synchronized代碼塊時,這個線程便獲得了該對象的鎖,其他線程暫時無法訪問這個方法,只有等待這個方法執行完畢或者代碼塊執行完畢,這個線程纔會釋放該對象的鎖,其他線程才能執行這個方法或者代碼塊。
下面這段代碼中兩個線程分別調用insertData對象插入數據:
1) synchronized方法
public class Test { public static void main(String[] args) { final InsertData insertData = new InsertData(); // 啓動線程 1 new Thread() { public void run() { insertData.insert(Thread.currentThread()); }; }.start(); // 啓動線程 2 new Thread() { public void run() { insertData.insert(Thread.currentThread()); }; }.start(); } } class InsertData { // 共享、可變資源 private ArrayList<Integer> arrayList = new ArrayList<Integer>(); //對共享可變資源的訪問 public void insert(Thread thread){ for(int i=0;i<5;i++){ System.out.println(thread.getName()+"在插入數據"+i); arrayList.add(i); } } }/* Output: Thread-0在插入數據0 Thread-1在插入數據0 Thread-0在插入數據1 Thread-0在插入數據2 Thread-1在插入數據1 Thread-1在插入數據2 */
根據運行結果就可以看出,這兩個線程在同時執行insert()方法。而如果在insert()方法前面加上關鍵字synchronized 的話,運行結果爲:
class InsertData { private ArrayList<Integer> arrayList = new ArrayList<Integer>(); public synchronized void insert(Thread thread){ for(int i=0;i<5;i++){ System.out.println(thread.getName()+"在插入數據"+i); arrayList.add(i); } } }/* Output: Thread-0在插入數據0 Thread-0在插入數據1 Thread-0在插入數據2 Thread-1在插入數據0 Thread-1在插入數據1 Thread-1在插入數據2 */
從以上輸出結果可以看出,Thread-1 插入數據是等 Thread-0 插入完數據之後才進行的。說明 Thread-0 和 Thread-1 是順序執行 insert() 方法的。這就是 synchronized 關鍵字對方法的作用。
不過需要注意以下三點:
1)當一個線程正在訪問一個對象的 synchronized 方法,那麼其他線程不能訪問該對象的其他 synchronized 方法。這個原因很簡單,因爲一個對象只有一把鎖,當一個線程獲取了該對象的鎖之後,其他線程無法獲取該對象的鎖,所以無法訪問該對象的其他synchronized方法。
2)當一個線程正在訪問一個對象的 synchronized 方法,那麼其他線程能訪問該對象的非 synchronized 方法。這個原因很簡單,訪問非 synchronized 方法不需要獲得該對象的鎖,假如一個方法沒用 synchronized 關鍵字修飾,說明它不會使用到臨界資源,那麼其他線程是可以訪問這個方法的,
3)如果一個線程 A 需要訪問對象 object1 的 synchronized 方法 fun1,另外一個線程 B 需要訪問對象 object2 的 synchronized 方法 fun1,即使 object1 和 object2 是同一類型),也不會產生線程安全問題,因爲他們訪問的是不同的對象,所以不存在互斥問題。
2) synchronized 同步塊
synchronized 代碼塊類似於以下這種形式:
synchronized (lock){ //訪問共享可變資源 ... }
當在某個線程中執行這段代碼塊,該線程會獲取對象lock的鎖,從而使得其他線程無法同時訪問該代碼塊。其中,lock 可以是 this,代表獲取當前對象的鎖,也可以是類中的一個屬性,代表獲取該屬性的鎖。特別地, 實例同步方法 與 synchronized(this)同步塊 是互斥的,因爲它們鎖的是同一個對象。但與 synchronized(非this)同步塊 是異步的,因爲它們鎖的是不同對象。
比如上面的insert()方法可以改成以下兩種形式:
// this 監視器 class InsertData { private ArrayList<Integer> arrayList = new ArrayList<Integer>(); public void insert(Thread thread){ synchronized (this) { for(int i=0;i<100;i++){ System.out.println(thread.getName()+"在插入數據"+i); arrayList.add(i); } } } } // 對象監視器 class InsertData { private ArrayList<Integer> arrayList = new ArrayList<Integer>(); private Object object = new Object(); public void insert(Thread thread){ synchronized (object) { for(int i=0;i<100;i++){ System.out.println(thread.getName()+"在插入數據"+i); arrayList.add(i); } } } }
從上面代碼可以看出,synchronized代碼塊 比 synchronized方法 的粒度更細一些,使用起來也靈活得多。因爲也許一個方法中只有一部分代碼只需要同步,如果此時對整個方法用synchronized進行同步,會影響程序執行效率。而使用synchronized代碼塊就可以避免這個問題,synchronized代碼塊可以實現只對需要同步的地方進行同步。
3) class 對象鎖
特別地,每個類也會有一個鎖,靜態的 synchronized方法 就是以Class對象作爲鎖。另外,它可以用來控制對 static 數據成員 (static 數據成員不專屬於任何一個對象,是類成員) 的併發訪問。並且,如果一個線程執行一個對象的非static synchronized 方法,另外一個線程需要執行這個對象所屬類的 static synchronized 方法,也不會發生互斥現象。因爲訪問 static synchronized 方法佔用的是類鎖,而訪問非 static synchronized 方法佔用的是對象鎖,所以不存在互斥現象。例如,
public class Test { public static void main(String[] args) { final InsertData insertData = new InsertData(); new Thread(){ @Override public void run() { insertData.insert(); } }.start(); new Thread(){ @Override public void run() { insertData.insert1(); } }.start(); } } class InsertData { // 非 static synchronized 方法 public synchronized void insert(){ System.out.println("執行insert"); try { Thread.sleep(5000); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println("執行insert完畢"); } // static synchronized 方法 public synchronized static void insert1() { System.out.println("執行insert1"); System.out.println("執行insert1完畢"); } }/* Output: 執行insert 執行insert1 執行insert1完畢 執行insert完畢 */
根據執行結果,我們可以看到第一個線程裏面執行的是insert方法,不會導致第二個線程執行insert1方法發生阻塞現象。下面,我們看一下 synchronized 關鍵字到底做了什麼事情,我們來反編譯它的字節碼看一下,下面這段代碼反編譯後的字節碼爲:
public class InsertData { private Object object = new Object(); public void insert(Thread thread){ synchronized (object) {} } public synchronized void insert1(Thread thread){} public void insert2(Thread thread){} }
從反編譯獲得的字節碼可以看出,synchronized 代碼塊實際上多了 monitorenter 和 monitorexit 兩條指令。 monitorenter指令執行時會讓對象的鎖計數加1,而monitorexit指令執行時會讓對象的鎖計數減1,其實這個與操作系統裏面的PV操作很像,操作系統裏面的PV操作就是用來控制多個進程對臨界資源的訪問。對於synchronized方法,執行中的線程識別該方法的 method_info 結構是否有 ACC_SYNCHRONIZED 標記設置,然後它自動獲取對象的鎖,調用方法,最後釋放鎖。如果有異常發生,線程自動釋放鎖。
有一點要注意:對於 synchronized方法 或者 synchronized代碼塊,當出現異常時,JVM會自動釋放當前線程佔用的鎖,因此不會由於異常導致出現死鎖現象。
四. 可重入性
一般地,當某個線程請求一個由其他線程持有的鎖時,發出請求的線程就會阻塞。然而,由於 Java 的內置鎖是可重入的,因此如果某個線程試圖獲得一個已經由它自己持有的鎖時,那麼這個請求就會成功。可重入鎖最大的作用是避免死鎖。例如:
public class Test implements Runnable { // 可重入鎖測試 public synchronized void get() { System.out.println(Thread.currentThread().getName()); set(); } public synchronized void set() { System.out.println(Thread.currentThread().getName()); } @Override public void run() { get(); } public static void main(String[] args) { Test test = new Test(); new Thread(test,"Thread-0").start(); new Thread(test,"Thread-1").start(); new Thread(test,"Thread-2").start(); } }/* Output: Thread-1 Thread-1 Thread-2 Thread-2 Thread-0 Thread-0 */
五. 注意事項
1). 內置鎖與字符串常量
由於字符串常量池的原因,在大多數情況下,同步synchronized代碼塊 都不使用 String 作爲鎖對象,而改用其他,比如 new Object() 實例化一個 Object 對象,因爲它並不會被放入緩存中。看下面的例子:
//資源類 class Service { public void print(String stringParam) { try { synchronized (stringParam) { while (true) { System.out.println(Thread.currentThread().getName()); Thread.sleep(1000); } } } catch (InterruptedException e) { e.printStackTrace(); } } } //線程A class ThreadA extends Thread { private Service service; public ThreadA(Service service) { super(); this.service = service; } @Override public void run() { service.print("AA"); } } //線程B class ThreadB extends Thread { private Service service; public ThreadB(Service service) { super(); this.service = service; } @Override public void run() { service.print("AA"); } } //測試 public class Run { public static void main(String[] args) { //臨界資源 Service service = new Service(); //創建並啓動線程A ThreadA a = new ThreadA(service); a.setName("A"); a.start(); //創建並啓動線程B ThreadB b = new ThreadB(service); b.setName("B"); b.start(); } }/* Output (死鎖): A A A A ... */
出現上述結果就是因爲 String 類型的參數都是 “AA”,兩個線程持有相同的鎖,所以 線程B 始終得不到執行,造成死鎖。進一步地,所謂死鎖是指: 不同的線程都在等待根本不可能被釋放的鎖,從而導致所有的任務都無法繼續完成。
b). 鎖的是對象而非引用
在將任何數據類型作爲同步鎖時,需要注意的是,是否有多個線程將同時去競爭該鎖對象:
1).若它們將同時競爭同一把鎖,則這些線程之間就是同步的;
2).否則,這些線程之間就是異步的。
看下面的例子:
//資源類 class MyService { private String lock = "123"; public void testMethod() { try { synchronized (lock) { System.out.println(Thread.currentThread().getName() + " begin " + System.currentTimeMillis()); lock = "456"; Thread.sleep(2000); System.out.println(Thread.currentThread().getName() + " end " + System.currentTimeMillis()); } } catch (InterruptedException e) { e.printStackTrace(); } } } //線程B class ThreadB extends Thread { private MyService service; public ThreadB(MyService service) { super(); this.service = service; } @Override public void run() { service.testMethod(); } } //線程A class ThreadA extends Thread { private MyService service; public ThreadA(MyService service) { super(); this.service = service; } @Override public void run() { service.testMethod(); } } //測試 public class Run1 { public static void main(String[] args) throws InterruptedException { //臨界資源 MyService service = new MyService(); //線程A ThreadA a = new ThreadA(service); a.setName("A"); //線程B ThreadB b = new ThreadB(service); b.setName("B"); a.start(); Thread.sleep(50);// 存在50毫秒 b.start(); } }/* Output(循環): A begin 1484319778766 B begin 1484319778815 A end 1484319780766 B end 1484319780815 */
由上述結果可知,線程 A、B 是異步的。因爲50毫秒過後, 線程B 取得的鎖對象是 “456”,而 線程A 依然持有的鎖對象是 “123”。所以,這兩個線程是異步的。若將上述語句 “Thread.sleep(50);” 註釋,則有:
//測試 public class Run1 { public static void main(String[] args) throws InterruptedException { //臨界資源 MyService service = new MyService(); //線程A ThreadA a = new ThreadA(service); a.setName("A"); //線程B ThreadB b = new ThreadB(service); b.setName("B"); a.start(); // Thread.sleep(50);// 存在50毫秒 b.start(); } }/* Output(循環): B begin 1484319952017 B end 1484319954018 A begin 1484319954018 A end 1484319956019 */
由上述結果可知,線程 A、B 是同步的。因爲線程 A、B 競爭的是同一個鎖“123”,雖然先獲得運行的線程將 lock 指向了 對象“456”,但結果還是同步的。因爲線程 A 和 B 共同爭搶的鎖對象是“123”,也就是說,鎖的是對象而非引用。
小結:
用一句話來說,synchronized 內置鎖 是一種 對象鎖 (鎖的是對象而非引用), 作用粒度是對象 ,可以用來實現對 臨界資源的同步互斥訪問 ,是 可重入 的。特別地,對於 臨界資源 有:
- 若該資源是靜態的,即被 static 關鍵字修飾,那麼訪問它的方法必須是同步且是靜態的,synchronized 塊必須是 class鎖;
- 若該資源是非靜態的,即沒有被 static 關鍵字修飾,那麼訪問它的方法必須是同步的,synchronized 塊是實例對象鎖;
實質上,關鍵字synchronized 主要包含兩個特徵:
- 互斥性:保證在同一時刻,只有一個線程可以執行某一個方法或某一個代碼塊;
- 可見性:保證線程工作內存中的變量與公共內存中的變量同步,使多線程讀取共享變量時可以獲得最新值的使用。