一般來說,對於併發的場景,我們通常使用鎖來保證線程安全:
- 悲觀鎖是一種悲觀的策略。它總是假設每一次的臨界區操作會產生衝突,因此,必須對每次操作都小心翼翼。如果有多個線程同時需要訪問臨界區資源,就寧可犧牲性能讓線程進行等待,所以說鎖會阻塞線程執行;
- 樂觀鎖是一種樂觀的策略,它會假設對資源的訪問是沒有衝突的。既然沒有衝突,自然不需要等待,所以所有的線程都可以在不停頓的狀態下持續執行。樂觀鎖的策略使用一種叫做比較交換的技術(CAS Compare And Swap)來鑑別線程衝突,一旦檢測到衝突產生,就重試當前操作直到沒有衝突爲止。
很多時候,對共享資源的訪問主要是對某一數據結構的讀寫操作,如果數據結構本身就帶有排他性訪問的特性,也就相當於該數據結構自帶一個細粒度的鎖,對該數據結構的併發訪問就能更加簡單高效。
使用樂觀鎖的好處:
- 在高併發的情況下,它比有鎖的程序擁有更好的性能;
- 它天生就是死鎖免疫的,即不會出現死鎖。
就憑藉這兩個優勢,就值得我們冒險嘗試使用樂觀鎖的併發。
那麼,java中有哪些樂觀鎖技術呢?
1.原子操作
原子操作:顧名思義就是不可分割的操作,該操作只存在未開始和已完成兩種狀態,不存在中間狀態,在JDK 1.8 中,java.util.concurrent.atomic 包下類都是原子類,原子類都是基於 sun.misc.Unsafe 實現的,用於實現原子操作。
上文提到的比較交換技術是通過名爲 CAS 的CPU指令實現原子操作,由 CPU 硬件級別上保證原子性:
CAS(變量, 比較值, 新值)
當變量的當前值與比較值相等時,才把變量更新爲新值。
java.util.concurrent.atomic
包中的原子分爲:原子性基本數據類型、原子性對象引用類型、原子性數組、原子性對象屬性更新器和原子性累加器。具體實現類如下:
- 原子性基本數據類型:
AtomicBoolean
、AtomicInteger
、AtomicLong
- 原子性對象引用類型:
AtomicReference
、AtomicStampedReference
、AtomicMarkableReference
- 原子性數組:
AtomicIntegerArray
、AtomicLongArray
、AtomicReferenceArray
- 原子性對象屬性更新:
AtomicIntegerFieldUpdater
、AtomicLongFieldUpdater
、AtomicReferenceFieldUpdater
- 原子性累加器:
DoubleAccumulator
、DoubleAdder
、LongAccumulator
、LongAdder
當多個線程需要操作同一個變量時,我們可以放心地假設是線程安全的。
2. 線程本地存儲
java.lang.ThreadLocal 類用於線程本地化存儲。
線程本地化存儲,就是爲每一個線程創建一個變量,只有本線程可以在該變量中查看和修改值。需要明確的是,這種情況根本不存在資源共享,各個線程各用操作各自的變量,因此,也就不存在死鎖。
典型的使用例子就是,spring 在處理數據庫事務問題的時候,就用了 ThreadLocal 爲每個線程存儲了各自的數據庫連接 Connection。
使用 ThreadLocal 要注意,在不使用該變量的時候,一定要調用 remove() 方法移除變量,否則可能造成內存泄漏的問題。
/**
* 描述 Java中的ThreadLocal類允許我們創建只能被同一個線程讀寫的變量。
* 因此,如果一段代碼含有一個ThreadLocal變量的引用,即使兩個線程同時執行這段代碼,
* 它們也無法訪問到對方的ThreadLocal變量。
*/
public class ThreadLocalExsample {
/**
* 創建了一個MyRunnable實例,並將該實例作爲參數傳遞給兩個線程。兩個線程分別執行
* run()方法,並且都在ThreadLocal實例上保存了不同的值。
* 如果它們訪問的不是ThreadLocal對象,則第二個線程會覆蓋掉第一個線程設置的值。
*/
public static class MyRunnable implements Runnable {
private ThreadLocal threadLocal = new ThreadLocal();
@Override
public void run() {
//一旦創建了一個ThreadLocal變量,你可以通過如下代碼設置某個需要保存的值
threadLocal.set((int) (Math.random() * 100D));
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
}
//可以通過下面方法讀取保存在ThreadLocal變量中的值
System.out.println("-------threadLocal value-------"+threadLocal.get());
}
}
public static void main(String[] args) {
MyRunnable sharedRunnableInstance = new MyRunnable();
Thread thread1 = new Thread(sharedRunnableInstance);
Thread thread2 = new Thread(sharedRunnableInstance);
thread1.start();
thread2.start();
}
}
運行結果:
-------threadLocal value-------38
-------threadLocal value-------88
3. copy-on-write
copy-on-write,即寫時複製,是指:在併發訪問的情景下,當需要修改JAVA中容器的元素時,不直接修改該容器,而是先複製一份副本,在副本上進行修改。修改完成之後,將指向原來容器的引用指向新的容器(副本容器)。
集合類上的常用操作是:向集合中添加元素、刪除元素、遍歷集合中的元素然後進行某種操作。當多個線程併發地對一個集合對象執行這些操作時就會引發ConcurrentModificationException,比如:
線程A在for-each中遍歷ArrayList
,而線程B同時又在刪除ArrayList
中的元素,就可能會拋出ConcurrentModificationException
,可以在線程A遍歷ArrayList
時加鎖避免這一異常;
但由於遍歷操作是一種常見的操作,加鎖之後會影響程序的性能,因此for-each遍歷選擇了不對ArrayList
加鎖,而是當有多個線程修改ArrayList
時拋出ConcurrentModificationException
,因此,這是一種設計上的權衡。
爲了應對這種多線程併發修改集合的這種情況,通常有下面的兩種策略:
- “寫時複製” 機制;
- 線程安全的容器類,例如利用
CopyOnWriteArrayList
代替ArrayList
,利用ConcurrentHashMap
代替HashMap
。它們並不是從“複製”這個角度來應對多線程併發修改,而是引入了分段鎖和CAS鎖解決多線程併發修改的問題。
這裏,主要討論"寫時複製"機制。由於不會其修改原始容器,只修改副本容器。因此,可以對原始容器進行併發地讀。其次,實現了讀操作與寫操作的分離,讀操作發生在原始容器上,寫操作發生在副本容器上;數據一致性問題:讀操作的線程可能不會立即讀取到新修改的數據,因爲修改操作發生在副本上。但最終修改操作會完成並更新容器,因此這是最終一致性。
CopyOnWrite
容器適用於讀多寫少的場景。因爲寫操作時,需要複製一個容器,造成內存開銷很大,也需要根據實際應用把握初始容器的大小。而不適合於數據的強一致性場合,即當數據修改之後要求能夠立即被讀到,則不能用寫時複製技術。因爲它是最終一致性。
Java 中的 copy-on-write 容器包括:CopyOnWriteArrayList
、CopyOnWriteArraySet
。
這裏通過如下實例說明一下CopyOnWriteArrayList
的用法:
import java.util.ArrayList;
import java.util.List;
import java.util.Random;
import java.util.concurrent.CopyOnWriteArrayList;
public class TestCopyOnWrite {
private static final Random R = new Random();
private static CopyOnWriteArrayList<Integer> cowList = new CopyOnWriteArrayList<Integer>();
//private static ArrayList<Integer> cowList = new ArrayList<Integer>();
public static void main(String[] args) throws InterruptedException {
List<Thread> threadList = new ArrayList<Thread>();
//啓動 10 個線程,向 cowList 添加 5 個隨機整數
for (int i = 0; i < 10; i++) {
Thread t = new Thread(() -> {
for (int j = 0; j < 5; j++) {
//休眠 10 毫秒,讓線程同時向 cowList 添加整數,引出併發問題
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
int tmp = R.nextInt(100);
cowList.add(tmp);
System.out.println(Thread.currentThread().getName()+" : "+tmp);
}
}) ;
t.start();
threadList.add(t);
}
//主線程等待所有的線程運行結束
for (Thread t : threadList) {
t.join();
}
System.out.println(cowList.size());
}
}
使用CopyOnWriteArrayList
可以保證列表中數據個數始終未50個,結果如下:
Thread-2 : 30
Thread-1 : 90
Thread-9 : 88
Thread-0 : 67
Thread-8 : 29
Thread-7 : 30
Thread-6 : 28
Thread-5 : 18
Thread-4 : 23
Thread-3 : 30
Thread-2 : 10
Thread-0 : 1
Thread-8 : 79
Thread-7 : 21
Thread-9 : 22
Thread-1 : 10
Thread-4 : 78
Thread-5 : 81
Thread-3 : 44
Thread-6 : 47
Thread-2 : 24
Thread-7 : 90
Thread-0 : 83
Thread-8 : 93
Thread-1 : 10
Thread-5 : 43
Thread-3 : 67
Thread-6 : 23
Thread-9 : 40
Thread-4 : 36
Thread-2 : 29
Thread-0 : 3
Thread-7 : 6
Thread-1 : 43
Thread-8 : 43
Thread-3 : 90
Thread-9 : 26
Thread-5 : 65
Thread-6 : 58
Thread-4 : 76
Thread-2 : 85
Thread-8 : 95
Thread-1 : 10
Thread-7 : 65
Thread-0 : 95
Thread-3 : 40
Thread-6 : 89
Thread-4 : 53
Thread-9 : 86
Thread-5 : 95
50
而如果使用ArrayList
,則偶爾會出現列表中數據個數少於50個的情況:
Thread-2 : 30
Thread-8 : 38
Thread-7 : 42
Thread-6 : 96
Thread-3 : 59
Thread-5 : 46
Thread-1 : 14
Thread-4 : 18
Thread-0 : 54
Thread-9 : 17
Thread-8 : 26
Thread-6 : 37
Thread-1 : 87
Thread-5 : 26
Thread-9 : 7
Thread-3 : 66
Thread-7 : 9
Thread-2 : 56
Thread-0 : 81
Thread-4 : 30
Thread-8 : 19
Thread-5 : 59
Thread-6 : 10
Thread-7 : 23
Thread-3 : 17
Thread-4 : 4
Thread-0 : 19
Thread-2 : 45
Thread-9 : 81
Thread-1 : 8
Thread-8 : 22
Thread-6 : 9
Thread-3 : 51
Thread-0 : 23
Thread-5 : 45
Thread-4 : 8
Thread-7 : 24
Thread-1 : 44
Thread-2 : 35
Thread-9 : 21
Thread-8 : 67
Thread-3 : 53
Thread-5 : 46
Thread-4 : 96
Thread-1 : 68
Thread-0 : 82
Thread-6 : 12
Thread-7 : 63
Thread-9 : 80
Thread-2 : 11
49
本文內容參考資料:Java 中有哪些無鎖技術來解決併發問題?如何使用?