一道號稱“史上最難”java面試題引發的線程安全思考,掌握了嗎?

一道號稱“史上最難”java面試題引發的線程安全思考,掌握了嗎?

最近偶然間看見一道名為史上最難的java面試題,這個題讓了我對線程安全的有了一些新的思考,給大家分享一下這個題吧:

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();

}

}

}

推薦大家先別急著看下面的答案,試著看看這個題的答案是什麼?剛開始看這個題的時候,第一反應我擦嘞,這個是哪個老鐵想出的題,如此混亂的代碼調用,真是驚為天人。當然這是一道有關於多線程的題,最低級的錯誤,就是一些人對於.start()和.run不熟悉,直接會認為.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

解釋這個答案之前,這種題其實在面試的時候遇到很多,依稀記得再學C++的時候,考地址,指針,學java的時候又在考i++,++i,"a" == b等於True? 這種題屢見不鮮,想必大家做這種題都知道靠死記硬背是解決不來的,因為這種變化實在太多了,所以要做這種比較模稜兩可的題目,必須要會其意,方得齊解。尤其是多線程,如果你不知道其原理,不僅僅在面試中過不了,就算僥倖過了,在工作中如何不能很好的處理線程安全的問題,只能導致你的公司出現損失。

這個題涉及了兩個點:

  • synchronized
  • 線程的幾個狀態:new,runnable(thread.start()),running,blocking(Thread.Sleep())

如果對這幾個不熟悉的同學不要著急下面我都會講,下面我解釋一下整個流程:

  1. 新建一個線程t, 此時線程t為new狀態。
  2. 調用t.start(),將線程至於runnable狀態。
  3. 這裡有個爭議點到點是t線程先執行還是tt.m2先執行呢,我們知道此時線程t還是runnable狀態,此時還沒有被cpu調度,但是我們的tt.m2()是我們本地的方法代碼,此時一定是tt.m2()先執行。
  4. 執行tt.m2()進入synchronized同步代碼塊,開始執行代碼,這裡的sleep()沒啥用就是混淆大家視野的,此時b=2000。
  5. 在執行tt.m2()的時候。有兩個情況:

情況A:有可能t線程已經在執行了,但是由於m2先進入了同步代碼塊,這個時候t進入阻塞狀態,然後主線程也將會執行輸出,這個時候又有一個爭議到底是誰先執行?是t先執行還是主線程,這裡有小夥伴就會把第3點拿出來說,肯定是先輸出啊,t線程不是阻塞的嗎,調度到CPU肯定來不及啊?很多人忽略了一點,synchronized其實是在1.6之後做了很多優化的,其中就有一個自旋鎖,就能保證不需要讓出CPU,有可能剛好這部分時間和主線程輸出重合,並且在他之前就有可能發生,b先等於1000,這個時候主線程輸出其實就會有兩種情況。2000 或者 1000。

情況B:有可能t還沒執行,tt.m2()一執行完,他剛好就執行,這個時候還是有兩種情況。b=2000或者1000

6.在t線程中不論哪種情況,最後肯定會輸出1000,因為此時沒有修改1000的地方了。

整個流程如下面所示:

一道號稱“史上最難”java面試題引發的線程安全思考,掌握了嗎?

2.線程安全

對於上面的題的代碼,雖然在我們實際場景中很難出現,但保不齊有哪位同事寫出了類似的,到時候有可能排坑的還是你自己,所以針對此想聊聊一些線程安全的事。

2.1何為線程安全

我們用《java concurrency in practice》中的一句話來表述:當多個線程訪問一個對象時,如果不用考慮這些線程在運行時環境下的調度和交替執行,也不需要進行額外的同步,或者在調用方進行任何其它的協調操作,調用這個對象的行為都可以獲得正確的結果,那這個對象就是線程安全的。

從上我們可以得知:

  1. 在什麼樣的環境:多個線程的環境下。
  2. 在什麼樣的操作:多個線程調度和交替執行。
  3. 發生什麼樣的情況: 可以獲得正確結果。
  4. 誰 : 線程安全是用來描述對象是否是線程安全。

2.2線程安全性

我們可以按照java共享對象的安全性,將線程安全分為五個等級:不可變、絕對線程安全、相對線程安全、線程兼容、線程對立:

2.2.1不可變

在java中Immutable(不可變)對象一定是線程安全的,這是因為線程的調度和交替執行不會對對象造成任何改變。同樣不可變的還有自定義常量,final及常池中的對象同樣都是不可變的。

在java中一般枚舉類,String都是常見的不可變類型,同樣的枚舉類用來實現單例模式是天生自帶的線程安全,在String對象中你無論調用replace(),subString()都無法修改他原來的值

2.2.2絕對線程安全

我們來看看Brian Goetz的《Java併發編程實戰》對其的定義:當多個線程訪問某個類時,不管運行時環境採用何種調度方式或者這些線程將如何交替進行,並且在主調代碼中不需要任何額外的同步或協同,這個類都能表現出正確的行為,那麼稱這個類是線程安全的。

周志明在<>中講到,Brian Goetz的絕對線程安全類定義是非常嚴格的,要實現一個絕對線程安全的類通常需要付出很大的、甚至有時候是不切實際的代價。同時他也列舉了Vector的例子,雖然Vectorget和remove都是synchronized修飾的,但還是展現了Vector其實不是絕對線程安全。簡單介紹下這個例子:

public Object getLast(Vector list) {

return list.get(list.size() - 1);

}

public void deleteLast(Vector list) {

list.remove(list.size() - 1);

}

如果我們使用多個線程執行上面的代碼,雖然remove和get是同步保證的,但是會出現這個問題有可能已經remove掉了最後一個元素,但是list.size()這個時候已經獲取了,其實get的時候就會拋出異常,因為那個元素已經remove。

2.2.3相對安全

周志明認為這個定義可以適當弱化,把“調用這個對象的行為”限定為“對對象單獨的操作”,這樣一來就可以得到相對線程安全的定義。其需要保證對這個對象單獨的操作是線程安全的,我們在調用的時候不需要做額外的操作,但是對於一些特定的順序連續調用,需要額外的同步手段。我們可以將上面的Vector的調用修改為:

public synchronized Object getLast(Vector list) {

return list.get(list.size() - 1);

}

public synchronized void deleteLast(Vector list) {

list.remove(list.size() - 1);

}

這樣我們作為調用方額外加了同步手段,其Vector就符合我們的相對安全。

2.2.4線程兼容

線程兼容是指其對象並不是線程安全,但是可以通過調用端正確地使用同步手段,比如我們可以對ArrayList進行加鎖,一樣可以達到Vector的效果。

2.2.5線程對立

線程對立是指無論調用端是否採取了同步措施,都無法在多線程環境中併發使用的代碼。由於Java語言天生就具備多線程特性,線程對立這種排斥多線程的代碼是很少出現的,而且通常都是有害的,應當儘量避免。

2.3如何解決線程安全

對於解決線程安全一般來說有幾個辦法:互斥阻塞(悲觀,加鎖),非阻塞同步(類似樂觀鎖,CAS),不需要同步(代碼寫得好,完全不需要考慮同步)

同步是指在多個線程併發訪問共享數據時,保證共享數據在同一個時刻只被一條線程(或是一些,使用信號量的時候)線程使用。

2.3.1 互斥同步

互斥是一種悲觀的手段,因為他擔心他訪問的時候時刻有人會破壞他的數據,所以他需要通過某種手段進行將這個數據在這個時間段給佔為獨有,不能讓其他人有接觸的機會。臨界區(CriticalSection)、互斥量(Mutex)和信號量(Semaphore)都是主要的互斥實現方式。在Java中一般用ReentrantLock和synchronized 實現同步。 而實際業務當中,推薦使用synchronized,在第一節的代碼其實也是使用的synchronized ,為什麼推薦使用synchronized 的呢?

  • 如果我們顯示的使用lock我們得手動的進行解鎖unlock()調用,但是很多人在實際開發過程其實有可能出現忘記,所以推薦使用synchronized ,在易於編程方面Lock敗。
  • synchronized 在jdk1.6之後對其進行了優化會從偏向鎖,輕量級鎖,自旋適應鎖,最後才到重量級鎖。而Lock一來就是重量鎖。在未來的jdk版本中,重點優化的也是synchronized。在性能方便Lock也敗。

如果你在業務中需要等待可中斷,等待超時,公平鎖等功能的話,那你可以選擇這個ReentrantLock。

當然在我們的Mysql數據庫中排他鎖其實也是互斥同步的實現,當加上排他鎖,其他事務都不能進行訪問其數據。

2.3.2 非阻塞同步

非阻塞同步是一種樂觀的手段,在樂觀的手段中他會先去嘗試操作,如果沒有人在競爭,就成功,否則就進行補償(一般就是死循環重試或者循環多次之後跳出),在互斥同步最重要的問題就是進行線程阻塞和喚醒所帶來的性能問題,而樂觀同步策略解決了這一問題。

但是上面就有個問題操作和檢測是否有人競爭這兩個操作一定得保證原子性,這就需要我們硬件設備的支持,例如我們java中的cas操作其實就是操作的硬件底層的指令。

在JDK1.5之後,Java程序中才可以使用CAS操作,該操作由sun.misc.Unsafe類裡面的compareAndSwapInt()和compareAndSwapLong()等幾個方法包裝提供,虛擬機在內部對這些方法做了特殊處理,即時編譯出來的結果就是一條平臺相關的處理器CAS之類,沒有方法調用的過程,或者可以認為是無條件內聯進去了

2.3.3 無同步

要保證線程安全,並不一定就要進行同步,兩者沒有因果關係。同步只是保障共享數據爭用時的正確性手段,如果一個方法本來就不涉及共享數據,那它自然就無須任何同步措施去保證正確性,因此會有一些代碼天生就是現場安全的。 一般分為兩類:

  • 可重入代碼:可重入代碼也叫純代碼,可以隨時中斷,恢復控制權之後程序依然不會出任何錯誤,可重入代碼的結果一般來說是可預測的:

public

int sum(){

return 1+2;

}

例如這種代碼就是可重入代碼,但是在我們自己的代碼中其實出現得很少

  • 線程本地存儲:而這個一般來說是我們用得比較多的手段,我們可以通過保證類是無狀態的,所有的變量都存在於我們的方法之中,或者通過ThreadLocal來進行保存。

2.4線程安全的一些其他經驗

上面寫得都比較官方,下面說說從一些真實的經驗中總結出來的:

  • 在使用某些對象作為單例的時候,需要確定這個對象是否是線程安全的: 比如我們使用SimpleDateFormate的時候,很多初學者都不注意將其作為單例一個工具類來使用,導致了我們的業務異常。可以參考我的另外一篇: 在Java中你真的會日期轉換嗎?
  • 如果發現其不是單例,需要進行替換,比如HashMap用ConcurrentHashMap,queue用ArrayBlockingQueue進行替換。
  • 注意死鎖,如果使用鎖一定記得釋放鎖,同時使用鎖的順序一定要注意,這裡不僅僅說的是單機的鎖,也要說分佈式鎖,一定要注意:一個線程先鎖A後鎖B,另一個線程先鎖B後鎖A這個情況。所以一般來說分佈式鎖會加上超時時間,避免由於網絡問題釋放鎖失敗,而導致死鎖。
  • 鎖的粒度:同樣的不僅僅是說單機的鎖,也包括了分佈式鎖,不要圖方便直接從入口方法,不加分析的就開始加鎖,這樣會嚴重影響性能。同樣的也不能過於細粒度,單機的鎖會增加上下文的切換,分佈式鎖會增加網絡調用,都會導致我們性能的下降。
  • 適當引入樂觀鎖:比如我們有個需求是給用戶扣款,為了防止多扣,這個時候會用悲觀鎖進行鎖,但是效率比較低,因為用戶扣款其實同時扣的情況是比較少的,我們就可以使用樂觀鎖,在用戶的賬戶表裡面添加version字段,首先查詢version,然後更新的時候看看當前version和數據庫的version是否一致,一致就更新不一致就證明已經扣過了。
  • 如果想要在多線程環境下使用非線程安全對象,數據可以放在ThreadLocal,或者只在方法裡面進行創建,我們的ArrayList雖然不是線程安全的,但是一般我們使用的時候其實都是在方法裡面進行List list = new ArrayList()使用,用無同步的方式也保證了線程安全。

毛主席曾說過:手裡有糧,心裡不慌。多多學習多線程知識,這個也是最重要的,當然可以關注我的頭條號來和共同進步。


分享到:


相關文章: