Java多線程 - 各種線程鎖

多個線程同時對同一個對象進行讀寫操作,很容易會出現(xiàn)一些難以預料的問題。所以很多時候我們需要給代碼塊加鎖,同一時刻只允許一個線程對某個對象進行操作。多線程之所以會容易引發(fā)一些難以發(fā)現(xiàn)的bug,很多時候是寫代碼的程序員對線程鎖不熟悉或者干脆就沒有在必要的地方給線程加鎖導致的。這里我想總結一下java多線程中的各種鎖的作用和用法,還有容易踩的坑。

這篇文章里面有很多的文字和代碼都來自于《實戰(zhàn)Java高并發(fā)程序設計》。它真的是一本很不錯的書,建議大家有空可以去看一下。

synchronized關鍵字

synchronized的作用

關鍵字synchronized的作用是實現(xiàn)線程間的同步。它的工作是對同步的代碼加鎖,使得每一次,只能有一個線程進入同步塊,從而保證線程間的安全性。

關鍵字synchronized可以有多張用法,這里做一個簡單的整理:

指定加鎖對象:對給定對象加鎖,進入同步代碼前要獲取給定對象的鎖。
直接作用于實例方法:相當于給當前實例加鎖,進入同步代碼塊前要獲得當前實例的鎖。
直接作用于靜態(tài)方法:相當于對當前類加鎖,進入同步代碼前要獲取當前類的鎖。

下面來分別說一下上面的三點:

假設我們有下面這樣一個Runnable,在run方法里對靜態(tài)成員變量sCount自增10000次:

class Count implements Runnable {
    private static int sCount = 0;

    public static int getCount() {
        return sCount;
    }

    @Override
    public void run() {
        for (int i = 0; i < 10000; i++) {
            sCount++;
        }
    }
}

假設我們在兩個Thread里面同時跑這個Runnable:

Count count = new Count();
Thread t1 = new Thread(count);
Thread t2 = new Thread(count);
t1.start();
t2.start();
try {
    t1.join();
    t2.join();
} catch (InterruptedException e) {
    e.printStackTrace();
}
System.out.print(Count.getCount());

得到的結果并不是20000,而是一個比20000小的數(shù),如14233。

這是為什么呢?假設兩個線程分別讀取sCount為0,然后各自技術得到sCount為1,并先后寫入這個結果,因此,雖然sCount++執(zhí)行了2次,但是實際sCount的值只增加了1。

我們可以用指定加鎖對象的方法解決這個問題,這里因為兩個Thread跑的是同一個Count實例,所以可以直接給this加鎖:

class Count implements Runnable {
    private static int sCount = 0;

    public static int getCount() {
        return sCount;
    }

    @Override
    public void run() {
        for (int i = 0; i < 10000; i++) {
            synchronized (this) {
                sCount++;
            }
        }
    }
}

我們也可以給實例方法加鎖,這種方式和上面那一種的區(qū)別就是給this加鎖,鎖的區(qū)域比較小,兩個線程交替執(zhí)行sCount++操作,而給方法加鎖的話,先拿到鎖的線程會連續(xù)執(zhí)行1000次sCount自增,然后再釋放鎖給另一個線程。

class Count implements Runnable {
    private static int sCount = 0;

    public static int getCount() {
        return sCount;
    }

    @Override
    public synchronized void run() {
        for (int i = 0; i < 10000; i++) {
            sCount++;
        }
    }
}

synchronized直接作用于靜態(tài)方法的用法和上面的給實例方法加鎖類似,不過它是作用于靜態(tài)方法:

class Count implements Runnable {
    private static int sCount = 0;

    public static int getCount() {
        return sCount;
    }

    @Override
    public void run() {
        for (int i = 0; i < 10000; i++) {
            increase();
        }
    }

    private static synchronized void increase() {
        sCount++;
    }
}

等待(wait)和通知(notify)

Object有兩個很重要的接口:Object.wait()和Object.notify()

當在一個對象實例上調(diào)用了wait()方法后,當前線程就會在這個對象上等待。直到其他線程調(diào)用了這個對象的notify()方法或者notifyAll()方法。notifyAll()方法與notify()方法的區(qū)別是它會喚醒所有正在等待這個對象的線程,而notify()方法只會隨機喚醒一個等待該對象的線程。

wait()、notify()和notifyAll()都需要在synchronized語句中使用:

class MyThread extends Thread {
    private Object mLock;

    public MyThread(Object lock) {
        this.mLock = lock;
    }

    @Override
    public void run() {
        super.run();

        synchronized (mLock) {
            try {
                mLock.wait();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }

            System.out.println("in MyThread");
        }
    }
}
Object lock = new Object();
MyThread t = new MyThread(lock);
t.start();

System.out.println("before sleep");

try {
    Thread.sleep(2000);
} catch (InterruptedException e) {
    e.printStackTrace();
}

System.out.println("after sleep");

synchronized (lock) {
    lock.notify();
}

try {
    t.join();
} catch (InterruptedException e) {
    e.printStackTrace();
}

從上面的例子可以看出來,在調(diào)用wait()方法實際上已經(jīng)釋放了對象的鎖,所以在其他線程中才能獲取到這個對象的鎖,從而進行notify操作。而等待的線程被喚醒后又需要重新獲得對象的鎖。

synchronized容易犯的隱蔽錯誤

是否給同一個對象加鎖

在用synchronized給對象加鎖的時候需要注意加鎖是不是同一個,如將代碼改成這樣:

Thread t1 = new Thread(new Count());
Thread t2 = new Thread(new Count());
t1.start();
t2.start();
try {
    t1.join();
    t2.join();
} catch (InterruptedException e) {
    e.printStackTrace();
}
System.out.print(Count.getCount());

因為兩個線程跑的是不同的Count實例,所以用給指定對象加鎖和給實例方法加鎖的方法都不能避免兩個線程同時對靜態(tài)成員變量sCount進行自增操作。

但是如果用第三種作用于靜態(tài)方法的寫法,就能正確的加鎖。

是否給錯誤的對象加鎖

如我們將sCount的類型改成Integer,并且在sCount++的時候直接對sCount加鎖會發(fā)生什么事情呢(畢竟我們會很自然的給要操作的對象加鎖來實現(xiàn)線程同步)?

class Count implements Runnable {
    private static Integer sCount = 0;

    public static int getCount() {
        return sCount;
    }

    @Override
    public void run() {
        for (int i = 0; i < 10000; i++) {
            synchronized (sCount) {
                sCount++;
            }
        }
    }
}
Count count = new Count();
Thread t1 = new Thread(count);
Thread t2 = new Thread(count);
t1.start();
t2.start();
try {
    t1.join();
    t2.join();
} catch (InterruptedException e) {
    e.printStackTrace();
}
System.out.print(Count.getCount());

最后的得到的結果仍然是比20000小的值。

這是為什么呢?《實戰(zhàn)Java高并發(fā)程序設計》中給出的解釋是這樣的:

在Java中,Integer使用不變對象。也就是對象一旦被創(chuàng)建,就不可能被修改。也就是說,如果你有一個Integer代表1,那么它就永遠是1,你不可能改變Integer的值,使它位。那如果你需要2怎么辦呢?也很簡單,新建一個Integer,并讓它表示2即可。

也就是說sCount在真實執(zhí)行時變成了:

sCount = Integer.valueOf(sCount.intValue()+1);

進一步看Integer.valueOf(),我們可以看到:

public static Integer valueOf(int i) {
    assert IntegerCache.high >= 127;
    if (i >= IntegerCache.low && i <= IntegerCache.high)
        return IntegerCache.cache[i + (-IntegerCache.low)];
    return new Integer(i);
}

所以在多個線程中,由于sCount一直在變,并不是同一個對象,所以兩個線程的加鎖可能加在了不同的Integer對象上,并沒有真正的鎖住代碼塊。

我再舉一個例子:

public void increase(Integer integer){
    integer++;
}

在外面這樣調(diào)用它,并不會使得傳入的Integer增加:

Integer i = 0;
increase(i);

重入鎖

ReentrantLock的意思是Re-Entrant-Lock也就是重入鎖,它的特點就是在同一個線程中可以重復加鎖,只需要解鎖同樣的次數(shù)就能真正解鎖:

class MyThread extends Thread {
    private ReentrantLock mLock = new ReentrantLock();

    @Override
    public void run() {
        super.run();

        mLock.lock();
        System.out.println("outside");

        mLock.lock();
        System.out.println("inside");
        mLock.unlock();

        mLock.unlock();
    }
}

事實上synchronized也是可重入的,比如下面的代碼同樣是可以正常退出的:

class MyThread extends Thread {
    @Override
    public void run() {
        super.run();
        synchronized (this) {
            System.out.println("outside");
            synchronized (this) {
                System.out.println("inside");
            }
        }
    }
}

與synchronized相比,重入鎖需要程序員手動調(diào)用加鎖和解鎖,也因為如此,重入鎖對邏輯控制的靈活性要遠遠好于synchronized。

重入鎖可以完全替代synchronized關鍵字。在JDK 5.0的早起版本中,重入鎖的性能遠遠好于synchronized。但從JDK 6.0開始,JDK在synchronized做了大量優(yōu)化,使得兩者的性能差距并不大。

ReentrantLock是可中斷的

對于synchronized,如果它在等待鎖,那么它就只有兩個狀態(tài):獲得鎖繼續(xù)執(zhí)行或者保持等待。但是對于重入鎖,就有了另外一種可能,那就是重入鎖在等待的時候可以被中斷:

class MyThread extends Thread {
    private ReentrantLock mLock = new ReentrantLock();

    @Override
    public void run() {
        super.run();

        try {
            mLock.lockInterruptibly();
            try {
                Thread.sleep(5000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        } catch (InterruptedException e) {
            e.printStackTrace();
        }finally {
            if(mLock.isHeldByCurrentThread()){
                mLock.unlock();
            }
        }
    }
}

ReentrantLock可以設置等待限時

ReentrantLock.tryLock()方法可以給等待鎖設置最長等待時間,如果在設置的時間結束之前獲取到鎖就會返回true,否則返回false:

class MyThread extends Thread {
    private ReentrantLock mLock = new ReentrantLock();

    @Override
    public void run() {
        super.run();

        try {
            if (mLock.tryLock(2, TimeUnit.SECONDS)) {
                try {
                    Thread.sleep(5000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        } catch (InterruptedException e) {
            e.printStackTrace();
        } finally {
            if (mLock.isHeldByCurrentThread()) {
                mLock.unlock();
            }
        }
    }
}

ReentrantLock.tryLock()也可以不帶參數(shù)直接運行。在這種情況下,當前線程會嘗試獲得鎖,如果鎖并未被其他線程占用,則申請鎖會成功,并立即返回true。如果鎖被其他線程占用,則當前線程不會進行等待,而是立即返回false。

ReentrantLock可以設置公平鎖

大多數(shù)情況下,鎖的申請是非公平的。也就是說,線程1首先請求了鎖A,接著線程2也請求了鎖A。那么當鎖A可用時,是線程1可以獲得鎖還是線程2可以獲得鎖呢?這是不一定的。系統(tǒng)只是會從這個鎖的等待隊列里隨機挑選一個:

class MyThread extends Thread {
    private ReentrantLock mLock;

    public MyThread(String name, ReentrantLock lock) {
        super(name);
        this.mLock = lock;
    }

    @Override
    public void run() {
        super.run();

        while (true) {
            mLock.lock();
            System.out.println(Thread.currentThread().getName() + "獲得鎖");
            mLock.unlock();
        }
    }
}
ReentrantLock lock = new ReentrantLock();
MyThread t1 = new MyThread("t1", lock);
t1.start();

MyThread t2 = new MyThread("t2", lock);
t2.start();

try {
    t1.join();
    t2.join();
} catch (InterruptedException e) {
    e.printStackTrace();
}

打印如下:

t1獲得鎖
t2獲得鎖
t2獲得鎖
t2獲得鎖
t1獲得鎖
t2獲得鎖
t2獲得鎖
t1獲得鎖
t2獲得鎖
t1獲得鎖
t2獲得鎖
t2獲得鎖
t2獲得鎖
t1獲得鎖
t1獲得鎖

synchronized產(chǎn)生的鎖也是非公平的。但如果使用ReentrantLock(boolean fair)構造函數(shù)創(chuàng)建ReentrantLock,并且傳入true。則該重入鎖是公平的:

ReentrantLock lock = new ReentrantLock(true);
MyThread t1 = new MyThread("t1", lock);
t1.start();

MyThread t2 = new MyThread("t2", lock);
t2.start();

try {
    t1.join();
    t2.join();
} catch (InterruptedException e) {
    e.printStackTrace();
}

打印如下:

t1獲得鎖
t2獲得鎖
t1獲得鎖
t2獲得鎖
t1獲得鎖
t2獲得鎖
t1獲得鎖
t2獲得鎖
t1獲得鎖
t2獲得鎖
t1獲得鎖

需要注意的是實現(xiàn)公平鎖必然要求系統(tǒng)維護一個有序隊列,所以公平鎖的實現(xiàn)成本較高,性能也相對低下,因此,默認情況下,鎖是非公平的。

ReentrantLock可以與Condition配合使用

Condition和之前講過的Object.wait()還有Object.notify()的作用大致相同:

class MyThread extends Thread {
    private ReentrantLock mLock;
    private Condition mCondition;
    
    public MyThread(ReentrantLock lock, Condition condition) {
        this.mLock = lock;
        this.mCondition = condition;
    }
    
    @Override
    public void run() {
        super.run();
    
        mLock.lock();
        try {
            mCondition.await();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        mLock.unlock();
    
        System.out.println("in MyThread");
    }
}
ReentrantLock lock = new ReentrantLock();
Condition condition = lock.newCondition();
MyThread t = new MyThread(lock, condition);
t.start();

System.out.println("before sleep");
try {
    Thread.sleep(2000);
} catch (InterruptedException e) {
    e.printStackTrace();
}
System.out.println("after sleep");

lock.lock();
condition.signal();
lock.unlock();

try {
    t.join();
} catch (InterruptedException e) {
    e.printStackTrace();
}

Condition的操作需要在ReentrantLock.lock()和ReentrantLock.unlock()之間進行的。

ReentrantLock.newCondition()可以創(chuàng)建一個Condition。Condition.await()方法相當于Object.wait()方法,而Condition.signal()方法相當于Object.notify()方法。當然它也有對應的Condition.signalAll()方法。

同樣的在調(diào)用Condition.await()之后,線程占用的鎖會被釋放。這樣在Condition.signal()方法調(diào)用的時候才獲取到鎖。

需要注意的是Condition.signal()方法調(diào)用之后,被喚醒的線程因為需要重新獲取鎖。所以需要等到調(diào)用Condition.signal()的線程釋放了鎖(調(diào)用ReentrantLock.unlock())之后才能繼續(xù)執(zhí)行。

Condition接口的基本方法如下,它提供了限時等待、不可中斷的等待之類的操作:

void await() throws InterruptedException;
void awaitUninterruptibly();
long awaitNanos(long nanosTimeout) throws InterruptedException;
boolean await(long time, TimeUnit unit) throws InterruptedException;
boolean awaitUntil(Date deadline) throws InterruptedException;
void signal();
void signalAll();

信號量

信號量為多線程協(xié)作提供了更為強大的控制方法。廣義上說,信號量是對鎖的拓展。無論是synchronize還是重入鎖,一次都只運行一個線程訪問一個資源,而信號鎖則可以指定多個線程,同時訪問某一個資源。

像下面的代碼, MyRunnable被加鎖的代碼塊一次會被5個線程執(zhí)行:

public class MyRunnable implements Runnable {
    private Semaphore mSemaphore;

    public MyRunnable(Semaphore semaphore) {
        mSemaphore = semaphore;
    }

    @Override
    public void run() {
        try {
            mSemaphore.acquire();
            Thread.sleep(2000);
            System.out.println("thread " + Thread.currentThread().getId() + " working");
            mSemaphore.release();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}
 Semaphore semaphore = new Semaphore(5);
for (int i = 0; i < 19; i++) {
    new Thread(new MyRunnable(semaphore)).start();
}

Thread t = new Thread(new MyRunnable(semaphore));
t.start();
t.join();

Semaphore.acquire()方法嘗試獲得一個準入許可。如無法獲得,線程就會等待。而Semaphore.release()則在線程訪問資源結束后,釋放一個許可。

Semaphore有下面的一些常用方法:

public Semaphore(int permits)
public Semaphore(int permits, boolean fair)
public void acquire() 
public void acquireUninterruptibly()
public boolean tryAcquire()
public boolean tryAcquire(long timeout, TimeUnit unit)
public void release()

其他的一些鎖

讀寫鎖

讀寫鎖(ReadWriteLock)是JDK5中提供的分離鎖。讀寫分離鎖可以有效的減少鎖競爭。

讀寫鎖允許多個線程同時讀,但是寫寫操作和讀寫操作就需要相互等待了。讀寫鎖的訪問約束如下:

非阻塞 阻塞
阻塞 阻塞

讀寫操作在某些特定操作下可以提高程序的性能,如下面的代碼。如果使用重入鎖,需要十一秒左右才能運行完:

public class Data {
    private String mData = "data";
    private ReentrantLock mLock = new ReentrantLock();

    public String readData(){
        mLock.lock();
        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println("read data : " + mData);
        String data = mData;
        mLock.unlock();
        return data;
    }

    public void writeData(String data){
        mLock.lock();
        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        mData = data;
        System.out.println("write data : " + mData);
        mLock.unlock();
    }
}
final Data data = new Data();
for (int i = 0; i < 10; i++) {
    new Thread(new Runnable() {
        @Override
        public void run() {
            data.readData();
        }
    }).start();
}

Thread write = new Thread(new Runnable() {
    @Override
    public void run() {
       data.writeData("update data");
    }
});
write.start();

但是如果將重入鎖改成讀寫鎖的話只需要兩秒左右就能完成:

public class Data {
    private String mData = "data";
    private ReadWriteLock mLock = new ReentrantReadWriteLock();
    private Lock mReadLock = mLock.readLock();
    private Lock mWriteLock = mLock.writeLock();

    public String readData(){
        mReadLock.lock();
        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        String data = mData;
        System.out.println("read data : " + mData);
        mReadLock.unlock();
        return data;
    }

    public void writeData(String data){
        mWriteLock.lock();
        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println("write data : " + mData);
        mData = data;
        mWriteLock.unlock();
    }
}

倒計時器、循環(huán)柵欄

倒計時器(CountDownLatch)和循環(huán)柵欄(CyclicBarrier)因為比較不常用,所以這里就不講了,有興趣的同學可以自己去看一下《實戰(zhàn)Java高并發(fā)程序設計》這本書。

最后編輯于
?著作權歸作者所有,轉載或內(nèi)容合作請聯(lián)系作者
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內(nèi)容