面試題-Netty篇


核心組件

1、整體結構

image-20210504171606057

​ Core 核心層
​ Core 核心層是 Netty 最精華的內容,它提供了底層網絡通信的通用抽象和實現,包括事件模型、通用API、支持零拷貝的 ByteBuf 等。

​ Protocol Support 協議支持層
​ 協議支持層基本上覆蓋了主流協議的編解碼實現,如 HTTP、Protobuf、WebSocket、二進制等主流協議,此外 Netty 還支持自定義應用層協議。Netty 豐富的協議支持降低了用戶的開發成本,基於 Netty 我們可以快速開發 HTTP、WebSocket 等服務。

​ Transport Service 傳輸服務層
​ 傳輸服務層提供了網絡傳輸能力的定義和實現方法。它支持 Socket、HTTP 隧道、虛擬機管道等傳輸方式。Netty 對 TCP、UDP 等數據傳輸做了抽象和封裝,用戶可以更聚焦在業務邏輯實現上,而不必關系底層數據傳輸的細節。

2、邏輯架構

image-20210504171514089

​ 網絡通信層
​ 網絡通信層的職責是執行網絡 I/O 的操作。它支持多種網絡協議和 I/O 模型的連接操作。當網絡數據讀取到內核緩沖區后,會觸發各種網絡事件,這些網絡事件會分發給事件調度層進行處理。

網絡通信層的核心組件包含BootStrap、ServerBootStrap、Channel三個組件。

​ Bootstrap 是“引導”的意思,負責 Netty 客戶端程序的啟動、初始化、服務器連接等過程,串聯了 Netty 的其他核心組件。

​ ServerBootStrap 用於服務端啟動綁定本地端口,會綁定Boss 和 Worker兩個 EventLoopGroup。

​ Channel 的是“通道”,Netty Channel提供了基於NIO更高層次的抽象,如 register、bind、connect、read、write、flush 等。

​ 事件調度層
​ 事件調度層的職責是通過 Reactor 線程模型對各類事件進行聚合處理,通過 Selector 主循環線程集成多種事件( I/O 事件、信號事件、定時事件等),實際的業務處理邏輯是交由服務編排層中相關的 Handler 完成。

事件調度層的核心組件包括 EventLoopGroup、EventLoop

image-20210504171631793

​ EventLoop 負責處理 Channel 生命周期內的所有 I/O 事件,如 accept、connect、read、write 等 I/O 事件

​ ①一個 EventLoopGroup 往往包含一個或者多個 EventLoop。

​ ②EventLoop 同一時間會與一個Channel綁定,每個 EventLoop 負責處理一種類型 Channel

​ ③Channel 在生命周期內可以對和多個 EventLoop 進行多次綁定和解綁

​ EventLoopGroup 是Netty 的核心處理引擎,本質是一個線程池,主要負責接收 I/O 請求,並分配線程執行處理請求。通過創建不同的 EventLoopGroup 參數配置,就可以支持 Reactor 的三種線程模型:

​ 單線程模型:EventLoopGroup 只包含一個 EventLoop,Boss 和 Worker 使用同一個EventLoopGroup;

​ 多線程模型:EventLoopGroup 包含多個 EventLoop,Boss 和 Worker 使用同一個EventLoopGroup;

​ 主從多線程模型:EventLoopGroup 包含多個 EventLoop,Boss 是主 Reactor,Worker 是從 Reactor,它們分別使用不同的 EventLoopGroup,主 Reactor 負責新的網絡連接 Channel 創建,然后把 Channel 注冊到從 Reactor。

​ 服務編排層
​ 服務編排層的職責是負責組裝各類服務,它是 Netty 的核心處理鏈,用以實現網絡事件的動態編排和有序傳播。

服務編排層的核心組件包括 ChannelPipeline、ChannelHandler、ChannelHandlerContext

​ ChannelPipeline 是 Netty 的核心編排組件,負責組裝各種 ChannelHandler,ChannelPipeline 內部通過雙向鏈表將不同的 ChannelHandler 鏈接在一起。當 I/O 讀寫事件觸發時,Pipeline 會依次調用 Handler 列表對 Channel 的數據進行攔截和處理。

image-20210504171749533

​ 客戶端和服務端都有各自的 ChannelPipeline。客戶端和服務端一次完整的請求:客戶端出站(Encoder 請求數據)、服務端入站(Decoder接收數據並執行業務邏輯)、服務端出站(Encoder響應結果)。

​ ChannelHandler 完成數據的編解碼以及處理工作。

image-20210504171829071

​ ChannelHandlerContext 用於保存Handler 上下文,通過 HandlerContext 我們可以知道 Pipeline 和 Handler 的關聯關系。HandlerContext 可以實現 Handler 之間的交互,HandlerContext 包含了 Handler 生命周期的所有事件,如 connect、bind、read、flush、write、close 等。同時,HandlerContext 實現了Handler通用的邏輯的模型抽象。

image-20210504171924019

網絡傳輸

1、五種IO模型的區別

阻塞I/O:(BIO)

image-20210504171959821

​ 應用進程向內核發起 I/O 請求,發起調用的線程一直等待內核返回結果。一次完整的 I/O 請求稱為BIO(Blocking IO,阻塞 I/O),所以 BIO 在實現異步操作時,只能使用多線程模型,一個請求對應一個線程。但是,線程的資源是有限且寶貴的,創建過多的線程會增加線程切換的開銷。

同步非阻塞I/O(NIO):

image-20210504172029418

​ 應用進程向內核發起 I/O 請求后不再會同步等待結果,而是會立即返回,通過輪詢的方式獲取請求結果。NIO 相比 BIO 雖然大幅提升了性能,但是輪詢過程中大量的系統調用導致上下文切換開銷很大。所以,單獨使用非阻塞 I/O 時效率並不高,而且隨着並發量的提升,非阻塞 I/O 會存在嚴重的性能浪費。

多路復用I/O(select和poll):

image-20210504172057237

​ 多路復用實現了一個線程處理多個 I/O 句柄的操作。多路指的是多個數據通道,復用指的是使用一個或多個固定線程來處理每一個 Socket。select、poll、epoll 都是 I/O 多路復用的具體實現,線程一次 select 調用可以獲取內核態中多個數據通道的數據狀態。其中,select只負責等,recvfrom只負責拷貝,阻塞IO中可以對多個文件描述符進行阻塞監聽,是一種非常高效的 I/O 模型。

信號驅動I/O(SIGIO):

image-20210504172124965

​ 信號驅動IO模型,應用進程告訴內核:當數據報准備好的時候,給我發送一個信號,對SIGIO信號進行捕捉,並且調用我的信號處理函數來獲取數據報。

異步I/O(Posix.1的aio_系列函數):

image-20210504172148095

​ 當應用程序調用aio_read時,內核一方面去取數據報內容返回,另一方面將程序控制權還給應用進程,應用進程繼續處理其他事情,是一種非阻塞的狀態。當內核中有數據報就緒時,由內核將數據報拷貝到應用程序中,返回aio_read中定義好的函數處理程序。

2、Reactor多線程模型

​ Netty 的 I/O 模型是基於非阻塞 I/O 實現的,底層依賴的是 NIO 框架的多路復用器 Selector。采用 epoll 模式后,只需要一個線程負責 Selector 的輪詢。當有數據處於就緒狀態后,需要一個事件分發器(Event Dispather),它負責將讀寫事件分發給對應的讀寫事件處理器(Event Handler)。事件分發器有兩種設計模式:Reactor 和 Proactor,Reactor 采用同步 I/O, Proactor 采用異步 I/O。

image-20210504172207142

​ Reactor 實現相對簡單,適合處理耗時短的場景,對於耗時長的 I/O 操作容易造成阻塞。Proactor 性能更高,但是實現邏輯非常復雜,適合圖片或視頻流分析服務器,目前主流的事件驅動模型還是依賴 select 或 epoll 來實現。

3、拆包粘包問題

拆包TCP 傳輸協議是面向流的,沒有數據包界限。
MTU(Maxitum Transmission Unit) 是鏈路層一次最大傳輸數據的大小。MTU 一般來說大小為 1500 byte。MSS(Maximum Segement Size) 是指 TCP 最大報文段長度,它是傳輸層一次發送最大數據的大小。

image-20210504172233844

如上圖所示,如果 MSS + TCP 首部 + IP 首部 > MTU,那么數據包將會被拆分為多個發送。這就是拆包現象

Nagle 算法
Nagle 算法可以理解為批量發送,也是我們平時編程中經常用到的優化思路,它是在數據未得到確認之前先寫入緩沖區,等待數據確認或者緩沖區積攢到一定大小再把數據包發送出去。Netty 中為了使數據傳輸延遲最小化,就默認禁用了 Nagle 算法

拆包/粘包的解決方案

在客戶端和服務端通信的過程中,服務端一次讀到的數據大小是不確定的。需要確定邊界:

消息長度固定
特定分隔符
消息長度 + 消息內容(Netty)

4、自定義協議

Netty 常用編碼器類型:

MessageToByteEncoder //對象編碼成字節流;

MessageToMessageEncoder //一種消息類型編碼成另外一種消息類型。

Netty 常用解碼器類型:

ByteToMessageDecoder/ReplayingDecoder //將字節流解碼為消息對象;

MessageToMessageDecoder //將一種消息類型解碼為另外一種消息類型。

編解碼器可以分為一次解碼器二次解碼器,一次解碼器用於解決 TCP 拆包/粘包問題,按協議解析后得到的字節數據。如果你需要對解析后的字節數據做對象模型的轉換,這時候便需要用到二次解碼器,同理編碼器的過程是反過來的。

Netty自定義協議內容:

/*
+---------------------------------------------------------------+
| 魔數 2byte | 協議版本號 1byte | 序列化算法 1byte | 報文類型 1byte  |
+---------------------------------------------------------------+
| 狀態 1byte |        保留字段 4byte     |      數據長度 4byte     | 
+---------------------------------------------------------------+
|                   數據內容 (長度不定)                          |
+---------------------------------------------------------------+
 */

如何判斷 ByteBuf 是否存在完整的報文?最常用的做法就是通過讀取消息長度 dataLength 進行判斷。如果 ByteBuf 的可讀數據長度小於 dataLength,說明 ByteBuf 還不夠獲取一個完整的報文。

5、WriteAndFlush

image-20210504172303465

​ ①writeAndFlush 屬於出站操作,它是從 Pipeline 的 Tail 節點開始進行事件傳播,一直向前傳播到 Head 節點。不管在 write 還是 flush 過程,Head 節點都中扮演着重要的角色。

​ ②write 方法並沒有將數據寫入 Socket 緩沖區,只是將數據寫入到 ChannelOutboundBuffer 緩存中,ChannelOutboundBuffer 緩存內部是由單向鏈表實現的。

​ ③flush 方法才最終將數據寫入到 Socket 緩沖區。

內存管理

1、堆外內存

​ 在 Java 中對象都是在堆內分配的,通常我們說的JVM 內存也就指的堆內內存堆內內存完全被JVM 虛擬機所管理,JVM 有自己的垃圾回收算法,對於使用者來說不必關心對象的內存如何回收。堆外內存與堆內內存相對應,對於整個機器內存而言,除堆內內存以外部分即為堆外內存。堆外內存不受 JVM 虛擬機管理,直接由操作系統管理。使用堆外內存有如下幾個優點:

  1. 堆內內存由 JVM GC 自動回收內存,降低了 Java 用戶的使用心智,堆外內存由於不受 JVM 管理,所以在一定程度上可以降低 GC 對應用運行時帶來的影響。
  2. 堆外內存需要手動釋放,這一點跟 C/C++ 很像,稍有不慎就會造成應用程序內存泄漏,當出現內存泄漏問題時排查起來會相對困難。
  3. 當進行網絡 I/O 操作、文件讀寫時,堆內內存都需要轉換為堆外內存,然后再與底層設備進行交互,所以直接使用堆外內存可以減少一次內存拷貝。
  4. 堆外內存可以方便實現進程之間、JVM 多實例之間的數據共享。

​ 在堆內存放的 DirectByteBuffer 對象並不大,僅僅包含堆外內存的地址、大小等屬性,同時還會創建對應的 Cleaner 對象,通過 ByteBuffer 分配的堆外內存不需要手動回收,它可以被 JVM 自動回收。當堆內的 DirectByteBuffer 對象被 GC 回收時,Cleaner 就會用於回收對應的堆外內存。

image-20210504172324820

​ 從 DirectByteBuffer 的構造函數中可以看出,真正分配堆外內存的邏輯還是通過 unsafe.allocateMemory(size),Unsafe 是一個非常不安全的類,它用於執行內存訪問、分配、修改等敏感操作,可以越過 JVM 限制的枷鎖。Unsafe 最初並不是為開發者設計的,使用它時雖然可以獲取對底層資源的控制權,但也失去了安全性的保證,使用 Unsafe 一定要慎重(Java 中是不能直接使用 Unsafe 的,但是可以通過反射獲取 Unsafe 實例)。Netty 中依賴了 Unsafe 工具類,是因為 Netty 需要與底層 Socket 進行交互,Unsafe 提升 Netty 的性能

​ 因為DirectByteBuffer 對象的回收需要依賴 Old GC 或者 Full GC 才能觸發清理,如果長時間沒有 GC執行,那么堆外內存即使不再使用,也會一直在占用內存不釋放,很容易將機器的物理內存耗盡。-XX:MaxDirectMemorySize 指定堆外內存的上限大小,超出時觸發GC,仍無法釋放拋出OOM異常。

image-20210504172324820

​ 當初始化堆外內存時,內存中的對象引用情況如下圖所示,first 是 Cleaner 類中的靜態變量,Cleaner 對象在初始化時會加入 Cleaner 鏈表中。DirectByteBuffer 對象包含堆外內存的地址、大小以及 Cleaner 對象的引用,ReferenceQueue 用於保存需要回收的 Cleaner 對象。

2、數據載體ByteBuf

JDK NIO 的 ByteBuffer

  • mark:為某個讀取過的關鍵位置做標記,方便回退到該位置;
  • position:當前讀取的位置;
  • limit:buffer 中有效的數據長度大小;
  • capacity:初始化時的空間容量。

image-20210504172358702

​ 第一,ByteBuffer 分配的長度是固定的,無法動態擴縮容,每次在存放數據的時候對容量大小做校驗,擴容需要將已有的數據遷移。

​ 第二,ByteBuffer 只能通過 position 獲取當前可操作的位置,因為讀寫共用的 position 指針,所以需要頻繁調用 flip、rewind 方法切換讀寫狀態。

Netty中的ByteBuf

  • 廢棄字節,表示已經丟棄的無效字節數據。
  • 可讀字節,表示 ByteBuf 中可以被讀取的字節內容,可以通過 writeIndex - readerIndex 計算得出。當讀寫位置重疊時時,表示 ByteBuf 已經不可讀。
  • 可寫字節,向 ByteBuf 中寫入數據都會存儲到可寫字節區域。當 writeIndex 超過 capacity,表示 ByteBuf 容量不足,需要擴容。
  • 可擴容字節,表示 ByteBuf 最多還可以擴容多少字節,最多擴容到 maxCapacity 為止,超過 maxCapacity 再寫入就會出錯。

image-20210504173347000

引用計數

​ 當byteBuf當引用計數為 0,該 ByteBuf 可以被放入到對象池中,避免每次使用 ByteBuf 都重復創建。

​ JVM 並不知道 Netty 的引用計數是如何實現的,當 ByteBuf 對象不可達時,一樣會被 GC 回收掉,但是如果此時 ByteBuf 的引用計數不為 0,那么該對象就不會釋放或者被放入對象池,從而發生了內存泄漏。Netty 會對分配的 ByteBuf 進行抽樣分析,檢測 ByteBuf 是否已經不可達且引用計數大於 0,判定內存泄漏的位置並輸出到日志中,通過關注日志中 LEAK 關鍵字可以找到內存泄漏的具體對象

3、內存分配jemalloc

​ 為了減少分配時產生的內部碎片和外部碎片,常見的內存分配算法動態內存分配伙伴算法Slab 算法

動態內存分配(DMA)

​ ⾸次適應算法(first fit),空閑分區鏈以地址遞增的順序將空閑分區以雙向鏈表的形式連接在一起,從空閑分區鏈中找到第一個滿足分配條件的空閑分區,然后從空閑分區中划分出一塊可用內存給請求進程,剩余的空閑分區仍然保留在空閑分區鏈中。

image-20210504173407923

​ 循環首次適應算法(next fit)不再是每次從鏈表的開始進行查找,而是從上次找到的空閑分區的以后開始查找。查找效率提升,會產生更多的碎片。

​ 最佳適應算法(best fit),空閑分區鏈以空閑分區大小遞增的順序將空閑分區以雙向鏈表的形式連接在一起,每次從空閑分區鏈的開頭進行查找。

伙伴算法(外部碎片少,內部碎片多)

​ 是一種非常經典的內存分配算法,它采用了分離適配的設計思想,將物理內存按照 2 的次冪進行划分,內存分配時也是按照 2 的次冪大小進行按需分配

image-20210504173433749

  1. 首先需要找到存儲 2^4 連續 Page 所對應的鏈表,即數組下標為 4;
  2. 查找 2^4 鏈表中是否有空閑的內存塊,如果有則分配成功;
  3. 如果 2^4 鏈表不存在空閑的內存塊,則繼續沿數組向上查找,即定位到數組下標為 5 的鏈表,鏈表中每個節點存儲 2^5 的連續 Page;
  4. 如果 2^5 鏈表中存在空閑的內存塊,則取出該內存塊並將它分割為 2 個 2^4 大小的內存塊,其中一塊分配給進程使用,剩余的一塊鏈接到 2^4 鏈表中。

Slab 算法(解決伙伴算法內部碎片問題)

​ Slab 算法在伙伴算法的基礎上,對小內存的場景專門做了優化,采用了內存池的方案,解決內部碎片問題。

image-20210504173454562

在 Slab 算法中維護着大小不同的 Slab 集合,將這塊內存划分為大小相同的 slot,不會對內存塊再進行合並,同時使用位圖 bitmap 記錄每個 slot 的使用情況。

​ kmem_cache 中包含三個 Slab 鏈表:完全分配使用 slab_full部分分配使用 slab_partial完全空閑 slabs_empty,這三個鏈表負責內存的分配和釋放。Slab 算法是基於對象進行內存管理的,它把相同類型的對象分為一類。當分配內存時,從 Slab 鏈表中划分相應的內存單元;單個 Slab 可以在不同的鏈表之間移動,例如當一個 Slab 被分配完,就會從 slab_partial 移動到 slabs_full,當一個 Slab 中有對象被釋放后,就會從 slab_full 再次回到 slab_partial,所有對象都被釋放完的話,就會從 slab_partial 移動到 slab_empty。當釋放內存時,Slab 算法並不會丟棄已經分配的對象,而是將它保存在緩存中,當下次再為對象分配內存時,直接會使用最近釋放的內存塊

4、jemalloc 架構

  • 內存是由一定數量的 arenas 負責管理,線程均勻分布在 arenas 當中;
  • 每個 arena 都包含一個 bin 數組,每個 bin 管理不同檔位的內存塊;
  • 每個 arena 被划分為若干個 chunks,每個 chunk 又包含若干個 runs,每個 run 由連續的 Page 組成,run 才是實際分配內存的操作對象;
  • 每個 run 會被划分為一定數量的 regions,在小內存的分配場景,region 相當於用戶內存;
  • 每個 tcache 對應一個 arena,tcache 中包含多種類型的 bin。

image-20210504173454562

內存管理Arena ,內存由一定數量的 arenas 負責管理。每個用戶線程采用 round-robin 輪詢的方式選擇可用的 arena 進行內存分配。

分級管理Bin,每個 bin 管理的內存大小是按分類依次遞增。jemalloc 中小內存的分配是基於 Slab 算法完成的,會產生不同類別的內存塊。

Page集合chunk,chunk 以 Page 為單位管理內存。每個 chunk 可被用於多次小內存的申請,但是在大內存分配的場景下只能分配一次。

實際分配單位run,run 結構具體的大小由不同的 bin 決定,例如 8 字節的 bin 對應的 run 只有一個 Page,可以從中選取 8 字節的塊進行分配。

run 細分region,每個 run 會將划分為若干個等長的 region,每次內存分配也是按照 region 進行分發。

tcache 是每個線程私有的緩存,tcache 每次從 arena 申請一批內存,在分配內存時首先在 tcache 查找,避免鎖競爭,分配失敗才會通過 run 執行內存分配。

image-20211205121406792

Small 場景,如果請求分配內存的大小小於 arena 中的最小的 bin,那么優先從線程中對應的 tcache 中進行分配。首先確定查找對應的 tbin 中是否存在緩存的內存塊,如果存在則分配成功,否則找到 tbin 對應的 arena,從 arena 中對應的 bin 中分配 region 保存在 tbin 的 avail 數組中,最終從 availl 數組中選取一個地址進行內存分配,當內存釋放時也會將被回收的內存塊進行緩存。

Large 場景的內存分配與 Smalll 類似,如果請求分配內存的大小大於 arena 中的最小的 bin,但是不大於 tcache 中能夠緩存的最大塊,依然會通過 tcache 進行分配,但是不同的是此時會分配 chunk 以及所對應的 run,從 chunk 中找到相應的內存空間進行分配。內存釋放時也跟 samll 場景類似,會把釋放的內存塊緩存在 tacache 的 tbin 中。此外還有一種情況,當請求分配內存的大小大於tcache 中能夠緩存的最大塊,但是不大於 chunk 的大小,那么將不會采用 tcache 機制,直接在 chunk 中進行內存分配。

Huge 場景,如果請求分配內存的大小大於 chunk 的大小,那么直接通過 mmap 進行分配,調用 munmap 進行回收。

5、內存池設計(待補充)

6、Recycle對象池(待補充)

7、零拷貝技術

  1. 當用戶進程發起 read() 調用后,上下文從用戶態切換至內核態。DMA 引擎從文件中讀取數據,並存儲到內核態緩沖區,這里是第一次數據拷貝
  2. 請求的數據從內核態緩沖區拷貝到用戶態緩沖區,然后返回給用戶進程。第二次數據拷貝的過程同時,會導致上下文從內核態再次切換到用戶態。
  3. 用戶進程調用 send() 方法期望將數據發送到網絡中,用戶態會再次切換到內核態,第三次數據拷貝請求的數據從用戶態緩沖區被拷貝到 Socket 緩沖區。
  4. 最終 send() 系統調用結束返回給用戶進程,發生了第四次上下文切換。第四次拷貝會異步執行,從 Socket 緩沖區拷貝到協議引擎中。

image-20210504175240348

​ 在 Linux 中系統調用 sendfile() 可以實現將數據從一個文件描述符傳輸到另一個文件描述符,從而實現了零拷貝技術。

​ 在 Java 中也使用了零拷貝技術,它就是 NIO FileChannel 類中的 transferTo() 方法,它可以將數據從 FileChannel 直接傳輸到另外一個 Channel。

image-20210504175323875

Netty 中的零拷貝技術除了操作系統級別的功能封裝,更多的是面向用戶態的數據操作優化,主要體現在以下 5 個方面:

  • 堆外內存,避免 JVM 堆內存到堆外內存的數據拷貝。
  • CompositeByteBuf 類,可以組合多個 Buffer 對象合並成一個邏輯上的對象,避免通過傳統內存拷貝的方式將幾個 Buffer 合並成一個大的 Buffer。
  • 通過 Unpooled.wrappedBuffer 可以將 byte 數組包裝成 ByteBuf 對象,包裝過程中不會產生內存拷貝。
  • ByteBuf.slice ,slice 操作可以將一個 ByteBuf 對象切分成多個 ByteBuf 對象,切分過程中不會產生內存拷貝,底層共享一個 byte 數組的存儲空間。
  • Netty 使用 封裝了transferTo() 方法 FileRegion,可以將文件緩沖區的數據直接傳輸到目標 Channel,避免內核緩沖區和用戶態緩沖區之間的數據拷貝。

高性能數據結構

1、FastThreadLocal

​ ThreadLocal 可以理解為線程本地變量。ThreadLocal 為變量在每個線程中都創建了一個副本,該副本只能被當前線程訪問,多線程之間是隔離的,變量不能在多線程之間共享。這樣每個線程修改變量副本時,不會對其他線程產生影響。

​ 既然多線程訪問 ThreadLocal 變量時都會有自己獨立的實例副本,那么很容易想到的方案就是在 ThreadLocal 中維護一個 Map,記錄線程與實例之間的映射關系。當新增線程和銷毀線程時都需要更新 Map 中的映射關系,因為會存在多線程並發修改,所以需要保證 Map 是線程安全的。但是在高並發的場景並發修改 Map 需要加鎖,勢必會降低性能。

image-20210504175349901

​ JDK 為了避免加鎖,采用了相反的設計思路。以 Thread 入手,在 Thread 中維護一個 Map,記錄 ThreadLocal 與實例之間的映射關系,這樣在同一個線程內,Map 就不需要加鎖了。

image-20210504175406824

​ ThreadLocalMap 是一種使用線性探測法實現的哈希表,底層采用數組存儲數據,通過魔數0x61c88647來使散列更加平衡。ThreadLocalMap 初始化一個長度為 16 的 Entry 數組。與 HashMap 不同的是,Entry 的 key 就是 ThreadLocal對象本身,value 就是用戶具體需要存儲的值。

image-20210504175428964

​ Entry 繼承自弱引用類 WeakReference,Entry 的 key 是弱引用,value 是強引用。在 JVM 垃圾回收時,只要發現了弱引用的對象,不管內存是否充足,都會被回收。那么為什么 Entry 的 key 要設計成弱引用呢?如果 key 都是強引用,當線 ThreadLocal 不再使用時,然而 ThreadLocalMap 中還是存在對 ThreadLocal 的強引用,那么 GC 是無法回收的,從而造成內存泄漏。

img

​ 雖然 Entry 的 key 設計成了弱引用,但是當 ThreadLocal不再使用(業務邏輯走完,但是由於線程復用導致線程並沒有結束)被 GC 回收后,ThreadLocalMap 中可能出現 Entry 的 key 為 NULL,那么 Entry 的 value 一直會強引用數據而得不到釋放,只能等待線程銷毀。那么應該如何避免 ThreadLocalMap 內存泄漏呢?ThreadLocal 已經幫助我們做了一定的保護措施,在執行 ThreadLocal.set()/get() 方法時,ThreadLocal 會清除 ThreadLocalMap 中 key 為 NULL 的 Entry 對象,讓它還能夠被 GC 回收。除此之外,當線程中某個 ThreadLocal 對象不再使用時,立即調用 remove() 方法刪除 Entry 對象。如果是在異常的場景中,應在 finally 代碼塊中進行清理,保持良好的編碼意識。在Netty中,可以方便的使用FashThreadLocal來防止內存泄漏

img

FastThreadLocal

​ FastThreadLocal 使用 Object 數組替代了 Entry 數組,Object[0] 存儲的是一個Set<FastThreadLocal<?>> 集合,從數組下標 1 開始都是直接存儲的 value 數據,不再采用 ThreadLocal 的鍵值對形式進行存儲。主要是針對set方法,增加了兩個額外的行為。

  1. 找到數組下標 index 位置,設置新的 value。
  2. 將 FastThreadLocal 對象保存到待清理的 Set 中

image-20210504175457264

  • 高效查找。FastThreadLocal 在定位數據的時候可以直接根據數組下標 index 獲取,時間復雜度 O(1)。而 JDK 原生的 ThreadLocal 在數據較多時哈希表很容易發生 Hash 沖突,線性探測法在解決 Hash 沖突時需要不停地向下尋找,效率較低。此外,FastThreadLocal 相比 ThreadLocal 數據擴容更加簡單高效,FastThreadLocal 以 index 為基准向上取整到 2 的次冪作為擴容后容量,然后把原數據拷貝到新數組。而 ThreadLocal 由於采用的哈希表,所以在擴容后需要再做一輪 rehash。
  • 安全性更高。JDK 原生的 ThreadLocal 使用不當可能造成內存泄漏,只能等待線程銷毀。在使用線程池的場景下,ThreadLocal 只能通過主動檢測的方式防止內存泄漏,從而造成了一定的開銷。然而 FastThreadLocal 不僅提供了 remove() 主動清除對象的方法,而且在線程池場景中 Netty 還封裝了 FastThreadLocalRunnable,任務執行完畢后一定會執行 FastThreadLocal.removeAll() 將 Set 集合中所有 FastThreadLocal 對象都清理掉

2、HashedTimerWheel

​ 生成月統計報表、每日得分結算、郵件定時推送

​ 定時任務三種形式:

​ 1、按固定周期定時執行

​ 2、延遲一定時間后執行

​ 3、指定某個時刻執行

​ 定時任務的三個關鍵方法:

​ Schedule 新增任務至任務集合;

​ Cancel 取消某個任務;

​ Run 執行到期的任務

JDK自帶的三種定時器:TimerDelayedQueue 和 ScheduledThreadPoolExecutor

Timer小根堆隊列,deadline 任務位於堆頂端,彈出的始終是最優先被執行的任務。Run 操作時間復雜度 O(1),Schedule 和Cancel 操作的時間復雜度都是 O(logn)。

不論有多少任務被加入數組,始終由 異步線程TimerThread 負責處理。TimerThread 會定時輪詢 TaskQueue 中的任務,如果堆頂的任務的 deadline 已到,那么執行任務;如果是周期性任務,執行完成后重新計算下一次任務的 deadline,並再次放入小根堆;如果是單次執行的任務,執行結束后會從 TaskQueue 中刪除。

DelayedQueue 采用優先級隊列 PriorityQueue延遲獲取對象的阻塞隊列。DelayQueue中的每個對象都必須實現Delayed 接口,並重寫 compareTo 和 getDelay 方法。

DelayQueue 提供了 put() 和 take() 的阻塞方法,可以向隊列中添加對象和取出對象。對象被添加到 DelayQueue 后,會根據 compareTo() 方法進行優先級排序。getDelay() 方法用於計算消息延遲的剩余時間,只有 getDelay <=0 時,該對象才能從 DelayQueue 中取出。

DelayQueue 在日常開發中最常用的場景就是實現重試機制。例如,接口調用失敗或者請求超時后,可以將當前請求對象放入 DelayQueue,通過一個異步線程 take() 取出對象然后繼續進行重試。如果還是請求失敗,繼續放回 DelayQueue。可以設置重試的最大次數以及采用指數退避算法設置對象的 deadline,如 2s、4s、8s、16s ……以此類推。DelayQueue的時間復雜度和Timer基本一致。

為了解決 Timer 的設計缺陷,JDK 提供了功能更加豐富的 ScheduledThreadPoolExecutor,多線程、相對時間、對異常

​ Timer 是單線程模式。如果某個 TimerTask 執行時間很久,會影響其他任務的調度。

​ Timer 的任務調度是基於系統絕對時間的,如果系統時間不正確,可能會出現問題。

​ TimerTask 如果執行出現異常,Timer 並不會捕獲,會導致線程終止,其他任務永遠不會執行。

時間輪原理分析

image-20210504175518335

根據任務的到期時間進行取余和取模,然后根據取余結果將任務分布到不同的 slot 中,每個slot中根據round值決定是否操作,每次輪詢到指定slot時,總時遍歷最少round的對象進行執行,這樣新增、執行兩個操作的時間復雜度都近似O(1)。如果沖突較大可以增加數組長度,或者采用多級時間輪的方式處理。

public HashedWheelTimer(        ThreadFactory threadFactory, //線程池,但是只創建了一個線程        long tickDuration, //時針每次 tick 的時間,相當於時針間隔多久走到下一個 slot        TimeUnit unit,             //表示 tickDuration 的時間單位,tickDuration * unit        int ticksPerWheel,  //時間輪上一共有多少個 slot,默認 512 個。        boolean leakDetection,        long maxPendingTimeouts) {//最大允許等待任務數    // 省略其他代碼    wheel = createWheel(ticksPerWheel); // 創建時間輪的環形數組結構    mask = wheel.length - 1; // 用於快速取模的掩碼    long duration = unit.toNanos(tickDuration); // 轉換成納秒處理    workerThread = threadFactory.newThread(worker); // 創建工作線程    leak = leakDetection || !workerThread.isDaemon() ? leakDetector.track(this) : null; // 是否開啟內存泄漏檢測    this.maxPendingTimeouts = maxPendingTimeouts; // 最大允許等待任務數,HashedWheelTimer 中任務超出該閾值時會拋出異常}

 

image-20210504175531294

​ 時間輪空推進問題

​ Netty 中的時間輪是通過固定的時間間隔 tickDuration 進行推動的,如果長時間沒有到期任務,那么會存在時間輪空推進的現象,從而造成一定的性能損耗。此外,如果任務的到期時間跨度很大,例如 A 任務 1s 后執行,B 任務 6 小時之后執行,也會造成空推進的問題。

Kafka解決方案

​ 為了解決空推進的問題,Kafka 借助 JDK 的 DelayQueue 來負責推進時間輪。DelayQueue 保存了時間輪中的每個 Bucket,並且根據 Bucket 的到期時間進行排序,最近的到期時間被放在 DelayQueue 的隊頭。Kafka 中會有一個線程來讀取 DelayQueue 中的任務列表,如果時間沒有到,那么 DelayQueue 會一直處於阻塞狀態,從而解決空推進的問題。雖然DelayQueue 插入和刪除的性能不是很好,但這其實就是一種權衡的策略,但是DelayQueue 只存放了 Bucket,Bucket 的數量並不多,相比空推進帶來的影響是利大於弊的。

​ 為了解決任務時間跨度很大的問題,Kafka 引入了層級時間輪,如下圖所示。當任務的 deadline 超出當前所在層的時間輪表示范圍時,就會嘗試將任務添加到上一層時間輪中,跟鍾表的時針、分針、秒針的轉動規則是同一個道理。

3、MpscQueue

4、select、poll、epoll的區別

select (windows)**poll **(linux)本質上和select沒有區別,查詢每個fd對應的設備狀態,如果設備就緒則在設備等待隊列中加入一項並繼續遍歷,如果遍歷完所有fd后沒有發現就緒設備,則掛起當前進程,直到設備就緒或者主動超時,被喚醒后它又要再次遍歷fd。

**epoll **支持水平觸發和邊緣觸發,最大的特點在於邊緣觸發,它只告訴進程哪些fd剛剛變為就緒態,並且只會通知一次。還有一個特點是,epoll使用“事件”的就緒通知方式,通過epoll_ctl注冊fd,一旦該fd就緒,內核就會采用類似callback的回調機制來激活該fd,epoll_wait便可以收到通知。

Epoll空輪詢漏洞

在 JDK 中, Epoll 的實現是存在漏洞的,即使 Selector 輪詢的事件列表為空,NIO 線程一樣可以被喚醒,導致 CPU 100% 占用。實際上 Netty 並沒有從根源上解決該問題,而是巧妙地規避了這個問題。

long time = System.nanoTime();if (/*事件輪詢的持續時間大於等於 timeoutMillis*/) {    selectCnt = 1;} 

else if (/*不正常的次數 selectCnt 達到閾值 512*/) { //重建Select並且SelectionKey重新注冊到新Selector上 selector = selectRebuildSelector(selectCnt);}

 

NioEventLoop 線程的可靠性至關重要,一旦 NioEventLoop 發生阻塞或者陷入空輪詢,就會導致整個系統不可用。
 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM