3.實戰java高併發程序設計--JDK併發包---3.1

3.1 多線程的團隊協作:同步控制

同步控制是併發程序必不可少的重要手段。之前介紹的關鍵字synchronized就是一種最簡單的控制方法,它決定了一個線程是否可以訪問臨界區資源。同時,Object.wait()方法和Object.notify()方法起到了線程等待和通知的作用。這些工具對於實現複雜的多線程協作起到了重要的作用。下面我們首先將介紹關鍵字synchronized、Object.wait()方法和Object.notify()方法的替代品(或者說是增強版)—重入鎖。

3.1.1 關鍵字synchronized的功能擴展:重入鎖

重入鎖可以完全替代關鍵字synchronized。在JDK 5.0的早期版本中,重入鎖的性能遠遠優於關鍵字synchronized,但從JDK 6.0開始,JDK在關鍵字synchronized上做了大量的優化,使得兩者的性能差距並不大。

重入鎖使用java.util.concurrent.locks.ReentrantLock類來實現。下面是一段最簡單的重入鎖使用案例。

上述代碼第7~12行使用重入鎖保護臨界區資源i,確保多線程對i操作的安全性。從這段代碼可以看到,與關鍵字synchronized相比,重入鎖有着顯示的操作過程。開發人員必須手動指定何時加鎖,何時釋放鎖。也正因爲這樣,重入鎖對邏輯控制的靈活性要遠遠優於關鍵字synchronized。但值得注意的是,在退出臨界區時,必須記得釋放鎖(代碼第11行),否則,其他線程就沒有機會再訪問臨界區了。

你可能會對重入鎖的名字感到奇怪。鎖爲什麼要加上“重入”兩個字呢?從類的命名上看,Re-Entrant-Lock翻譯成重入鎖非常貼切。之所以這麼叫,是因爲這種鎖是可以反覆進入的。當然,這裏的反覆僅僅侷限於一個線程。上述代碼的第7~12行,可以寫成下面的形式

在這種情況下,一個線程連續兩次獲得同一把鎖是允許的。如果不允許這麼操作,那麼同一個線程在第2次獲得鎖時,將會和自己產生死鎖。程序就會“卡死”在第2次申請鎖的過程中。但需要注意的是,如果同一個線程多次獲得鎖,那麼在釋放鎖的時候,也必須釋放相同次數。如果釋放鎖的次數多了,那麼會得到一個java.lang.IllegalMonitorStateException異常,反之,如果釋放鎖的次數少了,那麼相當於線程還持有這個鎖,因此,其他線程也無法進入臨界區。

除使用上的靈活性以外,重入鎖還提供了一些高級功能。比如,重入鎖可以提供中斷處理的能力。

1.中斷響應lockInterruptibly()

對於關鍵字synchronized來說,如果一個線程在等待鎖,那麼結果只有兩種情況,要麼它獲得這把鎖繼續執行,要麼它就保持等待。而使用重入鎖,則提供另外一種可能,那就是線程可以被中斷。也就是在等待鎖的過程中,程序可以根據需要取消對鎖的請求。有些時候,這麼做是非常有必要的。。如果一個線程正在等待鎖,那麼它依然可以收到一個通知,被告知無須等待,可以停止工作了。這種情況對於處理死鎖是有一定幫助的。

在這裏,對鎖的請求,統一使用lockInterruptibly()方法。這是一個可以對中斷進行響應的鎖申請動作,即在等待鎖的過程中,可以響應中斷

在代碼第47行,主線程main處於休眠狀態,此時,這兩個線程處於死鎖的狀態。在代碼第49行,由於t2線程被中斷,故t2會放棄對lock1的申請,同時釋放已獲得的lock2。這個操作導致t1線程可以順利得到lock2而繼續執行下去。

2.鎖申請等待限時tryLock()

除了等待外部通知之外,要避免死鎖還有另外一種方法,那就是限時等待。依然以約朋友打球爲例,如果朋友遲遲不來,又無法聯繫到他,那麼在等待1到2個小時後,我想大部分人都會掃興離去。對線程來說也是這樣。通常,我們無法判斷爲什麼一個線程遲遲拿不到鎖。也許是因爲死鎖了,也許是因爲產生了飢餓。如果給定一個等待時間,讓線程自動放棄,那麼對系統來說是有意義的。我們可以使用tryLock()方法進行一次限時的等待。

在這裏,tryLock()方法接收兩個參數,一個表示等待時長,另外一個表示計時單位。這裏的單位設置爲秒,時長爲5,表示線程在這個鎖請求中最多等待5秒。如果超過5秒還沒有得到鎖,就會返回false。如果成功獲得鎖,則返回true。在本例中,由於佔用鎖的線程會持有鎖長達6秒,故另一個線程無法在5秒的等待時間內獲得鎖,因此請求鎖會失敗。

ReentrantLock.tryLock()方法也可以不帶參數直接運行。在這種情況下,當前線程會嘗試獲得鎖,如果鎖並未被其他線程佔用,則申請鎖會成功,並立即返回true。如果鎖被其他線程佔用,則當前線程不會進行等待,而是立即返回false。這種模式不會引起線程等待,因此也不會產生死鎖。

3.公平鎖 new ReentrantLock(true)

公平的鎖,則不是這樣,它會按照時間的先後順序,保證先到者先得,後到者後得。公平鎖的一大特點是:它不會產生飢餓現象。只要你排隊,最終還是可以等到資源的。如果我們使用synchronized關鍵字進行鎖控制,那麼產生的鎖就是非公平的。而重入鎖允許我們對其公平性進行設置。它的構造函數如下:

當參數fair爲true時,表示鎖是公平的。公平鎖看起來很優美,但是要實現公平鎖必然要求系統維護一個有序隊列,因此公平鎖的實現成本比較高,性能卻非常低下,因此,在默認情況下,鎖是非公平的。如果沒有特別的需求,則不需要使用公平鎖。公平鎖和非公平鎖在線程調度表現上也是非常不一樣的。

對上面ReentrantLock的幾個重要方法整理如下。

● lock():獲得鎖,如果鎖已經被佔用,則等待。

● lockInterruptibly():獲得鎖,但優先響應中斷。

● tryLock():嘗試獲得鎖,如果成功,則返回true,失敗返回false。該方法不等待,立即返回。

● tryLock(long time, TimeUnit unit):在給定時間內嘗試獲得鎖。

● unlock():釋放鎖。

3.1.2 重入鎖的好搭檔:Condition : lock.newCondition()

如果大家理解了Object.wait()方法和Object.notify()方法,就能很容易地理解Condition對象了。它與wait()方法和notify()方法的作用是大致相同的。但是wait()方法和notify()方法是與synchronized關鍵字合作使用的,而Condition是與重入鎖相關聯的。通過lock接口(重入鎖就實現了這一接口)的Condition newCondition()方法可以生成一個與當前重入鎖綁定的Condition實例。利用Condition對象,我們就可以讓線程在合適的時間等待,或者在某一個特定的時刻得到通知,繼續執行。

● await()方法會使當前線程等待,同時釋放當前鎖,當其他線程中使用signal()方法或者signalAll()方法時,線程會重新獲得鎖並繼續執行。或者當線程被中斷時,也能跳出等待。這和Object.wait()方法相似。

● awaitUninterruptibly()方法與await()方法基本相同,但是它並不會在等待過程中響應中斷。

● singal()方法用於喚醒一個在等待中的線程,singalAll()方法會喚醒所有在等待中的線程。這和Obejct.notify()方法很類似。

與Object.wait()方法和notify()方法一樣,當線程使用Condition.await()方法時,要求線程持有相關的重入鎖,在Condition.await()方法調用後,這個線程會釋放這把鎖.同理,在Condition.signal()方法調用時,也要求線程先獲得相關的鎖。在signal()方法調用後,系統會從當前Condition對象的等待隊列中喚醒一個線程。一旦線程被喚醒,它會重新嘗試獲得與之綁定的重入鎖,一旦成功獲取,就可以繼續執行了。因此,在signal()方法調用之後,一般需要釋放相關的鎖,讓給被喚醒的線程,讓它可以繼續執行.

比如,在本例中,第24行代碼就釋放了重入鎖,如果省略第24行,那麼,雖然已經喚醒了線程t1,但是由於它無法重新獲得鎖,因而也就無法真正的繼續執行。

3.1.3 允許多個線程同時訪問:信號量(Semaphore)

信號量爲多線程協作提供了更爲強大的控制方法。從廣義上說,信號量是對鎖的擴展。無論是內部鎖synchronized還是重入鎖ReentrantLock,一次都只允許一個線程訪問一個資源,而信號量卻可以指定多個線程,同時訪問某一個資源。信號量主要提供了以下構造函數:

在構造信號量對象時,必須要指定信號量的准入數,即同時能申請多少個許可。當每個線程每次只申請一個許可時,這就相當於指定了同時有多少個線程可以訪問某一個資源。信號量的主要邏輯方法有:

ExecutorService exe = Executors.newFixedThreadPool(20);

exe.submit()此方法是線程池的用法

 

3.1.4 ReadWriteLock讀寫鎖 lock.writeLock(),lock.readLock()

ReadWriteLock是JDK 5中提供的讀寫分離鎖。讀寫分離鎖可以有效地幫助減少鎖競爭,提升系統性能。用鎖分離的機制來提升性能非常容易理解,比如線程A1、A2、A3進行寫操作,B1、B2、B3進行讀操作,如果使用重入鎖或者內部鎖,從理論上說所有讀之間、讀與寫之間、寫和寫之間都是串行操作。當B1進行讀取時,B2、B3則需要等待鎖。由於讀操作並不對數據的完整性造成破壞,這種等待顯然是不合理的。因此,讀寫鎖就有了發揮功能的餘地。

● 讀-讀不互斥:讀讀之間不阻塞。

● 讀-寫互斥:讀阻塞寫,寫也會阻塞讀。

● 寫-寫互斥:寫寫阻塞。

如果在系統中,讀操作的次數遠遠大於寫操作的次數,則讀寫鎖就可以發揮最大的功效,提升系統的性能。這裏我給出一個稍微誇張點的案例來說明讀寫鎖對性能的幫助。

最終用2秒完成了,如果不是讀寫鎖而是普通鎖,那麼用時20秒

3.1.5 倒計數器:CountDownLatch

CountDownLatch是一個非常實用的多線程控制工具類。這個工具通常用來控制線程等待,它可以讓某一個線程等待直到倒計數結束,再開始執行

對於倒計數器,一種典型的場景就是火箭發射。在火箭發射前,爲了保證萬無一失,往往還要對各項設備、儀器進行檢查。只有等所有檢查都完成後,引擎才能點火。這種場景就非常適合使用CountDownLatch。它可以使點火線程等待所有檢查線程全部完工後再執行

CountDownLatch的構造函數接收一個整數作爲參數,即當前這個計數器的計數個數。

上述代碼第2行生成一個CountDownLatch實例,計數數量爲10,這表示需要10個線程完成任務後等待在CountDownLatch上的線程才能繼續執行。代碼第10行使用了CountDownLatch.countdown()方法,也就是通知CountDownLatch,一個線程已經完成了任務,倒計數器減1。第21行使用CountDownLatch.await()方法,要求主線程等待所有檢查任務全部完成待10個任務全部完成後,主線程才能繼續執行

主線程在CountDownLatch上等待,當所有檢查任務全部完成後,主線程方能繼續執行。

3.1.6 循環柵欄:CyclicBarrier

CyclicBarrier是另外一種多線程併發控制工具。和CountDownLatch非常類似,它也可以實現線程間的計數等待,但它的功能比CountDownLatch更加複雜且強大。

CyclicBarrier比CountDownLatch略微強大一些,它可以接收一個參數作爲barrierAction。所謂barrierAction就是當計數器一次計數完成後,系統會執行的動作。如下構造函數,其中,parties表示計數總數,也就是參與的線程總數。

上述代碼第57行創建了CyclicBarrier實例,並將計數器設置爲10,要求在計數器達到指標時,執行第43行的run()方法。每一個士兵線程都會執行第11行定義的run()方法。在第14行,每一個士兵線程都會等待,直到所有的士兵都集合完畢。集合完畢意味着CyclicBarrier的一次計數完成,當再一次調用CyclicBarrier.await()方法時,會進行下一次計數。第15行模擬了士兵的任務。當一個士兵任務執行完,他就會要求CyclicBarrier開始下一次計數,這次計數主要目的是監控是否所有的士兵都已經完成了任務。一旦任務全部完成,第35行定義的BarrierRun就會被調用,打印相關信息。

3.1.7 線程阻塞工具類:LockSupport

LockSupport是一個非常方便實用的線程阻塞工具,它可以在線程內任意位置讓線程阻塞。與Thread.suspend()方法相比,它彌補了由於resume()方法發生導致線程無法繼續執行的情況。和Object.wait()方法相比,它不需要先獲得某個對象的鎖,也不會拋出InterruptedException異常。

LockSupport的靜態方法park()可以阻塞當前線程,類似的還有parkNanos()、parkUntil()等方法。它們實現了一個限時的等待。

注意,這裏只是將原來的suspend()方法和resume()方法用park()方法和unpark()方法做了替換。當然,我們依然無法保證unpark()方法發生在park()方法之後。但是執行這段代碼,你會發現,它自始至終都可以正常地結束,不會因爲park()方法而導致線程永久掛起。

這是因爲LockSupport類使用類似信號量的機制。它爲每一個線程準備了一個許可,如果許可可用,那麼park()方法會立即返回,並且消費這個許可(也就是將許可變爲不可用),如果許可不可用,就會阻塞,而unpark()方法則使得一個許可變爲可用(但是和信號量不同的是,許可不能累加,你不可能擁有超過一個許可,它永遠只有一個)。這個特點使得:即使unpark()方法操作發生在park()方法之前,它也可以使下一次的park()方法操作立即返回。這也就是上述代碼可順利結束的主要原因。同時,處於park()方法掛起狀態的線程不會像suspend()方法那樣還給出一個令人費解的Runnable狀態。它會非常明確地給出一個WAITING狀態,甚至還會標註是park()方法引起的

注意,在堆棧中,我們甚至還看到了當前線程等待的對象,這裏就是ChangeObjectThread實例。

除了有定時阻塞的功能,LockSupport.park()方法還能支持中斷影響。但是和其他接收中斷的函數很不一樣,LockSupport.park()方法不會拋出InterruptedException異常。它只會默默返回,但是我們可以從Thread.interrupted()等方法中獲得中斷標記。

3.1.8 Guava和RateLimiter限流

Guava是Google下的一個核心庫,提供了一大批設計精良、使用方便的工具類。許多Java項目都使用Guava作爲其基礎工具庫來提升開發效率,我們可以認爲Guava是JDK標準庫的重要補充。在這裏,將給大家介紹Guava中的一款限流工具RateLimiter。

一種簡單的限流算法就是給出一個單位時間,然後使用一個計數器counter統計單位時間內收到的請求數量,當請求數量超過門限時,餘下的請求丟棄或者等待。但這種簡單的算法有一個嚴重的問題,就是很難控制邊界時間上的請求。假設時間單位是1秒,每秒請求不超過10個。如果在這一秒的前半秒沒有請求,而後半秒有10個請求,下一秒的前半秒又有10個請求,那麼在這中間的一秒內,就會合理處理20個請求,而這明顯違反了限流的基本需求。這是一種簡單粗暴的總數量限流而不是平均限流,如圖3.3所示。

因此,更爲一般化的限流算法有兩種:漏桶算法和令牌桶算法。

漏桶算法的基本思想是:利用一個緩存區,當有請求進入系統時,無論請求的速率如何,都先在緩存區內保存,然後以固定的流速流出緩存區進行處理,如圖3.4所示。漏桶算法的特點是無論外部請求壓力如何,漏桶算法總是以固定的流速處理數據。漏桶的容積和流出速率是該算法的兩個重要參數。


3.2 線程複用:線程池

 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章