[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
實現而非加鎖實現的,其效率較高。
併發隊列ConcurrentLinkedQueue
的poll()
方法會嘗試從隊列頭中取出一個元素,若獲取不到,則返回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()
是原子性的,但是後面的if
與println
會打破原子性,但是這裏沒有問題,因爲poll()
後我們沒有對隊列進行修改操作(比如前面的remove
),不會出現重複和超額銷售。
三、併發容器
1. Map
主要的非併發容器有HashMap
、TreeMap
、LinkedHashMap
。
主要的併發容器有HashTable
、SynchronizedMap
、ConcurrentMap
。
HashTable
和SynchronizedMap
的效率較低,其同步的實現原理類似,都是給容器的所有方法都加鎖。其中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
低併發隊列有Vector
和SynchronizedList
,其中vector
類似HashTable
;SynchronizedList
類似SynchronizedMap
使用裝飾器模式,其構造函數接受一個List
實現類並返回同步List
,在java.util.Collections
包下。它們實現同步的原理都是將所有方法用同步代碼塊包裹起來.
高併發隊列分爲阻塞隊列BlockingQueue
和非阻塞隊列ConcurrentLinkedQueue
,其中阻塞隊列的常用實現類有LinkedBlockingQueue
、ArrayBlockingQueue
、DelayedQueue
、TransferQueue
、SynchronousQueue
;非阻塞隊列使用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()
方法執行錯誤操作會返回false
或null
,並放棄當前錯誤操作,不拋出異常。 - 一直阻塞: 若使用
put()
,take()
方法執行錯誤操作,當前線程會一直阻塞直到條件允許才喚醒線程執行操作. - 阻塞一段時間: 若使用
offer()
,poll()
方法並傳入時間單位,會將當前方法阻塞一段時間,若阻塞時間結束後仍不滿足條件則返回false
或null
,並放棄當前錯誤操作,不拋出異常。
3.2 經典阻塞隊列LinkedBlockingQueue和ArrayBlockingQueue
LinkedBlockingQueue
和ArrayBlockingQueue
是阻塞隊列的最常用實現類,用來更容易地實現生產者/消費者模式.
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
,向其中添加元素的方法除了BlockingQueue
的add()
,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()
請求.因此若玩家登陸但未準備好 或只有一個玩家準備好時遊戲線程都會阻塞,直到兩個人都準備好了,遊戲線程纔會被喚醒,遊戲繼續.
整理自馬士兵併發編程視頻:地址