文章目錄
- 第一部分基礎知識
- 第二部分結構化併發應用程序
- 第三部分活躍性、性能與併發開發注意事項
- 十.避免活躍性危險
- 十一.性能與可伸縮性
- 十二.併發開發注意事項
- 第四部分高級主題
第一部分基礎知識
二.線程安全性
無狀態:不包含任何域,也不包含任何對其他類中域的引用。無狀態的對象一定是線程安全的
競態條件:當某個結果的正確性取決於多線程的交替執行時就會發生競態條。最常見的就是先檢查後執行的操作,在檢查時可能會讀取到一個失效值從而執行了錯誤的操作
線程安全性:當多個線程訪問某個類時,這個類始終都能表現出正確的行爲,那麼就稱這個類是線程安全的。在線程安全類中封裝了必要的同步機制,因此客戶端無需進一步同步措施
重入:某個線程請求一個由其他線程持有的鎖時,發出請求的線程會阻塞。如果線程試圖獲取一個已經由它自己持有的鎖,那麼他發出的請求就會成功。
1.synchronized 是Java內置鎖(內置鎖可以重入,不然會發生死鎖),有方法鎖、對象鎖、類鎖區別
2.Java獲取鎖的操作粒度是線程而不是調用
三.對象的共享
可見性:一個線程修改了某個共享變量的值另一個線程可以立刻看到修改後的變量值
失效數據:在沒有同步的情況下,一個線程修改了某個共享變量的值但是另一個讀取該變量值的線程所讀取的值卻不是最新的值,讀到的是一個失效的值。
最低安全性:線程間沒有同步時,讀取變量值可能會讀到一個失效值,但是這個是失效值是之前某個線程設置的值,不是一個隨機值,這種安全性保障稱爲最低安全性,它適用於絕大多數變量但是不適用用long、double變量
重排序:在沒有同步時JVM和CPU及運行時可能對程序的執行順序進行一些調整
發佈對象:使對象可以在當前作用域以外的代碼中使用。比如將一個對象的引用保存到其他代碼可以訪問的地方,或者在某一個非私有的方法中返回該引用,或者將引用傳遞到其他類的方法中
逸出:當某個不應該發佈的對象被髮布時,就稱爲逸出
1.volatile關鍵字 可以在變量值改變後將值存到主內存,使其他線程對變量的緩衝失效,從而達到可見性即在多線程中一個線程改變了變量的值其它線程可以立刻看見該變量的最新值,但是不能保證原子性
2.long、double變量是非volatile類型的64位數值變量,JVM在對其進行讀或者寫操作時會先把其分解爲兩個32位操作,所以不適用最低安全性,讀取的值可能大可能小
3.當且僅當滿足以下所有條件時,才應該使用volatile 變量:
- 對變量的寫人操作不依賴變量的當前值,或者你能確保只有單個線程更新變量的值。
- 該變量不會與其他狀態變量一起納人不變性條件中。
- 在訪問變量時不需要加鎖。
3.1 不變性
不可變對象:爲滿足同步,使用不可變的對象(final)
1.當滿足以下條件時,對象纔是不可變的:
- 對象創建以後其狀態就不能修改。
- 對象的所有域都是final類型
- 對象是正確創建的(在對象的創建期間,this引用沒有逸出)。
2.不可變對象一定時線程安全的
3.volatile發佈不可變對象可以提供弱線程安全(volatile保障可見性,不可變對象保障弱原子性)
3.2 線程封閉
線程封閉:不在線程間共享數據的技術。可以使用ThreadLocal類、或者保證volatile變量只有一個線程執行寫操作。即把對象封裝在對應的線程內
1.ad-hoc線程封閉:這是完全靠實現者控制的線程封閉,他的線程封閉完全靠實現者實現。Ad-hoc線程封閉非常脆弱,沒有任何一種語言特性能將對象封閉到目標線程上。
2.棧封閉:棧封閉是線程封閉的一種特例,是我們編程當中遇到的最多的線程封閉。多個線程訪問一個方法,此方法中的局部變量都會被拷貝一分兒到線程棧中。所以局部變量是不被多個線程所共享的,也就不會出現併發問題。所以能用局部變量就別用全局的變量,全局變量容易引起併發問題。
3.使用ThreadLocal類,ThreadLocal可以使變量的值與線程關聯,同一個變量不同的線程擁有不同的值。initialValue是用來獲取初始值的方法(第一次調用ThreadLocal.get方法返回的值)
3.3 對象的發佈
1.對象的發佈需求取決於它的可變性:
- 不可變對象可以通過任意機制來發布。
- 事實不可變對象必須通過安全方式來發布。
- 可變對象必須通過安全方式來發布,並且必須是線程安全的或者由某個鎖保護起來。
2.如果一個狀態變量是線程安全的,並且沒有任何不變性條件來約束它的值,在變量的操作:上也不存在任何不允許的狀態轉換,那麼就可以安全地發佈這個變量。
3.4 安全發佈的常用模式
1.要安全地發佈一個對象,對象的引用以及對象的狀態必須同時對其他線程可見。一個正確構造的對象可以通過以下方式來安全地發佈:
- 在靜態初始化函數中初始化一個對象引用。
- 將對象的引用保存到volatile類型的域或者AtomicReferance對象中。
- 將對象的引用保存到某個正確構造對象的final類型域中。
- 將對象的引用保存到一個由鎖保護的域中。
3.5 在線程安全容器內部同步
1.通過將一個鍵或者值放入Hashtable、synchronizedMap 或者ConcurrentMap中,可以安全地將它發佈給任何從這些容器中訪問它的線程(無論是直接訪問還是通過迭代器訪問)。
2.通過將某個元素放入Vector、 CopyOnWriteArrayList、 CopyOnWriteArraySet、 synchronizedList或synchronizedSet中,可以將該元素安全地發佈到任何從這些容器中訪問該元素的線程。
3.通過將某個元素放入BlockingQueue或者ConcurrentLinkedQueue中,可以將該元素安全地發佈到任何從這些隊列中訪問該元素的線程。
3.6 在併發程序中使用和共享對象時的實用策略
1.線程封閉。線程封閉的對象只能由一個線程擁有,對象被封閉在該線程中,並且只能由這個線程修改。
2.只讀共享。在沒有額外同步的情況下,共享的只讀對象可以由多個線程併發訪問,但任何線程都不能修改它。共親的只讀對象包括不可變對象和事實不可變對象。
3.線程安全共享。線程安全的對象在其內部實現同步,因此多個線程可以通過對象的公有接口來進行訪問而不需要進一步的同步。
4.保護對象。被保護的對象只能通過特有特定的鎖來訪問,保護對象包括封裝在其他線程安全對象中的對象,以及已發佈的並且由某個特定鎖保護的對象。
四.對象的組合
4.1 設計線程安全的類
後驗條件:判斷某些狀態的遷移是否有效。比如一個自增操作(i++),下一個值只能是上一個值加一的值
同步策略(SynchronizationPolicy):定義瞭如何在不違背對象不變條件或後驗條件的情況下對其狀態的訪問操作進行協同。同步策略規定了如何將不可變性、線程封閉與加鎖機制等結合起來以維護線程的安全性,並且還規定了哪些變量由哪些鎖來保護。
類的不可變條件:用於判斷狀態是否有效。比如一個類中有一個long 值 那麼類其中一個不變性條件是Long.MIN_VALUE<value<Long.MAX_VALUE
1.在設計線程安全類的過程中,需要包含以下三個基本要素:
- 找出構成對象狀態的所有變量。
- 找出約束狀態變量的不變性條件。
- 建立對象狀態的併發訪問管理策略
4.1.1 依賴狀態的操作
前驗條件:某些對象的方法中包含了一些基於狀態的先驗條件。比如不能從一個空隊列中移除元素,在刪除元素前隊列必須是非空的。
依賴狀態的操作:如果在某個操作中包含有基於狀態的先驗條件,那麼該操作稱爲依賴狀態的操作。
4.1.2 狀態的所有權
狀態的所有權:許多情況下,所有權與封裝性總是相互關聯的。對象封裝它擁有的狀態,反之也成立,即擁有它封裝的狀態的所有權。
1.狀態變量的所有者將決定採用何種加鎖協議來維持變量狀態的完整性。所有權意味着控制權。然而,如果發佈了某個可變對象的引用,那麼就不再擁有獨佔的控制權,最多是”共享控制權“。對於從構造函數或者從方法中傳遞進來的對象,類通常並不擁有這些對象,除非這些方法是被專門設計爲轉移傳遞進來的對象的所有權。
2.容器類通常是一種所有權分離的形式,其中容器類擁有自身的狀態,而客戶代碼則擁有容器中各個對象的狀態。
4.2 實例封閉
Java監視器模式:把對象的所有可變狀態都封裝起來,並由對象自己的內置鎖保護
實例封閉:通過確保對象只能由一個線程訪問(線程封閉)或者通過一個鎖來保護該對象的所有訪問
1.私有鎖對象的優點
- 私有鎖對象可以將鎖封裝起來,使客戶代碼無法得到鎖,但客戶代碼纔可以通過公有方法來訪問鎖,以便(正確或者不正確的)參與到它的同步策略中。
- 要想驗證某個公有訪問的鎖在程序中是否被正確的使用,則需要檢查整個程序,而不是單個類,使用私有鎖降低了驗證的複雜度。
4.3 線程安全性的委託
線程安全性的委託:在某些情況下,通過線程安全類組合而成的類是線程安全的,稱之爲線程安全性的委託
委託失效:大多數組合對象存在着某些不變性條件。會導致委託失效,組合而成的類非線程安全。
1.大多數對象都是組合對象。當從頭開始構建一個類,或者將多個非線程安全的類組合爲一個類時,Java 監視器模式是非常有用的。但是,如果類中的各個組件都已經是線程安全的,有時會需要增加一個額外的線程安全層(組件間存在不變性條件),有時不用,要視情況而定。
4.3.1 活躍性問題
活躍性:指某件正確的事情最終會發生,當某個操作無法繼續下去的時候,就會發生活躍性問題。在多線程中一般有死鎖、活鎖和飢餓問題
死鎖:多個線程因爲環形的等待鎖的關係而永遠的阻塞下去。
活鎖:線程不斷重複執行相同的操作,而且總會失敗。當多個相互協作的線程都對彼此進行響應而修改各自的狀態,並使得任何一個線程都無法繼續執行(只能一直重複着響應和修改自身狀態),就發生了活鎖。如果迎面兩個人走路互相讓路,總是沒有隨機性地讓到同一個方向,那麼就會永遠地避讓下去。
飢餓:當線程無法訪問它所需要的資源而導致無法繼續時,就發生了飢餓。如一個線程佔有鎖永遠不釋放,等待同一個鎖的其他線程就會發生飢餓。
4.4 在現有的線程安全類中添加功能
1.最安全的方式是修改原始類,但是通常無法做到
2.擴展這個類,但是不是所有的類都可以擴展。因爲同步策略實現被分佈到多個單獨維護的源代碼文件中,如果底層的類改變了同步策略並選擇了不用的鎖來保護它的狀態變量那麼子類會被破壞(同步策略改變後,子類無法在使用正確的鎖來控制對基類狀態的併發訪問)
3.擴展類的功能,但是不擴展類,添加輔助類(客戶端加鎖機制)。在原子操作上添加基類的鎖。這種方法很脆弱,因爲它將類A的加鎖代碼放到與A類完全無關的類中,而且會破壞同步策略的封裝性。
4.組合,使用監視器模式封裝了要使用的類,這樣就不用關心到底使用哪個對象的鎖,統一用封裝類的鎖
五.基礎構建模塊
5.1 同步容器類
1.Java中的同步容器包括2類
- Vector、Stack、HashTable。他們是早期JDK的一部分
- Collections類中提供的靜態工廠方法創建的類。該類是通過將底層類的狀態封裝起來,並對每個公有方法進行同步來實現線程安全
2.同步容器類都是線程安全的,但是對於某些複合操作需要額外的加鎖來保護。常見覆合操作有:迭代(反覆訪問元素,直到遍歷所有元素)、跳轉(根據指定順序找到當期元素的下一個元素)以及條件運算(如:如沒有則添加)
3.同步容器缺陷
- 並非任何場景都是線程安全的。
- 將所有對容器狀態的訪問都串行化,以實現他們的線程安全性。嚴重降低了併發性,當多個線程競爭容器的鎖時,吞吐量將嚴重降低
5.2 併發容器
併發容器是針對多個線程併發訪問設計的,通過併發容器來代替同步容器,可以極大地提高伸縮性並降低風險
5.2.1 concurrentHashMap
concurrentHashMap採用了分段鎖來實現更大程度的共享。在這種機制下,任意數量的讀取線程可以併發地訪問Map,執行讀取操作的線程和執行寫入操作的線程可以並訪問Map,並且-定數量的寫人線程可以併發地修改Map。ConcurrentHashMap帶來的結果是在併發訪問環境下將實現更高的吞吐量,而在單線程環境中只損失非常小的性能。
concurrentHashMap不能被加鎖來執行獨佔訪問,因此無法使用客戶端加鎖來創建新的原子操作。但是一些常見的複合操作已經實現比如‘如果沒有則添加’(putIfAbsent)、‘若相等則移除’(remove),‘若相等則替換’(replace)
5.2.2 CopyOnWriteArrayList、CopyOnWriteArraySet
通過創建底層數組的新副本來實現所有可變操作(添加,設置等)。在每次修改時,都會創建並重新發佈一個新的容器副本,從而實現可變性。它返回的其實是list的一個快照。僅當迭代操作遠遠多於修改時才使用該容器
每當修改容器時都會複製底層數組,這需要一定的開銷。特別是當容器的規模較大時,所以一般當迭代操作遠遠多於修改時才使用該容器
1.優點:保證多線程的併發讀寫的線程安全
2.缺點:
- 創建底層數組的新副本,會帶來內存問題。如果實際應用數據比較多,而且比較大的情況下,佔用內存會比較大,這個可以用ConcurrentHashMap來代替
- CopyOnWrite容器只能保證數據的最終一致性,不能保證數據的實時一致性。所以如果你希望寫入的的數據,馬上能讀到,請不要使用CopyOnWrite容器
3.CopyOnWrite併發容器用於讀多寫少的併發場景。
4.CopyOnWriteArrayList 使用入門源碼詳解
5.3 阻塞隊列和生產者—消費者模式
生產者:負責產生數據的模塊
消費者:處理數據的模塊
緩衝區:生產者-消費者模式需要有一個緩衝區處於生產者和消費者之間,作爲一箇中介。
1.如果生產者直接調用消費者的某個方法,由於函數調用時同步的,那麼在消費者處理完數據之前,生產者就一直在等待。如果消費者的處理速度很快可能還好,如果消費者處理數據很慢,那麼生產者就要長時間等待,而不能進行下一步的操作。
2.生產者把數據放入緩衝區,而消費者從緩衝區取出數據。這樣可以降低生產者和消費者的耦合性,他們都依賴於某個緩衝區(隊列)
3.使用了生產者-消費者模式之後,生產者不用關心消費者的處理能力,直接將數據放入緩衝區就好。
4.生產者-消費者模式主要就是爲了處理併發問題。
5.在類庫中包含了BlockingQueue的多種實現,其中,LinkedBlockingQueue 和ArrayBlockingQueue是FIFO隊列,二者分別與LinkedList和ArrayList類似,但比同步List 擁有更好的併發性能。PriorityBlockingQueue 是一個按優先級排序的隊列,當你希望按照某種順序而不是FIFO來處理元素時,這個隊列將非常有用。正如其他有序的容器一樣,PriorityBlockingQueue 既可以根據元素的自然順序來比較元素(如果它們實現了Comparable方法),也可以使用Comparator來比較。
6.SynchronousQueue,實際上它不是一個真正的隊列,因爲它不會爲隊列中元素維護存儲空間。與其他隊列不同的是,它維護一組線程,這些線程在等待着把元素加入(生產者)或移出(消費者)隊列。
5.3.1 雙端隊列與工作密取
雙端隊列: 是一種具有隊列和棧性質的數據結構,可以(也只能)在線性表的兩端進行插入和刪除。它比傳統的生產者-消費者模式模式具有更高的可伸縮性
工作密取:如果一個消費者完成了自己雙端隊列中的全部工作,那麼它可以從其他消費者的雙端隊列末尾祕密的獲取工作。由於每個線程都有自己的雙端隊列,所以不會在任務隊列上產生競爭。
雙端隊列適用於工作密取,每個消費者都有自己的雙端隊列。
5.3.2 Java中Queue的一些常用方法:
方法 | 作用 | 說明 |
---|---|---|
add | 增加一個元索 | 如果隊列已滿,則拋出一個IIIegaISlabEepeplian異常 |
remove | 移除並返回隊列頭部的元素 | 如果隊列爲空,則拋出一個NoSuchElementException異常 |
element | 返回隊列頭部的元素 | 如果隊列爲空,則拋出一個NoSuchElementException異常 |
offer | 添加一個元素並返回true | 如果隊列已滿,則返回false |
poll | 移除並返問隊列頭部的元素 | 如果隊列爲空,則返回null |
peek | 返回隊列頭部的元素 | 如果隊列爲空,則返回null |
5.3.3 Java中BlockingQueue方法:
方法 | 作用 | 說明 |
---|---|---|
put | 添加一個元素 | 如果隊列滿,則阻塞 |
offer | 添加一個元素並返回true | 如果隊列已滿,則返回false (支持等待指定時間) |
poll | 移除並返問隊列頭部的元素 | 如果隊列爲空,則可以指定時間,如果指定時間超時還沒有數據可取,返回失敗 |
take | 移除並返回隊列頭部的元素 | 如果隊列爲空,則阻塞 |
remainingCapacity | 剩餘容量 | 返回int |
contains | 判斷是否包含某個元素 | 返回boolean |
drainTo | 移除此隊列中所有可用的元素,將它們添加到給定集合中。 | 返回int |
5.3.4 BlockingDeque常用方法
效果 | 拋異常方法 | 返回特定值方法 | 阻塞方法 | 超時方法 |
---|---|---|---|---|
在隊列前端插入元素 | addFirst(o) | offerFirst(o) | putFirst(o) | offerFirst(o, timeout, timeunit) |
在隊列前端移除元素 | removeFirst(o) | pollFirst(o) | takeFirst(o) | pollFirst(timeout, timeunit) |
獲取對列第一個元素 | getFirst(o) | peekFirst(o) | ||
在隊列尾端插入元素 | addLast(o) | offerLast(o) | putLast(o) | offerLast(o, timeout, timeunit) |
在隊列尾端移除元素 | removeLast(o) | pollLast(o) | takeLast(o) | pollLast(timeout, timeunit) |
獲取對列最後一個元素 | getLast(o) | peekLast(o) |
5.4 同步類工具
同步工具類:可以是任何一個對象,只要它根據其自身的狀態來協調線程的控制流。阻塞隊列可以作爲同步工具類,其他類型的同步工具類還包括信號量(Semaphore)、 柵欄(Barrier)以及閉鎖(Latch)。
所有的同步工具類都包含一些特定的結構化屬性:它們封裝了一些狀態,這些狀態將決定執行同步工具類的線程是繼續執行還是等待,此外還提供了一些方法對狀態進行操作,以及另一些方法用於高效地等待同步工具類進入到預期狀態。
5.4.1 閉鎖
閉鎖:是一種同步工具類,可以延遲線程的進度直到其到達終止狀態。
1.當閉鎖到達結束狀態後將不會再改變狀態,會一直處於打開狀態
2.閉鎖的作用:
- 確保某個計算在其需要的所有資源都被初始化之後才繼續執行。二元閉鎖(包括兩個狀態)可以用來表示“資源R已經被初始化”,而所有需要R的操作都必須先在這個閉鎖上等待。
- 確保某個服務在其依賴的所有其他服務都已經啓動之後才啓動。每個服務都有一個相關的二元閉鎖。當啓動服務S時,將首先在S依賴的其他服務的閉鎖.上等待,在所有依賴的服務都啓動後會釋放閉鎖S,這樣其他依賴S的服務才能繼續執行。
- 等待直到某個操作的所有參與者(例如,在多玩家遊戲中的所有玩家)都就緒再繼續執行。在這種情況中,當所有玩家都準備就緒時,閉鎖將到達結束狀態。
3.CountDownLatch可以使一個或者多個線程等待一組事件發生。它有一個計數器,起始值是一個整數,表示要等待的事件數量。它有一個countDown方法可以遞減計數器,每調用一次就表示一個事件已經發生。還有一個await方法調用時會阻塞到計數器爲0,表示所有事件都已經發生
5.4.2 FutureTask
1.FutureTask也可以用作閉鎖,(FutureTask 實現了Future語義,表示一種抽象的可生成結果的計算)。
2.FutureTask表示的計算是通過Callable來實現的,相當於一種可生成結果的Runnable,並且可以處於以下3種狀態:等待運行(Waiting to run),正在運行(Running)和運行完成(Completed)。 “執行完成”表示計算的所有可能結束方式,包括正常結束、由於取消而結束和由於異常而結束等。當FutureTask進入完成狀態後,它會永遠停止在這個狀態上。
3.Future.get的行爲取決於任務的狀態。如果任務已經完成,那麼get會立即返回結果,否則get將阻塞直到任務進人完成狀態,然後返回結果或者拋出異常。
5.4.3 信號量(Semaphore)
1.計數信號量可以用來實現資源池,或者對容器施加邊界(默認是非公平的)
2.Semaphore中管理着一組虛擬的許可(permit)
- 許可的初始數量可通過構造函數來指定。
- 在執行操作時可以首先通過調用acquire方法獲得許可(只要還有剩餘的許可),並在使用以後調用release方法釋放許可。
- 如果沒有許可,那麼acquire方法將阻塞直到有許可(或者直到被中斷或者操作超時)。
- release方法將返回一個許可給信號量。
3.計算信號量的一種簡化形式是二值信號量,即初始值爲1的Semaphore。二值信號量可以用做互斥體(mutex),並具備不可重入的加鎖語義:誰擁有這個唯一的許可, 誰就擁有了互斥鎖。
5.4.4 柵欄(Barrier)
1.所有線程必須同時到達柵欄位置,才能繼續執行,可以反覆使用。閉鎖是爲了等待事件,是一次性對象。
2.當所有線程都到達了柵欄位置後,就會打開柵欄,所有線程被釋放。柵欄被重置,以便下次使用。
3.CyclicBarrier是柵欄的一種實現,構造方法中parties代表要到達的線程總數,barrierAction是一個Runnable,在通過柵欄時調用(在另一個線程中)。
4.當線程到達柵欄處時,讓線程調用CyclicBarrier中await方法,該方法會阻塞到所有線程到達柵欄處,如果調用超時,或者await阻塞的線程被中斷,那麼柵欄就被認爲是打破了,所有阻塞的await調用都將終止並拋出BrokenBrrierException。如果成功地通過柵欄,在通過柵欄後該方法會爲每個線程返回一個唯一的到達索引,我們可以利用這個唯一的索引來選舉出一個領導線程,並在下一次迭代中由其執行一些特殊操作
5.Exchanger是一個兩方柵欄,可用於兩個線程之間交換信息。可簡單地將Exchanger對象理解爲一個包含兩個格子的容器,通過exchanger方法可以向兩個格子中填充信息。當兩個格子中的均被填充時,該對象會自動將兩個格子的信息交換,然後返回給線程,從而實現兩個線程的信息交換(線程安全的交換)。
第一部分基礎知識小結
1.可變狀態是至關重要的。所有的併發問題都可以歸結爲如何協調對併發狀態的訪問。可變狀態越少,就越容易確保線程安全性。
2.儘量將域聲明爲final 類型,除非需要它們是可變的。
3.不可變對象一定是線程安全的。不可變對象能極大地降低併發編程的複雜性。它們更爲簡單而且安全,可以任意共享而無須使用加鎖或保護性複製等機制。
4.封裝有助於管理複雜性。在編寫線程安全的程序時,雖然可以將所有數據都保存在全局變量中,但爲什麼要這樣做?將數據封裝在對象中,更易於維持不變性條件。將同步機制封裝在對象中,更易於遵循同步策略。
5.用鎖來保護每個可變變量。
6.當保護同一個不變性條件中的所有變量時,要使用同一個鎖。
7.在執行復合操作期間,要持有鎖。
8.如果從多個線程中訪問同一個可變變量時沒有同步機制,那麼程序會出現問題。
9.不要故作聰明地推斷出不需要使用同步。
10.在設計過程中考慮線程安全,或者在文檔中明確地指出它不是線程安全的。
11.將同步策略文檔化。
第二部分結構化併發應用程序
六.任務執行
任務:通常是一些抽象的且離散的工作單元。
通過把應用程序的工作分解到多個任務中,可以簡化程序的組織結構,提供一種自然的事務邊界來優化錯誤恢復過程,以及提供一種自然的並行工作結構來提升併發性。
6.1 在線程中執行任務
1.大多數服務器應用程序都提供了一種自然的任務邊界選擇方式:以獨立的客戶請求爲邊界
2.串行缺點:只能一個一個任務的處理,當一個任務需要很長時間或者阻塞時,其它的請求不能及時的處理。
3.並行優點:
-
任務處理過程從主線程中分離出來,使得主循環能夠更快地重新等待下一個到來的連接。這使得程序在完成前面的請求之前可以接受新的請求,從而提高響應性。
-
任務可以並行處理,從而能同時服務多個請求。如果有多個處理器,或者任務由於某種原因被阻塞,例如等待I/O完成、獲取鎖或者資源可用性等,程序的吞吐量將得到提高。
-
任務處理代碼必須是線程安全的,因爲當有多個任務時會併發地調用這段代碼。在正常負載情況下,爲每個任務分配一個線程的方法能提升串行執行的性能。
4.無限創建線程的不足
-
線程生命週期的開銷非常高。線程的創建與銷燬並不是沒有代價的。如果請求的到達率非常高且請求的處理過程是輕量級的,那麼爲每個請求創建一個新線程將消耗大量的計算資源。
-
資源消耗。活躍的線程會消耗系統資源,尤其是內存。如果可運行的線程數量多於可用處理器的數量,那麼有些線程將閒置。大量空閒的線程會佔用許多內存,給垃圾回收器帶來壓力,而且大量線程在競爭CPU資源時還將產生其他的性能開銷。如果你已經擁有足夠多的線程使所有CPU保持忙碌狀態,那麼再創建更多的線程反而會降低性能。
-
穩定性。在可創建線程的數量上存在一個限制。這個限制值將隨着平臺的不同而不同,並且受多個因素制約,包括JVM的啓動參數、Thread構造函數中請求的棧大小,以及底層操作系統對線程的限制等。如果破壞了這些限制,那麼很可能拋出OutOfMemoryError異常,要想從異常恢復過來是非常危險的,應該通過構造函數來避免超出這些限制
6.2 Executor框架
線程池:是指管理一組同構工作線程的資源池。
1.在線程池中執行任務比爲每個任務分配一個線程優勢更多。通過重用現有的線程而不是創建新線程,可以在處理多個請求時分攤在線程創建和銷燬中產生的開銷。
2.執行策略:通過將任務的提交與執行解耦開來,從而無須太大的困難就可以爲某種類型的任務指定和修改執行策略。在執行策略中定義了任務執行的“What、 Where、When、How”等方面,包括:
- 在什麼(What)線程中執行任務?
- 任務按照什麼(What)順序執行(FIFO、LIFO、優先級) ?
- 有多少個(How Many)任務能併發執行?
- 在隊列中有多少個(How Many)任務在等待執行?
- 如果系統由於過載而需要拒絕一個任務,那麼應該選擇哪一個(Which)任務?另外,如何(How)通知應用程序有任務被拒絕?
- 在執行一個任務之前或之後,應該進行哪些(What)動作?
6.2.1 Executor常用的創建線程池方法
1.newFixedThreadPool。newFixedThreadPool 將創建一個固定長度的線程池,每當提交一個任務時就創建一個線程,直到達到線程池的最大數量,這時線程池的規模將不再變化(如果某個線程由於發生了未預期的Exception而結束,那麼線程池會補充一個新的線程)。
2.newCachedThreadPool。newCachedThreadPool將創建一個可緩存的線程池,如果線程池的當前規模超過了處理需求時,那麼將回收空閒的線程,而當需求增加時,則可以添加新的線程,線程池的規模不存在任何限制。
3.newSingleThreadExecutor。newSingleThreadExecutor 是一個單線程的Executor,它創建單個工作者線程來執行任務,如果這個線程異常結束,會創建另一個線程來替代。newSingleThreadExecutor能確保依照任務在隊列中的順序來串行執行(例如FIFO、LIFO、優先級)。
4.newScheduledThreadPool。newScheduledThreadPool創建了一個固定長度的線程池,而且以延遲或定時的方式來執行任務,類似於Timer。
6.2.2 Executor的生命週期
生命週期有3種狀態:運行、關閉、終止
1.由於Executor以異步的方式來執行任務,因此在任何時刻之前提交的任務狀態不是立即可見的。有些任務可能已經完成,有些可能正在運行,而其他的任務可能在隊列中等待執行。
2.Executor提供了一些用於管理生命週期的方法
-
shutdown。它會平緩的關閉Executor:不在接受新的任務並且會等到已經提交的任務執行結束(包括在隊列中等待的任務)
-
shutdownNow。它會立即關閉Executor:它會嘗試關閉正在執行的任務並且不會執行正在等待的任務。
-
awaitTermination。 阻塞當前線程,直到Executor生命週期達到終止狀態,或者超時及線程被中斷(拋出異常)
6.2.3 延遲任務與週期任務
1.Timer類負責管理延遲任務以及週期任務
2.Timer缺點
-
Timer 在執行所有的定時任務時只會創建一個線程。如果每某個線程執行的時間過長會影響其他線程的精確性。例如:A任務執行時間爲1000ms,而B任務是每100ms執行一次,那麼B任務就是就會在A任務執行完成後連續調用10次或者直接丟失這期間的10次調用
-
如果Timer在執行的過程中拋出一個異常(捕獲的異常不算)那麼該線程被終止,已經被調度但尚未執行的任務不會執行,新提交的任務也不會被調度
3.如果要構建自己的調度服務,那麼可以使用DelayQueue,它實現了BlockingQueue,併爲ScheduledThreadPoolExecutor提供調度功能。DelayQueue 管理着一組Delayed對象。每個Delayed對象都有一個相應的延遲時間:在DelayQueue中,只有某個元素逾期後,才能從DelayQueue中執行take操作。從DelayQueue中返回的對象將根據它們的延遲時間進行排序。
小結
1.通過圍繞任務執行來設計應用程序,可以簡化開發過程,並有助於實現併發。
2.Executor框架將任務提交與執行策略解耦開來,同時還支持多種不同類型的執行策略。當需要創建線程來執行任務時,可以考慮使用Executor。
3.要想在將應用程序分解爲不同的任務時獲得最大的好處,必須定義清晰的任務邊界。某些應用程序中存在着比較明顯的任務邊界,而在其他一些程序中則需要進一步分析才能揭示出粒度更細的並行性。
七.取消與關閉
1.任務和線程的啓動很容易。一般情況下我們不會終止任務,但是在少數情況下我們希望提前結束任務或者線程。(有可能是因爲用戶取消了操作或者應用程序需要被快速關閉)
2.Java並沒有提供任何機制來安全的終止線程。但是提供了中斷,這是一種協作機制,可以使一個線程終止另一個線程當前的工作。
7.1 任務取消
7.1.1 中斷、響應中斷
1.方法:
-
Thread.currentThread().interrupt(); //中斷線程
-
Thread.currentThread().isInterrupted(); //返回線程的中斷狀態
-
Thread.currentThread().interrupted(); //恢復被中斷的線程
2.拋出InterruptedException異常後中斷的線程也會恢復。
3.在拋出InterruptedException異常之前,JVM會先把線程的中斷標識位清除,然後纔會拋出異常,這時如果調用isInterrupted(),將會返回false。
4.如果當前線程在非阻塞的情況下被中斷,它的中斷狀態將會被設置成true,然後根據將被取消的操作來檢查中斷狀態以判斷是否發生了中斷。通過這種方法中斷操作將變得”有粘性“,如果不觸發interruptedException,那麼中斷狀態將一直保持,直到明確的清除中斷狀態
5.中斷操作並不會真正的中斷一個正在運行的線程,它只是發出一個請求由線程自己在一個合適的時刻中斷自己
6.合適的時機:
-
如果該線程處在可中斷狀態下,(調用了xx.wait(),或者Thread.sleep()等特定會發生阻塞的方法),那麼該線程會立即被喚醒,同時會收到一個InterruptedException,如果是阻塞在io上,對應的資源會被關閉。 但是由於在拋出InterruptedException時會將中斷狀態設置爲false,所以程序會繼續執行
-
如果該線程處在不可中斷狀態下,(沒有調用阻塞方法)那麼Java只是設置一下該線程的interrupt狀態爲true,其他事情都不會發生,如果該線程之後會調用阻塞方法,那到時候線程會馬會上跳出,並拋出InterruptedException,接下來的事情就跟第一種狀況一致了。如果不會調用阻塞方法,那麼這個線程就會一直執行下去。除非你就是要實現這樣的線程,一般高性能的代碼中肯定會有wait(),yield()之類讓出cpu的函數,不會發生後者的情況。
7.1.2 中斷策略
中斷策略:規定線程如何解釋某個中斷請求。當發現中斷請求時,應該做哪些工作(如果需要的話),哪些工作單元對於中斷來說是原子操作,以及以多快的速度來響應中斷。
1.最合理的中斷策略是某種形式的線程級(Thread-Level)取消操作或服務級(Service-Level)取消操作。儘快退出,在必要時進行清理,通知某個所有者該線程已經退出。此外還可以建立其他的中斷策略,例如暫停服務或重新開始服務,但對於那些包含非標準中斷策略的線程或線程池,只能用於可以知道這些策略的任務中。
2.區分任務和線程對中斷的反應是很重要的。一箇中斷請求可以有一個或多個接收者;中斷線程池中的某個工作者線程,同時意味着“取消當前任務”和“關閉工作者線程”。
3.當檢查到中斷請求時,任務並不需要放棄所有的操作,它可以推遲處理中斷請求,並直到某個更合適的時刻。因此需要記住中斷請求,並在完成當前任務後拋出InterruptedException或者表示已收到中斷請求。這項技術能夠確保在更新過程中發生中斷時,數據結構不會被破壞。
4.任務不應該對執行該任務的線程的中斷策略做出任何假設,除非該任務被專門設計爲在服務中運行,並且在這些服務中包含特定的中斷策略。無論任務把中斷視爲取消,還是其他某個中斷響應操作,都應該小心地保存執行線程的中斷狀態。
7.1.3 響應中斷
1.當調用可中斷的阻塞函數時,例如Thread.sleep或BlockingQueue.put等,有兩種實用策略可用於處理InterruptedException:
-
傳遞異常(可能在執行某個特定於任務的清除操作之後拋出,或者直接拋出),從而使你的方法也成爲可中斷的阻塞方法。
-
恢復中斷狀態,從而使調用棧中的上層代碼能夠對其進行處理。
2.對於一些不支持取消但仍可以調用可中斷阻塞方法的操作,它們必須在循環中調用這些方法,並在發現中斷後重新嘗試。在這種情況下,它們應該在本地保存中斷狀態,並在返回前恢復狀態而不是在捕獲InterruntedExcention時恢復狀態。
3.thread.join() 在A線程中調用thread的join()方法,A線程會被掛起,直到thread線程的方法執行完。join的有參方法是加了對象鎖的;join有三個重載版本join(long millis, int nanos)、join(long millis)、join()。其中join(long millis)是底層方法,其他兩個都是調用了該方法
7.1.4 通過Future的cancel方法實現取消
- 該方法返回一個boolean,表示取消操作是否成功(只能表示任務是否接受了中斷請求,並不能保證任務接受中斷請求後檢測並處理了中斷);該方法的底層其實是調用了interrupt()方法
2.使用cancel後futureTask無法get出子線程的值,會報CancellationException
3.cancel方法有一個boolean類型的mayInterruptIfRunning參數 根據任務狀態的不同有不同的效果:
-
等待狀態。此時調用cancel()方法不管傳入true還是false都會標記爲取消,任務依然保存在任務隊列中,但當輪到此任務運行時會直接跳過。
-
完成狀態。此時cancel()不會起任何作用,因爲任務已經完成了。
-
運行中。此時傳入true會中斷正在執行的任務,傳入false則不會中斷。
4.Future.cancel(true)適用於:
- 長時間處於運行的任務,並且能夠處理interrupt
5.Future.cancel(false)適用於:
-
未能處理interrupt的任務
-
不清楚任務是否支持取消
-
需要等待已經開始的任務執行完成
7.1.5 處理不可中斷阻塞
1.Java中很多可阻塞方法都是可以通過提前返回或者拋出InterrupteException來響應中斷請求的。但是不是所有的可阻塞方法都能響應中斷;如果一個線程由於執行同步的Socket I/O或者等待獲得內置鎖而阻塞,那麼中斷請求除了設置線程的中斷狀態什麼作用也沒有。
2.不可中斷的阻塞操作:
-
Java.io包中的同步Socket I/O。在服務器應用程序中,最常見的阻塞I/O形式就是對套接字進行讀取和寫人。雖然InputStream和OutputStream中的read和write等方法都不會響應中斷,但通過關閉底層的套接字,可以使得由於執行read或write等方法而被阻塞的線程拋出一個SocketException。
-
Java.io包中的同步I/O。當中斷一個正在InterruptibleChannel.上等待的線程時,將拋出ClosedByInterruptException並關閉鏈路(這還會使得其他在這條鏈路上阻塞的線程同樣拋出ClosedByInterruptException)。當關閉一個InterruptibleChannel時,將導致所有在鏈路操作.上阻塞的線程都拋出AsynchronousCloseException。大多數標準的Channel都實現了InterruptibleChannel.
-
Selector的異步I/O。如果一個線程在調用Selector.select方法(在java.nio.channels中)時阻塞了,那麼調用close或wakeup方法會使線程拋出ClosedSelectorException並提前返回。
-
獲取某個鎖。如果一個線程由於等待某個內置鎖而阻塞,那麼將無法響應中斷,因爲線程認爲它肯定會獲得鎖,所以將不會理會中斷請求。但是,在Lock類中提供了lockInterruptibly方法,該方法允許在等待一個鎖的同時仍能響應中斷。
7.2 停止基於線程的服務
線程的所有者: 創建該線程的類。
1.線程的所有權是不能傳遞的
2.服務中應該提供線程的生命週期方法來關閉它自己及它擁有的線程
3.對於持有線程的服務,只要服務的存在時間大於創建線程的方法的存在時間,那麼就應該提供生命週期方法
7.2.1 關閉生產者-消費者服務下的線程
毒丸對象:是指放在隊列上的對象,當消費者得到這個對象時,立即停止。
爲了保證生產者線程結束後,消費者線程也能在完成所有工作後退出。一般用shutdown或’‘毒丸’'對象來關閉,具體情況如下:
-
在使用ExecutorService來創建消費者線程時(推薦該方法創建消費者線程,這樣我們可以更好的管理消費者的生命週期),一般使用shutdown來關閉線程池,也可以用shutdownNow來關閉但是shutdownNow有侷限性(當通過shutdownNow來強行關閉ExecutorService時,它會嘗試取消正在執行的任務,並返回所有已提交但尚未開始的任務。但是它不能返回那些被取消的執行的任務)
-
使用”毒丸“對象。在先進先出(FIFO)隊列中,"毒丸"將保證消費者在關閉之前已經完成的隊列中的全部工作,並且在生產者提交了”毒丸“後不再提交任何工作。但是隻有在生產者和消費者都已知的情況下,纔可以使用”毒丸“對象;在有多個生產者時,只需要每個生產者都向隊列中放入一個”毒丸“對象,並且消費者僅當接收到全部”毒丸“對象才停止;多個消費者也可以,然而,當生產者和消費者數量較大時,這種方法將變得難以使用;而且只有在無界隊列中,”毒丸“對象才能可靠地工作。
7.3 處理非正常的線程終止
一個線程在拋出未捕獲異常時會直接終止,從而可能影響其它任務的執行。解決辦法如下:
1.在任務中使用try-catch-finally來獲取異常及執行如果拋出異常該做什麼(一般在catch裏面記錄異常,finally裏面將異常傳遞給日誌系統),不要在外部捕獲異常,不然可能導致一些問題的出現,比如異常的時候無法回收一些系統資源,或者沒有關閉當前的連接等等
2.當一個線程由於未捕獲異常而退出時,JVM會把這個事件報告給應用程序提供的UncaughtExceptionHandler異常處理器,該處理器有一個uncaughtException(Thread t, Throwable e)方法,可以在該方法內部嘗試重啓線程、關閉應用程序、或者執行其他方法。如果沒有提供任何異常處理器,那麼默認將棧追蹤信息輸出到System.err。但是隻有通過execute方法提交的任務才能將它拋出的未捕獲異常交給UncaughtExceptionHandler異常處理器,如果是submit提交的任務拋出了未捕獲異常,那麼該線程會結束,異常將會被Futrue.get封裝在ExecutionException中重新拋出
7.4 JVM關閉
線程可分爲兩種:普通線程和守護線程。在JVM啓動時創建的所有線程中,除了主線程以外,其他的線程都是守護線程(例如垃圾回收器以及其他執行輔助工作的線程)。
1.當創建一個新線程時,新線程將繼承創建它的線程的守護狀態,因此在默認情況下,主線程創建的所有線程都是普通線程。
2.普通線程與守護線程之間的差異僅在於當線程退出時發生的操作。當一個線程退出時,JVM會檢查其他正在運行的線程,如果這些線程都是守護線程,那麼JVM會正常退出操作。當JVM停止時,所有仍然存在的守護線程都將被拋棄一既不會執行finally代碼塊,也不會執行回捲棧,而JVM只是直接退出。
3.當我們需要創建一個線程來執行一些輔助工作,但又不希望這個線程阻礙JVM的關閉。在這種情況下我們就需要守護線程
4.JVM關閉的方法:
-
當最後一個非守護線程結束
-
調用了System.exit
-
通過其他特定於平臺的方法關閉。例如發送了SIGINT信號或者鍵入Ctrl+C
-
通過調用Runtime.halt或者在操作系統中殺死JVM進程等強制關閉JVM
7.4.1 關閉鉤子
關閉鉤子:是指通過Runtime.addShutdownHook註冊的但尚未開始的線程。
1.在正常關閉中,JVM首先調用所有已註冊的關閉鉤子。JVM並不能保證關閉鉤子的調用順序。
2.在關閉應用程序線程時,如果有(守護或非守護)線程仍然在運行,那麼這些線程接下來將與關閉進程併發執行。
3.當所有的關閉鉤子都執行結束時,如果runFinalizersOnExit爲true,那麼JVM將運行終結器,然後再停止。
4.JVM並不會停止或中斷任何在關閉時仍然運行的應用程序線程。
5.當JVM最終結束時,這些線程將被強行結束。如果關閉鉤子或終結器沒有執行完成,那正常關閉進程“掛起”並且JVM必須被強行關閉。當被強行關閉時,只是關閉JVM,而不會運行關閉鉤子。
6.關閉鉤子應該是線程安全的:它們在訪問共享數據時必須使用同步機制,並且小心地避免發生死鎖,這與其他併發代碼的要求相同。
7.關閉鉤子不應該對應用程序的狀態(例如,其他服務是否已經關閉,或者所有的正常線程是否已經執行完成)或者JVM的關閉原因做出任何假設,因此在編寫關閉鉤子的代碼時必須考慮周全。最後,關閉鉤子必須儘快退出,因爲它們會延遲JVM的結束時間,而用戶可能希望JVM能儘快終止。
8.關閉鉤子不應該依賴那些可能被應用程序或其他關閉鉤子關閉的服務。實現這種功能的一種方式是對所有服務使用同一個關閉鉤子( 而不是每個服務使用一個不同的關閉鉤子),並且在該關閉鉤子中執行一系列的關閉操作。這確保了關閉操作在單個線程中串行執行,從而避免了在關閉操作之間出現競態條件或死鎖等問題。無論是否使用關閉鉤子,都可以使用這項技術,通過將各個關閉操作串行執行而不是並行執行,可以消除許多潛在的故障。當應用程序需要維護多個服務之間的顯式依賴信息時,這項技術可以確保關閉操作按照正確的順序執行。
9.關閉鉤子可以用於實現服務或應用程序的清理工作,例如刪除臨時文件,或者清除無法由操作系統自動清除的資源。
小結
在任務、線程、服務以及應用程序等模塊中的生命週期結束問題,可能會增加它們在設計和實現時的複雜性。Java並沒有提供某種搶佔式的機制來取消操作或者終結線程。相反,它提供了一種協作式的中斷機制來實現取消操作,但這要依賴於如何構建取消操作的協議,以及能否始終遵循這些協議。通過使用FutureTask和Executor框架,可以幫助我們構建可取消的任務和服務。
八.線程池的使用
8.1 在任務與執行策略之間的隱性耦合
線程飢餓死鎖: 所有正在執行的任務線程由於等待其它仍處於工作隊列中的任務而阻塞的現象被稱爲線程飢餓死鎖(比如在一個任務中將另一個任務提交到同一個Executor)
1.雖然Executor框架爲制定和修改執行策略都提供了相當大的靈活性,但並非所有的任務都能適用所有的執行策略。有些類型的任務需要明確地指定執行策略,包括:
-
依賴性任務。大多數行爲正確的任務都是獨立的:它們不依賴於其他任務的執行時序、執行結果或其他效果。當在線程池中執行獨立的任務時,可以隨意地改變線程池的大小和配置,這些修改只會對執行性能產生影響。然而,如果提交給線程池的任務需要依賴其他的任務,那麼就隱含地給執行策略帶來了約束,此時必須小心地維持這些執行策略以避免產生活躍性問題。
-
使用線程封閉機制的任務。與線程池相比,單線程的Executor能夠對併發性做出更強的承諾。它們能確保任務不會併發地執行,使你能夠放寬代碼對線程安全的要求。對象可以封閉在任務線程中,使得在該線程中執行的任務在訪問該對象時不需要同步,即使這些資源不是線程安全的也沒有問題。這種情形將在任務與執行策略之間形成隱式的耦合。任務要求其執行所在的Executor是單線程的。如果將Executor從單線程環境改爲線程池環境,那麼將會失去線程安全性。
-
對響應時間敏感的任務。GUI應用程序對於響應時間是敏感的:如果用戶在點擊按鈕後需要很長延遲才能得到可見的反饋,那麼他們會感到不滿。如果將一個運行時間較長的任務提交到單線程的Executor中,或者將多個運行時間較長的任務提交到一個只包含少量線程的線程池中,那麼將降低由該Executor管理的服務的響應性。
-
使用ThreadLocal的任務。ThreadLocal 使每個線程都可以擁有某個變量的一個私有“版本”。然而,只要條件允許,Executor 可以自由地重用這些線程。在標準的Executor實現中,當執行需求較低時將回收空閒線程,而當需求增加時將添加新的線程,並且如果從任務中拋出了一個未檢查異常,那麼將用一個新的工作者線程來替代拋出異常的線程。只有當線程本地值的生命週期受限於任務的生命週期時,在線程池的線程中使用ThreadLocal纔有意義,而在線程池的線程中不應該使用ThreadLocal在任務之間傳遞值。
2.在一些任務中,需要擁有或排除某種特定的執行策略。如果某些任務依賴於其他的任務,那麼會要求線程池足夠大,從而確保它們依賴任務不會被放入等待隊列中或被拒絕,而採用線程封閉機制的任務需要串行執行。
8.1.1 設置線程池的大小
線程池的理想大小取決於被提交任務的類型及所部署系統的特性。在代碼中通常不會固定線程的大小,而應該通過某種配置機制來提供,或者根據Runtime.getRuntime().availableProcessors()來動態計算。計算公式: cpu數*cpu利用率*(1+線程等待時間/線程執行時間)
8.2 配置ThreadPoolExecutor
1.ThreadPoolExecutor爲一些Executor提供了基本的實現,這些Executor是由Executors中的newCachedThreadPool、newFixedThreadPool 和newScheduledThreadExecutor等工廠方法返回的。ThreadPoolExecutor 是一個靈活的、穩定的線程池,允許進行各種定製。
2.如果默認的執行策略不能滿足需求,那麼可以通過ThreadPoolExecutor的構造函數來實例化一個對象,並根據自己的需求來定製,並且可以參考Executors的源代碼來了解默認配置下的執行策略,然後再以這些執行策略爲基礎進行修改。
8.2.1 線程的創建與銷燬
1.線程池的基本大小(Core Pool Size)、 最大大小(Maximum Pool Size)以及存活時間等因素共同負責線程的創建與銷燬。
2.基本大小也就是線程池的目標大小,即在沒有任務執行時線程池的大小,並且只有在工作隊列滿了的情況下才會創建超出這個數量的線程目。
3.線程池的最大大小表示可同時活動的線程數量的上限。如果某個線程的空閒時間超過了存活時間,那麼將被標記爲可回收的,並且當線程池的當前大小超過了基本大小時,這個線程將被終止。
4.通過調節線程池的基本大小和存活時間,可以幫助線程池回收空閒線程佔有的資源,從而使得這些資源可以用於執行其他工作。(顯然,這是一種折衷:回收空閒線程會產生額外的延遲,因爲當需求增加時,必須創建新的線程來滿足需求。)
5.newFixedThreadPool工廠方法將線程池的基本大小和最大大小設置爲參數中指定的值,而且創建的線程池不會超時。newCachedThreadPool 工廠方法將線程池的最大大小設置爲Integer.MAX_VALUE,而將基本大小設置爲零,並將超時設置爲1分鐘,這種方法創建出來的線程池可以被無限擴展,並且當需求降低時會自動收縮。其他形式的線程池可以通過顯式的ThreadPoolExecutor構造函數來構造。
8.2.2 管理隊列任務
1.如果無限制地創建線程,那麼將導致不穩定性,可以通過採用固定大小的線程池來解決這個問題。然而當負載很高的情況下,應用程序仍可能耗盡資源。比如當新請求到達的速度超過線程池處理請求的速度,那麼Executor會將這些來不及處理的請求放在其管理的Runnable隊列中等待,而不會像線程那樣去競爭CPU資源。通過一個Runnable和一個鏈表節點來表現一個等待中的任務會比使用線程來表示一個等待中的任務開銷低很多,但是如果請求的速率超過了服務器的處理速率,那麼仍然有可能會耗盡資源
2.newFixedThreadPool和newSingleThreadExecutor在默認情況小使用的是一個無界的LinkedBlockingQueue。如果所有工作線程都處於忙碌狀態,那麼任務將在隊列中等候。如果任務持續快速的到達,並且超過了線程池處理它們的速度,那麼隊列將無限的增加
3.在newCachedThreadPool中使用的是SynchhronousQueue隊列,它並不是一個真正的隊列,而是一種在線程之間進行任務移交的機制,使用直接它可以避免任務排隊。要將一個元素放入其中必須有一個線程正在等待接受這個任務。如果沒有線程正在等待,並且線程池的當前大小小於最大值,那麼ThreadPoolExecutor將創建一個新的線程,否則根據飽和策略,這個任務將被拒絕。
4.只有當任務相互獨立時,爲線程池或工作隊列設置界限纔是合理的。如果任務之間存在依賴性,那麼有界的線程池或隊列就可能導致線程“飢餓”死鎖問題。此時應該使用無界的線程池,例如newCachedThreadPool。
8.2.3 飽和策略
1.當有界隊列被填滿後,飽和策略開始發揮作用ThreadPoolExecutor的飽和策略可以通過調用setRejectedExecutionHandler來修改。( 如果某個任務被提交到一個已被關閉的Executor時,也會用到飽和策略。)
2.JDK提供了幾種不同的RejectedExecutionHandler實現,每種實現都包含有不同的飽和策略:AbortPolicy(默認飽和策略,中止策略)、CallerRunsPolicy(調用者運行策略)、DiscardPolicy(拋棄策略)、DiscardOldestPolicy(拋棄最舊的策略)
-
“中止(Abort)”策略是默認的飽和策略,該策略將拋出未檢查的RejectedExecution-Exception。調用者可以捕獲這個異常,然後根據需求編寫自己的處理代碼。
-
當新提交的任務無法保存到隊列中等待執行時,“拋棄( Discard)”策略會悄悄拋棄該任務。
-
“拋棄最舊的(Discard-Oldest)”策略則會拋棄下一個將被執行的任務,然後嘗試重新提交新的任務。(如果工作隊列是一個優先隊列,那麼“拋棄最舊的”策略將導致拋棄優先級最高的任務,因此最好不要將“拋棄最舊的”飽和策略和優先級隊列放在一起使用。)
-
“調用者運行( Caller-Runs)”策略實現了-種調節機制,該策略既不會拋棄任務,也不會拋出異常,而是將某些任務回退到調用者,從而降低新任務的流量。它不會在線程池的某個線程中執行新提交的任務,而是在一個調用了execute的線程中執行該任務。
3.當工作隊列被填滿後,沒有預定義的飽和策略來阻塞execute。然而,通過使用Semaphore(信號量)來限制任務的到達率,就可以實現飽和策略這個功能。如果線程池使用一個無界隊列(因爲不能限制隊列的大小和任務的到達率),將信號量的上界設置爲線程池的大小。這樣當沒有空閒線程時任務就會在線程池隊列中等待
8.2.4 線程工廠
如果在應用程序中需要利用安全策略來控制對某些特殊代碼庫的訪問權限,那麼可以通過Executor中的privilegedThreadFactory工廠來定製自己的線程工廠。通過這種方式創建出來的線程,將與創建privilegedThreadFactory的線程擁有相同的訪問權限、AccessControlContext 和contextClassLoader。如果不使用privilegedThreadFactory,線程池創建的線程將從在需要新線程時調用execute或submit的客戶程序中繼承訪問權限,從而導致令人困惑的安全性異常
8.3 擴展ThreadPoolExecutor
1.ThreadPoolExecutor是可擴展的,它提供了幾個可以在子類化中改寫的方法: beforeExecute、afterExecute和terminated,這些方法可以用於擴展ThreadPoolExecutor的行爲。
2.在執行任務的線程中將調用beforeExecute和afterExecute等方法,在這些方法中還可以添加日誌、計時、監視或統計信息收集的功能。無論任務是從run中正常返回,還是拋出一個異常而返回,afterExecute 都會被調用。(如果任務在完成後帶有一個Error,那麼就不會調用afterExecute.)如果beforeExecute拋出一個RuntimeException,那麼任務將不被執行,並且afterExecute也不會被調用。
3.在線程池完成關閉操作時調用terminated,也就是在所有任務都已經完成並且所有工作者線程也已經關閉後。terminated可以用來釋放Executor在其生命週期裏分配的各種資源,此外還可以執行發送通知、記錄日誌或者收集finalize統計信息等操作。
小結
對於併發執行的任務,Executor框架是一種強大且靈活的框架。它提供了大量可調節的選項,例如創建線程和關閉線程的策略,處理隊列任務的策略,處理過多任務的策略,並且提供了幾個鉤子方法來擴展它的行爲。然而,與大多數功能強大的框架一樣,其中有些設置參數並不能很好地工作,某些類型的任務需要特定的執行策略,而一些參數組合則可能產生奇怪的結果。
第三部分活躍性、性能與併發開發注意事項
十.避免活躍性危險
在安全性與活躍性之間通常存在着某種制衡。我們使用加鎖機制來確保線程安全,但如果過度地使用加鎖,則可能導致鎖順序死鎖。同樣,我們使用線程池和信號量來限制對資源的使用,但這些被限制的行爲可能會導致資源死鎖。Java應用程序無法從死鎖中恢復過來,因此在設計時一定要排除那些可能導致死鎖出現的條件。
10.1 死鎖
死鎖:線程A持有一個鎖L並想獲得鎖M的同時,線程B持有M鎖並嘗試獲得L,那麼這兩個線程將永遠地等待下去。這種情況是最常見的死鎖形式(抱死)
在數據庫系統的設計中考慮了監測死鎖以及從死鎖中恢復。
- 在執行一個事務時可能會獲取多個鎖,並一直持有鎖直到事務提交。所以在兩個事務之間很可能會發生死鎖,但是這種情況並不多見,而且數據庫服務器不會讓這種情況發生,當它監測到一組事務發生死鎖時,它將會選擇一個犧牲者並放棄這個事務。作爲犧牲者的事務會釋放它所持有的資源,從而使其他事務繼續進行。
10.1.1 鎖順序死鎖
兩個線程試圖以不同的順序來獲得相同的鎖,就會產生死鎖。如果按照相同的順序來請求鎖,那麼就不會出現循環的加鎖依賴性,也就不會產生死鎖
10.1.2 在協作對象之間發生死鎖
如果一個方法在持有鎖時調用某個外部方法,那麼將會出現活躍性問題。在這個外部方法中可能會獲取其他的鎖,從而可能會導致死鎖或者阻塞時間過長,導致其他線程無法及時獲得當前被持有的鎖
10.1.3 開放調用
開放調用:如果A方法在調用B方法時不需要持有一個鎖,那麼這種調用被稱爲開放調用。開放調用的類通常能表現出更好的行爲,並且與那些在調用方法時需要持有鎖的類相比,也更易於編寫。
1.通過儘可能地使用開放調用,將更易於找出那些需要獲得多個鎖的代碼路徑,因此也就更容易確保採用一致的順序來獲得鎖
2.在程序中應儘量使用開放調用。與那些在持有鎖時調用外部方法的程序相比,依賴於開放調用的程序更易於進行死鎖分析
10.1.4 資源死鎖
1.正如當多個線程相互持有彼此等待的鎖且不釋放自己的鎖時會發生死鎖一樣,當它們在相同資源集合上等待時,也會發生死鎖
2.線程飢餓死鎖就是一種資源死鎖的一種表現形式
3.相互依賴的任務不能共用一個有界線程池(資源池)
10.2 死鎖的避免與診斷
1.如果一個程序每次至多隻能獲得一個鎖,那麼就不會產生順序死鎖的問題,但是這種情況通常並不現實,但如果能夠實現每次只獲得一個鎖,那麼就能省去很多的工作。
2.如果必須獲得多個鎖,那麼在設計時必須考慮鎖的順序:儘量的減少潛在的加鎖交互數量,將獲得鎖時需要遵守的協議寫入正式的文檔並始終遵循這些協議
3.儘可能使用開放調用
10.2.1 支持定時的鎖
1.顯式使用Lock類中的定時tryLock功能來代替內置鎖機制。當使用內置鎖時,只要沒有獲得鎖,就會永遠等待下去,而顯式鎖則可以指定一個超時時限(Timeout),在等待超過該時間後tryLock會返回一個失敗信息。如果超時時限比獲取鎖的時間要長很多,那麼就可以在發生某個意外情況後重新獲
得控制權。
2.即使在整個系統中沒有始終使用定時鎖,使用定時鎖來獲取多個鎖也能有效地應對死鎖問題。如果在獲取鎖時超時,那麼可以釋放這個鎖,然後後退並在一段時間後再次嘗試,從而消除了死鎖發生的條件,使程序恢復過來。(這項技術只有在同時獲取兩個鎖時纔有效,如果在嵌套的方法調用中請求多個鎖,那麼即使你知道已經持有了外層的鎖,也無法釋放它。)
10.2.2 通過線程轉儲信息來分析死鎖
1.雖然防止死鎖的主要責任在於你自己,但JVM仍然通過線程轉儲(ThreadDump)來幫助識別死鎖的發生。線程轉儲包括各個運行中的線程的棧追蹤信息,這類似於發生異常時的棧追蹤信息。線程轉儲還包含加鎖信息,例如每個線程持有了哪些鎖,在哪些棧幀中獲得這些鎖,以及被阻塞的線程正在等待獲取哪一個鎖。在生成線程轉儲之前,JVM將在等待關係圖中通過搜索循環來找出死鎖。如果發現了一個死鎖,則獲取相應的死鎖信息,例如在死鎖中涉及哪些鎖和線程,以及這個鎖的獲取操作位於程序的哪些位置。
2.如果使用顯式的Lock類而不是內部鎖,那麼Java5.0並不支持與Lock相關的轉儲信息,在線程轉儲中不會出現顯式的Lock。雖然Java6中包含對顯式Lock的線程轉儲和死鎖檢測等的支持,但在這些鎖上獲得的信息比在內置鎖上獲得的信息精確度低。內置鎖與獲得它們所在,的線程棧幀是相關聯的,而顯式的Lock只與獲得它的線程相關聯。
10.3 其他活躍性危險
儘管死鎖是最常見的活躍性危險,但在併發程序中還存在一些其他活躍性危險,包括:飢餓、丟失信號和活鎖等
10.3.1 飢餓
飢餓:當線程由於無法訪問它所需要的資源而不能繼續執行時,就發生了“飢餓”。
1.引發飢餓的最常見資源就是CPU時鐘週期。如果在Java應用程序中對線程的優先級使用不當,或者在持有鎖時執行一些無法結束的結構(例如無限循環,或者無限制地等待某個資源),那麼也可能導致飢餓,因爲其他需要這個鎖的線程將無法得到它。
2.在Thread API中定義的線程優先級只是作爲線程調度的參考。在Thread API中定義了10個優先級,JVM根據需要將它們映射到操作系統的調度優先級。這種映射是與特定平臺相關的,因此在某個操作系統中兩個不同的Java優先級可能被映射到同一個優先級,而在另一個操作系統中則可能被映射到另一個不同的優先級。在某些操作系統中,如果優先級的數量少於10個,那麼有多個Java優先級會被映射到同一個優先級。
3.操作系統的線程調度器會盡力提供公平的、活躍性良好的調度,甚至超出Java語言規範的需求範圍。在大多數Java應用程序中,所有線程都具有相同的優先級Thread.NORM_PRIORITY。線程優先級並不是一種直觀的機制,而通過修改線程優先級所帶來的效果通常也不明顯。當提高某個線程的優先級時,可能不會起到任何作用,或者也可能使得某個線程的調度優先級高於其他線程,從而導致飢餓。
4.要避免使用線程優先級,因爲這會增加平臺依賴性,並可能導致活躍性問題。你經常能發現某個程序會在一些奇怪的地方調用Thread.sleep或Thread.yield,這是因爲該程序試圖克服優先級調整問題或響應性問題,並試圖讓低優先級的線程執行更多的時間。在大多數併發應用程序中,都可以使用默認的線程優先級。
10.3.2 糟糕的響應性
不良的鎖管理可能導致糟糕的響應性。如果某個線程長時間佔有一個鎖(或許正在對一個大容器進行迭代,並且對每個元素進行計算密集的處理),而其他想要訪問這個容器的線程就必須等待很長時間。
10.3.3 活鎖
活鎖:當多個相互協作的線程都對彼此進行響應從而修改各自的狀態,並使得任何一個線程都無法繼續執行時,就發生了活鎖
1.活鎖是另一種形式的活躍性問題,該問題儘管不會阻塞線程,但也會導致程序不能繼續執行。因爲線程將不斷重複執行相同的操作,而且總會失敗。
2.活鎖通常發生在處理事務消息的應用程序中:如果不能成功的處理某個消息,那麼消息處理機制將回滾整個事務,並將它重新放到隊列的開頭。如果消息處理器在處理某種特定類型的消息時存在錯誤並導致它失敗,那麼每當這個消息從隊列中取出並傳遞到存在錯誤的處理器時,都會發生事務回滾。由於這條消息又被放回到隊列開頭,因此處理器將被反覆調用,並返回相同的結果。(有時候也被稱爲毒藥消息,Poison Message。)雖然處理消息的線程並沒有陽塞,但也無法繼續執行下去。這種形式的活鎖通常是由過度的錯誤恢復代碼造成的,因爲它錯誤地將不可修復的錯誤作爲可修復的錯誤。
3.活鎖就像兩個過於禮貌的人在半路上面對面地相遇:他們彼此都讓出對方的路,然而又在另一條路上相遇了。因此他們就這樣反覆地避讓下去。要解決這種活鎖問題,需要在重試機制中引入隨機性。例如,在網絡上,如果兩臺機器嘗試使用相同的載波來發送數據包,那麼這些數據包就會發生衝突。這兩臺機器都檢查到了衝突,並都在稍後再次重發。如果二者都選擇了在1秒鐘後重試,那麼它們又會發生衝突,並且不斷地衝突下去,因而即使有大量閒置的帶寬,也無法使數據包發送出去。爲了避免這種情況發生,需要讓它們分別等待一段隨機的時間。(以太協議定義了在重複發生衝突時採用指數方式回退機制,從而降低在多臺存在衝突的機器之間發生擁塞和反覆失敗的風險。)在併發應用程序中,通過等待隨機長度的時間和回退可以有效地避免活鎖的發生。
小結
1.活躍性故障是一個非常嚴重的問題,因爲當出現活躍性故障時,除了中止應用程序之外沒有其他任何機制可以幫助從這種故障時恢復過來。最常見的活躍性故障就是鎖順序死鎖。
2.在設計時應該避免產生鎖順序死鎖:確保線程在獲取多個鎖時採用一致的順序。最好的解決方法是在程序中始終使用開放調用。這將大大減少需要同時持有多個鎖的地方,也更容易發現這些地方。
十一.性能與可伸縮性
線程的主要目的是提高程序的運行性能。線程可以充分利用系統的可用處理能力,從而提高系統資源的利用率。此外,線程還可以使程序在運行現有任務的情況下立即開始處理新的任務,從而提高系統的響應性
11.1 對性能的思考
資源密集型:當操作性能由於某種特定的資源而受到限制時,我們通常將該操作稱爲資源密集型的操作,例如,CPU密集型、數據庫密集型等。CPU消耗少
計算密集型:要進行大量的計算,消耗CPU資源
要想通過併發來獲得更好的性能,需要努力做好兩件事情:更有效地利用現有處理資源,以及在出現新的處理資源時使程序儘可能地利用這些新資源。從性能監視的視角來看,CPU需要儘可能保持忙碌狀態。(當然,這並不意味着將CPU時鐘週期浪費在一些無用的計算上,而是執行一些有用的工作。)如果程序是計算密集型的,那麼可以通過增加處理器來提高性能。因爲如果程序無法使現有的處理器保持忙碌狀態,那麼增加再多的處理器也無濟於事。通過將應用程序分解到多個線程上執行,使得每個處理器都執行一些工作,從而使所有CPU都保持忙碌狀態。
11.1.1 性能與可伸縮性
可伸縮性:當增加計算資源時(比如CPU、內存、存儲容量或I/O帶寬),程序的吞吐量或者處理能力能相應地增加
1.應用程序的性能可以採用多個指標來衡量,例如服務時間、延遲時間、吞吐率、效率、可伸縮性以及容量等。
2.服務時間、等待時間一般用於衡量程序的“運行速度”,即某個指定的任務單元需要多快才能處理完。
3.生產量、吞吐量用於程序的處理能力,即在計算資源一定的情況下能完成多少工作
4.性能的這兩個方面“多快”和“多少”,是完全獨立的,有時候甚至是相互矛盾的。要實現更高的可伸縮性或硬件利用率,通常會增加各個任務所要處理的工作量,例如把任務分解爲多個“流水線”子任務時。具有諷刺意味的是,大多數提高單線程程序性能的技術,往往
都會破壞可伸縮性。
5.對於服務器應用程序來說,”多少“比“多快”往往更受重視。通常會很接受每個工作單元執行更長的時間或消耗更多的計算資源,以換取應用程序在增加更多資源的情況下處理更高的負載
11.2 Amdahl定律
加速比:是同一個任務在單處理器系統和並行處理器系統中運行消耗的時間的比率,用來衡量並行系統或程序並行化的性能和效果
1.在有些問題中,如果可用資源越多,那麼問題的解決速度就越快。例如,如果參與收割莊稼的工人越多,那麼就能越快地完成收割工作。而有些任務本質上是串行的,例如,即使增加再多的工人也不可能增加作物的生長速度。如果使用線程主要是爲了發揮多個處理器的處理能力,那麼就必須對問題進行合理的並行分解,並使得程序能有效地使用這種潛在的並行能力。
2.大多數併發程序都與農業耕作有着許多相似之處,它們都是由一系列的並行工作和串行工作組成的。Amdahl定律描述的是:在增加計算資源的情況下,程序在理論上能夠實現最高加速比,這個值取決於程序中可並行組件與串行組件所佔的比重。假定F是必須被串行執行的部分,那麼根據Amdahl定律,在包含N個處理器的機器中,最高的加速比爲:
S<=1/(F+(1-F)/N)
3.當N趨近無窮大時,最大的加速比趨近於1/F。因此,如果程序有50%的計算需要串行執行,那麼最高的加速比只能是2 (而不管有多少個線程可用);如果在程序中有10%的計算需要串行執行,那麼最高的加速比將接近10。Amdahl 定律還量化了串行化的效率開銷。在擁有10個處理器的系統中,如果程序中有10%的部分需要串行執行,那麼最高的加速比爲5.3(53%的使用率),在擁有100個處理器的系統中,加速比可以達到9.2 (9%的使用率)。即使擁有無限多的CPU,加速比也不可能爲10。
11.2.1 上下文切換
1.切換上下文需要一定的開銷,而在線程調度過程中需要訪問由操作系統和JVM共享的數據結構。應用程序、操作系統以及JVM都使用一組相同的CPU。在JVM和操作系統的代碼中消耗越多的CPU時鐘週期,應用程序的可用CPU時鐘週期就越少。但上下文切換的開銷並不只是包含JVM和操作系統的開銷。當一個新的線程被切換進來時,它所需要的數據可能不在當前處理器的本地緩存中,因此上下文切換將導致一些緩存缺失,因而線程在首次調度運行時會更加緩慢。這就是爲什麼調度器會爲每個可運行的線程分配一個最小執行時間,即使有許多其他的線程正在等待執行:它將上下文切換的開銷分攤到更多不會中斷的執行時間上,從而提高整體的吞吐量(以損失響應性爲代價)。
2.當線程由於等待某個發生競爭的鎖而被阻塞時,JVM通常會將這個線程掛起,並允許它被交換出去。如果線程頻繁地發生阻塞,那麼它們將無法使用完整的調度時間片。在程序中發生越多的阻塞(包括阻塞I/O,等待獲取發生競爭的鎖,或者在條件變量.上等待),與CPU密集型的程序就會發生越多的,上下文切換,從而增加調度開銷,並因此而降低吞吐量。(無阻塞算法在一定程度上會有助於減少上下文切換)
3.在大多數處理器中,上下文的開銷相當於5000-10000個時鐘週期,也就是幾微妙
11.2.2 內存同步
1.同步操作的性能開銷包括多個方面。在synchronized和volatile提供的可見性保證中可能會使用一些特殊指令,即內存柵欄(Memory Barrier)。內存柵欄可以刷新緩存,使緩存無效, 刷新硬件的寫緩衝,以及停止執行管道。內存柵欄可能同樣會對性能帶來間接的影響,因爲它們將抑制一些編譯器優化操作。在內存柵欄中,大多數操作都是不能被重排序的。
2.在評估同步操作帶來的性能影響時,區分有競爭的同步和無競爭的同步非常重要。synchronized機制針對無競爭的同步進行了優化(volatile 通常是非競爭的),一個“快速通道(Fast-Path)”的非競爭同步將消耗20~250個時鐘週期。雖然無競爭同步的開銷不爲零,但它對應用程序整體性能的影響微乎其微。
3.現代的JVM能通過優化來去掉一些不會發生競爭的鎖,從而減少不必要的同步開銷。如果一個鎖對象只能由當前線程訪問,那麼JVM就可以通過優化來去掉這個鎖獲取操作,因爲另一個線程無法與當前線程在這個鎖上發生同步
4.某個線程中的同步可能會影響其他線程的性能。同步會增加共享內存總線上的通信量,總線的帶寬是有限的,並且所有的處理器都將共享這條總線。如果有多個線程競爭同步帶寬,那麼所有使用了同步的線程都會受到影響
11.2.3 阻塞
1.非競爭的同步可以完全在JVM中進行處理,而競爭的同步可能需要操作系統的介人,從而增加開銷。當在鎖上發生競爭時,競爭失敗的線程肯定會阻塞。JVM在實現阻塞行爲時,可以採用自旋等待(Spin-Waiting, 指通過循環不斷地嘗試獲取鎖,直到成功)或者通過操作系統掛起被阻塞的線程。這兩種方式的效率高低,要取決於上下文切換的開銷以及在成功獲取鎖之前需要等待的時間。如果等待時間較短,則適合採用自旋等待方式,而如果等待時間較長,則適合採用線程掛起方式。有些JVM將根據對歷史等待時間的分析數據在這兩者之間進行選擇,但是大多數JVM在等待鎖時都只是將線程掛起。
2.當線程無法獲取某個鎖或者由於在某個條件等待或在I/O操作上阻塞時,需要被掛起,在這個過程中將包含兩次額外的上下文切換,以及所有必要的操作系統操作和緩存操作:被阻塞的線程在其執行時間片還未用完之前就被交換出去,而在隨後當要獲取的鎖或者其他資源可用時,又再次被切換回來。(由於鎖競爭而導致阻塞時,線程在持有鎖時將存在一定的開銷:當它釋放鎖時,必須告訴操作系統恢復運行阻塞的線程。)
11.3 減少鎖的競爭
1.串行操作會減低可伸縮性,上下文切換也會減低性能。當鎖上發生競爭時將同時導致這兩種問題,因此減少鎖的競爭能夠提高性能和可伸縮性。
2.在併發程序中,對可伸縮性的最主要威脅就是獨佔方式的資源鎖(代碼會串行執行)
3.有兩個因素將影響在鎖上發生競爭的可能性:鎖的請求頻率,以及每次持有該鎖的時間。如果二者的乘積很小,那麼大多數獲取鎖的操作都不會發生競爭,因此在該鎖上的競爭不會對可伸縮性造成嚴重影響。然而,如果在鎖上的請求量很高,那麼需要獲取該鎖的線程將被阻塞並等待。在極端情況下,即使仍有大量工作等待完成,處理器也會被閒置。
4.有三種方式可以降低鎖的競爭程度
- 1.減少鎖的持有時間
- 2.降低鎖的請求頻率
- 3.使用帶有協調機制的獨佔鎖,這些機制允許更高的併發性
11.3.1 縮小鎖的範圍(快進快出,減少鎖的持有時間)
1.降低發生競爭可能性的一種有效方式就是儘可能的縮短鎖的持有時間。可以將一些與鎖無關的代碼移出同步代碼塊,尤其是那些開銷比較大的操作,以及可能被阻塞的操作(比如I/O操作)
2.儘管縮小同步代碼塊能提高可伸縮性,但同步代碼塊也不能過小一-些需要採用原子方式執行的操作(例如對某個不變性條件中的多個變量進行更新)必須包含在一個同步塊中。此外,同步需要一定的開銷,當把一個同步代碼塊分解爲多個同步代碼塊時(在確保正確性的情況下),反而會對性能提升產生負面影響。在分解同步代碼塊時,理想的平衡點將與平臺相關,但在實際情況中,僅當可以將一些“大量”的計算或阻塞操作從同步代碼塊中移出時,才應該考慮同步代碼塊的大小(如果JVM執行鎖粒度粗化操作,那麼可能會將分解的同步代碼塊又重新合併起來)。
11.3.2 減少鎖的粒度
1.另一種減小鎖的持有時間的方式是降低線程請求鎖的頻率(從而減小發生競爭的可能性)。這可以通過鎖分解和鎖分段等技術來實現,在這些技術中將採用多個相互獨立的鎖來保護獨立的狀態變量,從而改變這些變量在之前由單個鎖來保護的情況。這些技術能減小鎖操作的粒度,並能實現更高的可伸縮性,然而,使用的鎖越多,那麼發生死鎖的風險也就越高。
2.如果一個鎖需要保護多個相互獨立的狀態變量,那麼可以將這個鎖分解爲多個鎖,並且每個鎖只保護一個變量,從而提高可伸縮性,並最終降低每個鎖被請求的頻率。
3.如果在鎖上存在適中而不是激烈的競爭時,通過將一個鎖分解爲兩個鎖,能最大限度地提升性能。如果對競爭並不激烈的鎖進行分解,那麼在性能和吞吐量等方面帶來的提升將非常有限,但是也會提高性能隨着競爭提高而下降的拐點值。對競爭適中的鎖進行分解時,實際上是把這些鎖轉變爲非競爭的鎖,從而有效地提高性能和可伸縮性。
4.每個性能安全的set都會使用一個不同的鎖來保護其狀態
11.3.3 鎖分段
1.把一個競爭激烈的鎖分解爲兩個鎖時,這兩個鎖可能都存在激烈的競爭。雖然採用兩個線程併發執行能提高一部分可伸縮性,但在一個擁有多個處理器的系統中,仍然無法給可伸縮性帶來極大的提高。
2.在某些情況下,可以將鎖分解技術進一步擴展爲對一組獨立對象上的鎖進行分解,這種情況被稱爲鎖分段。例如,在ConcurrentHashMap的實現中使用了一個包含16個鎖的數組,每個鎖保護所有散列桶的1/16,其中第N個散列桶由第(N mod 16)個鎖來保護。假設散列函數具有合理的分佈性,並且關鍵字能夠實現均勻分佈,那麼這大約能把對於鎖的請求減少到原來的1/16。正是這項技術使得ConcurrentHashMap能夠支持多達16 個併發的寫入器。(要使得擁有大量處理器的系統在高訪問量的情況下實現更高的併發性,還可以進一步增加鎖的數量,但僅當你能證明併發寫入線程的競爭足夠激烈並需要突破這個限制時,才能將鎖分段的數量超過默認的16個。)
3.鎖分段的一個劣勢在於:與採用單個鎖來實現獨佔訪問相比,要獲取多個鎖來實現獨佔訪問將更加困難並且開銷更高。通常,在執行一個操作時最多隻需獲取一個鎖,但在某些情況下需要加鎖整個容器,例如當ConcurrentHashMap需要擴展映射範圍,以及重新計算鍵值的散列值要分佈到更大的桶集合中時,就需要獲取分段所集合中所有的鎖。
11.3.4 避免熱點域
1.鎖分解和鎖分段技術都能提高可伸縮性,因爲它們都能使不同的線程在不同的數據(或者同一個數據的不同部分),上操作, 而不會相互干擾。如果程序採用鎖分段技術,那麼一定要表現出在鎖上的競爭頻率高於在鎖保護的數據上發生競爭的頻率。如果一個鎖保護兩個獨立變量X和Y,並且線程A想要訪問X,而線程B想要訪問Y,那麼這兩個線程不會在任何數據上發生競爭,即使它們會在同一個鎖上發生競爭。
2.當每個操作都請求多個變量時,鎖的粒度將很難降低。這是在性能與可伸縮性之間相互制衡的另一個方面,一些常見的優化措施,例如將一些反覆計算的結果緩存起來,都會引入一些“熱點域(Hot Field)”,而這些熱點域往往會限制可伸縮性。當實現HashMap時,你需要考慮如何在size方法中計算Map中的元素數量。最簡單的方法就是,在每次調用時都統計一次元素的數量。一種常見的優化措施是,在插入和移除元素時更新一個計數器,雖然這在put和remove等方法中略微增加了一些開銷,以確保計數器是最新的值,但這將把size方法的開銷從O(n)降低到0(1)。
3.在單線程或者採用完全同步的實現中,使用一個獨立的計數能很好地提高類似size和isEmpty這些方法的執行速度,但卻導致可伸縮性問題,因爲每個修改map的操作都需要更新這個共享的計數器。即使使用鎖分段技術來實現散列鏈,那麼在對計數器的訪問進行同步時,也會重新導致在使用獨佔鎖時存在的可伸縮性問題。一個看似性能優化的措施(緩存size操作的結果)已經變成了一個可伸縮性問題。在這種情況下,計數器也被稱爲熱點域,因爲每個導致元素數量發生變化的操作都需要訪問它。
4.ConcurrentHashMap 中的size將對每個分段進行枚舉並將每個分段中的元素數量相加,而不是維護一個全局計數。爲了避免枚舉每個元素,ConcurrentHashMap 爲每個分段都維護了一個獨立的計數,並通過每個分段的鎖來維護這個值。
11.3.5 一些替代獨佔鎖的方法
1.降低競爭鎖的影響的技術還有一種方式就是放棄使用獨佔鎖,從而有助於使用-種友好併發的方式來管理共享狀態。例如,使用併發容器、讀-寫鎖、不可變對象以及原子變量。
2.原子變量提供了一種方式來降低更新”熱點域“時的開銷,例如靜態計數器、序列發生器、或者對鏈表數據結構中頭節點的引用。原子變量類提供了在整數或者對象引用上的細粒度原子操作,並使用了現代處理器中提供的底層併發原語。如果在類中只包含少量的熱點域並且不會與其他變量參與到不變性條件中,那麼用原子變量來替代他們能提高可伸縮性,但是不能完全消除
11.3.6 監測CPU的利用率
如果CPU沒有得到充分的利用,那麼需要找出其中的原因,通常有以下幾種原因:
-
負載不充足。測試的程序中可能沒有足夠多的負載,因而可以在測試時增加負載,並檢查利用率、響應時間和服務時間等指標的變化。如果產生足夠多的負載使應用程序達到飽和,那麼可能需要大量的計算機能耗,並且問題可能在於客戶端系統是否具有足夠的能力,而不是被測試系統。
-
I/O密集。可以通過iostat或perfmon來判斷某個應用程序是否是磁盤I/O密集型的,或者通過監測網絡的通信流量級別來判斷它是否需要高帶寬。
-
外部限制。如果應用程序依賴於外部服務,例如數據庫或Web服務,那麼性能瓶頸可能並不在你自己的代碼中。可以使用某個分析工具或數據庫管理工具來判斷在等待外部服務的結果時需要多少時間。
-
鎖競爭。使用分析工具可以知道在程序中存在何種程度的鎖競爭,以及在哪些鎖上存在“激烈的競爭”。然而,也可以通過其他一些方式來獲得相同的信息,例如隨機取樣,觸發一些線程轉儲並在其中查找在鎖上發生競爭的線程。如果線程由於等待某個鎖而被阻塞,那麼在線程轉儲信息中將存在相應的棧幀,其中包含的信息形如“waiting to lock monito…"。非競爭的鎖很少會出現在線程轉儲中,而對於競爭激烈的鎖,通常至少會有一個線程在等待獲取它,因此將在線程轉儲中頻繁出現。
11.4 減少上下文切換的開銷
1.在許多任務中都包含一些可能被阻塞的操作。當任務在運行和阻塞這兩個狀態之間轉換時,就相當於一次上下文切換。
2.將可能被阻塞並且不會對結果產生影響的操作(比如打印日誌)封裝到其他線程可以降低服務時間(一個操作完成的時間),從而提高吞吐量
小結
由於使用線程常常是爲了充分利用多個處理器的計算能力,因此在併發程序性能的討論中,通常更多地將側重點放在吞吐量和可伸縮性上,而不是服務時間。Amdah{定律告訴我們,程序的可伸縮性取決於在所有代碼中必須被串行執行的代碼比例。因爲Java程序中串行操作的主要來源是獨佔方式的資源鎖,因此通常可以通過以下方式來提升可伸縮性:減少鎖的持有時間,降低鎖的粒度,以及採用非獨佔的鎖或非阻塞鎖來代替獨佔鎖。
十二.併發開發注意事項
1.不一致的同步。許多對象遵循的同步策略是,使用對象的內置鎖來保護所有變量。如果某個域被頻繁地訪問,但並不是在每次訪問時都持有相同的鎖,那麼這就可能表示沒有一致地遵循這個同步策略。
2.調用Thread.run。在Thread中實現了Runnable,因此包含了一個run方法。然而,如果直接調用Thread.run,那麼通常是錯誤的,而應該調用Thread. start。
3.未被釋放的鎖。與內置鎖不同的是,執行控制流在退出顯式鎖的作用域時,通常不會自動釋放它們。標準的做法是在一個finally 塊中釋放顯式鎖,否則,當發生Exception事件時,鎖仍然處於未被釋放的狀態。
4.空的同步塊。雖然在Java內存模型中,空同步塊具有一定的語義,但它們總是被不正確地使用,無論開發人員嘗試通過空同步塊來解決何種問題,通常都存在一些更好的解決方案。
5.雙重檢查加鎖。雙重檢查加鎖是一種錯誤的習慣用法,其初衷是爲了降低延遲初始化過程中的同步開銷,該用法在讀取一個共享的可變域時缺少正確的同步。
6.在構造函數中啓動一個線程。如果在構造函數中啓動一個線程,那麼將可能帶來子類化問題,同時還會導致this引用從構造函數中逸出。
7.通知錯誤。notify和notifyAll方法都表示,某個對象的狀態可能以某種方式發生了變,化,並且這種方式將在相關條件隊列上被阻塞的線程恢復執行。只有在與條件隊列相關的狀態發生改變後,才應該調用這些方法。如果在一個同步塊中調用了notify或notifyAll,但沒有修改任何狀態,那麼就可能出錯
8.條件等待中的錯誤。當在一個條件隊列上等待時, Object.wait 和Condition.await方法應該在檢查了狀態謂詞之後,在某個循環中調用,同時需要持有正確的鎖。如果在調用Object.wait和Condition.await方法時沒有持有鎖,或者不在某個循環中,或者沒有檢查某些狀態謂詞,那麼通常都是一個錯誤。
9.對Lock和Condition的誤用。將Lock作爲同步塊來使用通常是一種錯誤的用法,正如調用Condition.wait而不調用await(後者能夠通過測試被發現,因此在第一次調用它時將拋出IllegalMonitorStateException)。
10.在休眠或者等待的同時持有一個鎖。如果在調用Thread.sleep時持有一個鎖,那麼將導致其他線程在很長一段時間內無法執行,因此可能導致嚴重的活躍性問題。如果在調用Object.wait或Condition.await時持有兩個鎖,那麼也可能導致同樣的問題。
11.自旋循環。如果在代碼中除了通過自旋(忙於等待)來檢查某個域的值以外不做任何事情,那麼將浪費CPU時鐘週期,並且如果這個域不是volatile類型,那麼將無法保證這種自旋過程能結束。當等待某個狀態轉換髮生時,閉鎖或條件等待通常是一種更好的技術。
第四部分高級主題
十三.顯示鎖
13.1 Lock與ReentrantLock
1.Lock類方法
-
lock獲取鎖:優先考慮獲取鎖,待獲取鎖成功後,才響應中斷
-
lockInterruptibly:優先考慮響應中斷,而不是響應鎖的普通獲取或重入獲取
-
tryLock會嘗試獲得鎖,如果沒有獲得返回false 調用線程不會阻塞 而lock 與 lockInterruptibly如果沒有獲得鎖會阻塞到獲得鎖
-
unlock釋放鎖
-
newCondition返回綁定到此實例的新Condition實例
2.使用幾次Lock鎖時要在finally裏面釋放幾次鎖(lock鎖不會自動清除鎖)
13.1.1 輪詢鎖與定時鎖
1.如果不能獲得所有需要的鎖,那麼可以使用可定時的或可輪詢的鎖獲取方式,從而使你重新獲得控制權,它會釋放已經獲得的鎖,然後重新嘗試獲取所有鎖(或者至少會將這個失敗記錄到日誌,並採取其他措施)。
2.在實現具有時間限制的操作時,定時鎖同樣非常有用。當在帶有時間限制的操作中調用了一個阻塞方法時,它能根據剩餘時間來提供一個時限。如果操作不能在指定的時間內給出結果,那麼就會使程序提前結束。當使用內置鎖時,在開始請求鎖後,這個操作將無法取消,因此內置鎖很難實現帶有時間限制的操作。
13.1.2 可中斷的鎖獲取操作
正如定時的鎖獲取操作能在帶有時間限制的操作中使用獨佔鎖,可中斷的鎖獲取操作同樣能在可取消的操作中使用加鎖。在內置鎖中實現可取消的任務比較複雜。lockInterruptibly方法能夠在獲得鎖的同時保持對中斷的響應,並且由於它包含在Lock中,因此無須創建其他類型的不可中斷阻塞機制
13.1.3 非塊結構的加鎖
1.在內置鎖中,鎖的獲取和釋放等操作都是基於代碼塊的,釋放鎖的操作總是與獲取鎖的操作處於同一個代碼塊,而不考慮控制權如何退出該代碼塊。自動的鎖釋放操作簡化了對程序的分析,避免了可能的編碼錯誤。都是在某些場合需要更靈活的加鎖規則
2.可以通過降低鎖的粒度可以提高代碼的可伸縮性。鎖分段技術在基於散列的容器中實現了不同的散列鏈,以便使用不同的鎖。我們可以通過採用類似的原則來降低鏈表中鎖的粒度,即爲每個鏈表節點使用一個獨立的鎖,使不同的線程能獨立地對鏈表的不同部分進行操作。每個節點的鎖將保護鏈接指針以及在該節點中存儲的數據,因此當遍歷或修改鏈表時,我們必須持有該節點上的這個鎖,直到獲得了下一個節點的鎖,只有這樣,才能釋放前一個節點上的鎖。(被稱爲連鎖式加鎖或者鎖耦合)
13.2 公平性
1.在RcentrantLock的構造函數中提供了兩種公平性選擇:創建一個非公平的鎖(默認)或者一個公平的鎖。在公平的鎖上,線程將按照它們發出請求的順序來獲得鎖,但在非公平的鎖上,則允許“插隊”:當一個線程請求非公平的鎖時,如果在發出請求的同時該鎖的狀態變爲可用,那麼這個線程將跳過隊列中所有的等待線程並獲得這個鎖。非公平的ReentrantLock並不提倡“插隊”行爲,但無法防止某個線程在合適的時候進行“插隊”。在公平的鎖中,如果有另一個線程持有這個鎖或者有其他線程在隊列中等待這個鎖,那麼新發出請求的線程將被放入隊列中。在非公平的鎖中,只有當鎖被某個線程持有時,新發出請求的線程纔會被放入隊列
2.當執行加鎖操作時,公平性將由於在掛起線程和恢復線程時存在的開銷而極大地降低性能。在實際情況中,統計上的公平性保證,確保被阻塞的線程能最終獲得鎖,通常已經夠用了,並且實際開銷也小得多。有些算法依賴於公平的排隊算法以確保它們的正確性,但這些算法並不常見。在大多數情況下,非公平鎖的性能要高於公平鎖的性能。
3.在激烈競爭的情況下,非公平鎖的性能高於公平鎖的性能的一個原因是:在恢復一個被掛起的線程與該線程真正開始運行之間存在着嚴重的延遲。假設線程A持有一個鎖,並且線程B請求這個鎖。由於這個鎖已被線程A持有,因此B將被掛起。當A釋放鎖時,B將被喚醒,因此會再次嘗試獲取鎖。與此同時,如果C也請求這個鎖,那麼C很可能會在B被完全喚醒之前獲得、使用以及釋放這個鎖。這樣的情況是一種“雙贏”的局面: B獲得鎖的時刻並沒有推遲,C更早地獲得了鎖,並且吞吐量也獲得了提高。
4.當持有鎖的時間相對較長,或者請求鎖的平均時間間隔較長,那麼應該使用公平鎖。在這些情況下,“插隊”帶來的吞吐量提升(當鎖處於可用狀態時,線程卻還處於被喚醒的過程中)則可能不會出現。
5.內置鎖不會提供確定的公平性保證
6.即使對於公平鎖而言,可輪詢的tryLock仍然會插隊
13.3 在synchronized和ReentrantLock之間進行選擇
1.與顯式鎖相比,內置鎖仍然具有很大的優勢。內置鎖爲許多開發人員所熟悉,並且簡潔緊湊,而且在許多現有的程序中都已經使用了內置鎖,如果將這兩種機制混合使用,那麼不僅容易令人困惑,也容易發生錯誤。
2.ReentrantLock 的危險性比同步機制要高,如果忘記在finally塊中調用unlock,那麼雖然代碼表面上能正常運行,但實際上已經埋下了一顆定時炸彈,並很有可能傷及其他代碼。僅當內置鎖不能滿足需求時,纔可以考慮使用ReentrantLock.
3.在一些內置鎖無法滿足需求的情況下,ReentrantLock可以作爲一種高級工具。當需要一些高級功能時才應該使用ReentrantLock,這些功能包括:可定時的、可輪詢的與可中斷的鎖獲取操作,公平隊列,以及非塊結構的鎖。否則,還是應該優先使用synchronized.
4.內置鎖可以在線程轉儲中看見相關的鎖信息而ReentrantLock鎖的信息不能出現在線程轉儲的信息中(java6中提供了一個管理和調試接口,鎖可以通過該接口進行註冊,這樣ReentrantLock鎖的相關加鎖信息就可以出現在線程轉儲中)
5.未來內置鎖的性能可能會進一步提升,功能可能也會有所添加,所以儘可能使用內置鎖
13.4 讀—寫鎖(ReadWriteLock)
1.在讀寫鎖實現的加鎖的策略中,允許多個讀操作同時進行,但每次只允許一個寫操作
2.讀寫鎖是一種性能優化措施,在一些特定的情況下能實現更高的併發性。在實際情況中,對於在多處理器系統上被頻繁讀取的數據結構,讀寫鎖能夠提高性能。而在其他情況下,讀寫鎖的性能比獨佔鎖的性能要略差一些:這是因爲它們的複雜性更高。如果要判斷在某種情況下使用讀寫鎖是否會帶來性能提升,最好對程序進行分析。由於ReadWriteLock使用Lock來實現鎖的讀一寫部分,因此如果分析結果表明讀一寫鎖沒有提高性能,那麼可以很容易地將讀一寫鎖換爲獨佔鎖。
3.讀寫鎖的可選實現
-
釋放優先。當一個寫入操作釋放寫入鎖時,並且隊列中同時存在讀線程和寫線程,那麼應該優先選擇讀線程,寫線程,還是最先發出請求的線程?
-
讀線程插隊。如果鎖是由讀線程持有,但有寫線程正在等待,那麼新到達的讀線程能否立即獲得訪問權,還是應該在寫線程後面等待?如果允許讀線程插隊到寫線程之前,那麼將提高併發性,但卻可能造成寫線程發生飢餓問題。
-
重入性。讀取鎖和寫入鎖是否是可重人的?
-
降級。如果一個線程持有寫人鎖,那麼它能否在不釋放該鎖的情況下獲得讀取鎖?這可能會使得寫人鎖被“降級”爲讀取鎖,同時不允許其他寫線程修改被保護的資源。
-
升級。讀取鎖能否優先於其他正在等待的讀線程和寫線程而升級爲一個寫入鎖?在大多數的讀寫鎖實現中並不支持升級,因爲如果沒有顯式的升級操作,那麼很容易造成死鎖。(如果兩個讀線程試圖同時升級爲寫人鎖,那麼二者都不會釋放讀取鎖。會造成相互等待)
4.ReentrantReadWriteLock爲這兩種鎖都提供了可重入的加鎖語義。與ReentrantLock類似,ReentrantReadWriteLock在構造時也可以選擇是一個非公平的鎖(默認)還是一個公平的鎖。在公平的鎖中,等待時間最長的線程將優先獲得鎖。如果這個鎖由讀線程持有,而另一個線程請求寫人鎖,那麼其他讀線程都不能獲得讀取鎖,直到寫線程使用完並且釋放了寫入鎖。在非公平的鎖中,線程獲得訪問許可的順序是不確定的。寫線程降級爲讀線程是可以的,但從讀線程升級爲寫線程則是不可以的(這樣做會導致死鎖)。
5.與ReentrantLock類似的是,ReentrantReadWriteLock中的寫入鎖只能有唯一的所有者,並且只能由獲得該鎖的線程來釋放。在Java5.0中,讀取鎖的行爲更類似於一個Semaphore而不是鎖,它只維護活躍的讀線程的數量,而不考慮它們的標識。在Java6中修改了這個行爲:記錄哪些線程已經獲得了讀者鎖。
小結
1.與內置鎖相比,顯式的Lock提供了一些擴展功能,在處理鎖的不可用性方面有着更高的靈活性,並且對隊列行有着更好的控制。但ReentrantLock不能完全替代synchronized,只有synchronized無法滿足需求時,才應該使用它。
2.讀寫鎖允許多個讀線程併發地訪問被保護的對象,當訪問以讀取操作爲主的數據結構時,它能提高程序的可伸縮性。
十四.構建自定義的同步工具
14.1 狀態依賴性的管理
1.在單線程程序中調用一個方法時,如果某個基於狀態的前提條件未得到滿足(例如“連接池必須非空”),那麼這個條件將永遠無法成真。因此,在編寫順序程序中的類時,要使得這些類在它們的前提條件未被滿足時就失敗。但在併發程序中,基於狀態的條件可能會由於其他線程的操作而改變:一個資源池可能在幾條指令之前還是空的,但現在卻變爲非空的,因爲另一個線程可能會返回一個元素到資源池。對於併發對象上依賴狀態的方法,雖然有時候在前提條件不滿足的情況下不會失敗,但通常有一種更好的選擇,即等待前提條件變爲真。
2.依賴狀態的操作可以一直阻塞直到可以繼續執行,這比使它們先失敗再實現起來要更爲方便且更不易出錯。內置的條件隊列可以使線程一直阻塞,直到對象進入某個進程可以繼續執行的狀態,並且當被阻塞的線程可以執行時再喚醒它們。
14.1.1 條件隊列
條件隊列:傳統隊列的元素是一個個數據,而與之不同的是,條件隊列中的元素是一個個正在等待相關條件的線程。
1.正如每個Java對象都可以作爲一個鎖,每個對象同樣可以作爲一個條件隊列,並且Object中的wait、notify和notifyAll方法就構成了內部條件隊列的API。對象的內置鎖與其內部條件隊列是相互關聯的,要調用對象X中條件隊列的任何一個方法,必須持有對象X上的鎖。這是因爲“等待由狀態構成的條件”與“維護狀態一致性”這兩種機制必須被緊密地綁定在一起:只有能對狀態進行檢查時,才能在某個條件上等待,並且只有能修改狀態時,才能從條件等待中釋放另一個線程。
2.Object.wait會自動釋放鎖,並請求操作系統掛起當前線程,從而使其他線程能夠獲得這個鎖並修改對象的狀態。當被掛起的線程醒來時,它將在返回之前重新獲取鎖。從直觀上來理解,調用wait 意味着“我要去休息了,但當發生特定的事情時喚醒我”,而調用通知方法就意味着“特定的事情發生了”。
14.2 使用條件隊列
1.條件謂詞是使某個操作成爲狀態依賴操作的前提條件。在有界緩存中,只有當緩存不爲空時,take 方法才能執行,否則必須等待。對take方法來說,它的條件謂詞就是“緩存不爲空”,take方法在執行之前必須首先測試該條件謂詞。同樣,put 方法的條件謂詞是“緩存不滿”。條件謂詞是由類中各個狀態變量構成的表達式。
2.在條件等待中存在一種重要的三元關係,包括加鎖、wait方法和一個條件謂詞。在條件謂詞中包含多個狀態變量,而狀態變量由一個鎖來保護,因此在測試條件謂詞之前必須先持有這個鎖。鎖對象與條件隊列對象(即調用wait和notify等方法所在的對象)必須是同一個對象。
3.wait 方法將釋放鎖,阻塞當前線程,並等待直到超時,然後中斷線程,或者通過一個通知被喚醒。
4.在喚醒進程後,wait在返回前還要重新獲取鎖。當線程從wait方法中被喚醒時,它在重新請求鎖時不具有任何特殊的優先級,而要與任何其他嘗試進人同步代碼塊的線程一起正常地在鎖上進行競爭。
14.2.1 過早喚醒
1.雖然在鎖、條件謂詞和條件隊列之間的三元關係並不複雜,但wait方法的返回並不一定意味着線程正在等待的條件謂詞已經變成真了。
2.內置條件隊列可以與多個條件謂詞一起使用。當一個線程由於調用notifyAll而醒來時,並不意味該線程正在等待的條件謂詞已經變成真了。另外,wait 方法還可以“假裝”返回,而不是由於某個線程調用了notify。
3.當執行控制重新進人調用wait的代碼時,它已經重新獲取了與條件隊列相關聯的鎖。現在條件謂詞是不是已經變爲真了?或許。在發出通知的線程調用notifyAll時,條件謂詞可能已經變成真,但在重新獲取鎖時將再次變爲假。在線程被喚醒到wait重新獲取鎖的這段時間裏,可能有其他線程已經獲取了這個鎖,並修改了對象的狀志。或者,條件謂詞從調用wait起根本就沒有變成真。你並不知道另一個線程爲什麼調用notify或notifyAll,也許是因爲與同一條件隊列相關的另一個條件謂詞變成了真。“一個條件隊列與多個條件謂詞相關”是一種很常見的情況
4.每當線程從wait中喚醒時,都必須再次測試條件謂詞,如果條件謂詞不爲真,那麼就繼續等待(或者失敗)。由於線程在條件謂詞不爲真的情況下也可以反覆地醒來,因此必須在一個循環中調用wait,並在每次迭代中都測試條件謂詞。
5.當使用條件等待時:
-
通常都有一個條件謂詞:包括一些對象狀態的測試,線程在執行前必須首先通過這些測試。
-
在調用wait之前測試條件謂詞,並且從wait中返回時再次進行測試。
-
在一個循環中調用wait。
-
確保使用與條件隊列相關的鎖來保護構成條件謂詞的各個狀態變量。
-
當調用wait、notify 或notifyAll等方法時,一定要持有與條件隊列相關的鎖。
-
在檢查條件謂詞之後以及開始執行相應的操作之前,不要釋放鎖。
14.2.2 丟失信號
丟失信號:線程等待一個已經發生的事件
線程必須等待一個已經爲真的條件,但在開始等待之前沒有檢查條件謂詞,這樣就會導致線程將等待一個已經發生的事件,會讓線程等待時間變得很長(等待另一個線程發出通知)
14.2.3 通知
1.每當在等待一個條件時,一定要確保在條件謂詞變爲真時通過某種方式發出通知
2.在條件隊列API中有兩個發出通知的方法,即notify和notifyAll。無論調用哪一個,都必須持有與條件隊列對象相關聯的鎖。在調用notify 時,JVM會從這個條件隊列上等待的多個線程中選擇一個來喚醒,而調用notifyAll則會喚醒所有在這個條件隊列,上等待的線程。由於在調用notify或notifyAll時必須持有條件隊列對象的鎖,而如果這些等待中線程此時不能重新獲得鎖,那麼無法從wait返回,因此發出通知的線程應該儘快地釋放鎖,從而確保正在等待的線程儘可能快地解除阻塞。
3.由於多個線程可以基於不同的條件謂詞在同一個條件隊列上等待,因此如果使用notify而不是notifyAll,那麼將是一種危險的操作,因爲單一的通知很容易導致類似於信號丟失的問題。
4.在進行優化時應該遵循”首先使程序正確執行,然後才使其運行得更快“的原則
14.3 顯示的Condition
1.Condition是一種廣義的內置條件隊列,一個Condition和一個Lock關聯在一起,就像一個條件隊列和一個內置鎖相關聯一樣
2.Condition提供了比內置條件隊列更豐富的功能:在每個鎖上可以存在多個等待、條件等待可以是可中斷的或不可中斷的、基於時限的等待、公平或者非公平的隊列操作
3.對於每個Lock可以有任意數量的Condition對象。Condition對象繼承了相關Lock對象的公平性,對於公平的鎖,線程會依照FIFO順序從Condition.await中釋放
4.當內置鎖和內置隊列不能滿足當前的需求時才應該使用顯示鎖及顯示隊列
14.4 Synchronizer剖析
1.ReentrantLock、Semaphore在實現時都使用了一個共同的基類,即AbstractQueuedSynchronizer(AQS),這個類也是其他許多同步類的基類。AQS是一個用於構建鎖和同步器的框架,許多同步器都可以通過AQS很容易並且高效地構造出來。不僅ReentrantLock和Semaphore是基於AQS構建的,還包括CountDownLatch、ReentrantReadWriteLock 、SynchronousQueue和FutureTask。
2.AQS解決了在實現同步器時涉及的大量細節問題,例如等待線程採用FIFO隊列操作順序。在不同的同步器中還可以定義一些靈活的標準來判斷某個線程是應該通過還是需要等待。
3.基於AQS來構建同步器能帶來許多好處。它不僅能極大地減少實現工作,而且也不必處理在多個位置上發生的競爭問題(在沒有使用AQS來構建同步器時的情況)。
小結
1.要實現一個依賴狀態的類-如果沒有滿足依賴狀態的前提條件,那麼這個類的方法必須阻塞,那麼最好的方式是基於現有的庫類來構建,例如Semaphore.BlockingQueue或CountDownLatch。然而,有時候現有的庫類不能提供足夠的功能,在這種情況下,可以使用內置的條件隊列、顯式的Condition對象或者AbstractQueuedSynchronizer來構建自己的同步器。
2.內置條件隊列與內置鎖是緊密綁定在一起的,這是因爲管理狀態依賴性的機制必須與確保狀態一致性的機制關聯起來。同樣,顯式的Condition與顯式的Lock也是緊密地綁定到一起的,並且與內置條件隊列相比,還提供了一個擴展的功能集,包括每個鎖對應於多個等待線程集,可中斷或不可中斷的條件等待,公平或非公平的隊列操作,以及基於時限的等待。
十五.原子變量與非阻塞同步機制
1.與基於鎖的方案相比,非阻塞算法在設計和實現上都要複雜得多,但它們在可伸縮性和活躍性上卻擁有巨大的優勢。由於非阻塞算法可以使多個線程在競爭相同的數據時不會發生阻塞,因此它能在粒度更細的層次上進行協調,並且極大地減少調度開銷。而且,在非阻塞算法中不存在死鎖和其他活躍性問題。在基於鎖的算法中,如果一個線程在休眠或自旋的同時持有一個鎖,那麼其他線程都無法執行下去,而非阻塞算法不會受到單個線程失敗的影響。從Java5.0開始,可以使用原子變量類(例如AtomicInteger和AtomicReference)來構建高效的非阻塞算法。
2.即使原子變量沒有用於非阻塞算法的開發,它們也可以用做一種“更好的volatile類型變量”。原子變量提供了與volatile類型變量相同的內存語義,此外還支持原子的更新操作,從而使它們更加適用於實現計數器、序列發生器和統計數據收集等,同時還能比基於鎖的方法提供更高的可伸縮性。
15.1 鎖的劣勢
1.如果有多個線程同時請求鎖,那麼JVM就需要藉助操作系統的功能,被阻塞的線程根據以前線程持有鎖的時間將自旋等待或者被掛起。如果被掛起,當線程恢復執行的時候就必須等待其他線程執行完他們的時間片以後才能被調度執行。在掛起和恢復線程等過程中存在着很大的開銷,並且通常存在着較長時間的中斷。如果在基於鎖的類中包含着細粒度的操作,那麼當鎖上存在着激烈的競爭時,調度開銷與工作開銷的比值將會非常高
2.當一個線程正在等待鎖時,它不能做任何其他事情。如果一個線程在持有鎖的情況下被延遲執行(例如發生了缺頁錯誤、調度延遲,或者其他類似情況),那麼所有需要這個鎖的線程都無法執行下去。如果被阻塞線程的優先級較高,而持有鎖的線程優先級較低,那麼這將是一個嚴重的問題,也被稱爲優先級反轉(Priority Inversion)。即使高優先級的線程可以搶先執行,但仍然需要等待鎖被釋放,從而導致它的優先級會降至低優先級線程的級別。如果持有鎖的線程被永久地阻塞(例如由於出現了無限循環,死鎖,活鎖或者其他的活躍性故障),所有等待這個鎖的線程就永遠無法執行下去。
15.2 硬件對併發的支持
15.2.1 比較並交換(CAS)
1.CAS包含了3個操作數,需要讀寫的內存位置V、進行比較的值A和要寫入的值S。當V的值等於A時通過原子的方式將V的值替換爲S的值,並且不管V的值是否等於A都返回V的值
2.當多個線程嘗試使用CAS同時更新同一個變量時,只有其中一個線程能更新變量的值,而其他線程都將失敗。然而,失敗的線程並不會被掛起(這與獲取鎖的情況不同:當獲取鎖失敗時,線程將被掛起),而是被告知在這次競爭中失敗,並可以再次嘗試。由於一個線程在競爭CAS時失敗不會阻塞,因此它可以決定是否重新嘗試,或者執行一些恢復操作,也或者不執行任何操作。這種靈活性就大大減少了與鎖相關的活躍性風險(儘管在一些不常見的情況下仍然存在活鎖風險)。
3.CAS的主要缺點是,它需要調用者處理競爭問題帶來的問題,比如發生競爭後的重試、回退、放棄,而在鎖中能自動處理競爭帶來的問題(比如在獲得鎖之前保存阻塞)
4.CAS的性能會隨着處理器數量的不同而發生較大的變化。在單CPU中CAS只需要很少的時鐘週期,因爲不需要處理器之間的同步。在非競爭的多CPU中需要10到150個時鐘週期的開銷。在大多數處理器上,在無競爭的鎖競爭上獲取和釋放上的開銷大於是CAS開銷的兩倍
15.3 原子變量
1.原子變量類共有12個,可分爲4組:標量類(Scalar)、 更新器類、數組類以及複合變量類。
-
最常用的原子變量就是標量類: AtomicInteger、 AtomicLong、 AtomicBoolean 以及AtomicReference。所有這些類都支持CAS,此外,AtornicInteger 和AtomicLong還支持算術運算。(要想模擬其他基本類型的原子變量,可以將short或byte等類型與int類型進行轉換,以及使用floatToIntBits 或doubleToLongBits來轉換浮點數。)
-
原子數組類(只支持Integer、Long和Reference版本)中的元素可以實現原子更新。原子數組類爲數組的元素提供了volatile類型的訪問語義,這是普通數組所不具備的特性,volatile類型的數組僅在數組引用上具有volatile語義,而在其元素上則沒有。
-
儘管原子的標量類擴展了Number類,但並沒有擴展基本類型的包裝類,例如Integer或Long。事實上,它們也不能進行擴展:基本類型的包裝類是不可修改的,而原子變量類是可修改的。在原子變量類中同樣沒有重新定義hashCode或equals方法,每個實例都是不同的。與其他可變對象相同,它們也不宜用做基於散列的容器中的鍵值。
2.compareAndSet判斷是否相等時 對於int 會使用Integer.valueOf進行轉化(Integer.valueOf轉化時-128~128的值會使用緩存地址相等 其他的new一個Integer出來,地址不相等)
3.在中低程度的競爭下,原子變量能提供更高的可伸縮性,而在高強度的競爭下,鎖能夠更有效地避免競爭。(在單CPU的系統上,基於CAS的算法在性能上同樣會超過基於鎖的算法,因爲CAS在單CPU的系統上通常能執行成功,只有在偶然情況下,線程纔會在執行讀-改-寫的操作過程中被其他線程搶佔執行。)
15.4 非阻塞算法
非阻塞算法:如果在某個算法中,一個線程的失敗或掛起不會導致其他線程也失敗或掛起,那麼這種算法就被稱爲非阻塞算法。
無鎖算法:如果在算法的每個步驟中都存在某個線程能夠執行下去,那麼這個算法也被稱爲無鎖算法。
如果在算法中僅將CAS用於協調線程之間的操作,並且能正確地實現,那麼它既是一個無阻塞算法又是一個無鎖算法。
1.在基於鎖的算法中可能會發生各種各樣的活躍性故障。如果線程在持有鎖時由於阻塞I/O,內存頁缺失或其他延遲而導致推遲執行,那麼很可能所有線程都不能繼續執行下去。
2.在非阻塞算法中通常不會出現死鎖和優先級反轉的問題,但是可能會出現飢餓和活鎖問題(可能會反覆執行CAS操作)
3.在實現相同的功能的前提下,非阻塞的算法通常比基於鎖的算法更爲複雜。創建非阻塞算法的關鍵在於,找出如何將原子修改的範圍縮小到單個變量上,同時還要維護數據的一致性。
15.4.1 線性表
線性表:就是一種連續或間斷存儲的數組,這裏的連續和間斷是針對物理內存空間中線性表元素之間是否連續。
順序表:連續數組對應內置數組的實現方式,被稱爲順序表實現;
鏈表:間斷數組對應的是指針的實現方式,這種方式也稱爲鏈表實現。保存的數據在內存中不連續的,用指針對數據進行訪問。
棧:是一種數據結構,只能在一端進行插入和刪除操作的特殊線性表,按照先進後出 (FILO)的原則存儲數據。棧有棧底和棧頂,元素從棧頂出。
隊列:是一種數據結構,其特點是先進先出,後進後出,只能在隊首刪除,在隊尾增加。
1.棧的結構特性:棧可以動態增長和縮減,即(一般)可以向一個棧添加或從一個棧刪除元素,但這種添加和刪除操作只能從棧的棧頂進行操作,這種限制也造就了棧的先進後出特性。一般我們可以用順序表和鏈表兩種方式來實現棧,但是,根據棧的特性,其實還可以用其他結構來實現棧,只要這種結構能實現棧的先進後出,而且只能從棧的棧頂進行插入和刪除操作。
2.隊列的結構特性:隊列是先進先出的線性表,它同時維護表的兩端,但只能在表尾進行插入,在表頭進行刪除操作,這是有隊列的先進先出特性決定的。所以,不管是用順序表還是用鏈表實現隊列,都需要遵循隊列的先進先出特性。
15.4.2 原子更新器
AtomicReferenceFieldUpdater:一個基於反射的工具類,它能對指定類的指定的非private的volatile引用字段進行原子更新。
1.通過調用AtomicReferenceFieldUpdater的靜態方法newUpdater就能創建它的實例,該方法要接收三個參數:
-
包含該字段的對象的類
-
將被更新的對象的類
-
將被更新的字段的名稱
2.其它的類還包括AtomicLongFieldUpdater、AtomicIntegerFieldUpdater
15.4.3 ABA
ABA問題是一種異常現象:如果在算法中的節點可以被循環使用,那麼在使用“比較並交換”指令時就可能出現這種問題(主要在沒有垃圾回收機制的環境中)。在CAS操作中將判斷“V的值是否仍然爲A”,並且如果是的話就繼續執行更新操作。在大多數情況下,這種判斷是完全足夠的。然而,有時候還需要知道“自從上次看到V的值爲A以來,這個值是否發生了變化?”在某些算法中,如果V的值首先由A變成B,再由B變成A,那麼仍然被認爲是發生了變化,並需要重新執行算法中的某些步驟。爲解決這個問題,我們可以選擇更新兩個值,包括一個引用和一個版本號。AtomicStampedReferenc和AtomicMarkableReference支持在兩個變量上執行原子的條件更新
小結
1.非阻塞算法通過底層的併發原語(例如比較並交換而不是鎖)來維持線程的安全性。這些底層的原語通過原子變量類向外公開,這些類也用做一種“更好的volatile變量”,從而爲整數和對象引用提供原子的更新操作。
2.非阻塞算法在設計和實現時非常困難,但通常能夠提供更高的可伸縮性,並能更好地防止活躍性故障的發生。在JVM從一個版本升級到下一個版本的過程中,併發性能的主要提升都來自於(在JVM內部以及平臺類庫中)對非阻塞算法的使用。
十六.Java內存模型
16.1 內存模型
1.如果一個線程對一個變量賦值 aVariable=3;內存模型需要解決這樣的問題:“在什麼條件下,讀取aVariabl的線程將看到這個值爲3”這聽起來似乎是一個愚蠢的問題,但如果缺少同步,那麼將會有許多因素使得線程無法立即甚至永遠,看到另一個線程的操作結果。在編譯器中生成的指令順序,可以與源代碼中的順序不同,此外編譯器還會把變量保存在寄存器而不是內存中;處理器可以採用亂序或並行等方式來執行指令;緩存可能會改變將寫人變量提交到主內存的次序;而且,保存在處理器本地緩存中的值,對於其他處理器是不可見的。這些因素都會使得一個線程無法看到變量的最新值,在沒有使用正確的同步的情況下會導致其他線程中的內存操作似乎在亂序執行。
2.在單線程環境中,我們無法看到所有這些底層技術,它們除了提高程序的執行速度外,不會產生其他影響。Java語言規範要求JVM在線程中維護一種類似串行的語義:只要程序的最終結果與在嚴格串行環境中執行的結果相同,那麼,上述所有操作都是允許的。這確實是一件好事情,因爲在最近幾年中,計算性能的提升在很大程度上要歸功於這些重新排序措施。當然,時鐘頻率的提供同樣提升了性能,此外還有不斷提升的並行性–採用流水線的超標量執行單元,動態指令調度,猜測執行以及完備的多級緩存。隨着處理變得越來越強大,編譯器也在不斷地改進:通過對指令重新排序來實現優化執行,以及使用成熟的全局寄存器分配算法。由於時鐘頻率越來越難以提高,因此許多處理器製造廠商都開始轉而生產多核處理器,因爲能夠提高的只有硬件並行性。
3.在多線程環境中,維護程序的串行性將導致很大的性能開銷。對於併發應用程序中的線程來說,它們在大部分時間裏都執行各自的任務,因此在線程之間的協調操作只會降低應用程序的運行速度,而不會帶來任何好處。只有當多個線程要共享數據時,才必須協調它們之間的操作,並且JVM依賴程序通過同步操作來找出這些協調操作將在何時發生。
16.1.1 平臺的內存模型
1.在共享內存的多處理器體系架構中,每個處理器都擁有自己的緩存,並且定期地與主內存.進行協調。在不同的處理器架構中提供了不同級別的緩存一致性,其中一部分只提供最小的保證,即允許不同的處理器在任意時刻從同一個存儲位置上看到不同的值。操作系統、編譯器以及運行時(有時甚至包括應用程序)需要彌合這種在硬件能力與線程安全需求之間的差異。
2.要想確保每個處理器都能在任意時刻知道其他處理器正在進行的工作,將需要非常大的開銷。在大多數時間裏,這種信息是不必要的,因此處理器會適當放寬存儲一致性保證,以換取性能的提升。在架構定義的內存模型中將告訴應用程序可以從內存系統中獲得怎樣的保證,此外還定義了一些特殊的指令(稱爲內存柵欄或柵欄),當需要共享數據時,這些指令就能實現額外的存儲協調保證。爲了使Java開發人員無須關心不同架構上內存模型之間的差異,Java 還提供了自己的內存模型,並且JVM通過在適當的位置.上插入內存柵欄來屏蔽在JMM與底層平臺內存模型之間的差異。
3.在現代支持共享內存的多處理器(和編譯器)中,當跨線程共享數據時,會出現一些奇怪的情況,除非通過使用內存柵欄來防止這些情況的發生。但是Java 程序不需要指定內存柵欄的位置,而只需通過正確地使用同步來找出何時將訪問共享狀態。
16.1.2 重排序
在沒有充分同步的程序中,如果調度器採用不恰當的方式來交替執行不同線程的操作,那麼將導致不正確的結果。更糟的是,JMM還使得不同線程看到的操作執行順序是不同的,從而導致在缺乏同步的情況下,要推斷操作的執行順序將變得更加複雜。各種使操作延遲或者看似亂序執行的不同原因,都可以歸爲重排序。
16.1.3 Java內存模型簡介
1.Java內存模型是通過各種操作來定義的,包括對變量的讀寫操作,監視器的加鎖和釋放操作,以及線程的啓動和合並操作。JMM爲程序中所有的操作定義了一個偏序關係,稱之爲Happens-Before。要想保證執行操作B的線程看到操作A的結果(無論A和B是否在同一個線程中執行),那麼在A和B之間必須滿足Happens-Before關係。如果兩個操作之間缺乏Happens-Before關係,那麼JVM可以對它們任意地重排序。
2.當一個變量被多個線程讀取並且至少被一個線程寫入時,如果在讀操作和寫操作之間沒有依照Happens-Before來排序,那麼就會產生數據競爭問題。在正確同步的程序中不存在數據競爭,並會表現出串行一致性,這意味着程序中的所有操作都會按照一種固定的和全局的順序執行。
16.1.4 藉助同步
1.由於Happens-Before的排序功能很強大,因此有時候可以“藉助(Piggyback)” 現有同步機制的可見性屬性。這需要將Happens-Before的程序順序規則與其他某個順序規則(通常是監視器鎖規則或者volatile變量規則)結合起來,從而對某個未被鎖保護的變量的訪問操作進行排序。這項技術由於對語句的順序非常敏感,因此很容易出錯。它是一項高級技術,並且只有當需要最大限度地提升某些類(例如ReentrantLock)的性能時,才應該使用這項技術。
2.在類庫中提供的其他Happens-Before排序包括:
-
將一個元素放入一個線程安全容器的操作將在另一個線程從該容器中獲得這個元素的操作之前執行。
-
在CountDownLatch.上的倒數操作將在線程從閉鎖上的await方法中返回之前執行。.
-
釋放Semaphore許可的操作將在從該Semaphore上獲得一個許可之前執行。
-
Future表示的任務的所有操作將在從Future.get中返回之前執行。
-
向Executor提交一個Runnable或Callable的操作將在任務開始執行之前執行。
-
一個線程到達CyclicBarrier或Exchanger的操作將在其他到達該柵欄或交換點的線程被釋放之前執行。如果CyclicBarrier使用-一個柵欄操作,那麼到達柵欄的操作將在柵欄操作之前執行,而柵欄操作又會在線程從柵欄中釋放之前執行。
16.2 發佈
16.2.1 不安全的發佈
1.當缺少Happens-Before關係時,就可能出現重排序問題,這就解釋了爲什麼在沒有充分同步的情況下發佈一個對象會導致另一個線程看到一個只被部分構造的對象。在初始化一個新的對象時需要寫入多個變量,即新對象中的各個域。同樣,在發佈一個引用時也需要寫人一個變量,即新對象的引用。如果無法確保發佈共享引用的操作在另一個線程加載該共享引用之前執行,那麼對新對象引用的寫入操作將與對象中各個域的寫入操作重排序(從使用該對象的線程的角度來看)。在這種情況下,另一個線程可能看到對象引用的最新值,但同時也將看到對象的某些或全部狀態中包含的是無效值,即一個被部分構造對象。
2.除了不可變對象以外,使用被另一個線程初始化的對象通常都是不安全的,除非對象的發佈操作是在使用該對象的線程開始使用之前執行。
16.2.2 安全的發佈
1.如果線程A將X放入BlockingQueue (並且隨後沒有線程修改它),線程B從隊列中獲取X,那麼可以確保B看到的X與A放入的X相同。這是因爲在BlockingQueue的實現中有足夠的內部同步確保了put方法在take方法之前執行。同樣,通過使用一個由鎖保護共享變量或者使用共享的volatile類型變
量,也可以確保對該變量的讀取操作和寫人操作按照Happens-Before關係來排序。
2.Happens-Before 比安全發佈提供了更強可見性與順序保證。如果將X從A安全地發佈到B,那麼這種安全發佈可以保證X狀態的可見性,但無法保證A訪問的其他變量的狀態可見性(A線程對非X對象的其他變量修改對B不保證可見性)。然而,如果A將X置人隊列的操作 happens-before 線程B從隊列中獲取X的操作,那麼B不僅能看到A留下的X狀態(假設線程A或其他線程都沒有對X再進行修改),而且還能看到A在移交X之前所做的任何操作(如果移交之後修改了X的值不保證對B的可見性)。
3.既然JMM已經提供了這種更強大的Happens-Before 關係,那麼爲什麼還要介紹@GuardedBy和安全發佈呢?與內存寫入操作的可見性相比,從轉移對象的所有權以及對象公佈等角度來看,它們更符合大多數的程序設計。Happens-Before 排序是在內存訪問級別上操作的,它是一種“併發級彙編語言”,而安全發佈的運行級別更接近程序設計。
16.2.3 安全初始化模式
16.3 初始化過程中的安全性
1.如果能確保初始化過程的安全性,那麼就可以使得被正確構造的不可變對象在沒有同步的情況下也能安全地在多個線程之間共享,而不管它們是如何發佈的,甚至通過某種數據競爭來發布。
2.如果不能確保初始化的安全性,那麼當在發佈或線程中沒有使用同步時,一些本應爲不可變對象(例如String)的值將會發生改變。安全性架構依賴於String的不可變性,如果缺少了初始化安全性,那麼可能會導致一個安全漏洞,從而使惡意代碼繞過安全檢查。
3.初始化安全性將確保,對於被正確構造的對象,所有線程都能看到由構造函數爲對象給各個final域設置的正確值,而不管採用何種方式來發布對象。而且,對於可以通過被正確構造對象中某個final域到達的任意變量(例如某個final數組中的元素,或者由一個final域引用的HashMap的內容)將同祥對於其他線程是可見的.
4.對於含有final域的對象,初始化安全性可以防止對對象的初始引用被重排序到構造過程之前。當構造函數完成時,構造函數對final域的所有寫入操作,以及對通過這些域可以到達的任何變量的寫人操作,都將被“凍結”,並且任何獲得該對象引用的線程都至少能確保看到被凍結的值。對於通過final域可到達的初始變量的寫人操作,將不會與構造過程後的操作一起被重排序。
5.的初始化安全性只能保證通過final域可達的值從構造過程完成時開始的可見性。對於通過非final域可達的值,或者在構成過程完成後可能改變的值,必須採用同步來確保可見性。
小結
Java內存模型說明了某個線程的內存操作在哪些情況下對於其他線程是可見的。其中包括確保這些操作是按照一種Happens-Before的偏序關係進行排序,而這種關係是基於內存操作和同步操作等級別來定義的。如果缺少充足的同步,那麼當線程訪問共享數據時,會發生一些非常奇怪的問題。然而,如果使用更高級的規則,例如@GuardedBy和安全發佈,那麼即使不考慮Happens-Before的底層細節,也能確保線程安全性。
併發性標註
類的標註
@ThreadSafe:表示該類是線程安全的。
@Immutable :表示類是不可變的,它包含了@ThreadSafe的含義。
@NotThreadSafe:表示該類不是線程安全的。是可選的,如果一個類沒有標註爲線程安全的,那麼就應該加上它不是線程安全的,但如果想明確地表示這個類不是線程安全的,那麼就可以使用@NotThreadSafe。
這些標註都是非侵入式的,它們對於使用者和維護人員來說都是有益的。使用者可以立即看出一個類是否是線程安全的,而維護人員也可以直接看到是否維持了線程安全性保證。對第三方來說,標準同樣很有用:工具。靜態的代碼分析工具可以驗證代碼是否遵守了由標註指定的契約,例如驗證被標註爲@Immutable的類是否是不可變的。
域和方法的標註
1.在使用加鎖的類中,應該說明哪些狀態變量由哪些鎖保護的,以及哪些鎖被用於保護這些變量。一種造成不安全性的常見原因是:某個線程安全的類一直通過加鎖來保護其狀態,但隨後又對這個類進行了修改,並添加了一些未通過鎖來保護的新變量,或者沒有使用正確加鎖來保護現有狀態變量的新方法。通過說明哪些變量由哪些鎖來保護,有助於避免這些疏忽。
2.@GuardedBy (lock) 表示只有在持有了某個特定的鎖時才能訪問這個域或方法。參數lock表示在訪問被標註的域或方法時需要持有的鎖。lock的可能取值包括:
-
@GuardedBy (“this"),表示在包含對象上的內置鎖(被標註的方法或域是該對象的成員)。
-
@GuardedBy (“fieldName"),表示與fieldName引用的對象相關聯的鎖,可以是一個隱式鎖(對於不引用一個Lock的域),也可以是一個顯式鎖(對於引用了一個Lock的域)。
-
@GuardedBy (“Class Name.fieldName"),類似於@GuardedBy (“fieldName"),但指向在另一個類的靜態域中持有的鎖對象。
-
@GuardedBy (“methodName()"),是指通過調用命名方法返回的鎖對象。
-
@GuardedBy (“ClassName.class"),是指命名類的類字面量對象。
3.通過@GuardedBy來標識每個需要加鎖的狀態變量以及保護該變量的鎖,能夠有助於代碼的維護與審查,以及通過一些自動化的分析工具找出潛在的線程安全性錯誤。