Java併發程式設計——基礎知識(二)

it_was發表於2020-09-28

主要包括兩者:VectorHashTable。這些類實現執行緒安全的方式主要是將它們的狀態封裝起來,對每個公有方法都進行同步,使得每次只有一個執行緒能訪問這些容器:boom:

1.1 問題一:同步容器類在所有情況下都是執行緒安全的嗎:question:

答案是:No!:no_good:
的確,如果只是很簡單地,原子地去使用這些同步容器類的方法的話是執行緒安全的,但當進行一些複合操作的時候,例如迭代,條件語句等,在併發環境下就很容易出現問題!
例如以下程式碼::eyes:

 public static Object getLast(Vector vector) {
     int lastindex = vector.size() - 1; //1
     return vector.get(lastindex);     //2
 }
 public static Object deleteLast(Vector vector) {
     int lastindex = vector.size() - 1;//1
     return vector.remove(lastindex); //2
 }

如果此時有兩個執行緒分別同時執行get和delete方法,此時假設他們透過了對應的第一行語句,即分別獲取了這個同步容器類的大小,ok,下一步,只能有一個執行緒進行操作,假設此時恰好是執行delete方法,導致容器變小,之後退出方法。然後另一個執行緒執行get方法,因為此時容器的容量減小,故導致丟擲陣列訪問越界異常!

透過對上面情況的討論,我們應該知道,對這些容易出錯的複合操作,也應該加上鎖來保證同步,但此時併發性也會大大降低

1.2 迭代器及其問題

對於同步容器類的迭代,其實開發者並沒有考慮到併發修改的問題,即在併發環境下,如果其他執行緒在該迭代器進行迭代期間而進行修改,那很有可能丟擲ConcurrentModificatinException!——fail fast!:boom:

    public synchronized Iterator<E> iterator() {
        return new Itr();
    }

    /**
     * An optimized version of AbstractList.Itr
     */
    private class Itr implements Iterator<E> {
        int cursor;       // index of next element to return
        int lastRet = -1; // index of last element returned; -1 if no such
        int expectedModCount = modCount;

        public boolean hasNext() {
            // Racy but within spec, since modifications are checked
            // within or after synchronization in next/previous
            return cursor != elementCount;
        }

        public E next() {
            synchronized (Vector.this) {
                checkForComodification();
                //.......
            }
        }

        public void remove() {
            if (lastRet == -1)
                throw new IllegalStateException();
            synchronized (Vector.this) {
                checkForComodification();
                //.......
            }
            cursor = lastRet;
            lastRet = -1;
        }

        @Override
        public void forEachRemaining(Consumer<? super E> action) {
            Objects.requireNonNull(action);
            synchronized (Vector.this) {
                   //.......
                checkForComodification();
            }
        }

        final void checkForComodification() {
            if (modCount != expectedModCount)
                throw new ConcurrentModificationException();
        }
    }

上面是同步容器類Vector的迭代器原始碼,我們可以發現,雖然其Next方法做到了同步,但是致命的一點就是checkForComodification檢查方法沒有進行同步!也就意味著在併發情況下很有可能導致fail-fast情況。而且我們仔細看,其實它的remove方法也是有問題,其上鎖的區塊並沒有包含整個方法,同步範圍小,也是很容易出問題的!:imp:

注意:fail——fast機制在單執行緒環境下也是會出現的,即在迭代期間沒有透過迭代器本身進行刪除,而是透過容器直接新增,那麼會很容易陷入快速失敗的情況!

那我們該如何解決呢 ?:anguished:
  • :one: 迭代期間加鎖!
    這並不是一個好方法,如果容器的容量很大,那麼迭代將耗費很長時間,此時其他執行緒都處於阻塞狀態,會極大地降低吞吐量和CPU的利用率!
    注意:我們知道在用迭代器的地方加鎖,可是要注意某些情況下迭代器會隱藏!!!!比如容器的hashcode和equals等方法也會間接進行迭代!!所以要時刻警惕這些場景!
  • :two: 克隆容器!
    將容器克隆出副本,並在副本進行迭代,克隆期間還是要加鎖!這方法也有問題,就是取決於容器克隆的效能開銷

1.3 總結

同步容器類其實是一種古老的是實現執行緒安全的方法,你完全也可以自己透過Collections.SynchronizedXXX靜態工廠方法將你的容器同步。但由於其所有方法都是同步的,複合操作的不安全性以及迭代器的問題,在併發場景下,很少再用這些古老的,效率低下的同步容器了,取而代之的是下面將要介紹的 併發容器!

由於併發場景下,同步容器類表現出令人不滿意的情況——併發性低,吞吐量低。
併發容器應運而生:ConcurrentHashMap 代替 同步的 Map,CopyOnWriteArraylList 代替同步的list 以及還有 ConcurrentSkipListMap代替同步的 SortedMap等等

2.1 ConcurrentHashMap

  • JDK 1.7及以前,ConcurrentHashMap採用的是分段鎖 setment繼承自ReentrantLock,即Segment 陣列 + HashEntry 陣列 + 連結串列。將鎖的粒度由之前的整個容器降低為一段一段,在併發環境下實現更高的吞吐量
    Java併發程式設計——基礎知識(二)

  • JDK 1.8之後,採用Node 陣列 + 連結串列 / 紅黑樹,併發控制使用 synchronized 和 CAS 來操作。將鎖的粒度從之前的段到了現在的Node結點!並且採用synchronized 和CAS操作,使併發程度更高,效能更好。

    Java併發程式設計——基礎知識(二)

    注意:雖然ConcurrentHashMap表現出很好的併發性,但是其一些方法是被弱化了的,比如size 和 isEmpty。比如size方法返回的允許是一個近似值,事實上這樣的方法用處小,因為返回值總是在不斷變化。因此這些操作的需求被弱化了,更多的是提升put ,get等操作的效能!

2.2 CopyOnWriteArraylList

在很多應用場景中,讀操作可能會遠遠大於寫操作。由於讀操作根本不會修改原有的資料,因此對於每次讀取都進行加鎖其實是一種資源浪費。我們應該允許多個執行緒同時訪問 List 的內部資料,畢竟讀取操作是安全的。
‘寫入時複製’容器的執行緒安全性是每次修改時,都會建立並重新釋出一個新的容器副本!即讀取是完全不用加鎖的,寫入也不會阻塞讀取操作,因為寫入操作是在新的副本上操作!只有寫入和寫入之間需要進行同步等待。這樣一來,讀操作的效能就會大幅度提升。
直接上原始碼::eyes:

    public E get(int index) { //讀取操作不加鎖
        return get(getArray(), index);
    }
     public E set(int index, E element) {
        final ReentrantLock lock = this.lock;
        lock.lock(); //上鎖
        try {
            Object[] elements = getArray();
            E oldValue = get(elements, index);

            if (oldValue != element) {
                int len = elements.length;
                Object[] newElements = Arrays.copyOf(elements, len);
                newElements[index] = element;
                setArray(newElements);
            } else {
                // Not quite a no-op; ensures volatile write semantics
                setArray(elements);
            }
            return oldValue;
        } finally {
            lock.unlock(); //解鎖
        }
    }
    public boolean add(E 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();//解鎖
        }
    }

透過以上原始碼我們可以發現:

  • CopyOnWriteArraylList 的讀讀共享,讀寫共享,寫讀共享,唯有寫寫互斥!
  • 實現寫讀共享的機制就是透過在寫時先複製原陣列,然後操作完成之後將原陣列的引用指向新陣列!

但這有一定的問題,CopyOnWriteArraylList容器是無法保證讀寫的瞬時一致性,只能保證最終一致性!試想同時有兩個執行緒,執行緒A將某個i位置的值更新,而執行緒B讀取i位置的值,由於複製陣列的開銷,很有可能導致執行緒B先讀取到原先的舊值,即出現 “髒讀”!還有就是之前討論過的,複製陣列的開銷!:-1:

雖然有一定的問題,但是在讀操作或者迭代操作多於寫操作的場景下,CopyOnWriteArraylList的效能是有目共睹的!:+1:

2.3 ConcurrentLinkedQueue

Java 提供的執行緒安全的 Queue 可以分為阻塞佇列非阻塞佇列,其中阻塞佇列的典型例子是 BlockingQueue,非阻塞佇列的典型例子是 ConcurrentLinkedQueue,在實際應用中要根據實際需要選用阻塞佇列或者非阻塞佇列。 阻塞佇列可以透過加鎖來實現,非阻塞佇列可以透過 CAS 操作實現。

2.4 BlockingQueue

阻塞佇列(BlockingQueue)被廣泛使用在“生產者-消費者”問題中,其原因是 BlockingQueue 提供了可阻塞的插入和移除的方法。當佇列容器已滿,生產者執行緒會被阻塞,直到佇列未滿;當佇列容器為空時,消費者執行緒會被阻塞,直至佇列非空時為止。

Java併發程式設計——基礎知識(二)

2.4.1 ArrayBlockingQueue

2.4.2 LinkedBlockingQueue

2.4.3 PriorityBlockingQueue

本作品採用《CC 協議》,轉載必須註明作者和本文連結

相關文章