Java多線程專題-線程鎖深度解析

 

我們在前面章節也提到過多線程的鎖機制,但沒有深入的去研究鎖的種類以及其用法。在這裏做一個深度說明。多線程鎖是爲了解決有可能產生得線程安全問題,從而保證多線程程序的健壯性和可靠性。本節我們將討論Java多線程中的各種鎖以及其用法。

悲觀鎖和樂觀鎖

悲觀鎖和樂觀鎖無具體實現,只是概念上的鎖。下面會講到這兩種概念鎖的具體實現細節何其應用場景。

悲觀鎖(Pessimistic Lock)

顧名思義,就是很悲觀,每次去拿數據的時候都認爲會被其他線程修改,所以每次在拿數據的時候都會上鎖,這樣其他線程想拿這個數據就會block直到它拿到鎖。傳統的關係型數據庫裏邊就用到了很多這種鎖機制,比如行鎖,表鎖等,讀鎖,寫鎖等,都是在做操作之前先上鎖。

舉例:在我們對數據庫中的一條記錄進行操作時,我們可以對其加上悲觀鎖,是其他更改操作阻塞無法立即修改。

格式 SELECT…FOR UPDATE
例:select * from account where name="Erica" for update

這條 sql 語句鎖定了 account 表中所有符合檢索條件( name=“Erica” )的記錄。 本次事務提交之前(事務提交時會釋放事務過程中的鎖),外界其他人無法修改這些記錄。

當我們不希望被阻塞,遇到加鎖的記錄就立即返回,不等待釋放鎖再去修改,可以使用下面的語法格式進行;

格式 SELECT…FOR UPDATE NOWAIT

該關鍵字的含義是“不用等待,立即返回”,如果當前請求的資源被其他會話鎖定時,會發生阻塞,nowait可以避免這一阻塞。

樂觀鎖(Optimistic Lock)

 顧名思義,就是很樂觀,每次去拿數據的時候都認爲不會被其他線程修改,所以不會上鎖,但是在更新的時候會判斷一下在此期間別人有沒有去更新這個數據,可以使用版本號等機制。樂觀鎖適用於多讀的應用類型,這樣可以提高吞吐量,像數據庫如果提供類似於write_condition機制的其實都是提供的樂觀鎖。

樂觀鎖,大多是基於數據版本 ( Version )記錄機制實現。何謂數據版本?即爲數據增加一個版本標識,在基於數據庫表的版本解決方案中,一般是通過爲數據庫表增加一個 “version” 字段來實現。

讀取出數據時,將此版本號一同讀出,之後更新時,對此版本號加一。此時,將提交數據的版本數據與數據庫表對應記錄的當前版本信息進行比對,如果提交的數據版本號大於數據庫表當前版本號,則予以更新,否則認爲是過期數據。

悲觀鎖和樂觀鎖總結

兩種鎖各有優缺點,都有其適用的場景,像樂觀鎖適用於寫比較少的情況下,即衝突真的很少發生的時候,這樣可以省去了鎖的開銷,加大了系統的整個吞吐量。但如果經常產生衝突,上層應用會不斷的進行retry,這樣反倒是降低了性能,所以這種情況下用悲觀鎖就比較合適。

重入鎖

重入鎖定義

重進入是指任意線程在獲取到鎖之後,再次獲取該鎖而不會被該鎖所阻塞。關聯一個線程持有者+計數器,重入意味着鎖操作的顆粒度爲“線程”。在JAVA環境下 ReentrantLock 和synchronized 都是可重入鎖。

重入鎖原理

線程再次獲取鎖,鎖需要識別獲取鎖的現場是否爲當前佔據鎖的線程,如果是,則再次成功獲取,重入鎖的計數器加一;

每個鎖關聯一個線程持有者和計數器,當計數器爲0時表示該鎖沒有被任何線程持有,那麼任何線程都可能獲得該鎖而調用相應的方法;當某一線程請求成功後,JVM會記下鎖的持有線程,並且將計數器置爲1;此時其它線程請求該鎖,則必須等待;而該持有鎖的線程如果再次請求這個鎖,就可以再次拿到這個鎖,同時計數器會遞增;當線程退出同步代碼塊時,計數器會遞減,如果計數器爲0,則釋放該鎖。

public class ThreadDemo extends Thread {
    ReentrantLock lock = new ReentrantLock();
    
    public void get() {
        lock.lock();
        try {
            System.out.println(Thread.currentThread().getId());
            set();
        } finally {
            lock.unlock();
        }
    }
    
    public void set() {
        lock.lock();
        try {
            System.out.println(Thread.currentThread().getId());
        } finally {
            lock.unlock();
        }
    }
    
    @Override
    public void run() {
        get();
    }
    
    public static void main(String[] args) {
        ThreadDemo ss = new ThreadDemo();
        new Thread(ss).start();
        new Thread(ss).start();
        new Thread(ss).start();
    }
}

以上demo使用了ReentrantLock可重入鎖,線程在執行get()方法時,已經取得了鎖,未釋放鎖之前,可以在set()方法中再次獲取鎖,所以當前線程獲取了兩次鎖,必須執行兩次釋放鎖的操作才能真正的釋放鎖。同理,在使用synchronized關鍵字是時,也是如此。

讀寫鎖

讀寫鎖介紹

假設你的程序中涉及到對一些共享資源的讀和寫操作,且寫操作沒有讀操作那麼頻繁。在沒有寫操作的時候,兩個線程同時讀一個資源沒有任何問題,所以應該允許多個線程能在同時讀取共享資源。但是如果有一個線程想去寫這些共享資源,就不應該再有其它線程對該資源進行讀或寫(也就是說:-讀能共存,讀-寫不能共存,寫-寫不能共存)。

Java併發包中ReadWriteLock是一個接口,主要有兩個方法,如下:

public interface ReadWriteLock {
    Lock readLock();
    Lock writeLock();
}

ReentrantReadWriteLock分析

ReentrantReadWriteLock有如下特性:

  • 非公平模式(默認) 。當以非公平初始化時,讀鎖和寫鎖的獲取的順序是不確定的。非公平鎖主張競爭獲取,可能會延緩一個或多個讀或寫線程,但是會比公平鎖有更高的吞吐量;
  • 公平模式 。當以公平模式初始化時,線程將會以隊列的順序獲取鎖。當前線程釋放鎖後,等待時間最長的寫鎖線程就會被分配寫鎖;或者有一組讀線程組等待時間比寫線程長,那麼這組讀線程組將會被分配讀鎖。當有寫線程持有寫鎖或者有等待的寫線程時,一個嘗試獲取公平的讀鎖(非重入)的線程就會阻塞。這個線程直到等待時間最長的寫鎖獲得鎖後並釋放掉鎖後才能獲取到讀鎖;
  • 可重入。允許讀鎖可寫鎖可重入。寫鎖可以獲得讀鎖,讀鎖不能獲得寫鎖;
  • 鎖降級。允許寫鎖降低爲讀鎖;
  • 中斷鎖的獲取。在讀鎖和寫鎖的獲取過程中支持中斷 ;
  • 支持Condition 。寫鎖提供Condition實現;
  • 監控。提供確定鎖是否被持有等輔助方法。

我們可以查看ReentrantReadWriteLock類的構造器:

    public ReentrantReadWriteLock() {
        this(false);
    }
    public ReentrantReadWriteLock(boolean fair) {
        sync = fair ? new FairSync() : new NonfairSync();
        readerLock = new ReadLock(this);
        writerLock = new WriteLock(this);
    }

默認使用的是非公平模式,如果需要使用公平模式創建讀寫鎖,可以通過構造器傳參的方式獲取。

ReentrantReadWriteLock的具體使用,我們再來看一下下面這個比較常見的案例:

public class ThreadDemo6 {
    static Map<String, Object> map = new HashMap<String, Object>();
    static ReentrantReadWriteLock rwl = new ReentrantReadWriteLock();
    static Lock r = rwl.readLock();
    static Lock w = rwl.writeLock();

    // 獲取一個key對應的value
    public static final Object get(String key) {
        r.lock();
        try {
            System.out.println("正在做讀的操作,key:" + key + " 開始");
            Thread.sleep(10);
            Object object = map.get(key);
            System.out.println("正在做讀的操作,key:" + key + " 結束");
            System.out.println();
            return object;
        } catch (InterruptedException e) {
        } finally {
            r.unlock();
        }
        return key;
    }

    // 設置key對應的value,並返回舊有的value
    public static final Object put(String key, Object value) {
        w.lock();
        try {
            System.out.println("正在做寫的操作,key:" + key + ",value:" + value + "開始.");
            Thread.sleep(10);
            Object object = map.put(key, value);
            System.out.println("正在做寫的操作,key:" + key + ",value:" + value + "結束.");
            System.out.println();
            return object;
        } catch (InterruptedException e) {
        } finally {
            w.unlock();
        }
        return value;
    }

    // 清空所有的內容
    public static final void clear() {
        w.lock();
        try {
            map.clear();
        } finally {
            w.unlock();
        }
    }

    public static void main(String[] args) {
        for (int i = 0; i < 3; i++) {
            final int key = i;
            new Thread(new Runnable() {
                @Override
                public void run() {
                    ThreadDemo6.put(key + "", key + "");
                }
            }).start();
        }
        
        for (int i = 0; i < 3; i++) {
            final int key = i;
            new Thread(new Runnable() {
                @Override
                public void run() {
                    ThreadDemo6.get(key + "");
                }
            }).start();
        }
    }
} 
////運行結果:

正在做寫的操作,key:0,value:0開始.
正在做寫的操作,key:0,value:0結束.

正在做寫的操作,key:1,value:1開始.
正在做寫的操作,key:1,value:1結束.

正在做寫的操作,key:2,value:2開始.
正在做寫的操作,key:2,value:2結束.

正在做讀的操作,key:1 開始
正在做讀的操作,key:0 開始
正在做讀的操作,key:2 開始
正在做讀的操作,key:0 結束

正在做讀的操作,key:2 結束

正在做讀的操作,key:1 結束

由運行結果可以看出,進行寫操作時。只能一個線程執行,但在讀操作是,可以有多個線程並行執行。

CAS無鎖機制

(1)與鎖相比,使用比較交換(下文簡稱CAS)會使程序看起來更加複雜一些。但由於其非阻塞性,它對死鎖問題天生免疫,並且,線程間的相互影響也遠遠比基於鎖的方式要小。更爲重要的是,使用無鎖的方式完全沒有鎖競爭帶來的系統開銷,也沒有線程間頻繁調度帶來的開銷,因此,它要比基於鎖的方式擁有更優越的性能。

(2)無鎖的好處:

第一,在高併發的情況下,它比有鎖的程序擁有更好的性能;

第二,它天生就是死鎖免疫的。

就憑藉這兩個優勢,就值得我們冒險嘗試使用無鎖的併發。

(3)CAS算法的過程是這樣:它包含三個參數CAS(V,E,N): V表示要更新的變量,E表示預期值,N表示新值。僅當V值等於E值時,纔會將V的值設爲N,如果V值和E值不同,則說明已經有其他線程做了更新,則當前線程什麼都不做。最後,CAS返回當前V的真實值。

(4)CAS操作是抱着樂觀的態度進行的,它總是認爲自己可以成功完成操作。當多個線程同時使用CAS操作一個變量時,只有一個會勝出,併成功更新,其餘均會失敗。失敗的線程不會被掛起,僅是被告知失敗,並且允許再次嘗試,當然也允許失敗的線程放棄操作。基於這樣的原理,CAS操作即使沒有鎖,也可以發現其他線程對當前線程的干擾,並進行恰當的處理。

(5)簡單地說,CAS需要你額外給出一個期望值,也就是你認爲這個變量現在應該是什麼樣子的。如果變量不是你想象的那樣,那說明它已經被別人修改過了。你就重新讀取,再次嘗試修改就好了。

(6)在硬件層面,大部分的現代處理器都已經支持原子化的CAS指令。在JDK 5.0以後,虛擬機便可以使用這個指令來實現併發操作和併發數據結構,並且,這種操作在虛擬機中可以說是無處不在。

 

 

 

 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章