public class TestSync2 implements Runnable {
int b = 100;
synchronized void m1() throws InterruptedException {
b = 1000;
Thread.sleep(500); //6
System.out.println('b=' + b);
}
synchronized void m2() throws InterruptedException {
Thread.sleep(250); //5
b = 2000;
}
public static void main(String[] args) throws InterruptedException {
TestSync2 tt = new TestSync2();
Thread t = new Thread(tt); //1
t.start(); //2
tt.m2(); //3
System.out.println('main thread b=' + tt.b); //4
}
@Override
public void run() {
try {
m1();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
推薦大家先別急著看下面的答案,試著看看這個題的答案是什么?剛開始看這個題的時候,第一反應(yīng)我擦嘞,這個是哪個老鐵想出的題,如此混亂的代碼調(diào)用,真是驚為天人。當(dāng)然這是一道有關(guān)于多線程的題,最低級的錯誤,就是一些人對于.start()和.run不熟悉,直接會認(rèn)為.start()之后run會占用主線程,所以得出答案等于:
main thread b=2000
b=2000
比較高級的錯誤:了解start(),但是忽略了或者不知道synchronized,在那里瞎在想sleep()有什么用,有可能得出下面答案:
main thread b=1000
b=2000
總而言之問了很多人,大部分第一時間都不能得出正確答案,其實正確答案如下:
main thread b=2000
b=1000
or
main thread b=1000
b=1000
解釋這個答案之前,這種題其實在面試的時候遇到很多,依稀記得再學(xué)C++的時候,考地址,指針,學(xué)java的時候又在考i++,++i,'a' == b等于True? 這種題屢見不鮮,想必大家做這種題都知道靠死記硬背是解決不來的,因為這種變化實在太多了,所以要做這種比較模棱兩可的題目,必須要會其意,方得齊解。尤其是多線程,如果你不知道其原理,不僅僅在面試中過不了,就算僥幸過了,在工作中如何不能很好的處理線程安全的問題,只能導(dǎo)致你的公司出現(xiàn)損失。
這個題涉及了兩個點:
synchronized
線程的幾個狀態(tài):new,runnable(thread.start()),running,blocking(Thread.Sleep())
如果對這幾個不熟悉的同學(xué)不要著急下面我都會講,下面我解釋一下整個流程:
1. 新建一個線程t, 此時線程t為new狀態(tài)。
2. 調(diào)用t.start(),將線程至于runnable狀態(tài)。
3. 這里有個爭議點到點是t線程先執(zhí)行還是tt.m2先執(zhí)行呢,我們知道此時線程t還是runnable狀態(tài),此時還沒有被cpu調(diào)度,但是我們的tt.m2()是我們本地的方法代碼,此時一定是tt.m2()先執(zhí)行。
4. 執(zhí)行tt.m2()進入synchronized同步代碼塊,開始執(zhí)行代碼,這里的sleep()沒啥用就是混淆大家視野的,此時b=2000。
5. 在執(zhí)行tt.m2()的時候。有兩個情況:
情況A:有可能t線程已經(jīng)在執(zhí)行了,但是由于m2先進入了同步代碼塊,這個時候t進入阻塞狀態(tài),然后主線程也將會執(zhí)行輸出,這個時候又有一個爭議到底是誰先執(zhí)行?是t先執(zhí)行還是主線程,這里有小伙伴就會把第3點拿出來說,肯定是先輸出啊,t線程不是阻塞的嗎,調(diào)度到CPU肯定來不及啊?很多人忽略了一點,synchronized其實是在1.6之后做了很多優(yōu)化的,其中就有一個自旋鎖,就能保證不需要讓出CPU,有可能剛好這部分時間和主線程輸出重合,并且在他之前就有可能發(fā)生,b先等于1000,這個時候主線程輸出其實就會有兩種情況。2000 或者 1000。
情況B:有可能t還沒執(zhí)行,tt.m2()一執(zhí)行完,他剛好就執(zhí)行,這個時候還是有兩種情況。b=2000或者1000
6. 在t線程中不論哪種情況,最后肯定會輸出1000,因為此時沒有修改1000的地方了。
整個流程如下面所示:
對于上面的題的代碼,雖然在我們實際場景中很難出現(xiàn),但保不齊有哪位同事寫出了類似的,到時候有可能排坑的還是你自己,所以針對此想聊聊一些線程安全的事。
我們用《java concurrency in practice》中的一句話來表述:當(dāng)多個線程訪問一個對象時,如果不用考慮這些線程在運行時環(huán)境下的調(diào)度和交替執(zhí)行,也不需要進行額外的同步,或者在調(diào)用方進行任何其它的協(xié)調(diào)操作,調(diào)用這個對象的行為都可以獲得正確的結(jié)果,那這個對象就是線程安全的。
從上我們可以得知:
1. 在什么樣的環(huán)境:多個線程的環(huán)境下。
2. 在什么樣的操作:多個線程調(diào)度和交替執(zhí)行。
3. 發(fā)生什么樣的情況: 可以獲得正確結(jié)果。
4. 誰:線程安全是用來描述對象是否是線程安全。
我們可以按照java共享對象的安全性,將線程安全分為五個等級:不可變、絕對線程安全、相對線程安全、線程兼容、線程對立:
在java中Immutable(不可變)對象一定是線程安全的,這是因為線程的調(diào)度和交替執(zhí)行不會對對象造成任何改變。同樣不可變的還有自定義常量,final及常池中的對象同樣都是不可變的。
在java中一般枚舉類,String都是常見的不可變類型,同樣的枚舉類用來實現(xiàn)單例模式是天生自帶的線程安全,在String對象中你無論調(diào)用replace(),subString()都無法修改他原來的值
我們來看看Brian Goetz的《Java并發(fā)編程實戰(zhàn)》對其的定義:當(dāng)多個線程訪問某個類時,不管運行時環(huán)境采用何種調(diào)度方式或者這些線程將如何交替進行,并且在主調(diào)代碼中不需要任何額外的同步或協(xié)同,這個類都能表現(xiàn)出正確的行為,那么稱這個類是線程安全的。
周志明在<深入理解java虛擬機>>中講到,Brian Goetz的絕對線程安全類定義是非常嚴(yán)格的,要實現(xiàn)一個絕對線程安全的類通常需要付出很大的、甚至有時候是不切實際的代價。同時他也列舉了Vector的例子,雖然Vectorget和remove都是synchronized修飾的,但還是展現(xiàn)了Vector其實不是絕對線程安全。簡單介紹下這個例子:
public Object getLast(Vector list) {
return list.get(list.size() - 1);
}
public void deleteLast(Vector list) {
list.remove(list.size() - 1);
}
如果我們使用多個線程執(zhí)行上面的代碼,雖然remove和get是同步保證的,但是會出現(xiàn)這個問題有可能已經(jīng)remove掉了最后一個元素,但是list.size()這個時候已經(jīng)獲取了,其實get的時候就會拋出異常,因為那個元素已經(jīng)remove。
周志明認(rèn)為這個定義可以適當(dāng)弱化,把“調(diào)用這個對象的行為”限定為“對對象單獨的操作”,這樣一來就可以得到相對線程安全的定義。其需要保證對這個對象單獨的操作是線程安全的,我們在調(diào)用的時候不需要做額外的操作,但是對于一些特定的順序連續(xù)調(diào)用,需要額外的同步手段。我們可以將上面的Vector的調(diào)用修改為:
public synchronized Object getLast(Vector list) {
return list.get(list.size() - 1);
}
public synchronized void deleteLast(Vector list) {
list.remove(list.size() - 1);
}
這樣我們作為調(diào)用方額外加了同步手段,其Vector就符合我們的相對安全。
線程兼容是指其對象并不是線程安全,但是可以通過調(diào)用端正確地使用同步手段,比如我們可以對ArrayList進行加鎖,一樣可以達(dá)到Vector的效果。
線程對立是指無論調(diào)用端是否采取了同步措施,都無法在多線程環(huán)境中并發(fā)使用的代碼。由于Java語言天生就具備多線程特性,線程對立這種排斥多線程的代碼是很少出現(xiàn)的,而且通常都是有害的,應(yīng)當(dāng)盡量避免。
對于解決線程安全一般來說有幾個辦法:互斥阻塞(悲觀,加鎖),非阻塞同步(類似樂觀鎖,CAS),不需要同步(代碼寫得好,完全不需要考慮同步)
同步是指在多個線程并發(fā)訪問共享數(shù)據(jù)時,保證共享數(shù)據(jù)在同一個時刻只被一條線程(或是一些,使用信號量的時候)線程使用。
互斥是一種悲觀的手段,因為他擔(dān)心他訪問的時候時刻有人會破壞他的數(shù)據(jù),所以他需要通過某種手段進行將這個數(shù)據(jù)在這個時間段給占為獨有,不能讓其他人有接觸的機會。臨界區(qū)(CriticalSection)、互斥量(Mutex)和信號量(Semaphore)都是主要的互斥實現(xiàn)方式。在Java中一般用ReentrantLock和synchronized 實現(xiàn)同步。 而實際業(yè)務(wù)當(dāng)中,推薦使用synchronized,在第一節(jié)的代碼其實也是使用的synchronized ,為什么推薦使用synchronized 的呢?
如果我們顯示的使用lock我們得手動的進行解鎖unlock()調(diào)用,但是很多人在實際開發(fā)過程其實有可能出現(xiàn)忘記,所以推薦使用synchronized ,在易于編程方面Lock敗。
synchronized 在jdk1.6之后對其進行了優(yōu)化會從偏向鎖,輕量級鎖,自旋適應(yīng)鎖,最后才到重量級鎖。而Lock一來就是重量鎖。在未來的jdk版本中,重點優(yōu)化的也是synchronized。在性能方便Lock也敗。
如果你在業(yè)務(wù)中需要等待可中斷,等待超時,公平鎖等功能的話,那你可以選擇這個ReentrantLock。
當(dāng)然在我們的Mysql數(shù)據(jù)庫中排他鎖其實也是互斥同步的實現(xiàn),當(dāng)加上排他鎖,其他事務(wù)都不能進行訪問其數(shù)據(jù)。
非阻塞同步是一種樂觀的手段,在樂觀的手段中他會先去嘗試操作,如果沒有人在競爭,就成功,否則就進行補償(一般就是死循環(huán)重試或者循環(huán)多次之后跳出),在互斥同步最重要的問題就是進行線程阻塞和喚醒所帶來的性能問題,而樂觀同步策略解決了這一問題。
但是上面就有個問題操作和檢測是否有人競爭這兩個操作一定得保證原子性,這就需要我們硬件設(shè)備的支持,例如我們java中的cas操作其實就是操作的硬件底層的指令。
在JDK1.5之后,Java程序中才可以使用CAS操作,該操作由sun.misc.Unsafe類里面的compareAndSwapInt()和compareAndSwapLong()等幾個方法包裝提供,虛擬機在內(nèi)部對這些方法做了特殊處理,即時編譯出來的結(jié)果就是一條平臺相關(guān)的處理器CAS之類,沒有方法調(diào)用的過程,或者可以認(rèn)為是無條件內(nèi)聯(lián)進去了
要保證線程安全,并不一定就要進行同步,兩者沒有因果關(guān)系。同步只是保障共享數(shù)據(jù)爭用時的正確性手段,如果一個方法本來就不涉及共享數(shù)據(jù),那它自然就無須任何同步措施去保證正確性,因此會有一些代碼天生就是現(xiàn)場安全的。 一般分為兩類:
可重入代碼:可重入代碼也叫純代碼,可以隨時中斷,恢復(fù)控制權(quán)之后程序依然不會出任何錯誤,可重入代碼的結(jié)果一般來說是可預(yù)測的:
public int sum(){
return 1+2;
}
例如這種代碼就是可重入代碼,但是在我們自己的代碼中其實出現(xiàn)得很少
線程本地存儲:而這個一般來說是我們用得比較多的手段,我們可以通過保證類是無狀態(tài)的,所有的變量都存在于我們的方法之中,或者通過ThreadLocal來進行保存。
上面寫得都比較官方,下面說說從一些真實的經(jīng)驗中總結(jié)出來的:
在使用某些對象作為單例的時候,需要確定這個對象是否是線程安全的: 比如我們使用SimpleDateFormate的時候,很多初學(xué)者都不注意將其作為單例一個工具類來使用,導(dǎo)致了我們的業(yè)務(wù)異常??梢詤⒖嘉业牧硗庖黄? 在Java中你真的會日期轉(zhuǎn)換嗎?
如果發(fā)現(xiàn)其不是單例,需要進行替換,比如HashMap用ConcurrentHashMap,queue用ArrayBlockingQueue進行替換。
注意死鎖,如果使用鎖一定記得釋放鎖,同時使用鎖的順序一定要注意,這里不僅僅說的是單機的鎖,也要說分布式鎖,一定要注意:一個線程先鎖A后鎖B,另一個線程先鎖B后鎖A這個情況。所以一般來說分布式鎖會加上超時時間,避免由于網(wǎng)絡(luò)問題釋放鎖失敗,而導(dǎo)致死鎖。
鎖的粒度:同樣的不僅僅是說單機的鎖,也包括了分布式鎖,不要圖方便直接從入口方法,不加分析的就開始加鎖,這樣會嚴(yán)重影響性能。同樣的也不能過于細(xì)粒度,單機的鎖會增加上下文的切換,分布式鎖會增加網(wǎng)絡(luò)調(diào)用,都會導(dǎo)致我們性能的下降。
適當(dāng)引入樂觀鎖:比如我們有個需求是給用戶扣款,為了防止多扣,這個時候會用悲觀鎖進行鎖,但是效率比較低,因為用戶扣款其實同時扣的情況是比較少的,我們就可以使用樂觀鎖,在用戶的賬戶表里面添加version字段,首先查詢version,然后更新的時候看看當(dāng)前version和數(shù)據(jù)庫的version是否一致,一致就更新不一致就證明已經(jīng)扣過了。
如果想要在多線程環(huán)境下使用非線程安全對象,數(shù)據(jù)可以放在ThreadLocal,或者只在方法里面進行創(chuàng)建,我們的ArrayList雖然不是線程安全的,但是一般我們使用的時候其實都是在方法里面進行List list = new ArrayList()使用,用無同步的方式也保證了線程安全。
毛主席曾說過:手里有糧,心里不慌。多多學(xué)習(xí)多線程知識,這個也是最重要的,當(dāng)然可以關(guān)注我的公眾號來和共同進步。
本文從最開始的一道號稱史上最難的面試題,引入了我們工作中最為重要之一的線程安全。希望大家后續(xù)可以好好的閱讀周志明的《深入理解jvm虛擬機》的第13章線程安全和鎖優(yōu)化,相信讀完之后一定會有一個新的提升。由于作者本人水平有限,如果有什么錯誤,還請指正。
出處:https://mp.weixin.qq.com/s/71ccU9Hms0XbQsjjyxyiIQ
架構(gòu)文摘
ID:ArchDigest
互聯(lián)網(wǎng)應(yīng)用架構(gòu)丨架構(gòu)技術(shù)丨大型網(wǎng)站丨大數(shù)據(jù)丨機器學(xué)習(xí)