簡單瞭解下Java中鎖的概念和原理

落叶微风發表於2024-06-29

你好,這裡是codetrend專欄“高併發程式設計基礎”。

Java提供了很多種鎖的介面和實現,透過對各種鎖的使用發現理解鎖的概念是很重要的。

Java的鎖透過java程式碼實現,go語言的鎖透過go實現,python語言的鎖透過python實現。它們都實現的什麼呢?這部分就是鎖的定義和設計模式、演算法、原理等一些理論上的東西。

下文基於此說明Java常見的鎖分類和原理。

樂觀鎖&悲觀鎖

樂觀鎖和悲觀鎖是在併發程式設計中保證資料一致性的兩種常見的鎖機制。

樂觀鎖:樂觀鎖假設在大多數情況下,不會出現併發衝突,因此在讀取資料時並不加鎖,只有在提交更新時才會檢查是否有其他併發操作修改了資料。如果檢測到了衝突,就放棄當前的操作並返回錯誤資訊。通常採用版本號或時間戳等機制來實現樂觀鎖。樂觀鎖機制適用於讀操作頻繁、寫操作較少的場景。

悲觀鎖:悲觀鎖則假設併發衝突隨時都可能發生,因此在讀取資料時就會加鎖,直到操作完成後才會釋放鎖。一般可以使用資料庫中的行級鎖、表級鎖或者使用 synchronized 等語言提供的鎖機制來實現悲觀鎖。悲觀鎖機制適用於寫操作頻繁、讀操作較少的場景。

選擇何種鎖機制應根據具體的應用場景進行選擇。在讀寫比例不明顯的情況下,可以考慮使用樂觀鎖機制,這樣可以減少鎖競爭帶來的效能損失。如果讀寫比例明顯,考慮使用悲觀鎖機制可以更好地確保資料的一致性。

需要注意的是,在實際應用中,樂觀鎖和悲觀鎖並不是嚴格的對立關係,而是可以結合使用的。例如,在高併發場景中,可以使用樂觀鎖機制來減少對資料庫的壓力,但在必要的時候也可以使用悲觀鎖機制來確保資料的一致性。

下面是使用 Java 實現一個簡單的樂觀鎖和悲觀鎖的示例:

樂觀鎖示例

import java.util.concurrent.atomic.AtomicInteger;

public class OptimisticLock {
    private AtomicInteger value = new AtomicInteger(0);

    public void increment() {
        int oldValue, newValue;
        while (!value.compareAndSet(oldValue, newValue)){
            // CAS操作,如果值沒有被修改,則更新為新值
            // 讀取當前值
            oldValue = value.get();
            // 計算新值
            newValue = oldValue + 1;
        } 
    }

    public int getValue() {
        return value.get();
    }
}

悲觀鎖示例

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

public class PessimisticLock {
    private int value = 0;
    private Lock lock = new ReentrantLock();

    public void increment() {
        lock.lock(); // 加鎖
        try {
            value++; // 更新資料
        } finally {
            lock.unlock(); // 解鎖
        }
    }

    public int getValue() {
        return value;
    }
}

公平鎖&非公平鎖

公平鎖:公平鎖保證執行緒獲取鎖的順序與其請求鎖的順序相同,即先到先得。當多個執行緒同時競爭同一個公平鎖時,這些執行緒會按照先後順序排隊等待獲取鎖。公平鎖可以避免飢餓現象,但是由於需要維護一個有序佇列,因此效能較低。

非公平鎖:非公平鎖則不保證執行緒獲取鎖的順序,即先到不一定先得。當一個執行緒釋放鎖時,如果有多個執行緒正在等待獲取鎖,那麼當前持有鎖的執行緒有可能會再次獲取到鎖,而不是讓等待時間最長的執行緒獲取鎖。非公平鎖具有更高的吞吐量和更低的競爭開銷,但是容易導致某些執行緒長時間等待,出現飢餓現象。

在 Java 中,可以使用 ReentrantLock 類來實現公平鎖和非公平鎖。預設情況下,ReentrantLock 類使用非公平鎖,可以透過建構函式傳入 true 來建立公平鎖,例如:

// 建立公平鎖
Lock fairLock = new ReentrantLock(true);

// 建立非公平鎖
Lock unfairLock = new ReentrantLock(false);

自旋鎖&適應性自旋鎖

自旋鎖和適應性自旋鎖都是基於忙等待的鎖機制,它們在獲取鎖時不會立即阻塞執行緒,而是反覆檢查鎖的狀態,直到獲取到鎖為止。下面對每種鎖做一些說明,並提供Java中的實現示例。

自旋鎖:自旋鎖適合用於鎖持有時間非常短暫的情況,可以避免執行緒切換帶來的開銷。自旋鎖的基本思想是,當執行緒發現共享資源已經被其他執行緒佔用時,就進行自旋等待,直到佔用共享資源的執行緒釋放鎖為止。Java中的ReentrantLock就支援自旋鎖,可以透過建構函式的引數來設定自旋次數,例如:

ReentrantLock lock = new ReentrantLock(true); // 使用公平鎖
lock.lock(); // 獲取鎖
// 共享資源的訪問操作
lock.unlock(); // 釋放鎖

適應性自旋鎖:適應性自旋鎖是一種最佳化過的自旋鎖,它會根據前一次在同一個鎖上的自旋時間和鎖的擁有者情況來確定自旋次數。如果在同一個鎖上,前一次自旋的時間較長,那麼下一次就會更傾向於阻塞執行緒而不是自旋等待。這樣可以避免長時間的自旋等待,減少資源的浪費。Java中的StampedLock就是支援適應性自旋鎖的一種鎖機制,例如:

StampedLock lock = new StampedLock();
long stamp = lock.readLock(); // 獲取悲觀讀鎖
// 共享資源的訪問操作
lock.unlockRead(stamp); // 釋放悲觀讀鎖

無鎖 & 偏向鎖 & 輕量級鎖 & 重量級鎖

無鎖、偏向鎖、輕量級鎖和重量級鎖都是Java中不同的鎖狀態,用於實現執行緒同步的機制。

無鎖:無鎖是指在多執行緒環境下,對共享資源的訪問沒有任何同步控制,所有執行緒可以同時訪問共享資源,不會發生爭用。無鎖適用於只讀操作或者執行緒衝突非常少的情況。例如:

int value = sharedValue; // 讀取共享資源

偏向鎖:偏向鎖是一種針對加鎖操作進行最佳化的機制,它適用於只有一個執行緒反覆獲取鎖的情況。當一個執行緒首次獲取鎖時,會將鎖的標記設定為該執行緒,下次該執行緒再次獲取鎖時無需競爭,直接進入臨界區。偏向鎖的目標是提高單執行緒下的效能。例如:

// 執行緒1首次獲取鎖
synchronized (lock) {
    // 臨界區程式碼
}

// 執行緒1再次獲取鎖
synchronized (lock) {
    // 臨界區程式碼
}

輕量級鎖:輕量級鎖是一種基於CAS(Compare and Swap)操作的鎖機制,它適用於多個執行緒交替執行同一段臨界區程式碼的情況。當一個執行緒獲取鎖時,會嘗試使用CAS操作將物件頭部的鎖記錄替換為指向自己的執行緒ID,如果成功,則表示獲取鎖成功;否則,表示有其他執行緒競爭鎖,可能發生鎖膨脹。例如:

Lock lock = new ReentrantLock();
lock.lock(); // 獲取鎖
try {
    // 臨界區程式碼
} finally {
    lock.unlock(); // 釋放鎖
}

重量級鎖:重量級鎖是一種基於作業系統互斥量(Mutex)的鎖機制,它適用於多個執行緒頻繁競爭同一把鎖的情況。當一個執行緒獲取鎖時,會進入阻塞狀態,直到鎖被釋放,然後喚醒其他執行緒進行競爭。重量級鎖適用於執行緒衝突比較頻繁的情況。例如:

synchronized (obj) {
    // 臨界區程式碼
}

可重入鎖 & 非可重入鎖

可重入鎖:可重入鎖是指允許同一執行緒多次獲取同一把鎖,而不會發生死鎖或者其他異常情況。當一個執行緒獲取鎖後,再次嘗試獲取鎖時會自動成功,並且需要釋放相同次數的鎖才能真正釋放鎖。Java中的ReentrantLock和synchronized都支援可重入鎖,例如:

// ReentrantLock示例
ReentrantLock lock = new ReentrantLock();
lock.lock(); // 獲取鎖
try {
    // 臨界區程式碼
    lock.lock(); // 再次獲取鎖
    try {
        // 巢狀臨界區程式碼
    } finally {
        lock.unlock(); // 釋放巢狀鎖
    }
} finally {
    lock.unlock(); // 釋放鎖
}

非可重入鎖:非可重入鎖是指同一執行緒不能多次獲取同一把鎖,否則會導致死鎖或者其他異常情況。Java中的普通物件鎖就是一種非可重入鎖,例如:

package engineer.concurrent.battle.glock;

public class LockEnterNoRepeat {
    private static Object lock = new Object();

    public static void main(String[] args) {
        synchronized (lock) {
            // 臨界區程式碼
            synchronized (lock) { // 再次獲取鎖會導致死鎖
                // 巢狀臨界區程式碼
                System.out.println("thread end");
            }
        }
    }
}

需要注意的是,可重入鎖雖然提供了更大的靈活性和便利性,但也要注意避免死鎖和其他問題的發生。在設計和使用多執行緒程式碼時,應該根據具體情況選擇合適的鎖機制。

獨享鎖 & 共享鎖

獨享鎖Exclusive Lock是指在某個時間點只允許一個執行緒持有鎖,其他執行緒不能同時持有該鎖。獨享鎖也被稱為排它鎖或寫鎖,用於保護臨界資源的獨佔訪問。

共享鎖Shared Lock是指在某個時間點允許多個執行緒同時持有鎖,這些執行緒可以同時訪問被保護的資源,但是不能進行寫操作。共享鎖也被稱為讀鎖,用於實現讀多寫少的併發模式。

在Java中,ReentrantReadWriteLock是一種同時支援獨享鎖和共享鎖的鎖機制。透過使用ReentrantReadWriteLock,可以在不同的執行緒之間實現對共享資源的讀寫操作控制。

下面是使用ReentrantReadWriteLock實現獨享鎖和共享鎖的示例程式碼:

import java.util.concurrent.locks.ReentrantReadWriteLock;

public class ReadWriteLockExample {
    private ReentrantReadWriteLock lock = new ReentrantReadWriteLock();
    private String data = "Hello, World!";

    public void readData() {
        lock.readLock().lock();
        try {
            System.out.println("Reading data: " + data);
        } finally {
            lock.readLock().unlock();
        }
    }

    public void writeData(String newData) {
        lock.writeLock().lock();
        try {
            System.out.println("Writing data: " + newData);
            data = newData;
        } finally {
            lock.writeLock().unlock();
        }
    }

    public static void main(String[] args) {
        ReadWriteLockExample example = new ReadWriteLockExample();

        // 建立多個讀執行緒
        for (int i = 0; i < 3; i++) {
            Thread readerThread = new Thread(() -> {
                example.readData();
            });
            readerThread.start();
        }

        // 建立一個寫執行緒
        Thread writerThread = new Thread(() -> {
            example.writeData("New data");
        });
        writerThread.start();
    }
}

執行該程式會輸出以下結果:

Reading data: Hello, World!
Reading data: Hello, World!
Reading data: Hello, World!
Writing data: New data

可以看到,有三個讀執行緒同時獲取了共享鎖,並讀取了資料。而在寫執行緒中,只有該執行緒獲取了獨享鎖,併成功修改了資料。

透過ReentrantReadWriteLock,我們可以實現對共享資源的讀操作併發執行,提高讀操作的效率;而寫操作會獨佔鎖,保證在寫操作時只有一個執行緒能夠訪問臨界資源,確保資料一致性。

關於作者

來自全棧程式設計師nine的探索與實踐,持續迭代中。

歡迎關注和點贊~

相關文章