關於執行緒安全的例子,簡而言之就是多個執行緒在同時訪問或修改公共資源的時候,由於不同執行緒搶佔公共資源而導致的結果不確定性,就是在併發程式設計中經常要考慮的執行緒安全問題。現在嘗試來用Lock顯式加鎖來解決執行緒安全的問題,先來看一下Lock介面的定義:

public interface Lock

Lock介面有幾個重要的方法:

//獲取鎖,如果鎖不可用,出於執行緒排程目的,將禁用當前執行緒,並且在獲得鎖之前,該執行緒將一直處於休眠狀態。 
void lock()
//釋放鎖,
void unlock()

lock()和unlock()是Lock介面的兩個重要方法,下面的案例將會使用到它倆。Lock是一個介面,實現它的子類包括:可重入鎖:ReentrantLock, 讀寫鎖中的只讀鎖:ReentrantReadWriteLock.ReadLock和讀寫鎖中的只寫鎖:ReentrantReadWriteLock.WriteLock 。我們先來用一用ReentrantLock可重入鎖來解決執行緒安全問題,如何還不明白什麼是執行緒安全的同學可以回頭看我文章開頭給的連結文章。

import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

public class MyThread implements Runnable {
    private int number = 5; //公共變數,5個執行緒都會訪問和修改該變數

    private Lock lock = new ReentrantLock(); //可重入鎖

    @Override
    public void run() {
        lock.lock(); //進方法的第一件事就是鎖住該方法,不能讓其他執行緒進來
        try {
            number--;
            System.out.println("執行緒 : " + Thread.currentThread().getName() + "獲取到了公共資源,number = " + number);
            Thread.sleep((long)(Math.random()*1000));
        } catch (InterruptedException e) {
            e.printStackTrace();
        } finally {
            lock.unlock(); //釋放鎖
        }
    }

    public static void main(String[] args) {
        //起5個執行緒
        MyThread mt = new MyThread();
        Thread t1 = new Thread(mt, "t1");
        Thread t2 = new Thread(mt, "t2");
        Thread t3 = new Thread(mt, "t3");
        Thread t4 = new Thread(mt, "t4");
        Thread t5 = new Thread(mt, "t5");
        t1.start();
        t2.start();
        t3.start();
        t4.start();
        t5.start();
    }
}

控制檯輸出:

執行緒 : t1獲取到了公共資源,number = 4
執行緒 : t2獲取到了公共資源,number = 3
執行緒 : t3獲取到了公共資源,number = 2
執行緒 : t4獲取到了公共資源,number = 1
執行緒 : t5獲取到了公共資源,number = 0

程式中建立了一把鎖,一個公共變數的資源,和5個執行緒,每起一個執行緒就會對公共資源number做自減操作,從上面的輸出可以看到程式中的5個執行緒對number的操作得到正確的結果。需要注意的是,在你加鎖的程式碼塊的finaly語句一定要釋放鎖,就是呼叫一下lock的unlock()方法。

現在來看一下什麼是可重入鎖 ,可重入鎖就是同一個執行緒多次嘗試進入同步程式碼塊的時候,能夠順利的進去並執行。例項程式碼如下:

import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

public class MyThread implements Runnable {
    private int number = 5; //公共變數,5個執行緒都會訪問和修改該變數

    private Lock lock = new ReentrantLock(); //可重入鎖

    public void sayHello(String threadName) {
        lock.lock();
        System.out.println("Hello!執行緒: " + threadName);
        lock.unlock();
    }

    @Override
    public void run() {
        lock.lock(); //進方法的第一件事就是鎖住該方法,不能讓其他執行緒進來
        try {
            number--;
            System.out.println("執行緒 : " + Thread.currentThread().getName() + "獲取到了公共資源,number = " + number);
            Thread.sleep((long)(Math.random()*1000));
            sayHello(Thread.currentThread().getName());
        } catch (InterruptedException e) {
            e.printStackTrace();
        } finally {
            lock.unlock(); //釋放鎖
        }
    }

    public static void main(String[] args) {
        //起5個執行緒
        MyThread mt = new MyThread();
        Thread t1 = new Thread(mt, "t1");
        Thread t2 = new Thread(mt, "t2");
        Thread t3 = new Thread(mt, "t3");
        Thread t4 = new Thread(mt, "t4");
        Thread t5 = new Thread(mt, "t5");
        t1.start();
        t2.start();
        t3.start();
        t4.start();
        t5.start();
    }
}

上述程式碼什麼意思呢?意思是每起一個執行緒的時候,執行緒執行run方法的時候,需要去呼叫sayHello()方法,那個sayHello()也是一個需要同步的和保證安全的方法,方法的第一行程式碼一來就給方法上鎖,然後做完自己的工作之後再釋放鎖,工作期間,禁止其他執行緒進來,除了本執行緒除外。上面程式碼輸出:

執行緒 : t1獲取到了公共資源,number = 4
Hello!執行緒: t1
執行緒 : t2獲取到了公共資源,number = 3
Hello!執行緒: t2
執行緒 : t3獲取到了公共資源,number = 2
Hello!執行緒: t3
執行緒 : t4獲取到了公共資源,number = 1
Hello!執行緒: t4
執行緒 : t5獲取到了公共資源,number = 0
Hello!執行緒: t5

實現一把簡單的鎖
如果你明白了上面幾個例子是用來幹嘛的,好,我們可以繼續進行下去了,我們來實現一把最簡單的鎖。先不考慮這把鎖的公平性和可重入性,只要求達到當使用這把鎖的時候我們的程式碼快安全即可。

我們先來定義自己的一把鎖MyLock。

public class MyLock implements Lock {
    
    @Override
    public void lock() {

    }

    @Override
    public void lockInterruptibly() throws InterruptedException {

    }

    @Override
    public boolean tryLock() {
        return false;
    }

    @Override
    public boolean tryLock(long time, TimeUnit unit) throws InterruptedException {
        return false;
    }

    @Override
    public void unlock() {

    }

    @Override
    public Condition newCondition() {
        return null;
    }
}

定義自己的鎖需要實現Lock介面,而上面是Lock介面需要實現的方法,我們拋開其他因素,只看lock()和unlock()方法。

public class MyLock implements Lock {

    private boolean isLocked = false; //定義一個變數,標記鎖是否被使用

    @Override
    public synchronized void lock() {
        while(isLocked) { //不斷的重複判斷,isLocked是否被使用,如果已經被佔用,則讓新進來想嘗試獲取鎖的執行緒等待,直到被正在執行的執行緒喚醒
            try {
                wait();
            }catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
        //進入該程式碼塊有兩種情況:
        // 1.第一個執行緒進來,此時isLocked變數的值為false,執行緒沒有進入while迴圈體裡面
        // 2.執行緒進入那個迴圈體裡面,呼叫了wait()方法並經歷了等待階段,現在已經被另一個執行緒喚醒,
        // 喚醒它的執行緒將那個變數isLocked設定為true,該執行緒才跳出了while迴圈體

        //跳出while迴圈體,本執行緒做的第一件事就是趕緊佔用執行緒,並告訴其他執行緒說:嘿,哥們,我佔用了,你必須等待
        isLocked = true; //將isLocked變數設定為true,表示本執行緒已經佔用
    }

    @Override
    public void lockInterruptibly() throws InterruptedException {

    }

    @Override
    public boolean tryLock() {
        return false;
    }

    @Override
    public boolean tryLock(long time, TimeUnit unit) throws InterruptedException {
        return false;
    }

    @Override
    public synchronized void unlock() {
        //執行緒釋放鎖,釋放鎖的過程分為兩步
        //1. 將標誌變數設定為true,告訴其他執行緒,你可以佔用了,不必死迴圈了
        //2. 喚醒正在等待中的執行緒,讓他們去強制資源
        isLocked = false;
        notifyAll(); //通知所有等待的執行緒,誰搶到我不管
    }

    @Override
    public Condition newCondition() {
        return null;
    }
}

從上面程式碼可以看到,這把鎖還是照樣用到了同步語句synchronized,只是同步的過程我們自己來實現,使用者只需要呼叫我們的鎖上鎖和釋放鎖就行了。其核心思想是用一個公共變數isLocked來標誌當前鎖是否被佔用,如果被佔用則當前執行緒等待,然後每被喚醒一次就嘗試去搶那把鎖一次(處於等待狀態的執行緒不止當前執行緒一個),這是lock方法裡面使用那個while迴圈的原因。當執行緒釋放鎖時,首先將isLocked變數置為false,表示鎖沒有被佔用,其實執行緒可以使用了,並呼叫notifyAll()方法喚醒正在等待的執行緒,至於誰搶到我不管,不是本寶寶份內的事。

那麼上面我們實現的鎖是不是一把可重入的鎖呢?我們來呼叫sayHello()方法看看:

import java.util.concurrent.locks.Lock;

public class MyThread implements Runnable {
    private int number = 5; //公共變數,5個執行緒都會訪問和修改該變數

    private Lock lock = new MyLock(); //建立一把自己的鎖

    public void sayHello(String threadName) {
        System.out.println(Thread.currentThread().getName() + "執行緒進來,需要佔用鎖");
        lock.lock();
        System.out.println("Hello!執行緒: " + threadName);
        lock.unlock();
    }

    @Override
    public void run() {
        lock.lock(); //進方法的第一件事就是鎖住該方法,不能讓其他執行緒進來
        try {
            number--;
            System.out.println("執行緒 : " + Thread.currentThread().getName() + "獲取到了公共資源,number = " + number);
            Thread.sleep((long)(Math.random()*1000));
            sayHello(Thread.currentThread().getName());
        } catch (InterruptedException e) {
            e.printStackTrace();
        } finally {
            lock.unlock(); //釋放鎖
        }
    }

    public static void main(String[] args) {
        //起5個執行緒
        MyThread mt = new MyThread();
        Thread t1 = new Thread(mt, "t1");
        Thread t2 = new Thread(mt, "t2");
        Thread t3 = new Thread(mt, "t3");
        Thread t4 = new Thread(mt, "t4");
        Thread t5 = new Thread(mt, "t5");
        t1.start();
        t2.start();
        t3.start();
        t4.start();
        t5.start();
    }
}

為了特意演示效果,我在sayHello方法加鎖之前列印一下當前執行緒的名稱,現在控制檯輸出如下:

執行緒 : t1獲取到了公共資源,number = 4
t1執行緒進來,需要佔用鎖

如上所述,t1執行緒啟動並對公共變數做自減的時候,呼叫了sayHello方法。同一個執行緒t1,線上程啟動的時候獲得過一次鎖,再在呼叫sayHello也想要獲取這把鎖,這樣的需求我們是可以理解的,畢竟sayHello方法有時候也需要達到執行緒安全效果嘛。可問題是同一個執行緒嘗試獲取鎖兩次,程式就被卡住了,t1在run方法的時候獲得過鎖,在sayHello方法想再次獲得鎖的時候被告訴說:唉,哥們,該鎖被使用了,至於誰在使用我不管(雖然正在使用該鎖執行緒就是我自己),你還是等等吧!所以導致結果就是sayHello處於等待狀態,而run方法則等待sayHello執行完。控制檯則一直處於執行狀態。

如果你不理解什麼是可重入鎖和不可重入鎖,對比一下上面使用MyLock的例子和使用J.U.C.包下的ReentrantLock倆例子的區別,ReentrantLock是可重入的,而MyLock是不可重入的。

實現一把可重入鎖
現在我們來改裝一下這把鎖,讓他變成可重入鎖,也就是說:如果我已經獲得了該鎖並且還沒釋放,我想再進來幾次都行。核心思路是:用一個執行緒標記變數記錄當前正在執行的執行緒,如果當前想嘗試獲得鎖的執行緒等於正在執行的執行緒,則獲取鎖成功。此外還需要用一個計數器來記錄一下本執行緒進來過多少次,因為如果同步方法呼叫unlock()時,我不一定就要釋放鎖,只有本執行緒的所有加鎖方法都釋放鎖的時候我才真正的釋放鎖,計數器就起到這個功能。

改裝過後的程式碼如下:

public class MyLock implements Lock {

    private boolean isLocked = false; //定義一個變數,標記鎖是否被使用

    private Thread runningThread = null; //第一次執行緒進來的時候,正在執行的執行緒為null

    private int count = 0;  //計數器

    @Override
    public synchronized void lock() {
        Thread currentThread = Thread.currentThread();
        //不斷的重複判斷,isLocked是否被使用,如果已經被佔用,則讓新進來想嘗試獲取鎖的執行緒等待,直到被正在執行的執行緒喚醒
        //除了判斷當前鎖是否被佔用之外,還要判斷正在佔用該鎖的是不是本執行緒自己
        while(isLocked && currentThread != runningThread) { //如果鎖已經被佔用,而佔用者又是自己,則不進入while迴圈
            try {
                wait();
            }catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
        //進入該程式碼塊有三種情況:
        // 1.第一個執行緒進來,此時isLocked變數的值為false,執行緒沒有進入while迴圈體裡面
        // 2.執行緒進入那個迴圈體裡面,呼叫了wait()方法並經歷了等待階段,現在已經被另一個執行緒喚醒,
        // 3.執行緒不是第一次進來,但是新進來的執行緒就是正在執行的執行緒,則直接來到這個程式碼塊
        // 喚醒它的執行緒將那個變數isLocked設定為true,該執行緒才跳出了while迴圈體

        //跳出while迴圈體,本執行緒做的第一件事就是趕緊佔用執行緒,並告訴其他執行緒說:嘿,哥們,我佔用了,你必須等待,計數器+1,並設定runningThread的值
        isLocked = true; //將isLocked變數設定為true,表示本執行緒已經佔用
        runningThread = currentThread; //給正在執行的執行緒變數賦值
        count++; //計數器自增
    }

    @Override
    public void lockInterruptibly() throws InterruptedException {

    }

    @Override
    public boolean tryLock() {
        return false;
    }

    @Override
    public boolean tryLock(long time, TimeUnit unit) throws InterruptedException {
        return false;
    }

    @Override
    public synchronized void unlock() {
        //執行緒釋放鎖,釋放鎖的過程分為三步
        //1. 判斷髮出釋放鎖的請求是否是當前執行緒
        //2. 判斷計數器是否歸零,也就是說,判斷本執行緒自己進來了多少次,是不是全釋放鎖了
        //3. 還原標誌變數
        if(runningThread == Thread.currentThread()) {
            count--;//計數器自減
            if(count == 0) { //判斷是否歸零
                isLocked = false; //將鎖的狀態標誌為未佔用
                runningThread = null;  //既然已經真正釋放了鎖,正在執行的執行緒則為null
                notifyAll(); //通知所有等待的執行緒,誰搶到我不管
            }
        }
    }

    @Override
    public Condition newCondition() {
        return null;
    }
}

如程式碼註釋所述,這裡新增了兩個變數runningThread和count,用於記錄當前正在執行的執行緒和當前執行緒獲得鎖的次數。程式碼的關鍵點在於while迴圈判斷測試獲得鎖的執行緒的條件,之前是隻要鎖被佔用就讓進來的執行緒等待,現在的做法是,如果鎖已經被佔用,則判斷一下正在佔用這把鎖的就是我自己,如果是,則獲得鎖,計數器+1;如果不是,則新進來的執行緒進入等待。相應的,當執行緒呼叫unlock()釋放鎖的時候,並不是立馬就釋放該鎖,而是判斷當前執行緒還有沒有其他方法還在佔用鎖,如果有,除了讓計數器減1之外什麼事都別幹,讓最後一個釋放鎖的方法來做最後的清除工作,當計數器歸零時,才表示真正的釋放鎖。

我知道你在懷疑這把被改造過後的鎖是不是能滿足我們的需求,現在就讓我們來執行一下程式,控制檯輸出如下:

執行緒 : t1獲取到了公共資源,number = 4
t1執行緒進來,需要佔用鎖
Hello!執行緒: t1
執行緒 : t5獲取到了公共資源,number = 3
t5執行緒進來,需要佔用鎖
Hello!執行緒: t5
執行緒 : t2獲取到了公共資源,number = 2
t2執行緒進來,需要佔用鎖
Hello!執行緒: t2
執行緒 : t4獲取到了公共資源,number = 1
t4執行緒進來,需要佔用鎖
Hello!執行緒: t4
執行緒 : t3獲取到了公共資源,number = 0
t3執行緒進來,需要佔用鎖
Hello!執行緒: t3

嗯,沒錯,這就是我們想要的結果。