10 道超級高頻 Java 面試題,助力金三銀四

簡介

大家好,我是毛毛蟲,也是公衆號 Java dev 的作者,多年一線研發老兵,面試超過 200 人次,也算是在面試方面稍有經驗,2019 年下半年的時候開始琢磨着如何把自己的一些知識點分享給初級的研發同學。

任何一個技術人都逃離不了技術面試一關,俗話說不打沒有準備的仗,那麼如何有效的準備呢,不陷入細節,又不能淺嘗輒止,所以我決定在公衆號上利用業餘時間多多更新一些自己實際面試過程中會用到的面試題,陸陸續續已經分享了十幾篇原創文章。

令人欣慰的是有讀者留言告訴毛毛蟲,說自己拿到了心儀的 offer,面試題目中有好幾道題目都在毛毛蟲的公衆號裏出現過,非常感謝。 介於公衆號的擴散能力有限,毛毛蟲將十幾篇精華面試題目綜合在一起,奉送給更多的同學,希望你們在 2020 年的金三銀四大戰中大放異彩。

問題 1

Java 在語法層面已經有了 synchronized 來實現管程,爲什麼還要在 JDK 中提供了 Lock 和 Condition 工具類來做這樣的事情,這屬於重複造輪子嗎?

答案

首先你可能會想到的是 synchronized 性能問題,但是我想告訴你的是 synchronized 在高版本的 JDK 中性能已經得到了大幅的提升,很多開發者開始提倡使用 synchronized,性能問題可以不斷優化提升,它並不是重載輪子的原因。

大家都知道管程幫助我們解決了多線程資源共享問題,但同時也帶來了死鎖的風險。Coffman 等人在 1971 年總結出來產生死鎖的四個必要條件:

1.互斥條件

一個資源在同一時刻只能被一個線程操作。

2.佔有且等待

線程因爲請求資源而阻塞時不會釋放已經獲取到的資源。

3.不可強行佔有

線程已經獲取到的資源,在未釋放前不允許被其他線程強行剝奪。

4.循環等待

線程存在循環等待資源的關係(線程 T1 依次佔有資源 A,B;線程 T2 依次佔有資源 B,A;這就構成了循環等待資源關係)。

當發生死鎖的時候必然上面四個條件都會滿足,那麼只要我們破壞其中的任何一個條件,我們即可解決死鎖問題。首先條件 1 無法破解,因爲共享資源必須是互斥的,如果可以多個線程同時操作也沒必要加鎖了。那我們試圖使用 synchronized 來破解剩餘的三個條件:

1.佔有且等待

synchronized 獲取資源時候,只要資源獲取不到,線程立即進入阻塞狀態,並且不會釋放已經獲取的資源。那麼我們可以調整一下獲取共享資源的方式,我們通過一個鎖中介,通過中介一次性獲取線程需要的所有資源,如果存在單個資源不滿足情況,直接阻塞,而不是獲取部分資源,這樣我們即可解決這個問題,破解該條件。

2.不可強行佔有

synchronized 獲取不到資源時候,線程直接阻塞,無法被中斷釋放資源,因此這個條件 synchronized 無法破解。

3.循環等待

循環等待是因爲線程在競爭 2 個互斥資源時候會以不同的順序去獲取資源,如果我們將線程獲取資源的順序固定下來即可破解這個條件。

綜上我們可以知道 synchronized 不能破解“不可強行佔有”條件,這就是 JDK 同時提供 Lock 這種管程的實現方式的原因。當然啦,Lock 使用起來也更加的靈活。例如我們有多個共享資源,鎖是嵌套方式獲取的,如線程需要先獲取 A 鎖,然後獲取 B 鎖,然後釋放 A 鎖,獲取 C 鎖,接着釋放 B 鎖,獲取 D 鎖 等等。這種嵌套獲取鎖的方式 synchronized 是無法實現的,但是 Lock 卻可以幫助我們來解決這個問題。既然我們知道了 JDK 重造管程的原因,那我們來一起看一下 Lock 爲我們提供的四種進入獲取鎖的方式:

1.void lock();

這種方式獲取不到鎖時候線程會進入阻塞狀態,和 synchronized 類似。

2.void lockInterruptibly() throws InterruptedException;

這種方式獲取不到鎖線程同樣會進入阻塞,但是它可以接收中斷信號,退出阻塞。

3.boolean tryLock();

這種方式不管是否能獲取到鎖都不會阻塞而是立刻返回獲取結果,成功返回 true,失敗返回 false。

4.boolean tryLock(long time, TimeUnit unit) throws InterruptedException;

這種方式獲取不到鎖的線程會進入阻塞狀態,但是在指定的時間內如果仍未獲得鎖,則返回 false,否則返回 true。

問題 2

你已經知道管程 synchronized 是通過鎖對象所關聯的 Monitor 對象的計數來標識線程對鎖的佔有狀態,那麼你知道 ReentrantLock 是如何來控制鎖的佔有狀態嗎?

答案

Lock 鎖採用了與 Monitor 對象計數完全不同的一種方式,它依賴了併發包中的 AbstractQueuedSynchronizer (隊列同步器簡稱 AQS ) 來實現在多線程情況下對鎖狀態的控制。那麼 AQS 是如何保證在多線程場景中鎖狀態可以正常控制呢,它主要使用瞭如下 3 個技術點:

1.volatile 關鍵字

首先 AQS 中定義了一個 volatile 修飾的 int 變量 state,大家都知道 volatile 是實現無鎖編程的關鍵技術。它有內存可見性和禁止指令重排序的功效,當多個線程去爭奪鎖的佔有權的時候,如果有其中任何一個線程已經持有鎖,它會設置 state 加 1。這樣由於 volatile 的可見性功效,其他線程會爭取鎖權限的時候發現鎖已經被其他線程持有,就會進入等待隊列。當持有鎖的線程使用結束後,釋放鎖,會將 state 減 1,然後去喚醒隊列中等待的線程。

2.CAS(compare and swap) 算法

你是否存在疑問,當線程將 state 加 1 的時候是如何保證只有一個線程在做這個操作呢,畢竟在加 1 操作的時候線程尚未獲得鎖,所以加 1 操作存在競爭關係。雖然 volatile 可以解決線程間的內存可見性問題,但是仍然存在多個線程競爭 state 更新的問題,這就要使用我們的 CAS 算法來解決這個問題了,compare and swap 中文意思是比較並且替換。它提供的功能是讓我們提供一個預期值和一個即將要設置爲的值,如果此時內存中的值正好是我們預期的值,則直接將值設置爲我們即將要設置爲的值。

拿競爭 state 舉例,所有線程都希望 state 的值爲 0 (0 代表沒有線程佔用),然後自己將 state 設置爲 1(佔有鎖)。在 AQS 中 由如下方法來幫我完成這件事情:

protected final boolean compareAndSetState(int expect, int update) {        
  // See below for intrinsics setup to support this        
  return unsafe.compareAndSwapInt(this, stateOffset, expect, update);
}

你注意到了,該方法並不會直接幫我們完成這件事情而是一個 Unsafe 的對象完成了實際的操作,Unsafe 類提供了大量的原子方法,但是這些方法都是 native 標識的,代表它們是 JVM 提供的本地 C++ 方法,底層本質是對 CPU CAS 指令的一個封裝。這樣 volatile 的內存可見性特效結合 CAS 算法就完美的解決了多線程併發獲取鎖的安全操作。當然啦,Unsafe 正如其名,寓意爲不安全,不推薦開發者直接使用,只是在 Java SDK 中使用到了。

3.雙向隊列

到此你是否還存在疑問,多個線程競爭鎖,一個線程獲取到了鎖,那其他線程怎麼辦呢?其他線程其實同樣進入了阻塞等待狀態(此處也使用了 Unsafe 的 park 本地方法),與 synchronized 不同是,AQS 內部提供了一個雙向隊列(不然怎麼叫隊列同步器呢,哈哈),隊列的節點定義如下:

static final class Node {       
  /**省略部分屬性**/
  volatile Node prev;      
  volatile Node next;      
  volatile Thread thread;      
  Node nextWaiter;      
  /**省略部分方法**/
}

每一個進入等待隊列的線程都會被關聯到一個 Node,所有等待的線程按照先後順序會組成一個雙向隊列(組成隊列的過程中同樣使用到了 CAS 算法 和 volatile 特性,不然多線程併發的給隊列結尾添加節點也會存在競爭問題),等待持有鎖的線程釋放鎖後喚醒隊列頭部的節點中關聯的線程(此處喚醒同樣使用了 Unsafe 的 unpark 本地方法),這樣就有序的控制了線程對共享數據的併發訪問。

如上我們介紹了 Lock 鎖中用到的三個重要技術點和基本實現原理,相信問題回答到這一步面試官已經默默心裏想:小夥子有點東西啊。哈哈!

問題 3

HashMap 想必是平時工作中使用非常的頻繁的數據結構了吧,它簡單易用,同時也是面試掃盲第一輪技術面中出現頻率極高的題目之一。假設我問你:HashMap 一次 put 操作的詳細過程是怎樣的,你可以對答如流嗎?

我的答案

HashMap 的存儲結構就不用多介紹了,底層是一個 列表/二叉樹 的數組結構。當我們調用一次 put 操作的時候主要會經過如下 4 個大步驟,讓我們來詳細看一下這四個步驟都做了些什麼事情。

1.求 Key 的 hash 值

想要存入 HashMap 的元素的 key 都必須要經過一個 hash 運算,首先調用 key 對象的 hashCode 方法獲取到對象的 hash 值。然後將獲得 hash 值向右位移 16 位,接着將位移後的值與原來的值做異或運算,即可獲得到一個比較理想的 hash 值,之所以這樣運算時爲了儘可能降低 hash 碰撞(即使得元素均勻分佈到各個槽中)。

2.確定 hash 槽

獲取到 hash 值後就是計算該元素所對應的 hash 槽,這一步操作比較簡單。直接將上一步操作中獲取到的 hash 值與底層數組長度取模(爲了提高性能,源碼採用了運算,這樣等價於取模運算,但是性能更好)獲取 index 位置。

3.將元素放入槽中

在上一步中我們獲得了 hash 槽的索引,接下來就是將元素放入到槽中,如果槽裏當前沒有任何元素則直接生成 Node 放入槽中,如果槽中已經有元素存在,則會有下面 2 種情況:

3.1 槽裏是一顆平衡二叉樹

當列表的元素長度超過 8 時,爲了加快槽上元素的索引速度,HashMap 會將列表轉換爲平衡二叉樹。所以在這種情況下,插入元素是在一顆平衡二叉樹上做操作,查找和更新的時間複雜度都是 log(n),HashMap 會將元素包裝爲一個 TreeNode 添加到樹上。具體平衡二叉樹的操作就不在此展開了,有興趣的小夥伴可自行探索。

3.2 槽裏是一個列表

初始情況下,槽裏面的元素是以列表形式存在的,HashMap 遍歷列表將元素 更新 / 追加 到列表尾部。元素添加後,HashMap 會判斷當前列表元素個數,如達到 8 個元素則將列表轉化爲平衡二叉樹,具體轉換詳情可參考 HashMap 中的方法 final void treeifyBin(Node<K,V>[] tab, int hash)

4.擴容

到這裏時候,我們元素已經完美的放到了 HashMap 的存儲中。但是還有一個 HashMap 的自動擴容操作需要完成,默認情況下自動擴容因子是 0.75,即當容量爲 16 時候,如果存儲元素達到 16 * 0.75 = 12 個的時候,HashMap 會進行 resize 操作(俗稱 rehash)。HashMap 會新建一個 2 倍容量與舊數組的數組,然後依次遍歷舊的數組中的每個元素,將它們轉移到新的數組當中。其中有 2 個需要注意的點:列表中元素依然會保持原來的順序和加入二叉樹上的元素少達 6 個時候會將二叉樹再次轉化爲列表來存儲。

如上我們介紹了 HashMap 的依次 put 操作的全過程,小夥伴們快去看源碼鞏固一下吧,歡迎留言與我交流哦。

問題 4

ConcurrentHashMap 在我們平時編碼的過程中不是很常用,但是它在 Java 基礎面試中是也是一道必考題,通常會拿出來和 HashMap 做比較,所以我們必須要掌握它。那麼,你知道 ConcurrentHashMap 是如何來實現一個支持併發讀寫的高效哈希表呢?

我的答案

ConcurrentHashMap 同 HashMap 一樣,都是 Map 接口的子類。不同之處在於 HashMap 是非線程安全的,如果想要在多線程環境下使用必須對它的操作添加互斥鎖。而 ConcurrentHashMap 爲併發而生,可以在多線程環境下隨便使用而不用開發人員去添加互斥鎖。那麼 ConcurrentHashMap 是如何實現線程安全的特性呢,接下來我們就從它的 put 方法說開來,對比 HashMap 的 put 操作來一步步揭開它的神祕面紗。

1.獲取哈希值
同 HashMap 一樣,ConcurrentHashMap 底層也是使用一個 Node 的數組來存儲數據。因此第一步也是獲取要存儲元素的哈希值。與 HashMap 不同的是,ConcurrentHashMap 不支持 null 鍵和 null 值。

2.確定 hash 槽
這一步就不多說了,同 HashMap 基本一致。

3.初始化底層數組存儲
拿到 hash 槽以後就是存儲元素,但是首次 put 元素時候會觸發初始化底層數組操作,在 HashMap 中這一步操作是很簡單的,因爲是單線程操作直接初始化就可以了。但是在 ConcurrentHashMap 中就需要考慮併發問題了,因爲有可能有多個線程同時 put 元素。這裏就用到了我們之前文章中介紹的無鎖編程利器 volatile 和 CAS 原子操作。每個線程初始化數組之前都會先獲取到 volatile 修飾的 sizeCtl 變量,只有設置了這個變量的值纔可以初始化數組,同時數組也是由 volatile 修飾的,以便修改後能被其他線程及時察覺。不知道 volatile 和 CAS 算法的同學可以參考往期的文章 ReentranLock 實現原理居然是這樣?。具體初始化代碼可參考 ConcurrentHashMap 的 initTable 方法。

3.插入元素
數組初始化結束後就可以開心的插入元素了,插入數組元素又分了如下 3 個情況:

3.1 當前槽中沒有元素
這種情況最爲簡單,直接調用 Unsafe 封裝好的 CAS 方法插入設置元素即可,成功則罷,不成功的話會轉變爲下個情況。

3.2 槽中已有元素
操作已有元素也有 2 個情況:

3.2.1 槽中元素正在被遷移(ConcurrentHashMap 擴容操作)
如果當前槽的元素正在被擴容到新的數組當中,那麼當前線程要幫忙遷移擴容。

3.2.3 槽中元素處於普通狀態
這個時候就是和 HashMap 類似的 put 操作,特殊之處在於 ConcurrentHashMap 需要在此處加鎖,因爲可能當前有多個線程都想往這個槽上添加元素。這裏使用的 synchronized 鎖,鎖對象爲當前槽的第一個元素。鎖住以後的操作就和 HashMap 類似了,如果槽中依然是列表形態,那麼將當前元素包裝爲 Node 添加到隊列尾部,插入元素結束後如果發現列表元素達到了 8, 則會將列表轉換爲二叉樹(注意:插入元素後會釋放鎖,若發現要轉換爲二叉樹,則會重新進行加鎖操作)。如果槽中元素已經轉換爲了平衡二叉樹,那麼將元素封裝爲 TreeNode 插入到樹中。

4.擴容
在插入元素的末尾,ConcurrentHashMap 會計算當前容器內的元素,然後決定是否需要擴容。擴容這一部分涉及到了比較複雜的運算,後面我們會單獨出一遍文章來探討分析,敬請關注。

至此相信你已經對 ConcurrentHashMap 可以支持併發的原理有了大致的瞭解,它主要依靠了 volatile 和 CAS 操作,再加上 synchronized 即實現了一個支持併發的哈希表。瞭解了以上的內容和我們後面將要討論的擴容話題基本可以對付面試了,當然啦 ConcurrentHashMap 的源碼複雜程度遠遠高於 HashMap,對源碼有興趣的同學可以繼續努力啃一啃,相信肯定會有更大的收穫。

問題 5

相信你一定記得學習併發編程的一個入門級例子,多個線程操作一個變量,累加 10000 次,最後結果居然不是 10000。後來你把這個變量換成了併發包中的原子類型變量 AtomicLong,完美的解決了併發問題。假如面試官問:還有更好的選擇嗎?LongAdder 瞭解過嗎?你能對答如流嗎?

我的答案

AtomicLong 是 Java 1.5 併發包中提供的一個原子類,他提供給了我們在多線程環境下安全的併發操作一個整數的特性。並且性能還可以,它主要依賴了 2 個技術,volatile 關鍵字和 CAS 原子指令,不知道這倆個技術的小夥伴參考往期的文章:ReentranLock 實現原理居然是這樣?;AtomicLong 性能已經不錯了,但是當在線程高度競爭的狀況下性能會急劇下降,因爲高度競爭下 CAS 操作會耗費大量的失敗計算,因爲當一個線程去更新變量時候發現值早已經被其他線程更新了。那麼有沒有更好的解決方案呢,於是 LongAdder 誕生了。

LongAdder 是 Java 1.8 併發包中提供的一個工具類,它採用了一個分散熱點數據的思路。簡單來說,Atomic 中所有線程都去更新內存中的一個變量,而 LongAdder 中會有一個 Cell 類型的數組,這個數組的長度是 CPU 的核心數,因爲一臺電腦中最多同時會有 CPU 核心數個線程並行運行,每個線程更新數據時候會被映射到一個 Cell 元素去更新,這樣就將原本一個熱點的數據,分散成了多個數據,降低了熱點,這樣也就減少了線程的競爭程度,同時也就提高了更新的效率。當然這樣也帶了一個問題,就是更新函數不會返回更新後的值,而 AtomicLong 的更新方法會返回更新後的結果,LongAdder 只有在調用 sum 方法的時候纔會去累加每個 Cell 中的數據,然後返回結果。當然 LongAdder 中也用到了 volatile 和 CAS 原子操作,所以小夥伴們一定要掌握這倆個技術點,這是面試必問的點。

既然說 LongAdder 的效率更好,那我們就來一段測試代碼,小小展示一下 LongAdder 的膩害之處,請看如下:

public class AtomicLongTester {
    private static AtomicLong numA = new AtomicLong(0);    
  	private static LongAdder numB = new LongAdder();

    public static void main(String[] args) throws InterruptedException {        
      for (int i = 1; i < 10001; i*=10) {            
        test(false, i);            
        test(true, i);        
      }    
    }

    public static void test(boolean isLongAdder, int threadCount) throws InterruptedException {        
      long starTime = System.currentTimeMillis();        
      final CountDownLatch latch = new CountDownLatch(threadCount);
        for (int i = 0; i < threadCount; i++) {            
          new Thread(new Runnable() {                
            public void run() {                    
              for (int i = 0; i < 100000; i++) {                        
                if (isLongAdder) {                            
                  numB.add(1);                        
                } else {                            
                  numA.addAndGet(1);                        
                }                    
              }
                    latch.countDown();                
            }            
          }).start();        
        }
        // 等待所有運算結束        
      latch.await();
        if (isLongAdder) {            
          System.out.println("Thread Count=" + threadCount + ", LongAdder cost ms=" + (System.currentTimeMillis() - starTime) + ", Result=" + numB.sum());        
        } else {            
          System.out.println("Thread Count=" + threadCount + ", AtomicLong cost ms=" + (System.currentTimeMillis() - starTime) + ", Result=" + numA.get());        
        }
        numA = new AtomicLong(0);        
      numB = new LongAdder();    
    }
}

實驗結果大致如下:

Thread Count=1, AtomicLong cost ms=9, Result=100000
Thread Count=1, LongAdder cost ms=13, Result=100000
Thread Count=10, AtomicLong cost ms=14, Result=1000000
Thread Count=10, LongAdder cost ms=41, Result=1000000
Thread Count=100, AtomicLong cost ms=111, Result=10000000
Thread Count=100, LongAdder cost ms=45, Result=10000000
Thread Count=1000, AtomicLong cost ms=1456, Result=100000000
Thread Count=1000, LongAdder cost ms=379, Result=100000000
Thread Count=10000, AtomicLong cost ms=17452, Result=1000000000
Thread Count=10000, LongAdder cost ms=3545, Result=1000000000

從上面的結果可以看出來,當線程競爭率比較低的時候 AtomicLong 效率還是優於 LongAdder 的,但是當線程競爭率增大的時候,我們可以看出來 LongAdder 的性能遠遠高於 AtomicLong。

因此在使用原子類的時候,我們要結合實際情況,如果競爭率很高,那麼建議使用 LongAdder 來替代 AtomicLong。說到 LongAdder 也不得的說一說 volatile 帶來的僞共享問題,對僞共享感興趣的同學歡迎關注後續的文章,我們會在後續的文章中探討這個問題。

以上即爲 公衆號:Java dev 昨天的問題的答案,小夥伴們對這個答案是否滿意呢?歡迎留言和我討論。

問題 6

往期問題中,我們介紹了 LongAdder 工具類。我們知道了 LongAdder 中爲了分散熱點數據,存在一個 volatile 修飾的 Cell 數組。由於數組的連續存儲特性,會存在僞共享問題,你知道 LongAdder 是如何解決的嗎?

我的答案

在探討 LongAdder 是如何解決僞共享問題之前,我們要先梳理清一個概念,什麼是 僞共享 共享

共享在 Java 編程裏面我們可以這樣理解,有一個 Share 類,它有一個 value 的屬性。如下:

public class Share {   int value;}

我們初始化 Share 的一個實例,然後啓動多個線程去操作它的 value 屬性,此時的 Share 變量被多個線程操作的這種情況我們稱之爲 共享

大家都知道在不添加任何互斥措施的情況,多線程操作這個 Share 變量的 value 屬性肯定存在線程安全性的問題。那有什麼辦法可以解決這個問題呢?我們可以使用 volatile 和 CAS 技術來保證共享變量可以安全的被多個線程共享操作使用,不知道 volatile 和 CAS 技術點的同學可以參考往期文章 ReentranLock 實現原理居然是這樣?

但是由於 volatile 的引入,會帶來一些問題。大家都知道 JMM(Java 內存模型)規範了 volatile 具有內存可見性和禁止指令重排序的語義。這倆條語義使得某個線程更新本地緩存中的 value 值後會將其他線程的本地緩存中的 value 值失效,然後其他線程再次讀取 value 值的時候需要去主存裏面獲取 value 值,這樣即保證了 value 的內存可見性。

當然啦,這沒有任何問題,但是由於線程本地緩存的操作是以緩存行爲單位的,一個緩存行大小通常爲 64B(不同型號的電腦緩存行大小會有不同)。因此一個緩存行中不會只單單存儲 value 一個變量,可能還會存儲其他變量。這樣當一個線程更新了 value 之後,如果其他線程本地緩存中同樣緩存了 value, value 所在的緩存行就會失效,這意味着該緩存行上的其他變量也會失效,那麼線程對這個該緩存行上所有變量的訪問都需要從主存中獲取。我們都知道 CPU 訪問主存的速度相對於訪問緩存的速度有着數量級的差距,這就帶了很大的性能問題,我們將這個問題稱之爲 僞共享

理解了僞共享到底是什麼鬼以後,我們來看看 Java 大師們是怎麼解決這個問題的。在早期版本的 JDK 裏面你應該見到過類似如下的代碼:

public class Share {    
  volatile int value;    
  long p1, p2, p3, p4, p5, p6;
}

你可能猜到了,定義了幾個無用的變量作爲填充物,他們會保證一個緩存行裏面只保存了 Share 變量,這樣更新 Share 變量的時候就不會存在僞共享問題了。但是這種方法存在什麼問題呢?

首先基於每臺運行 Java 程序的機器的緩存行大小可能不同,其次由於這些類似填充物的變量並沒有被實際使用,可以被 JVM 優化掉,這樣就失效了。

基於此,在 Java 8 的時候,官方給出瞭解決策略,這就是 Contended 註解。依賴於這個註解,我們在 Java 8 環境下可以這樣改善代碼:

public class Share {    
  @Contended    
  volatile int value;
}

使用如上註解,並且在 JVM 啓動參數中加入 -XX:-RestrictContended,這樣 JVM 在運行時就會自動的爲我們的 Share 類添加合適大小的填充物(padding)來解決僞共享問題,而不需要我們手寫變量來作爲填充物了,這樣就更加便捷優雅的解決了僞共享問題。悄悄的告訴你,LongAdder 就是使用 Contended 來解決僞共享問題噠。

好了,相信你已經瞭解了什麼是僞共享問題,以及早期併發編程大師是如何解決僞共享問題的,最後我們也介紹了在 Java 8 中使用 Contended 來更優雅的解決僞共享問題。Contended 還提供了一個緩存行分組的功能,在上文中我們沒有介紹,歡迎有興趣的小夥伴們自行探索吧(嘿嘿)。

問題 7

少年,老衲看你骨骼清奇,眉宇之間透露着一股王者的氣息。來吧,跟我講講 ConcurrentHashMap 是如何進行管理它的容量的,也就是當我們調用它的 size 方法的時候發生了什麼故事?(前面我們介紹了 ConcurrentHashMap 的實現原理,但是擴容和容量大小留了個小尾巴,今天來割一下這個小尾巴,嘿嘿)

我的答案

畢竟是要支持併發,ConcurrentHashMap 的擴容操作比較複雜,我們將從以下幾點來帶討論一下它的擴容。

觸發擴容

1.添加新元素後,元素個數達到擴容閾值觸發擴容。

2.調用 putAll 方法,發現容量不足以容納所有元素時候觸發擴容。

3.某個槽內的鏈表長度達到 8,但是數組長度小於 64 時候觸發擴容。

統計元素個數

觸發後面 2 點沒啥問題,但是第 1 點中有個小問題,它是如何統計元素的個數呢?它採用和 LongAdder 類似的分散熱點數據的解決思路,不知道 LongAdder 的小夥伴可以參考往期文章 還在用 AtomicLong?是時候瞭解一下 LongAdder 了。ConcurrentHashMap 內部定義了一個 CounterCell 的類,它同樣被 Contended 修飾防止 volatile 帶來的僞共享問題,僞共享不瞭解可以參考往期文章 面試官問我 volatile 是否存在僞共享問題?我懵逼了。內部實例化了一個 CounterCell 的數組來記錄元素的個數,每當線程 put 一個元素到容器中,線程會被映射到一個 CounterCell 的一個元素上面採用 CAS 算法進行加 1 操作,當然如果當前 CounterCell 上已經有線程在操作,或者併發量比較小的話會直接將加 1 累加到 BASECOUNT 上面。

就是依據這樣的策略,容器的元素的個數就會遊刃有餘的計算出來,當需要獲取當前容器元素個數的時候,直接將 CounterCell 數據的每個元素值加起來再加上 BASECOUNT 的值就是當前容器中的元素個數。

擴容

上面我們知道了觸發擴容的條件爲元素個數達到閾值開始擴容,然後也知道了它是如何統計元素的個數的,接下來就看看擴容的運行機制。

首先每個線程承擔不小於 16 個槽中的元素的擴容,然後從右向左劃分 16 個槽給當前線程去遷移,每當開始遷移一個槽中的元素的時候,線程會鎖住當前槽中列表的頭元素,假設這時候正好有 get 請求過來會仍舊在舊的列表中訪問,如果是插入、修改、刪除、合併、compute 等操作時遇到 ForwardingNode,當前線程會加入擴容大軍幫忙一起擴容,擴容結束後再做元素的更新操作。

總結

總結一下,對於 ConcurrentHashMap 的擴容我們需要明確如上三點就可以了,擴容觸發的時機、元素個數的計算以及具體擴容過程中是如何堅持對外提供服務的就可以了。

如上即爲 ConcurrentHashMap 擴容操作的簡單介紹,實際 JDK 裏面的擴容源碼非常複雜,如果有小夥伴對源碼感興趣的話,本毛毛蟲找到一篇不錯的源碼分析文章大家可以參考一下 https://blog.csdn.net/ZOKEKAI/article/details/90051567 。這篇相當於 ConcurrentHashMap 的續集,對前序感興趣的可以參考往期文章 ConcurrentHashMap 併發讀寫,用了什麼黑科技

以上即爲昨天的問題的答案,小夥伴們對這個答案是否滿意呢?歡迎留言和我討論。

問題 8

你用過 Java 中的 final 關鍵字嗎?它有哪些作用?這也是出鏡率極高的題目哦。

我的答案

1.final 修飾類的時候代表這個類是不可以被擴展繼承的,例如 JDK 裏面的 String 類。

2.final 修飾方法的時候代表這個方法不能被子類重寫。

3.final 修飾變量的時候代表這個變量一旦被賦值就不能再次被賦值。

想必上面這三點是大家所熟知的,但是下面這 2 點你想到了嗎?

緩存

final 變量會被緩存在寄存器中而不需要去主從獲取,而非 final 變量的則需要去主存重新加載。

線程可見性

類的 final 域在編譯器層面會保證在類的構造器結束之前一定要初始化完成,同時 Java 內存模型會保證對象實例化後它的 final 域對其他線程是可見的,然而非 final 域並沒有這種待遇。例如如下代碼:

public class FinalFiled {    
  final int x;    int y;    
  static FinalFiled f;
    public FinalFiled() {        
      x = 100;        
      y = 100;    
    }
    static void writer() {        
      f = new FinalFiled();    
    }
    static void reader() {        
      if (f != null) {            
        int i = f.x;  
        // 保證此時一定是 100            
        int j = f.y;  
        // 有可能此時還是 0        
      }    
    }
}
當線程 A 執行了 writer 方法後,有線程 B 會進入 f != null 成立條件的代碼塊,此時由於變量 x 是 final 修飾的,JMM 會保證 x 此時的值一定是 100,而 y 是非 final 的,則有可能此時 y 的值還是 0,並未被初始化。
安全性

String 類的安全性也得益於恰到好處的使用了大量的 final 域,大家可以去翻翻 String 類的源碼。我們來舉個例子,假設有線程 A 執行如下代碼段:

Global.flag = "/001/002".substring(4);

又有線程 B 執行如下代碼段:

String myS = Global.flag;
if (myS.equals("/001"))
  System.out.println(myS);

如果沒有 final 域的可見性保證,那麼線程 B 在執行的時候有可能看到的字符串的長度可能仍然是 0。當有了 final 域的可見性保證,就可以讓併發程序正確的執行。也使得 String 類成爲名副其實不可變安全類。

問題 9

利用 Java 中的動態綁定我們可以實現很多有意思的玩法。例如

Object obj = new String();

集合類想必是小夥伴們低頭不見擡頭見的類了,那麼你有沒有想過集合類是否可以這樣玩?

List<Object> list = new ArrayList<Integer>();

不瞞你說,這是毛毛蟲在一次面試中遇到的真實的面試題,當時被問的一臉懵。 如果是你,你知道怎麼回答嗎?

我的答案

首先我要告訴你的是,Java 集合類不允許這麼玩,這樣編譯器會直接報錯編譯不通過。

我們都知道賦值一定需要是 is a 的關係,雖然 Object 和 Integer 是父子關係,但是很可惜 List 和 new ArrayList 不是父子關係。當你這麼回答時候,面試官臉上露出了狡黠的笑容,假設可以這麼玩,那會有什麼樣的問題呢?

好吧,假設 Java 允許我們這樣寫

List<Object> objList = new ArrayList<Integer>();

那麼接下來編譯器需要來確定一下容器的具體類型,因爲容器裏面必須存放一種確定的對象類型,不然泛型也就沒有誕生的意義了。那究竟是 Object 還是 Integer 呢?

假設一

假設它最終確定下來存放的是 Object,那就和如下代碼是一樣的效果了

List<Object> list = new ArrayList<>();

這種寫法是 Java 7 引入的寫法,官方稱之爲 diamond ,就是那一對空着的尖括號,使用這種寫法時候編譯器會自動推算出類型爲 Object。這樣就相當於賦值語句 ArrayList 中的泛型 Integer 毫無意義,那麼無意義的操作 Java 顯然是不允許的。

假設二

我們再假設最終確定下來的是存放的是 Integer,這樣問題就更大了。因爲我們都知道 Object 是所有類的基類,這就代表着 objList 可以添加任何類型的對象。即我們可以做如下操作

objList.add(new String());
objList.add(new Integer());

然後我們使用容器裏面的對象的時候取出來需要將它強制轉換爲 Integer 對象,如果容器中本來就存放的是 Integer 的對象還好,如若不是就會出現 ClassCastException。有沒有發現,這樣不是恰如回退到沒有泛型的 Java 版本了,即 Java 1.5 之前。我們使用容器不能在編譯期間保證它的類型安全了,歷史回退這種傻傻的操作 Java 也絕對是不允許的。

經過如上的一通分析我們發現泛型它就是不能這麼玩,而且這麼玩也是毫無意義。

這個時候面試官微微的點點頭,表示略有贊同,你以爲終於可以結束這樣各種假設的騷操作了。面試官嘴角再次漏出狡黠的笑容,前面你說 List 和 ArrayList 不是父子關係,那泛型裏面有父子關係嗎或者說 List 和 ArrayList 是否存在着某種關係?

好吧。它們是兄弟,都是 List<?> 的子類。也就是說,你可以這麼玩:

List<?> list1 = new ArrayList<Object>();
List<?> list2 = new ArrayList<Integer>();

如上是 2 段代碼是合法的,容器造出來了。那讓我們來給容器裏面塞一些對象進去吧。

list1.add(new Object());

不好意思你不能這麼寫,因爲這 2 個容器裏面可以存儲任何東西,導致編譯器都無法判斷到底給裏面會存儲什麼,它倆是 Read Only 哦。What? 那這有啥用呢?

好吧,假如有這樣一個需求:寫一個方法可以累加數字容器裏面的元素值,那我們可以這樣去寫

public static long sumNumbers(List<?> list) {
    Long sum = 0l;
    for (Object num : list) {
        sum += ((Number) num).longValue();
    }
    return sum;
}

你可能注意到了,沒錯這裏經歷了一次強制類型轉換,萬一方法的調用方傳入的是 new ArrayList() 那麼此處你可能會接到一個 ClassCastException 了。那有沒有好的解決辦法呢?有!

public static long sumNumbers(List<? extends Number> list) {/****/}

我們將方法的參數改爲了 List<? extends Number> list ,這樣當調用方試圖傳入非 Number 類型的容器實例時候在編譯期就會直接報錯咯,嘿嘿。

當然啦,你也可以通過 super 關鍵字來設置泛型參數的下限,例如 List<? super Number> list ,那這個時候調用方法的時候就只能傳入泛型參數是 Number 或者 Number 的父親級別的容器了。

好了,這就是本題的答案啦。想必回答到此處以後,面試官心裏一定心裏在想:“小夥子有點東西啊”。

以上即爲昨天的問題的答案,小夥伴們對這個答案是否滿意呢?歡迎留言和我討論。

問題 10

你好同學,我是今天的面試官。咱們來聊聊平時開發中爲什麼要使用線程池技術,Java 線程池它具體是怎麼實現的?

好處多多

假設我們不使用線程池技術,那麼就在任務來臨時刻啓動一個新的線程,任務處理結束,釋放線程資源。但是啓動和銷燬線程對服務器來說是比較耗費性能的一件事情,首先當任務來臨時候,由於需要創建新的線程,會造成任務的延遲,其次頻繁的創建和銷燬線程也造成了大量不必要的資源浪費。在使用線程池以後,線程處理完當前任務以後不會被銷燬,當新任務來臨時候會重新利用已經創建好的線程,避免了創建銷燬線程的開銷,同時由於任務來臨時線程已經就緒,也提高了服務的吞吐量。在平時的工作中,有很多地方都使用到了這種池化技術,例如數據庫連接池,網絡請求連接池,在比如說 JDK 中字符串常量池也可以認爲是一種池化技術。

Java 線程池怎麼玩

想玩明白 Java 的線程池,只需要的知道構建線程池的幾個參數具體的含義基本上就明瞭了。那麼接下來,就讓我們一一瓦解這些參數。

corePoolSize

我們假設有 N 個任務需要提交到線程池去處理,當任務數量 N 小於核心線程數 corePoolSize(後文用 C 來代替) 的時候,線程池會不斷新建線程來處理用戶提交進來的任務即使有線程空閒。C 其實代表的是線程池通常情況下會保留的線程數量(如果將線程池比作一個工廠,C 可以類比爲工廠的正式編制人員數量),當任務數量 N 超過核心線程數量 C 的時候,線程池就要用到下一個參數 workQueue 了。

workQueue

當用戶提交的任務數量變多了,這時候線程池中的線程數量已經達到核心線程數 C,那麼只能將提交過來的任務暫存在 workQueue 隊列中。每當有線程處理完手頭上活的時候就會來工作隊列領取任務,如果隊列中沒有任務,那麼當前線程就阻塞在隊列上,等待任務。工作隊列可以簡單分爲 2 種:無界隊列和有界隊列。

無界隊列

如果我創建線程池的傳入的是無界隊列,那麼意味着用戶可以源源不斷的提交任務到線程池,而不需要擔心線程池拒絕接收,例如 LinkedBlockingQueue 就是一種選擇。

有界隊列

如果我們傳入的是有界隊列,例如 ArrayBlockingQueue,那就需要考慮隊列存滿了怎麼辦?不用擔心這個時候線程池會幫忙找一些臨時工來幹活,這就需要用到下一個參數 maximumPoolSize 了。

maximumPoolSize

此時所有的核心線程都在幹活,而且工作隊列也存滿了任務。如果還是有任務提交進來,那麼線程池會再創建新的線程來幫助工作(可以類比爲一個工廠,管理員發現任務太多,倉庫也堆滿了任務需要僱傭一些臨時工來幫助幹活)。當然臨時工也不能僱傭太多,畢竟工廠資源有限,需要設定工廠裏面工人最大上限,這個就是 maximumPoolSize 了。然而瘋狂的用戶哪管你能不能處理完任務,還是不斷的提交任務進來,這個時候線程池忍無可忍了,關門拒絕用戶提交新的任務,這時候 RejectedExexcutionHandler 就要開始發揮作用了。

RejectedExexcutionHandler

線程池共提供瞭如下 4 種拒絕策略

AbortPolicy 策略會拋出一個 RejectedExecutionException 異常給用戶,告訴它任務被拒絕了。

DiscardPolicy 策略當任務來臨時候不會給用戶任何反饋,悄無聲息拒絕任務。

DiscardOldestPolicy 策略比較霸道,它會直接將最早存儲在工作隊列的任務丟棄掉,然後再試圖去執行當前提交進來的任務。

CallerRunsPolicy 策略呢雖然線程池中的工人不幫忙處理任務了,它會佔用用戶線程去處理當前任務,這也就意味着用戶線程要處理完當前任務纔可以做其他事情。

使用上面的幾個核心參數完美的解決了任務的提交流程和工作分配問題,接下來就要來考慮一下後面的工作了。用戶提交了一大波任務以後,就不在提交了。這時候線程池的中工人都還在呢,如果一直保留這些資源但是又沒有活幹,會造成資源的浪費。這時候就需要用到 keepAliveTime 和 TimeUnit 參數了。

keepAliveTime 和 TimeUnit

這 2 個參數組合起來決定了一個工人最多可以在工廠裏愉快的摸魚時間,如果摸魚時間超過這個限度,這個工人資源就會被釋放,也就是這個空閒線程資源就被回收掉咯。當然啦,線程池會保留核心線程在工廠裏面等待新任務,以備有新任務的到來,我們也可以通過 public void allowCoreThreadTimeOut(boolean value) 方法設置參數,來允許線程池也可以釋放核心線程。

threadFactory

還剩下最後一個參數,它比較簡單,主要用來創建線程,例如我們想讓線程池中的線程做一些定製化的工作就可以自己來定義線程工廠,這樣線程池創建線程的時候就使用我們指定的工廠了。

你可能會覺得構建一個線程還要設置這麼參數,太麻煩了,貼心的 JDK 幫我們在 Executors 中準備了幾個靜態工廠方法,我們一起看一下它們的特性:

newFixedThreadPool(int nThreads) 可以創建一個固定線程數量的線程池,同時它的工作隊列是一個無界隊列。

newSingleThreadExecutor() 可以創建只有一個線程工作的線程池,同時它的工作隊列也是無界隊列。

newCachedThreadPool() 可以創建一個沒上限工作線程的線程池,它使用了 SynchronousQueue 只要有任務過來,如果有空閒的線程,會優先利用空閒的線程池,沒有空閒線程就會新創建線程。

newSingleThreadScheduledExecutor() 創建的是一個具有延遲和循環執行任務線程池,同時它內部也只有一個線程,它的工作隊列是一個具有延遲功能的隊列 DelayedWorkQueue。

newWorkStealingPool() 這種方法是 Java 8 提供的,它實際創建的是一個 ForkJoinTool 而不是 ThreadPoolExecutor 的實例。

如上即爲 5 中創建線程池的工廠方法,大家根據需要選擇適合自己工作的,當然也可以直接使用 ThreadPoolExecutor 來創建一個。

以上即爲昨天的問題的答案,小夥伴們對這個答案是否滿意呢?歡迎留言和我討論。

問題 11

不管你平時是否接觸大量的 IO 網絡編程,IO 模型都是高級 Java 工程師面試非常高頻的一道題。你瞭解 Java 的 IO 模型嗎?多路複用技術是什麼?

我的答案

在瞭解 Java IO 模型之前,我們先來明確幾個概念,初學者通常會被如下幾個概念給誤導:

同步和異步

同步指的是當程序在做一個任務的時候,必須做完當前任務才能繼續做下一個任務,這是一種可靠有序的運行機制,假設當前任務執行失敗了,可能就不會進行下一個任務了,往往在一些有依賴性的任務之間,我們使用同步機制。而異步恰恰相反,它不能保證有序性。程序在提交當前任務後,並不會等待任務結果,而是直接進行下一個任務,通常在一些任務之間沒有依賴性關係的時候可以使用異步機制。

這麼說可能還是有點抽象,我們舉個例子來說吧。假設有 4 個數字 a, b, c, d,我們需要計算它們連續相除的結果。那麼可以寫這樣一個函數:

public static int divide(int paraA, int paraB) {
  return paraA / paraB;
}

如上即爲我們的方法,假設我們使用同步機制去做,程序會寫成類似如下這樣:

int tmp1 = divide(a, b);
int tmp2 = divide(tmp1, c);
int result = divide(tmp2, d);

此處假如我們定義了 4 個數字的值爲如下:

int a = 1;
int b = 0;
int c = 1;
int d = 1;

這時候我們編寫的同步機制的程序,tmp2 的計算需要依賴於 tmp1,result 又依賴於 tmp2,事實上計算 tmp1 的值時候即會發生除數爲的 0 的異常 ArithmeticException。

我們也可以通過多線程來將這個程序轉換爲異步機制的方式去做,如下(我們不考慮整數進位造成的結果不同問題):

Callable<Integer> cA = () -> divide(a, b);
FutureTask<Integer> taskA = new FutureTask<>(cA);
new Thread(taskA).start();

Callable<Integer> cB = () -> divide(c, d);
FutureTask<Integer> taskB = new FutureTask<>(cB);
new Thread(taskB).start();

int tResult = taskA.get() / taskB.get();

如上我們使用多線程將同步的運作的程序修改爲了異步,先去同時計算 a / b 和 b / c 的結果,它倆之間沒有相互依賴,taskB 不會等待 taskA 的結果,taskA 出現 ArithmeticException 也不會影響 taskB 的運行。

這就是同步與異步,你 get 到了嗎?

阻塞和非阻塞

阻塞指的是當前線程在執行運算的時候會阻塞直到預期的結果出現後,線程纔可以繼續進行後續的操作。而非阻塞則是在執行某項操作後直接返回,無論結果是什麼。是不是還有點抽象,我們來舉個例子。改造一下上面的 divide 方法,將 divide 方法改造爲會阻塞的方法:

public synchronized int blockingDivide(int paraA, int paraB) throws InterruptedException {
  synchronized (SyncOrAsyncDemo.class) {
    wait(5000);
    return paraA / paraB;
  }
}

如上,我們將 divide 方法修改爲了一個會阻塞的方法,當我們的主線程去調用 blockingDivide 方法的時候,該方法會將當前線程阻塞直到方法運行結束。我們也可以使用多線程和回調將該方法修改爲一個非阻塞方法:

public synchronized void nonBlockingDivide(int paraA, int paraB, Callback callback) throws InterruptedException {
  new Thread(new Runnable() {
    @Override
    public void run() {
      synchronized (SyncOrAsyncDemo.class) {
        try {
          wait(5000);
        } catch (InterruptedException e) {
          e.printStackTrace();
        }
        callback.handleResult(paraA / paraB);
      }
    }
  }).start();
}

如上,我們將業務邏輯包裝到了一個單獨的線程中,執行結束後調用主線程設置好的回調函數即可。而主線程在調用該方法時不會阻塞,會直接返回結果,然後進行接下來的操作,這就是非阻塞機制。

弄清楚這幾個概念以後,讓我們一起來看看 Java IO 的幾種模型吧。

Blocking IO(同步阻塞 IO)

在 Java 1.0 時代 JDK 提供了面向 Stream 流的同步阻塞式 IO 模型的實現,讓我們用一段僞代碼實際感受一下:

try (ServerSocket serverSocket = new ServerSocket(8888)) {
  while (true) {
    Socket socket = serverSocket.accept();
    // 提交到線程池處理後續的任務
    executorService.submit(new ProcessRequest(socket));
  }
} catch (Exception e) {
  e.printStackTrace();
}

我們在一個死循環裏面調用了 ServerSocket 的阻塞方法 accept 方法,該方法調用後會阻塞直到有客戶端連接過來。如果此時有客戶端連接了,任務繼續進行,我們此處將連接後的處理放在了線程池中去處理。接着我們模擬一個讀取客戶端內容的邏輯,也就是 ProcessRequest 的內在邏輯:

try (BufferedReader reader = new BufferedReader(new InputStreamReader(socket.getInputStream()))) {
  int ch;
  while ((ch = reader.read()) != -1) {
    System.out.print((char)ch);
  }
} catch (Exception e) {
  e.printStackTrace();
}

我們採用 BufferedReader 讀取來自客戶端的內容,調用 read 方法後,服務器端線程會一直阻塞直至收到客戶端發送的內容過來,這就是 Java 1.0 提供的同步阻塞式 IO 模型。

Non-Blocking IO(同步非阻塞 IO)

在 Java 1.4 時代 JDK 爲我們提供了面 Channel 的同步非阻塞的 IO 模型實現,同樣以一段僞代碼來展示:

try {
  ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
  serverSocketChannel.bind(new InetSocketAddress("127.0.0.1", 8888));
  while (true) {
    SocketChannel socketChannel = serverSocketChannel.accept();
    executorService.execute(new ProcessChannel(socketChannel));
  }
} catch (Exception e) {
  e.printStackTrace();
}

默認情況下 ServerSocketChannel 採用的阻塞方式,調用 accept 方法會阻塞直到有客戶端連接過來,通過 Channel 的 read 方法獲取管道里面的內容時候同樣會阻塞直到客戶端有內容輸入到服務器端:

ByteBuffer buffer = ByteBuffer.allocate(1024);
while (true) {
  try {
    if (socketChannel.read(buffer) != -1) {
      // do something
    }
  } catch (IOException e) {
    e.printStackTrace();
  }
}

這時候我們可以調用 configureBlocking 方法,將管道設置爲非阻塞模式:

serverSocketChannel.configureBlocking(false);

這個時候調用 accept 方法就是非阻塞方式了,它會立即返回結果,但是返回結果有可能是 null,所以我們做額外的判斷處理,如:

if (socketChannel == null) continue;

你需要注意的是此時調用 Channel 的 read 方法仍然會阻塞當前線程知道有客戶端有結果返回,不是說非阻塞嗎,怎麼還是阻塞呢?是時候亮出大殺器 Selector 了。

Selector 多路複用器可以讓阻塞 IO 變得更加靈活,注意註冊 Selector 必須將 Channel 設置爲非阻塞模式:

/**省略部分相同的代碼**/
Selector selector = Selector.open();
serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT);
while (true) {
  selector.select();
  Set<SelectionKey> keys = selector.selectedKeys();
  Iterator<SelectionKey> iter = keys.iterator();
  while (iter.hasNext()) {
    SelectionKey key = iter.next();
    iter.remove();
    if (key.isAcceptable()) {
      SocketChannel socketChannel = ((ServerSocketChannel)key.channel()).accept();
      socketChannel.configureBlocking(false);
      socketChannel.register(selector, SelectionKey.OP_READ);
    }
    if (key.isReadable()) {
      SocketChannel channel = (SocketChannel) key.channel();
      ByteBuffer buffer = ByteBuffer.allocate(1024);
      if (channel.read(buffer) != -1) {
        buffer.flip();
        System.out.println(Charset.forName("utf-8").decode(buffer));
      } 
      key.cancel();
    }
  }

}

使用了 Selector 以後,我們會使用它的 select 方法來阻塞當前線程直到監聽到操作系統的 IO 就緒事件,這裏首先設置了 SelectionKey.OP_ACCEPT,當 select 方法返回時候代表 accept 已經就緒,服務器端與客戶端可以正式連接,這時候的連接操作會立即返回屬於非阻塞操作。

當與客戶端建立連接後,我們關注的是 SelectionKey.OP_READ 事件,僞代碼中使用

socketChannel.register(selector, SelectionKey.OP_READ)

註冊了這個事件,當 select 方法再次返回時候代表 IO 目前已經到達可讀狀態,可以直接調用 channel.read(buffer) 來讀取客戶端發送過來的內容,這時候的 read 方法同樣是一個非阻塞的操作。

如上就是 Java 1.4 爲我們提供的非阻塞 IO 模式加上 Selector 多路複用技術,從而擺脫一個客戶端連接佔用一個線程資源的窘境,此處只有 select 方法阻塞,其餘方法都是非阻塞運作。

雖然多路複用技術在性能上帶來了提升,但是你也看到了。非阻塞編程相對於阻塞模式的代碼段變得更加複雜了,而且還需要處理 NPE 問題。

Async Non-Blocking IO(異步非阻塞 IO)

Java 1.7 時代推出了異步非阻塞的 IO 模型,同樣以一段僞代碼來展示一下:

AsynchronousServerSocketChannel serverChannel = AsynchronousServerSocketChannel
                .open()
                .bind(new InetSocketAddress(8888));

serverChannel.accept(serverChannel, new CompletionHandler<AsynchronousSocketChannel, AsynchronousServerSocketChannel>() {
  @Override
  public void completed(AsynchronousSocketChannel result, AsynchronousServerSocketChannel attachment) {
    serverChannel.accept(serverChannel, this);
    ByteBuffer buffer = ByteBuffer.allocate(1024);
    /**連接客戶端成功後註冊 read 事件**/
    result.read(buffer, buffer, new CompletionHandler<Integer, ByteBuffer>() {
      @Override
      public void completed(Integer result, ByteBuffer attachment) {
        /**IO 可讀事件出現的時候,讀取客戶端發送過來的內容**/
        attachment.flip();
        System.out.println(Charset.forName("utf-8").decode(attachment));
      }
            /**省略無關緊要的方法**/
    });
  }
  /**省略無關緊要的方法**/
});

你會發現異步非阻塞的代碼量很少,而且 AsynchronousServerSocketChannel 的 accept 方法使用後完全不會阻塞我們的主線程。主線程繼續做後續的事情,在回調方法裏面處理 IO 就緒事件後面的流程,這與前面介紹的 2 種同步 IO 模型編程思想上有比較大的區別。

想必通過開頭介紹的幾個概念你已經可以想到這款異步非阻塞的 IO 模型背後的實現原理了,無非就是 JDK 幫助我們啓動了單獨的線程,將同步的 IO 操作轉換爲了異步的 IO 操作,同時利用操作的 IO 事件模型,將阻塞的方法轉換爲了非阻塞的方法。

當然啦,NIO 爲我們提供的也不僅僅是 Selector 多路複用技術,還有一些其他黑科技我們沒有提到,感興趣的話歡迎關注我等待後續的內容。

如上就是 Java 給我們提供的三種 IO 模型,通過我們一起探討,你現在是不是已經掌握了它們之間的區別呢?歡迎留言與我討論。

以上即爲昨天的問題的答案,小夥伴們對這個答案是否滿意呢?歡迎留言和我討論。

問題 12

上一期中我們一起討論了 Java 的三大 IO 模型,說到其中的 Non-Blocking IO 就不得不提零拷貝技術,你知道零拷貝技術嗎?

我的答案

想要弄清楚什麼是零拷貝,首先得明確一個問題,這裏的拷貝指的是什麼?我們這裏所描述的 拷貝 指的是在應用程序中將文件從 A 拷貝到 B,其中的 A 和 B 可以是電腦上的磁盤文件,也可以是網絡中的文件。像這樣的拷貝操作在操作系統中經歷了複雜的操作,首先應用程序發起讀取文件操作,讀取到文件後又發起寫入文件操作或者寫到網絡中去。

傳統的數據傳輸

瞭解傳統數據傳輸之前,我們要明確用戶態和內核態 2 個概念:

用戶態 是非特權執行狀態,該狀態下運行的程序被操作系統禁止進行一些危險操作,例如寫入系統配置文件,殺死其他用戶進程,重啓系統,不可直接訪問硬件設備。

內核態 是高級別特權執行狀態,運行在該狀態下的程序通常爲操作系統程序,具有高的特權級別,擁有訪問設備的所有權限。

讀、寫操作,在我們看來是一個完整的操作,其實在操作系統內部將讀操作又進行了細化拆分。

首先操作系統需要從用戶態切換到內核態,在內核態將文件內容從磁盤讀取到內核空間緩衝區中。

然後切換到用戶態,將文件內容從內核空間緩衝區讀取到用戶空間。

而寫操作正好是一個逆向的過程,程序需要先將文件內容寫到內核空間緩衝區,然後從用戶態切換到內核態,再將內容寫到磁盤裏,最後切換回用戶態。圖示爲如下:
在這裏插入圖片描述

聰明的寶寶肯定注意到了,這其中涉及到了 4 次的上下文切換和 4 次的數據拷貝操作,其中磁盤與內核態進行的拷貝操作應用了 DMA 技術(全稱 Direct Memory Access,它是一項由硬件設備支持的 IO 技術,應用這項技術可以更好的利用 CPU 資源,在此期間 CPU 可以去做其他事情),而內核態緩衝區到應用程序傳輸數據需要 CPU 的參與,在此期間 CPU 不能做其他工作。

mmap 提升拷貝性能

mmap 是一種內存映射的方法,它可以將磁盤上的文件映射進內存。用戶程序可以直接訪問內存即可達到訪問磁盤文件的效果,這種數據傳輸方法的性能高於將數據在內核空間和用戶之間來回拷貝操作,通常用於高性能要求的應用程序中。

因爲用戶態和內核態的上下文切換以及 CPU 數據拷貝是耗時的操作,所以可以考慮減少數據傳輸過程中的上下文切換和繁多的 CPU 拷貝數據操作,從而來提升數據傳輸性能。採用 mmap 來代替 read 系統調用可以有效減少內核空間到用戶空間之間的 CPU 拷貝數據操作,於是就誕生了如下的工作情形:

在這裏插入圖片描述
觀察如上拷貝示意圖,我們可以發現此時上下文切換操作縮減到了 2 次,應用程序發起拷貝操作後切換到內核態,數據直接在內核態完成傳輸而不需要拷貝到用戶態,但是此處仍然進行了 3 次拷貝操作,其中還包含一次耗時的 CPU 拷貝操作。彆着急,接下來我們看看終極版零拷貝。

零拷貝技術

我們知道 DMA 技術是高效的,因此只要去除掉 CPU 拷貝操作即可大大的提升性能。在 Linux 內核 2.1 版本中引入了 sendfile 系統調用,採用這種方式後內核態的緩衝區之間不再進行 CPU 拷貝操作,只需要將源數據的地址和長度告訴目標緩衝區,然後直接採用 DMA 技術將數據傳輸到目的地,如下圖示:

在這裏插入圖片描述
如上採用 sendfile 已經剔除了所有耗時的 CPU 拷貝操作,相比於傳統的數據拷貝操作性能更高,這就是所謂的零拷貝技術。

Java 中的零拷貝

使用傳統的文件拷貝方式在 Java 你會看到如下樣板代碼:

try (FileInputStream fis = new FileInputStream("sourceFile.txt");
     FileOutputStream fos = new FileOutputStream("targetFile.txt")) {
  byte datas[] = new byte[1024*8];
  int len = 0;

  while((len = fis.read(datas)) != -1){
    fos.write(datas, 0, len);
  }
}

使用如上方式進行文件拷貝的內在執行原理就如我們開頭的介紹的那樣,經過了多次用戶態和內核態的切換,並且伴隨着耗時的 CPU 拷貝操作,可想而知在遇到大文件拷貝時候效率會比較低下,此時可以考慮使用零拷貝技術。

在 Java 1.4 中, FileChannel 的 transferTo 方法即引入了零拷貝技術,讓我們來一起看一下,如何使用它來提升性能吧。

RandomAccessFile sourceFile = new RandomAccessFile("sourceFile.txt", "rw");
FileChannel fromChannel = sourceFile.getChannel();

RandomAccessFile targetFile = new RandomAccessFile("targetFile.txt", "rw");
FileChannel toChannel = targetFile.getChannel();

fromChannel.transferTo(0, fromChannel.size(), toChannel);

如上我們首先獲取 FilleChannel,然後調用 FileChannel 的 transferTo 方法即可實現零拷貝操作。內在執行原理就是使用 sendfile 系統調用,剔除了耗時的 CPU 拷貝操作,同時用戶態和內核態的上下文切換也是最少的,當你遇到文件拷貝的性能問題時,你可以考慮一下 FilleChannel。

FileChannel 中還提供了其他的方法,例如 transferFrom 方法,感興趣的小夥伴可以自己嘗試一下。

總結

以上即爲比較高頻的 12 道毛毛蟲認爲的高頻 Java 面試題,由於個人能力有限,文中難免有所疏漏,希望大家多多指正。

歡迎有疑問的小夥伴留言與我交流,也歡迎有志於深入技術鑽研的同學加入公衆號 Java dev 一起探討學習,一起交流,offer 來得更簡單點。

在這裏插入圖片描述

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章