如何安全發布對象
發布對象:
- 使一個對象能夠被當前範圍之外的代碼所使用,例如通過方法返回對象的引用,或者通過公有的靜態變量發布對象
對象逸出:
- 一種錯誤的發布,當一個對象還沒有構造完成時,就使它被其他線程所見
不正確的發布可變對象導致的兩種錯誤:
- 發布線程意外的所有線程都可以看到被發布對象的過期的值
- 線程看到的被發布對象的引用是最新的,然而被發布對象的狀態卻是過期的
不安全的發布示例:
package org.zero.concurrency.demo.example.publish; import lombok.extern.slf4j.Slf4j; import java.util.Arrays; /** * @program: concurrency-demo * @description: 不安全的對象發布示例 * @author: 01 * @create: 2018-10-16 16:21 **/ @Slf4j public class UnsafePublish { private String[] states = {"a", "b", "c"}; /** * 過public訪問級別發布了類的域,在類的外部,任何線程都可以訪問這個域 * 這樣發布的對象是不安全的,因為我們無法得知其他線程是否會修改這個域導致該類裏數據的錯誤 * * @return String[] */ public String[] getStates() { return states; } public static void main(String[] args) { UnsafePublish unsafePublish = new UnsafePublish(); // 輸出 [a, b, c] log.info("{}", Arrays.toString(unsafePublish.getStates())); unsafePublish.getStates()[0] = "d"; // 輸出 [d, b, c] log.info("{}", Arrays.toString(unsafePublish.getStates())); } }
在這個例子中,我們通過new對象得到了對象實例。獲得這個對象後,我們可以調用getStates()方法得到私有屬性的引用,這樣就可以在其他任何線程中,修改該屬性的值。那麽這就會導致我們在其他線程中,獲取該屬性的值時是不確定的,因為並不能得知該屬性的值是否已被其他線程所修改過,所以這就是不安全的對象發布。
對象逸出示例:
package org.zero.concurrency.demo.example.publish; import lombok.extern.slf4j.Slf4j; import org.zero.concurrency.demo.annotations.NoRecommend; import org.zero.concurrency.demo.annotations.NotThreadSafe; /** * @program: concurrency-demo * @description: 對象逸出示例,在對象構造完成之前,不可以將其發布 * @author: 01 * @create: 2018-10-16 16:36 **/ @Slf4j @NotThreadSafe @NoRecommend public class Escape { private int thisCanBeEscape = 0; public Escape() { new InnerClass(); } private class InnerClass { public InnerClass() { log.info("{}", Escape.this.thisCanBeEscape); } } public static void main(String[] args) { new Escape(); } }
在以上這個例子中,內部類的構造器裏包含了對封裝實例的隱含引用,這樣在對象沒有被正確構造完成之前就會被發布,由此會導致不安全的因素在裏面。其中一個就是導致this引用在構造期間逸出的錯誤,它是在構造函數構造過程中啟動了一個線程,無論是顯式啟動還是隱式啟動,都會造成this引用的逸出。新線程總會在所屬對象構造完畢之前就已經看到它了,所以如果要在構造函數中創建線程,那麽不要啟動它,而是應該采用一個專有的start,或是其他初始化的方式統一啟動線程。這裏其實我們可以使用工廠方法和私有構造函數來完成對象創建和監聽器的註冊等等來避免不正確的發布。
如何安全發布對象
上一小節中,我們簡述了什麽是發布對象,以及給出了不安全發布對象的示例和對象逸出的示例和說明。所以本小節我們將看看如何安全的發布對象,想要安全的發布對象主要有四種方法:
- 在靜態初始化函數中初始化一個對象的引用
- 將對象的引用保存到volatile類型域或者AtomicReference對象中
- 將對象的引用保存到某個正確構造對象的final類型域中
- 將對象的引用保存到一個由鎖保護的域中
以上所提到的幾種方法都可以應用到單例模式中,所以本文將以單例模式為例,介紹如何安全發布對象,以及單例實現的一些問題。
眾所周知,單例模式是最常用的設計模式了。Spring容器中所管理的類的實例默認也是單例的,雖然單例看似簡單,但也是有不少需要註意的地方,特別是在多線程環境下。基礎的單例模式實現方式就不贅述了,我們來看看為什麽采用了雙重同步鎖的懶漢式單例還是線程不安全的。示例代碼如下:
/**
* 雙重同步鎖懶漢式單例-線程不安全
* 實例在第一次使用的時候創建
*
* @author 01
*/
public class SingletonExample4 {
/**
* 單例對象
*/
private static SingletonExample4 instance = null;
/**
* 私有構造函數
*/
private SingletonExample4() {
}
/**
* 靜態工廠方法-獲取實例
*
* @return instance
*/
public static SingletonExample4 getInstance() {
// 雙重檢查機制
if (instance == null) {
// 同步鎖
synchronized (SingletonExample4.class) {
if (instance == null) {
instance = new SingletonExample4();
}
}
}
return instance;
}
}
以上代碼中在執行 instance = new SingletonExample4();
語句的時候,底層實際進行了以下三步操作:
1.memory = allocate() // 分配對象的內存空間
2.ctorInstance() // 初始化對象
3.instance = memory // 設置instance指向剛分配的內存
在代碼邏輯上,看似不會出現線程不安全的問題。但是在JVM裏,這幾步可能會被亂序執行,即便是亂序執行,在單線程下也不會有什麽問題,但是在多線程下就不一樣了。經過JVM和CPU的優化,指令可能會重排成下面的順序:
1.memory = allocate() // 分配對象的內存空間
3.instance = memory // 設置instance指向剛分配的內存
2.ctorInstance() // 初始化對象
假設按照這個指令順序執行的話,那麽當線程A執行完1和3時,instance對象還未完成初始化,但已經不再指向null。此時如果線程B搶占到CPU資源,執行 if (instance == null)
的結果會是false,從而返回一個沒有初始化完成的instance對象。如下圖所示:
那麽要如何避免這一情況呢?我們需要給instance對象增加一個volatile關鍵字進行修飾,這樣就不會出現指令重排的情況了。若對volatile不是很清楚的話,可以參考我另一篇文章中對volatile關鍵字的介紹:
- 線程安全性詳解
修改後線程安全的懶漢式單例代碼如下:
public class SingletonExample5 {
/**
* 單例對象,使用 volatile 關鍵字禁止指令重排
*/
private volatile static SingletonExample5 instance = null;
/**
* 私有構造函數
*/
private SingletonExample5() {
}
/**
* 靜態工廠方法-獲取實例
*
* @return instance
*/
public static SingletonExample5 getInstance() {
// 雙重檢查機制
if (instance == null) {
// 同步鎖
synchronized (SingletonExample5.class) {
if (instance == null) {
instance = new SingletonExample5();
}
}
}
return instance;
}
}
經過volatile的修飾,當線程A執行instance = new Singleton的時候,JVM執行順序是什麽樣?始終保證是下面的順序:
1.memory = allocate() // 分配對象的內存空間
2.ctorInstance() // 初始化對象
3.instance = memory // 設置instance指向剛分配的內存
如此在線程B看來,instance對象的引用要麽指向null,要麽指向一個初始化完畢的Instance,而不會出現某個中間態,保證了安全。
實現單例模式的方式有很多種,除了以上所提到的,我們還可以使用靜態內部類來實現單例,這樣更簡單,不需要判空也不需要加 volatile 關鍵字去防止指令重排的問題。示例代碼如下:
/**
* 使用靜態內部類實現的單例模式-線程安全
* 實例在第一次使用的時候創建
*
* @author 01
*/
public class SingletonExample8 {
/**
* 私有構造函數
*/
private SingletonExample8() {
}
/**
* 靜態工廠方法-獲取實例
*
* @return instance
*/
public static SingletonExample8 getInstance() {
return LazyHolder.INSTANCE;
}
/**
* 用靜態內部類創建單例對象
*/
private static class LazyHolder {
private static final SingletonExample8 INSTANCE = new SingletonExample8();
}
}
這裏有幾個需要註意的點:
- 從外部無法訪問靜態內部類LazyHolder,只有當調用Singleton.getInstance方法的時候,才能得到單例對象INSTANCE。
- INSTANCE對象初始化的時機並不是在單例類Singleton被加載的時候,而是在調用getInstance方法,使得靜態內部類LazyHolder被加載的時候。因此這種實現方式是利用classloader的加載機制來實現懶加載,並保證構建單例的線程安全。
以上所提到的單例實現方式並不能算是完全安全的,這裏的安全不僅指線程安全還有發布對象的安全。因為以上例子所實現的單例模式,我們都可以通過反射機制去獲取私有構造器更改其訪問級別從而實例化多個不同的對象,雖然一般不會這麽幹,但也難免會有這種情況。那麽如何防止利用反射構建對象呢?這時我們就需要使用到內部枚舉類了,因為JVM可以阻止反射獲取枚舉類的私有構造方法。示例代碼如下:
/**
* 使用枚舉類實現的單例模式-線程最為安全
* 實例在第一次使用的時候創建
*
* @author 01
*/
public class SingletonExample7 {
/**
* 私有構造函數
*/
private SingletonExample7() {
}
/**
* 靜態工廠方法-獲取實例
*
* @return instance
*/
public static SingletonExample7 getInstance() {
return Singleton.INSTANCE.getInstance();
}
/**
* 由枚舉類創建單例對象
*/
@Getter
private enum Singleton {
INSTANCE;
/**
* 單例對象
*/
private SingletonExample7 instance;
/**
* JVM保證這個方法絕對只調用一次
*/
Singleton() {
instance = new SingletonExample7();
}
}
}
使用枚舉實現的單例模式,是最為推薦的一種寫法,因為這種實現方式不但可以防止利用反射強行構建單例對象,而且可以保證線程安全,並且可以在枚舉類對象被反序列化的時候,保證反序列的返回結果是同一對象。這裏之所以使用內部枚舉類的原因是為了讓這個單例對象可以懶加載,相當於是結合了靜態內部類的實現思想。若不使用內部枚舉類的話,單例對象就會在枚舉類被加載的時候被構建。
單例模式實現總結:
如何安全發布對象