一道“史上最難”java面試題引發的執行緒安全思考

咖啡拿鐵發表於2018-08-02

1.前言

最近偶然間看見一道名為史上最難的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

or
b=1000
main thread b=2000
複製程式碼

有人沒測出來b=2000這裡給大家看看:

一道“史上最難”java面試題引發的執行緒安全思考

解釋這個答案之前,這種題其實在面試的時候遇到很多,依稀記得再學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()先執行。這裡修改: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併發程式設計實戰》對其的定義:當多個執行緒訪問某個類時,不管執行時環境採用何種排程方式或者這些執行緒將如何交替進行,並且在主調程式碼中不需要任何額外的同步或協同,這個類都能表現出正確的行為,那麼稱這個類是執行緒安全的。

周志明在<<深入理解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()使用,用無同步的方式也保證了執行緒安全。
  • 毛主席曾說過:手裡有糧,心裡不慌。多多學習多執行緒知識,這個也是最重要的,當然可以關注我的公眾號來和共同進步。

最後

本文從最開始的一道號稱史上最難的面試題,引入了我們工作中最為重要之一的執行緒安全。希望大家後續可以好好的閱讀周志明的《深入理解jvm虛擬機器》的第13章執行緒安全和鎖優化,相信讀完之後一定會有一個新的提升。由於作者本人水平有限,如果有什麼錯誤,還請指正。

最後打個廣告,如果你覺得這篇文章對你有文章,可以關注我的技術公眾號,最近作者收集了很多最新的學習資料視訊以及面試資料,關注之後即可領取。

如果大家覺得這篇文章對你有幫助,或者你有什麼疑問想提供1v1免費vip服務,都可以關注我的公眾號,關注即可免費領取海量最新java學習資料視訊,以及最新面試資料,你的關注和轉發是對我最大的支援,O(∩_∩)O:

一道“史上最難”java面試題引發的執行緒安全思考

相關文章