談談JAVA中的安全發布
昨天看到一篇文章闡述技術類資料的"等級",看完之后很有共鳴。再加上最近在工作中越發覺得線程安全性的重要性和難以捉摸,又掏出了《Java並發編程實戰》研讀一番,這本書應該是屬於為“JAVA 多線程作注解”的一本書,那我就為書中關於對象安全發布的內容作一些注解,作為今年的第一篇博文。
我讀的是中文版,確實感覺書中有些地方的描述晦澀難懂,也沒有去拿英文原文來對照,就按中文版描述,再配上一些示例代碼記錄我的一些理解吧。
1. 安全發布的定義
發布是個動詞,是去發布對象。而對象,通俗的理解是:JAVA里面通過 new 關鍵字 創建一個對象。
發布一個對象的意思是:使對象在當前作用域之外的代碼中使用。比如下面knowSecrets指向的HashSet類型的對象,由static修飾,是一個類變量。當前作用域為PublishExample類。
import java.util.HashSet;
import java.util.Set;
/**
* @author psj
* @date 2019/03/10
*/
public class PublishExample {
public static Set<Secret> knowSecrets;
public void initialize() {
knowSecrets = new HashSet<>();
}
}
public修飾引用knowSecrets,導致 在其他類中也能訪問到這個HashSet對象,比如在UsingSecret 類中 向 knowSecrets添加元素 或者刪除元素。因此,也就發布了knowSecrets 這個對象。
public class UsingSecret {
public static void main(String[] args) {
PublishExample.knowSecrets.add(new Secret());
PublishExample.knowSecrets.remove(new Secret());
}
}
另外,值得注意的是:添加到HashSet集合中的Secret對象也被發布了。
2. 不安全的發布
因為對象一般是在構造函數里面初始化的(不討論反射),當 new 一個對象時,會為這個對象的屬性賦值,當前時刻對象各個屬性擁有的值 稱為對象的狀態。
public class Secret {
private String password;
private int length;
public Secret(){}
public Secret(String password, int length) {
this.password = password;
this.length = length;
}
public static void main(String[] args) {
//"current state" 5 組成了secObjCurrentState對象的當前狀態
Secret secObjCurrentState = new Secret("current state", 5);
//改變 secObjCurrentState 對象的狀態
secObjCurrentState.setPassword("state changed");
}
public void setPassword(String password) {
this.password = password;
}
}
Secret對象有兩個屬性:password和length,secObjCurrentState.setPassword("state changed")
改變了對象的狀態。
創建對象的目的是使用它,而要用它,就要把它發布出去。同時,也引出了一個重要問題,我們是在哪些地方用到這個對象呢?比如:只在一個線程里面訪問這個對象,還是有可能多個線程並發訪問該對象?
對象被發布后,是無法知道其他線程對已發布的對象執行何種操作的,這也是導致線程安全問題的原因。
2.1 this引用逸出
先看一個不安全發布的示例----this引用逸出。參考《Java並發編程實戰》第3章程序清單3-7
當我第一次看到"this引用逸出"時,是懵逼的。后來在理解了“發生在先”原則、“初始化過程安全性”、"volatile關鍵字的作用"之后才慢慢理解了。這些東西后面再說。
外部類ThisEscape和它的內部類EventListener
public class ThisEscape {
private int intState;//外部類的屬性,當構造一個外部類對象時,這些屬性值就是外部類狀態的一部分
private String stringState;
public ThisEscape(EventSource source) {
source.registerListener(new EventListener(){
@Override
public void onEvent(Event e) {
doSomething(e);
}
});
//執行到這里時,new 的EventListener就已經把ThisEscape對象隱式發布了,而ThisEscape對象尚未初始化完成
intState=10;//ThisEscape對象繼續初始化....
stringState = "hello";//ThisEscape對象繼續初始化....
//執行到這里時, ThisEscape對象才算初始化完成...
}
/**
* EventListener 是 ThisEscape的 非靜態 內部類
*/
public abstract class EventListener {
public abstract void onEvent(Event e);
}
private void doSomething(Event e) {}
public int getIntState() {
return intState;
}
public void setIntState(int intState) {
this.intState = intState;
}
public String getStringState() {
return stringState;
}
public void setStringState(String stringState) {
this.stringState = stringState;
}
現在要創建一個ThisEscape對象,於是執行ThisEscape的構造方法,構造方法里面有 new EventListener對象,於是EventListener對象就隱式地持有外部類ThisEscape對象的引用。
那如果能在其他地方訪問到EventListner對象,就意味着"隱式"地發布了ThisEscape對象,而此時ThisEscape對象可能還尚未初始化完成,因此ThisEscape對象就是一個尚未構造完成的對象,這就導致只能看到ThisEscape對象的部分狀態!
看下面示例:我故意讓EventSource對象持有EventListener對象的引用,也意味着:隱式地持有ThisEscape對象的引用了,這就是this引用逸出。
public class EventSource {
ThisEscape.EventListener listener;//EventSource對象 持有外部類ThisEscape的 內部類EventListener 的引用
public ThisEscape.EventListener getListener() {
return listener;
}
public void registerListener(ThisEscape.EventListener listener) {
this.listener = listener;
}
}
public class ThisEscapeTest {
public static void main(String[] args) {
EventSource eventSource = new EventSource();
ThisEscape thisEscape = new ThisEscape(eventSource);
ThisEscape.EventListener listener = eventSource.getListener();//this引用逸出
thisEscape.setStringState("change thisEscape state...");
//--------演示一下內存泄漏---------//
thisEscape = null;//希望觸發 GC 回收 thisEscape
consistentHold(listener);//但是在其他代碼中長期持有listener引用
}
}
額外提一下:內部類對象隱式持有外部類對象,可能會發生內存泄漏問題。
2.2 不安全的延遲初始化
Happens Before 發生在先關系
深刻理解這個關系,對判斷代碼中是否存在線程安全性問題很有幫助。扯一下發生在先關系的來龍去脈。
為了加速代碼的執行,底層硬件有寄存器、CPU本地緩存、CPU也有多個核支持多個線程並發執行、還有所謂的指令重排…那如何保證代碼的正確運行?因此Java語言規范要求JVM:
JVM在線程中維護一種類似於串行的語義:只要程序的最終執行結果與在嚴格串行環境中執行的結果相同,那么寄存器、本地緩存、指令重排都是允許的,從而既保證了計算性能又保證了程序運行的正確性。
在多線程環境中,為了維護這種串行語義,比如說:操作A發生了,執行操作B的線程如何看到操作A的結果?
Java內存模型(JMM)定義了Happens-Before關系,用來判斷程序執行順序的問題。這個概念還是太抽象,下面會用具體的示例說明。在我寫代碼的過程中,發現有四個規則對判斷多線程下程序執行順序非常有幫助:
-
程序順序規則:
如果程序中操作A在操作B之前(即:寫的代碼語句的順序),那么在單個線程執行中A操作將在B操作之前執行。
-
監視器規則:
這個規則是關於鎖的,定義是:在監視器鎖上的解鎖操作必須在同一個監視器鎖上的加鎖操作之前。咋一看,沒啥用。我這里擴展一下,如下圖:
在線程A內部的所有操作都按照它們在源程序中的先后順序來排序,在線程B內部的操作也是如此。(這就是程序順序規則)
由於A釋放了鎖,而B獲得了鎖,因此A中所有在釋放鎖之前的操作 位於 B中請求鎖之后的所有操作之前。這句話:它的意思就是:在線程A解鎖M之前的所有操作,對於線程B加鎖M之后的所有操作都是可見的。這樣,在線程B中就能看到:線程A對 變量x 、變量y的所寫入的值了。
再擴展一下:為了在線程之間傳遞數據,我們經常用到BlockingQueue,一個線程調用put方法添加元素,另一個線程調用take方法獲取元素,這些操作都滿足發生在先關系。線程B不僅僅是拿到了一個元素,而且還能看到線程A修改的一些對象的狀態(這就是可見性)
總結一下:
同步操作,比如鎖的釋放和獲取、volatile變量的讀寫,不僅滿足發生在先關系(偏序),而且還滿足全序關系。總之:要想保證執行操作B的線程看到操作A的結果(不管操作A、操作B 是否在同一個線程中執行),操作A、操作B 之間必須滿足發生在先關系
-
volatile變量規則:對volatile變量的寫入操作必須在該變量的讀取操作之前執行。這條規則幫助理解:為什么在聲明類的實例變量時用了volatile修飾,作者的意圖是什么?
-
傳遞性:如果操作A在操作B之前執行,操作B在操作C之前執行,那么操作A必須在操作C之前執行。在你看到一大段代碼,這個線程里面調用了synchronized修飾的方法、那個線程又向阻塞隊列put了一個元素、另一個線程又讀取了一個volatile修飾的變量…從這些發生在先規則里面 使用 傳遞性 就能大致推斷整個代碼的執行流程了。
扯了這么多,看一個不安全發布的示例。
public class UnsafeLazyInitialization {
private static Resource resource;
public static Resource getResource() {
if (resource == null) {
resource = new Resource();//不安全的發布
}
return resource
}
}
這段代碼沒有應用到前面提到的任何一個發生在先規則,代碼在執行過程中發生的指令重排導致了不安全的發布。
在創建對象、發布對象時,隱藏了很多操作的。new Resource對象時需要給Resource對象的各個屬性賦值,賦值完了之后,在堆中對象的地址要賦值給 靜態變量resource。在整個過程中就有可能存在指令重排,看圖:
類似地,雙重檢查加鎖也會導致不安全的發布。
3. 安全的發布
public class EagerInitialization {
private static Resource resource = new Resource();
public static Resource getResource() {
return resource;
}
}
在聲明靜態變量時同時初始化,由JVM來保證初始化過程的安全性。static修飾說明是類變量,因而符合單例模式。
3.1 初始化安全性
初始化安全性是一種保證:正確構造的對象在沒有同步的情況下也能安全地在多個線程之間共享,而不管它是如何被發布的。換句話說:對於被正確構造的對象,所有線程都能看到由構造函數為對象各個final域設置的正確值。
再換句話說:對於含有final域的對象,初始化安全性可以防止對象的初始引用被重排序到構造過程之前。這句話已經點破了關鍵了。看上一幅圖,線程A在賦值到半路,太累了,休息了一下,抽了一根煙。然后繼續開始了它的賦值,這些賦值操作,就是對象的構造過程。而在賦值的中間,存在着一個指令重排---將尚未構造完成的對象的堆地址寫入到初始引用中去了,而如果這個時候恰好有其他線程拿着這個初始引用去訪問對象(比如訪問該對象的某個屬性),但這個對象還未初始化完成啊,就會導致bug。
哈哈哈哈……是不是還是看不懂、很抽象?這就是 經。經書級別的經,難念的經。咱用代碼來說明一下:
public class Resource {
private int x;//沒有用final修飾
private String y;//沒有用final修飾
public Resource(int x, String y) {
this.x = x;
this.y = y;
}
}
而如果,這兩個屬性都用final修飾的話,那么就滿足初始化安全的保證,就沒有指令重排了。
這就是final關鍵字所起的作用。
另外,你是不是注意到,如果用final修飾實例變量時,IDEA會提示你尚未給final修飾的實例變量賦初始值?哈哈……
總結一下:
構造函數對final域的所有寫入操作,以及對通過這些域可以到達的任何變量的寫入操作,都將被“凍結”,並且任何獲得該對象引用的線程都至少能確保看到被凍結的值。對於通過final域可到達的初始變量的寫入操作,將不會與構造過程后的操作一起被重排序。
所以:如果Resouce是一個不可變對象,那么UnsafeLazyInitialization就是安全的了。
//不可變
public class Resource {
private final int x;
private final String y;
public Resource(){x=10;y="hello"}
public Resource(int x, String y) {
this.x = x;
this.y = y;
}
}
//UnsafeLazyInitialization 不僅是安全的發布,而且在多線程訪問中也是線程安全的。
//因為Resource的屬性x、y 都是不可變的。
public class UnsafeLazyInitialization {
private static Resource resource;
public static Resource getResource() {
if (resource == null) {
resource = new Resource();//安全的發布!
}
return resource;
}
}
關於初始化安全性,只能保證 final 域修飾的屬性在構造過程完成時的可見性。如果,構造的對象存在非final域修飾的屬性,或者在構造完成后,在程序中其他地方能夠修改屬性的值,那么必須采用同步來保證可見性(必須采用同步保證線程安全),示例如下:
import java.util.HashMap;
import java.util.Map;
/**
* @author psj
* @date 2019/03/10
*/
public class UnSafeStates {
/**
* UnSafeStates 唯一的一個屬性是由final修飾的,初始化安全性還是存在的
* 即:其他線程能看到一個正確且 **構造完成** 的UnSafeStates對象
*/
private final Map<String,String> states;
public UnSafeStates() {
states = new HashMap<>();
states.put("hello", "he");
states.put("world", "wo");
}
public String getAbbreviation(String s) {
return states.get(s);
}
/**
* 這個方法能夠修改 states 屬性的值, UnSafeStates 不再是一個線程安全的類了
* 如果多線程並發調用 setAbbreviation 方法, 就存在線程安全性問題. HashMap的循環引用了解一下?哈哈……
* @param key
* @param value
*/
public void setAbbreviation(String key, String value) {
states.put(key, value);
}
}
3.2 volatile 修飾的屬性的安全發布問題
這個和final關鍵字中討論的初始化安全性類似。只不過,volatile修飾的屬性是滿足發生在先關系的。
套用volatile變量規則:在volatile變量的寫入操作必須在對該變量的讀取操作之前執行,那volatile也能避免前面提到的指令重排了。因為,初始化到一半,然后好累,要休息一下,說明初始化過程尚未完成,也即:變量的寫入操作尚未徹底完成。那根據volatile變量規則:對該變量的訪問也不能開始。這樣就保證了安全發布。這也是為什么DCL雙重檢查鎖中定義的static變量 用volatile修飾就能安全發布的原因。
4. 總結
在寫代碼過程中,有時不太刻意地去關注安全發布,在聲明一個類的屬性時,有時就順手給實例變量用一個final修飾。抑或是在考慮多線程訪問到一個狀態變量時,給它用個volatile修飾,並沒有真正地去思考總結final到底起作用在哪里了?
所以總結起來就是:final關鍵字在初始化過程中防止了指令重排,保證了初始化完成后對象的安全發布。volatile則是通過JMM定義的發生在先關系,保證了變量的內存可見性。
最近在看ES源碼過程中,看別人寫的代碼,就好奇,哎,為什么這里這個屬性要用個final呢?為什么那個屬性加了volatile修飾呢?其實只有明白背后原理,才能更好地去理解別人的代碼吧。
當然,上面寫的全是自己的理解,有可能出錯,因為我並沒有將源代碼編譯成字節碼、甚至是從機器指令角度去分析 上面示例的執行流程,因為我看不懂那些匯編指令,哈哈哈哈哈哈……
5. 參考資料
《Java並發編程實戰》第3章、第16章
這篇文章前前后后加起來居然寫了6個小時,沒時間打球了…^:(^ ^:(^