??前言👀~
上一章我們介紹了線程池的一些基本概念,今天接著分享多線程的相關知識,這些屬于是面試比較常見的,大部分都是文本內容
?常見的鎖策略
樂觀鎖
悲觀鎖
輕量鎖
重量級鎖
自旋鎖
掛起等待鎖
可重入鎖和不可重入鎖
互斥鎖
讀寫鎖
公平鎖和非公平鎖
CAS(重要)
原子類
CAS操作的解釋
ABA問題
synchronized原理(重要)
鎖升級(重要)
鎖消除
鎖粗化
Callable 接口
ReentrantLock類
信號量semaphore
CountDownLatch
線程安全的集合類
多線程環境使用哈希表(重要)
Hashtable
ConcurrentHashMap
如果各位對文章的內容感興趣的話,請點點小贊,關注一手不迷路,講解的內容我會搭配我的理解用我自己的話去解釋如果有什么問題的話,歡迎各位評論糾正 🤞🤞🤞
個人主頁:N_0050-CSDN博客
相關專欄:java SE_N_0050的博客-CSDN博客??java數據結構_N_0050的博客-CSDN博客??java EE_N_0050的博客-CSDN博客
?常見的鎖策略
下面這些是"鎖的一種特點",是"一類鎖",不是一把具體的鎖。其實就是根據場景來描述出此時Synchronized鎖的特點或者說狀態
樂觀鎖
這兩個鎖就是字面意思,但都是對后續鎖沖突是否頻繁給出的預測,根據實際場景進行預測,最終目的都是為了保證線程安全,避免在并發場景下的資源競爭問題
樂觀鎖:預測接下來鎖沖突的概率小,就少做些工作稱為"樂觀鎖",樂觀鎖認為多個線程訪問同一個共享變量沖突的概率不大,線程可以不停地訪問數據無需加鎖也無需等待, 在訪問的同時識別當前的數據是否出現訪問沖突
樂觀鎖的實現:可以引入一個版本號,借助版本號識別出當前的數據訪問是否沖突也可以使用CAS?
例子:就像你有問題問老師,樂觀的人認為老師不忙肯定有時間,然后直接去找老師,老師如果確實沒空就回去,有空就直接問(雖然沒加鎖, 但是能識別出數據訪問沖突)
悲觀鎖
悲觀鎖:預測接下來鎖沖突的概率大,就多做些工作稱為"悲觀鎖",悲觀鎖認為多個線程訪問同一個共享變量沖突的概率較大, 會在每次訪問共享變量之前都去真正加鎖,這樣別的線程想拿這個數據就會阻塞直到上個線程解鎖
悲觀鎖的實現:就是先加鎖(比如借助操作系統提供的 mutex(互斥鎖)), 獲取到鎖再操作數據. 獲取不到鎖就等待
例子:就像你有問題問老師,悲觀的人認為老師比較忙,不一定有時間,然后你要發個消息給老師確認一下(相當于加鎖)
Synchronized 初始使用樂觀鎖策略,當發現鎖競爭比較頻繁的時候,就會自動切換成悲觀鎖策略
輕量鎖
下面兩個鎖和樂觀鎖和悲觀鎖有關系,只不過一個是預測鎖沖突的概率,一個是鎖的開銷以及效率問題
輕量級鎖:不涉及到內核態,開銷小執行速度快。樂觀鎖一般情況下也就是輕量級鎖,輕量級鎖所適應的場景是線程交替執行同步塊的情況,如果存在同一時間訪問同一鎖的情況,就會導致輕量級鎖膨脹為重量級鎖
重量級鎖
重量級鎖 :涉及大量的內核態用戶態切換,容易引發線程的調度,線程的調度這個操作需要線程上下文切換,開銷大執行速度比輕量級鎖慢。悲觀鎖一般情況下也就是重量級鎖
但是上面這種說法也不絕對只是說一般情況,比如synchronized 開始是一個輕量級鎖,如果鎖沖突比較嚴重, 就會變成重量級鎖
自旋鎖
下面兩個鎖又和重量級鎖和輕量級鎖有關系
我們之前說過多個線程爭取同一把鎖失敗后,會進入阻塞等待,等待操作系統調度,但實際上,大部分情況下搶鎖失敗,過一會鎖就釋放了沒必要就放棄 CPU資源,這個時候使用自旋鎖去解決這個問題
自旋鎖:自旋鎖是輕量級鎖的一種具體表現,是指當一個線程在獲取鎖的時候,如果鎖已經被其它線程獲取,那么該線程將循環等待(忙等),然后不斷的判斷鎖是否能夠被成功獲取,直到獲取到鎖才會退出循環但是如果自旋到一定程度會膨脹成重量級鎖。就類似舔狗天天舔,哪天女神分手了就有機會了
優點:因為沒有放棄cpu資源,如果鎖的占有時間短, 一旦鎖被釋放就能第一時間獲取到鎖, 更高效
缺點:因為沒有放棄cpu資源,如果鎖的占有時間長,就會浪費cpu資源
掛起等待鎖
掛起等待鎖:掛起等待鎖是重量級鎖的一種具體表現,是指當一個線程在獲取鎖失敗后,進入阻塞等待,此時被操作系統內核掛起就不占cpu資源了,等獲取鎖的線程釋放之后,喚醒當前線程然后再次進行爭奪
可重入鎖和不可重入鎖
之前講過,簡單提一下就是一個線程針對同一把鎖加鎖兩次,不會出現死鎖就是可重入鎖,否則就是不可重入鎖
互斥鎖
如果兩個線程爭取同一個鎖,不管兩個線程是要對數據進行讀還是寫,都會產生鎖沖突,synchronized 就是個典型的互斥鎖;加鎖就是單純的加鎖,進入代碼塊加鎖,出了代碼塊解鎖
讀寫鎖
多線程之間,數據的讀取方之間不會產生線程安全問題,但數據的寫入方互相之間以及和讀者之間都需要進行互斥。如果兩個線程爭取同一個鎖,就會產生鎖沖突。所以讀寫鎖因此而產生,讀寫鎖是把加鎖操作,分為讀鎖和寫鎖
ReadWriteLock是一個讀寫鎖,它允許多個線程同時讀共享數據,而寫操作則是互斥的,就是如果沒有線程沒有進行寫操作,那么多個線程就可以同時進行讀取,提高性能。因為我們知道多線程下同時讀取一個數據是沒有安全問題!
public class Test {public static void main(String[] args) {//創建讀寫鎖ReentrantReadWriteLock lock = new ReentrantReadWriteLock();//獲取寫加鎖lock.readLock().lock();//釋放讀鎖lock.readLock().unlock();//創建寫鎖lock.writeLock().lock();//釋放寫鎖lock.writeLock().unlock();}
}
讀寫鎖的約定:
1.讀鎖和讀鎖不會造成鎖沖突
2.讀鎖和寫鎖會造成鎖沖突
3.寫鎖和寫鎖也會造成鎖沖突
讀寫鎖最主要用在 "頻繁讀, 不頻繁寫" 的場景中,所以就是我們在讀操作多的場景,通過使用讀鎖,提高效率,因為兩個線程同用讀鎖不會造成鎖沖突,可以并發執行
公平鎖和非公平鎖
我們之前好像說過多個線程爭取一把鎖,一個線程拿到鎖后,其他線程進入阻塞等待,等這個線程解鎖后,后續的線程想要獲取這把鎖的概率是均等的
公平鎖:線程要想獲取同一把鎖,遵循先來后到的準則
非公平鎖:就和我開頭說的一樣,線程要想獲取同一把鎖,每個線程獲取這把鎖的概率是均等的
操作系統內部的線程調度就是隨機的,就屬于"非公平鎖",要想實現公平鎖需要使用隊列來記錄線程的先后順序
synchronized 屬于哪種鎖?(重要)
初始情況下,synchronized 會預測鎖沖突的概率高不高,如果不高則以樂觀鎖的模式運行也就是輕量級鎖,以自旋鎖的方式實現
初始情況下,synchronized 會預測鎖沖突的概率高不高,如果高則以悲觀鎖的模式運行也就是重量級鎖,以掛起等待鎖的方式實現
這個是不會變的,是可重入鎖,不是讀寫鎖,是非公平鎖
面試題:
1.你是怎么理解樂觀鎖和悲觀鎖的,具體怎么實現呢?
2.介紹下讀寫鎖?
3.什么是自旋鎖,為什么要使用自旋鎖策略呢,缺點是什么?
4.synchronized 是可重入鎖么?
CAS(重要)
compare and swap 比較交換的是內存和寄存器,多線程的一個經典操作,CAS的本質是為了賦值,就比如說內存A,寄存器B,寄存器C,內存A的值要和寄存器C的值進行交換。先將內存A的值和寄存器B的值進行比較,如果相同則進行交換然后返回true,如果不相同直接返回false。交換的本質就是為了把寄存器C的值賦給內存A,我們更關注內存A中的情況
CAS其實是一個cpu指令,這些比較交換的過程是由一個cpu指令完成的,也就是單個的cpu指令,所以是原子的!我們可以使用CAS完成一些操作,進一步替代加鎖。本來我們遇到的線程安全的問題,第一時間想到的是加鎖,但是呢鎖沖突嚴重的話會使其他線程進入阻塞等待,最終影響執行效率。此時引入CAS能保證操作的原子性,又沒有阻塞等待,所以在一定程度上可以替代加鎖。所以我們可以使用無鎖編程(基于CAS實現線程安全的方式),但是CAS代碼復雜不易理解,只適合特定的一些場景,如果在資源競爭激烈的情況下不如加鎖方式通用。CAS本質是咱們的硬件設備cpu提供的指令,被操作系統封裝提供成API,又被JVM封裝提供成API給我們進行使用
總結:例如count++這個指令在cpu上可以拆分多步所以不算原子操作,在多線程下容易引發安全問題,有兩種辦法解決一種是加鎖,另外一種則是CAS,這樣這個count++通過CAS操作在cpu就變為原子操作了,也就是使用CAS操作實現count++的原子性,這樣在硬件層面上,CAS 是由單個的 CPU 指令完成的,這意味著它是原子的,能夠避免競態條件。主要還是因為cpu上支持CAS操作,所以你可以這樣寫,如果不支持這樣寫就不能保證原子性了
原子類
比如之前說的count++這個操作分為三步,不屬于原子操作,在多線程中容易引發安全問題,通過原子類的方法可以完成一些自增自減的操作并且解決線程安全問題,AtomicInteger,基于CAS的方法對int進行封裝,此時++這個自增操作就基于CAS指令來完成,就屬于原子操作
public class Test {public static AtomicInteger count = new AtomicInteger();public static void main(String[] args) throws InterruptedException {Thread t1 = new Thread(() -> {for (int i = 0; i < 1000; i++) {count.getAndIncrement();}});Thread t2 = new Thread(() -> {for (int i = 0; i < 1000; i++) {count.getAndIncrement();}});t1.start();t2.start();t1.join();t1.join();System.out.println(count);}
}
輸出結果,符合預期說明保證了線程安全,通過使用原子類提供的方法
點進這個方法會看到unsafe,java中對于偏底層的操作會放到unsafe進行歸類,所以在使用的時候要注意
再進一步點進去可以看到源碼中的CAS操作
結論:其實是通過這個AtomicInteger類提供的方法實現的,原子類里面的方法是基于CAS操作實現的
CAS操作的解釋
理解CAS操作保證線程安全:解釋一下在多線程下下面的代碼的執行,t1線程初始value=0,然后進入方法,value進行賦值,oldvalue=0。然后t2線程初始value=0,然后進入方法,value進行賦值,oldvalue=0,進入while循環開始CAS操作,此時看作value看作是內存中的值,oldvalue看作是寄存器A的值,oldvalue+1看作是寄存器B的值,然后我們開始比較,內存中的值等于寄存器A中的值,然后內存中的和寄存器B中的值進行交換,此時CAS操作會返回true,接著判斷條件true!=true得出false循環結束,我們直接返回oldvalue也就是0。接著回到t1線程,此時內存中的值也就是value為1,寄存器B中的值為0,然后進行比較,不同返回false,接著判斷條件false!=true執行whlie循環里的代碼,進行賦值把value的值賦值給oldvalue,接著再次while循環判斷,此時內存中的值和寄存器B中的值相同,然后內存中的值和寄存器C中的值進行交換,返回true,判斷條件true!=true得出false循環結束。此時內存中的值就是2
前面說線程不安全的原因,是對同一個變量進行讀寫操作的時候,穿插執行導致的。CAS操作讓不同線程對同一變量進行讀寫操作的時候不穿插執行,核心的思路和加鎖類似,區別加鎖是讓其他線程進入阻塞等待避免穿插,CAS則是通過重試的方式避免穿插。并且不會引起線程切換和上下文切換的開銷,從而提高并發性能
ABA問題
屬于是CAS的一個關鍵問題,CAS進行操作的關鍵,是以值未發生變化作為其他線程沒有穿插執行的判斷依據,但是這種判斷不夠嚴謹會有些極端的情況
舉個例子描述這個問題,你要存款卡里100存個100,然后你點了兩下確定,然后創建了兩個線程,然后一個線程進行判斷,內存中和期望值相同進行交換,內存中的值更新為200說明100存進去了,但是這個時候你女朋友剛好花了100,內存中的值變成100了。然后輪到另外一個線程執行的時候和期望值一樣進行交互,內存中的值又更新為200,這時候就出現ABA問題了白嫖了
大部分情況下ABA問題沒什么事情,但是對于賬戶余額這種可能會出現極端的問題,有增有減就可能出現ABA問題,只增或只減問題不大
解決辦法:通過設置一個版本號,不僅要比較數值還要比較版本號,還是上面的例子,初始版本號為1,兩個線程執行存款操作,t1線程存款完成,并且版本號沒問題,版本號+1此時版本為2,接著就算有線程插入執行扣款我們版本再+1,此時版本號為3,接著輪到t2線程執行,即使錢是對的但是版本號不同就不執行存款操作了,此時數據就是正確的了
面試題:
1.講解下你自己理解的 CAS 機制
2.ABA問題怎么解決?
synchronized原理(重要)
鎖升級(重要)
JVM 將 synchronized 鎖分為 無鎖、偏向鎖、輕量級鎖、重量級鎖 狀態。會根據情況,進行依次升級,鎖升級過程是單向的,不能降級。無鎖->偏向鎖->自旋鎖->重量級鎖
解釋一下偏向鎖:如果直接加鎖效率會比較低并且有開銷,所以在鎖升級的過程中我們先嘗試升級到偏向鎖優化一下效率。偏向鎖不是真的 "加鎖", 只是給對象頭中做一個 "偏向鎖的標記", 記錄這個鎖屬于哪個線程,如果后續沒有其他線程來競爭該鎖, 那么就不用進行其他同步操作了(避免了加鎖解鎖的開銷),如果后續有其他線程來競爭該鎖(剛才已經在鎖對象中記錄了當前鎖屬于哪個線程了, 很容易識別當前申請鎖的線程是不是之前記錄的線程), 那就取消原來的偏向鎖狀態, 進入輕量級鎖狀態。偏向鎖是運行時的事情,運行時多個線程調度執行的情況不確定,這個線程的鎖可能有人競爭也可能沒有人競爭
偏向鎖這樣的思想和餓漢模式的實現挺像的,餓漢模式在創建實例的時候會先進行判斷當前實例有沒有被創建,如果被創建了直接返回實例,沒有則進行加鎖并且創建實例,和偏向鎖的道理差不多,以及記住不是加鎖了線程就安全了,例如單例模式中的餓漢模式你加了鎖還是會有線程安全的問題,還得加些邏輯來處理才能保證
鎖消除
編譯器在編譯過程觸發沒在運行時進行優化的手段,編譯器會針對你寫的加鎖代碼進行優化,它會判斷當前需不需要加鎖,如果不需要就直接就把鎖優化掉了,也就是你寫的synchronized,例如單個線程你加鎖,此時會給你優化掉,節省不必要的開銷。但是編譯器只有在把握很大的情況下,才會進行鎖消除
之前說過StringBuffer和StringBuilder,一個帶synchronized,一個不帶synchronized。在單線程的時候,編譯器就可以進行鎖消除了
鎖粗化
JVM對鎖進行優化
先理解鎖的粒度:synchronized里的代碼多鎖的粒度粗,代碼少鎖的粒度細
鎖的粒度細有一定的好處,能夠充分發揮多核cpu的性能,能夠并發執行的邏輯就更多。但是缺點就是如果遇到頻繁被加鎖和解鎖,多個線程也要獲取這把鎖會反復造成鎖沖。開銷會變大效率反而降低,不如鎖的粒度粗來的直接。就像是你跟別人說事情一樣,你本來可以一次性說完但是分多次說,別人恨不得扇你
實際上可能并沒有其他線程來搶占這個鎖,這種情況 JVM 就會自動把鎖粗化, 避免頻繁申請釋放鎖
Callable 接口
這個接口是創建線程的一種方式,Callable和Runnbale都是用來描述一個任務,Callable適用于線程在執行完一段邏輯后,并且帶返回值,Runnbale則不帶返回值
public class Test {public static void main(String[] args) throws ExecutionException, InterruptedException {Callable<Integer> callable = new Callable<Integer>() {@Overridepublic Integer call() throws Exception {int num = 0;for (int i = 0; i < 1000; i++) {num++;}return num;}};FutureTask<Integer> futureTask = new FutureTask<>(callable);Thread thread = new Thread(futureTask);thread.start();System.out.println(futureTask.get());}
}
Callable接口和FutureTask搭配使用,為什么要搭配FutureTask類?
例子:你去肯德基點餐(這里就是Callable),點完后給你一張小票(這里使用FutureTask來記錄這個任務的結果),工作人員那也有一張小票根據這種小票給你做餐(線程根據FutureTask執行任務),做完了,它會喊小票上的號碼,你在拿著你的小票去取餐,這個FutureTask的作用就是工作人員可以通過小票里的任務做餐,而你可以通過小票去獲取到任務執行完后的結果。要如果沒有小票,很多人都點餐,那通過什么方式取呢?
除了上面那種實現Callable接口還可以像下面這樣實現,使用lambda表達式
public class Test {public static void main(String[] args) throws ExecutionException, InterruptedException {FutureTask<Integer> futureTask = new FutureTask<>(() -> {int num = 0;for (int i = 0; i < 1000; i++) {num++;}return num;});Thread thread = new Thread(futureTask);thread.start();System.out.println(futureTask.get());}
}
面試題:
介紹下 Callable 是什么?
ReentrantLock類
和synchronized一樣都是可重入鎖,早期synchronized效果不咋滴,沒有像現在一樣被優化過,所以提供了其他鎖
優勢:
1.加鎖的時候有兩種方式,一種是lock和synchronized一樣如果鎖沖突激烈的情況,其他線程會進入阻塞等待。一種是trylock嘗試獲取鎖時,獲取不到直接放棄獲取,不會死等,有更多操作空間
public class Test1 {public static int count;public static void main(String[] args) throws InterruptedException {ReentrantLock lock = new ReentrantLock();Thread t1 = new Thread(() -> {lock.lock();for (int i = 0; i < 1000; i++) {count++;}lock.unlock();});Thread t2 = new Thread(() -> {lock.lock();for (int i = 0; i < 1000; i++) {count++;}lock.unlock();});t1.start();t2.start();t1.join();t2.join();System.out.println(count);}
}
public class Test1 {public static int count;public static void main(String[] args) throws InterruptedException {ReentrantLock lock = new ReentrantLock();Thread t1 = new Thread(() -> {try {lock.lock();for (int i = 0; i < 1000; i++) {count++;}} finally {lock.unlock();}});Thread t2 = new Thread(() -> {lock.lock();for (int i = 0; i < 1000; i++) {count++;}lock.unlock();});t1.start();t2.start();t1.join();t2.join();System.out.println(count);}
}
2.提供了公平鎖的實現,默認情況是非公平鎖,可以通過構造方法傳入參數進行設置為公平鎖
ReentrantLock 和 synchronized 的區別:
1.都實現了線程安全,都是可重入互斥鎖,默認情況是非公平鎖,但是ReentrantLock 可以設置為公平鎖
2.synchronized 是一個關鍵字,ReentrantLock 是標準庫中的一個類
3.synchronized 自動解鎖,ReentrantLock 需要手動解鎖
4.synchronized 獲取鎖失敗會死等,trylock嘗試獲取鎖時,獲取不到直接放棄獲取
信號量semaphore
信號量本質上就是一個計數器,用來表示 "可用資源的個數",所以開發中使用申請資源的場景,可以使用信號量來實現
拿停車場舉例子,停車場會有一個牌子實時記錄當前車位,車進入+1,車出去-1,信號量就是這個牌子,每次申請一個可用資源則+1,稱為P操作。每次取出一個可用資源則-1,稱為V操作。英語中這個P用acquire表示,這個V用release表示。這里的+1和-1的操作都是原子的,所以多線程下使用是線程安全的。當信號量數值為0的情況,線程會出現阻塞等待。什么意思呢?就像我們停車場沒車位,我們可以選擇等,也可以選擇找別的停車場,這里選擇的是等,下面有代碼演示
public class Test1 {public static void main(String[] args) throws InterruptedException {Semaphore semaphore = new Semaphore(3);semaphore.acquire();System.out.println("資源-1");semaphore.acquire();System.out.println("資源-1");semaphore.acquire();System.out.println("資源-1");semaphore.release();System.out.println("資源+1");semaphore.acquire();System.out.println("資源-1");semaphore.acquire();System.out.println("資源-1");}
}
輸出結果,進入阻塞等待,因為一共可獲得資源只有3個,在獲取第四個得時候會進入阻塞
還可以使用信號量來實現多線程對同一變量自增并且保證線程安全
public class Test2 {public static int count;public static void main(String[] args) throws InterruptedException {Semaphore semaphore = new Semaphore(1);Thread t1 = new Thread(() -> {for (int i = 0; i < 1000; i++) {try {semaphore.acquire();count++;} catch (InterruptedException e) {throw new RuntimeException(e);}semaphore.release();}});Thread t2 = new Thread(() -> {for (int i = 0; i < 1000; i++) {try {semaphore.acquire();count++;} catch (InterruptedException e) {throw new RuntimeException(e);}semaphore.release();}});t1.start();t2.start();t1.join();t2.join();System.out.println(count);}
}
輸出結果
CountDownLatch
下載一個文件,我們可以使用多線程來完成,把文件拆解成多個模塊,一個模塊交給一個線程去完成,讓這些任務并發執行,怎么判斷這些線程是否都完成了呢?就使用CountDownLatch去判斷這個任務的執行進度
countDown方法,用來通知CountDownLatch任務其中之一完成,await方法,使用這個方法會進入阻塞等待,等所有任務完成才會往下走,下面是代碼實現
public class Test1 {public static void main(String[] args) throws InterruptedException {CountDownLatch count = new CountDownLatch(10);for (int i = 0; i < 10; i++) {int finalI = i;Thread thread = new Thread(() -> {System.out.println("線程" + finalI);count.countDown();});thread.start();}count.await();System.out.println("結束");}
}
輸出結果
如果沒完成則進入阻塞等待,我就創建9個線程去完成任務,但是我設置了10個要完成的任務
public class Test1 {public static void main(String[] args) throws InterruptedException {CountDownLatch count = new CountDownLatch(10);for (int i = 0; i < 9; i++) {int finalI = i;Thread thread = new Thread(() -> {System.out.println("線程" + finalI);count.countDown();});thread.start();}count.await();System.out.println("結束");}
}
輸出結果
面試題:
線程同步的方式有哪些?
為什么有了 synchronized 還需要 juc 下的 lock?
AtomicInteger 的實現原理是什么?
信號量聽說過么?之前都用在過哪些場景下?
解釋一下 ThreadPoolExecutor 構造方法的參數的含義
線程安全的集合類
原來數據結構學的集合類,,大部分都不是線程安全的。vector、Stack、HashTable帶有synchronized 所以線程安全,所以要想在多線程下使用這些集合類,需要考慮線程安全的問題,可以選擇直接加鎖,這種比較常見適用范圍廣一些,標準庫也提供了一些搭配的組件保證線程安全
多線程環境使用 ArrayList:
1.可以直接加鎖synchronized或ReentrantLock
2.Collections.synchronizedList(new ArrayList);
synchronizedList 的關鍵操作上都帶有 synchronized,你創建出這個ArrayList相當于帶了一個synchronized,這樣使用的時候就可以保證線程安全了
3.CopyOnWriteArrayList:寫時拷貝
就是多線程情況下,有兩個線程使用ArrayList的時候可能會有讀有寫操作,對于讀操作沒什么問題,對于寫操作,我們直接拷貝一個ArrayList出來,一個線程要修改的話,就修改這個拷貝出來的ArrayList,另外一個線程讀的話還是讀之前的ArrayList,當另外一個線程修改完成后,把之前的ArrayList給覆蓋掉,也就是引用賦值。總結就是拷貝出一個ArrayList進行修改,沒修改完之前讀原本的ArrayList即可,修改完后把原先的ArrayList覆蓋掉就行了。總結就是有線程進行修改操作的時候,拷貝出一個ArrayList進行修改,在未修改完之前讀操作都是讀取之前的ArrayList,修改完成后覆蓋掉之前的ArrayList。在讀多寫少的場景下, 性能很高, 不需要加鎖競爭。但是不適合多個線程同時修改,不然可能會混淆,但是有缺點,如果裝的數據太多了,拷貝時間太久也占空間
特別適合服務器的配置更新,可以通過配置文件,來描述配置的詳細內容,這個內容不會很大。配置好的內容會被讀到內存中,然后由其他線程進行讀取,修改這個配置內容,只由一個線程進行修改。就比如我們修改了配置文件,通過某個命令讓服務器重新加載配置,可以使用寫時拷貝的方式,這樣效率也高也能保證線程安全
多線程環境使用哈希表(重要)
Hashtable
保證線程安全就是直接在一些關鍵的方法上加synchronized關鍵字,相當于給this加鎖也就是給Hashtable加了鎖,整個哈希表只有一把鎖,其他線程使用同一個Hashtable的話會出現鎖沖突,影響效率
ConcurrentHashMap
ConcurrentHashMap和HashMap的使用方法差不多
分段鎖:java 8之前是多個鏈表共用一把鎖,java 8之后則是ConcurrentHashMap基于分段鎖的方式下實現的,每個鏈表有獨立的鎖,以鏈表的頭節點作為鎖對象
改進:
1.不考慮擴容的情況下,操作不同鏈表的時候,此時線程是安全的。但是在操作同一個鏈表的時候容易出現線程安全的問題,所以操作不同鏈表不需要加鎖,操作同一鏈表的時候進行加鎖即可。所以就是每個鏈表擁有獨立的鎖(鏈表的頭節點作為鎖對象),降低鎖沖突的概率,提高效率
2.充分利用CAS特點,在一些只增只減的操作上,使用原子操作完成,比如我們會使用一個變量記錄hash表中的元素個數,這里就不用加鎖,使用CAS進行修改即可,又降低了鎖沖突的概率,提高了效率
3.針對讀操作沒有進行加鎖(但是使用了 volatile 保證從內存讀取結果),讀和讀之間以及讀和寫之間都沒有加鎖,進一步降低鎖沖突,來提高效率。對于讀和寫操作的時候,ConcurrentHashMap底層編碼中處理了一些細節,在修改的時候會避免使用非原子的操作,要進行修改的話使用的是原子的操作(=),所以進行讀的時候要么是之前讀的值,要么是讀到寫后的值
4.ConcurrentHashMap對擴容進行優化,HashMap和Hashtable在擴容的時候會把所有的元素都拷貝一遍,數據多的情況下時間會有些久,導致用戶體驗不好。ConcurrentHashMap則不采取一次性拷貝,分為多次進行拷貝,避免出現這樣的情況。擴容期間,新老數組同時存在,后續每個來操作 ConcurrentHashMap 的線程,都會參與拷貝的過程.,每個操作負責拷貝一小部分元素。如果要插入元素則插入新數組中,要刪除元素需要去查找當前元素在新數組還是老數組,要查元素則是新老數組同時查,拷貝完成后再將老數組刪除
面試題:
ConcurrentHashMap的讀是否要加鎖,為什么?
介紹下 ConcurrentHashMap的鎖分段技術?
ConcurrentHashMap在jdk1.8做了哪些優化?
Hashtable和HashMap、ConcurrentHashMap 之間的區別?
以上便是多線程進階的相關內容,多線程就講解到這了,進階內容大部分都是面試考的內容,所以好好理解理解,我們下一章再見💕