淺談對java中鎖的理解,淺談java理解
在並發編程中,經常遇到多個線程訪問同一個 共用資源 ,這時候作為開發人員必須考慮如何維護資料一致性,在java中synchronized關鍵字被常用於維護資料一致性。synchronized機制是給共用資源上鎖,只有拿到鎖的線程才可以訪問共用資源,這樣就可以強制使得對共用資源的訪問都是順序的,因為對於共用資源屬性訪問是必要也是必須的,下文會有具體樣本示範。
一.java中的鎖
一般在java中所說的鎖就是指的內建鎖,每個java對象都可以作為一個實現同步的鎖,雖然說在java中一切皆對象, 但是鎖必須是參考型別的,基礎資料型別 (Elementary Data Type)則不可以 。每一個參考型別的對象都可以隱式的扮演一個用於同步的鎖的角色,執行線程進入synchronized塊之前會自動獲得鎖,無論是通過正常語句退出還是執行過程中拋出了異常,線程都會在放棄對synchronized塊的控制時自動釋放鎖。 獲得鎖的唯一途徑就是進入這個內部鎖保護的同步塊或方法 。
正如引言中所說,對共用資源的訪問必須是順序的,也就是說當多個線程對共用資源訪問的時候,只能有一個線程可以獲得該共用資源的鎖,當線程A嘗試擷取線程B的鎖時,線程A必須等待或者阻塞,直到線程B釋放該鎖為止,否則線程A將一直等待下去,因此java內建鎖也稱作互斥鎖,也即是說鎖實際上是一種互斥機制。
根據使用方式的不同一般我們會將鎖分為對象鎖和類鎖,兩個鎖是有很大差別的,對象鎖是作用在執行個體方法或者一個對象執行個體上面的,而類鎖是作用在靜態方法或者Class對象上面的。一個類可以有多個執行個體對象,因此一個類的對象鎖可能會有多個,但是每個類只有一個Class對象,所以類鎖只有一個。 類鎖只是一個概念上的東西,並不是真實存在的,它只是用來協助我們理解鎖定的是執行個體方法還是靜態方法區別的 。
在java中實現鎖機制不僅僅限於使用synchronized關鍵字,還有JDK1.5之後提供的Lock,Lock不在本文討論範圍之內。一個synchronized塊包含兩個部分:鎖對象的引用,以及這個鎖保護的代碼塊。如果作用在執行個體方法上面,鎖就是該方法所在的當前對象,靜態synchronized方法會從Class對象上獲得鎖。
二.synchronized使用樣本
1.多視窗售票
假設一個火車票售票系統,有若干個視窗同時售票,很顯然在這裡票是作為多個視窗的共用資源存在的,由於座位號是確定的,因此票上面的號碼也是確定的,我們用多個線程來類比多個視窗同時售票,首先在不使用synchronized關鍵字的情況下測試一下售票情況。
先將票本身作為一個共用資源放在單獨的線程中,這種作為共用資源存在的線程很顯然應該是實現Runnable介面,我們將票的總數num作為一個入參傳入,每次產生一個票之後將num做減法運算,直至num為0即停止,說明票已經售完了,然後開啟多個線程將票資源傳入。
public class Ticket implements Runnable{ private int num;//票數量 private boolean flag=true;//若為false則售票停止 public Ticket(int num){ this.num=num; } @Override public void run() { while(flag){ ticket(); } } private void ticket(){ if(num<=0){ flag=false; return; } try { Thread.sleep(20);//類比延時操作 } catch (InterruptedException e) { e.printStackTrace(); } //輸出當前視窗號以及出票序號 System.out.println(Thread.currentThread().getName()+"售出票序號:"+num--); } } public class MainTest { public static void main(String[] args) { Ticketticket = new Ticket(5); Threadwindow01 = new Thread(ticket, "視窗01"); Threadwindow02 = new Thread(ticket, "視窗02"); Threadwindow03 = new Thread(ticket, "視窗03"); window01.start(); window02.start(); window03.start(); } }
程式的輸出結果如下:
視窗02售出票序號:5
視窗03售出票序號:4
視窗01售出票序號:5
視窗02售出票序號:3
視窗01售出票序號:2
視窗03售出票序號:2
視窗02售出票序號:1
視窗03售出票序號:0
視窗01售出票序號:-1
從上面程式運行結果可以看出不但票的序號有重號而且出票數量也不對,這種售票系統比12306可要爛多了,人家在繁忙的時候只是刷不到票而已,而這裡的售票系統倒好了,出票比預計的多了而且會出現多個人爭搶做同一個座位的風險。如果是單個售票視窗是不會出現這種問題,多視窗同時售票就會出現爭搶共用資源因此紊亂的現象,解決該現象也很簡單,就是在ticket()方法前面加上synchronized關鍵字或者將ticket()方法的方法體完全用synchronized塊包括起來。
//方式一 private synchronized void ticket(){ if(num<=0){ flag=false; return; } try { Thread.sleep(20);//類比延時操作 } catch (InterruptedException e) { e.printStackTrace(); } System.out.println(Thread.currentThread().getName()+"售出票序號:"+num--); } //方式二 private void ticket(){ synchronized (this) { if (num <= 0) { flag = false; return; } try { Thread.sleep(20);//類比延時操作 } catch (InterruptedException e) { e.printStackTrace(); } System.out.println(Thread.currentThread().getName() + "售出票序號:" + num--); } }
再看一下加入synchronized關鍵字的程式運行結果:
視窗01售出票序號:5
視窗03售出票序號:4
視窗03售出票序號:3
視窗02售出票序號:2
視窗02售出票序號:1
從這裡可以看出在執行個體方法上面加上synchronized關鍵字的實現效果跟對整個方法體加上synchronized效果是一樣的。 另外一點需要注意加鎖的時機也非常重要 ,本樣本中ticket()方法中有兩處操作容易出現紊亂,一個是在if語句模組,一處是在num–,這兩處操作本身都不是原子類型的操作,但是在使用啟動並執行時候需要這兩處當成一個整體操作,所以synchronized將整個方法體都包裹在了一起。如若不然,假設num當前值是1,但是視窗01執行到了num–,整個操作還沒執行完成,只進行了賦值運算還沒進行自減運算,但是視窗02已經進入到了if語句模組,此時num還是等於1,等到視窗02執行到了輸出語句的時候,視窗01的num–也已經將自減運算執行完成,這時候視窗02就會輸出序號0的票。再者如果將synchronized關鍵字加在了run方法上面,這時候的操作不會出現紊亂或者錯誤,但是這種加鎖方式無異於單視窗操作,當視窗01拿到鎖進入run()方法之後,必須等到flag為false才會將語句執行完成跳出迴圈,這時候的num就已經為0了,也就是說票已經被售賣完了,這種方式摒棄了多線程操作,違背了最初的設計原則-多視窗售票。
2.懶漢式單例模式
建立單例模式有很多中實現方式,本文只討論懶漢式建立。在Android開發過程中單例模式可以說是最常使用的一種設計模式,因為它操作簡單還可以有效減少記憶體溢出。下面是懶漢式建立單例模式一個樣本:
public class Singleton { private static Singletoninstance; private Singleton() { } public static SingletongetInstance() { if (instance == null) { instance = new Singleton(); } return instance; } }
如果對於多視窗售票邏輯已經完全明白了的話就可以看出這裡的實現方式是有問題的,我們可以簡單的建立幾個線程來擷取單例輸出對象的hascode值。
com.sunny.singleton.Singleton@15c330aa
com.sunny.singleton.Singleton@15c330aa
com.sunny.singleton.Singleton@41aff40f
在多線程模式下發現會出現不同的對象,這種單例模式很顯然不是我們想要的,那麼根據上面多視窗售票的邏輯我們在getInstance()方法上面加上一個synchronized關鍵字,給該方法加上鎖,加上鎖之後可以避免多線程模式下產生多個不同對象,但是同樣會帶來一個效率問題,因為不管哪個線性進入getInstance()方法都會先獲得鎖,然後再次釋放鎖,這是一個方面,另一個方面就是只有在第一次調用getInstance()方法的時候,也就是在if語句塊內才會出現多線程並發問題,而我們卻索性將整個方法都上鎖了。討論到這裡就引出了另外一個問題,究竟是synchronized方法好還是synchronized代碼塊好呢? 有一個原則就是鎖的範圍越小越好 ,加鎖的目的就是將鎖進去的代碼作為原子性操作,因為非原子操作都不是安全執行緒的,因此synchronized代碼塊應該是在開發過程中優先考慮使用的加鎖方式。
public static SingletongetInstance() { if (instance == null) { synchronized (Singleton.class) { instance = new Singleton(); } } return instance; }
這裡也會遇到類似上面的問題,多線程並發下回產生多個執行個體,如線程A和線程B都進入if語句塊,假設線程A先獲得鎖,線程B則等待,當new一個執行個體後,線程A釋放鎖,線程B獲得鎖後會再次執行new語句,同樣不能保證單例要求,那麼下面代碼再來一個null判斷,進行雙重檢查上鎖呢?
public static SingletongetInstance() { if (instance == null) { synchronized (Singleton.class) { if(instance==null){ instance = new Singleton(); } } } return instance; }
該模式就是雙重檢查上鎖實現的單例模式,這裡在代碼層面我們已經 基本 保證了安全執行緒了,但是還是有問題的, 雙重檢查鎖定的問題是:並不能保證它會在單一處理器或多處理器電腦上順利運行。雙重檢查鎖定失敗的問題並不歸咎於 JVM 中的實現bug,而是歸咎於java平台記憶體模型。記憶體模型允許所謂的“無序寫入”,這也是這些習語失敗的一個主要原因。 更為詳細的介紹可以參考 Java單例模式中雙重檢查鎖的問題 。所以單例模式建立比較建議使用惡漢式建立或者靜態內部類方式建立。
3.synchronized不具有繼承性
我們可以通過一個簡單的demo驗證這個問題,在一個方法中順序的輸出一系列數字,並且輸出該數字所在的線程名稱,在父類中加上synchronized關鍵字,子類重寫父類方法測試一下加上synchronized關鍵字和不加關鍵字的區別即可。
public class Parent { public synchronized void test() { for (int i = 0; i < 5; i++) { System.out.println("Parent " + Thread.currentThread().getName() + ":" + i); try { Thread.sleep(500); } catch (InterruptedException e) { e.printStackTrace(); } } } }
子類繼承父類Parent,重寫test()方法.
public class Child extends Parent { @Override public void test() { for (int i = 0; i < 5; i++) { System.out.println("Child " + Thread.currentThread().getName() + ":" + i); try { Thread.sleep(500); } catch (InterruptedException e) { e.printStackTrace(); } } } }
測試代碼如下:
final Child c = new Child(); new Thread() { public void run() { c.test(); }; }.start(); new Thread() { public void run() { c.test(); }; }.start();
輸出結果如下:
Parent Thread-0:0 Child Thread-0:0
Parent Thread-0:1 Child Thread-1:0
Parent Thread-0:2 Child Thread-0:1
Parent Thread-0:3 Child Thread-1:1
Parent Thread-0:4 Child Thread-0:2
Parent Thread-1:0 Child Thread-1:2
Parent Thread-1:1 Child Thread-0:3
Parent Thread-1:2 Child Thread-1:3
Parent Thread-1:3 Child Thread-0:4
Parent Thread-1:4 Child Thread-1:4
通過輸出資訊可以知道,父類Parent中會將單個線程中序號輸出完成才會執行另一個線程中代碼,但是子類Child中確是兩個線程交替輸出數字,所以synchronized不具有繼承性。
4.死結樣本
死結是多線程開發中比較常見的一個問題。若有多個線程訪問多個資源時,相互之間存在競爭,就容易出現死結。下面就是一個死結的樣本,當一個線程等待另一個線程持有的鎖時,而另一個線程也在等待該線程鎖持有的鎖,這時候兩個線程都會處於阻塞狀態,程式便出現死結。
public class Thread01 extends Thread{ private Object resource01; private Object resource02; public Thread01(Object resource01, Object resource02) { this.resource01 = resource01; this.resource02 = resource02; } @Override public void run() { synchronized(resource01){ System.out.println("Thread01 locked resource01"); try { Thread.sleep(500); } catch (InterruptedException e) { e.printStackTrace(); } synchronized (resource02) { System.out.println("Thread01 locked resource02"); } } } } public class Thread02 extends Thread{ private Object resource01; private Object resource02; public Thread02(Object resource01, Object resource02) { this.resource01 = resource01; this.resource02 = resource02; } @Override public void run() { synchronized(resource02){ System.out.println("Thread02 locked resource02"); try { Thread.sleep(500); } catch (InterruptedException e) { e.printStackTrace(); } synchronized (resource01) { System.out.println("Thread02 locked resource01"); } } } } public class MainTest { public static void main(String[] args) { final Object resource01="resource01"; final Object resource02="resource02"; Thread01thread01=new Thread01(resource01, resource02); Thread02thread02=new Thread02(resource01, resource02); thread01.start(); thread02.start(); } }
執行上面的程式就會一直等待下去,出現死結。當線程Thread01獲得resource01的鎖後,等待500ms,然後嘗試擷取resource02的鎖,但是此時resouce02鎖已經被Thread02持有,同樣Thread02也等待了500ms嘗試擷取resouce01鎖,但是該所已經被Thread01持有,這樣兩個線程都在等待對方所有的資源,造成了死結。
三.其它
關鍵字synchronized具有鎖重入功能,當一個線程已經持有一個對象鎖後,再次請求該對象鎖時是可以得到該對象的鎖的,這種方式是必須的,否則在一個synchronized方法內部就沒有辦法調用該對象的另外一個synchronized方法了。鎖重入是通過為每個所關聯一個計數器和一個佔有它的線程,當計數器為0時,認為鎖是未被佔有的。線程請求一個未被佔有的鎖時,JVM會記錄鎖的佔有者,並將計數器設定為1。如果同一個線程再次請求該鎖,計數器會遞增,每次佔有的線程退出同步代碼塊時計數器會遞減,直至減為0時鎖才會被釋放。
在聲明一個對象作為鎖的時候要注意字串類型鎖對象,因為字串有一個常量池,如果不同的線程持有的鎖是具有相同字元的字串鎖時,兩個鎖實際上同一個鎖。