【JDK併發包基礎】併發容器詳解

      Java.util.concurrent 包是專爲 Java併發編程而設計的包,它下有很多編寫好的工具,使用這些更高等的同步工具來編寫代碼,讓我們的程序可以不費力氣就得到優化。

                  

腦圖地址,感謝深入淺出 Java Concurrency ,此腦圖在這篇基礎上修改而來。在這裏我們看容器部分:

      1.ConcurrentHashMap

      2.CopyOnWrite容器

      3.併發Queue

1.ConcurrentHashMap

  • 原理

      先來說說HashTable(線程安全的HashMap),他們內部採用橫向數組,縱向鏈表的結構來儲存數據。橫向數組的下標爲key的hash值,縱向鏈表爲hash值相同的元素組成的鏈表:

                    

      Hashtable容器在競爭激烈的併發情況下,所有訪問HashTable的線程都必須使用同一把鎖,導致效率低下。假如容器有多把鎖,每一把鎖用於容器的一部分數據,多線程訪問不同數據段時,線程間就不存在鎖競爭。這就是ConcurrentHashMap的鎖分段技術,它既是線程安全的又支持高併發的HashMap,每一個段就像是一個小的HashTable,它們都有自己的鎖。

      按默認16個Segment(段)來講,理論上就允許16個線程併發執行,只要多個修改操作發生在不同的段上,就可以併發進行:

                     

      一個ConcurrentHashMap包含了一個Segments數組,每一個Segment和HashTable類似,是一種數組的鏈表,即每一個Segment維護一個HashEntry數組,每一個HashEntry是一個鏈表結構。

      ConcurrentHashMap的使用方式與HashMap一樣:

    public static void main(String[] args) {
		ConcurrentHashMap<String, Object> chm = new ConcurrentHashMap<String, Object>();
		chm.put("k1", "v1");
		chm.put("k2", "v2");
		chm.put("k3", "v3");
		chm.putIfAbsent("k3", "vvvv");
		System.out.println(chm.get("k2"));
		System.out.println(chm.size());
		for(Map.Entry<String, Object> me : chm.entrySet()){
			System.out.println("key:" + me.getKey() + ",value:" + me.getValue());
		}
	}
  • get操作

      ConcurrentHashMap的get操作不需要加鎖,是經過一次再散列,然後使用這個散列值通過散列運算定位到Segment,再通過散列算法定位到元素。

  • put操作

      和get操作一樣,先通過key進行兩次hash確定應該去哪個Segment中取數據,鎖定這個Segment。第一步先判斷是否需要對Segment裏的HashEntry數組進行擴容。第二步如果需要擴容,則定位到添加元素的位置,放在HashEntry數組裏。在擴容的時候,首先會創建一個容量是原來兩倍的數組,然後將原數組裏的元素進行再散列插入到新的數組裏。這樣的設計令哈希表即便是在擴容期間,也能保證無鎖的讀。爲了高效,ConcurrentHashMap只會對需要擴容的Segment擴容。

  • size操作

      ConcurrentHashMap統計size時會比HashMap麻煩的多,因爲使用了分段技術,爲了高效,ConcurrentHashMap先會嘗試兩次不鎖住全部Segment的方式統計大小。如果統計過程中容器的count大小發生了變化,再採用加鎖的方式統計所有的Segment大小。

2.CopyOnWrite容器

      從CopyOnWrite字面意思理解是在寫時複製。當我們向容器裏添加元素時,不直接往當前容器裏添加,而是先將當前容器複製出一個新的容器,然後往新的容器裏添加元素。添加完元素後,再將原容器的引用指向新的容器,這樣做的好處是可以對容器進行併發的讀,而不需要加鎖。

      CopyOnWrite容器是一種讀寫分離的思想。jdk裏的CopyOnWrite容器有兩種:CopyOnWriteArrayList和CopyOnWriteArraySet,它們適用於讀多寫少的場景中。我先看看CopyOnWriteArrayList源碼:

   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();
        }
    }

      可以發現在添加的時候是需要加鎖的,否則多線程寫的時候會Copy出N個副本出來,導致最終的數組數據不是我們期望的。CopyOnWriteArraySet內部構造函數中又調用了CopyOnWriteArrayList,它僅僅是不允許重複的Object數組。

     private final CopyOnWriteArrayList<E> al;
     public CopyOnWriteArraySet() {
        al = new CopyOnWriteArrayList<E>();
    }

      CopyOnWriteArrayList和CopyOnWriteArraySet與ArrayList和HashSet用法一樣,就不在這裏做介紹了。

3.併發Queue

    生產者-消費者是一個經典的多線程設計模式,它通常由兩類線程組成:生產者線程負責生產數據,消費者線程負責具體拿到數據處理數據。如果生產者直接給消費者提供數據,則耦合度過高。聰明的程序員想了一個辦法,把數據存在第三方容器那裏,大家都去那裏存取數據。這個過程官方的說法就是生產者和消費者之間通過共享內存緩衝區進行通信。併發包下的Queue接口,有很多基於內存緩衝區的隊列,層次結構如下:

             

      3.1 高性能隊列ConcurrentLinkedQueue

      ConcurrentLinkedQueue是高併發場景下,應用很廣泛的一種隊列。它是一個基於鏈接節點的無界安全隊列,遵循元素先進先出的原則,且元素不允許爲null。它有兩個重要的方法:

  •  加入元素的方法:add()和offer()(在ConcurrentLinkedQueue中這倆方法沒有區別),他倆都是Queue接口add和offer方法的實現.因爲其他隊列有不同的實現.其他隊列請看下文。
  •  取出元素的方法:poll()取出並刪除頭元素和peek()取出元素。
		//高性能無阻塞無界隊列:ConcurrentLinkedQueue
		ConcurrentLinkedQueue<String> qlq = new ConcurrentLinkedQueue<String>();
		qlq.offer("a");
		qlq.offer("b");
		qlq.offer("c");
		qlq.offer("d");
		qlq.add("e");
		
		System.out.println(qlq.poll());	//a 從頭部取出元素,並從隊列裏刪除
		System.out.println(qlq.size());	//4
		System.out.println(qlq.peek());	//b
		System.out.println(qlq.size());	//4

      3.2 阻塞隊列BlockingQueue接口

      使用上文的非阻塞隊列時有一個很大問題:它不會對當前線程產生阻塞,在面對類似消費者-生產者的模型時,就必須額外地實現同步策略以及線程間喚醒策略,這個實現起來就非常麻煩。BlockingQueue很好的解決了多線程中,如何高效安全“傳輸”數據的問題。可以將BlockingQueue隊列應用於生產者--消費者隊列。

                                

      當試圖在空隊列裏讀數據時,讀的線程就會做一個等待,等待另外一個線程往隊列裏寫數據時,等待線程就會喚醒,並且拿到數據。反之當隊列滿時,試圖寫數據得線程就會等待,直到有線程從另一端拿數據時,寫數據的線程將會喚醒。

  • ArrayBlockingQueue

      它是基於數組的阻塞隊列實現,屬於有界隊列。可以指定先進先出或先進後出,內部用一個定長的數組緩存隊列中的數據對象。因爲沒有實現讀寫分離,所以生產者和消費者不能完全並行。

		ArrayBlockingQueue<String> array = new ArrayBlockingQueue<String>(5);
		array.put("a");
		array.put("b");
		array.add("c");
		array.add("d");
		array.add("e");
		//array.add("f");這裏放開會報異常,因爲已經添加了5個元素了
        //2秒後返回false,表示添加失敗
		System.out.println(array.offer("a", 2, TimeUnit.SECONDS));
  • LinkedBlockingQueue

      它是基於鏈表的阻塞隊列實現,屬於無界隊列。內部用一個鏈表緩存隊列中的數據對象,它實現了讀寫分離,所以可以高效的處理併發數據,生產者和消費者也可以完全並行。其內部是使用ReentrantLock和Condition實現生產者和消費者模式。(ReentrantLock和Condition的解讀可以訪問博主的另外一篇文章:【Java併發基礎】concurrent包工具類詳解):

                 

      【生產者-消費者應用】3秒後生產者停止生產數據,則消費者將一直等待隊列裏有數據:   

public class BlockingQueueHosee {
	//數據生產器
	private static AtomicInteger count = new AtomicInteger();
	
	final BlockingQueue<Integer> bq = new LinkedBlockingQueue<Integer>(10);
	
	class Producer implements Runnable {
		//多線程間是否啓動變量,有強制從主內存中刷新的功能。即時返回線程的狀態
		private volatile boolean isRunning = true;
		//隨機對象
		private  Random r = new Random(); 
		@Override
		public void run() {
			while(isRunning){
				try {
					//隨機休眠0 - 1000 毫秒 表示獲取數據(產生數據的耗時) 
					Thread.sleep(r.nextInt(1000));
					//獲取的數據進行累計..
					int incrementCount = count.incrementAndGet();
					bq.put(incrementCount);
					System.out.println(
							"當前線程:" + Thread.currentThread().getName() + ", 生產了數據"+incrementCount+", 進行裝載到公共緩衝區中..."
							);
				} catch (InterruptedException e) {
					e.printStackTrace();
				}
			}
		}
		public void stop(){
			this.isRunning = false;
		}
	}

	class Consumer implements Runnable {
		//隨機對象
		private  Random r = new Random(); 
		@Override
		public void run() {
			while(true){
				try {
					Integer take = bq.take();
					//進行數據處理。休眠0 - 1000毫秒模擬耗時
					Thread.sleep(r.nextInt(1000));
					System.out.println("當前消費線程:" + Thread.currentThread().getName() + ", 消費成功,消費數據爲id: " + take);
				} catch (Exception e) {
					e.printStackTrace();
				}
			}
		}
	}

	public static void main(String[] args) throws Exception {
		BlockingQueueHosee hosee = new BlockingQueueHosee();
		Producer producer = hosee.new Producer();
		new Thread(producer,"生產者").start();
		new Thread(hosee.new Consumer(),"消費者1").start();
		new Thread(hosee.new Consumer(),"消費者2").start();
		Thread.sleep(3000);
		producer.stop();
	}
}

代碼執行結果如下:    

                             

  • SynchronousQueue

      一種沒有緩衝的隊列,生產者生產的數據會直接被消費者獲取並消費。它裏面不能放數據,向它裏添加元素會報錯:

//需要先取數據,才能向SynchronousQueue添加數據,直接添加會報錯
final SynchronousQueue<String> q = new SynchronousQueue<String>();
		//q.add("adsa ");// java.lang.IllegalStateException: Queue full
		Thread t1 = new Thread(new Runnable() {
			@Override
			public void run() {
				try {
					System.out.println(q.take());
				} catch (InterruptedException e) {
					e.printStackTrace();
				}
			}
		});
		t1.start();
		Thread t2 = new Thread(new Runnable() {
			@Override
			public void run() {
				q.add("asdasd");
			}
		});
		t2.start();		
	}
  • DelayQueue

      帶有延遲隊列,其中元素只有當指定時間到了,才能夠從隊列中獲取元素。DelayQueue能做什麼呢?比如餓了麼在用戶提交訂單後60秒給用戶發送短信通知,比如對緩存超時的對象移除,任務超時處理,空閒鏈接的關閉等等,它的用處很廣泛。ref:Java 之DelayQueue實際運用示例

      到這裏博主介紹完了常用的Java併發容器,博主是個普通的程序猿,水平有限,文章難免有錯誤,歡迎犧牲自己寶貴時間的讀者,就本文內容直抒己見,博主的目的僅僅是希望對讀者有所幫助。

系列:

【JDK併發包基礎】線程池詳解

【JDK併發包基礎】併發容器詳解

【JDK併發包基礎】工具類詳解

【JDK併發基礎】Java內存模型詳解

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