1. 程式人生 > >高並發編程

高並發編程

挑戰 rem 電梯 element ril throw time code 共享內存

(一)高並發編程基礎知識

這裏涉及到一些基礎的概念,我重新捧起了一下《實戰 Java 高並發程序設計》這一本書,感覺到心潮澎湃,這或許就是筆者敘述功底紮實的魅力吧,喜歡。對於並發的基礎可以參照一下我之前寫過的一篇博文:Java學習筆記(4)——並發基礎

1)多線程和單線程的區別和聯系?

答:

  1. 在單核 CPU 中,將 CPU 分為很小的時間片,在每一時刻只能有一個線程在執行,是一種微觀上輪流占用 CPU 的機制。

  2. 多線程會存在線程上下文切換,會導致程序執行速度變慢,即采用一個擁有兩個線程的進程執行所需要的時間比一個線程的進程執行兩次所需要的時間要多一些。

結論:即采用多線程不會提高程序的執行速度,反而會降低速度,但是對於用戶來說,可以減少用戶的響應時間。

面試官:那使用多線程有什麽優勢?

解析:盡管面臨很多挑戰,多線程有一些優點仍然使得它一直被使用,而這些優點我們應該了解。

答:

(1)資源利用率更好

想象一下,一個應用程序需要從本地文件系統中讀取和處理文件的情景。比方說,從磁盤讀取一個文件需要5秒,處理一個文件需要2秒。處理兩個文件則需要:

1| 5秒讀取文件A
2| 2秒處理文件A
3| 5秒讀取文件B
4| 2秒處理文件B
5| ---------------------
6| 總共需要14秒

從磁盤中讀取文件的時候,大部分的CPU時間用於等待磁盤去讀取數據。在這段時間裏,CPU非常的空閑。它可以做一些別的事情。通過改變操作的順序,就能夠更好的使用CPU資源。看下面的順序:

1| 5秒讀取文件A
2| 5秒讀取文件B + 2秒處理文件A
3| 2秒處理文件B
4| ---------------------
5| 總共需要12秒

CPU等待第一個文件被讀取完。然後開始讀取第二個文件。當第二文件在被讀取的時候,CPU會去處理第一個文件。記住,在等待磁盤讀取文件的時候,CPU大部分時間是空閑的。

總的說來,CPU能夠在等待IO的時候做一些其他的事情。這個不一定就是磁盤IO。它也可以是網絡的IO,或者用戶輸入。通常情況下,網絡和磁盤的IO比CPU和內存的IO慢的多。

(2)程序設計在某些情況下更簡單

在單線程應用程序中,如果你想編寫程序手動處理上面所提到的讀取和處理的順序,你必須記錄每個文件讀取和處理的狀態。相反,你可以啟動兩個線程,每個線程處理一個文件的讀取和操作。線程會在等待磁盤讀取文件的過程中被阻塞。在等待的時候,其他的線程能夠使用CPU去處理已經讀取完的文件。其結果就是,磁盤總是在繁忙地讀取不同的文件到內存中。這會帶來磁盤和CPU利用率的提升。而且每個線程只需要記錄一個文件,因此這種方式也很容易編程實現。

(3)程序響應更快

有時我們會編寫一些較為復雜的代碼(這裏的復雜不是說復雜的算法,而是復雜的業務邏輯),例如,一筆訂單的創建,它包括插入訂單數據、生成訂單趕快找、發送郵件通知賣家和記錄貨品銷售數量等。用戶從單擊“訂購”按鈕開始,就要等待這些操作全部完成才能看到訂購成功的結果。但是這麽多業務操作,如何能夠讓其更快地完成呢?

在上面的場景中,可以使用多線程技術,即將數據一致性不強的操作派發給其他線程處理(也可以使用消息隊列),如生成訂單快照、發送郵件等。這樣做的好處是響應用戶請求的線程能夠盡可能快地處理完成,縮短了響應時間,提升了用戶體驗。

多線程還有一些優勢也顯而易見:
① 進程之前不能共享內存,而線程之間共享內存(堆內存)則很簡單。
② 系統創建進程時需要為該進程重新分配系統資源,創建線程則代價小很多,因此實現多任務並發時,多線程效率更高.
③ Java語言本身內置多線程功能的支持,而不是單純第作為底層系統的調度方式,從而簡化了多線程編程.

2)多線程一定快嗎?

答:不一定。

比如,我們嘗試使用並行和串行來分別執行累加的操作觀察是否並行執行一定比串行執行更快:

技術分享圖片

以下是我測試的結果,可以看出,當不超過1百萬的時候,並行是明顯比串行要慢的,為什麽並發執行的速度會比串行慢呢?這是因為線程有創建和上下文切換的開銷。

技術分享圖片

3)什麽是同步?什麽又是異步?

解析:這是對多線程基礎知識的考察

答:同步和異步通常用來形容一次方法調用。

同步方法調用一旦開始,調用者必須等到方法返回後,才能繼續後續的行為。這就好像是我們去商城買一臺空調,你看中了一臺空調,於是就跟售貨員下了單,然後售貨員就去倉庫幫你調配物品,這天你熱的實在不行,就催著商家趕緊發貨,於是你就在商店裏等著,知道商家把你和空調都送回家,一次愉快的購物才結束,這就是同步調用。

而異步方法更像是一個消息傳遞,一旦開始,方法調用就會立即返回,調用者就可以繼續後續的操作。回到剛才買空調的例子,我們可以坐在裏打開電腦,在網上訂購一臺空調。當你完成網上支付的時候,對你來說購物過程已經結束了。雖然空調還沒有送到家,但是你的任務都已經完成了。商家接到你的訂單後,就會加緊安排送貨,當然這一切已經跟你無關了,你已經支付完成,想什麽就能去幹什麽了,出去溜達幾圈都不成問題。等送貨上門的時候,接到商家電話,回家一趟簽收即可。這就是異步調用。

技術分享圖片

面試官:那並發(Concurrency)和並行(Parallelism)的區別呢?

解析:並行性和並發性是既相似又有區別的兩個概念。

答:並行性是指兩個或多個事件在同一時刻發生。而並發性是指連個或多個事件在同一時間間隔內發生。

技術分享圖片

在多道程序環境下,並發性是指在一段時間內宏觀上有多個程序在同時運行,但在單處理機環境下(一個處理器),每一時刻卻僅能有一道程序執行,故微觀上這些程序只能是分時地交替執行。例如,在1秒鐘時間內,0-15ms程序A運行;15-30ms程序B運行;30-45ms程序C運行;45-60ms程序D運行,因此可以說,在1秒鐘時間間隔內,宏觀上有四道程序在同時運行,但微觀上,程序A、B、C、D是分時地交替執行的。

如果在計算機系統中有多個處理機,這些可以並發執行的程序就可以被分配到多個處理機上,實現並發執行,即利用每個處理機處理一個可並發執行的程序。這樣,多個程序便可以同時執行。以此就能提高系統中的資源利用率,增加系統的吞吐量。

4)線程和進程的區別:(必考)

答:

  1. 進程是一個 “執行中的程序”,是系統進行資源分配和調度的一個獨立單位;
  2. 線程是進程的一個實體,一個進程中擁有多個線程,線程之間共享地址空間和其它資源(所以通信和同步等操作線程比進程更加容易);
  3. 線程上下文的切換比進程上下文切換要快很多。
    • (1)進程切換時,涉及到當前進程的 CPU 環境的保存和新被調度運行進程的 CPU 環境的設置。

    • (2)線程切換僅需要保存和設置少量的寄存器內容,不涉及存儲管理方面的操作。

面試官:進程間如何通訊?線程間如何通訊?

答:進程間通訊依靠 IPC 資源,例如管道(pipes)、套接字(sockets)等;

線程間通訊依靠 JVM 提供的 API,例如 wait()、notify()、notifyAll() 等方法,線程間還可以通過共享的主內存來進行值的傳遞。

關於線程和進程有一篇寫得非常不錯的文章,不過是英文的,我進行了翻譯,相信閱讀之後會對進程和線程有不一樣的理解:線程和進程基礎——翻譯文

5)什麽是阻塞(Blocking)和非阻塞(Non-Blocking)?

答:阻塞和非阻塞通常用來形容多線程間的相互影響。比如一個線程占用了臨界區資源,那麽其他所有需要這個而資源的線程就必須在這個臨界區中進行等待。等待會導致線程掛起,這種情況就是阻塞。此時,如果占用資源的線程一直不願意釋放資源,那麽其他所有阻塞在這個臨界區上的線程都不能工作。

非阻塞的意思與之相反,它強調沒有一個線程可以妨礙其他線程執行。所有的線程都會嘗試不斷前向執行。

面試官:臨界區是什麽?

答:臨界區用來表示一種公共資源或者說是共享資源,可以被多個線程使用。但是每一次,只能有一個線程使用它,一旦臨界區資源被占用,其他線程要想使用這個資源,就必須等待。

比如,在一個辦公室裏有一臺打印機,打印機一次只能執行一個任務。如果小王和小明同時需要打印文件,很顯然,如果小王先下發了打印任務,打印機就開始打印小王的文件了,小明的任務就只能等待小王打印結束後才能打印,這裏的打印機就是一個臨界區的例子。

在並行程序中,臨界區資源是保護的對象,如果意外出現打印機同時執行兩個打印任務,那麽最可能的結果就是打印出來的文件就會是損壞的文件,它既不是小王想要的,也不是小明想要的。

6)什麽是死鎖(Deadlock)、饑餓(Starvation)和活鎖(Livelock)?

答:死鎖、饑餓和活鎖都屬於多線程的活躍性問題,如果發現上述幾種情況,那麽相關線程可能就不再活躍,也就說它可能很難再繼續往下執行了。

技術分享圖片

  1. 死鎖應該是最糟糕的一種情況了,它表示兩個或者兩個以上的進程在執行過程中,由於競爭資源或者由於彼此通信而造成的一種阻塞的現象,若無外力作用,它們都將無法推進下去。此時稱系統處於死鎖狀態或系統產生了死鎖,這些永遠在互相等待的進程稱為死鎖進程。

  2. 饑餓是指某一個或者多個線程因為種種原因無法獲得所需要的資源,導致一直無法執行。比如:
    1)它的線程優先級可能太低,而高優先級的線程不斷搶占它需要的資源,導致低優先級的線程無法工作。在自然界中,母雞餵食雛鳥時,很容易出現這種情況,由於雛鳥很多,食物有限,雛鳥之間的食物競爭可能非常厲害,小雛鳥因為經常搶不到食物,有可能會被餓死。線程的饑餓也非常類似這種情況。
    2)另外一種可能是,某一個線程一直占著關鍵資源不放,導致其他需要這個資源的線程無法正常執行,這種情況也是饑餓的一種。
    與死鎖相比,饑餓還是有可能在未來一段時間內解決的(比如高優先級的線程已經完成任務,不再瘋狂的執行)

  3. 活鎖是一種非常有趣的情況。不知道大家是不是有遇到過這樣一種情況,當你要坐電梯下樓,電梯到了,門開了,這時你正準備出去,但不巧的是,門外一個人擋著你的去路,他想進來。於是你很紳士的靠左走,避讓對方,但同時對方也很紳士,但他靠右走希望避讓你。結果,你們又撞上了。於是乎,你們都意識到了問題,希望盡快避讓對方,你立即向右走,他也立即向左走,結果又撞上了!不過介於人類的只能,我相信這個動作重復 2、 3 次後,你應該可以順利解決這個問題,因為這個時候,大家都會本能的對視,進行交流,保證這種情況不再發生。
    但如果這種情況發生在兩個線程間可能就不會那麽幸運了,如果線程的智力不夠,且都秉承著 “謙讓” 的原則,主動將資源釋放給他人使用,那麽就會出現資源不斷在兩個線程中跳動,而沒有一個線程可以同時拿到所有的資源而正常執行。這種情況就是活鎖。

7)多線程產生死鎖的 4 個必要條件?

答:

  1. 互斥條件:一個資源每次只能被一個線程使用;

  2. 請求與保持條件:一個線程因請求資源而阻塞時,對已獲得的資源保持不放;

  3. 不剝奪條件:進程已經獲得的資源,在未使用完之前,不能強行剝奪;

  4. 循環等待條件:若幹線程之間形成一種頭尾相接的循環等待資源關系。

面試官:如何避免死鎖?(經常接著問這個問題哦~)

答:指定獲取鎖的順序,舉例如下:

  1. 比如某個線程只有獲得 A 鎖和 B 鎖才能對某資源進行操作,在多線程條件下,如何避免死鎖?

  2. 獲得鎖的順序是一定的,比如規定,只有獲得 A 鎖的線程才有資格獲取 B 鎖,按順序獲取鎖就可以避免死鎖!!!

8)如何指定多個線程的執行順序?

解析:面試官會給你舉個例子,如何讓 10 個線程按照順序打印 0123456789?(寫代碼實現)

答:

  1. 設定一個 orderNum,每個線程執行結束之後,更新 orderNum,指明下一個要執行的線程。並且喚醒所有的等待線程。

  2. 在每一個線程的開始,要 while 判斷 orderNum 是否等於自己的要求值!!不是,則 wait,是則執行本線程。

9)Java 中線程有幾種狀態?

答:六種(查看 Java 源碼也可以看到是 6 種),並且某個時刻 Java 線程只能處於其中的一個狀態。

技術分享圖片

  1. 新建(NEW)狀態:表示新創建了一個線程對象,而此時線程並沒有開始執行。

  2. 可運行(RUNNABLE)狀態:線程對象創建後,其它線程(比如 main 線程)調用了該對象的 start() 方法,才表示線程開始執行。當線程執行時,處於 RUNNBALE 狀態,表示線程所需的一切資源都已經準備好了。該狀態的線程位於可運行線程池中,等待被線程調度選中,獲取 cpu 的使用權。

  3. 阻塞(BLOCKED)狀態:如果線程在執行過程終於到了 synchronized 同步塊,就會進入 BLOCKED 阻塞狀態,這時線程就會暫停執行,直到獲得請求的鎖。

  4. 等待(WAITING)狀態:當線程等待另一個線程通知調度器一個條件時,它自己進入等待狀態。在調用Object.wait方法或Thread.join方法,或者是等待java.util.concurrent庫中的Lock或Condition時,就會出現這種情況;

  5. 計時等待(TIMED_WAITING)狀態:Object.wait、Thread.join、Lock.tryLock和Condition.await 等方法有超時參數,還有 Thread.sleep 方法、LockSupport.parkNanos 方法和 LockSupport.parkUntil 方法,這些方法會導致線程進入計時等待狀態,如果超時或者出現通知,都會切換會可運行狀態;

  6. 終止(TERMINATED)狀態:當線程執行完畢,則進入該狀態,表示結束。

註意:從 NEW 狀態出發後,線程不能再回到 NEW 狀態,同理,處於 TERMINATED 狀態的線程也不能再回到 RUNNABLE 狀態。


(二)高並發編程-JUC 包

在 Java 5.0 提供了 java.util.concurrent(簡稱 JUC )包,在此包中增加了在並發編程中很常用的實用工具類,用於定義類似於線程的自定義子系統,包括線程池、異步 IO 和輕量級任務框架。

1)sleep( ) 和 wait( n)、wait( ) 的區別:

答:

  1. sleep 方法:是 Thread 類的靜態方法,當前線程將睡眠 n 毫秒,線程進入阻塞狀態。當睡眠時間到了,會解除阻塞,進行可運行狀態,等待 CPU 的到來。睡眠不釋放鎖(如果有的話);

  2. wait 方法:是 Object 的方法,必須與 synchronized 關鍵字一起使用,線程進入阻塞狀態,當 notify 或者 notifyall 被調用後,會解除阻塞。但是,只有重新占用互斥鎖之後才會進入可運行狀態。睡眠時,釋放互斥鎖。

2)synchronized 關鍵字:

答:底層實現:

  1. 進入時,執行 monitorenter,將計數器 +1,釋放鎖 monitorexit 時,計數器-1;

  2. 當一個線程判斷到計數器為 0 時,則當前鎖空閑,可以占用;反之,當前線程進入等待狀態。

含義:(monitor 機制)

Synchronized 是在加鎖,加對象鎖。對象鎖是一種重量鎖(monitor),synchronized 的鎖機制會根據線程競爭情況在運行時會有偏向鎖(單一線程)、輕量鎖(多個線程訪問 synchronized 區域)、對象鎖(重量鎖,多個線程存在競爭的情況)、自旋鎖等。

該關鍵字是一個幾種鎖的封裝。

3)volatile 關鍵字:

答:該關鍵字可以保證可見性不保證原子性。

功能:

  1. 主內存和工作內存,直接與主內存產生交互,進行讀寫操作,保證可見性;

  2. 禁止 JVM 進行的指令重排序。

解析:關於指令重排序的問題,可以查閱 DCL 雙檢鎖失效相關資料。

4)volatile 能使得一個非原子操作變成原子操作嗎?

答:能。

一個典型的例子是在類中有一個 long 類型的成員變量。如果你知道該成員變量會被多個線程訪問,如計數器、價格等,你最好是將其設置為 volatile。為什麽?因為 Java 中讀取 long 類型變量不是原子的,需要分成兩步,如果一個線程正在修改該 long 變量的值,另一個線程可能只能看到該值的一半(前 32 位)。但是對一個 volatile 型的 long 或 double 變量的讀寫是原子。

面試官:volatile 修飾符的有過什麽實踐?

答:

  1. 一種實踐是用 volatile 修飾 long 和 double 變量,使其能按原子類型來讀寫。double 和 long 都是64位寬,因此對這兩種類型的讀是分為兩部分的,第一次讀取第一個 32 位,然後再讀剩下的 32 位,這個過程不是原子的,但 Java 中 volatile 型的 long 或 double 變量的讀寫是原子的。

  2. volatile 修復符的另一個作用是提供內存屏障(memory barrier),例如在分布式框架中的應用。簡單的說,就是當你寫一個 volatile 變量之前,Java 內存模型會插入一個寫屏障(write barrier),讀一個 volatile 變量之前,會插入一個讀屏障(read barrier)。意思就是說,在你寫一個 volatile 域時,能保證任何線程都能看到你寫的值,同時,在寫之前,也能保證任何數值的更新對所有線程是可見的,因為內存屏障會將其他所有寫的值更新到緩存。

5)ThreadLocal(線程局部變量)關鍵字:

答:當使用 ThreadLocal 維護變量時,其為每個使用該變量的線程提供獨立的變量副本,所以每一個線程都可以獨立的改變自己的副本,而不會影響其他線程對應的副本。

ThreadLocal 內部實現機制:

  1. 每個線程內部都會維護一個類似 HashMap 的對象,稱為 ThreadLocalMap,裏邊會包含若幹了 Entry(K-V 鍵值對),相應的線程被稱為這些 Entry 的屬主線程;

  2. Entry 的 Key 是一個 ThreadLocal 實例,Value 是一個線程特有對象。Entry 的作用即是:為其屬主線程建立起一個 ThreadLocal 實例與一個線程特有對象之間的對應關系;

  3. Entry 對 Key 的引用是弱引用;Entry 對 Value 的引用是強引用。

技術分享圖片

6)線程池有了解嗎?(必考)

答:java.util.concurrent.ThreadPoolExecutor 類就是一個線程池。客戶端調用 ThreadPoolExecutor.submit(Runnable task) 提交任務,線程池內部維護的工作者線程的數量就是該線程池的線程池大小,有 3 種形態:

  • 當前線程池大小 :表示線程池中實際工作者線程的數量;
  • 最大線程池大小 (maxinumPoolSize):表示線程池中允許存在的工作者線程的數量上限;
  • 核心線程大小 (corePoolSize ):表示一個不大於最大線程池大小的工作者線程數量上限。
  1. 如果運行的線程少於 corePoolSize,則 Executor 始終首選添加新的線程,而不進行排隊;

  2. 如果運行的線程等於或者多於 corePoolSize,則 Executor 始終首選將請求加入隊列,而不是添加新線程;

  3. 如果無法將請求加入隊列,即隊列已經滿了,則創建新的線程,除非創建此線程超出 maxinumPoolSize, 在這種情況下,任務將被拒絕。

面試官:我們為什麽要使用線程池?

答:

  1. 減少創建和銷毀線程的次數,每個工作線程都可以被重復利用,可執行多個任務。

  2. 可以根據系統的承受能力,調整線程池中工作線程的數目,放置因為消耗過多的內存,而把服務器累趴下(每個線程大約需要 1 MB 內存,線程開的越多,消耗的內存也就越大,最後死機)

面試官:核心線程池內部實現了解嗎?

答:對於核心的幾個線程池,無論是 newFixedThreadPool() 方法,newSingleThreadExecutor() 還是 newCachedThreadPool() 方法,雖然看起來創建的線程有著完全不同的功能特點,但其實內部實現均使用了 ThreadPoolExecutor 實現,其實都只是 ThreadPoolExecutor 類的封裝。

為何 ThreadPoolExecutor 有如此強大的功能呢?我們可以來看一下 ThreadPoolExecutor 最重要的構造函數:

    public ThreadPoolExecutor(int corePoolSize,
                              int maximumPoolSize,
                              long keepAliveTime,
                              TimeUnit unit,
                              BlockingQueue<Runnable> workQueue,
                              ThreadFactory threadFactory,
                              RejectedExecutionHandler handler)

函數的參數含義如下:

  • corePoolSize:指定了線程池中的線程數量
  • maximumPoolSize:指定了線程池中的最大線程數量
  • keepAliveTime:當線程池線程數量超過 corePoolSize 時,多余的空閑線程的存活時間。即,超過了 corePoolSize 的空閑線程,在多長時間內,會被銷毀。
  • unit: keepAliveTime 的單位。
  • workQueue:任務隊列,被提交但尚未被執行的任務。
  • threadFactory:線程工廠,用於創建線程,一般用默認的即可。
  • handler:拒絕策略。當任務太多來不及處理,如何拒絕任務。

7)Atomic關鍵字:

答:可以使基本數據類型以原子的方式實現自增自減等操作。參考博客:concurrent.atomic包下的類AtomicInteger的使用

8)創建線程有哪幾種方式?

答:有兩種創建線程的方法:一是實現Runnable接口,然後將它傳遞給Thread的構造函數,創建一個Thread對象;二是直接繼承Thread類。

面試官:兩種方式有什麽區別呢?

  1. 繼承方式:
    • (1)Java中類是單繼承的,如果繼承了Thread了,該類就不能再有其他的直接父類了.
    • (2)從操作上分析,繼承方式更簡單,獲取線程名字也簡單.(操作上,更簡單)
    • (3)從多線程共享同一個資源上分析,繼承方式不能做到.
  2. 實現方式:
  • (1)Java中類可以多實現接口,此時該類還可以繼承其他類,並且還可以實現其他接口(設計上,更優雅).
    • (2)從操作上分析,實現方式稍微復雜點,獲取線程名字也比較復雜,得使用Thread.currentThread()來獲取當前線程的引用.
    • (3)從多線程共享同一個資源上分析,實現方式可以做到(是否共享同一個資源).

9)run() 方法和 start() 方法有什麽區別?

答:start() 方法會新建一個線程並讓這個線程執行 run() 方法;而直接調用 run() 方法知識作為一個普通的方法調用而已,它只會在當前線程中,串行執行 run() 中的代碼。

10)你怎麽理解線程優先級?

答:Java 中的線程可以有自己的優先級。優先極高的線程在競爭資源時會更有優勢,更可能搶占資源,當然,這只是一個概率問題。如果運行不好,高優先級線程可能也會搶占失敗。

由於線程的優先級調度和底層操作系統有密切的關系,在各個平臺上表現不一,並且這種優先級產生的後果也可能不容易預測,無法精準控制,比如一個低優先級的線程可能一直搶占不到資源,從而始終無法運行,而產生饑餓(雖然優先級低,但是也不能餓死它啊)。因此,在要求嚴格的場合,還是需要自己在應用層解決線程調度的問題。

在 Java 中,使用 1 到 10 表示線程優先級,一般可以使用內置的三個靜態標量表示:

public final static int MIN_PRIORITY = 1;
public final static int NORM_PRIORITY = 5;
public final static int MAX_PRIORITY = 10;

數字越大則優先級越高,但有效範圍在 1 到 10 之間,默認的優先級為 5 。

11)在 Java 中如何停止一個線程?

答:Java 提供了很豐富的 API 但沒有為停止線程提供 API 。

JDK 1.0 本來有一些像 stop(),suspend() 和 resume() 的控制方法但是由於潛在的死鎖威脅因此在後續的 JDK 版本中他們被棄用了,之後 Java API 的設計者就沒有提供一個兼容且線程安全的方法來停止任何一個線程。

當 run() 或者 call() 方法執行完的時候線程會自動結束,如果要手動結束一個線程,你可以用 volatile 布爾變量來退出 run() 方法的循環或者是取消任務來中斷線程。

12)多線程中的忙循環是什麽?

答:忙循環就是程序員用循環讓一個線程等待,不像傳統方法 wait(),sleep() 或yield() 它們都放棄了 CPU 控制權,而忙循環不會放棄 CPU,它就是在運行一個空循環。這麽做的目的是為了保留 CPU 緩存。

在多核系統中,一個等待線程醒來的時候可能會在另一個內核運行,這樣會重建緩存,為了避免重建緩存和減少等待重建的時間就可以使用它了。

13)10 個線程和 2 個線程的同步代碼,哪個更容易寫?

答:從寫代碼的角度來說,兩者的復雜度是相同的,因為同步代碼與線程數量是相互獨立的。但是同步策略的選擇依賴於線程的數量,因為越多的線程意味著更大的競爭,所以你需要利用同步技術,如鎖分離,這要求更復雜的代碼和專業知識。

14)你是如何調用 wait()方法的?使用 if 塊還是循環?為什麽?

答:wait() 方法應該在循環調用,因為當線程獲取到 CPU 開始執行的時候,其他條件可能還沒有滿足,所以在處理前,循環檢測條件是否滿足會更好。下面是一段標準的使用 wait 和 notify 方法的代碼:

// The standard idiom for using the wait method
synchronized (obj) {
while (condition does not hold)
obj.wait(); // (Releases lock, and reacquires on wakeup)
... // Perform action appropriate to condition
}

參見 Effective Java 第 69 條,獲取更多關於為什麽應該在循環中來調用 wait 方法的內容。

15)什麽是多線程環境下的偽共享(false sharing)?

答:偽共享是多線程系統(每個處理器有自己的局部緩存)中一個眾所周知的性能問題。偽共享發生在不同處理器的上的線程對變量的修改依賴於相同的緩存行,如下圖所示:

技術分享圖片

偽共享問題很難被發現,因為線程可能訪問完全不同的全局變量,內存中卻碰巧在很相近的位置上。如其他諸多的並發問題,避免偽共享的最基本方式是仔細審查代碼,根據緩存行來調整你的數據結構。

16)用 wait-notify 寫一段代碼來解決生產者-消費者問題?

解析:這是常考的基礎類型的題,只要記住在同步塊中調用 wait() 和 notify()方法,如果阻塞,通過循環來測試等待條件。

答:

import java.util.Vector;
import java.util.logging.Level;
import java.util.logging.Logger;

/**
 * Java program to solve Producer Consumer problem using wait and notify
 * method in Java. Producer Consumer is also a popular concurrency design pattern.
 *
 * @author Javin Paul
 */
public class ProducerConsumerSolution {

    public static void main(String args[]) {
        Vector sharedQueue = new Vector();
        int size = 4;
        Thread prodThread = new Thread(new Producer(sharedQueue, size), "Producer");
        Thread consThread = new Thread(new Consumer(sharedQueue, size), "Consumer");
        prodThread.start();
        consThread.start();
    }
}

class Producer implements Runnable {

    private final Vector sharedQueue;
    private final int SIZE;

    public Producer(Vector sharedQueue, int size) {
        this.sharedQueue = sharedQueue;
        this.SIZE = size;
    }

    @Override
    public void run() {
        for (int i = 0; i < 7; i++) {
            System.out.println("Produced: " + i);
            try {
                produce(i);
            } catch (InterruptedException ex) {
                Logger.getLogger(Producer.class.getName()).log(Level.SEVERE, null, ex);
            }

        }
    }

    private void produce(int i) throws InterruptedException {

        // wait if queue is full
        while (sharedQueue.size() == SIZE) {
            synchronized (sharedQueue) {
                System.out.println("Queue is full " + Thread.currentThread().getName()
                                    + " is waiting , size: " + sharedQueue.size());

                sharedQueue.wait();
            }
        }

        // producing element and notify consumers
        synchronized (sharedQueue) {
            sharedQueue.add(i);
            sharedQueue.notifyAll();
        }
    }
}

class Consumer implements Runnable {

    private final Vector sharedQueue;
    private final int SIZE;

    public Consumer(Vector sharedQueue, int size) {
        this.sharedQueue = sharedQueue;
        this.SIZE = size;
    }

    @Override
    public void run() {
        while (true) {
            try {
                System.out.println("Consumed: " + consume());
                Thread.sleep(50);
            } catch (InterruptedException ex) {
                Logger.getLogger(Consumer.class.getName()).log(Level.SEVERE, null, ex);
            }

        }
    }

    private int consume() throws InterruptedException {
        // wait if queue is empty
        while (sharedQueue.isEmpty()) {
            synchronized (sharedQueue) {
                System.out.println("Queue is empty " + Thread.currentThread().getName()
                                    + " is waiting , size: " + sharedQueue.size());

                sharedQueue.wait();
            }
        }

        // Otherwise consume element and notify waiting producer
        synchronized (sharedQueue) {
            sharedQueue.notifyAll();
            return (Integer) sharedQueue.remove(0);
        }
    }
}

Output:
Produced: 0
Queue is empty Consumer is waiting , size: 0
Produced: 1
Consumed: 0
Produced: 2
Produced: 3
Produced: 4
Produced: 5
Queue is full Producer is waiting , size: 4
Consumed: 1
Produced: 6
Queue is full Producer is waiting , size: 4
Consumed: 2
Consumed: 3
Consumed: 4
Consumed: 5
Consumed: 6
Queue is empty Consumer is waiting , size: 0

17)用 Java 寫一個線程安全的單例模式(Singleton)?

解析:有多種方法,但重點掌握的是雙重校驗鎖。

答:

1.餓漢式單例

餓漢式單例是指在方法調用前,實例就已經創建好了。下面是實現代碼:

public class Singleton {

    private static Singleton instance = new Singleton();

    private Singleton (){}

    public static Singleton getInstance() {
        return instance;
    }
}

2.加入 synchronized 的懶漢式單例

所謂懶漢式單例模式就是在調用的時候才去創建這個實例,我們在對外的創建實例方法上加如 synchronized 關鍵字保證其在多線程中很好的工作:

public class Singleton {    

    private static Singleton instance;    

    private Singleton (){}    

    public static synchronized Singleton getInstance() {    
        if (instance == null) {    
            instance = new Singleton();    
    }    
    return instance;    
    }    
}  

3.使用靜態內部類的方式創建單例

這種方式利用了 classloder 的機制來保證初始化 instance 時只有一個線程,它跟餓漢式的區別是:餓漢式只要 Singleton 類被加載了,那麽 instance 就會被實例化(沒有達到 lazy loading 的效果),而這種方式是 Singleton 類被加載了,instance 不一定被初始化。只有顯式通過調用 getInstance() 方法時才會顯式裝載 SingletonHoder 類,從而實例化 singleton

public class Singleton {

    private Singleton() {
    }

    private static class SingletonHolder {// 靜態內部類  
        private static Singleton singleton = new Singleton();
    }

    public static Singleton getInstance() {
        return SingletonHolder.singleton;
    }
}

4.雙重校驗鎖

為了達到線程安全,又能提高代碼執行效率,我們這裏可以采用DCL的雙檢查鎖機制來完成,代碼實現如下:

public class Singleton {  
  
    private static Singleton singleton;  

    private Singleton() {  
    }  

    public static Singleton getInstance(){  
        if (singleton == null) {  
            synchronized (Singleton.class) {  
                if (singleton == null) {  
                    singleton = new Singleton();  
                }  
            }  
        }  
        return singleton;  
    }  
} 

這種是用雙重判斷來創建一個單例的方法,那麽我們為什麽要使用兩個if判斷這個對象當前是不是空的呢 ?因為當有多個線程同時要創建對象的時候,多個線程有可能都停止在第一個if判斷的地方,等待鎖的釋放,然後多個線程就都創建了對象,這樣就不是單例模式了,所以我們要用兩個if來進行這個對象是否存在的判斷。

5.使用 static 代碼塊實現單例

靜態代碼塊中的代碼在使用類的時候就已經執行了,所以可以應用靜態代碼塊的這個特性的實現單例設計模式。

public class Singleton{  
       
    private static Singleton instance = null;  
       
    private Singleton(){}  
  
    static{  
        instance = new Singleton();  
    }  
      
    public static Singleton getInstance() {   
        return instance;  
    }   
}  

6.使用枚舉數據類型實現單例模式

枚舉enum和靜態代碼塊的特性相似,在使用枚舉時,構造方法會被自動調用,利用這一特性也可以實現單例:

public class ClassFactory{   
      
    private enum MyEnumSingleton{  
        singletonFactory;  
          
        private MySingleton instance;  
          
        private MyEnumSingleton(){//枚舉類的構造方法在類加載是被實例化  
            instance = new MySingleton();  
        }  
   
        public MySingleton getInstance(){  
            return instance;  
        }  
    }   
   
    public static MySingleton getInstance(){  
        return MyEnumSingleton.singletonFactory.getInstance();  
    }  
}  

小結:關於 Java 中多線程編程,線程安全等知識一直都是面試中的重點和難點,還需要熟練掌握。


參考資料:

① 知名互聯網公司校招 Java 開發崗面試知識點解析
② 最近5年133個Java面試問題列表
③ 《實戰 Java 高並發程序設計 —— 葛一鳴 郭超 編著》

轉自:https://www.cnblogs.com/wmyskxz/p/9021597.html

高並發編程