淺談Java鎖機制

1、悲觀鎖和樂觀鎖

我們可以將鎖大體分為兩類:

  • 悲觀鎖
  • 樂觀鎖

顧名思義,悲觀鎖總是假設最壞的情況,每次獲取數據的時候都認為別的線程會修改,所以每次在拿數據的時候都會上鎖,這樣其它線程想要修改這個數據的時候都會被阻塞直到獲取鎖。比如MySQL數據庫中的表鎖、行鎖、讀鎖、寫鎖等,Java中的synchronizedReentrantLock等。

而樂觀鎖總是假設最好的情況,每次獲取數據的時候都認為別的線程不會修改,所以並不會上鎖,但是在修改數據的時候需要判斷一下在此期間有沒有別的線程修改過數據,如果沒有修改過則正常修改,如果修改過則這次修改就是失敗的。常見的樂觀鎖有版本號控制、CAS算法等。

2、悲觀鎖應用

案例如下:

public class LockDemo {

    static int count = 0;

    public static void main(String[] args) throws InterruptedException {
        List<Thread> threadList = new ArrayList<>();
        for (int i = 0; i < 50; i++) {
            Thread thread = new Thread(() -> {
                for (int j = 0; j < 1000; ++j) {
                    count++;
                }
            });
            thread.start();
            threadList.add(thread);
        }
        // 等待所有線程執行完畢
        for (Thread thread : threadList) {
            thread.join();
        }
        System.out.println(count);
    }
}

在該程序中一共開啟瞭50個線程,並在線程中對共享變量count進行++操作,所以如果不發生線程安全問題,最終的結果應該是50000,但該程序中一定存在線程安全問題,運行結果為:

48634

若想解決線程安全問題,可以使用synchronized關鍵字:

public class LockDemo {

    static int count = 0;

    public static void main(String[] args) throws InterruptedException {
        List<Thread> threadList = new ArrayList<>();
        for (int i = 0; i < 50; i++) {
            Thread thread = new Thread(() -> {
                // 使用synchronized關鍵字解決線程安全問題
                synchronized (LockDemo.class) {
                    for (int j = 0; j < 1000; ++j) {
                        count++;
                    }
                }
            });
            thread.start();
            threadList.add(thread);
        }
        for (Thread thread : threadList) {
            thread.join();
        }
        System.out.println(count);
    }
}

將修改count變量的操作使用synchronized關鍵字包裹起來,這樣當某個線程在進行++操作時,別的線程是無法同時進行++的,隻能等待前一個線程執行完1000次後才能繼續執行,這樣便能保證最終的結果為50000。

使用ReentrantLock也能夠解決線程安全問題:

public class LockDemo {

    static int count = 0;

    public static void main(String[] args) throws InterruptedException {
        List<Thread> threadList = new ArrayList<>();
        Lock lock = new ReentrantLock();
        for (int i = 0; i < 50; i++) {
            Thread thread = new Thread(() -> {
                // 使用ReentrantLock關鍵字解決線程安全問題
                lock.lock();
                try {
                    for (int j = 0; j < 1000; ++j) {
                        count++;
                    }
                } finally {
                    lock.unlock();
                }
            });
            thread.start();
            threadList.add(thread);
        }
        for (Thread thread : threadList) {
            thread.join();
        }
        System.out.println(count);
    }
}

這兩種鎖機制都是悲觀鎖的具體實現,不管其它線程是否會同時修改,它都直接上鎖,保證瞭原子操作。

3、樂觀鎖應用

由於線程的調度是極其耗費操作系統資源的,所以,我們應該盡量避免線程在不斷阻塞和喚醒中切換,由此產生瞭樂觀鎖。

在數據庫表中,我們往往會設置一個version字段,這就是樂觀鎖的體現,假設某個數據表的數據內容如下:

+----+------+----------+ ------- +
| id | name | password | version |
+----+------+----------+ ------- +
|  1 | zs   | 123456   |    1    |
+----+------+----------+ ------- +

它是如何避免線程安全問題的呢?

假設此時有兩個線程A、B想要修改這條數據,它們會執行如下的sql語句:

select version from e_user where name = 'zs';

update e_user set password = 'admin',version = version + 1 where name = 'zs' and version = 1;

首先兩個線程均查詢出zs用戶的版本號為1,然後線程A先執行瞭更新操作,此時將用戶的密碼修改為瞭admin,並將版本號加1,接著線程B執行更新操作,此時版本號已經為2瞭,所以更新肯定是失敗的,由此,線程B就失敗瞭,它隻能重新去獲取版本號再進行更新,這就是樂觀鎖,我們並沒有對程序和數據庫進行任何的加鎖操作,但它仍然能夠保證線程安全。

4、CAS

仍然以最開始做加法的程序為例,在Java中,我們還可以采用一種特殊的方式來實現它:

public class LockDemo {

    static AtomicInteger count = new AtomicInteger(0);

    public static void main(String[] args) throws InterruptedException {
        List<Thread> threadList = new ArrayList<>();
        for (int i = 0; i < 50; i++) {
            Thread thread = new Thread(() -> {
                for (int j = 0; j < 1000; ++j) {
                    // 使用AtomicInteger解決線程安全問題
                    count.incrementAndGet();
                }
            });
            thread.start();
            threadList.add(thread);
        }
        for (Thread thread : threadList) {
            thread.join();
        }
        System.out.println(count);
    }
}

為何使用AtomicInteger類就能夠解決線程安全問題呢?

我們來查看一下源碼:

public final int incrementAndGet() {
    return unsafe.getAndAddInt(this, valueOffset, 1) + 1;
}

count調用incrementAndGet()方法時,實際上調用的是UnSafe類的getAndAddInt()方法:

public final int getAndAddInt(Object var1, long var2, int var4) {
    int var5;
    do {
        var5 = this.getIntVolatile(var1, var2);
    } while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4));

    return var5;
}

getAndAddInt()方法中有一個循環,關鍵的代碼就在這裡,我們假設線程A此時進入瞭該方法,此時var1即為AtomicInteger對象(初始值為0),var2的值為12(這是一個內存偏移量,我們可以不用關心),var4的值為1(準備對count進行加1操作)。

首先通過AtomicInteger對象和內存偏移量即可得到主存中的數據值:

var5 = this.getIntVolatile(var1, var2);

獲取到var5的值為0,然後程序會進行判斷:

!this.compareAndSwapInt(var1, var2, var5, var5 + var4)

compareAndSwapInt()是一個本地方法,它的作用是比較並交換,即:判斷var1的值與主存中取出的var5的值是否相同,此時肯定是相同的,所以會將var5+var4的值賦值給var1,並返回true,對true取反為false,所以循環就結束瞭,最終方法返回1。

這是一切正常的運行流程,然而當發生並發時,處理情況就不太一樣瞭,假設此時線程A執行到瞭getAndAddInt()方法:

public final int getAndAddInt(Object var1, long var2, int var4) {
    int var5;
    do {
        var5 = this.getIntVolatile(var1, var2);
    } while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4));

    return var5;
}

線程A此時獲取到var1的值為0(var1即為共享變量AtomicInteger),當線程A正準備執行下去時,線程B搶先執行瞭,線程B此時獲取到var1的值為0,var5的值為0,比較成功,此時var1的值就變為1;這時候輪到線程A執行瞭,它獲取var5的值為1,此時var1的值不等於var5的值,此次加1操作就會失敗,並重新進入循環,此時var1的值已經發生瞭變化,此時重新獲取var5的值也為1,比較成功,所以將var1的值加1變為2,若是在獲取var5之前別的線程又修改瞭主存中var1的值,則本次操作又會失敗,程序重新進入循環。

這就是利用自旋的方式來實現一個樂觀鎖,因為它沒有加鎖,所以省下瞭線程調度的資源,但也要避免程序一直自旋的情況發生。

5、手寫一個自旋鎖

public class LockDemo {

    private AtomicReference<Thread> atomicReference = new AtomicReference<>();

    public void lock() {
        // 獲取當前線程對象
        Thread thread = Thread.currentThread();
        // 自旋等待
        while (!atomicReference.compareAndSet(null, thread)) {
        }
    }

    public void unlock() {
        // 獲取當前線程對象
        Thread thread = Thread.currentThread();
        atomicReference.compareAndSet(thread, null);
    }

    static int count = 0;

    public static void main(String[] args) throws InterruptedException {
        LockDemo lockDemo = new LockDemo();
        List<Thread> threadList = new ArrayList<>();
        for (int i = 0; i < 50; i++) {
            Thread thread = new Thread(() -> {
                lockDemo.lock();
                for (int j = 0; j < 1000; j++) {
                    count++;
                }
                lockDemo.unlock();
            });
            thread.start();
            threadList.add(thread);
        }
        // 等待線程執行完畢
        for (Thread thread : threadList) {
            thread.join();
        }
        System.out.println(count);
    }
}

使用CAS的原理可以輕松地實現一個自旋鎖,首先,AtomicReference中的初始值一定為null,所以第一個線程在調用lock()方法後會成功將當前線程的對象放入AtomicReference,此時若是別的線程調用lock()方法,會因為該線程對象與AtomicReference中的對象不同而陷入循環的等待中,直到第一個線程執行完++操作,調用瞭unlock()方法,該線程才會將AtomicReference值置為null,此時別的線程就可以跳出循環瞭。

通過CAS機制,我們能夠在不添加鎖的情況下模擬出加鎖的效果,但它的缺點也是顯而易見的:

  • 循環等待占用CPU資源
  • 隻能保證一個變量的原子操作
  • 會產生ABA問題

到此這篇關於淺談Java鎖機制的文章就介紹到這瞭,更多相關Java鎖機制內容請搜索WalkonNet以前的文章或繼續瀏覽下面的相關文章希望大傢以後多多支持WalkonNet!

推薦閱讀: