《Java特種兵》5.2 執行緒安全
本文是《Java特種兵》的樣章,感謝博文視點和作者授權本站釋出
接下來的內容都將基於多核處理器,因為單核處理器不會出現將要談到的可見性問題,不過並不代表單核CPU上多個執行緒就沒有一致性問題,因為CPU有時間片原則,還會有其他的一些問題,例如重排序。
本節的內容比較偏重於理論化,看過的同學應該知道這部分內容並不容易看懂。不過要學習併發程式設計就必然會涉及這方面的內容,所以我們一定要努力去學習,胖哥會盡量通過自己的理解,用一些相對簡單的方式讓大家得到一些感性認識,進一步的深入就得靠大家自己多去參看相關的資料了。
5.2.1 併發記憶體模型概述
前文中提到,為了提升效能CPU會Cache許多資料。但在多核CPU下,每個CPU都有自己獨立的Cache,每個CPU都可以修改同一個單元資料,因此它們會有自己的一套“快取一致性協議”,這個一致性是從CPU的角度認為某些來自主存的資料的讀取和修改需要一定的一致性保障。
在JVM執行時,每個執行緒的私有棧在使用共享資料時,會先將共享資料拷貝到棧頂進行運算,這份資料其實是一個副本,因此也同樣存在多個執行緒修改同一個記憶體單元的一致性問題。JVM自己完成了一套記憶體模型(Java Memory Model,JMM)的規範,這套模型將基於不同的平臺去做特定的優化,JMM的基本管理策略是滿足於一種通用的規範,所以這件事情是比較麻煩的。這種模型在JDK 1.2就有了,直到JDK 1.5(JSR-133)及以後的版本中,JVM的記憶體管理才開始逐步成熟起來。
要了解併發的問題,就要知道併發的問題到底是什麼,下面給出一段簡單的程式碼來做個測試,以便對併發的問題有一個初步的瞭解。
程式碼清單5-5 初識執行緒不安全的程式碼
public class NoVisiabilityTest { private static class ReadThread extends Thread { private boolean ready; private int number; public void run() { while(!ready) { number++; } System.out.println(ready); } public void readyOn() { this.ready = true; } } public static void main(String []args) throws InterruptedException { ReadThread readThread = new ReadThread(); readThread.start(); Thread.sleep(200); readThread.readyOn(); System.out.println(readThread.ready); } }
這段程式碼請確保執行在Server模式下,執行的結果可能會與我們所預想的不一樣,不過某些JDK本身不支援Server模式,則達不到本例子所想要模擬的結果。
由於伺服器端程式絕大部分都是在Server模式下,所以本例也並非故意製造變態環境,因此編寫伺服器端程式的同學應當關注這樣的問題。
按照常理來講,這段程式碼會在執行一段時間後自動退出,因為readyOn()方法會將物件的ready引數設定為true,那麼while(!ready)會失敗,理論上就會跳出迴圈,結束子執行緒的執行。但是在Server模式下執行時,這段程式碼會走入死迴圈,主執行緒輸出了結果,而子執行緒一直都不結束。
在同一個物件內部,一個執行緒將該物件的屬性修改後,另一個執行緒看不到該屬性被修改後的結果,或者說未必能馬上看到結果,這就是併發模型中的“可見性”問題。因為普通變數的修改並不需要立即寫回到主存,而執行緒讀取時也不需要每一次都從主存中去讀取,因此就可能導致這樣的現象。此時若在迴圈中增加一個Thread.yield();操作就可以讓執行緒讓步CPU,進而很可能到主存中讀到最新的ready值,達到退出迴圈的目的。也可以在迴圈中加一條System.out語句,或者將ready變數增加一個volatile修飾符也可以達到退出的目的。
我們研究這段程式碼的目的不僅僅是為了解決一個死迴圈的問題,而且是要告訴大家程式碼執行過程中確實存在可見性問題。或許前面提到的幾種方法可以達到目的,但我們可能並不是太清楚是怎麼解決的,或者說在什麼情況下用這種方式來解決。
為了解決這個問題,早期做法都是加悲觀鎖,隨著計算機需求的發展,大牛們對技術內在做了許多研究,他們發現這種方式的開銷很大,我們希望有更細粒度的控制方式來提升效能。
從巨集觀上我們將JVM記憶體劃分為堆和棧兩個部分,堆記憶體就是Java物件所使用的區域,在JMM的定義中通常把這塊空間叫作“Main Memroy”(主存)。棧空間內部應當包含區域性變數、運算元棧、當前方法的常量池指標、當前方法的返回地址等資訊,這塊空間在我們看來是最接近CPU運算的,也是每個執行緒私有的空間,當呼叫某方法開始時將給該私有棧分配空間,在方法內部再呼叫方法還會繼續使用相應的棧空間,方法返回時回收相應的棧空間(不論是否丟擲異常)。這塊空間通常叫作“Working Memory”(工作記憶體)。
如果棧內部所包含的“區域性變數”是引用(Reference),則僅僅是引用值在棧中,而且會佔用一個引用本身的大小,具體的物件還是在堆當中的,即物件本身的大小與棧空間的使用無關。
工作記憶體與主存之間會採用read/write的方式進行通訊,而當工作記憶體中的資料需要計算時,它會發生load/store操作,load操作通常是將本地變數推至棧頂,用來給CPU排程運算,而store就是將棧頂的資料寫入本地變數。
通過javap命令輸出指令列表,從中可以看到各種XXload指令(0x15~0x35),就是將不同型別的資訊推至棧頂,指令除了可以標識資料型別外,還可以標識寬度;相應的XXstore指令(0x36~0x56)就是將不同型別的棧頂資料存入本地變數。
區域性變數(本地變數)在使用時通常不存在一致性問題,因為它的定義本身就歸屬於執行緒執行時,生命週期由相應的程式碼塊決定。所以在一致性問題上,我們關注的是多執行緒對主存的一些資料讀寫操作。
關於執行緒換個角度來理解,如果將JVM當成一個作業系統,則可以將多執行緒本身理解為多個CPU(理論上多個執行緒可以並行一起執行),站在抽象層次上看,多核處理快取一致性就和這些思想有許多相通之處了。
Java想要自己來實現一套一致性協議,需要有一些基礎規則,下面列舉幾個。
(1)JMM中一些普通變數的操作指令
◎ Load操作發生在read之後(兩個之間可以有其他的指令)。
◎ 普通變數的修改未必會立即發生Store操作,但發生Store操作,就會發生write操作。
有了這個基本規則後,我們似乎就不太關注write/read了,因為Load發生之前肯定有read,而Store操作之後肯定有write,因此我們將讀/寫的4個步驟理解為簡單的2個步驟。
這個JMM的基本規約似乎與多核處理器上的快取一致性還沒有太大關係,因為在程式碼執行過程中完全有可能一個執行緒讀、一個執行緒寫,此時就可能出現許多奇怪的現象,比如程式中讀到了老的資料,或者很久甚至永遠都讀不到最新的資料。把這類問題叫作“可見性”問題。
在現實中,我們通常會遇到如下版本管理問題。
例子1:你認為自己從SVN上拿到了最新程式碼去修改,結果你在拿程式碼時,別人提交了最新版本,在現實中或許沒有多大關係,因為版本管理最終可以一致,但是在計算機中或許不行,因為會導致許多意想不到的後果(例如,可能會導致算錯資料)。
例子2:拿到了最新版本的程式碼,一直在修改,一直沒有從SVN更新最新的程式碼,如果在這個過程中別人提交了程式碼,程式碼就不是最新的了。
在現實中想要解決這個問題,就需要大家睜大眼睛隨時看著SVN這個公共的區域是否發生改變,包括對每一個檔案的修改,或者大家將程式碼就直接寫在SVN上,在同一臺伺服器上除錯程式碼,顯然這十分影響工作效率,而且也不現實。計算機也是這樣,在絕大部分情況下,各自的變數沒有衝突,那麼就無須關注對方是否和自己操作同一個記憶體單元,這樣一來計算機的效能就會很高,只是在某些特殊場景下,我們不得不有選擇性地使用另一種方式來保障一致性。
這個時候喜歡思考的小夥伴們有了美妙的聯想,希望每次使用某個檔案前都自動從SVN上提取最新的,如果有人修改了檔案,則自動立即上傳,上傳中如果有人正在下載,則必須等待上傳者完成。當然這只是一種假設,在計算機中或許能達到這個粒度,我們就有機會進一步做事情了。
這種最細的粒度支援,也就是對Load、Store的各種順序控制,load、store兩兩組合為4種情況:LoadLoad、StoreStore、LoadStore、StoreLoad,它們以一種指令屏障的方式來控制順序,有些系統可能不支援某些指令的順序化,不過絕大部分系統都支援StoreLoad。
(2)StoreLoad的意思
我們可以簡單地認為讓Store先於Load發生。例如兩個在某個瞬間同時修改和讀取主存中的一個共享變數,此時的讀取操作將發生在修改之後。有了這樣一種特徵,就實現了最細粒度的鎖,也是最輕量級的鎖(在這裡所提到的輕量的概念與JVM本身對悲觀鎖優化中所引匯出的輕量級鎖的概念不是同一個)。
不過,這樣的方式僅僅能保證讀的一瞬間確保執行緒讀取到最新的資料,因此要進一步做到讀取、修改、寫入的動作是一致的,就將其升級為原子性。要達到原子性的效果,可以通過可見性、CAS自旋來完成,也可以通過synchronized來完成。
5.2.2 一些併發問題描述
前文中的一個簡單例子提到了可見性問題,這個問題比較好模擬,但是還有一些非常不好模擬的併發問題,而且總是存在於一些細節上,本節我們就來描述一此併發問題。
(1)指令重排序
指令重排序可能是Java編譯器在編譯Java程式碼時對虛指令進行重排序,也可以是CPU對目標指令進行重排序,它們的目的當然都是為了高效(換句話說,我們自己寫的程式碼對於計算機的解析和執行來講順序未必是最高效的)。
重排序在提升效能的同時,也給我們帶來了許多麻煩,而這些麻煩通常帶來的問題十分詭異。為了說明這樣的問題,請大家先看圖5-2。
圖5-2 兩個執行緒操作共享變數
根據圖5-2描述的程式碼及呼叫關係,按照常理執行緒1執行init()方法將先建立一個B物件賦值給屬性b,然後再將初始化引數inited設定為true(先拋開可見性問題),其他的執行緒會先通過isInited()方法判定物件內的屬性是否已經被初始化好,然後再操作物件本身,這樣物件的操作應當是安全的。
如果這裡發生指令重排序,就未必安全了!假如inited=true先於其他屬性被賦值前被執行,那麼此時執行緒2通過isInited()方法得到返回值為true,但有可能物件內部的某些屬性根本還沒有初始化完成,從而導致許多不可預見的問題。
(1)重排序並不意味著程式中的所有程式碼是雜亂無序的,只是重排序那些沒有相互依賴的程式碼,便於某些優化,我們可以認為在單執行緒中執行程式碼結果永遠不會變(as-if-serial)。
(2)用Java寫程式碼時本身不注重效能,但是並不意味著不注重較低的抽象層次,因為較低的抽象層次的優化可能影響到Java中大量頻繁的簡單操作的效能,這種時間的提升雖然很小,但有可能對Java整體的執行效能是有影響的。
(2)4位元組賦值問題
在JVM中允許對一個非volatile的64位(8位元組)變數賦值時,分解為兩個32位(4位元組)來完成,但並不是必須要一次性完成(從Java角度來理解,在虛指令中對變數的操作都以slot為單位,每個slot就是4位元組)。
問題出來了,如果變數是long、double型別的資料,在賦值某個32位後,正好被另一個執行緒所讀取,那麼它讀取出來的資料就可能是不可預見的結果。
(3)資料失效
正常的在JavaBean中提供大量的set、get是沒有問題的,如果這樣的物件提供給多執行緒使用就不一定了,用前面提到的可見性的道理來講,當一個執行緒呼叫set後,其他的執行緒未必能看得到。
這似乎沒什麼大不了的,那麼胖哥就說個有點小影響的例子。
假如某個賦值是一個應用平臺的系統配置引數,它在記憶體中有一份拷貝,這份配置引數將會影響程式對某些金額的計算方法或業務流程,這時偶然發生了另一個執行緒看不到的情況,導致雖然有最新的配置,但是這個執行緒還在走老路,那麼結果自然是錯誤的,完全由可能帶來經濟損失。
(4)非安全的釋出
為了說明問題,請先看圖5-3。
圖5-3 物件釋出逃逸
這裡的引用a可以直接被外部使用,它希望有一個執行緒將其初始化後再讓外部看到,但在未初始化好a以前,執行緒2可能對a直接進行使用了,那麼自然是空指標。更加可怕的是另一種現象,就是此時a引用指向的是一個還未初始化完成的A物件,這個物件空間可能被建立了,但是它的內部屬性的初始化還需要一個過程(例子中只有1個簡單屬性,在實際的程式中可能會有很多複雜屬性),這是由於在JMM中並沒有規定普通屬性的賦值必須發生在構造方法的return語句之前。換句話說,A物件的構造方法內的屬性賦值可以在return發生之後,而當A物件的構造方法發生return後,B類中的靜態屬性a就可以被賦值了,這樣就會導致執行緒3在判定B類中的屬性a不為空的情況下,使用這個物件來做操作,而這個物件依然有可能沒有被初始化好。這種問題算是釋出的一種“逃逸”問題,換句話說,就是程式訪問了不該訪問的內容,訪問了可能還沒準備好的內容。
併發問題的例子遠不止這些,下面再做一點點補充。
例如,在記憶體中用陣列或集合類來Cache一些資料,提供給應用伺服器的多個執行緒訪問使用,那麼我們應當如何提供API給它們使用呢?
假如API返回整個集合類或陣列,那麼集合類或陣列中的元素將有可能被多執行緒併發修改,這種修改自然就可能會存在併發的問題;如果返回集合類或陣列中其中一個下標的元素理論上沒有問題,但集合類或陣列中所存放的元素也是物件,物件中包含了許多可變的屬性,則該物件依然可能會被多執行緒併發地修改。
因此,為了避免併發問題,我們需要考慮一些場景,例如可以使用一份資料拷貝返回,或者返回不允許修改的代理API(Collections.unmodifiable相關API)。返回的資料拷貝,可以是一個集合類或陣列的一個元素或者整個集合類或陣列,而拷貝的深度需要根據業務來控制,對於複雜的包裝物件,要保證絕對的執行緒安全性是十分麻煩的。如果要返回不允許修改的資料結構的代理操作類,則可以使用Collections提供的unmodifiable相關的靜態方法,例如Collections.unmodifiableList(List)將返回不可修改的List物件,同樣的,該API限制僅僅侷限於List本身不能被修改,若List內部的元素是陣列、集合類、包含許多非final屬性的物件,則依然可以在獲取元素後修改相應的內容。
下面再舉個通過子類“逃逸”的例子。
從圖5-3中可以看到一種通過“逃逸”的例子。很多時候場景千變萬化,例如在子類中提供了自定義的構造方法,在構造方法中將this 引用交給另一個執行緒所訪問或作為任務的屬性提交給執行緒池,它們可以通過this訪問這個物件的一些方法或屬性,而此時物件可能還沒有初始化完成相應的屬性。
描述了這麼多問題,或許我們不會這樣寫程式碼,或許有的牛人會說“根本就不該這樣寫程式碼”,不過反過來講,其實我們是在就問題而探討問題,或許真正的程式會隱藏得更深,但道理基本是一致的。換句話說,我們要理解內在機制,當某一天遇到類似的問題時,可以有能力去分析和解決。
接下來,我們就介紹一些Java面對各種併發問題提供的語法支援,或者稱作JVM層面上的功能支援。
5.2.3 volatile
變數被volatile關鍵字修飾後,貌似就會避開前面提到的部分問題。在前面我們用一個SVN上傳/下載的理想例子來說明,要做到完美就得犧牲一些效能,雖然volatile它被譽為“最輕量級的鎖”,但它依舊比普通變數的訪問慢一些。之所以說它輕,是因為它只是在讀這個瞬間要求一個簡單的順序,而不是一個變數上的原子讀寫,或者在一段程式碼上的同步。
在JDK 1.5以前,volatile變數並沒有完全實現輕量級的鎖,不過JDK 1.5對可見性做了更為嚴格的定義,因此我們可以開始放心使用。不過也因此volatile修飾的變數的效能可能會有所下降。JDK也同樣對synchronized做了各種輕量級、偏向的優化,簡單來講是Java的作者們開始意識到一些Java鎖的場景,認為在很多場景下鎖的開銷是不需要完全使用悲觀鎖的,因此做了很多改造和優化,不過從理論上講,其開銷應該不會比volatile小(因為它最少會保證一個原子變數的讀寫一致性,而volatile不需要)。另外,synchronized通常是基於程式碼段的,開銷變小僅僅是加鎖的過程開銷,而並非鎖所包含的程式碼區域也會加快執行速度,相關的程式碼區域依然被序列訪問,因此不要期望於系統的鎖優化為我們解決所有的問題,很多優化原則依然需要根據我們對於鎖粒度本身的理解,以及結合相關的業務背景來綜合分析,才能得出答案。
volatile變數也會像普通變數那樣從主存中拷貝到各個執行緒中去操作,區別在於它要求實現StoreLoad指令屏障(當然JVM也未必一定要用這種指令來實現,前文中提到的4種指令屏障只要對應的處理器支援,也可以採用具體的平臺來優化),由此我們可以簡單地認為volatile的第一個作用就是:保證多執行緒中的共享變數是始終可見的(但這並不保證volatile引用物件內部的屬性是完全可見的)。
在JSR-133中對volatile語義的定義進行了增強(主要是為了真正實現更簡單的鎖),要求在對volatile變數進行讀/寫操作時,其前後的指令在某些情況下不允許進行重排序(不論是編譯時重排序還是處理器重排序,都不允許)。對於這種限制分為如下幾種情況。
(1)如果是一條對volatile變數進行賦值操作的程式碼,那麼在該程式碼前面的任何程式碼不能與這個賦值操作交換順序。在圖5-2中,一個inited變數如果使用volatile修飾,那麼就能夠達到目的,因為它能確保前面屬性的賦值在inited=true發生之前。
我們需要注意兩點:
◎ 如果這個操作後有普通變數的讀寫操作,則是可以與它交換順序的。
◎ 在這個動作之前的指令相互之間還是可以重排序的,只是不能排序到該動作後面。它就像一個向前的隔板,隔板前面的多個動作依然可以被重排序,隔板一邊的普通變數可以進入另一邊,以便於做更多的優化。
(2)如果是一條讀取volatile變數的程式碼,則正好相反,相當於隔板翻了一個面,在它後面的操作動作不允許與它交換順序,之後的多個動作依然可以重排序,在它之前的普通變數的操作動作也可以與它交換順序。
(3)普通變數的讀寫操作相互之間是可以重排序的,只要不影響它們之間的邏輯語義順序就可以重排序。但是如果普通變數的讀/寫操作遇上了volatile變數的操作,就需要遵循前兩個基本原則。
(4)如果兩個volatile變數的讀/寫操作都在一段程式碼中,則依然遵循前兩個基本原則,此時無論兩者之間讀/寫順序如何,都肯定不會重排序。
這裡反覆強調的順序,大家可能會有所疑惑,由於它的重要性,也同時幫助大家理解,下面舉幾個真實程式碼的例子。
◎ 一個變數被賦值後,經過某些讀寫操作,再賦值給另一個變數,這個順序是不會被打亂的。
◎ 程式中try、catch、finally程式碼肯定不會交換順序來執行,比如:try部分的程式碼還沒執行完,也沒有丟擲異常,就執行finally的程式碼,這種情況是不會發生的,否則就全部亂套了。我們可以用前面提到的“始終保持與單執行緒中的最終計算結果是相同的”這句話來理解這個邏輯順序問題。
綜上所述,volatile修飾變數的第2個作用是:防止相關性程式碼的重排序,從指令級別達到了輕量級鎖的目的。除此之外,volatile還有一個重要的作用是解決前面提到的4位元組賦值問題,對於volatile修飾的變數,必須一次性賦值。
volatile內在到底是怎麼回事?或許我們可以輸出某些平臺上的彙編指令來看看它到底與普通變數的操作有何特殊性。
輸出彙編指令?這在Java領域很少聽到,不過確實可以做到,只是有點麻煩。首先要做的事情就是下載外掛,由於與平臺相關,所以外掛也與平臺相關。開啟地址https://kenai. com/projects/base-hsdis/downloads,可以下載到很多種操作的外掛,Windows 32bit版本在http://hllvm.group.iteye.com/ 上可以找到。
將外掛下載好以後,放在哪裡?在Linux JVM中放在$JAVA_HOME/jre/lib/amd64/server及client兩個目錄下,並記得執行chmod +x相應的命令;在Windows JVM中放在%JAVA_ HOME%/jre/bin/server及client兩個目錄中,若只存在一個目錄,則只放一個。
接下來,我們需要編寫一段簡單的程式,用於檢測不同變數的賦值。
程式碼清單5-6 測試輸出彙編指令
public class AssemTest { int a, b; volatile int c, d; public static void main(String[] args) throws Exception { new AssemTest().test(); } public void test() { a = 1; b = 2; c = a; d = b; b = 3; c = 2; } }
該程式碼通過javac編譯為Class檔案,下面使用java命令攜帶相應的引數來輸出彙編指令。
java -XX:+UnlockDiagnosticVMOptions -XX:+PrintAssembly -Xcomp -XX:CompileCommand=compileonly,*AssemTest.test AssemTest
在Linux系統上輸出結果如圖5-4所示。
從圖5-4所示的彙編指令中可以看出,彙編指令中出現了lock addl $0x0操作,它是通過某個較低抽象層次的鎖實現了相應的屏障。
圖5-4 輸出Java在對應平臺上的彙編指令
5.2.4 final
在JMM中要求final域(屬性)的初始化動作必須在構造方法return之前完成。換言之,一個物件建立以及將其賦值給一個引用是兩個動作,物件建立還需要經歷分配空間和屬性初始化的過程,普通的屬性初始化允許發生在構造方法return之後(指令重排序)。
似乎這個問題變得很可怕,因為在Java程式中得到的物件竟然有可能還沒有執行完構造方法內的屬性賦值,但在大部分情況下,物件的使用都是線上程內部定義的,在單執行緒中是絕對可靠的,或者說在單執行緒中要求使用物件引用時,該物件已經被初始化好。但如果在此過程中有另一個執行緒通過這個未初始化好的物件引用讀取相應的屬性,那麼就可能讀取到的並不是真正想要的值。在Java中final可以保證這一點,所以它可以避免這種型別的逃逸問題。
但是它並不能完全解決所有的逃逸問題,而只是確保在構造方法return以前是會被初始化的,無法確保不與其他的指令進行重排序,比如下面的程式碼:
private static TestObject testObject = null; final int a; public 構造方法() { a = 100; testObject = this; //這個地方可能和a=100發生指令重排序 } public static void read() { if(testObject != null) { //對變數testObject.a做操作 } }
如果有另一個執行緒呼叫靜態方法read(),則可能得到testObject非空值,而此時有可能a=100這個動作還未執行(因為它可以與testObject = this進行重排序),那麼操作的資料就將是錯誤的。
進一步探討:如果final所修飾的不是普通變數,而是陣列、物件,那麼它能保證自己本身的初始化在其外部物件的構造方法返回之前,但是它本身作為物件,對內部的屬性是無法保證的。如果是某些具有標誌性的屬性,則需要根據實際情況做進一步處理,才可以達到執行緒安全的目的。
經過JSR-133對final進行語義增強後,我們就可以比較放心地使用final語法了。但是我們想看看構造方法還沒做完,變數是什麼樣子呢?普通變數和final變數到底又有什麼區別呢?下面我們就寫一段和併發程式設計沒多大關係的程式碼來跑一跑看看。
程式碼清單5-7 構造方法未結束,看看屬性是什麼樣子
public class FinalConstructorTest { static abstract class A { public A() { display(); } public abstract void display(); } static class B extends A { private int INT = 100; private final int FINAL_INT = 100; private final Integer FINAL_INTEGER = 100; private String STR1 = "abc"; private final String FINAL_STR1 = "abc"; private final String FINAL_STR2 = new String("abc"); private final List<String> FINAL_LIST = new ArrayList<String>(); public B() { super(); System.out.println("abc"); } public void display() { System.out.println(INT); System.out.println(FINAL_INT); System.out.println(FINAL_INTEGER); System.out.println(STR1); System.out.println(FINAL_STR1); System.out.println(FINAL_STR2); System.out.println(FINAL_LIST); } } public static void main(String []args) { new B(); } }
在這段程式碼中,我們跳開了構造方法返回之前對final的初始化動作,而是在構造方法內部去輸出這些final屬性。這段程式碼的輸出結果可能會讓我們意想不到,大家可以自行測試,如果在測試過程中使用斷點跟蹤去檢視這些資料的值,則可能在斷點中看到的值與實際輸出的值還會有所區別,因為斷點也是通過另一個執行緒去看物件的屬性值的,看到的物件可能正好是沒有初始化好的物件。
這樣看來,volatile和final在程式中必不可少嗎?當然不是!
如果每個屬性都使用這樣的修飾符,那麼系統就沒有必要設定這樣的修飾符了!其實它們是有一定的效能開銷的!我們關注的是程式碼是否真的有併發問題,如果資料本身就是某些只讀資料,或者這些Java物件本身就是執行緒所私有的區域性變數或類似於ThrealLocal的變數,那麼就沒有必要使用這些修飾符了。
提到final,我們再補充一個相關的話題(該話題與併發程式設計無關)。當在方法中使用匿名內部類時,匿名內部類的方法要直接使用外部方法中的區域性變數,這個區域性變數必須用final來宣告才可以被使用。很多人會問這到底是為什麼?虛擬碼如下:
public void test() { final int a = 100;//這個a必須定義為final,才能被匿名子類直接使用 new A() { public void display() { System.out.println(a); } } 其他操作 }
這其實是對一個語法的疑問,本身沒什麼好解釋的,但是如果非要解釋,或許我們可以從這個角度來理解:JVM本身也是一種軟體平臺,它設計了一種語法規則,自然也有它的侷限性和初衷。在編譯時,這個地方會自動生成一個匿名內部類,而本地變數a的作用域是在方法test()中,它如何能用到另一個內部類中呢?
其中一種方式是引數傳遞;另一種方式就是作為一個屬性存在於匿名內部類物件中。不論哪一種方式都會在這個匿名內部類物件中有一份資料拷貝(如果本地變數是引用,那麼拷貝將是引用的值),不過這似乎與外部定義本地變數時是否定義為final沒有關係。
JVM在設計時並不知道我們的程式碼會怎麼寫,或者說它不明確在所建立的匿名內部類中到底會做什麼,例如在程式碼中完全可以在內部建立一個執行緒來使用這個變數,或者建立一個任務提交給執行緒池來使用這個變數。如果是這樣,匿名內部類的執行將與該方法本身的執行處於兩個執行緒中,當外部方法可能已經結束時,那麼相應的區域性變數的作用域已經結束,自動會被回收,要保證匿名內部類一直可以使用該變數,就只能用拷貝的方法,這似乎還是與final沒有多大關係。
我們現在回過頭來回答:為何外部方法必須使用final定義這個這個變數?我們將上述結論反過來想,如果這個屬性不是final修飾的,在匿名內部類中使用“同名”的變數操作,並且可以對它做任意修改,自然外部也應當能感知到。但是事實上不能感知到,因為這是一份資料拷貝,就像傳遞的引數一樣,不是原來的資料。胖哥認為這種語法的設計手段是為了避免誤解,語法上強制約束它為final修飾。
如果這裡的a是一個引用,那麼就只會拷貝引用值,而不是整個物件的內容,在內部修改引用所指向的物件不會影響外部,外部的final也無法約束其改變,但如果改變其物件內部的屬性,只要外部還會使用這個物件,那麼就會受到影響。
5.2.5 棧封閉
棧封閉算是一種概念,也就是執行緒操作的資料都是私有的,不會與其他的執行緒共享資料。簡單來說,如果每個執行緒所訪問的JVM區域是隔離的,那麼這個系統就像一個單執行緒系統一樣簡單了,我們把它叫作棧封閉。這樣說是不是有點抽象,下面講實際的例子。
通常在做Web開發時不需要自己去關注多執行緒的各種內在,因為容器給我們做好了,從前端請求就給業務處理分配好了資料,我們無須關注那些併發的過程,Web容器會自動給我們提供私有的Reqeust、Response物件的處理,因為它不會被其他的執行緒所佔用,所以可以放心使用,它是執行緒絕對安全的(當使用Session或ServletContext時,它也在內部給你封裝好了併發的控制)。
但這並不意味著程式永遠不關注多執行緒與非同步的問題,當需要併發去訪問某些共享快取資料時,當需要去操作共享檔案資料時,當自定義多執行緒去併發做一些任務時,都必然會用到這些基本的知識體系來作為支撐,否則程式碼出現某些詭異的問題還不知道怎麼回事。
比如在一個專案中,使用了Spring注入的DAO層,大家都應該知道Spring生成的物件預設是“單例”的,也就是一個類只會生成一個對應的例項。在這個DAO的許多方法中,使用StringBuilder進行SQL拼接,在DAO裡面定義了StringBuilder,認為這個變數可以提供給許多的DAO層的方法共同使用,以節約空間,每個相應的方法都是對它做一個或多個append操作後,通過toString()獲取結果String物件,最後再把這個StringBuilder清空。
先拋開併發本身的問題,這樣做也根本節約不了什麼空間,因為這個物件將擁有與這個DAO一樣永久的生命週期佔用記憶體,由於DAO是“單例”的,所以相當於永久的生命週期。我們節約空間的方式通常是希望它短命,在Young空間就幹掉它,而不是讓它共享。
繼續看有什麼併發的問題。雖然這個StringBuilder不是static修飾的,但由於它所在的這個DAO物件例項是“單例”的,由Spring控制生成,所以它幾乎等價於全域性物件。它至少會在這個類裡面的所有方法被訪問時共享,就算這個類裡面只有一個方法也會有併發問題(因為同一個方法是可以被多個執行緒同時訪問的,為何?因為它是程式碼段,程式執行時只需要從這裡獲取到指令列表即可,或者反過來理解,如果所有的程式碼都不能並行訪問,那麼多執行緒程式就完全被序列化了)。
如果資料區域發生共享就有問題了,多個執行緒可能在同時改一個數據段,這樣沒有任何安全策略的資料段,最終結果會是什麼樣子,誰也不清楚。本例中提到的StringBuilder就是一個共享的資料區域,假如有兩個執行緒在append(),然後一個執行緒toString()得到的結果將有可能是兩個執行緒共同寫入的資料,它在操作完成後可能還會將資料清空,那麼另一個執行緒就可能拿到一個空字串甚至於更加詭異的結果。這樣的程式很明顯是有問題的。
如果改成StringBuffer,是否可行?
答曰:StringBuffer是同步的,但是並不代表它在業務上是絕對安全的,認為它安全是因為它在每一次做append()類似操作時都會加上synchronized的操作,但是在實際的程式中是可以對StringBuffer進行多次append()操作的,在這些append()操作之間可能還會有其他的程式碼步驟,StringBuffer可以保證每次append()操作是執行緒安全的,但它無法保證多執行緒訪問時進行多次append()也能得到理想的結果。
難道我們還要在外層加一個鎖來控制?
如果是這樣的話,新的問題就出現了,這個類所有的方法訪問到這裡都是序列的,如果所有的DAO層都是這樣的情況,拋開鎖本身的開銷,此時系統就像單執行緒系統一樣在執行,外部的併發訪問到來時,系統將奇慢無比。如果訪問過大,就會堆積大量的執行緒阻塞,以及執行緒所持有的上下文無法釋放,而且會越堆積越多,後果可想而知。
鎖的開銷是巨大的,它對於併發程式設計中的效能是十分重要的,於是許多大牛開始對無鎖化的問題有了追求,或者說盡量靠近無鎖化。在大多數情況下我們希望事情是樂觀的,希望使用盡量細粒度化的鎖機制,不過對於大量迴圈呼叫鎖的情況會反過來使用粗粒度化的鎖機制,因為加鎖的開銷本身也是巨大的。
關於棧封閉,除了使用區域性變數外,還有一種方式就是使用ThreadLocal,ThreadLocal使用一種變通的方式來達到棧封閉的目的,具體的請參看下一小節的內容。
5.2.6 ThreadLocal
雖然ThreadLocal與併發問題相關,但是許多程式設計師僅僅將它作為一種用於“方便傳參”的工具,胖哥認為這也許並不是ThreadLocal設計的目的,它本身是為執行緒安全和某些特定場景的問題而設計的。
ThreadLocal是什麼呢!
每個ThreadLocal可以放一個執行緒級別的變數,但是它本身可以被多個執行緒共享使用,而且又可以達到執行緒安全的目的,且絕對執行緒安全。
例如:
public final static ThreadLocal<String> RESOURCE = new ThreadLocal<String>();
RESOURCE代表一個可以存放String型別的ThreadLocal物件,此時任何一個執行緒可以併發訪問這個變數,對它進行寫入、讀取操作,都是執行緒安全的。比如一個執行緒通過RESOURCE.set(“aaaa”);將資料寫入ThreadLocal中,在任何一個地方,都可以通過RESOURCE.get();將值獲取出來。
但是它也並不完美,有許多缺陷,就像大家依賴於它來做引數傳遞一樣,接下來我們就來分析它的一些不好的地方。
為什麼有些時候會將ThreadLocal作為方便傳遞引數的方式呢?例如當許多方法相互呼叫時,最初的設計可能沒有想太多,有多少個引數就傳遞多少個變數,那麼整個引數傳遞的過程就是零散的。進一步思考:若A方法呼叫B方法傳遞了8個引數,B方法接下來呼叫C方法->D方法->E方法->F方法等只需要5個引數,此時在設計API時就涉及5個引數的入口,這些方法在業務發展的過程中被許多地方所複用。
某一天,我們發現F方法需要加一個引數,這個引數在A方法的入口引數中有,此時,如果要改中間方法牽涉面會很大,而且不知道修改後會不會有Bug。作為程式設計師的我們可能會隨性一想,ThreadLocal反正是全域性的,就放這裡吧,確實好解決。
但是此時你會發現系統中這種方式有點像在貼補丁,越貼越多,我們必須要求呼叫相關的程式碼都使用ThreadLocal傳遞這個引數,有可能會搞得亂七八糟的。換句話說,並不是不讓用,而是我們要明確它的入口和出口是可控的。
詭異的ThreadLocal最難琢磨的是“作用域”,尤其是在程式碼設計之初很亂的情況下,如果再增加許多ThreadLocal,系統就會逐漸變成神龍見首不見尾的情況。有了這樣一個省事的東西,可能許多小夥伴更加不在意設計,因為大家都認為這些問題都可以通過變化的手段來解決。胖哥認為這是一種惡性迴圈。
對於這類業務場景,應當提前有所準備,需要粗粒度化業務模型,即使要用ThreadLocal,也不是加一個引數就加一個ThreadLocal變數。例如,我們可以設計幾種物件來封裝入口引數,在介面設計時入口引數都以物件為基礎。
也許一個類無法表達所有的引數意思,而且那樣容易導致強耦合。
通常我們按照業務模型分解為幾大類型物件作為它們的引數包裝,並且將按照物件屬性共享情況進行抽象,在繼承關係的每一個層次各自擴充套件相應的引數,或者說加引數就在物件中加,共享引數就在父類中定義,這樣的引數就逐步規範化了。
我們回到正題,探討一下ThreadLocal到底是用來做什麼的?為此我們探討下文中的幾個話題。
(1)應用場景及使用方式
為了說明ThreadLocal的應用場景,我們來看一個框架的例子。Spring的事務管理器通過AOP切入業務程式碼,在進入業務程式碼前,會根據對應的事務管理器提取出相應的事務物件,假如事務管理器是DataSourceTransactionManager,就會從DataSource中獲取一個連線物件,通過一定的包裝後將其儲存在ThreadLocal中。並且Spring也將DataSource進行了包裝,重寫了其中的getConnection()方法,或者說該方法的返回將由Spring來控制,這樣Spring就能讓執行緒內多次獲取到的Connection物件是同一個。
為什麼要放在ThreadLocal裡面呢?因為Spring在AOP後並不能嚮應用程式傳遞引數,應用程式的每個業務程式碼是事先定義好的,Spring並不會要求在業務程式碼的入口引數中必須編寫Connection的入口引數。此時Spring選擇了ThreadLocal,通過它保證連線物件始終線上程內部,任何時候都能拿到,此時Spring非常清楚什麼時候回收這個連線,也就是非常清楚什麼時候從ThreadLocal中刪除這個元素(在9.2節中會詳細講解)。
從Spring事務管理器的設計上可以看出,Spring利用ThreadLocal得到了一個很完美的設計思路,同時它在設計時也十分清楚ThreadLocal中元素應該在什麼時候刪除。由此,我們簡單地認為ThreadLocal儘量使用在一個全域性的設計上,而不是一種打補丁的間接方法。
瞭解了基本應用場景後,接下來看一個例子。定義一個類用於存放靜態的ThreadLocal物件,通過多個執行緒並行地對ThreadLocal物件進行set、get操作,並將值進行列印,來看看每個執行緒自己設定進去的值和取出來的值是否是一樣的。程式碼如下:
程式碼清單5-8 簡單的ThreadLocal例子
public class ThreadLocalTest { static class ResourceClass { public final static ThreadLocal<String> RESOURCE_1 = new ThreadLocal<String>(); public final static ThreadLocal<String> RESOURCE_2 = new ThreadLocal<String>(); } static class A { public void setOne(String value) { ResourceClass.RESOURCE_1.set(value); } public void setTwo(String value) { ResourceClass.RESOURCE_2.set(value); } } static class B { public void display() { System.out.println(ResourceClass.RESOURCE_1.get() + ":" + ResourceClass.RESOURCE_2.get()); } } public static void main(String []args) { final A a = new A(); final B b = new B(); for(int i = 0 ; i < 15 ; i ++) { final String resouce1 = "執行緒-" + I; final String resouce2 = " value = (" + i + ")"; new Thread() { public void run() { try { a.setOne(resouce1); a.setTwo(resouce2); b.display(); }finally { ResourceClass.RESOURCE_1.remove(); ResourceClass.RESOURCE_2.remove(); } } }.start(); } } }
關於這段程式碼,我們先說幾點。
◎ 定義了兩個ThreadLocal變數,最終的目的就是要看最後兩個值是否能對應上,這樣才有機會證明ThreadLocal所儲存的資料可能是執行緒私有的。
◎ 使用兩個內部類只是為了使測試簡單,方便大家直觀理解,大家也可以將這個例子的程式碼拆分到多個類中,得到的結果是相同的。
◎ 測試程式碼更像是為了方便傳遞引數,因為它確實傳遞引數很方便,但這僅僅是為了測試。
◎ 在finally裡面有remove()操作,是為了清空資料而使用的。為何要清空資料,在後文中會繼續介紹細節。
測試結果如下:
執行緒-6: value = (6)
執行緒-9: value = (9)
執行緒-0: value = (0)
執行緒-10: value = (10)
執行緒-12: value = (12)
執行緒-14: value = (14)
執行緒-11: value = (11)
執行緒-3: value = (3)
執行緒-5: value = (5)
執行緒-13: value = (13)
執行緒-2: value = (2)
執行緒-4: value = (4)
執行緒-8: value = (8)
執行緒-7: value = (7)
執行緒-1: value = (1)
大家可以看到輸出的執行緒順序並非最初定義執行緒的順序,理論上可以說明多執行緒應當是併發執行的,但是依然可以保持每個執行緒裡面的值是對應的,說明這些值已經達到了執行緒私有的目的。
不是說共享變數無法做到執行緒私有嗎?它又是如何做到執行緒私有的呢?這就需要我們知道一點點原理上的東西,否則用起來也沒那麼放心,請看下面的介紹。
(2)ThreadLocal內在原理
從前面的操作可以發現,ThreadLocal最常見的操作就是set、get、remove三個動作,下面來看看這三個動作到底做了什麼事情。首先看set操作,原始碼片段如圖5-5所示。
圖5-5 ThreadLcoal.set原始碼片段
圖5-5中的第一條程式碼取出了當前執行緒t,然後呼叫getMap(t)方法時傳入了當前執行緒,換句話說,該方法返回的ThreadLocalMap和當前執行緒有點關係,我們先記錄下來。進一步判定如果這個map不為空,那麼設定到Map中的Key就是this,值就是外部傳入的引數。這個this是什麼呢?就是定義的ThreadLocal物件。
程式碼中有兩條路徑需要追蹤,分別是getMap(Thread)和createMap(Thread , T)。首先來看看getMap(t)操作,如圖5-6所示。
圖5-6 getMap(Thread)操作
在這裡,我們看到ThreadLocalMap其實就是執行緒裡面的一個屬性,它在Thread類中的定義是:
ThreadLocal.ThreadLocalMap threadLocals = null;
這種方法很容易讓人混淆,因為這個ThreadLocalMap是ThreadLocal裡面的內部類,放在了Thread類裡面作為一個屬性而存在,ThreadLocal本身成為這個Map裡面存放的Key,使用者輸入的值是Value。太亂了,理不清楚了,畫個圖來看看(見圖5-7)。
簡單來講,就是這個Map物件在Thread裡面作為私有的變數而存在,所以是執行緒安全的。ThreadLocal通過Thread.currentThread()獲取當前的執行緒就能得到這個Map物件,同時將自身作為Key發起寫入和讀取,由於將自身作為Key,所以一個ThreadLocal物件就能存放一個執行緒中對應的Java物件,通過get也自然能找到這個物件。
圖5-7 Thread與ThreadLocal的虛擬碼關聯關係
如果還沒有理解,則可以將思維放寬一點。當定義變數String a時,這個“a”其實只是一個名稱(在第3章中已經說到了常量池),虛擬機器需要通過符號表來找到相應的資訊,而這種方式正好就像一種K-V結構,底層的處理方式也確實很接近這樣,這裡的處理方式是顯式地使用Map來存放資料,這也是一種實現手段的變通。
現在有了思路,繼續回到上面的話題,為了驗證前面的推斷和理解,來看看createMap方法的細節,如圖5-8所示。
圖5-8 createMap操作
這段程式碼是執行一個建立新的Map的操作,並且將第一個值作為這個Map的初始化值,由於這個Map是執行緒私有的,不可能有另一個執行緒同時也在對它做put操作,因此這裡的賦值和初始化是絕對執行緒安全的,也同時保證了每一個外部寫入的值都將寫入到Map物件中。
最後來看看get()、remove()程式碼,或許看到這裡就可以認定我們的理論是正確的,如圖5-9所示。
圖5-9 get()/remove()方法的程式碼片段
給我們的感覺是,這樣實現是一種技巧,而不是一種技術。
其實是技巧還是技術完全是從某種角度來看的,或者說是從某種抽象層次來看的,如果這段程式碼在C++中實現,難道就叫技術,不是技巧了嗎?當然不是!胖哥認為技術依然是建立在思想和方法基礎上的,只是看實現的抽象層次在什麼級別。就像在本書中多個地方探討的一些基礎原理一樣,我們探討了它的思想,其實它的實現也是基於某種技巧和手段的,只是對程式封裝後就變成了某種語法和API,因此胖哥認為,一旦學會使用技巧思考問題,就學會了通過技巧去看待技術本身。我們應當通過這種設計,學會一種變通和發散的思維,學會理解各種各樣的場景,這樣便可以積累許多真正的財富,這些財富不是通過某些工具的使用或測試就可以獲得的。
ThreadLocal的這種設計很完美嗎?
不是很完美,它依然有許多坑,在這裡對它容易誤導程式設計師當成傳參工具就不再多提了,下面我們來看看它的使用不當會導致什麼技術上的問題。
(3)ThreadLocal的坑
通過上面的分析,我們可以認識到ThreadLocal其實是與執行緒繫結的一個變數,如此就會出現一個問題:如果沒有將ThreadLocal內的變數刪除(remove)或替換,它的生命週期將會與執行緒共存。因此,ThreadLocal的一個很大的“坑”就是當使用不當時,導致使用者不知道它的作用域範圍。
大家可能認為執行緒結束後ThreadLocal應該就回收了,如果執行緒真的登出了確實是這樣的,但是事實有可能並非如此,例如線上程池中對執行緒管理都是採用執行緒複用的方法(Web容器通常也會採用執行緒池),線上程池中執行緒很難結束甚至於永遠不會結束,這將意味著執行緒持續的時間將不可預測,甚至與JVM的生命週期一致。那麼相應的ThreadLocal變數的生命週期也將不可預測。
也許系統中定義少量幾個ThreadLocal變數也無所謂,因為每次set資料時是用ThreadLocal本身作為Key的,相同的Key肯定會替換原來的資料,原來的資料就可以被釋放了,理論上不會導致什麼問題。但世事無絕對,如果ThreadLocal中直接或間接包裝了集合類或複雜物件,每次在同一個ThreadLocal中取出物件後,再對內容做操作,那麼內部的集合類和複雜物件所佔用的空間可能會開始膨脹。
拋開程式碼本身的問題,舉一個極端的例子。如果不想定義太多的ThreadLocal變數,就用一個HashMap來存放,這貌似沒什麼問題。由於ThreadLocal在程式的任何一個地方都可以用得到,在某些設計不當的程式碼中很難知道這個HashMap寫入的源頭,在程式碼中為了保險起見,通常會先檢查這個HashMap是否存在,若不存在,則建立一個HashMap寫進去;若存在,通常也不會替換掉,因為程式碼編寫者通常會“害怕”因為這種替換會丟掉一些來自“其他地方寫入HashMap的資料”,從而導致許多不可預見的問題。
在這樣的情況下,HashMap第一次放入ThreadLocal中也許就一直不會被釋放,而這個HashMap中可能開始存放許多Key-Value資訊,如果業務上存放的Key值在不斷變化(例如,將業務的ID作為Key),那麼這個HashMap就開始不斷變長,並且很可能在每個執行緒中都有一個這樣的HashMap,逐漸地形成了間接的記憶體洩漏。曾經有很多人吃過這個虧,而且吃虧的時候發現這樣的程式碼可能不是在自己的業務系統中,而是出現在某些二方包、三方包中(開源並不保證沒有問題)。
要處理這種問題很複雜,不過首先要保證自己編寫的程式碼是沒問題的,要保證沒問題不是說我們不去用ThreadLocal,甚至不去學習它,因為它肯定有其應用價值。在使用時要明白ThreadLocal最難以捉摸的是“不知道哪裡是源頭”(通常是程式碼設計不當導致的),只有知道了源頭才能控制結束的部分,或者說我們從設計的角度要讓ThreadLocal的set、remove有始有終,通常在外部呼叫的程式碼中使用finally來remove資料,只要我們仔細思考和抽象是可以達到這個目的的。有些是二方包、三方包的問題,對於這些問題我們需要學會的是找到問題的根源後解決,關於二方包、三方包的執行跟蹤,可參看第3.7.9節介紹的BTrace工具。
補充:在任何非同步程式中(包括非同步I/O、非阻塞I/O),ThreadLocal的引數傳遞是不靠譜的,因為執行緒將請求傳送後,就不再等待遠端返回結果繼續向下執行了,真正的返回結果得到後,處理的執行緒可能是另一個。