多執行緒安全性和Java中的鎖

zhong0316發表於2019-02-24

Java是天生的併發語言。多執行緒在帶來更高效率的同時,又帶來了資料安全性問題。一般我們將多執行緒的資料安全性問題分為三種:原子性、可見性和有序性。原子性是指我們的一系列操作要麼全部都做,要麼全部不做。可見性是指當一個執行緒修改了一個共享變數後,這個修改能夠及時地被另一個執行緒看到。有序性是指在java為了效能優化,會對指令進行重排序,在本執行緒中我們的前後操作看起來是有序的,但是如果在另一個執行緒中觀察,我們的操作是無序的。 為了解決多執行緒的資料安全性問題,java中引入了鎖,鎖是為了防止在多執行緒同時讀寫一個共享記憶體時出現的併發資料安全性問題。Java中的鎖大體分為兩類:"synchronized"關鍵字鎖和"JUC"(java.util.concurrent包)中的locks包和atomic中提供的鎖。

原子性,可見性和有序性

原子性

原子性是指我們的一系列操作是一個整體,要麼全部做,要麼全部不做,不能不分做,否則就會產生資料安全性問題。請看一個例子:

import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class AtomicityViolation {

    static long counter = 0L;
    static ExecutorService executorService = Executors.newFixedThreadPool(10);

    public static void main(String[] args) {
        violateAtomicity();
    }

    static void violateAtomicity() {
        CountDownLatch latch = new CountDownLatch(10);
        for (int i = 0; i < 10; i++) {
            executorService.submit(new Runnable() {
                @Override
                public void run() {
                    for (int j = 0; j < 10000; j++) {
                        counter++;
                    }
                    latch.countDown();
                }
            });
        }
        try {
            latch.await();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println(counter);
        executorService.shutdown();
    }
}
複製程式碼

在上面的例子中,我們開啟10個執行緒,每個執行緒負責對一個counter計數器累計10000次,如果沒有安全性問題,我們期望得到的結果是100000,可是事實卻並不如此,並且每次執行的結果都不一樣,但是總是小於等於100000。為什麼會這樣呢?原因就在於counter++操作並不是一個原子操作。java記憶體模型規定了6種原子操作:read、load、assign、use、store和write。 如果我們要保證counter++是一個原子操作必須要對這個操作加鎖:

import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class SafeCounter {

    static long counter = 0L;
    static ExecutorService executorService = Executors.newFixedThreadPool(10);

    public static void main(String[] args) throws InterruptedException {
        safeCount();
    }

    static void safeCount() throws InterruptedException {
        CountDownLatch latch = new CountDownLatch(10);
        for (int i = 0; i < 10; i++) {
            executorService.submit(() -> {
                for (int j = 0; j < 10000; j++) {
                    synchronized (SafeCounter.class) {
                        counter++;
                    }
                }
                latch.countDown();
            });
        }
        latch.await();
        System.out.println(volatileCounter);
        executorService.shutdown();
    }
}
複製程式碼

經過加鎖處理後可以得到預期的結果。注意上面加鎖處理在for迴圈中,一般我們不這麼寫,應該將加鎖處理放到迴圈體外。這裡只是為了說明原子性操作才這麼寫。

可見性

java記憶體模型規定,每個java執行緒可以有自己的工作記憶體,工作記憶體是執行緒私有的,而共享記憶體(主存)是執行緒共享的。執行緒工作記憶體中會有共享變數的副本,當執行緒對一個共享變數進行寫入時,會先寫入執行緒私有的工作記憶體,然後再重新整理到主存中。 這樣就可能會產生一個問題:執行緒1改變了共享變數的值,在還未重新整理到主存時候,執行緒2去讀取這個變數,此時執行緒2將看不到執行緒1對這個變數所做的修改。這就是多執行緒併發帶來的資料可見性問題。

java記憶體模型

java中可以通過申明一個變數為volatile來解決可見性問題。執行緒讀取一個volatile變數時JMM會強制要求執行緒從主記憶體中讀取,寫一個volatile變數時JMM會要求立馬重新整理到主記憶體中。java中通過synchronized加鎖後的寫入也可以保證資料的可見性。 volatile能夠解決可見性和有序性但是不能保證原子性,如果需要保證原子性則需要加鎖。這裡有一點需要注意的是:volatile型別的long,double變數的讀取是原子讀取,而非volatile的long,double型別變數讀取是非原子讀取,所以也可以說volatile在一定程度上解決了原子性問題。

有序性

如果在本執行緒內觀察,所有操作都是有序的,但是如果在一個執行緒觀察另一個執行緒,所有的操作都是無序的。產生這種問題的根本原因在於"指令重排序"和"工作記憶體和主記憶體同步延遲"。java中volatile變數通過記憶體屏障來防止指令重排序從而保證有序。

java中的鎖

上面介紹了多執行緒併發中的資料安全性問題:原子性、可見性和有序性。java中的鎖就是用來保證這三條特性。java中的鎖可以分為兩大類:synchronized鎖和JUC包中的Lock鎖。

synchronized鎖

synchronized加鎖方式

synchronized是jvm中的一個關鍵字,它有兩種使用方式:加在方法上或者程式碼塊上。 加在方法上:

synchronized void foo() {
    //...
}
複製程式碼

如果加在方法上且當前方法是非"static"方法,則鎖住的是當前類的例項,如果該方法是"static"的,則鎖住的是當前類的class物件。 加在程式碼塊上:

void foo() {
    synchronized(lock) {
        //...
    }
}
複製程式碼

對於加在程式碼塊的鎖,鎖住的是'lock'代表的物件。

synchronized鎖特性

synchronized鎖是JVM提供的內建鎖。synchronized鎖是非公平的鎖,並且是阻塞的,不支援鎖請求中斷。synchronized鎖是可重入的,所謂可重入是指同一個執行緒獲取到某個物件的鎖之後在未釋放鎖之前還可以通過synchronized再次獲取鎖,而不會阻塞。一個物件在JVM中的記憶體佈局包括物件頭、例項資料和對齊填充,synchronized鎖就是通過物件頭來實現鎖的。synchronized還支援偏向鎖、輕量級鎖和重量級鎖。

偏向鎖

大多數情況下鎖不僅不存在多執行緒競爭,而且總是由同一執行緒多次獲得。偏向鎖的目的是在某個執行緒獲得鎖之後,消除這個執行緒鎖重入(CAS)的開銷,看起來讓這個執行緒得到了偏護。另外,JVM對那種會有多執行緒加鎖,但不存在鎖競爭的情況也做了優化,聽起來比較拗口,但在現實應用中確實是可能出現這種情況,因為執行緒之前除了互斥之外也可能發生同步關係,被同步的兩個執行緒(一前一後)對共享物件鎖的競爭很可能是沒有衝突的。對這種情況,JVM用一個epoch表示一個偏向鎖的時間戳(真實地生成一個時間戳代價還是蠻大的,因此這裡應當理解為一種類似時間戳的identifier)

  1. 偏向鎖的獲取:當一個執行緒訪問同步塊並獲取鎖時,會在物件頭和棧幀中的鎖記錄裡儲存鎖偏向的執行緒ID,以後該執行緒在進入和退出同步塊時不需要花費CAS操作來加鎖和解鎖,而只需簡單的測試一下物件頭的Mark Word裡是否儲存著指向當前執行緒的偏向鎖,如果測試成功,表示執行緒已經獲得了鎖,如果測試失敗,則需要再測試下Mark Word中偏向鎖的標識是否設定成1(表示當前是偏向鎖),如果沒有設定,則使用CAS競爭鎖,如果設定了,則嘗試使用CAS將物件頭的偏向鎖指向當前執行緒。

  2. 偏向鎖的撤銷:偏向鎖使用了一種等到競爭出現才釋放鎖的機制,所以當其他執行緒嘗試競爭偏向鎖時,持有偏向鎖的執行緒才會釋放鎖。偏向鎖的撤銷,需要等待全域性安全點(在這個時間點上沒有位元組碼正在執行),它會首先暫停擁有偏向鎖的執行緒,然後檢查持有偏向鎖的執行緒是否活著,如果執行緒不處於活動狀態,則將物件頭設定成無鎖狀態,如果執行緒仍然活著,擁有偏向鎖的棧會被執行,遍歷偏向物件的鎖記錄,棧中的鎖記錄和物件頭的Mark Word,要麼重新偏向於其他執行緒,要麼恢復到無鎖或者標記物件不適合作為偏向鎖,最後喚醒暫停的執行緒。

  3. 偏向鎖的設定:關閉偏向鎖:偏向鎖在Java 6和Java 7裡是預設啟用的,但是它在應用程式啟動幾秒鐘之後才啟用,如有必要可以使用JVM引數來關閉延遲-XX:BiasedLockingStartupDelay = 0。如果你確定自己應用程式裡所有的鎖通常情況下處於競爭狀態,可以通過JVM引數關閉偏向鎖-XX:-UseBiasedLocking=false,那麼預設會進入輕量級鎖狀態。

輕量級鎖和重量級鎖

  1. 輕量級鎖,加鎖:執行緒在執行同步塊之前,JVM會先在當前執行緒的棧楨中建立用於儲存鎖記錄的空間,並將物件頭中的Mark Word複製到鎖記錄中,官方稱為Displaced Mark Word。然後執行緒嘗試使用CAS將物件頭中的Mark Word替換為指向鎖記錄的指標。如果成功,當前執行緒獲得鎖,如果失敗,則自旋獲取鎖,當自旋獲取鎖仍然失敗時,表示存在其他執行緒競爭鎖(兩條或兩條以上的執行緒競爭同一個鎖),則輕量級鎖會膨脹成重量級鎖。解鎖:輕量級解鎖時,會使用原子的CAS操作來將Displaced Mark Word替換回到物件頭,如果成功,則表示同步過程已完成。如果失敗,表示有其他執行緒嘗試過獲取該鎖,則要在釋放鎖的同時喚醒被掛起的執行緒。

  2. 重量級鎖:重量鎖在JVM中又叫物件監視器(Monitor),它很像C中的Mutex,除了具備Mutex(0|1)互斥的功能,它還負責實現了Semaphore(訊號量)的功能,也就是說它至少包含一個競爭鎖的佇列,和一個訊號阻塞佇列(wait佇列),前者負責做互斥,後一個用於做執行緒同步。

鎖膨脹

"JUC"框架提供的鎖

java.util.concurrent(JUC)包中主要有locks包和atomic包,locks包中提供了Lock鎖,包括可重入鎖(ReentrantLock),可重入讀寫鎖(ReentrantReadWriteLock),和StampedLock。atomic包中提供了基於"CAS"(Compare And Set)的樂觀鎖的一些類。

ReentrantLock

ReentrantLock顧名思義,它是一種可重入鎖,其相對synchronized鎖而言支援鎖中斷,公平鎖等特性。ReentrantLock原始碼中涉及加鎖主要的方法有:

public ReentrantLock(boolean fair) { // 支援公平鎖
    sync = fair ? new FairSync() : new NonfairSync();
}
public void lock() {
    sync.lock();
}

public void lockInterruptibly() throws InterruptedException {
    sync.acquireInterruptibly(1);
}

public boolean tryLock() {
    return sync.nonfairTryAcquire(1);
}

public boolean tryLock(long timeout, TimeUnit unit) throws InterruptedException {
    return sync.tryAcquireNanos(1, unit.toNanos(timeout));
}
    
複製程式碼

lock()方法用於同步獲取鎖,如果獲取不到鎖,執行緒將一直阻塞到可以獲取鎖為止。lockInterruptibly()方法用於同步獲取鎖,但是這個請求是可以中斷的。tryLock()方法不會阻塞等待,如果當前鎖沒有被其他執行緒獲取,當前執行緒加鎖後返回true,如果當前鎖已經被其他執行緒獲取了,則該方法立馬返回false,不會阻塞等待。tryLock(long timeout, TimeUnit unit)相對於tryLock()方法多了一個超時機制,如果在指定超時時間之內還沒有獲取到鎖則返回false,不會立馬返回false,在超時之前該請求也可以被中斷。注意JUC中的Lock需要我們手動釋放鎖,如果獲取鎖後方法異常也請記得釋放鎖(在finally中釋放鎖),否則其他執行緒就無法獲取鎖了。synchronized鎖在方法異常時JVM會自動為我們釋放鎖。這也是兩者的不同之處。

ReentrantReadWriteLock

ReentrantLock獲取的是排它鎖,而ReentrantReadWriteLock是一種讀寫鎖分離的鎖。在寫鎖沒有被獲取的情況下,多執行緒併發獲取寫鎖不會出現阻塞,在讀多寫少的情況下較ReentrantLock有明顯的優勢。 假設執行緒1首先獲取讀鎖或者寫鎖,此時執行緒2再來請求獲取讀鎖或者寫鎖的情況如下圖:

執行緒1\執行緒2
×
× ×

StampedLock

首先StampedLock鎖是不可重入的。StampedLock的思想是:讀請求不僅不應該阻塞讀請求(對應於ReentrantReadWriteLock),也不應該阻塞寫請求。 StampedLock控制鎖有三種模式(寫,讀,樂觀讀),一個StampedLock狀態是由版本和模式兩個部分組成,鎖獲取方法返回一個數字作為票據stamp,它用相應的鎖狀態表示並控制訪問,數字0表示沒有寫鎖被授權訪問。在讀鎖上分為悲觀鎖和樂觀鎖。

所謂的樂觀讀模式,也就是若讀的操作很多,寫的操作很少的情況下,你可以樂觀地認為,寫入與讀取同時發生機率很少,因此不悲觀地使用完全的讀取鎖定,程式可以檢視讀取資料之後,是否遭到寫入執行的變更,再採取後續的措施(重新讀取變更資訊,或者丟擲異常) ,這一個小小改進,可大幅度提高程式的吞吐量!! 下面是java doc提供的StampedLock一個例子:

class Point {

    private final StampedLock sl = new StampedLock();

    private double x, y;
    
    void move(double deltaX, double deltaY) { // an exclusively locked method
        long stamp = sl.writeLock();
        try {
            x += deltaX;
            y += deltaY;
        } finally {
            sl.unlockWrite(stamp);
        }
    }
    
    //下面看看樂觀讀鎖案例
    double distanceFromOrigin() { // A read-only method
        long stamp = sl.tryOptimisticRead(); //獲得一個樂觀讀鎖
        double currentX = x, currentY = y; //將兩個欄位讀入本地區域性變數
        if (!sl.validate(stamp)) { //檢查發出樂觀讀鎖後同時是否有其他寫鎖發生?
            stamp = sl.readLock(); //如果沒有,我們再次獲得一個讀悲觀鎖
            try {
                currentX = x; // 將兩個欄位讀入本地區域性變數
                currentY = y; // 將兩個欄位讀入本地區域性變數
            } finally {
                sl.unlockRead(stamp);
            }
        }
        return Math.sqrt(currentX * currentX + currentY * currentY);
    }
    
    //下面是悲觀讀鎖案例
    void moveIfAtOrigin(double newX, double newY) { // upgrade
        // Could instead start with optimistic, not read mode
        long stamp = sl.readLock();
        try {
            while (x == 0.0 && y == 0.0) { //迴圈,檢查當前狀態是否符合
                long ws = sl.tryConvertToWriteLock(stamp); //將讀鎖轉為寫鎖
                if (ws != 0L) { //這是確認轉為寫鎖是否成功
                    stamp = ws; //如果成功 替換票據
                    x = newX; //進行狀態改變
                    y = newY; //進行狀態改變
                    break;
                } else { //如果不能成功轉換為寫鎖
                    sl.unlockRead(stamp); //我們顯式釋放讀鎖
                    stamp = sl.writeLock(); //顯式直接進行寫鎖 然後再通過迴圈再試
                }
            }
        } finally {
            sl.unlock(stamp); //釋放讀鎖或寫鎖
        }
    }
}
複製程式碼

基於"CAS"樂觀鎖的atomic類

java.util.concurrent.atomic包下提供了一些AtomicXXX類,例如:AtomicInteger,AtomicLong,AtomicBoolean等類。這些類通過"CAS"自旋鎖來保證執行緒安全性。相對於JUC locks包中的鎖,它不需要掛起和喚醒執行緒,通過執行緒"忙自旋"避免系統呼叫。他的優點是沒有系統呼叫不需要掛起和喚醒執行緒,他的缺點是會過度佔用CPU,無法解決"ABA"問題(ABA問題可以通過AtomicStampedReference類來解決)。

參考資料

《深入理解Java虛擬機器》

相關文章