Java併發程式設計-鎖及併發容器

黃俊彬發表於2019-03-03

鎖是用來控制多個執行緒訪問共享資源的方式,一般來說,一個鎖能夠防止多個執行緒同時訪問共享資源(但是有些鎖可以允許多個執行緒併發的訪問共享資源,比如讀寫鎖)。在Lock介面出現之前,Java程式是靠synchronized關鍵字實現鎖功能的,而JavaSE5之後,併發包中新增了Lock介面(以及相關實現類)用來實現鎖功能,它提供了與synchronized關鍵字類似的同步功能,只是在使用時需要顯式地獲取和釋放鎖。雖然它缺少了(通過synchronized塊或者方法所提供的)隱式獲取釋放鎖的便捷性,但是卻擁有了鎖獲取與釋放的可操作性、可中斷的獲取鎖以及超時獲取鎖等多種synchronized關鍵字所不具備的同步特性。

重入鎖ReentrantLock

百度圖片搜尋
百度圖片搜尋

重入鎖ReentrantLock,顧名思義,就是支援重進入的鎖,它表示該鎖能夠支援一個執行緒對資源的重複加鎖。除此之外,該鎖的還支援獲取鎖時的公平和非公平性選擇。ReentrantLock是java.unti.concurrent包下的一個類,它的一般使用結構如下所示:

public void lockMethod() {  
    ReentrantLock myLock = new ReentrantLock();  
    myLock.lock();  
    try{  
        // 受保護的程式碼段  
        //critical section  
    } finally {  
        // 可以保證發生異常 鎖可以得到釋放 避免死鎖的發生  
        myLock.unlock();  
    }  
}複製程式碼

ReentrantLock與synchronized的比較

  • 相同:ReentrantLock提供了synchronized類似的功能和記憶體語義。
  • 不同:
  1. ReentrantLock功能性方面更全面,比如時間鎖等候,可中斷鎖等候,鎖投票等,因此更有擴充套件性。在多個條件變數和高度競爭鎖的地方,用ReentrantLock更合適,ReentrantLock還提供了Condition,對執行緒的等待和喚醒等操作更加靈活,一個ReentrantLock可以有多個Condition例項,所以更有擴充套件性。
  2. ReentrantLock 的效能比synchronized會好點。
  3. ReentrantLock提供了可輪詢的鎖請求,他可以嘗試的去取得鎖,如果取得成功則繼續處理,取得不成功,可以等下次執行的時候處理,所以不容易產生死鎖,而synchronized則一旦進入鎖請求要麼成功,要麼一直阻塞,所以更容易產生死鎖。

公平性

在Java的ReentrantLock建構函式中提供了兩種鎖:建立公平鎖和非公平鎖(預設)。程式碼如下:

  public ReentrantLock() {

       sync = new NonfairSync();

}

 public ReentrantLock(boolean fair) {
          sync = fair ? new FairSync() : new NonfairSync();
    }複製程式碼

在公平的鎖上,執行緒按照他們發出請求的順序獲取鎖,但在非公平鎖上,則允許‘插隊’:當一個執行緒請求非公平鎖時,如果在發出請求的同時該鎖變成可用狀態,那麼這個執行緒會跳過佇列中所有的等待執行緒而獲得鎖。

非公平鎖效能高於公平鎖效能的原因:
在恢復一個被掛起的執行緒與該執行緒真正執行之間存在著嚴重的延遲。

讀寫鎖ReentrantReadWriteLock

之前提到鎖基本都是排他鎖,這些鎖在同一時刻只允許一個執行緒進行訪問,而讀寫鎖在同一時刻可以允許多個讀執行緒訪問,但是在寫執行緒訪問時,所有的讀執行緒和其他寫執行緒均被阻塞。讀寫鎖維護了一對鎖,一個讀鎖和一個寫鎖,通過分離讀鎖和寫鎖,使得併發性相比一般的排他鎖有了很大提升。

一般情況下,讀寫鎖的效能都會比排它鎖好,因為大多數場景讀是多於寫的。在讀多於寫
的情況下,讀寫鎖能夠提供比排它鎖更好的併發性和吞吐量。Java併發包提供讀寫鎖的實現是ReentrantReadWriteLock

百度圖片搜尋
百度圖片搜尋

public class Cache {
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 {
return map.get(key);
} finally {
r.unlock();
}
}
// 設定key對應的value,並返回舊的value
public static final Object put(String key, Object value) {
w.lock();
try {
return map.put(key, value);
} finally {
w.unlock();
}
}
// 清空所有的內容
public static final void clear() {
w.lock();
try {
map.clear();
} finally {
w.unlock();
}
}
}複製程式碼

Cache組合一個非執行緒安全的HashMap作為快取的實現,同時使用讀寫鎖的
讀鎖和寫鎖來保證Cache是執行緒安全的。在讀操作get(String key)方法中,需要獲取讀鎖,這使
得併發訪問該方法時不會被阻塞。寫操作put(String key,Object value)方法和clear()方法,在更新
HashMap時必須提前獲取寫鎖,當獲取寫鎖後,其他執行緒對於讀鎖和寫鎖的獲取均被阻塞,而
只有寫鎖被釋放之後,其他讀寫操作才能繼續。

Condition介面

Condition是在java 1.5中才出現的,它用來替代傳統的Object的wait()、notify()實現執行緒間的協作,相比使用Object的wait()、notify(),使用Condition的await()、signal()這種方式實現執行緒間協作更加安全和高效。

呼叫Condition的await()和signal()方法,都必須在lock保護之內,就是說必須在lock.lock()和lock.unlock之間才可以使用

  • Conditon中的await()對應Object的wait()
  • Condition中的signal()對應Object的notify()
  • Condition中的signalAll()對應Object的notifyAll()
public class ConTest {  

     final Lock lock = new ReentrantLock();  
     final Condition condition = lock.newCondition();  

    public static void main(String[] args) {  
        // TODO Auto-generated method stub  
        ConTest test = new ConTest();  
        Producer producer = test.new Producer();  
        Consumer consumer = test.new Consumer();  


        consumer.start();   
        producer.start();  
    }  

     class Consumer extends Thread{  

            @Override  
            public void run() {  
                consume();  
            }  

            private void consume() {  

                    try {  
                           lock.lock();  
                        System.out.println("我在等一個新訊號"+this.currentThread().getName());  
                        condition.await();  

                    } catch (InterruptedException e) {  
                        // TODO Auto-generated catch block  
                        e.printStackTrace();  
                    } finally{  
                        System.out.println("拿到一個訊號"+this.currentThread().getName());  
                        lock.unlock();  
                    }  

            }  
        }  

     class Producer extends Thread{  

            @Override  
            public void run() {  
                produce();  
            }  

            private void produce() {                   
                    try {  
                           lock.lock();  
                           System.out.println("我拿到鎖"+this.currentThread().getName());  
                            condition.signalAll();                             
                        System.out.println("我發出了一個訊號:"+this.currentThread().getName());  
                    } finally{  
                        lock.unlock();  
                    }  
                }  
     }  

}複製程式碼

執行結果:

我在等一個新訊號Thread-1
我拿到鎖Thread-0
我發出了一個訊號:Thread-0
拿到一個訊號Thread-1複製程式碼

併發容器

CopyOnWrite容器

CopyOnWrite容器即寫時複製的容器。通俗的理解是當我們往一個容器新增元素的時候,不直接往當前容器新增,而是先將當前容器進行Copy,複製出一個新的容器,然後新的容器裡新增元素,新增完元素之後,再將原容器的引用指向新的容器。這樣做的好處是我們可以對CopyOnWrite容器進行併發的讀,而不需要加鎖,因為當前容器不會新增任何元素。所以CopyOnWrite容器也是一種讀寫分離的思想,讀和寫不同的容器

在使用CopyOnWriteArrayList之前,我們先閱讀其原始碼瞭解下它是如何實現的。以下程式碼是向ArrayList裡新增元素,可以發現在新增的時候是需要加鎖的,否則多執行緒寫的時候會Copy出N個副本出來。

public boolean add(T e) {
    final ReentrantLock lock = this.lock;
    lock.lock();
    try {

        Object[] elements = getArray();

        int len = elements.length;
        // 複製出新陣列

        Object[] newElements = Arrays.copyOf(elements, len + 1);
        // 把新元素新增到新陣列裡

        newElements[len] = e;
        // 把原陣列引用指向新陣列

        setArray(newElements);

        return true;

    } finally {

        lock.unlock();

    }

}

final void setArray(Object[] a) {
    array = a;
}複製程式碼

讀的時候不需要加鎖,如果讀的時候有多個執行緒正在向ArrayList新增資料,讀還是會讀到舊的資料,因為寫的時候不會鎖住舊的ArrayList。

public E get(int index) {
    return get(getArray(), index);
}複製程式碼

ConcurrentHashMap的實現原理與使用

ConcurrentHashMap是執行緒安全且高效的HashMap。ConcurrentHashMap是由Segment陣列結構和HashEntry陣列結構組成。Segment是一種可重入鎖ReentrantLock,在ConcurrentHashMap裡扮演鎖的角色,HashEntry則用於儲存鍵值對資料。一個ConcurrentHashMap裡包含一個Segment陣列,Segment的結構和HashMap類似,是一種陣列和連結串列結構,一個Segment裡包含一個HashEntry陣列,每個HashEntry是一個連結串列結構的元素, 每個Segment守護者一個HashEntry陣列裡的元素,當對HashEntry陣列的資料進行修改時,必須首先獲得它對應的Segment鎖。

為什麼要使用ConcurrentHashMap

  • 執行緒不安全的HashMap

在多執行緒環境下,使用HashMap進行put操作會引起死迴圈,導致CPU利用率接近100%,所以在併發情況下不能使用HashMap

  • 效率低下的HashTable

HashTable容器使用synchronized來保證執行緒安全,但線上程競爭激烈的情況下HashTable的效率非常低下。因為當一個執行緒訪問HashTable的同步方法,其他執行緒也訪問HashTable的同步方法時,會進入阻塞或輪詢狀態。如執行緒1使用put進行元素新增,執行緒2不但不能使用put方法新增元素,也不能使用get方法來獲取元素,所以競爭越激烈效率越低

  • ConcurrentHashMap的鎖分段技術可有效提升併發訪問率

HashTable容器在競爭激烈的併發環境下表現出效率低下的原因是所有訪問HashTable的執行緒都必須競爭同一把鎖,假如容器裡有多把鎖,每一把鎖用於鎖容器其中一部分資料,那麼當多執行緒訪問容器裡不同資料段的資料時,執行緒間就不會存在鎖競爭,從而可以有效提高併發訪問效率,這就是ConcurrentHashMap所使用的鎖分段技術。首先將資料分成一段一段地儲存,然後給每一段資料配一把鎖,當一個執行緒佔用鎖訪問其中一個段資料的時候,其他段的資料也能被其他執行緒訪問

hash定位

在定位元素的程式碼裡我們可以發現,定位HashEntry和定位Segment的雜湊演算法雖然一樣,都與陣列的長度減去1再相“與”,但是相“與”的值不一樣,定位Segment使用的是元素的hashcode通過再雜湊後得到的值的高位,而定位HashEntry直接使用的是再雜湊後的值。其目的是避免兩次雜湊後的值一樣,雖然元素在Segment裡雜湊開了,但是卻沒有在HashEntry裡雜湊開

hash >>> segmentShift) & segmentMask  // 定位Segment所使用的hash演算法
int index = hash & (tab.length - 1);  // 定位HashEntry所使用的hash演算法複製程式碼

get

Segment的get操作實現非常簡單和高效。先經過一次再雜湊,然後使用這個雜湊值通過散
列運算定位到Segment,再通過雜湊演算法定位到元素,程式碼如下

public V get(Object key) {
int hash = hash(key.hashCode());
return segmentFor(hash).get(key, hash);
}複製程式碼

get操作的高效之處在於整個get過程不需要加鎖,除非讀到的值是空才會加鎖重讀。我們
知道HashTable容器的get方法是需要加鎖的,那麼ConcurrentHashMap的get操作是如何做到不加鎖的呢?原因是它的get方法裡將要使用的共享變數都定義成volatile型別,如用於統計當前Segement大小的count欄位和用於儲存值的HashEntry的value。定義成volatile的變數,能夠線上程之間保持可見性,能夠被多執行緒同時讀,並且保證不會讀到過期的值

put

由於put方法裡需要對共享變數進行寫入操作,所以為了執行緒安全,在操作共享變數時必須加鎖。put方法首先定位到Segment,然後在Segment裡進行插入操作。插入操作需要經歷兩個步驟,第一步判斷是否需要對Segment裡的HashEntry陣列進行擴容,第二步定位新增元素的位置,然後將其放在HashEntry陣列裡

 public V put(K key, V value) {
        Segment<K,V> s;
        if (value == null)
            throw new NullPointerException();
        int hash = hash(key);
        int j = (hash >>> segmentShift) & segmentMask;
        if ((s = (Segment<K,V>)UNSAFE.getObject          // nonvolatile; recheck
             (segments, (j << SSHIFT) + SBASE)) == null) //  in ensureSegment
            s = ensureSegment(j);
        return s.put(key, hash, value, false);
    }複製程式碼

Segment的put方法

   final V put(K key, int hash, V value, boolean onlyIfAbsent) {
            HashEntry<K,V> node = tryLock() ? null :
                scanAndLockForPut(key, hash, value);
            V oldValue;
            try {
                HashEntry<K,V>[] tab = table;
                int index = (tab.length - 1) & hash;
                HashEntry<K,V> first = entryAt(tab, index);
                for (HashEntry<K,V> e = first;;) {
                    if (e != null) {
                        K k;
                        if ((k = e.key) == key ||
                            (e.hash == hash && key.equals(k))) {
                            oldValue = e.value;
                            if (!onlyIfAbsent) {
                                e.value = value;
                                ++modCount;
                            }
                            break;
                        }
                        e = e.next;
                    }
                    else {
                        if (node != null)
                            node.setNext(first);
                        else
                            node = new HashEntry<K,V>(hash, key, value, first);
                        int c = count + 1;
                        if (c > threshold && tab.length < MAXIMUM_CAPACITY)
                            rehash(node);
                        else
                            setEntryAt(tab, index, node);
                        ++modCount;
                        count = c;
                        oldValue = null;
                        break;
                    }
                }
            } finally {
                unlock();
            }
            return oldValue;
        }複製程式碼

size

ConcurrentHashMap的做法是先嚐試2次通過不鎖住Segment的方式來統計各個Segment大小,如果統計的過程中,容器的count發生了變化,則再採用加鎖的方式來統計所有Segment的大小。
那麼ConcurrentHashMap是如何判斷在統計的時候容器是否發生了變化呢?使用modCount變數,在put、remove和clean方法裡操作元素前都會將變數modCount進行加1,那麼在統計size前後比較modCount是否發生變化,從而得知容器的大小是否發生變化

阻塞佇列

阻塞佇列(BlockingQueue)是一個支援兩個附加操作的佇列。這兩個附加的操作支援阻塞的插入和移除方法。

  1. 支援阻塞的插入方法:意思是當佇列滿時,佇列會阻塞插入元素的執行緒,直到佇列不
    滿。
  2. 支援阻塞的移除方法:意思是在佇列為空時,獲取元素的執行緒會等待佇列變為非空。阻塞佇列常用於生產者和消費者的場景,生產者是向佇列裡新增元素的執行緒,消費者是從佇列裡取元素的執行緒。阻塞佇列就是生產者用來存放元素、消費者用來獲取元素的容器

插入和移除操作的4中處理方式

方法/處理方式 丟擲異常 返回特殊值 一直阻塞 超時退出
插入方法 add(e) offer(e) put(e) offer(e,time,unit)
移除方法 remove() poll() take() poll(e,time,unit)
檢查方法 element() peek() 不可用 不可用
  • 丟擲異常:當佇列滿時,如果再往佇列裡插入元素,會丟擲IllegalStateException("Queue
    full")異常。當佇列空時,從佇列裡獲取元素會丟擲NoSuchElementException異常。
  • 返回特殊值:當往佇列插入元素時,會返回元素是否插入成功,成功返回true。如果是移
    除方法,則是從佇列裡取出一個元素,如果沒有則返回null。
  • 一直阻塞:當阻塞佇列滿時,如果生產者執行緒往佇列裡put元素,佇列會一直阻塞生產者
    執行緒,直到佇列可用或者響應中斷退出。當佇列空時,如果消費者執行緒從佇列裡take元素,隊
    列會阻塞住消費者執行緒,直到佇列不為空。
  • 超時退出:當阻塞佇列滿時,如果生產者執行緒往佇列裡插入元素,佇列會阻塞生產者執行緒
    一段時間,如果超過了指定的時間,生產者執行緒就會退出

Java裡的阻塞佇列

JDK 7提供了7個阻塞佇列,如下。

  • ArrayBlockingQueue:一個由陣列結構組成的有界阻塞佇列。

ArrayBlockingQueue是一個用陣列實現的有界阻塞佇列。此佇列按照先進先出(FIFO)的原則對元素進行排序。預設情況下不保證執行緒公平的訪問佇列

  • LinkedBlockingQueue:一個由連結串列結構組成的有界阻塞佇列。

LinkedBlockingQueue是一個用連結串列實現的有界阻塞佇列。此佇列的預設和最大長度為Integer.MAX_VALUE。此佇列按照先進先出的原則對元素進行排序。

  • PriorityBlockingQueue:一個支援優先順序排序的無界阻塞佇列。

PriorityBlockingQueue是一個支援優先順序的無界阻塞佇列。預設情況下元素採取自然順序升序排列。也可以自定義類實現compareTo()方法來指定元素排序規則,或者初始化PriorityBlockingQueue時,指定構造引數Comparator來對元素進行排序。需要注意的是不能保證同優先順序元素的順序

  • DelayQueue:一個使用優先順序佇列實現的無界阻塞佇列。

DelayQueue是一個支援延時獲取元素的無界阻塞佇列。佇列使用PriorityQueue來實現。佇列中的元素必須實現Delayed介面,在建立元素時可以指定多久才能從佇列中獲取當前元素。只有在延遲期滿時才能從佇列中提取元素

  • SynchronousQueue:一個不儲存元素的阻塞佇列。

SynchronousQueue是一個不儲存元素的阻塞佇列。每一個put操作必須等待一個take操作,否則不能繼續新增元素

  • LinkedTransferQueue:一個由連結串列結構組成的無界阻塞佇列。

LinkedTransferQueue是一個由連結串列結構組成的無界阻塞TransferQueue佇列。相對於其他阻塞佇列,LinkedTransferQueue多了tryTransfer和transfer方法。

  • LinkedBlockingDeque:一個由連結串列結構組成的雙向阻塞佇列。

LinkedBlockingDeque是一個由連結串列結構組成的雙向阻塞佇列。所謂雙向佇列指的是可以從佇列的兩端插入和移出元素。雙向佇列因為多了一個操作佇列的入口,在多執行緒同時入隊時,也就減少了一半的競爭。相比其他的阻塞佇列,LinkedBlockingDeque多了addFirst、addLast、offerFirst、offerLast、peekFirst和peekLast等方法

參考資料

《Java併發程式設計的藝術》

ReentrantLock之公平鎖與非公平鎖淺析

java condition使用及分析

聊聊併發-Java中的Copy-On-Write容器

Java集合---ConcurrentHashMap原理分析

相關文章