單例模式是最簡單的設計模式,實現也非常“簡單”。一直以為我寫沒有問題,直到被 Coverity 打臉。
1. 暴露問題
前段時間,有段代碼被 Coverity 警告了,簡化一下代碼如下,為了方便后面分析,我在這里標上了一些序號:
private static SettingsDbHelper sInst = null;public static SettingsDbHelper getInstance(Context context) {if (sInst == null) { // 1 synchronized (SettingsDbHelper.class) { // 2 SettingsDbHelper inst = sInst; // 3 if (inst == null) { // 4 inst = new SettingsDbHelper(context); // 5 sInst = inst; // 6 }}}return sInst; // 7 }
大家知道,這可是高大上的 Double Checked locking 模式,保證多線程安全,而且高性能的單例實現,比下面的單例實現,“逼格”不知道高到哪里去了:
private static SettingsDbHelper sInst = null;public static synchronized SettingsDbHelper getInstance(Context context) {if (sInst == null) {
sInst = new SettingsDbHelper(context);}return sInst;}
你一個機器人竟敢警告我代碼寫的不對,我一度懷疑它不認識這種寫法(后面將證明我是多么幼稚,啪。。。)。然后,它認真的給我分析這段代碼為什么有問題,如下圖所示:

2. 原因分析
Coverity 是靜態代碼分析工具,它會模擬其實際運行情況。例如這里,假設有兩個線程進入到這段代碼,其中紅色的部分是運行的步驟解析,開頭的標號表示其運行順序。關於 Coverity 的詳細文檔可以參考這里,這里簡單解析一下其運行情況如下:
- 線程 1 運行到 1 處,第一次進入,這里肯定是為
true的; - 線程 1 運行到 2 處,獲得鎖
SettingsDbHelper.class; - 線程 1 運行到 3 和 4 處,賦值
inst = sInst,這時 sInst 還是 null,所以繼續往下運行,創建一個新的實例; - 線程 1 運行到 6 處,修改 sInst 的值。這一步非常關鍵,這里的解析是,因為這些修改可能因為和其他賦值操作運行被重新排序(Re-order),這就可能導致先修改了 sInst 的值,而
new SettingsDbHelper(context)這個構造函數並沒有執行完。而在這個時候,程序切換到線程 2; - 線程 2 運行到 1 處,因為第 4 步的時候,線程 1 已經給 sInst 賦值了,所以
sInst == null的判斷為false,線程 2 就直接返回 sInst 了,但是這個時候 sInst 並沒有被初始化完成,直接使用它可能會導致程序崩潰。
上面解析得好像很清楚,但是關鍵在第 4 步,為什么會出現 Re-Order?賦值了,但沒有初始化又是怎么回事?這是由於 Java 的內存模型決定的。問題主要出現在這 5 和 6 兩行,這里的構造函數可能會被編譯成內聯的(inline),在 Java 虛擬機中運行的時候編譯成執行指令以后,可以用如下的偽代碼來表示:
inst = allocat(); // 分配內存
sInst = inst;
constructor(inst); // 真正執行構造函數
說到內存模型,這里就不小心觸及了 Java 中比較復雜的內容——多線程編程和 Java 內存模型。在這里,我們可以簡單的理解就是,構造函數可能會被分為兩塊:先分配內存並賦值,再初始化。關於 Java 內存模型(JMM)的詳解,可以參考這個系列文章 《深入理解Java內存模型》,一共有 7 篇(一,二,三,四,五,六,七)。
3. 解決方案
上面的問題的解決方法是,在 Java 5 之后,引入擴展關鍵字 volatile 的功能,它能保證:
對
volatile變量的寫操作,不允許和它之前的讀寫操作打亂順序;對volatile變量的讀操作,不允許和它之后的讀寫亂序。
關於 volatile 關鍵字原理詳解請參考上面的 深入理解內存模型(四)。
所以,上面的操作,只需要對 sInst 變量添加 volatile 關鍵字修飾即可。但是,我們知道,對 volatile 變量的讀寫操作是一個比較重的操作,所以上面的代碼還可以優化一下,如下:
private static volatile SettingsDbHelper sInst = null; // <<< 這里添加了 volatile public static SettingsDbHelper getInstance(Context context) {
SettingsDbHelper inst = sInst; // <<< 在這里創建臨時變量 if (sInst == null) {synchronized (SettingsDbHelper.class) {
inst = sInst;if (inst == null) {
inst = new SettingsDbHelper(context);
sInst = inst;}}}return inst; // <<< 注意這里返回的是臨時變量 }
通過這樣修改以后,在運行過程中,除了第一次以外,其他的調用只要訪問 volatile 變量 sInst 一次,這樣能提高 25% 的性能(Wikipedia)。
有讀者提到,這里為什么需要再定義一個臨時變量 inst?通過前面的對 volatile 關鍵字作用解釋可知,訪問 volatile 變量,需要保證一些執行順序,所以的開銷比較大。這里定義一個臨時變量,在 sInst 不為空的時候(這是絕大部分的情況),只要在開始訪問一次 volatile 變量,返回的是臨時變量。如果沒有此臨時變量,則需要訪問兩次,而降低了效率。
最后,關於單例模式,還有一個更有趣的實現,它能夠延遲初始化(lazy initialization),並且多線程安全,還能保證高性能,如下:
class Foo {private static class HelperHolder {public static final Helper helper = new Helper();}public static Helper getHelper() {return HelperHolder.helper;}}
延遲初始化,這里是利用了 Java 的語言特性,內部類只有在使用的時候,才回去加載,從而初始化內部靜態變量。關於線程安全,這是 Java 運行環境自動給你保證的,在加載的時候,會自動隱形的同步。在訪問對象的時候,不需要同步 Java 虛擬機又會自動給你取消同步,所以效率非常高。
另外,關於 final 關鍵字的原理,請參考 深入理解Java內存模型(六)。
補充一下,有同學提醒有一種更加 Hack 的實現方式--單個成員的枚舉,據稱是最佳的單例實現方法,如下:
public enum Foo {
INSTANCE;}
詳情可以參考 這里。
4. 總結
在 Java 中,涉及到多線程編程,問題就會復雜很多,有些 Bug 甚至會超出你的想象。通過上面的介紹,開始對自己的代碼運行情況都不那么自信了。其實大可不必這樣擔心,這種僅僅發生在多線程編程中,遇到有臨界值訪問的時候,直接使用 synchronized 關鍵字能夠解決絕大部分的問題。
對於 Coverity,開始抱着敬畏知心,它是由一流的計算機科學家創建的。Coverity 作為一個程序,本身知道的東西比我們多得多,而且還比我認真,它指出的問題必須認真對待和分析。
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
|
@NotThreadSafe
public
class
NumberRange{
private
int
lower,upper;
public
int
getLower(){
return
lower;
}
public
int
getUpper(){
return
upper;
}
public
void
setLower(
int
value){
if
(value > upper)
throw
new
IllegalArgumentException(...);
lower = value;
}
public
void
setUpper(
int
value){
if
(value < lower)
throw
new
IllegalArgumentException(...);
upper = value;
}
}
|
|
1
2
3
4
5
6
7
8
9
10
11
12
13
|
volatile
boolean shutdownRequested;
...
public
void
shutdown()
{
shutdownRequested=
true
;
}
public
void
doWork()
{
while
(!shutdownRequested)
{
//dostuff
}
}
|
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
|
public
class
BackgroundFloobleLoader{
public
volatile
Flooble theFlooble;
public
void
initInBackground(){
//dolotsofstuff
theFlooble = newFlooble();
//this is the only write to theFlooble
}
}
public
class
SomeOtherClass{
public
void
doWork(){
while
(
true
){
//dosomestuff...
//usetheFlooble,butonlyifitisready
if
(floobleLoader.theFlooble!=
null
)doSomething(floobleLoader.theFlooble);
}
}
}
|
|
1
2
3
4
5
6
7
8
9
10
11
12
|
public
class
UserManager{
public
volatile
String lastUser;
public
boolean
authenticate(String user, String password){
boolean
valid = passwordIsValid(user, password);
if
(valid){
User u =
new
User();
activeUsers.add(u);
lastUser = user;
}
return
valid;
}
}
|
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
|
@ThreadSafe
public
class
Person{
private
volatile
String firstName;
private
volatile
String lastName;
private
volatile
intage;
public
String getFirstName(){
return
firstName;
}
public
String getLastName(){
return
lastName;
}
public
int
getAge(){
return
age;
}
public
void
setFirstName(String firstName){
this
.firstName = firstName;
}
public
void
setLastName(String lastName){
this
.lastName = lastName;
}
public
void
setAge(
int
age){
this
.age = age;
}
}
|
|
1
2
3
4
5
6
7
8
9
10
11
12
13
|
@ThreadSafe
public
class
CheesyCounter{
//Employs the cheap read-write lock trick
//All mutative operations MUST be done with the 'this' lock held
@GuardedBy
(
"this"
)
private
volatile
int
value;
public
int
getValue(){
return
value;
}
public
synchronized
int
increment(){
return
value++;
}
}
|
