Java併發程式設計入門(十八)再論執行緒安全
一、無需加鎖的執行緒安全場景
如下幾種場景無需加鎖就能做到執行緒安全:
1.不變物件
2.執行緒封閉
3.棧封閉
4.ThreadLocal
I、不變物件
經典併發程式設計描述物件滿足不變性有以下條件:
1.物件建立後狀態就不再變化。
2.物件的所有域都是final型別。
3.建立物件期間,this引用沒有溢位。
實際對於第2點描述不完全準確:
1.只要成員變數是私有的,並且只提供只讀操作,就可能做到執行緒安全,並不一定需要final修飾,注意這裡說的是可能,原因見第2點。
2.如果成員變數是個物件,並且外部可寫,那麼也不能保證執行緒安全,例如:
public class Apple {
public static void main(String[] args) {
Dictionary dictionary = new Dictionary();
Map<String,String> map = dictionary.getMap();
//這個操作後,導致下一步的操作結果和預期不符,預期不符就不是執行緒安全
map.clear();
System.out.println(dictionary.translate("蘋果"));
}
}
class Dictionary {
private final Map<String,String> map = new HashMap<String,String>();
public Dictionary() {
map.put("蘋果","Apple");
map.put("橘子","Orange");
}
public String translate(String cn) {
if (map.containsKey(cn)) {
return map.get(cn);
}
return "UNKONWN";
}
public Map<String,String> getMap() {
return map;
}
}
複製程式碼
因此對不變物件的正確理解應該是:
1.物件建立後狀態不再變化(所有成員變數不再變化)
2.只有只讀操作。
3.任何時候物件的成員都不會溢位(成員不被其他外部物件進行寫操作),而不僅僅只是在構建時。
另,一些書籍和培訓提到不變類應該用final修飾,以防止類被繼承後子類不安全,個人覺得子類和父類本身就不是一個物件,我們說一個類是否執行緒安全說的是這個類本身,而不需要關心子類是否安全。
II、執行緒封閉
如果物件只在單執行緒中使用,不在多個執行緒中共享,這就是執行緒封閉。
例如web應用中獲取連線池中的資料庫連線訪問資料庫,每個web請求是一個獨立執行緒,當一個請求獲取到一個資料庫連線後,不會再被其他請求使用,直到資料庫連線關閉(回到連線池中)才會被其他請求使用。
III、棧封閉
物件只在區域性程式碼塊中使用,就是棧封閉的,例如:
public void print(Vector v) {
int size = v.size();
for (int i = 0; i < size; i++) {
System.out.println(v.get(i));
}
}
複製程式碼
變數size是區域性變數(棧封閉),Vector又是執行緒安全的容器,因此對於這個方法而言是執行緒安全的。
VI、ThreadLocal
通過ThreadLocal儲存的物件只對當前執行緒可見,因此也是執行緒安全的。
二、常見誤解的執行緒安全場景
I、執行緒安全容器總是安全
有些容器執行緒安全指的是原子操作執行緒安全,並非所有操作都安全,非執行緒安全的操作如:IF-AND-SET,容器迭代,例如:
public class VectorDemo {
public static void main(String[] args) {
Vector<String> tasks = new Vector<String>();
for (int i = 0; i < 10; i++) {
tasks.add("task" + i);
}
Thread worker1 = new Thread(new Worker(tasks));
Thread worker2 = new Thread(new Worker(tasks));
Thread worker3 = new Thread(new Worker(tasks));
worker1.start();
worker2.start();
worker3.start();
}
}
class Worker implements Runnable {
private Vector<String> tasks;
public Worker(Vector<String> tasks) {
this.tasks = tasks;
}
public void run() {
//如下操作非執行緒安全,多個執行緒同時執行,在判斷時可能都滿足條件,但實際處理時可能已經不再滿足條件
while (tasks.size() > 0) {
//模擬業務處理
sleep(100);
//實際執行時,這裡可能已經不滿足tasks.size() > 0
System.out.println(Thread.currentThread().getName() + " " + tasks.remove(0));
}
}
private void sleep(long millis) {
try {
TimeUnit.MILLISECONDS.sleep(millis);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
複製程式碼
輸出日誌:
Thread-0 task0
Thread-1 task2
Thread-2 task1
Thread-1 task3
Thread-2 task5
Thread-0 task4
Thread-0 task6
Thread-1 task8
Thread-2 task7
Thread-1 task9
Exception in thread "Thread-0" Exception in thread "Thread-2" java.lang.ArrayIndexOutOfBoundsException: Array index out of range: 0
at java.util.Vector.remove(Vector.java:831)
at com.javashizhan.concurrent.demo.safe.Worker.run(VectorDemo.java:46)
at java.lang.Thread.run(Thread.java:745)
java.lang.ArrayIndexOutOfBoundsException: Array index out of range: 0
at java.util.Vector.remove(Vector.java:831)
at com.javashizhan.concurrent.demo.safe.Worker.run(VectorDemo.java:46)
at java.lang.Thread.run(Thread.java:745)
複製程式碼
可以看到其中一個工作執行緒在tasks.remove(0)時,由於集合中已經沒有資料而丟擲異常。要做到執行緒安全則要對非原子操作加鎖,修改後的程式碼如下:
public void run() {
//對非原子操作加鎖
synchronized (tasks) {
while (tasks.size() > 0) {
sleep(100);
System.out.println(Thread.currentThread().getName() + " " + tasks.remove(0));
}
}
}
複製程式碼
II、final修飾的物件執行緒安全
在上述例子中,即使用final修飾Vector也非執行緒安全,final不代表被修飾物件是屬於執行緒安全的不變物件。
III、volatile修飾物件執行緒安全
volatile關鍵字修飾的物件只能保證可見性,這類變數不快取在CPU的快取中,這樣能保證如果A執行緒先修改了volatile變數的值,那麼B執行緒後讀取時就能看到最新值,而可見性不等於執行緒安全。
三、狹義執行緒安全和廣義執行緒安全
我們說Vector是執行緒安全的,但上面的例子已經說明:並非所有場景下Vector的操作都是執行緒安全的,但明明Vector又被公認為是執行緒安全的,這怎麼解釋?
由此,我們就可以定義狹義執行緒安全和廣義執行緒安全:
1.狹義:物件的每一個單個操作均執行緒安全
2.廣義:物件的每一個單個操作和組合操作都執行緒安全
對於上面例子中的Vector要修改為廣義執行緒安全,就需要在remove操作中做二次判斷,如果容器中已經沒有物件,就返回null,方法簽名可以修改為existsAndRemove,當然,為了做到廣義執行緒安全,修改的方法還不僅僅只有這一個。
四、總結
本文描述了不加鎖情況下執行緒安全的場景,以及容易誤解的執行緒安全場景,再到狹義執行緒安全和廣義執行緒安全,理解了這些,可以讓我們更清楚何時該加鎖,何時不需要加鎖,從而更有效的編寫執行緒安全程式碼。
end.
相關閱讀:
Java併發程式設計(一)知識地圖
Java併發程式設計(二)原子性
Java併發程式設計(三)可見性
Java併發程式設計(四)有序性
Java併發程式設計(五)建立執行緒方式概覽
Java併發程式設計入門(六)synchronized用法
Java併發程式設計入門(七)輕鬆理解wait和notify以及使用場景
Java併發程式設計入門(八)執行緒生命週期
Java併發程式設計入門(九)死鎖和死鎖定位
Java併發程式設計入門(十)鎖優化
Java併發程式設計入門(十一)限流場景和Spring限流器實現
Java併發程式設計入門(十二)生產者和消費者模式-程式碼模板
Java併發程式設計入門(十三)讀寫鎖和快取模板
Java併發程式設計入門(十四)CountDownLatch應用場景
Java併發程式設計入門(十五)CyclicBarrier應用場景
Java併發程式設計入門(十六)秒懂執行緒池差別
Java併發程式設計入門(十七)一圖掌握執行緒常用類和介面
Java極客站點: javageektour.com/