多執行緒程式設計進階——Java類庫中的鎖

FuyunWang發表於2018-03-07

在Java多執行緒中,可以使用synchronized關鍵字來實現執行緒之間同步互斥,在JDK1.5以後,Java類庫中新增了Lock介面用來實現類似的鎖功能。下面會逐一介紹關於Java類庫中所提供的鎖功能。

鎖可以理解為對共享資料進行保護的許可證,對於同一把鎖保護的共享資料而言,任何執行緒對這些共享資料的訪問都需要先持有該鎖。一把鎖只能同時被一個執行緒持有,當以一個該鎖的持有執行緒對共享資料訪問結束之後必須釋放該鎖,以便讓其他執行緒持有。鎖的持有執行緒在鎖的獲得和鎖的釋放之間的這段時間所執行的程式碼被稱為臨界區。

鎖能夠保護共享資料以實現執行緒安全,鎖的主要作用有保障原子性、保障可見性和保障有序性。由於鎖具有互斥性,因此當執行緒執行臨界區中的程式碼時,其他執行緒無法做到干擾,臨界區中的程式碼也就具有了不可分割的原子特性。

鎖具有排他性,即一個鎖一次只能被一個執行緒持有,這種鎖又被稱之為排他鎖或互斥鎖。當然,新版本的JDK中為了效能優化還推出了另一種鎖——讀寫鎖,讀寫鎖是作為了排它鎖的一種改進而存在的。

按照Java虛擬機器對鎖的實現方式劃分,Java平臺中的鎖包括內部鎖(主要是通過synchronized實現)和顯式鎖(主要是通過Lock介面及其實現類實現),下文將逐一介紹。

公平鎖和非公平鎖:

鎖Lock分為"公平鎖"和"非公平鎖",公平鎖表示執行緒獲取鎖的順序是按照執行緒加鎖的順序來分配的,即先來先得的FIFO先進先出順序。非公平鎖就是一種獲取鎖的搶佔機制,是隨機獲得鎖的,和公平鎖不一樣的就是先來的不一定先得到鎖,這個方式可能造成某些執行緒一直拿不到鎖,結果也就是不公平的了。

內部鎖屬於非公平鎖,而顯式鎖不僅支援公平鎖而且支援非公平鎖。

內部鎖——眾所周知的synchronized

Java平臺中的任何一個物件都有唯一一個與之關聯的鎖,這種鎖被稱之為監視器(或者叫內部鎖)。內部鎖是一種排它鎖,它能保證原子性、可見性和有序性。內部鎖就由synchronized關鍵字實現。

synchronized可以修飾方法或者程式碼塊。當synchronized修飾方法的時候,該方法內部的程式碼就屬於一個臨界區,該方法就屬於一個同步方法。此時一個執行緒對該方法內部的變數的更新就保證了原子性和可見性,從而實現了執行緒安全。當synchronized修飾程式碼塊的時候,需要一個鎖控制程式碼(一個物件的引用或者是一個可以返回物件的表示式),此時synchronized關鍵字引導的程式碼塊就是臨界區;同步塊的鎖控制程式碼可以寫為this關鍵字,此時表示為當前物件,鎖控制程式碼對應的監視器就被稱之為相應同步塊的引導鎖。

作為鎖控制程式碼的變數通常以private final修飾,防止鎖控制程式碼變數的值改變之後,導致執行同一個同步塊的多個執行緒使用不同的鎖,從而避免了競態。

同步例項方法相當於以"this"為引導鎖的同步塊;同步靜態方法相當於以當前類物件為引導鎖的同步塊。

執行緒讀內部鎖的申請和釋放均由Java虛擬機器負責代為實施,內部鎖的使用不會導致鎖洩漏,這是因為Java編譯器在將同步塊程式碼編譯成位元組碼的時候,對臨界區中可能丟擲的而程式程式碼中又未捕獲的異常進行了特殊的處理,這使得臨界區的程式碼即使丟擲異常也不會妨礙內部鎖的釋放。

注意Java虛擬機器會為每一個內部鎖分配一個入口集用於存放等待獲得相應內部鎖的執行緒,當內部鎖的持有執行緒釋放當前鎖的時候,可能是入口集中處於BLOCKED狀態的執行緒獲得當前鎖也可能是處於RUNNABLE狀態的其他執行緒。內部鎖的競爭是激烈的,也是不公平的,可能等待了長時間的執行緒沒有獲得鎖,也可能是沒有經過等待的執行緒直接就獲得了鎖。

顯式的加鎖和解鎖——Lock介面

在Java5.0之前,在協調對共享物件的訪問時可以使用的機制只有synchronized和volatile。在Java 5.0中增加了一種新的機制:Lock介面(以及其實現類如ReentrantLock等),Lock介面中定義了一組抽象的加鎖操作。與synchronized不同的是,synchronized可以方便的隱式的獲取鎖,而Lock介面則提供了一種顯式獲取鎖的特性。

顯式鎖是自從JDK1.5之後開始引入的排它鎖。顯式鎖是Lock介面的例項,Lock介面的預設實現類是ReentrantLock。

重入鎖——ReentrantLock類

在詳細介紹關於ReentrantLock類的詳細資訊之前,先介紹一下鎖的可重入性的概念。

	如果一個執行緒持有一個鎖的時候還能繼續成功的申請該鎖,那麼我們就稱該鎖是可重入的,否則我們就稱該鎖是非可重入的。
複製程式碼

ReentrantLock是一個可重入鎖,ReentrantLock類與synchronized類似,都可以實現執行緒之間的同步互斥。但ReentrantLock類此外還擴充套件了更多的功能,如嗅探鎖定、多路分支通知等,在使用上也比synrhronized更加的靈活。

上面已經提到ReentrantLock是一個既支援公平支援非公平的顯示鎖,所以在例項化ReentrantLock類的時候我們可以明確的看到ReentrantLock的一個構造簽名為ReentrantLock(boolean fair),當我們傳入true的時候得到的鎖是一個公平鎖。公平鎖的開銷較非公平鎖的開銷大,因此顯式鎖預設使用的是非公平的排程策略。由於ReentrantLock可以具有公平性,因此:

預設情況下使用內部鎖,而當多數執行緒持有一個鎖的時間相對較長或者執行緒申請鎖的平均時間間隔相對長的情況下我們可以考慮使用顯式鎖。

讀寫鎖——(Read/Write Lock)

讀寫鎖是一種改進型的排它鎖。讀寫鎖允許多個執行緒可以同時讀取(只讀)共享變數。讀寫鎖是分為讀鎖和寫鎖兩種角色的,讀執行緒在訪問共享變數的時候必須持有相應讀寫鎖的讀鎖,而且讀鎖是共享的、多個執行緒可以共同持有的;寫鎖是排他的,以一個執行緒在持有寫鎖的時候,其他執行緒無法獲得相應鎖的寫鎖或讀鎖。總之,讀寫鎖通過讀寫鎖的分離從而提高了併發性。

ReadWriteLock介面是對讀寫鎖的抽象,其預設的實現類是ReentrantReadWriteLock。ReadWriteLock定義了兩個方法readLock()和writeLock(),分別用於返回相應讀寫鎖例項的讀鎖和寫鎖。這兩個方法的返回值型別都是Lock。

讀寫鎖主要用於讀執行緒持有鎖的時間比較長的情景下。

鎖的替代

多個執行緒共享同一個非執行緒安全物件時,我們往往採用鎖來保證執行緒安全性。但是,鎖也有其弊端,比如鎖的開銷和在使用鎖的時候容易發生死鎖等。所以在Java中也提供了一些對於某些情況下替代鎖的同步機制解決方案,如volatile關鍵字、final關鍵字、static關鍵字、原子變數以及各種併發容器和框架,這些大多數內容我將以後介紹;此外我們還可以採用一定的多執行緒設計模式來完成多執行緒的同步。

首先介紹在併發程式設計中,我們使用和共享物件可以採用的一些策略。上面所提到的Java內建的一些工具類和關鍵字以及我們所採用的設計模式大多都基於這些策略的思想。

  1. 採用執行緒特有物件: 各個不同的執行緒建立各自的例項,一個例項只能被一個執行緒訪問的物件就被稱之為執行緒的特有物件。採用執行緒特有物件,保障了對非執行緒安全物件的訪問的執行緒安全。
  2. 只讀共享:在沒有額外同步的情況下,共享的只讀物件可以有可以由多個執行緒併發訪問,但是任何執行緒都不能修改它。共享的只讀物件包括不可變物件和事實不可變物件。
  3. 執行緒安全共享:執行緒安全的物件在其內部實現同步,多個執行緒可以通過物件的公有介面來進行訪問而不需要進一步的同步。
  4. 保護物件:被保護的物件只能通過持有特定的鎖來訪問。保護物件包括封裝在其他執行緒安全物件中的物件,以及已釋出的並且由某個特定鎖保護的物件。

這裡我首先介紹其中的volatile關鍵字、ThreadLocal二者在鎖的某些功能上的替代作用。

volatile關鍵字

通過volatile關鍵字的使用,我們可以保證共享變數的可見性和有序性。不同於常見的鎖,在原子性方面,volatile僅能保障寫volatile變數操作的原子性,沒有鎖的排他性;此外,volatile關鍵字的使用不會引起上下文的切換,因此volatile常被稱為輕量級鎖。

多執行緒程式設計基礎一文中,我已經初步介紹了Java的記憶體模型。volatile最主要的就是實現了共享變數的記憶體可見性,其實現的原理是:volatile變數的值每次都會從快取記憶體或者主記憶體中讀取,對於volatile變數,每一個執行緒不再會有一個副本變數,所有執行緒對volatile變數的操作都是對同一個變數的操作。

volatile變數的開銷包括讀變數和寫變數兩個方面。volatile變數的讀、寫操作都不會導致上下文的切換,因此volatile的開銷比鎖小。但是volatile變數的值不會暫存在暫存器中,因此讀取volatile變數的成本要比讀取普通變數的成本更高。

ThreadLocal

ThreadLocal,即執行緒變數,是一個以ThreadLocal物件為鍵、任意物件為值的儲存結構。這個結構被附帶線上程上,也就是說一個執行緒可以根據一個ThreadLocal物件查詢到繫結在這個執行緒上的一個值。

ThreadLocal採用的是上述策略中的第一種設計思想——採用執行緒的特有物件.採用執行緒的特有物件,我們可以保障每一個執行緒都具有各自的例項,同一個物件不會被多個執行緒共享,ThreadLocal是維護執行緒封閉性的一種更加規範的方法,這個類能使執行緒中的某個值與儲存值的物件關聯起來,從而保證了執行緒特有物件的固有執行緒安全性。

ThreadLocal類相當於執行緒訪問其執行緒特有物件的代理,即各個執行緒通過這個物件可以建立並訪問各自的執行緒特有物件,泛型T指定了相應執行緒持有物件的型別。一個執行緒可以使用不同的ThreadLocal例項來建立並訪問其不同的執行緒持有物件。多個執行緒使用同一個ThreadLocal例項所訪問到的物件時型別T的不同例項。代理的關係圖如下:

Aaron Swartz

ThreadLocal提供了get和set等訪問介面或方法,這些方法為每一個使用該變數的執行緒都存有一份獨立的副本,因此get總是能返回由當前執行執行緒在呼叫set時設定的最新值。其主要使用的方法如下:

	public T get(): 獲取與當前執行緒中ThreadLocal例項關聯的執行緒特有物件。
	public void set(T value):重新關聯當前執行緒中ThreadLocal例項所對應的執行緒特有物件。
	protected T initValue():如果沒有呼叫set(),在初始化threadlocal物件的時候,該方法的返回值就是當前執行緒中與ThreadLocal例項關聯的執行緒特有物件。
	public void remove():刪除當前執行緒中ThreadLocal和執行緒特有物件的關係。
複製程式碼

那麼ThreadLocal底層是如何實現Thread持有自己的執行緒特有物件的?檢視set()方法的原始碼:

Aaron Swartz

Aaron Swartz
可以看到,當我們呼叫threadlocal的set方法來儲存當前執行緒的特有物件時,threadlocal會取出當前執行緒關聯的threadlocalmap物件,然後呼叫ThreadLocalMap物件的set方法來進行當前給定值的儲存。

Aaron Swartz

每一個Thread都會維護一個ThreadLocalMap物件,ThreadLocalMap是一個類似Map的資料結構,但是它沒有實現任何Map的相關介面。ThreadLocalMap是一個Entry陣列,每一個Entry物件都是一個"key-value"結構,而且Entry物件的key永遠都是ThreadLocal物件。當我們呼叫ThreadLocal的set方法時,實際上就是以當前ThreadLocal物件本身作為key,放入到了ThreadLocalMap中。

可能發生記憶體洩漏:

通過檢視Entry結構可知,Entry屬於WeakReference型別,因此Entry不會阻止被引用的ThreadLocal例項被垃圾回收。當一個ThreadLocal例項沒有對其可達的強引用時,這個例項就可以被垃圾回收,即其所在的Entry的key會被置為null,但是如果建立ThreadLocal的執行緒一直持續執行,那麼這個Entry物件中的value就有可能一直得不到回收,從而發生記憶體洩露。

解決記憶體洩漏的最有效方法就是,在使用完ThreadLocal之後,要注意呼叫threadlocal的remove()方法釋放記憶體。

相關文章