Java那麼多鎖,能鎖住滅霸嗎?

Java中的鎖有不少種,常常會聽到「鎖」這個詞。面試

猶如天天出門時,🔑就是一種「鎖」,拿不到🔑,就進不去了。算法

Java那麼多種類的鎖,都是按不一樣標準來分類的。就像商店裏的各類商品,能夠按式樣,也能夠按顏色或者尺寸。編程

其實它們都是一種思想。安全

爲何會有鎖?

一個進程能夠包含多個線程,那麼多個線程就會有競爭資源的問題出現,爲了互相不打架,就有了鎖的概念了。 一個線程也能夠本身完成任務,但就像一個小組能夠互相配合、共同完成任務,比一我的要快不少是否是?bash

分類

整理個大圖~多線程

其實這麼多分類只是從特性、表現、實現方式等不一樣的側重點來講的,不是絕對的分類,例如,不可重入鎖和自旋鎖,實際上是同一種鎖。併發

要繞暈了是否是?下面就分別來講說。函數

1. 悲觀 Vs 樂觀

林妹妹比較悲觀,寶玉比較樂觀~高併發

1.1 悲觀鎖

看名字便知,它是悲觀的,老是想到最壞的狀況。 鎖也會悲觀,它並非難過,它只是很謹慎,怕作錯。性能

每次要讀data的時候,老是以爲其餘人會修改數據,因此先加個🔐,讓其餘人不能改數據,再慢慢讀~

要是你在寫一篇日記,怕別人會偷看了,就加了個打開密碼,別人必須拿到密碼才能打開這篇文章。這就是悲觀鎖了。

應用: synchronized關鍵字和Lock的實現類都是悲觀鎖。

1.2 樂觀鎖

它很樂觀,老是想着最好的狀況。 它比較大條,不會太擔憂。若是要發生,總會發生,若是不會發生,那就不會。爲何要擔憂那麼多?

每次讀data時,老是樂觀地想沒有其餘人會同時修改數據,不用加鎖,放心地讀data。 但在更新的時候會判斷一下在此期間別人有沒有去更新這個數據。

就像和別人共同編輯一篇文章,你在編輯的時候別人也能夠編輯,並且你以爲別人不會改動到你寫的部分,那就是樂觀鎖了。

事事無絕對,悲觀也好樂觀也好,沒有絕對的悲觀,也沒有絕對的樂觀。只是在這個當時,相信,仍是不相信。

1.3 悲觀 Vs 樂觀

類型 實現 使用場景 缺點
悲觀鎖 synchronized關鍵字和Lock的實現類 適合寫操做多的場景,能夠保證寫操做時數據正確 若是該事務執行時間很長,影響系統的吞吐量
樂觀鎖 無鎖編程,CAS算法 適合讀操做多的場景,可以大幅提高其讀操做的性能 若是有外來事務插入,那麼就可能發生錯誤

1.4 應用

樂觀鎖 —— CAS(Compare and Swap 比較並交換)

是樂觀鎖的一種實現方式。

簡單來講,有3個三個操做數:

  • 須要讀寫的內存值 V。
  • 進行比較的值 A。
  • 要寫入的新值 B。

2 公平 Vs 非公平

沒有絕對的公平,也沒有絕對的不公平。

公平,就是按順序排隊嘛。 公平鎖維護了一個隊列。要獲取鎖的線程來了都排隊。

waitQueue.png

非公平,上來就想搶到鎖,好像一個不講道理的,搶不到的話,只好再去乖乖排隊了。 非公平鎖沒有維護隊列的開銷,沒有上下文切換的開銷,可能致使不公平,可是性能比fair的好不少。看這個性能是對誰有利了。

舉個栗子

3 可重入鎖 Vs 不可重入鎖

3.1 可重入鎖

廣義上的可重入鎖,而不是單指JAVA下的ReentrantLock。

可重入鎖,也叫作遞歸鎖,指的是同一線程外層函數得到鎖以後,內層遞歸函數仍然有獲取該鎖的代碼,但不受影響。

這句話神馬意思?

這種鎖是能夠反覆進入的。

當一個線程執行到某個synchronized方法時,好比說method1,而在method1中會調用另一個synchronized方法method2,此時線程沒必要從新去申請鎖,而是能夠直接執行方法method2。

class MyClass {
    public synchronized void method1() {
        enterNextRoom();
    }

    public synchronized void method2() {
        // todo
    }
}
複製代碼

兩個方法method1和method2都用synchronized修飾了。

假設某一時刻,線程A執行到了method1,此時線程A獲取了這個對象的鎖,而因爲method2也是synchronized方法,假如synchronized不具有可重入性,此時線程A須要從新申請鎖。可是這就會形成一個問題,由於線程A已經持有了該對象的鎖,而又在申請獲取該對象的鎖,這樣就會線程A一直等待永遠不會獲取到的鎖。

若是不是可重入鎖的話,method2可能不會被當前線程執行,可能形成死鎖。

可重入鎖最大的做用是避免死鎖。

實現類:

  • synchronized
  • ReentrantLock

3.2 不可重入鎖

按上面的例子,線程A從method1執行到method2的時候,不能直接獲取到鎖,要執行下去,必須先解鎖。

實現不可重入鎖有什麼方式呢?那就是自旋~

(什麼是自旋鎖?等下詳細說,先有個概念,就是當一個線程在獲取鎖的時候,若是鎖已經被其它線程獲取,那麼該線程將循環等待)

  • 代碼以下:
public class UnreentrantLock {

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

public void lock() {
    Thread current = Thread.currentThread();
    //這句是很經典的「自旋」語法,AtomicInteger中也有
    for (;;) {
        if (!owner.compareAndSet(null, current)) {
            return;
        }
    }
}

    public void unlock() {
        Thread current = Thread.currentThread();
        owner.compareAndSet(current, null);
    }
}
複製代碼

同一線程兩次調用lock()方法,若是不執行unlock()釋放鎖的話,第二次調用自旋的時候就會產生死鎖。

4 自旋鎖 & 自適應旋轉鎖

4.1 自旋鎖

自旋鎖就是,若是此時拿不到鎖,它不立刻進入阻塞狀態,而願意等待一段時間。

相似於線程在那裏作空循環,若是循環必定的次數還拿不到鎖,那麼它纔會進入阻塞的狀態,這個循環次數是能夠人爲指定的。

  • 栗子時間

有一天去全家買咖啡,服務員說真不巧,前面咖啡機壞了,如今正在修,要等10分鐘喔,剛好沒什麼急事,那就等吧,坐到一邊休息區等10分鐘(其它什麼事都沒作)。介就是自旋鎖~

那是否是有點浪費?若是你等了15分鐘,還沒修好,那你可能不肯意繼續等下去了(15分鐘就是設定的自旋等待的最大時間)

4.2 自適應旋轉鎖

上面說自旋鎖循環的次數是人爲指定的,而自適應旋轉鎖,厲害了,它不須要人爲指定循環次數,它本身自己會判斷要循環幾回,並且每一個線程可能循環的次數也是不同的。

若是這個線程以前拿到過鎖,或者常常拿到一個鎖,那它本身判斷下來再次拿到這個鎖的機率很大,循環次數就大一些;若是這個線程以前沒拿到過這個鎖,那它就沒把握了,怕消耗CPU,循環次數就小一點。

它解決的是「鎖競爭時間不肯定」的問題,但也不必定它本身設定的必定合適。

  • 栗子時間

仍是前面去全家等咖啡的栗子吧~ 要是等到5分鐘,還沒修好,你目測10分鐘裏也修很差,就再也不等下去了(循環次數小);要是等了10分鐘了,服務員說很是抱歉,快了快了,再1分鐘就能夠用了,你也還不急,都已經等了10分鐘了,就多等一下子嘛(循環次數大)

4.3 怎麼實現自旋鎖?

簡單實現以下:

public class SpinLock {
    private AtomicReference<Thread> cas = new AtomicReference<Thread>();
    public void lock() {
        Thread current = Thread.currentThread();
        // 利用CAS
        while (!cas.compareAndSet(null, current)) {
            // DO nothing
        }
    }
    public void unlock() {
        Thread current = Thread.currentThread();
        cas.compareAndSet(current, null);
    }
}
複製代碼

lock()方法利用CAS,當第一個線程A獲取鎖的時候,可以成功獲取到,不會進入while循環;

若是此時線程A沒有釋放鎖,另外一個線程B又來獲取鎖,此時因爲不知足CAS,因此就會進入while循環,不斷判斷是否知足CAS,直到A線程調用unlock方法釋放了該鎖,線程B才能獲取鎖。

  • 存在的問題
  1. 若是某個線程持有鎖的時間過長,就會致使其它等待獲取鎖的線程進入循環等待,消耗CPU。使用不當會形成CPU使用率極高。
  2. 自己沒法保證公平性,即沒法知足等待時間最長的線程優先獲取鎖。不公平的鎖就會存在「線程飢餓」問題。
  3. 也沒法保證可重入性。基於自旋鎖,能夠實現具有公平性和可重入性質的鎖。

4.4 自旋鎖 Vs 阻塞鎖

自旋鎖是不阻塞鎖,可是它也會等一段時間,那和阻塞鎖有什麼區別?

  • 舉栗子~

去一個熱門飯店吃飯,到了門口一看,門口的座位坐滿了人……這咋整……服務員說,您能夠先拿個號~小票上掃個二維碼,關注我們,輪到您了,服務號裏就會有提示噠~(很熟悉是否是?) 而後你就先取了號去逛逛周圍小店去了,等輪到你了,手機裏收到一條服務提醒消息,到你啦~這時你再去,就能夠進店了。

這就是阻塞的過程~

若是是自旋鎖呢? 就是你本身其它事情都不作,等在那裏,就像去超市排隊結帳同樣,你走開的話是沒有人會通知你的,只能從新排隊,須要本身時刻檢查有沒有排到(能不能訪問到共享資源)。

插播一下:

阻塞或喚醒一個Java線程須要操做系統切換CPU狀態來完成,這種狀態轉換須要耗費處理器時間。
複製代碼
自旋鎖 阻塞鎖
改變線程狀態? 不改變線程運行狀態,一直處於用戶態,即線程一直都是active的 改變線程運行狀態,讓線程進入阻塞狀態進行等待
佔用CPU? 佔用CPU時間 不會佔用CPU時間,不會致使 CPU佔用率太高,但進入時間以及恢復時間都要比自旋鎖略慢
適用場景 線程競爭不激烈,而且保持鎖的時間段 競爭激烈的狀況下 阻塞鎖的性能要明顯高於自旋鎖

5 無鎖 -> 偏向鎖 -> 輕量級鎖 -> 重量級鎖

5.1 鎖的狀態

  • 無鎖狀態
  • 偏向鎖狀態
  • 輕量級鎖狀態
  • 重量級鎖狀態

這四種狀態都不是Java語言中的鎖,而是Jvm爲了提升鎖的獲取與釋放效率而作的優化(使用synchronized時),它們會隨着競爭的激烈而逐漸升級,而且是不可逆的升級。

  • 偏向鎖 -> 輕量級鎖 -> 重量級鎖

P.S., 無鎖,即沒有鎖~

若是一個方法原本就不涉及共享數據,那它天然就無須任何同步措施去保證正確性,所以會有一些代碼天生就是線程安全的。

CAS算法 即compare and swap(比較與交換),就是有名的無鎖算法。

5.2 各狀態比較

狀態 描述 優勢 缺點 應用場景 適用場景
偏向鎖 無實際競爭,讓一個線程一直持有鎖,在其餘線程須要競爭鎖的時候,再釋放鎖 加鎖解鎖不須要額外消耗 若是線程間存在競爭,會有撤銷鎖的消耗 只有一個線程進入臨界區 適用於只有一個線程訪問同步塊場景。
輕量級 無實際競爭,多個線程交替使用鎖;容許短期的鎖競爭 競爭的線程不會阻塞 若是線程一直得不到鎖,會一直自旋,消耗CPU 多個線程交替進入臨界區 追求響應時間。同步塊執行速度很是快。
重量級 有實際競爭,且鎖競爭時間長 線程競爭不使用自旋,不消耗CPU 線程阻塞,響應時間長 多個線程同時進入臨界區 追求吞吐量。同步塊執行速度較長。
  • 來看栗子~

你常常去一家店坐在同一個位置吃飯,老闆已經記住你啦,每次你去的時候,只要店裏客人很少,老闆都會給你留着那個座位,這個座位就是你的「偏向鎖」,每次只有你這一個線程用。

有一天你去的時候,店裏已經坐滿了,你的位置也被別人坐了,你只能等着(進入競爭狀態),這時那個座位就升級到「輕量級」了。

要是那個座位特別好(臨窗風景最佳,能隔江賞月~)每次你到的時候,都有其餘好幾我的也要去搶那個位置,沒坐到那個位置就不吃飯了>_< 那時那個座位就升級到「重量級」了。

5.3 主要區別

輕量級鎖和重量級鎖的重要區別是: 拿不到「鎖」時,是否有線程調度和上下文切換的開銷。

簡單來講:若是發現同步週期內都是不存在競爭,JVM會使用CAS操做來替代操做系統互斥量。這個優化就被叫作輕量級鎖。

相比重量級鎖,其加鎖和解鎖的開銷會小不少。重量級鎖的「重」,關鍵在於線程上下文切換的開銷大。

6 獨享 Vs 共享

共享 Vs 獨享 圖~ 是否是很形象? 😄

類型 描述 實現類
共享(讀鎖) 可被多個線程所持有,其餘用戶能夠併發讀取數據。若是事務T對數據A加上共享鎖後,則其餘事務只能對A再加共享鎖,不能加排他鎖。獲准共享鎖的事務只能讀數據,不能修改數據。 ReentrantReadWriteLock裏的讀鎖
獨享(排他鎖,寫鎖) 一次只能被一個線程所持有。若是事務T對數據A加上排它鎖後,則其餘事務不能再對A加任何類型的鎖。得到排它鎖的事務即能讀數據又能修改數據。 synchronized、ReentrantLock、ReentrantReadWriteLock裏的寫鎖

排它鎖是悲觀鎖的一種實現。

問題來了:共享鎖爲何要加個「讀鎖」?

防止數據在被讀取的時候被別的線程加上寫鎖。
複製代碼

而獨佔鎖的原理是:

若是有線程獲取到鎖,那麼其它線程只能是獲取鎖失敗,而後進入等待隊列中等待被喚醒。
複製代碼
  • 栗子來了

小組每一個禮拜都要各個成員共同填一份週報表格,要是每一個人打開的時候,能夠加一個寫鎖,即你在寫的時候,別人不能修改,這就是獨享鎖(寫鎖); 可是這份表格你們能夠同時打開,看到表格內容(讀取數據),正在改數據的人能夠對這份表格加上共享鎖,那這個鎖就是共享鎖。

6.1 共享鎖的代碼實現

共享鎖的獲取方法爲acquireShared,源碼爲:

public final void acquireShared(int arg) {
    if (tryAcquireShared(arg) < 0)
        doAcquireShared(arg);
}
複製代碼

當返回值爲大於等於0的時候方法結束說明得到成功獲取鎖,不然,代表獲取同步狀態失敗即所引用的線程獲取鎖失敗,會執行doAcquireShared方法.

6.2 獲取獨佔鎖方法

public final void acquire(int arg) {
  if (!tryAcquire(arg) && 
      acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
    selfInterrupt();
}
複製代碼
  • 解讀:
  1. 嘗試獲取鎖,這個方法須要實現類本身實現獲取鎖的邏輯,獲取鎖成功後則不執行後面加入等待隊列的邏輯了;
  2. 若是嘗試獲取鎖失敗後,則執行 addWaiter(Node.EXCLUSIVE) 方法將當前線程封裝成一個 Node 節點對象,並加入隊列尾部;
  3. 把當前線程執行封裝成 Node 節點後,繼續執行 acquireQueued 的邏輯,該邏輯主要是判斷當前節點的前置節點是不是頭節點,來嘗試獲取鎖,若是獲取鎖成功,則當前節點就會成爲新的頭節點,這也是獲取鎖的核心邏輯。

簡單來講 addWaiter(Node mode) 方法作了如下事情:

建立基於當前線程的獨佔式類型的節點; 利用 CAS 原子操做,將節點加入隊尾。

鎖的實現類

ReentrantLock

ReentrantLock 是一個獨佔/排他鎖。

特性

  • 公平性:支持公平鎖和非公平鎖。默認使用了非公平鎖。
  • 可重入
  • 可中斷:相對於 synchronized,它是可中斷的鎖,可以對中斷做出響應。
  • 超時機制:超時後不能得到鎖,所以不會形成死鎖。

ReentrantLock構造函數

提供了是否公平鎖的初始化:

/**
     * Creates an instance of {@code ReentrantLock}.
     * This is equivalent to using {@code ReentrantLock(false)}.
     */
    public ReentrantLock() {
        sync = new NonfairSync();
    }

    /**
     * Creates an instance of {@code ReentrantLock} with the
     * given fairness policy.
     *
     * @param fair {@code true} if this lock should use a fair ordering policy
     */
    public ReentrantLock(boolean fair) {
        sync = fair ? new FairSync() : new NonfairSync();
    }
複製代碼

使用ReentrantLock必須在finally控制塊中進行解鎖操做。

在資源競爭不激烈的情形下,性能稍微比synchronized差點點。可是當同步很是激烈的時候,synchronized的性能一會兒能降低好幾十倍,而ReentrantLock確還能維持常態。

高併發量狀況下使用ReentrantLock。

優勢: 可必定程度避免死鎖。

  • Semaphore
  • AtomicInteger、AtomicLong等

小總結

對Java的各類鎖概念作了下整理,寫了些本身的理解, 還有不少基礎方面,好比Java的對象頭、對象模型(都比較基礎)、鎖的優化、各種鎖代碼實現等,後續再補充下。 有不少公號有不少高水平的文章,須要理解和練習的有太多。

參考

  1. 關於Java鎖機制面試官會怎麼問,深入易懂
  2. 不可不說的Java「鎖」事
  3. 深刻理解多線程
相關文章
相關標籤/搜索