悲觀鎖 和 樂觀鎖 是啥?

一、悲觀鎖

悲觀鎖:總是假設最壞的情況,每次去拿數據的時候都認爲別人會修改,所以每次在拿數據的時候都會上鎖,這樣別人想拿這個數據就會阻塞,直到它拿到鎖。

悲觀鎖是一種利用數據庫內部機制提供的鎖的方法,也就是對更新的數據加鎖,這樣在併發期間一旦一個事務持有了數據庫記錄的鎖,其他的線程將不能再對數據進行更新了,這就是悲觀鎖的實現邏輯。

對於悲觀鎖來說,只能有一個事務佔據資源,其他事務被掛起等待持有資源的事務提交併釋放資源。CPU就會將這些得不到資源的線程掛起,掛起的線程也會消耗CPU的資源,尤其是在高井發的請求中。

一旦該線程提交了事務,那麼鎖就會被釋放,這個時候被掛起的線程就會開始競爭資源,那麼競爭到的線程就會被 CPU 恢復到運行狀態,繼續運行。

在高併發的過程中,使用悲觀鎖就會造成大量的線程被掛起和恢復,這將十分消耗資源,這就是爲什麼使用悲觀鎖性能不佳的原因。

有些時候,我們也會把悲觀鎖稱爲獨佔鎖,畢竟只有一個線程可以獨佔這個資源,或者稱爲阻塞鎖,因爲它會造成其他線程的阻塞。無論如何它都會造成併發能力的下降,從而導致 CPU 頻繁切換線程上下文,造成性能低下。爲了克服這個問題,提高併發的能力,避免大量線程因爲阻塞導致 CPU 進行大量的上下文切換,程序設計大師們提出了樂觀鎖機制,樂觀鎖已經在企業中被大量應用了。

 

二、樂觀鎖

樂觀鎖是一種思想,總是假設最好的情況,每次去拿數據的時候都認爲別人不會修改,所以不會上鎖,只在更新的時候會判斷一下在此期間別人有沒有去更新這個數據。

樂觀鎖是一種不會阻塞其他線程併發的機制,它不會使用數據庫的鎖進行實現,它的設計裏面由於不阻塞其他線程,所以並不會引發線程頻繁掛起和恢復,這樣便能夠提高併發能力,所以也有人把它稱爲非阻塞鎖。

樂觀鎖使用的是 CAS 原理,所以我們先來討論 CAS 原理的內容。

2.1 CAS 原理概述

CAS (Compare and Swap) 即比較並替換,實現併發算法時常用到的一種技術。CAS 原理並不排斥併發,也不獨佔資源,只是在線程開始階段就讀入線程共享數據,保存爲舊值。當處理完邏輯,需要更新數據的時候,會進行一次比較,即比較各個線程當前共享的數據是否和舊值保持一致。如果一致,就開始更新數據;如果不一致,則認爲該數據已經被其它線程修改了,那麼就不再更新數據,可以考慮重試或者放棄。有時候可重試,這樣就是一個可重入鎖。
 
CAS操作包含三個操作數——內存位置、原值及新值。執行CAS操作的時候,將內存位置的值與預期原值比較,如果相匹配,那麼處理器會自動將該位置值更新爲新值,否則,處理器不做任何操作。
 

2.2 ABA 問題解決

但是 CAS 原理會有 一個問題,那就 ABA 問題,下面先來討論 ABA 問題。舉個例子,你看到桌子上有100塊錢,然後你去幹其他事了,回來之後看到桌子上依然是100塊錢,你可能會認爲這100塊沒人動過,其實在你走的那段時間,別人已經拿走了100塊,後來又還回來了。你以爲錢沒被動過,但已經被動過了,這就是ABA問題。
 
 
ABA 問題的發生是因爲業務邏輯存在回退的可能性, 如果加入一個非業務邏輯的屬性,比如在一個數據中加入版本號( version ),對於版本號有一個約定,就是隻要修改變量的數據,強制版本號(version )只能遞增,而不會回退,即使是其他業務數據回退,它也會遞增,那麼 ABA 問題就解決了。
例如:我們對數據加一個版本控制字段,只要有人動過這個數據,就把版本進行增加,我們看到桌子上有100塊錢版本是1,回來後發現桌子上100沒變,但是版本卻是2,就立馬明白100塊有人動過。
 

2.3 樂觀鎖思想

可以說樂觀鎖是由CAS機制+版本機制來實現的。

樂觀鎖假設認爲數據一般情況下不會產生併發衝突,所以在數據進行提交更新的時候,纔會正式對數據是否產生併發衝突進行檢測,如果發現併發衝突了,則讓返回用戶錯誤的信息,讓用戶決定如何去做。

(1)CAS機制:當多個線程嘗試使用CAS同時更新同一個變量時,只有其中一個線程能更新變量的值,而其它線程都失敗。CAS 有效地說明了“ 我認爲位置 V 應該包含值 A;如果包含該值,則將 B 放到這個位置;否則,不要更改該位置,只告訴我這個位置現在的值即可“。

(2)版本機制:CAS機制保證了在更新數據的時候沒有被修改爲其他數據的同步機制,版本機制就保證了沒有被修改過的同步機制,解決了ABA問題。

通過 CA 原理和 ABA 題的討論,我們更加明確了樂觀鎖的原理,使用樂觀鎖有助於提高併發性能,但是由於版本號衝突,樂觀鎖導致多次請求服務失敗的概率大大提高,而我們通過重入(按時間戳或者按次數限定)來提高成功的概率,這樣對於樂觀鎖而現的方式就相對複雜了,其性能也會隨着版本號衝突的概率提升而提升,並不穩定。使用樂觀鎖的弊端在於導致大量的 SQL 被執行,對於數據庫的性能要求較高,容易引起數據庫性能的瓶頸,而且對於開發還要考慮重入機制,從而導致開發難度加大。

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問題的點。

 

 

 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章