線程安全性
什么是線程安全性
《Java Concurrency In Practice》一書的作者 Brian Goetz 是這樣描述“線程安全”的:“當多個線程訪問一個對象時,如果不用考慮這些線程在運行時環境下的調度和交替執行,也不需要進行額外的同步,或者在調用方進行任何其他的協調操作,調用這個對象的行為都可以獲得正確的結果,那這個對象是線程安全的”。
在這定義中,最核心的概念是“正確性”。
在計算機世界中,在一段程序工作進行期間,會被不停的中斷和切換,對象的屬性(數據)可能會在中斷期間被修改和變臟。
在 Java 語言中,線程安全性的問題限定於多個線程之間存在共享數據訪問這個前提,因為如果一段代碼根本不會與其他線程共享數據,那么從線程安全的角度來看,程序是串行執行還是多線程執行對它來說都是完全沒有區別的。
如果每個線程中對共享數據(如全局變量、靜態變量)只有讀操作,而無寫操作,一般來說這種共享數據是線程安全的,而如果存在多個線程同時執行寫操作,一般都需要考慮線程同步,否則就可能影響線程安全。
Java 中的線程安全
Brian Goetz 曾發表過一篇論文,他並沒有將線程安全當做一個非真即假的概念,而是按照線程安全的“安全程度”由強至弱來排序,來將 java 語言中的各種操作共享的數據分為以下 5 類:不可變、絕對線程安全、相對線程安全、線程兼容和線程對立。
1.不可變
在 Java 語言中(特指 JDK 1.5 以后,即 Java 內存模型被修正之后的 Java 語言),不可變的對象一定是線程安全的,無論是對象的方法實現還是方法的調用者,都不需要采取任何的線程安全保障措施。
在 Java 中,如果共享數據的數據類型不同,保證其不可變的方式也有所不同。
-
共享數據是基本數據類型:這種情況只需要在定義時使用 final 關鍵字修飾它就可以保證它是不可變的。
-
共享數據是一個對象:這種情況需要保證對象的行為不會對其狀態產生影響。保證對象行為不會影響自己狀態的途徑有很多種:
比如 String 對象,當我們調用 String 對象的 subString()、replace()等方法時都不會影響它原來的值,只會返回一個新構造的字符串對象。又或者我們可以直接將對象中所有的變量都聲明為 final。
2.絕對線程安全
絕對線程安全即是完全滿足 Brian Goetz 對線程安全的定義,這是個很嚴格的定義:一個類要達到“不管運行時環境如何,調用者都不需要任何額外的同步措施”。
3.相對線程安全
相對線程安全就是我們通常意義上所講的線程安全,它需要保證對這個對象單獨的操作是線程安全的,在調用時不需要做額外的保障措施,但是對於一些特定順序的連續調用,就可能需要在調用端使用額外的同步手段來保證正確性。Java 語言中的大部分線程安全類都屬於這種類型,如 Vector、HashTable、Collections 的 synchronizedCollection() 方法包裝集合等。
4. 線程兼容
線程兼容指的是對象本身並不是線程安全的,但是可以通過在調用端正確地使用同步手段來保證對象在並發環境中可以安全地使用。我們通常所說的一個類不是線程安全的,絕大多數時候指的是這種情況。Java API中的大部分類都是屬於線程兼容的。比如集合類 ArrayList 和 HashMap 等。
5. 線程對立
線程對立是指無論調用端是否采取了同步措施,都無法在多線程環境中並發使用的代碼。由於 Java 語言天生就具備多線程特性,線程對立這種排斥多線程的代碼是很少出現的,而且通常是有害的,應當盡量避免。
非線程安全的影響
全局變量的非線程安全
在了解了什么是線程安全之后,我們來看一下在多線程環境下,對非線程安全的共享數據進行操作,會導致什么樣的問題。
下面用經典的 Java 多線程模擬賣火車票的問題來進行說明:
public class TicketTest {
public static void main(String[] args) {
TicketSaleRunnable runnable = new TicketSaleRunnable();
Thread t1 = new Thread(runnable, "1號窗口");
Thread t2 = new Thread(runnable, "2號窗口");
Thread t3 = new Thread(runnable, "3號窗口");
t1.start();
t2.start();
t3.start();
}
}
class TicketSaleRunnable implements Runnable {
private int tickets = 10; //總票數10張
public void run() {
while(true) {
if(tickets > 0) {
tickets--;
Thread.yield(); //讓出線程,增加出錯幾率
System.out.println(
Thread.currentThread().getName() + ",剩余票數:" + tickets);
}else {
break;
}
}
}
}
輸出結果:
1號窗口,剩余票數:9
3號窗口,剩余票數:7
2號窗口,剩余票數:7
1號窗口,剩余票數:6
2號窗口,剩余票數:4
3號窗口,剩余票數:4
1號窗口,剩余票數:3
3號窗口,剩余票數:1
2號窗口,剩余票數:1
1號窗口,剩余票數:0
可以看到當多個線程同時訪問余票(全局變量)時,出現了線程不安全的問題,在不同的線程中輸出了重復的結果。
下面我們再通過 ArrayList 和 Vector 來進一步分析一下非線程安全所帶來的問題,以及產生的原因。
ArrayList 和 Vector 的線程安全性
不安全的 ArrayList
我們經常見到這樣的面試題“ArrayList 和 Vector 的區別,HashMap 和 HashTable 的區別,StringBuilder 和 StringBuffer 的區別?”
答案在上文也有所提及:
ArrayList 是非線程安全的,Vector 是線程安全的;
HashMap 是非線程安全的,HashTable 是線程安全的;
StringBuilder 是非線程安全的,StringBuffer 是線程安全的。
下面通過一個示例來展示一下 ArrayList 非線程安全問題:
public class UnsafeTest {
public static void main(String[] args) throws InterruptedException {
final List<Integer> list = new ArrayList<Integer>();
new Thread(new Runnable(){
public void run() {
for(int i = 0; i < 100; i++) {
list.add(i);
try {
Thread.sleep(10);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}).start();
new Thread(new Runnable() {
public void run() {
for(int i = 100; i < 200; i++){
list.add(i);
try {
Thread.sleep(10);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}).start();
Thread.sleep(10000);
// 打印所有結果
for (int i = 0; i < list.size(); i++) {
System.out.println("第" + (i) + "號元素為:" + list.get(i));
}
}
}
運行結果:
Exception in thread "Thread-1" java.lang.ArrayIndexOutOfBoundsException: 109
at java.util.ArrayList.add(Unknown Source)
at selfprivate.UnsafeTest$2.run(UnsafeTest.java:32)
at java.lang.Thread.run(Unknown Source)
即便是我們多嘗試幾次,使得程序運行成功結束不拋出異常:
第0號元素為:0
第1號元素為:100
第2號元素為:1
···
第8號元素為:106
第9號元素為:6
第10號元素為:null
第11號元素為:107
第12號元素為:108
···
第185號元素為:197
第186號元素為:97
第187號元素為:98
第188號元素為:198
第189號元素為:199
第190號元素為:99
也經常會發現某些位置出現了 null 值的情況,並且 ArrayList 最終的 size 是小於 200 的。
從運行的結果來看,ArrayList 的確是非線程安全的,我們結合 ArrayList 的源碼一起分析一下它的問題主要出在哪里:
public class ArrayList<E> extends AbstractList<E>
implements List<E>, RandomAccess, Cloneable, java.io.Serializable
{
//默認集合容量大小
private static final int DEFAULT_CAPACITY = 10;
//ArrayList內部維護的是一個數組來保存元素
transient Object[] elementData;
//elementData所存儲的元素個數
private int size;
...
public boolean add(E e) {
ensureCapacityInternal(size + 1); // 判斷內部數組的容量是否足夠,是否需要擴容
elementData[size++] = e; //將元素保存到數組中,並將 size 自增1
return true;
}
}
1. 多個線程同時進行 add 操作可能會導致拋出數組越界 ArrayIndexOutOfBoundsException 的異常。
當數組總容量為 10,且當前已保存了 9 個元素(即size=9)時,線程A 進入 add 方法,並調用ensureCapacityInternal方法判斷了容量夠用,不需要擴容。隨后立即執行線程B 的add 方法開,也調用了ensureCapacityInternal判斷了此時容量夠用,不需要擴容,接着執行線程 A 的elementData[size++] = e 操作,size 變為 10,線程 B 也開始執行這個賦值操作,而 elementData[]數組的最大下標為9,則調用 elemenmt[10] = e,則就拋出了數組越界異常了。
2. ArrayList 集合中某些位置上的值出現了 null 的情況
3. 一個線程的值會覆蓋掉另一個線程添加的值
這是因為賦值操作 element[size++] = e 並不是一個原子操作,它可以看成這樣兩步:
elementData[size] = e;
size = size + 1; //注意這一步也不是原子操作
當線程 A 執行了 elementData[size] = e 之后,即開始執行線程 B 的 elementData[size] = e 操作,此時這兩個線程的 size 值都還沒有增加,所以 線程 B 的值覆蓋掉了 線程 A 的賦值。接着線程 A 執行 size 增加 1 的操作,線程 B 的 size 也加 1,這就導致了 size 一共增加了兩次,這樣就空出了一個位置,就導致某一位置的值為 null 的情況。
4. ArrayList 集合實際的 size 比期望的 size 值要小
這是因為源碼中的遞增操作 size++ 並非是原子操作,實際上它包含了三個獨立的操作:讀取 size 的值,將值加1,然后將計算結果寫入 size。這在多線程環境就很容易導致 size 的計算出錯。線程 A 讀取了 size,在執行加1之前,線程 B 也讀取了 size 的值,這兩個線程獲取的是同樣的 size 值,然后這兩個線程各自為 size 增加 1,將值寫入 size 中,最終得到的 size 也只增加了一次,而不是兩次。
安全的 Vector
現在我們把上面的例子中的 ArrayList
final List<Integer> list = new ArrayList<Integer>();
替換為 Vector
final List<Integer> list = new Vector<Integer>();
再次運行程序,輸出結果:
第0號元素為:0
第1號元素為:100
第2號元素為:1
第3號元素為:101
第4號元素為:2
第5號元素為:102
···
第195號元素為:197
第196號元素為:198
第197號元素為:98
第198號元素為:199
第199號元素為:99
沒有出現 null 值的情況,size 的值也與期望的一樣是 200。
從結果來看 Vector 確實是線程安全的。那么Vector是如何保證線程安全的呢?
通過查看 Vector 的源碼,可以看到它的 add 方法多了一個 synchronized 修飾符。
public synchronized boolean add(E e) {
modCount++;
ensureCapacityHelper(elementCount + 1);
elementData[elementCount++] = e;
return true;
}
在下一篇文章我們將學習一下 synchronized 操作符的作用。
