[Java併發] 2. 併發容器

[Java併發] 2. 併發容器

一、線程安全的單例模式

單例模式就是說系統中對於某類只能有一個實例對象,不能出現第二個!面試中常常會被問到或者手寫一個線程安全的單例模式,主要考察多線程情況下的線程安全問題。

1. 不使用同步鎖
//直接加載。缺點:在該類加載的時候就會直接new一個靜態對象出來
//當系統中這樣的類較多時,就使得啓動速度變慢。
public class SingletonDirectlyNew {
	//直接初始化一個對象
    private static SingletonDirectlyNew single = new SingletonDirectlyNew();  
    //構造方法私有,保證其他類對象不能直接new一個該對象的實例
    private SingletonDirectlyNew() {         
    
    }
    public static SingletonDirectlyNew getSingle(){ //該類唯一的一個public方法
        return single;
    }
}

上述代碼中的一個缺點是該類加載的時候就會直接new一個靜態對象出來,當系統中這樣的類較多時,會使得啓動速度變慢 。現在流行的設計都是講延遲加載,我們可以在第一次使用的時候才初始化第一個該類對象。所以這種適合在小系統。

2. 延遲/懶加載 使用同步方法
/*鎖住了一個方法,鎖的力度有點大*/
public class SingletonSynMethod {
    private static SingletonSynMethod instance;
    private SingletonSynMethod() {  //構造方法私有
        
    }
    public static synchronized SingletonSynMethod getInstance() {  //對獲取實例的方法進行同步
        if (instance == null) {
            instance = new SingletonSynMethod();
        }
        return instance;
    }
}

上述代碼中的一次鎖住了一個方法, 這個粒度有點大 ,改進就是隻鎖住其中的new語句就OK。就是所謂的“雙重鎖”機制。

3. 延遲/懶加載 使用雙重同步鎖
public class SingletonDoubleSyn {
    private static SingletonDoubleSyn instance;
    private SingletonDoubleSyn () {  //構造方法私有
        
    }
    public static SingletonDoubleSyn getInstance() {
        if (instance == null) {
            synchronized (SingletonDoubleSyn.class) {  //鎖定new語句
                if (instance == null) {
                    instance = new SingletonDoubleSyn();
                }
            }
        }
        return instance;
    }
}
4. 延遲/懶加載 使用靜態內部類
/*不用加鎖 也能實現懶加載*/
public class SingletonInner {
    private SingletonInner() {
 
    }
    private static class Inner { //靜態內部類
        private static SingletonInner s = new SingletonInner();
    }
    private static  SingletonInner getSingle() {
        return Inner.s;
    }
}

既不用加鎖,也能實現懶加載,使用內部類,只加載一次。

二、售票問題

寫一個程序模擬:有n張火車票,每張票都有一編號,同時有10個窗口在對外售票。

1. List

直接使用List存儲票會發生重複銷售和超量銷售,因爲List的所有操作都是非原子性的。

/*下面程序模擬賣票可能會出現兩個問題:①票賣重了 ②還剩最後一張票時,好幾個線程同時搶,出現-1張票
 *出現上面兩個問題主要是因爲:①remove()方法不是原子性的 ②判斷+操作不是原子性的*/
import java.util.ArrayList;
import java.util.List;
public class TicketSeller1 {
	static List<String> tickets = new ArrayList<String>();
	
	static {
		for (int i=0; i<10000; i++) tickets.add("票編號:" + i);//共一萬張票
	}
	
	public static void main(String[] args) {
		for (int i=0; i<10; i++) {//共10個線程賣票
			new Thread(() -> {
				while(tickets.size() > 0) {//判斷餘票
					System.out.println("銷售了--" + tickets.remove(0));//操作減票
				}
			}).start();
		}
	}
}
2. Vector

Vector本身就是同步容器,所有的方法都加鎖,所有操作均爲原子性。但仍會出現問題,因爲判斷與操作是分離的,形成的複合操作不能保證原子性。

/*本程序雖然用了Vector作爲容器,Vector中的方法都是原子性的,但是在判斷size和減票的中間還是可能被打斷的,即被減到-1張*/
import java.util.Vector;
import java.util.concurrent.TimeUnit;
public class TicketSeller2 {
    static Vector<String> tickets = new Vector<>();

    static {
        for (int i = 0; i < 1000; i++) {
            tickets.add("票-" + i);
        }
    }

    public static void main(String[] args) {
        for (int i = 0; i < 10; i++) {
            new Thread(() -> {
                while (tickets.size() > 0) {
                    // 線程判斷後睡10毫秒再執行取操作,放大複合操作的問題
                    try {
                        TimeUnit.MILLISECONDS.sleep(10);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    System.out.println("銷售了:" + tickets.remove(0));
                }
            }).start();
        }
    }
}
3. synchronized

使用synchronized代碼塊將判斷和取票操作鎖在一起執行,保證其原子性。這樣可以保證售票過程的正確性,但每次取票都要鎖定整個隊列,效率低。

public class TicketSeller3 {
    static List<String> tickets = new ArrayList<>();

    static {
        for (int i = 0; i < 1000; i++) {
            tickets.add("票-" + i);
        }
    }

    public static void main(String[] args) {
        for (int i = 0; i < 10; i++) {
            new Thread(() -> {
                while (tickets.size() > 0) {
                    // sychronized 保證了原子性
                    synchronized (tickets) {
                        System.out.println("銷售了:" + tickets.remove(0));
                    }
                }
            }).start();
        }
    }
}
4. 併發隊列

使用併發隊列ConcurrentLinkedQueue存儲元素,其底層使用CAS實現而非加鎖實現的,其效率較高。
併發隊列ConcurrentLinkedQueuepoll()方法會嘗試從隊列頭中取出一個元素,若獲取不到,則返回null,對其返回值做判斷可以實現先取票後判斷,可以避免加鎖。

import java.util.Queue;
import java.util.concurrent.ConcurrentLinkedQueue;
public class TicketSeller4 {

    static ConcurrentLinkedQueue<String> queue = new ConcurrentLinkedQueue<>();

    static {
        for (int i = 0; i < 1000; i++) {
            queue.add("票-" + i);
        }
    }

    public static void main(String[] args) {
        for (int i = 0; i < 10; i++) {
            new Thread(() -> {
                while (true) {
                    // 嘗試取出隊列頭,若取不到則返回null
                    String t = queue.poll();//poll方法是同步的
                    if (t == null) break;
                    System.out.println("銷售了:" + t);
                }
            }).start();
        }
    }
}

poll()是原子性的,但是後面的ifprintln會打破原子性,但是這裏沒有問題,因爲poll()後我們沒有對隊列進行修改操作(比如前面的remove),不會出現重複和超額銷售。

三、併發容器

1. Map

主要的非併發容器有HashMapTreeMapLinkedHashMap

主要的併發容器有HashTableSynchronizedMapConcurrentMap

  • HashTableSynchronizedMap的效率較低,其同步的實現原理類似,都是給容器的所有方法都加鎖。其中SynchronizedMap使用裝飾器模式,調用其構造方法並傳入一個Map實現類,返回一個同步的Map容器。
  • ConcurrentMap的效率較高,有兩個實現類
    • ConcurrentHashMap: 使用哈希表實現,key是無序的。
    • ConcurrentSkipListMap: 使用跳錶實現,key是有序的。

關於跳錶的實現:跳錶(SkipList)及ConcurrentSkipListMap源碼解析,ConcurrentSkipListMap和Treemap插入時效率比較低,需要排好順序。但是查的時候效率很高。

ConcurrentMap同步的實現原理在JDK1.8前後不同,

  • 在JDK1.8以前,其實現同步使用的是分段鎖,將整個容器分爲16段(Segment),每次操作只鎖住操作的那一段,是一種細粒度更高的鎖.
  • 在JDK1.8及以後,其實現同步用的是Node+CAS,關於CAS的實現,可以看這篇文章Java:CAS(樂觀鎖)
import java.util.Arrays;
import java.util.HashMap;
import java.util.Hashtable;
import java.util.Map;
import java.util.Random;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentSkipListMap;
import java.util.concurrent.CountDownLatch;

public static void main(String[] args) {
	//不同容器在多線程併發下的效率問題
    Map<String, String> map = Collections.synchronizedMap(new HashMap<>());	// 所有同步方法都鎖住整個容器
    // Map<String, String> map = new Hashtable<>();							// 所有同步方法都鎖住整個容器
    // Map<String, String> map = new ConcurrentHashMap<>(); 				// 1.8以前使用分段鎖,1.8以後使用CAS
    // Map<String, String> map = new ConcurrentSkipListMap<>(); 			// 使用跳錶,高併發且有序
    // Map<String,String> map = new TreeMap<>();                            //插入時要排序,所以插入可能會比較慢

    Random r = new Random();
    Thread[] ths = new Thread[100];
    CountDownLatch latch = new CountDownLatch(ths.length); // 啓動了一個門閂,每有一個線程退出,門閂就減1,直到所有線程結束,門閂打開,主線程結束

    long start = System.currentTimeMillis();
    // 創建100個線程,鎖100個門閂,每個線程都往map中加10000個隨機字符串,每個完成後打開一個門閂。當所有線程執行完畢,記錄所花費的時間。
    for (int i = 0; i < ths.length; i++) {
        ths[i] = new Thread(() -> {
            for (int j = 0; j < 10000; j++) {
                map.put("a" + r.nextInt(10000), "a" + r.nextInt(100000));
            }
            latch.countDown();
        }, "t" + i);
    }
    Arrays.asList(ths).forEach(Thread::start);
    try {
        latch.await();
    } catch (InterruptedException e) {
        e.printStackTrace();
    }

    long end = System.currentTimeMillis();
    System.out.println(end - start);	
    System.out.println(map.size());
}

Map和Set本質上是一樣的,只是Set只有key,沒有value,所以下面談到的Map可以替換成Set。

  • 在不加鎖的情況下,可以用:HashMap、TreeMap、LinkedHashMap。想加鎖可以用Hashtable(用的非常少)。
  • 在併發量不是很高的情況下,可以用Collections.synchronizedXxx()方法,在該方法中傳一個不加鎖的容器(如Map),它返回一個加了鎖的容器(容器中的所有方法加鎖)
  • 在併發性比較高的情況下,用ConcurrentHashMap ,如果併發性高且要排序的情況下,用ConcurrentSkipListMap。
2. 寫時複製CopyOnWrite

CopyOnWriteArrayList位於java.util.concurrent包下,它實現同步的方式是: 當發生寫操作(添加,刪除,修改)時,就會複製原有容器然後對新複製出的容器進行寫操作,操作完成後將引用指向新的容器。其寫效率非常低,讀效率非常高。

  • 優點: 讀寫分離,使得讀操作不需要加鎖,效率極高。
  • 缺點: 寫操作效率極低
  • 應用場合: 應用在讀多寫少的情況,如事件監聽器
3. 隊列Queue

低併發隊列VectorSynchronizedList,其中vector類似HashTableSynchronizedList類似SynchronizedMap使用裝飾器模式,其構造函數接受一個List實現類並返回同步List,在java.util.Collections包下。它們實現同步的原理都是將所有方法用同步代碼塊包裹起來.

高併發隊列分爲阻塞隊列BlockingQueue和非阻塞隊列ConcurrentLinkedQueue,其中阻塞隊列的常用實現類有LinkedBlockingQueueArrayBlockingQueueDelayedQueueTransferQueueSynchronousQueue;非阻塞隊列使用CAS保證操作的原子性,不會因爲加鎖而阻塞線程.類似於ConcurrentMap

3.1 高併發隊列的方法
方法 拋出異常 返回特殊值 一直阻塞 (非阻塞隊列不可用) 阻塞一段時間 (非阻塞隊列不可用)
插入元素 add(element) offer(element) put(element) offer(element,time,unit)
移除首個元素 remove() poll() take() poll(time,unit)
返回首個元素 element() peek() 不可用 不可用

對於高併發隊列,若使用不同的方法對空隊列執行查詢和刪除,以及對滿隊列執行插入,會產生不同行爲:

  • 拋出異常: 使用add()remove()element()方法,若執行錯誤操作會直接拋出異常。
  • 返回特殊值: 若使用offer()poll()peek()方法執行錯誤操作會返回falsenull,並放棄當前錯誤操作,不拋出異常。
  • 一直阻塞: 若使用put()take()方法執行錯誤操作,當前線程會一直阻塞直到條件允許才喚醒線程執行操作.
  • 阻塞一段時間: 若使用offer()poll()方法並傳入時間單位,會將當前方法阻塞一段時間,若阻塞時間結束後仍不滿足條件則返回falsenull,並放棄當前錯誤操作,不拋出異常。
3.2 經典阻塞隊列LinkedBlockingQueue和ArrayBlockingQueue

LinkedBlockingQueueArrayBlockingQueue是阻塞隊列的最常用實現類,用來更容易地實現生產者/消費者模式.

import java.util.Random;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.TimeUnit;

public static void main(String[] args) {

    // 阻塞隊列,設置其最大容量爲10
    BlockingQueue<String> queue = new LinkedBlockingQueue<>(10);//非常常用的無界隊列

    // 啓動生產者線程
    for (int i = 0; i < 5; i++) {
        new Thread(() -> {
            for (int j = 0; j < 100; j++) {
                try {
                    // 使用put()方法向隊列插入元素,隊列程滿了則當前線程阻塞
                    queue.put("product" + j);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }, "producer" + i).start();
    }

    // 啓用消費者線程
    for (int i = 0; i < 5; i++) {
        new Thread(() -> {
            while (true) {
                try {
                    // 使用take()方法從隊列取出元素,隊列空了則當前線程阻塞
                    queue.take();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }, "consumer" + i).start();
    }
}
3.3 延遲隊列DelayedQueue

延遲隊列DelayedQueue中存儲的元素必須實現Delay接口,其中定義了getDelay()方法;而Delay接口繼承自Comparable接口,其中定義了compareTo()方法。各方法作用如下:

  • getDelay():規定當前元素的延時,Delay類型的元素必須要等到其延時過期後才能從容器中取出,提前取會取不到.
  • compareTo(): 規定元素在容器中的排列順序,按照compareTo()的結果升序排列。

Delayqueue可以用來執行定時任務。

public class T {

    // 定義任務類,實現Delay接口
	static class MyTask implements Delayed {
        private long runningTime;	// 定義執行時間

        public MyTask(long runTime) {
            this.runningTime = runTime;
        }

        // 執行時間減去當前時間 即爲延遲時間
        @Override
        public long getDelay(TimeUnit unit) {
            return unit.convert(runningTime - System.currentTimeMillis(), TimeUnit.MILLISECONDS);
        }

        // 規定任務排序規則: 先到期的元素排在前邊
        @Override
        public int compareTo(Delayed o) {
            return (int) (this.getDelay(TimeUnit.MILLISECONDS) - o.getDelay(TimeUnit.MILLISECONDS));
        }

        @Override
        public String toString() {
            return "MyTask{" + "runningTime=" + runningTime + '}';
        }
    }
    
    public static void main(String[] args) throws InterruptedException {
        long now = System.currentTimeMillis();

        // 初始化延遲隊列,並添加五個任務,注意任務不是按照順序加入的
        DelayQueue<MyTask> tasks = new DelayQueue<>();
        tasks.put(new MyTask(now + 1000));
        tasks.put(new MyTask(now + 2000));
        tasks.put(new MyTask(now + 1500));
        tasks.put(new MyTask(now + 2500));
        tasks.put(new MyTask(now + 500));
        
        // 從輸出可以看到我們規定的compareTo()排序方法進行排序的
        System.out.println(tasks);  

        // 取出所有任務,因爲要一段時間後才能取出,因此需要take()方法阻塞式地取
        while (!tasks.isEmpty()) {
            System.out.println(tasks.take());
            // System.out.println(tasks.remove());	// 使用remove()取元素會發生異常
            // System.out.println(tasks.poll());	// 使用poll()會輪詢取元素,效率低
        }
    }
}

程序輸出如下,我們發現延遲隊列中的元素按照compareTo()結果升序排列,且5個元素都被阻塞式的取出

[MyTask{runningTime=1563667111945}, MyTask{runningTime=1563667112445}, MyTask{runningTime=1563667112945}, MyTask{runningTime=1563667113945}, MyTask{runningTime=1563667113445}]
MyTask{runningTime=1563667111945}
MyTask{runningTime=1563667112445}
MyTask{runningTime=1563667112945}
MyTask{runningTime=1563667113445}
MyTask{runningTime=1563667113945}
3.4 阻塞消費隊列TransferQueue

TransferQueue繼承自BlockingQueue,向其中添加元素的方法除了BlockingQueueadd(),offer(),put()之外,還有一個transfer()方法,該方法會使當前線程阻塞直到消費者將該線程消費爲止.

transfer()put()的區別: put()方法會阻塞直到元素成功添加進隊列,transfer()方法會阻塞直到元素成功被消費.

TransferQueue特有的方法如下:

  • transfer(E): 阻塞當前線程直到元素E成功被消費者消費

  • tryTransfer(E): 嘗試將當前元素送給消費者線程消費,若沒有消費者接受則返回false且放棄元素E,不將其放入容器中.

  • tryTransfer(E,long,TimeUnit): 阻塞一段時間等待消費者線程消費,超時則返回false且放棄元素E,不將其放入容器中.

  • hasWaitingConsumer(): 指示是否有阻塞在當前容器上的消費者線程

  • getWaitingConsumerCount(): 返回阻塞在當前容器上的消費者線程的個數

public static void main(String[] args) {

    // 創建鏈表實現的TransferQueue
    TransferQueue transferQueue = new LinkedTransferQueue();

    // 啓動消費者線程,睡五秒後再來消費
    new Thread(() -> {
        try {
            TimeUnit.SECONDS.sleep(5);
            System.out.println(transferQueue.take());
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }).start();

    // 啓動生產者線程,使用transfer()方法添加元素 會阻塞等待元素被消費
    new Thread(() -> {
        try {
            // transferQueue.put("product");	// 使用put()方法會阻塞等待元素成功加進容器
            transferQueue.transfer("product"); 	// 使用transfer()方法會阻塞等待元素成功被消費
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }).start();
}

運行程序,我們發現生產者線程會一直阻塞直到五秒後"product2"被消費者線程消費.

適用場景:消費者先啓動,生產者生產一個東西的時候,不扔在隊列裏,而是直接去找有沒有消費者,有的話直接扔給消費者,若沒有消費者線程,調用transfer()方法就會阻塞,調用add()、offer()、put()方法不會阻塞。TransferQueue適用於更高的併發情況

3.5 零容量的阻塞消費隊列SynchronousQueue

SynchronousQueue是一種特殊的TransferQueue,特殊之處在於其容量爲0. 因此對其調用add(),offer()方法都會使程序發生錯誤(拋出異常或阻塞線程).只能對其調用put()方法,其內部調用transfer()方法,將元素直接交給消費者而不存儲在容器中.

public static void main(String[] args) {

    BlockingQueue synchronousQueue = new SynchronousQueue();

    // 啓動消費者線程,睡五秒後再來消費
    new Thread(() -> {
        try {
            TimeUnit.SECONDS.sleep(5);
            System.out.println(synchronousQueue.take());
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }).start();

    System.out.println(synchronousQueue.size()); // 輸出0

    // 啓動生產者線程,使用put()方法添加元素,其內部調用transfer()方法,會阻塞等待元素被消費
    new Thread(() -> {
        try {
            // synchronousQueue.add("product");	// SynchronousQueue容量爲0,調用add()方法會報錯
            synchronousQueue.put("product");    // put()方法內部調用transfer()方法會阻塞等待元素成功被消費
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }).start();
}

該程序的輸出行爲與上一程序類似,生產者線程調用put()方法後阻塞五秒直到消費者線程消費該元素.

SynchronousQueue應用場景: 網遊的玩家匹配: 若一個用戶登錄,相當於給服務器的消息隊列發送一個take()請求;若一個用戶準備成功,相當於給服務器的消息隊列發送一個put()請求.因此若玩家登陸但未準備好 或只有一個玩家準備好時遊戲線程都會阻塞,直到兩個人都準備好了,遊戲線程纔會被喚醒,遊戲繼續.

整理自馬士兵併發編程視頻:地址

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