對於Java開發人員來講,i++的併發不安全是人所共知,可是它真的有那麼不安全麼?html
在開發Java代碼時,如何可以避免多線程併發出現的安全問題?這是全部Java程序員都會面臨的問題。本文講述了在開發Java代碼時安全併發設計所須要考慮的點,文中以一張圖展開,圍繞着Java類狀態,討論各類狀況下的併發安全問題。當理解了Java類的各類變量狀態在併發狀況下的表現,在寫Java多線程代碼時就能夠作到心中有數,遊刃有餘,寫出更加安全、健壯、靈活的多線程併發代碼。java
目錄git
在現代操做系統中,CPU的調度都是以線程爲基本單位,各個線程各自獨立獲取到CPU時間片並執行代碼指令,這就是多線程併發。於此同時,同一進程中的全部線程將共享當前進程的內存地址空間,這些線程能夠訪問當前內存地址空間上的同一個變量。若一個線程在使用某個變量時,另外一個線程對這個變量進行修改,將形成不可預測的結果,這也是多線程併發問題。程序員
一個簡單的例子是,當一個線程循環讀取一個數組時,另一個線程對這個數組內對象進行刪除,則前面一個線程可能讀取失敗或讀取的是髒數據。算法
在多線程併發中,若一段代碼的執行不能按預期正確地進行,或者執行的最終結果不可預測,則咱們說這段代碼併發不安全。換句話說,若線程之間可以按照預期執行代碼,操做數據並獲取到指望的結果,則實現了安全的併發。編程
類狀態是指類中所聲明的變量,不管是公有變量、私有變量,亦或static和final修飾的變量,都是不一樣形式的類狀態。按照Java語法,類變量有以下各類形式,數組
這些類變量在運行時刻,映射到JVM內存中各類對象。Java安全併發設計,其核心在於如何處理這些變量在併發中的表現,掌握它們的特性是Java安全併發設計的關鍵。安全
下圖從類狀態出發,簡要的說明了Java類變量的各類狀態形式,及其相關的併發安全性,多線程
其中,併發
須要注意的是,該圖是以Java語言爲例來講明如何設計併發安全的對象類,但實踐中,圖中所涉及的狀態、私有狀態、不可變狀態、非阻塞和阻塞訪問,這些概念也應該適用於更多面對對象的編程語言。
下面將對上圖中各個類狀態進行一一講解,介紹各個狀態下併發設計的要點。
一個無狀態類是指其沒有任何聲明的成員變量,例如,
public class StatelessClass { public void increment() { int i = 0; String msg = String.valueOf(i++); log.info(msg); } }
無狀態類是線程安全的。上述類中的increment()方法中,有兩個本地變量i和msg,這兩個本地變量都在方法棧空間上分配,因爲棧內存空間是按線程各自獨立的,相互隔離,所以棧空間上的變量是線程安全的。
由此還能夠知道,在方法調用中分配的變量和對象,若在棧退出後變量或對象引用被JVM釋放(不會被外部再訪問到),則這個變量和對象也是線程安全的。關於本地變量和JVM棧空間的更多介紹,能夠參考這篇文章。
和無狀態相反,有狀態類是指類中有聲明的成員變量,例如
public class StatefulClass { private int i=0; public void increment() { i++; } }
上面的類聲明瞭一個int i的類變量,並初始化爲0。大多數狀況下Java類都是屬於有狀態類。
有狀態是致使線程不安全的必要條件,但它不是充分條件,請繼續看下文。
若Java類的狀態經過ThreadLocal等方法,使得狀態被隔離在各個線程中,相互不干擾,例如,
public class PrivateStateClass { private ThreadLocal<Integer> i = new ThreadLocal<>(); public void set(int i) { i.set(i); } public void increment() { Integer value = i.get(); i.set(value + 1); } }
上面的類聲明瞭一個ThreadLocal i的變量,這個類狀態按各個線程進行了隔離,爲一種私有狀態,在執行increment()方法時能夠被多線程安全訪問。
正常的Java成員變量是線程共享的,即多個線程經過Java類提供的類方法訪問類對象時,類對象中的成員變量能夠被共享訪問到,這是大多數狀況下的應用場景。
共享狀態在多線程併發時,不必定就是不安全,其又能夠分爲常量狀態和可變狀態兩種狀況來討論,請見下文。
下面的Java類中,有一個Integer PI變量被聲明爲final,這說明這個變量是一個常量對象,初始化以後再也不改變。
public class FinalStateClass { private final Integer PI = 3.14; public double calculate(double radius) { return PI*radius*radius; } }
多線程訪問上述的calculate()方法是線程安全的。
final聲明使得變量變爲常量狀態,多線程在訪問時不能更改狀態,在必定程度上實現了只讀,從而是線程安全的。
對於可變的共享狀態,當多線程訪問時,必然出現協同操做和同步問題,若代碼設計不當,則很容易出現線程不安全問題。
對於可變共享狀態的訪問,是多線程併發設計時的考慮重點。爲了實現線程安全,通常經過下面兩種方法,
下面是這兩種方法的簡單比較,
非阻塞設計 | 阻塞設計 | |
---|---|---|
多線程執行 | 並行執行 | 串行執行 |
安全實現方法 | 經過算法設計 | 經過鎖 |
吞吐性能 | 高 | 低 |
優勢 | 無死鎖,線程不會被阻塞掛起 | 經過鎖能夠實現可控的線程調度 |
缺點 | 算法實現複雜,在高度競爭狀況下,吞吐性能會低於鎖 | 線程的掛起和上下文切換、死鎖 |
更詳細的討論見下文。
下面的Java類經過原子變量AtomicInteger實現非阻塞的自增算法。
public class AtomicStateClass { private AtomicInteger i = new AtomicInteger(0); public void increment() { i.incrementAndGet(); } }
能夠看到increment()方法沒有添加任何鎖,可是它能夠實現多線程的安全自增操做。AtomicInteger其原理是經過CAS算法,即compareAndSet()方法,先查看變量是否變化,若沒有變化則設置值,如有變化,則從新嘗試,在絕大數狀況下,值的設置在第一次嘗試就成功。
更多非阻塞算法設計,好比非阻塞的棧、非阻塞的鏈表插入操做,見這裏。
阻塞是指經過鎖來控制線程對類狀態的訪問,使得當前狀態只能由一個線程訪問,其它訪問線程則掛起等待,一直等到鎖被釋放後,全部的等待線程競爭鎖,得到下一次訪問權。
鎖的設計,使得線程各自之間實現同步,串行執行代碼指令,避免了競爭狀態。可是於此同時,它也帶來了死鎖的困擾。若兩個線程之間相互持有對方須要的資源或鎖,則進入死鎖狀態。
JVM在解決死鎖上沒有提供較好的辦法機制,更多的是提供監控工具來查看。對於死鎖問題,最終解決方案是依賴開發者實現的代碼,增長更多的資源,減小鎖的碰撞,實現鎖的有序持有和不定時釋放,都是避免死鎖的有效方案。
資源死鎖是一種普遍的死鎖定義,簡單例子是,一個打印任務須要得到打印機和文件對象,若一個線程得到了打印機,而另一個線程得到了文件對象,相互都不釋放得到的資源,則出現資源死鎖狀況。
增長更多的資源,是解決此類死鎖的有效方案。
下面是一個鎖順序死鎖的演示代碼,
public class LockOrderingDeadLock { public void transferMoney(Account from, Account to, Integer amount) { synchronized (from) { synchronized (to) { from.debit(amount); to.credit(amount); } } } }
若同時啓動兩個線程,分別執行下面兩個操做,
則頗有可能出現死鎖狀態,由於線程1在握有accountA對象鎖的同時,線程2也握有accountB的鎖。下面是對transferMoney方法測試過程當中,經過JConsole觀察到的死鎖狀況,
圖1:pool-1-thread-5握有account@120f74e3的鎖,等待account@3e9369b9的鎖
圖2:pool-1-thread-8握有account@3e9369b9的鎖,等待account@120f74e3的鎖
解決辦法之一,是實現鎖的按序持有,即對於任何兩個對象鎖A和B,先進行排序(排序算法必須是穩定有序),不管是哪一個線程,都必須按照鎖的排序,依次獲取,從而避免相互持有對方須要的鎖。
狀態公開是指類成員變量被公開,在必定程度上破壞了面向對象設計的數據封裝性。對類方法再好的阻塞設計,一旦狀態被公開,其併發安全性都會功虧一簣。
見下面的例子,類中定義了一個personList的對象,方法insert()和iterate()經過synchronized進行了阻塞加鎖,其只能運行一個線程進入類方法執行操做。
public class PublicStateClass { public ArrayList<String> personList = new ArrayList<>(); public synchronized void insert(String person) { personList.add(person); } public synchronized void iterate() { Integer size = personList.size(); for (int i = 0; i < size; i++) { System.out.println(personList.get(i)); } } }
但多線程訪問insert()和iterate()方法時,並不必定線程安全,主要緣由是personList被聲明瞭公開對象,使得類以外的線程能夠輕易地訪問到personList變量,從而致使personList的狀態不一致,在iterate整個person列表時,可能列表中的對象已被刪除。
這是類狀態公開致使的線程安全問題,究其緣由,還要歸結於沒有作好類的面對對象設計,對外部沒有隱藏好數據。
下面的getList方法返回也會致使一樣的問題,
public class PublicStateClass { private ArrayList<String> personList = new ArrayList<>(); public List getList() { return personList; } }
對於這樣的問題,推薦的作法是,成員變量聲明爲私有,在執行讀操做時,對外克隆一份數據副本,從而保證類內部數據對象不被泄露,
public class PublicStateClass { private ArrayList<String> personList = new ArrayList<>(); public List getList() { return (List) personList.clone(); } }
類的靜態狀態是指類中被static聲明的成員變量,這個狀態會在類初次加載時初始化,被全部的類對象所共享。Java程序員對這個static關鍵字應該不會陌生,其使用的場景仍是很是普遍,好比一些常量數據,因爲沒有必要在每一個Java對象中存儲一份,爲了節省內存空間,不少時候聲明爲static變量。
但static變量併發不安全,從面向對象設計來講,一旦變量聲明爲靜態,則做用空間擴大到整個類域,若被聲明爲公共變量,則成爲全局性的變量,static的變量聲明大大破壞了類的狀態封裝。
爲了使靜態變量變得多線程併發安全,final聲明是它的「咖啡伴侶」。在阿里巴巴的編碼規範中,其中一條是,如果static成員變量,必須考慮是否爲final。
上文在講併發設計時,都是針對類內部狀態,即類內部成員變量被聲明爲私有,類方法只對類內部變量進行操做,這是一種簡化的應用場景,針對的是依據徹底面向對象設計的Java類。一種更常見的狀況是,類方法須要對外部傳入的對象進行操做。這個時候,類的併發設計則和外部狀態息息相關。
例如,
public class StatelessClass { public void iterate(List<Person> personList) { Integer size = personList.size(); for (int i = 0; i < size; i++) { System.out.println(personList.get(i)); } } }
上面的類是一個無狀態類,裏面沒有任何聲明的變量。可是iterate方法接受一個personList的列表對象,由外部傳入,personList是一個外部狀態。
外部狀態相似上文中內部狀態公開,不管在類方法上作如何的參數定義(使用ThreadLocal/final進行聲明定義),作如何併發安全措施(加鎖,使用非阻塞設計),類方法其對狀態的操做都是不安全的。外部狀態的安全性取決於外部的併發設計。
一個簡單的處理方法,在調用類方法的地方,傳入一個外部狀態的副本,隔離內外部數據的關聯性。
類狀態的併發,本質上是內存共享數據對象的多線程訪問問題。只有對代碼中各個Java對象變量的狀態特性掌握透徹,寫起併發代碼時將事倍功半。
下面的類中,整個hasPosition()方法被synchronized修飾,
public class UserLocator { private final Map<String, String> userLocations = new HashMap<>(); public synchronized boolean hasPositioned(String name, String position) { String key = String.format("%s.location", name); String location = userLocations.get(key); return location != null && position.equals(location); } }
但仔細查看能夠知道外部變量name和position、內部變量key和location都是併發安全,只有userLocations這個變量存在併發風險,須要加鎖保護。所以,將上面的方法進行以下調整,將減小鎖的粒度,有效提升併發效率。
public class UserLocator { private final Map<String, String> userLocations = new HashMap<>(); public boolean hasPositioned(String name, String position) { String key = String.format("%s.location", name); String location; synchronized (this) { location = userLocations.get(key); } return location != null && position.equals(location); } }
因而可知,瞭解類中各個變量特性對寫好併發安全代碼的重要性。在這個基礎上,優化鎖的做用範圍,減小鎖的粒度,實現鎖分段,均可以作到信手拈來,遊刃有餘。
關於類狀態,說了這麼多,最後給一個全文性總結:面向對象進行類設計,隱藏好數據,控制好類的狀態,從嚴控制變量的訪問範圍,能private儘可能private,能final儘可能final,這些都將有助於提升代碼的併發健壯性。
全部的演示代碼在以下的代碼倉庫中,