一道「史上最難」java面試題引起的線程安全思考

1.前言

最近偶然間看見一道名爲史上最難的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()有什麼用,有可能得出下面答案:sql

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這裏給你們看看:編程

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

這個題涉及了兩個點:bash

  • 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的地方了。

整個流程以下面所示:

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:

相關文章
相關標籤/搜索