文章目錄
- 前言
- 一、CAS
- 1.1 CAS的概念
- 1.2 原子類
- 1.3 CAS的ABA問題
- 二、JUC中常用類
- 2.1 Callable接口
- 2.2 ReentrantLock(可重入)
- 2.3 Semaphore信號量
- 2.4 CountDownLatch類
- 2.5 CopyOnWriteArrayList類
- 2.6 ConcurrentHashMap
前言
對于多線程進階的部分,更多總結的就是面試常考,但是工作中開發中不常用到的知識。
一、CAS
1.1 CAS的概念
CAS就是compare and swap的首字母縮寫,意味著比較和交換,這樣的一條指令即可完成比較和交換這一套操作,也就是說這套操作是原子的。
我們可以將CAS的流程想象成一個方法。
這里的交換其實思想上更偏向于賦值,因為一般更關注于內存地址address中的內容而不關心寄存器reg2中的內容,所以就可以近似說這里的操作就是將reg2的值賦給了address地址。
CAS一般就是cpu中的一條指令,所以操作系統為了使用它完成這樣的操作就需要去提供這樣的CAS的api。然后JVM又對這樣的api進行了封裝,使得我們在java中也能夠使用CAS操作了。但是實際上這樣的CAS操作被封裝到了“unsafe”包當中,就是提醒大家容易出錯,不鼓勵直接使用CAS。
1.2 原子類
Java當中也有一些類對CAS進行了進一步的封裝,就比如說原子類。
如上圖的AtomicInteger就相當于對int進行了封裝,對于它的++或者–操作都是原子的,實例代碼如下:
package thread;import java.util.concurrent.atomic.AtomicInteger;public class Demo41 {public static AtomicInteger count = new AtomicInteger(0);public static void main(String[] args) throws InterruptedException {Thread t = new Thread(() -> {for (int i = 0; i < 50000; i++) {//count++ 這里的對count的修改都是原子的count.getAndIncrement();//++count//count.incrementAndGet();//--count//count.decrementAndGet();//count--//count.getAndDecrement();//count+=10;//count.getAndAdd(10);}});Thread t1 = new Thread(() -> {for (int i = 0; i < 50000; i++) {count.getAndIncrement();}});t.start();t1.start();t.join();t1.join();System.out.println(count);}
}
這里的多線程代碼就是經典的兩個線程兩個循環來計算count值,因為這里的count使用到了原子類的方法,所以加一操作是原子性的,自然不存在線程安全的問題,也能夠得到正確結果。
那么使用這種原子性操作的意義是什么呢?意義就在于效率,因為鎖是一個很重量級的操作,如果操作沒有原子性在多線程的情況下就要加鎖,但是我們可以使用CAS從而不去使用鎖,從而提高代碼效率。這一套基于CAS不加鎖實現線程安全代碼的方式,也被稱為“無鎖編程”。但是CAS這種方法也就僅僅適用于少數場景。
1.3 CAS的ABA問題
屬于CAS的一個重要注意事項,CAS的核心就是“比較-發現相等-交換”->發現相等即數據沒發生任何改變,但是相等不等于沒改變過。可能值經歷了一個從A到B再到A的過程,這種情況在極端環境下會產生問題。
如上圖取款操作,假如我們要取500,情急之下,我們多按了兩次取款按鈕,此時產生了兩個線程來進行扣款操作,但是如果在此時別人給你轉了500,那么就會出現問題了。
如圖左邊是t1線程,右邊是t2線程,t2線程完成扣款五百之后,此時t3線程給賬戶又轉了500,此時應該不成立的t1線程的判斷又成立了,導致又完成一次扣款。上述的過程就是典型的ABA問題所造成的bug,是非常極端的情況。
如何去避免這樣的問題呢?可以約定一個版本號,每次進行扣款或存款都更新版本號,如果版本號沒有改變數據就一定沒變過。
通過版本號約束就可以避免這里的ABA問題,避免多次扣款。即使t3線程仍然給賬戶匯了500,但是此時版本號已經是2了,所以t1線程的版本號對不上,方法內部的扣款操作無法完成,所以即使有兩個線程去扣款,扣的款也只有500。
二、JUC中常用類
JUC是java.util.concurrent這個包的首字母,在這里介紹一下這個包當中的常用類。
2.1 Callable接口
我們都知道Runnable接口用來表示一個待執行的任務,Callable接口和Runnable也是相似的,他也是用來表示一個待執行的任務,但是Callable有返回值,表示這個線程執行結束要得到的結果是啥。
public class Demo42 {private static int count = 0;public static void main(String[] args) throws InterruptedException, ExecutionException {//使用Runnable來求出1~100的和Thread t = new Thread(new Runnable() {@Overridepublic void run() {int result = 0;for (int i = 1; i <= 100; i++) {result += i;}//需要用成員變量來接收值 主線程和t線程的耦合程度高 如果有多個這樣的線程就不方便了count = result;}});
以上給出了一段代碼,就是使用類變量count來得到線程結果,這樣的代碼等線程多了之后很不方便,代碼不夠優雅。Callable就是用來解決上述代碼的問題的。接下來給出全部代碼用于對比:
package thread;import java.util.concurrent.Callable;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.FutureTask;public class Demo42 {private static int count = 0;public static void main(String[] args) throws InterruptedException, ExecutionException {//使用Runnable來求出1~100的和Thread t = new Thread(new Runnable() {@Overridepublic void run() {int result = 0;for (int i = 1; i <= 100; i++) {result += i;}//需要用成員變量來接收值 主線程和t線程的耦合程度高 如果有多個這樣的線程就不方便了count = result;}});// t.start();// t.join();// System.out.println(count);// Callable和Runnable很相似 但是Runnable可以返回計算的值Callable<Integer> callable = new Callable<Integer>() {@Overridepublic Integer call() throws Exception {int result = 0;for (int i = 1; i <= 100; i++) {result += i;}return result;}};// futuretask這個類用來包裝callable這個類 這樣callable就可以直接放入線程FutureTask<Integer> futureTask = new FutureTask<>(callable);Thread t2 = new Thread(futureTask);t2.start();//從future獲取線程啟動通過callable計算得到的值t2.join();System.out.println(futureTask.get());}}
如以上代碼,Callable接口需要使用FutureTask來包裝,包裝之后就將FutureTask對象放入線程,線程執行完成之后就可以通過FutureTask對象來得到線程執行的結果。
2.2 ReentrantLock(可重入)
在以前的JDK中,synchronized還沒現在那么好用,那時ReentrantLock還是非常有市場的。但是隨著版本的迭代,synchronized越來越強,基本上需要加鎖的時候無腦使用synchronized大概率不會出問題。那么ReentrantLock現在還有什么價值?
(1)ReentrantLock實現了公平鎖
這里代碼中的參數寫true就是公平鎖,false就是非公平鎖。
(2)ReentrantLock提供了tryLock操作,給加鎖提供了更多的操作空間。
嘗試加鎖,如果該鎖已經被獲取了,那么就直接失敗返回,不會繼續等待。tryLock還有一個類似版本就是可以指定等待的時間,超時后返回。
(3)synchronized搭配wait以及notify的等待通知機制,ReentrantLock搭配Condition類完成等待通知。
Condition類比wait以及notify強一點。(多個線程wait,notify喚醒隨機一個。Condition指定線程喚醒)
2.3 Semaphore信號量
信號量是一個非常簡單的概念,就是一個計數器,描述了可用資源的數目。圍繞信號量有兩個操作,P操作,計數器減一,申請資源,V操作,計數器加一,釋放資源。提出信號量的是荷蘭人,PV是荷蘭語的首字母,在英語中是acquire就是獲取,以及release表示釋放。代碼示例如下:
package thread;import java.util.concurrent.Semaphore;public class Demo44 {public static void main(String[] args) throws InterruptedException {// 四個可用資源 P申請資源 V釋放資源Semaphore semaphore = new Semaphore(4);semaphore.acquire(1);System.out.println("P操作");semaphore.acquire(1);System.out.println("P操作");semaphore.acquire(1);System.out.println("P操作");semaphore.acquire(1);System.out.println("P操作");// 此時信號量的四個資源已經被申請完了// 如果繼續申請的話就會堵塞 因為要等別的線程釋放信號量的資源semaphore.acquire(1);}}
以上代碼信號量擁有四個單位的資源,然后通過acquire方法來申請資源,當資源被申請完并且沒有資源釋放時,再次申請資源就會阻塞。當設置信號量資源為一個單位,則信號量取值只能為1或者0,此時的信號量可以當成鎖來使用。代碼示例如下:
package thread;import java.util.concurrent.Semaphore;public class Demo45 {public static int count = 0;public static void main(String[] args) throws InterruptedException {//設置 1 0 信號量Semaphore semaphore = new Semaphore(1);Thread t1 = new Thread(() -> {try {for (int i = 0; i < 50000; i++) {semaphore.acquire(1);count++;semaphore.release();}} catch (InterruptedException e) {throw new RuntimeException(e);}});Thread t2 = new Thread(() -> {try {for (int i = 0; i < 50000; i++) {semaphore.acquire(1);count++;semaphore.release();}} catch (InterruptedException e) {throw new RuntimeException(e);}});t1.start();t2.start();t1.join();t2.join();System.out.println(count);}
}
以上代碼其實還是多線程代碼的經典例子,使用兩個線程來計算累加值。當t1進行count加一的操作時,它已經申請了唯一的信號量資源,此時如果t2線程也想進行count加一就必須先執行申請信號量資源的操作,此時就會阻塞,只有當t1線程的count++執行結束之后釋放資源,t2線程才能繼續執行,這就實現了count++操作的原子性,從而避免線程安全問題。
2.4 CountDownLatch類
相對來說比較實用的工具類,當我們把一個任務分為多個時,就可以通過這個工具類來識別任務是否整體執行完畢了。代碼示例如下:
package thread;import java.util.concurrent.CountDownLatch;public class Demo46 {public static void main(String[] args) throws InterruptedException {CountDownLatch latch = new CountDownLatch(10);for (int i = 0; i < 10; i++) {int temp = i;Thread t = new Thread(() -> {System.out.println("線程啟動:" + temp);//當作任務try {Thread.sleep(3000);} catch (InterruptedException e) {throw new RuntimeException(e);}System.out.println("線程結束:" + temp);latch.countDown();});t.start();}//等待所有線程中的任務結束latch.await();System.out.println("所有線程結束。");}}
這段代碼中我們給CountDownLatch類對象的參數為10,并且創建10個線程去執行任務,并且在每個線程中使用countDown方法,countDown方法相當于計數,當一個線程結束就會加一,latch.await()方法就會等待所有線程執行結束,當countDown方法累加的數等于初始化CountDownLatch對象的參數時await方法就會停止等待,整個代碼就運行結束了。這里代碼中CountdownLatch對象的參數和線程數相等,并且每個線程都放了countDown方法,所以所有線程運行結束await方法也就不等了。
2.5 CopyOnWriteArrayList類
ArrayList,LinkedList,Stack,Queue,HashMap…在多線程下使用集合類需要注意線程安全問題。Vector自帶了synchronized,Stack繼承自Vector所以也有synchronized,HashTable也是自帶synchronized。但是需要注意一點,加鎖不代表就是線程安全的,不加鎖也不能確定線程就是一定不安全的,需要具體代碼具體分析。
在我們使用未加鎖的類時需要手動進行加鎖,這樣是比較麻煩的,標準庫提供了一些其它的解決方案,如下圖。
通過這樣的操作,給ArrayList這些集合類套一層殼,就是給一些關鍵方法加上了synchronized,使得ArrayList達到Vector那樣的效果。
CopyOnWriteArrayList類也是一種解決線程安全問題的方法。
如果當前有多個線程讀列表上的數據,那么不需要做任何處理。如果某個線程對上面的數據進行修改,此時另一個線程進行讀取,那么很可能會讀到200 3這樣的中間情況。CopyOnWriteArrayList這樣的類就是一種寫時拷貝,在你對列表進行修改時會開辟新空間在新空間上進行修改,你要讀取數據那么就在舊空間進行讀取,當修改完成后將新的列表的引用代替舊的引用,舊的空間就可以釋放了。這樣的過程沒有任何的加鎖和阻塞,也能保證線程讀不到錯誤的數據。
這種方法的思想應用的很廣,例如顯卡渲染畫面到顯示器,顯示的動態效果其實就是很多張圖片,由于顯卡渲染足夠快這些圖片就能融合在一起,看到動畫效果。實際上就是寫時拷貝,在顯示上一個畫面的時候,在背后的額外空間生成下一個畫面,生成完畢了用下一個畫面代替上一個畫面。
2.6 ConcurrentHashMap
我們知道HashMap是線程不安全的,HashTable是帶鎖的,是否是線程安全的?事實上并不推薦使用這個,標準庫提供了更好的替代也就是ConcurrentHashMap。
HashTable加鎖就是簡單粗暴的給每個方法加了synchronized,就相當于針對this加鎖,只要針對HashTable上的元素進行操作,就都會涉及到鎖沖突。
ConcurrentHashMap做出了以下優化:
(1)使用鎖桶的方式來代替一把全局鎖,有效降低沖突概率。
這一點很好理解,如果有兩個線程針對兩個不同的鏈表進行操作,那么它們之間是不會產生鎖沖突的。本身兩個線程修改的是不同的鏈表,也沒涉及到“公共變量”,所以不涉及線程安全問題。這個提升是非常大的,因為一個哈希表上的桶非常多,桶之間發生沖突的概率非常小,并且synchronized我們前面的博客也講過了,只要不發生沖突synchronized只是加了一個偏向鎖,就類似一個標記,消耗非常小。
(2)對于哈希表的size即使你修改的不同鏈表/桶,但是你在多線程的情況下也會涉及到多個線程修改一個公共變量的問題,在ConcurrentHashMap中對于size的修改就是使用CAS這種具有原子性的語句來完成,這樣不僅避免了加鎖這種重量級的操作,也解決了線程安全的問題。
(3)針對擴容進行了特殊優化。
如果發現負載因子太大了,那么就需要擴容,然而擴容又是比較低效的操作,普通的HashMap要在一次put的過程中完成整個擴容過程,就會使得put操作非常卡。ConcurrentHashMap就會在擴容的時候整出另外的一份空間,每次進行哈希表的基本操作都會將一部分擴容之前空間的數據搬到新空間,不是一口氣搬完而是分多次,在搬的過程中如果是插入操作就將新數據插入到新空間,刪除操作,新舊空間都進行刪除,查找操作,新舊空間都要查找。
另外值得一提的是,在java8之前ConcurrentHashMap是基于分段鎖的形式進行實現的,就是引入多個鎖對象,每個鎖對象去管理若干個哈希桶。相比于HashTable這個方法是進化,但是還是不如直接鎖桶,后面就把這個方法給廢棄了。