一、悲觀鎖
悲觀鎖:總是假設最壞的情況,每次去拿數據的時候都認爲別人會修改,所以每次在拿數據的時候都會上鎖,這樣別人想拿這個數據就會阻塞,直到它拿到鎖。
悲觀鎖是一種利用數據庫內部機制提供的鎖的方法,也就是對更新的數據加鎖,這樣在併發期間一旦一個事務持有了數據庫記錄的鎖,其他的線程將不能再對數據進行更新了,這就是悲觀鎖的實現邏輯。
對於悲觀鎖來說,只能有一個事務佔據資源,其他事務被掛起等待持有資源的事務提交併釋放資源。CPU就會將這些得不到資源的線程掛起,掛起的線程也會消耗CPU的資源,尤其是在高井發的請求中。
一旦該線程提交了事務,那麼鎖就會被釋放,這個時候被掛起的線程就會開始競爭資源,那麼競爭到的線程就會被 CPU 恢復到運行狀態,繼續運行。
在高併發的過程中,使用悲觀鎖就會造成大量的線程被掛起和恢復,這將十分消耗資源,這就是爲什麼使用悲觀鎖性能不佳的原因。
有些時候,我們也會把悲觀鎖稱爲獨佔鎖,畢竟只有一個線程可以獨佔這個資源,或者稱爲阻塞鎖,因爲它會造成其他線程的阻塞。無論如何它都會造成併發能力的下降,從而導致 CPU 頻繁切換線程上下文,造成性能低下。爲了克服這個問題,提高併發的能力,避免大量線程因爲阻塞導致 CPU 進行大量的上下文切換,程序設計大師們提出了樂觀鎖機制,樂觀鎖已經在企業中被大量應用了。
二、樂觀鎖
樂觀鎖是一種思想,總是假設最好的情況,每次去拿數據的時候都認爲別人不會修改,所以不會上鎖,只在更新的時候會判斷一下在此期間別人有沒有去更新這個數據。
樂觀鎖是一種不會阻塞其他線程併發的機制,它不會使用數據庫的鎖進行實現,它的設計裏面由於不阻塞其他線程,所以並不會引發線程頻繁掛起和恢復,這樣便能夠提高併發能力,所以也有人把它稱爲非阻塞鎖。
樂觀鎖使用的是 CAS 原理,所以我們先來討論 CAS 原理的內容。
2.1 CAS 原理概述
2.2 ABA 問題解決
2.3 樂觀鎖思想
可以說樂觀鎖是由CAS機制+版本機制來實現的。
樂觀鎖假設認爲數據一般情況下不會產生併發衝突,所以在數據進行提交更新的時候,纔會正式對數據是否產生併發衝突進行檢測,如果發現併發衝突了,則讓返回用戶錯誤的信息,讓用戶決定如何去做。
(1)CAS機制:當多個線程嘗試使用CAS同時更新同一個變量時,只有其中一個線程能更新變量的值,而其它線程都失敗。CAS 有效地說明了“ 我認爲位置 V 應該包含值 A;如果包含該值,則將 B 放到這個位置;否則,不要更改該位置,只告訴我這個位置現在的值即可“。
(2)版本機制:CAS機制保證了在更新數據的時候沒有被修改爲其他數據的同步機制,版本機制就保證了沒有被修改過的同步機制,解決了ABA問題。
2.4 樂觀鎖的代碼實例
2.4.1 線程不安全實例
先看一個不使用鎖,然後多線程訪問的實例:
要爭奪的資源
public class Number {
int num = 0;
public int getNum() {
return this.num;
}
public void setNum(int num) {
this.num = num;
}
public void add() {
num += 1;
}
public void dec() {
num -= 1;
}
}
線程一:
/**
* 線程一 數據做加操作的線程
*/
public class ThreadOne extends Thread {
Number num;
public ThreadOne(Number num) {
this.num = num;
}
@Override
public void run() {
for (int i = 0; i < Test.LOOP; ++i) {
num.add();
}
}
}
線程二:
/**
* 線程二 數據做減法操作的線程
*/
public class ThreadTwo extends Thread {
Number num;
public ThreadTwo(Number num) {
this.num = num;
}
@Override
public void run() {
for (int j = 0; j < Test.LOOP; j++) {
num.dec();
}
}
}
測試類:
public class Test {
final static int LOOP = 1000;
public static void main(String[] args) throws InterruptedException {
Number num = new Number();
Thread addThread = new ThreadOne(num);
Thread decThread = new ThreadTwo(num);
addThread.start();
decThread.start();
addThread.join();
decThread.join();
System.out.println(num.getNum());
}
}
我們運行3次,結果:
-16
8
293
對同一個數據0執行1000加法,再執行1000次減法,最後數據應該還是0纔對,但三次執行結果都不是0,且三次結果都不一樣。
也就是多線程更新數據時,數據沒有朝着我們期望的結果進行,產生這個結果的原因就是加線程和減線程並沒有均勻的搶佔資源,若果是均勻搶佔那麼意味着加和減的操作次數是一樣的,最終結果肯定是0;若加線程搶佔的次數多了,那結果就是正數;若減操作搶佔的次數多,結果就會是負數。
線程安全可以認爲是多線程訪問同一代碼(數據/資源)時,不會產生不確定的結果,那麼上面的情況就可以認爲是線程不安全的例子,解決的辦法就是加鎖,加鎖就會涉及到悲觀鎖和樂觀鎖兩種加鎖方式。
2.4.2 悲觀鎖實例
先看悲觀鎖的處理方式:
public class Number {
int num = 0;
public synchronized void add() {
num += 1;
}
public synchronized void dec() {
num -= 1;
}
public int getNum() {
return this.num;
}
public void setNum(int num) {
this.num = num;
}
}
以上共享資源的加和減操作都加上了鎖,相當於Number這個資源被鎖定,只有當釋放鎖以後另一個線程才能訪問。
public class ThreadOne extends Thread {
Number num;
public ThreadOne(Number num) {
this.num = num;
}
@Override
public void run() {
for (int i = 0; i < Test.LOOP; ++i) {
num.add();
}
}
}
public class ThreadTwo extends Thread {
Number num;
public ThreadTwo(Number num) {
this.num = num;
}
@Override
public void run() {
for (int j = 0; j < Test.LOOP; j++) {
num.dec();
}
}
}
public class Test {
final static int LOOP = 1000;
public static void main(String[] args) throws InterruptedException {
System.out.println("開始時間:" + new Date().getTime());
Number num = new Number();
Thread addThread = new ThreadOne(num);
Thread decThread = new ThreadTwo(num);
addThread.start();
decThread.start();
addThread.join();
decThread.join();
System.out.println(num.getNum());
System.out.println("結束時間:" + new Date().getTime());
}
}
結果:
開始時間:1592102861797
0
結束時間:1592102861799
每次執行都是0。
2.4.3 樂觀鎖實例
下面用樂觀鎖思想實現一下,
public class Number {
// int num = 0;
//使用AtomicInteger代替基本數據類型
AtomicInteger num = new AtomicInteger(0);
public void add() {
// num += 1;
num.addAndGet(1);
}
public void dec() {
// num -= 1;
num.decrementAndGet();
}
public AtomicInteger getNum() {
return this.num;
}
}
上面用AtomicInteger代替基本數據類型,其它代碼不變:
public class ThreadOne extends Thread {
Number num;
public ThreadOne(Number num) {
this.num = num;
}
@Override
public void run() {
for (int i = 0; i < Test.LOOP; ++i) {
num.add();
}
}
}
public class ThreadTwo extends Thread {
Number num;
public ThreadTwo(Number num) {
this.num = num;
}
@Override
public void run() {
for (int j = 0; j < Test.LOOP; j++) {
num.dec();
}
}
}
public class Test {
final static int LOOP = 1000;
public static void main(String[] args) throws InterruptedException {
System.out.println("開始時間:" + new Date().getTime());
Number num = new Number();
Thread addThread = new ThreadOne(num);
Thread decThread = new ThreadTwo(num);
addThread.start();
decThread.start();
addThread.join();
decThread.join();
System.out.println(num.getNum());
System.out.println("結束時間:" + new Date().getTime());
}
}
開始時間:1592103599077
0
結束時間:1592103599079
這裏最值得探究的就是AtomicInteger,爲什麼改個數據類型就能實現樂觀鎖的功能,打開源碼:
compareAndSwapInt不是就是CAS麼!返回true就可以執行更新操作。
那麼你會問CAS有了,ABA在哪呢?Java提供了AtomicStampedReference工具類。通過爲引用建立類似版本號(stamp)的方式,來保證CAS的正確性。AtomicStampedReference它內部不僅維護了對象值,還維護了一個時間戳(我這裏把它稱爲時間戳,實際上它可以使任何一個整數,它使用整數來表示狀態值)。當AtomicStampedReference對應的數值被修改時,除了更新數據本身外,還必須要更新時間戳。當AtomicStampedReference設置對象值時,對象值以及時間戳都必須滿足期望值,寫入纔會成功。因此,即使對象值被反覆讀寫,寫回原值,只要時間戳發生變化,就能防止不恰當的寫入。
AtomicStampedReference主要的方法入下:
我們大致演示一下這個類的使用方法:
public static void main(String[] args) {
String str1 = "aaa";
String str2 = "bbb";
String str3 = "ccc";
// 某個位置設置初始值爲str1,版本號爲1
AtomicStampedReference<String> reference = new AtomicStampedReference<String>(str1, 1);
System.out.println("reference.getReference() = " + reference.getReference());
System.out.println("reference.getStamp() = " + reference.getStamp());
System.out.println("-----------------------------");
// 用str2替換str1,替換成功返回true,同時更新版本號到2
boolean flag = reference.compareAndSet(str1, str2, reference.getStamp(), reference.getStamp() + 1);
System.out.println("flag: " + flag);
System.out.println("reference.getReference() = " + reference.getReference());
System.out.println("reference.getStamp() = " + reference.getStamp());
System.out.println("-----------------------------");
// 設置版本號到3
boolean b = reference.attemptStamp(str2, reference.getStamp() + 1);
System.out.println("b: " + b);
System.out.println("reference.getReference() = " + reference.getReference());
System.out.println("reference.getStamp() = " + reference.getStamp());
System.out.println("-----------------------------");
// // 再次用str3替代str2,預期原始版本號爲3,新版號爲 原版本號+1 成功!
// boolean c = reference.weakCompareAndSet(str2, str3, 3, reference.getStamp() + 1);
// System.out.println("c = " + c);
// System.out.println("reference.getReference() = " + reference.getReference());
// System.out.println("reference.getStamp() = " + reference.getStamp());
// System.out.println("-----------------------------");
//
// // 再次用str1替代str2,預期原始版本號爲3,新版號爲 原版本號+1 失敗!
// boolean d = reference.weakCompareAndSet(str2, str1, 3, reference.getStamp() + 1);
// System.out.println("d = " + d);
// System.out.println("reference.getReference() = " + reference.getReference());
// System.out.println("reference.getStamp() = " + reference.getStamp());
// System.out.println("-----------------------------");
// 再次用str1替代str2,預期原始版本號爲4,新版號爲 原版本號+1 失敗!
boolean f = reference.weakCompareAndSet(str2, str1, 3, reference.getStamp() + 1);
System.out.println("f = " + f);
System.out.println("reference.getReference() = " + reference.getReference());
System.out.println("reference.getStamp() = " + reference.getStamp());
}
reference.getReference() = aaa
reference.getStamp() = 1
-----------------------------
flag: true
reference.getReference() = bbb
reference.getStamp() = 2
-----------------------------
b: true
reference.getReference() = bbb
reference.getStamp() = 3
-----------------------------
f = true
reference.getReference() = aaa
reference.getStamp() = 4
可以看到,雖然這個位置的值開始到最終的值都是“aaa”,但是版本號已經由1變成了4,這就是CAS中解決ABA問題的點。