1. 程式人生 > >《Java並發編程實戰》學習筆記 - 第一部分

《Java並發編程實戰》學習筆記 - 第一部分

64位 設計模式 vol 技術 this 許可證 mod 包括 成了

最近在復習Java的一些基礎知識,重新刷了一下包括《Thinking in Java》、《Effective Java》、《Core Java》等經典書籍,發現自己對JVM以及並發這兩塊還不是很熟悉,於是先入手了這本《Java Concurrency in Practice》,原作者以JUC包核心成員為主,Doug Lea大神赫然在其中,不禁又想到讀ConcurrentHashMap代碼實現的陰影了。此外還有領導Java Collection框架設計Joshua Bloch(嗯,也是《Effective Java》的作者。。。),想必讀完會有不少收獲。

ok,讓我們在Doug Lea大神的註目下開始本書的學習!

技術分享圖片

這本書一共分四部分,基礎知識、結構化並發應用程序、活躍性、性能與測試、高級主題,本文將首先對第一部分-基礎知識進行梳理總結。


第2章 線程安全性

我們平時寫一個類,其中可能會有各種實例變量或者靜態變量,這些變量的值決定了這個類或這個類對象所處的狀態。

要編寫線程安全的代碼,其核心在於對狀態訪問操作的管理,特別是共享狀態和可變狀態的訪問。

  • 共享:意味著變量可以由多個線程訪問,例如一個全局遞增的id生成器
  • 可變:意味著變量的值在其生命周期內可以變化,例如一個service的狀態是running還是stopped

那麽什麽是線程安全性?

要清晰的定義線程安全性,首先需要弄明白所謂的“正確性”,通俗一點講,正確性就是代碼的行為跟我寫的時候想要它做的事情是完全一樣的(原文是:某個類的行為與其規範完全一致)。現在在來看線程安全的概念:

  • 一個類的線程安全性:當多個線程(調用方)訪問某個類,且調用方沒有采取任何同步措施時,不論采用何種線程調度方式或者這些線程以何種順序交替執行,這個類總是能保持其正確性,那麽這個類就是線程安全的

競態條件(Race Condition)

在多線程並發執行的過程中,不同執行順序可能出現不正確的結果,稱之為競態條件,簡而言之就是,你的代碼能不能work取決於運氣。。。

最常見的競態條件類型就是“先檢查後執行”,例如,先檢查是否為空,為空就初始化一下(想一想單例模式),再比如,先檢查service是否ready,沒有ready的話就分配資源設為ready,巴拉巴拉。。。

再一類就是“讀取-修改-寫入”操作,例如,i++,i--。

書裏舉了一個很有意思的例子,假設沒有手機,沒有大眾點評、微信、百度地圖,你跟朋友昨天下班後約了今天下午1點在某某大街的星巴克見面,結果你12:50分到了之後發現街的兩頭各有一個星巴克,你等到1點朋友還是沒來,於是你就開始想,朋友會不會去了另外一家,你決定去尋找他,當你從前門出去的時候,跟你同樣想法的你朋友,從後門進來了。。。於是,你們多久能遇到對方呢?

如何保證狀態的一致性?

通俗來說就是:如果一個狀態由多個變量一起定義,那麽在一個原子操作中,這些變量都需要被更新到。其他線程只能在操作完成之前或者操作完成之後讀取狀態。

鎖的重入

之前聽過這個概念,但是沒有仔細思考過應用場景。書裏給出了一個很典型的場景:子類被synchronized修飾的方法中調用了父類被synchronized修飾的方法,如果沒有鎖重入機制,將發生死鎖

(解釋一下,在沒有鎖重入機制時,想象其調用過程:子類執行方法拿到鎖,執行過程中調用父類方法,父類方法說,我要鎖,子類說,我在執行,我不能給你,父類說,那我也不幹,就死鎖了。。。)

好,給出正規一點的定義:如果某個線程試圖獲得一個已經由它自己持有的鎖,那麽這個請求就會成功,這一機制表明:獲取鎖的操作的粒度是線程,而不是方法調用。

鎖重入的實現:

為每個鎖關聯一個計數器和所有者線程,例如(count,pid),當count為0,表示鎖無人持有,每次持有鎖之後count++,重入鎖也是count++,退出重入的代碼塊之後,count--,退出最外層代碼塊之後count減到0,釋放鎖。

不要濫用synchronized

同步的代價是很高的,最壞情況下,把多個操作並行完全變成了挨個串行操作,必須要判斷同步代碼塊的合理大小,當執行時間較長的計算或者可能無法快速完成的操作時(例如,網絡I/O),盡量不要持有鎖。


第3章 對象的共享

本章一開始就提到了“重排序”的概念,所以說這本說看上去的定位是給有經驗的開發者看的。

重排序是指:在沒有同步的情況下,編譯器會對一些字節碼的操作進行優化,使得其執行順序跟代碼中寫的順序不一定一致,且編譯器認為這樣執行起來更快且應該不會出錯呢。

最低安全性:就算沒有使用同步,你總能讀到由其中一個線程設定的狀態(值),盡管它可能不是正確的,但至少不是隨機值。

而在64位的讀寫操作上,連這一點都不能保證,非volatile修飾的long和double變量的讀寫是會分解為兩個32位操作的,也就是說,並發情況下,可能讀到某個數的高32位和另外一個數的低32位,構造出一個不可預期的值。所以對long和double類型來說,多線程情況下要麽使用volatile修飾(稍後會介紹),要麽使用其對應的Atomic類型。

volatile變量

這是Java語言提供的一種較弱的同步機制,編譯器和運行時都會知曉volatile變量是共享的,從而不會將volatile變量的操作參與到指令重排序之中,也不會被緩存在寄存器中,因此讀取的volatile變量時候總能拿到最新的值。

volatile變量的讀寫操作不會加鎖,所以也不會造成線程阻塞,所以說它是比synchronized關鍵字更輕量級的同步機制。

如何理解“volatile變量對可見性的影響比volatile變量本身更為重要”?

線程A首先寫入一個volatile變量,線程B稍後讀取了該變量,那麽,在A寫入之前,A能訪問到的變量的值,對於B來說,在其讀取了volatile變量後也是可見的。

這段聽上去有點費腦子,其實就是說,由於volatile關鍵字避免了該變量參與指令重排序,如果A的寫操作在B的讀操作之前進行,那麽在A的寫操作之前看到的東西(比如其他變量被修改的值)B也能看到。

volatile使用場景

一般用於檢查某個狀態標記來判斷是否做點啥事,比如生命周期事件的發生(初始化、關閉等)。

volatile只能保證可見性,並不能保證原子性,例如count++,而加鎖兩者都可以保證。

當且僅當滿足以下所有條件時,才應該使用volatile變量:

  • 對變量的寫入操作不依賴變量的當前值,或者確保單線程更新變量的值
  • 該變量不會與其他狀態變量一起納入不變性條件中(這翻譯的,又說的不是人話了,我想了想,應該指的是該變量不會與其他變量進行比較操作之類,例如判斷條件a<b)
  • 在訪問變量時不需要加鎖

安全的對象發布:不要在構造過程中使得this引用“逸出”。例如在構造函數中,創建線程,但不要啟動它。

如果想要在構造函數中啟動線程,可以使用,似有構造函數加上public工廠方法來避免不正確構造過程。

線程封閉:

一種避免使用同步的方法就是不共享數據,講到這裏是不是想到了threadlocal?沒錯就是它。

在講threadlocal之前,書中還提到了一種叫棧封閉,其實就是方法中的局部變量不會被共享,且要註意防止引用對象逸出。

ThreadLocal類能使得線程中的某個值與保存值的對象關聯起來,每一個使用ThreadLocal類變量的線程都有一份獨立的副本。

舉個例子,一個transactionmanager裏面維護了一個threadlocal類的隊列,存放著一個事務和其子事務,每一個調用方在執行事務操作時,其實是讀取的自己線程set進去的事務隊列,跟別的調用方法隔離開來。

類似的例子還包括全局的dbconnection等。

不可變對象

  • 對象創建之後不能修改
  • 對象的所有的域都是final類型
  • 對象通過正確調用構造函數創建,且過程中this引用沒有逸出

Final域

final類型的域是不可修改的,基本類型的域是值不能修改,引用類型的域是引用不可修改但本身的內容是可變的,例如list set等。

除非需要某個域是可變的,否則應該將其聲明為final域

安全的發布對象:

  • 使用靜態初始化:public static XXX xxx = new XXX();
  • 將對象的引用保存到volatile域或者AtomicReference對象中
  • 將對象引用保存到某個正確構造對象的final類型域中
  • 到對象的引用保存到一個由鎖保護的域中(vector,hashtable,concurrentmap等)

第4章 對象的組合

設計線程安全的類

  • 找出構成對象狀態的所有變量
    • 分析對象的域,以及被引用對象的域
  • 找出約束狀態變量的不變性條件
  • 建立對象狀態的並發訪問管理策略

實例封閉

將數據封裝在對象內部,可以將數據的訪問限制在對象的方法上,從而更容易確保線程在訪問數據時總能持有正確的鎖。

可以封閉在類的一個實例中(私有成員),或者作用域(局部變量),或者封閉在線程內部,在線程內部方法間傳遞。

也就是說,將不可變對象封裝在內部,並對外部訪問接口/方法進行合適的加鎖處理

Java類庫中的同步容器包裝器

使用了decorator設計模式,將非線程安全的容器類封裝在一個同步的容器包裝器對象中,而包裝器將每一個接口中的方法都實現為同步方法,並將調用請求轉發到底層的容器對象上。

在現有的線程安全類中添加功能

  • 繼承並添加新的同步方法,但是需要考慮到父類的同步策略如果改變,可能會導致子類的同步策略失效
  • 客戶端加鎖:對於使用某個對象X的客戶端代碼,使用X本身用於保護其狀態的鎖來保護這段客戶端代碼。必須要了解對象X使用的是哪個鎖。
    • 這裏說的是,就算方法上用synchronized修飾了,並不能保證方法內部想要保護的引用對象的操作是原子的,需要確定引用對象的鎖是哪一個,對該對象進行synchronize。例如,synchronized(list)
  • 組合:需要同步的對象上封裝一層,將實際操作委托給底層的實際對象處理,同時實現各接口的同步方法

不要忽視文檔

在文檔中說明客戶代碼需要了解的安全性保證,以及代碼維護人員需要了解的同步策略

對於使用類的開發人員來說,往往很難註意到使用的類可能存在的線程不安全隱患,例如:java.text.SimpleDateFormat類就不是線程安全的。

一種好的實踐是,如果類沒有顯式說明是線程安全的,那麽使用的時候就假設它是線程不安全的,並在需要的地方考慮使用同步策略。


第5章 基礎構建模塊

同步容器類

Vector以及Hashtable: 其實現方式是,將狀態都封裝起來,對所有的公有方法進行同步。使得每次只有一個線程能訪問容器的狀態。所以,就性能很慢啊。

而且,在使用時,仍然需要通過客戶端加鎖來維護復合操作的原子性。

隱藏的叠代器

容器類的tostring、containsall、removeall等方法可能會觸發叠代器的調用,如果此時內容被修改會拋出ConcurrentModificationException。

並發容器類

ConcurrentHashmap

用於替代同步容器的使用,可以極大的提高伸縮性並降低風險,該容器就是專門針對多線程並發而設計的。

使用Lock Striping分段鎖機制,任意數量的讀線程可以並發的訪問map,讀寫操作可以同時訪問map,一定數量的寫入線程可以並發的修改map(取決於segment的數量)。

但是size和isempty的語義被相應的減弱了,其返回可能只是一個估計的值,隨時失效,但是考慮到並發場景中,其實用處很小。

添加了原子操作的支持,包括:若沒有則添加,若相等則移除,若相等則替換操作。

CopyOnWrite容器系列

CopyOnWriteList/CopyOnWriteSet

其安全性通過在修改時創建被重新發布一個新的容器副本來實現。適用於叠代操作比修改操作多的多的場景,畢竟復制的開銷很大。

阻塞隊列:生產者-消費者模式

BlockingQueue

支持任意數量的生產者和消費者,典型場景為線程池和工作隊列。

在構建高可靠的應用程序時,有界隊列是一種強大的資源管理工具,它們能抑制並防止生產過多的工作項,使得應用程序在復合過載的情況下變得健壯。

雙端隊列(Deque,讀作“deck”,一直以為叫DQ來著。。。)

適用於工作密取模式,也就是能者多勞,我從隊頭上取任務,做完之後沒事幹了,不行,我找個別的隊列從隊尾開始取任務繼續幹

Latch(閉鎖)

相當於一扇門,在閉鎖達到結束狀態之前,門一直關著不讓任何線程通過,一旦門打開,將永遠打開。

適用場景:確保某些活動直到其他活動都完成後才繼續執行。

例如,等待所有資源初始化完才開始計算;啟動服務時的依賴鏈,被依賴的服務啟動後才能啟動當前服務;等待所有參與者就緒,比如遊戲的準備。

實現:CountDownLatch

使用方法:設定等待的事件數量,對應的事件執行完之後調用CountDown方法,主線程調用Await方法一直阻塞,直到減到0為止,繼續執行下面邏輯。

例如,可以用來進行簡單的並發測試,n個線程先啟動,然後集體await,主線程調用countDown,使得所有線程同時開始執行任務。

其他工具就不贅述了,都是JUC包裏的

Futuretask(異步調用,callable返回)

Semaphore(用於實現某種資源池,或對容器大小施加邊界):初始化一定數量的許可證,每次操作都需要獲取一個許可證並釋放一個許可證

CyclicBarrier 柵欄: 用於需要反復匯集再執行下一任務的場景。

在不涉及I/O操作和共享數據訪問的計算問題中,當線程數量為cpu的個數或者cpu的個數+1時,將獲得最優的吞吐量。更多的線程並不會帶來任何幫助,甚至會降低性能。因為資源競爭會非常影響性能。

如何構建高效且可伸縮的結果緩存

並發容器+異步計算+原子操作

第一部分到這裏基本就完了,全書看完之後我會再編輯整理一下。

《Java並發編程實戰》學習筆記 - 第一部分