Java多線程之CAS算法實(shí)現(xiàn)線程安全
前言
對(duì)于線程安全,我們有說(shuō)不盡的話題。大多數(shù)保證線程安全的方法是添加各種類(lèi)型鎖,使用各種同步機(jī)制,用限制對(duì)共享的、可變的類(lèi)變量并發(fā)訪問(wèn)的方式來(lái)保證線程安全。文本從另一個(gè)角度,使用“比較交換算法”(CompareAndSwap)實(shí)現(xiàn)同樣的需求。我們實(shí)現(xiàn)一個(gè)簡(jiǎn)單的“?!保⒅鸩街貥?gòu)代碼來(lái)進(jìn)行講解。
本文通俗易懂,不會(huì)涉及到過(guò)多的底層知識(shí),適合初學(xué)者閱讀(言外之意是各位大神可以繞道了)。
旅程開(kāi)始
1.先定個(gè)小目標(biāo),實(shí)現(xiàn)一個(gè)“?!?/strong>
“?!保╯tack)是大家經(jīng)常使用的抽象數(shù)據(jù)類(lèi)型(啥?!不知道,請(qǐng)自行百度)?!皸!睗M足“后進(jìn)先出”特性。我們用鏈表數(shù)據(jù)結(jié)構(gòu)完成一個(gè)簡(jiǎn)單的實(shí)現(xiàn):
public class Stack<E> { //鏈表結(jié)構(gòu)頭部節(jié)點(diǎn) private Node<E> head; /** * 入棧 * @param item */ public void push(E item) { //為新插入item創(chuàng)建一個(gè)新node Node<E> newHead = new Node<>(item); if(head!=null){ //將新節(jié)點(diǎn)的下一個(gè)節(jié)點(diǎn)指向原來(lái)的頭部 newHead.next = head; } //將頭部指向新的節(jié)點(diǎn) head=newHead; } /** * 出棧 * @return */ public E pop() { if(head==null){ //當(dāng)前鏈表為空 return null; } //暫存當(dāng)前節(jié)點(diǎn)。 Node<E> oldHead=head; //將當(dāng)前節(jié)點(diǎn)指向當(dāng)前節(jié)點(diǎn)的下一個(gè)節(jié)點(diǎn) head=head.next; //從暫存的當(dāng)前節(jié)點(diǎn)記錄返回?cái)?shù)據(jù) return oldHead.item; } /** * 鏈表中的節(jié)點(diǎn) * @param <E> */ private static class Node<E> { //節(jié)點(diǎn)保存的數(shù)據(jù) public final E item; //指向下一個(gè)鏈表中下一個(gè)節(jié)點(diǎn) public Node<E> next; public Node(E item) { this.item = item; } } }
代碼使用鏈表數(shù)據(jù)結(jié)構(gòu)實(shí)現(xiàn)“?!?,在Stack中維護(hù)一個(gè)鏈表的“頭部節(jié)點(diǎn)”,通過(guò)對(duì)頭部節(jié)點(diǎn)的操作完成入棧和出棧操作。
我們運(yùn)行代碼測(cè)試一下:
public static void main(String[] args) { Stack<Integer> stack=new Stack<>(); for (int i = 0; i < 3; i++) { //入棧1、2、3 stack.push(i+1); } for (int i = 0; i < 3; i++) { //出棧3、2、1 System.out.println(stack.pop()); } }
結(jié)果為:
3 2 1
我們使用入棧方法向Stack插入1、2、3,使用出棧方法打印為3、2、1,符合預(yù)期。
2.讓多線程搗搗亂
前面我們已經(jīng)測(cè)試過(guò)我們的方法,符合我們對(duì)Stack功能的預(yù)期,那是不是任何情況先我們的“?!倍寄苷9ぷ髂??
我們運(yùn)行如下代碼:
public static void main(String[] args) { Stack<Integer> stack=new Stack<>(); int max=3; Thread[] threads=new Thread[max]; for (int i = 0; i < max; i++) { int temp=i; //入棧1、2、3 Thread thread=new Thread(new Runnable() { @Override public void run() { stack.push(temp+1); } }); thread.start(); threads[temp]=thread; } //等待所有線程完成。 for (int i = 0; i < max; i++) { try { threads[i].join(); } catch (InterruptedException e) { } } for (int i = 0; i < max; i++) { //出棧3、2、1 System.out.println(stack.pop()); } }
你可能運(yùn)行了很多次,每次運(yùn)行時(shí)除了打印順序(3、2、1或2、3、1或1、2、3)有變化之外也沒(méi)有發(fā)現(xiàn)其他異常,你可能會(huì)說(shuō)打印順序變化很正常呀,因?yàn)槲覀兊膶⑷霔2僮鞣诺疆惒骄€程中操作,三個(gè)線程的執(zhí)行過(guò)程由系統(tǒng)調(diào)度,所以入棧操作的內(nèi)容自然每次都有可能不同。
好吧,你說(shuō)的沒(méi)錯(cuò),至少?gòu)拇罅窟\(yùn)行的結(jié)果上看是這樣的,但是這就是多線程編程的奇(tao)幻(yan)之處,也許你運(yùn)行一次沒(méi)有問(wèn)題,兩次沒(méi)有問(wèn)題,一萬(wàn)次也沒(méi)有問(wèn)題,但是終有一次你會(huì)得到那個(gè)意想不到的結(jié)果(你也不想得到,因?yàn)槟鞘莃ug)。這就像一個(gè)“黑天鵝事件”,小概率但是一定會(huì)發(fā)生,且發(fā)生后對(duì)你的系統(tǒng)影響不堪設(shè)想。
下面讓我?guī)憧纯慈绾蔚玫揭饬现獾慕Y(jié)果:
我們使用調(diào)試模式運(yùn)行上面的程序在Stack中push()方法第一行打一個(gè)斷點(diǎn),然后按照表格中的順序切換不同的線程以單步調(diào)試(step over)方式運(yùn)行run方法中的每一步,直到遇到Resume。
執(zhí)行順序 | thread-0 | thread-1 | thread-2 |
---|---|---|---|
1 | Node<E> newHead = new Node<>(item); | -- | -- |
2 | head=newHead; | -- | -- |
3 | (Resume) | -- | -- |
4 | -- | Node<E> newHead = new Node<>(item); | -- |
5 | -- | -- | Node<E> newHead = new Node<>(item); |
6 | -- | newHead.next = head; | -- |
7 | -- | -- | newHead.next = head; |
8 | -- | head=newHead; | -- |
9 | -- | -- | head=newHead; |
10 | -- | (Resume) | |
11 | -- | -- | (Resume) |
當(dāng)你再次看到打印結(jié)果,你會(huì)發(fā)現(xiàn)結(jié)果為3、1、null,“黑天鵝”出現(xiàn)了。
異常結(jié)果是如何產(chǎn)生的?
1.當(dāng)thread-0執(zhí)行到順序3時(shí),head表示的鏈表為node(1)。
2.當(dāng)thread-1執(zhí)行到順序10時(shí),head表示的鏈表為node(2)->node(1)。
3.當(dāng)thread-2執(zhí)行到順序11時(shí),head表示的鏈表為node(3)->node(1)。
當(dāng)三個(gè)線程都執(zhí)行完畢之后,head的最終表示為node(3)->node(1),也就是說(shuō)thread-2將thread-1的執(zhí)行結(jié)果覆蓋了。
語(yǔ)句newHead.next = head;
是對(duì)頭部節(jié)點(diǎn)的讀取。語(yǔ)句head=newHead
;是對(duì)頭部節(jié)點(diǎn)的寫(xiě)入操作。這兩條語(yǔ)句組成了一個(gè)“讀取——設(shè)置——寫(xiě)入”語(yǔ)句模式(就像n=n+1)。
如果一個(gè)線程執(zhí)行了共享頭部變量讀取語(yǔ)句,切換其他線程執(zhí)行了修改共享變量的值,再切回到第一個(gè)線程后,第一個(gè)線程中修改頭部結(jié)點(diǎn)的數(shù)據(jù)就不是最新的數(shù)據(jù)為依據(jù)的,所以修改之后其他線程的修改就被覆蓋了。
只有保證這兩條語(yǔ)句及中間語(yǔ)句以原子方式執(zhí)行,才能避免多線程覆蓋問(wèn)題。
大家可以任意調(diào)整代碼中讀取頭部節(jié)點(diǎn)和寫(xiě)入頭部節(jié)點(diǎn)的調(diào)試順序,制造多線程交錯(cuò)讀寫(xiě)觀察不同的異常結(jié)果。
為什么我們直接執(zhí)行無(wú)法看到異常結(jié)果呢?
因?yàn)槲覀兊膔un方法很簡(jiǎn)單,在CPU分配的時(shí)間片內(nèi)能運(yùn)行完,沒(méi)有出現(xiàn)在不同的運(yùn)行周期中交錯(cuò)運(yùn)行的狀態(tài)。所以我們才要用調(diào)試模式這種交錯(cuò)運(yùn)行。
為什么上文中我說(shuō)過(guò)這種異常一定會(huì)發(fā)生?
原因在于我們?cè)赟tack類(lèi)中對(duì)共享的、可變的變量head進(jìn)行的多線程讀寫(xiě)操作。
怎么才能保證類(lèi)Stack在多線程情況下運(yùn)行正確?
引用一段《JAVA并發(fā)編程實(shí)踐》中的話:
無(wú)論何時(shí),只要有多于一個(gè)的線程訪問(wèn)給定的狀態(tài)變量,而且其中某個(gè)線程會(huì)寫(xiě)入該變量,此時(shí)必須使用同步來(lái)協(xié)調(diào)線程對(duì)該變量的訪問(wèn)。
好吧,看來(lái)我們必須采用“同步”方法了,來(lái)保障我們的Stack類(lèi)在多線程并行和單線程串行的情況下都有正確的結(jié)果,也就是說(shuō)將Stack變成一個(gè)線程安全的類(lèi)。
3.讓你搗亂,請(qǐng)家長(zhǎng)!
既然多線程總來(lái)?yè)v亂,我們就請(qǐng)他的家長(zhǎng),讓家長(zhǎng)管管他,守守規(guī)矩,不在搗亂。
我們已經(jīng)知道了Stack類(lèi)問(wèn)什么不能再多線程下正確的運(yùn)行的原因,所有我們要限制多線程對(duì)Stack類(lèi)中head變量的并發(fā)寫(xiě)入,Stack方法中push()和pop()方法都會(huì)對(duì)head進(jìn)行寫(xiě)操作,所以要限制這兩個(gè)方法不能多線程并發(fā)訪問(wèn),所以我們想到了synchronized
關(guān)鍵字。
程序重構(gòu):
public class SynchronizedStack<E> { //鏈表結(jié)構(gòu)頭部節(jié)點(diǎn) private Node<E> head; /** * 入棧 * @param item */ public synchronized void push(E item) { //為新插入item創(chuàng)建一個(gè)新node Node<E> newHead = new Node<>(item); if(head!=null){ //將新節(jié)點(diǎn)的下一個(gè)節(jié)點(diǎn)指向原來(lái)的頭部 newHead.next = head; } //將頭部指向新的節(jié)點(diǎn) head=newHead; } /** * 出棧 * @return */ public synchronized E pop() { if(head==null){ //當(dāng)前鏈表為空 return null; } //暫存當(dāng)前節(jié)點(diǎn)。 Node<E> oldHead=head; //將當(dāng)前節(jié)點(diǎn)指向當(dāng)前節(jié)點(diǎn)的下一個(gè)節(jié)點(diǎn) head=head.next; //從暫存的當(dāng)前節(jié)點(diǎn)記錄返回?cái)?shù)據(jù) return oldHead.item; } /** * 鏈表中的節(jié)點(diǎn) * @param <E> */ private static class Node<E> { //節(jié)點(diǎn)保存的數(shù)據(jù) public final E item; //指向下一個(gè)鏈表中下一個(gè)節(jié)點(diǎn) public Node<E> next; public Node(E item) { this.item = item; } } }
將Stack
類(lèi)替換為SynchronizedStack
類(lèi)的測(cè)試方法。
public static void main(String[] args) { SynchronizedStack<Integer> stack=new SynchronizedStack<>(); int max=3; Thread[] threads=new Thread[max]; for (int i = 0; i < max; i++) { int temp=i; //入棧1、2、3 Thread thread=new Thread(new Runnable() { @Override public void run() { stack.push(temp+1); } }); thread.start(); threads[temp]=thread; } //等待所有線程完成。 for (int i = 0; i < max; i++) { try { threads[i].join(); } catch (InterruptedException e) { } } for (int i = 0; i < max; i++) { //出棧3、2、1 System.out.println(stack.pop()); } }
我們?cè)俅芜\(yùn)行第二章為多線程準(zhǔn)備的測(cè)試方法,發(fā)現(xiàn)當(dāng)執(zhí)行一個(gè)線程的方法時(shí),其他線程的方法均被阻塞,只能等到第一個(gè)線程方法執(zhí)行完成之后才能執(zhí)行其他線程方法。
我們只不過(guò)是在push()
和pop()
方法上加入了synchronized
關(guān)鍵字,就將這兩個(gè)方法編程了同步方法,在多線程并發(fā)的情況下也如同單線程串行調(diào)用一般,方法再不能在線程間交替運(yùn)行,也就不能對(duì)head變量做并發(fā)更改了,這樣修改的Stack類(lèi)就是線程安全的了。
除了synchronized關(guān)鍵字,還有其他的方式實(shí)現(xiàn)加鎖嗎?
除了synchronized
關(guān)鍵字還可以使用java.util.concurrent.locks
包中各種鎖來(lái)保證同步,但是大概思路都是相同的,都是使用阻塞其他線程的方式在達(dá)到防止并發(fā)寫(xiě)入的目的。
阻塞線程是否會(huì)影響執(zhí)行效率?
如果和不加通過(guò)的“?!鳖?lèi)相比,在多線程執(zhí)行的之后效率一定會(huì)有影響,因?yàn)橥椒椒ㄏ拗屏司€程之間的并發(fā)性,但是為了保證“?!鳖?lèi)的在多線程環(huán)境時(shí)功能正確,我們不得不做出效率和正確性的權(quán)衡。
必須要對(duì)整個(gè)方法加上鎖嗎?
我們上面已經(jīng)分析了需要加鎖的范圍,只要保證讀取頭部節(jié)點(diǎn)和寫(xiě)入頭部節(jié)點(diǎn)之間的語(yǔ)句原子性就可以。所以我們可以這樣執(zhí)行。
/** * 入棧 * * @param item */ public void push(E item) { //為新插入item創(chuàng)建一個(gè)新node Node<E> newHead = new Node<>(item); synchronized (this) { if (head != null) { //將新節(jié)點(diǎn)的下一個(gè)節(jié)點(diǎn)指向原來(lái)的頭部 newHead.next = head; } //將頭部指向新的節(jié)點(diǎn) head = newHead; } } /** * 出棧 * * @return */ public E pop() { synchronized (this) { if (head == null) { //當(dāng)前鏈表為空 return null; } //暫存當(dāng)前節(jié)點(diǎn)。 Node<E> oldHead = head; //將當(dāng)前節(jié)點(diǎn)指向當(dāng)前節(jié)點(diǎn)的下一個(gè)節(jié)點(diǎn) head = head.next; //從暫存的當(dāng)前節(jié)點(diǎn)記錄返回?cái)?shù)據(jù) return oldHead.item; } }
通過(guò)synchronized
塊實(shí)現(xiàn),因?yàn)榉椒ū容^簡(jiǎn)單,所以也沒(méi)有很明顯的縮小加鎖范圍。
除了加鎖的方式,是否還有其他方式?
當(dāng)然,我們還有無(wú)鎖化編程來(lái)解決線程之間同步的問(wèn)題。這就是下面要介紹的比較交換算法。
4.換個(gè)思路,樂(lè)觀一點(diǎn)
加鎖實(shí)現(xiàn)線程同步的方式是預(yù)防性方式。無(wú)論共享變量是否會(huì)被并發(fā)修改,我們都只允許同一時(shí)刻只有一個(gè)線程運(yùn)行方法來(lái)阻止并發(fā)發(fā)生。這就相當(dāng)于我們假設(shè)并發(fā)一定會(huì)發(fā)生,所以比較悲觀。
現(xiàn)在我們換一種思路,樂(lè)觀一點(diǎn),不要假設(shè)對(duì)變量的并發(fā)修改一定發(fā)生,這樣也就不用對(duì)方法加鎖阻止多線程并行運(yùn)行方法了。但是一旦發(fā)生了并發(fā)修改,我們想法發(fā)解決就是了,解決的方法就是將這個(gè)操作重試一下。
繼續(xù)重構(gòu)“?!贝a:
public class TreiberStack<E> { private AtomicReference<Node<E>> headNode = new AtomicReference<>(); public void push(E item) { Node<E> newHead = new Node<>(item); Node<E> oldHead; do { oldHead = headNode.get(); newHead.next = oldHead; } while (!headNode.compareAndSet(oldHead, newHead)); } public E pop() { Node<E> oldHead; Node<E> newHead; do { oldHead = headNode.get(); if (oldHead == null) return null; newHead = oldHead.next; } while (!headNode.compareAndSet(oldHead, newHead)); return oldHead.item; } private static class Node<E> { public final E item; public Node<E> next; public Node(E item) { this.item = item; } } }
這個(gè)就是大名鼎鼎的Treiber Stack,我也只是做了一次代碼的搬運(yùn)工。
我們來(lái)看看TreiberStack和我們前面的Stack有什么不同。
首先關(guān)注第一行:
private AtomicReference<Node<E>> headNode = new AtomicReference<>();
我們用了一個(gè)AtomicReference類(lèi)存儲(chǔ)鏈表的頭部節(jié)點(diǎn),這個(gè)類(lèi)可以獲取存儲(chǔ)對(duì)象的最新值,并且在修改存儲(chǔ)值時(shí)候采用比較交換算法保證原子操作,具體大家可以自行百度。
然后重點(diǎn)關(guān)注pop()
和push()
方法中都有的一個(gè)代碼結(jié)構(gòu):
//略... do { oldHead = headNode.get(); //略... } while (!headNode.compareAndSet(oldHead, newHead)); //略...
我們AtomicReference
中get()
方法最新的獲取頭部節(jié)點(diǎn),然后調(diào)用AtomicReference
中compareAndSet()
將設(shè)置新頭部節(jié)點(diǎn),如果當(dāng)前線程執(zhí)行這兩端代碼的時(shí)候如果有其他已經(jīng)修改了頭部節(jié)點(diǎn)的值,'compareAndSet()'方法返回false ,表明修改失敗,循環(huán)繼續(xù),否則修改成功,跳出循環(huán)。
這樣一個(gè)代碼結(jié)構(gòu)和synchronized
關(guān)鍵字修飾的方法一樣,都保證了對(duì)于頭部節(jié)點(diǎn)的讀取和寫(xiě)入操作及中間代碼在一個(gè)線程下原子執(zhí)行,前者是通過(guò)其他線程修改過(guò)就重試的方式,后者通過(guò)阻塞其他線程的方式,一個(gè)是樂(lè)觀的方式,一個(gè)是悲觀的方式。
大家可以按照前面的例子自己寫(xiě)測(cè)試方法測(cè)試。
后記
我們通過(guò)對(duì)“?!钡囊徊揭徊酱a重構(gòu),逐步介紹了什么是線程安全及保證線程安全的各種方法。這里需要說(shuō)明一點(diǎn),對(duì)于一個(gè)類(lèi)來(lái)說(shuō),是否需要支持線程安全是由類(lèi)的使用場(chǎng)景決定,不是有類(lèi)所提供的功能決定的,如果一個(gè)類(lèi)不會(huì)被應(yīng)用于多線程的情況下也就無(wú)需將他轉(zhuǎn)化為線程安全的類(lèi)。
以上就是本文的全部?jī)?nèi)容,希望對(duì)大家的學(xué)習(xí)有所幫助,也希望大家多多支持腳本之家。
相關(guān)文章
完全解析Java編程中finally語(yǔ)句的執(zhí)行原理
這篇文章主要深度介紹了Java編程中finally語(yǔ)句的執(zhí)行原理,細(xì)致講解了finally在異常處理中的流程控制作用,需要的朋友可以參考下2015-11-11使用idea+gradle編譯spring5.x.x源碼分析
這篇文章主要介紹了idea?+?gradle編譯spring5.x.x源碼,在編譯spring5源碼時(shí)需要將項(xiàng)目導(dǎo)入idea中然后編譯配置,本文給大家講解的非常詳細(xì),需要的朋友可以參考下2022-04-04Spring?Cloud灰度部署實(shí)現(xiàn)過(guò)程詳解
這篇文章主要為大家介紹了Spring?Cloud灰度部署實(shí)現(xiàn)過(guò)程詳解,有需要的朋友可以借鑒參考下,希望能夠有所幫助,祝大家多多進(jìn)步,早日升職加薪2023-06-06java 爬蟲(chóng)詳解及簡(jiǎn)單實(shí)例
這篇文章主要介紹了java 爬蟲(chóng)詳解及簡(jiǎn)單實(shí)例的相關(guān)資料,需要的朋友可以參考下2017-05-05java使用Jdom實(shí)現(xiàn)xml文件寫(xiě)入操作實(shí)例
這篇文章主要介紹了java使用Jdom實(shí)現(xiàn)xml文件寫(xiě)入操作的方法,以完整實(shí)例形式分析了Jdom針對(duì)XML文件寫(xiě)入操作的相關(guān)技巧,具有一定參考借鑒價(jià)值,需要的朋友可以參考下2015-10-10SpringBoot中的ImportSelector類(lèi)動(dòng)態(tài)加載bean詳解
這篇文章主要介紹了SpringBoot中的ImportSelector類(lèi)動(dòng)態(tài)加載bean詳解,ImportSelector接口是spring中導(dǎo)入外部配置的核心接口,根據(jù)給定的條件(通常是一個(gè)或多個(gè)注釋屬性)判定要導(dǎo)入那個(gè)配置類(lèi),在spring自動(dòng)化配置和@EnableXXX中都有它的存在,需要的朋友可以參考下2024-01-01mybatis中返回多個(gè)map結(jié)果問(wèn)題
這篇文章主要介紹了mybatis中返回多個(gè)map結(jié)果問(wèn)題,具有很好的參考價(jià)值,希望對(duì)大家有所幫助。如有錯(cuò)誤或未考慮完全的地方,望不吝賜教2022-06-06已有的springcloud+mybatis項(xiàng)目升級(jí)為mybatis-plus的方法
這篇文章主要介紹了已有的springcloud+mybatis項(xiàng)目升級(jí)為mybatis-plus,文中通過(guò)示例代碼介紹的非常詳細(xì),對(duì)大家的學(xué)習(xí)或者工作具有一定的參考學(xué)習(xí)價(jià)值,需要的朋友們下面隨著小編來(lái)一起學(xué)習(xí)學(xué)習(xí)吧2021-03-03