JavaSE中線程與並行API框架學習筆記——線程為什麽會不安全?
前言:休整一個多月之後,終於開始投簡歷了。這段時間休息了一陣子,又病了幾天,真正用來復習準備的時間其實並不多。說實話,心裏不是非常有底氣。
這可能是學生時代遺留的思維慣性——總想著做好萬全準備才去做事。當然,在學校裏考試之前當然要把所有內容學一遍和復習一遍。但是,到了社會裏做事,很多時候都是邊做邊學。應聘如此,工作如此,很多的挑戰都是如此。沒辦法,硬著頭皮上吧。
3.5 線程的分組管理
在實際的開發過程當中,可能會有多個線程同時存在,這對批量處理有了需求。這就有點像用迅雷下載電視劇,假設你在同時下載《越獄》和《紙牌屋》,這時候女朋友說想先看《越獄》,那麽為了盡快滿足她就要先暫停其他電視劇的下載。一個一個點暫停效率很低,最好的方法是批量選擇所有的目標任務再點暫停。
每個線程都屬於某個線程群組,即ThreadGroup。如果在main()主流程中產生了一個線程,該線程就屬於main線程群組。我們可以使用這樣的語句取得目前線程所屬線程組名:
1 Thread.currentThread().getThreadGroup().getName();
每個線程產生時,都會歸入某個線程群組。如果沒有指定,則會歸入產生該子線程的線程群組。當然,也可以自行指定線程群組。需要特別註意的是,線程一旦歸到某個群組,就無法更換。
java.lang.ThreadGroup類如其名,可以管理群組中的線程。可以使用以下方法產生群組,並在產生線程的時候指定所屬群組:
1 ThreadGroup threadGroup1 = new ThreadGroup("group1");
2 ThreadGroup threadGroup2 = new ThreadGroup("group2");
3 Thread thread1 = new Thread(threadGroup1, "group1‘s member");
4 Thread thread2 = new Thread(threadGroup2, "group2‘s member");
ThreadGroup的某些方法,可以對群組中所有線程產生作用。例如,interrupt()方法可以中斷群組裏面所有的線程,setMaxPriority()方法可以設定群組中所有線程最大優先權(本來就擁有更高優先權的線程不受影響)。
如果想要一次性地取得群組中所有線程,可以使用enumerate()方法:
1 Thread[] threads = new Thread[threadGroup1.activeCount()];
2 threadGroup1.enumerate(threads);
在這個代碼片段裏面,activeCount()方法取得群組的線程數量,enumerate()方法要傳入Thread數組,這會將線程對象設定到每個數組索引。
3.5.1 線程群組的異常處理
把若幹線程歸入到某個特定的線程群組之後,如果群組中某個線程發生了異常,有可能我們會采用統一的處理方式。
ThreadGroup中有個uncaughtException()方法,群組中某個線程發生異常而未捕捉時,JVM會調用此方法進行處理。如果ThreadGroup有父ThreadGroup,就會調用父ThreadGroup的uncaughtException()方法,否則看看異常是否為ThreadDeath實例。如果是那就什麽都不做;如果不是就要調用異常的printStrackTrace()。如果必須定義ThreadGroup中線程的異常處理行為,可以重新定義此方法。例如:
1 /**
2 * Created by Levenyes on 2017/7/29.
3 */
4 public class ThreadGroupDemo {
5 public static void main(String[] args) {
6 ThreadGroup tg1 = new ThreadGroup("tg1") {
7 @Override
8 public void uncaughtException(Thread t, Throwable e) {
9 System.out.printf("%s: %s%n", t.getName(), e.getMessage());
10 }
11 };
12
13 Thread t1 = new Thread(tg1, new Runnable() {
14 public void run() {
15 throw new RuntimeException("測試異常");
16 }
17 }
18 );
19
20 t1.start();
21 }
22 }
uncaughtException()方法第一個參數可取得發生異常的線程實例,第二個參數可取得異常對象,實驗用例中顯示了線程的名稱以及異常信息:
3.6 為什麽線程會不安全?
以前剛畢業那會兒背面試題就背到過,“String是定長的,StringBuffer和StringBuilder是不定長的;StringBuffer是線程安全的,StringBuilder是線程不安全的”。那麽問題就來了,為什麽線程會不安全呢?
我們在之前的文章裏講到過ArrayList類。之前在單線程的情況下使用是沒有問題的,但如果在多線程的環境下使用會不會出現意外呢?
1 import java.util.*;
2
3 /**
4 * 線程不安全實驗用例
5 */
6 public class ArrayListDemo {
7 public static void main(String[] args) {
8 final ArrayList list = new ArrayList ();
9 Thread t1 = new Thread() {
10 public void run() {
11 while(true) {
12 list.add(1);
13 }
14 }
15 };
16 Thread t2 = new Thread() {
17 public void run() {
18 while(true) {
19 list.add(2);
20 }
21 }
22 };
23 t1.start();
24 t2.start();
25 }
26 }
如果你跟我一樣讓上面這段代碼跑起來,就“有可能”出現下面這些異常:
為什麽要強調說是“有可能”呢?這是幾率問題,有可能發生,也有可能沒發生,就因數組長度過長,JVM分配到的內存不夠,而發生java.lang.OutOfMemoryError,我們不討論OutOfMemoryError問題,而將焦點放在為何會出現ArrayIndexOutOfBoundsException異常。
首先來看ArrayList在JavaSE源代碼中的add()方法:
1 /**
2 * Appends the specified element to the end of this list.
3 *
4 * @param e element to be appended to this list
5 * @return <tt>true</tt> (as specified by {@link Collection#add})
6 */
7 public boolean add(E e) {
8 ensureCapacityInternal(size + 1); // Increments modCount!!
9 elementData[size++] = e;
10 return true;
11 }
這個方法先會檢查數組的大小是否已經到了最大值,如果是的話就會先做增加最大值的動作,再把新元素加入到數組當中來。按理來說,不可能會出現溢出的情況。
然而,如果有t1、t2兩個線程同時調用add()方法,假設t1執行add()已經到了elementData[size++] = e這行,這個時候CPU調度器將t1置為Runnable狀態,將t2置為Running狀態,而t2執行add()已經完成elementData[size++] = e這行的執行,此時剛好數組滿了。如果這個時候CPU調度器將t2置為Runnable狀態,將t1置為Running狀態,t1就會繼續跑elementData[size++] = e這一行,因為數組已經滿了,就會出現ArrayIndexOutOfBoundsException異常。
用術語來說,這就是線程存取同一對象相同資源時所引發的競速,即Race condition。類似這樣因為多線程而出錯的情況,我們就可以理解成線程有了出錯的危險,即不安全。
像ArrayList這樣的類,我們習慣稱為不具備線程安全(Thread-safe)或線程不安全的類。
3.7 保證同步的syncronized
如何解決線程不安全的問題呢?我們可以使用關鍵字synchronized,顧名思義,就是同步的意思。
1 public synchronized boolean add(E e) {
2 ensureCapacityInternal(size + 1); // Increments modCount!!
3 elementData[size++] = e;
4 return true;
5 }
想辦法在add()方法前面加上synchronized關鍵字之後,再次運行前面那個demo,ArrayIndexOutOfBoundsException就不會再出現了。這是為什麽呢?
這是因為每個對象都會有個內部鎖定,即IntrinsicLock,或稱為監控鎖定,即Monitor lock。被標識為synchronized的區塊將會被監控,任何線程要執行synchronized區塊將會被監控,任何線程要執行該區塊都必須先取得指定的對象鎖定。
如果A線程已取得對象鎖定開始執行synchronized區塊,B線程也想執行synchronized區塊,會因無法取得對象鎖定而進入等待鎖定狀態,直到A線程釋放鎖定(例如執行完了區塊內的任務),B線程才有可能取得鎖定而執行synchronized區塊。
舉個不太恰當的例子,這就好像你跟一個好哥們到西藏自駕遊,任意時刻都只能有一個人在駕駛位上開車。如果你在開車,你的哥們就只能在旁邊看著。只有等你停車從駕駛位上走開,他才可以坐上去開車。如果你們是一個人負責踩剎車和油門,另一個人負責握方向盤,就很有可能發生交通意外。
值得一提的是,線程在等待對象鎖定時,也會進入Blocked狀態。所以我們可以進一步擴展在上一篇文章提到過的線程生命周期示意圖:
線程如果因為嘗試執行synchronized區塊而進入Blocked狀態,在取得鎖定之後,會先回到Runnable狀態,等待CPU調度器排入Running狀態。
synchronized不是只可以聲明在方法上,也可以描述句方式使用。例如下面這樣的寫法:
1 public void add(Object o) {
2 synchronized (this) {
3 if(next == list.length) {
4 list = Arrays.copyOf(list, list.length * 2);
5 }
6 list[next++] = o;
7 }
8 }
這個程序片段的意思就是,在線程要執行synchronized區塊時,必須取得括號中指定的對象鎖定。事實上此語法目的之一,可應用於不想鎖定整個方法,而只想鎖定會發生競速狀況的區塊,在執行完區塊後線程即釋放鎖定,其他線程就有機會再競爭對象鎖定,相較於將整個方法聲明為synchronized來說,會比較有效率。
我們在之前的文章介紹過的Collection和Map,它們的實現類大多沒有考慮線程安全,其實可以使用自帶的synchronizedCollection()、synchronizedList()、synchronizedSet()、synchronizedMap()等方法獲取新增線程安全特性的對象。
3.8 線程安全小結
值得註意的是,synchronized聲明固然可以讓線程變得“安全”,不容易發生競速狀況,但這樣的線程安全特性需要付出代價。首先是很大概率會使運行效率有程度不一的下降,因為會一旦發生因synchronized而起的阻塞狀況就會令運行時間變長。其次,如果程序設計不當,還有可能會發生死鎖這樣嚴重的問題,即Dead Lock。
一個線程要完成一個事務可能需要多個資源,就好像你要做飯,需要用到菜刀和砧板。如果這時候你跟你的舍友都要切菜,你拿著菜刀不肯放,他拿著砧板不肯放,那就誰都吃不上飯。對應到多線程當中,一個事務需要同時利用a和b資源才可以完成,有可能出現A線程鎖定a資源,B線程鎖定b資源,兩個線程同時在等待對方放棄鎖定。
當然了,我們人是活的,可以互相商量著讓誰先切菜。但是程序是“死”的,如果沒有一個良好的設計機制避免死鎖發生,很有可能就會出現多個線程同時等待且不可能等待結束的狀況。
因此,如果你的程序沒有出現競速狀況的可能性,就盡量不要用synchronized聲明。一旦使用,就要考慮到性能下降和可能發生死鎖這兩個關鍵點,盡可能在獲取線程安全這一特性的同時盡可能避免發生死鎖和盡可能少地犧牲效率。
相關文章推薦:
JavaSE中Collection集合框架學習筆記(1)——具有索引的List
JavaSE中Collection集合框架學習筆記(2)——拒絕重復內容的Set和支持隊列操作的Queue
JavaSE中Collection集合框架學習筆記(3)——遍歷對象的Iterator和收集對象後的排序
JavaSE中Map框架學習筆記
JavaSE中線程與並行API框架學習筆記1——線程是什麽?
如果你喜歡我的文章,可以掃描關註我的個人公眾號“李文業的思考筆記”。
不定期地會推送我的原創思考文章。
JavaSE中線程與並行API框架學習筆記——線程為什麽會不安全?