多線程安全性和Java中的鎖

Java是天生的併發語言。多線程在帶來更高效率的同時,又帶來了數據安全性問題。通常咱們將多線程的數據安全性問題分爲三種:原子性、可見性和有序性。原子性是指咱們的一系列操做要麼所有都作,要麼所有不作。可見性是指當一個線程修改了一個共享變量後,這個修改可以及時地被另外一個線程看到。有序性是指在java爲了性能優化,會對指令進行重排序,在本線程中咱們的先後操做看起來是有序的,可是若是在另外一個線程中觀察,咱們的操做是無序的。 爲了解決多線程的數據安全性問題,java中引入了鎖,鎖是爲了防止在多線程同時讀寫一個共享內存時出現的併發數據安全性問題。Java中的鎖大致分爲兩類:"synchronized"關鍵字鎖和"JUC"(java.util.concurrent包)中的locks包和atomic中提供的鎖。java

原子性,可見性和有序性

原子性

原子性是指咱們的一系列操做是一個總體,要麼所有作,要麼所有不作,不能不分作,不然就會產生數據安全性問題。請看一個例子:安全

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循環中,通常咱們不這麼寫,應該將加鎖處理放到循環體外。這裏只是爲了說明原子性操做才這麼寫。bash

可見性

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鎖。jvm

synchronized鎖

synchronized加鎖方式

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

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虛擬機》

相關文章
相關標籤/搜索