本文雖然是講Netty,但實際更關注的是Netty中的NIO的實現,因此對於Netty中的OIO(Old I/O)並無作過多的描述,或者說根本隻字未提,因此本文中所述的全部實現細節都是基於NIO版本的。javascript
Netty做爲一個已經發展了十多年的框架,已然很是成熟了,其中有大量的細節是普通使用者不知道或者不關心的,因此本文不免有遺漏或者紕漏的地方,若是你發現了請告知。html
本文不涉及Netty5的部分。java
雖然這一節叫「寫在前面」,但實際上上最後寫的。node
Netty4和Netty3中的buffer
包裏的類有很大的區別,但提供的特性大體相同,其中很重要的一個是提供了「零拷貝」的特性。web
在處理請求或生成回覆時,每每要使用已有數據,並對之進行截取、拼接等操做。假設如今要進行一個拼接字符串(bytes1
和bytes2
)的操做,若是使用Java NIO中的java.nio.ByteBuffer
類的話,咱們把它假設成一個byte數組(其底層真正的存儲也是如此),每每要生成一個更大的byte數組newBytes
,而後將bytes1
和bytes2
分別複製到newBytes
的地址中去——其實newBytes
中的數據已經都存在內存中了,只是分屬不一樣的數組,存儲中不連續的內存上而已——這麼作須要在內存中作額外的拷貝。算法
而使用Netty中的Buffer類(如io.netty.buffer.ByteBuf
)的話,則不會生成新的newBytes
數組,而是生成一個新的對象指向原來的兩個數組bytes1
和bytes2
。編程
並非說Java NIO中的這種拷貝的策略很差,拋開場景去談性能是沒有意義的。數組
Netty3中零拷貝的API和Netty4不盡相同,但實現原理是同樣的,這裏拿Netty4來舉例,代碼1中對使用Java NIO的java.nio.ByteBuffer
和Netty4的io.netty.buffer.ByteBuf
拼接數據進行對比。服務器
//代碼1
public static void main(String[] args) {
byte[] byte1 = "he ".getBytes();
byte[] byte2 = "llo ".getBytes();
ByteBuffer b1 = ByteBuffer.allocate(10);
b1.put(byte1);
ByteBuffer b2 = ByteBuffer.allocate(10);
b2.put(byte2);
ByteBuffer b3 = ByteBuffer.allocate(20);
ByteBuffer[] b4 = {b1, b2}; #1
b3.put(b1.array());
b3.put(b2.array()); #2
//讀取內容
System.out.println(new String(b3.array()));
System.out.println("b1 addr:" + b1.array());
System.out.println("b2 addr:" + b2.array());
System.out.println("b3 addr:" + b3.array());
ByteBuf nb1 = Unpooled.buffer(10);
nb1.writeBytes(byte1);
ByteBuf nb2 = Unpooled.buffer(10);
nb2.writeBytes(byte2);
// nb2.array();
ByteBuf nb3 = Unpooled.wrappedBuffer(nb1, nb2);
nb3.array(); #3
//讀取內容 #4
byte[] bytes = new byte[20];
for(int i =0; i< nb3.capacity(); i++) {
bytes[i] = nb3.getByte(i);
}
System.out.println(new String(bytes));
}
輸出:
he llo
b1 addr:[B@4aa298b7
b2 addr:[B@7d4991ad
b3 addr:[B@28d93b30
he llo 複製代碼
能夠看到,若是使用java.nio.ByteBuffer
進行拼接,須要在#2的地方進行數組內存的拷貝,爲了進一步提升這種場景下的系統性能,在使用Unpooled.wrappedBuffer(ByteBuf... buffers)
進行拼接時並無進行內存的拷貝,因此會在#3的地方拋出UnsupportedOperationException
異常,由於此時b3
中已經沒有一個數組是存儲自身實際內容了,Unpooled.wrappedBuffer
返回的對象是io.netty.buffer.CompositeByteBuf.CompositeByteBuf
的實例(具體邏輯見代碼2)。網絡
//代碼2
public static ByteBuf wrappedBuffer(int maxNumComponents, ByteBuf... buffers) {
switch (buffers.length) {
case 0:
break;
case 1:
ByteBuf buffer = buffers[0];
if (buffer.isReadable()) {
return wrappedBuffer(buffer.order(BIG_ENDIAN));
} else {
buffer.release();
}
break;
default:
for (int i = 0; i < buffers.length; i++) {
ByteBuf buf = buffers[i];
if (buf.isReadable()) {
return new CompositeByteBuf(ALLOC, false, maxNumComponents, buffers, i, buffers.length);
}
buf.release();
}
break;
}
return EMPTY_BUFFER;
}複製代碼
固然你也可使用代碼1
中#1行的方法,建立一個ByteBuffer的數組,因爲Java的數組中使用「引用」來指向其成員對象,這樣就防止了內存拷貝,但這會帶來另外一個問題,在進行拼接以後,其結果是一個「ByteBuffer數組」而非「ByteBuffer對象」,這樣會給實際編程帶來不少不便。而使用ByteBuf的拼接則能在返回一個ByteBuf對象的同時又防止了內存拷貝,這就是Netty中所謂的零拷貝。
同時,更多的Netty中自定義的這些Buffer類能夠帶來的好處以下:
- 根據這些類你能夠自定義本身的Buffer類。
- 透明的零拷貝實現。
ByteBuf
提供了不少開箱即用的「訪問特定類型字節數組(如getChar(int index)
)」特性。- 不須要每次都調用
flip()
來轉換讀與寫。- 比
ByteBuffer
的性能要更好(初始化時不寫0,不用GC)。
你可能已經發現了,上一節中舉得例子(要在拼接字符串的時候,獲得一個同一類型——此處假設爲Aclass
——的對象,且實現零內存拷貝)中,徹底能夠在類Aclass
中定義一個ByteBuffer數組,而後再增長對於Aclass
中數組索引到這個ByteBuffer數組中的索引映射就行了。實際上,Netty中也是這麼實現的。
//代碼3
//org.jboss.netty.buffer.CompositeChannelBuffer.getByte(int)的實現
public byte getByte(int index) {
//找到相應的子對象
int componentId = componentId(index);
//返回子對象中的字節數組中的相應字節
return components[componentId].getByte(index - indices[componentId]);
}複製代碼
而Netty中關於ByteBuf更有爭議的部分在於,在ByteBuf的內存的管理上,它實現了本身的對象池。
Netty的對象池是基於ThreadLocal的,因此線程與線程之間的池是不相關的。
Netty作了這麼複雜的事情想優化內存的使用,以致於在Netty4中又進一步引入了自定義的對象池。在這個池中,Netty實現了本身的內存管理(分配和釋放)。按照Netty文檔上的說法,在處理網絡事件時,每每須要在短期內分配大量的、生命週期很短的對象,而若是要等待JVM的GC來回收這些對象,速度會很慢,同時GC自己也是要消耗資源的。
熟悉垃圾回收算法的朋友對於「引用計數」必定不陌生,iOS的Runtime中垃圾回收使用的就是引用計數,它是一種更高效、更原始的垃圾回收方法。高效體如今它的內存回收更「實時而直接」,原始體如今你須要在程序中顯式地對引用計數進行增長和減小。當一個對象的引用計數降爲0
,則其內存會被回收。這種方式在手機這種「資源相而言更緊張」的設備上會帶來很好的性能表現。
而JVM中採用的是「基於分代垃圾回收的構建引用樹」的方法,全部不在這棵樹上的對象則可回收,可想而知,構建這棵樹自己就會消耗必定的資源,另外「分代垃圾回收」較「引用計數」也更復雜。
對於某些有這種需求(短期內分配大量的、生命週期很短)的對象,Netty中使用引用計數來管理這些對象的分配和釋放。具體的方法大體以下:
應用程序構建本身的內存池的作法是有爭議的,每每會帶來內存泄漏的結果,也不能得到JVM的GC算法帶來的好處。但Netty的內存池已經證實,合理的使用內存池可以帶來更好的性能。
在介紹I/O模型「從I/O模型到Netty(一)」時,就提到過Direct I/O,它帶來的好處是在作I/O操做時,不須要把內存從用戶空間拷貝到內核空間,節省了一部分資源,但在JVM的環境中申請Direct I/O要比在堆上分配內存消耗更多的性能。而利用Netty的對象池,恰好能夠抵消這部分消耗,由池管理的Direct I/O的內存分配節省了GC的消耗。
有些地方會使用「零拷貝」來指代Direct I/O相對於Buffered I/O省去的那次拷貝(在用戶空間和內核空間之間進行拷貝)
假設在某種場景下,整個程序的目的都是處理單一的事情(好比一個web服務器的目的只是處理請求),咱們能夠將「與處理請求無關」的邏輯封裝到一個框架內,在每次請求處理完後,都執行一次事件的分發和處理,這就是event loop了。不少語言中都有這種概念,如nodejs中的event loop,iOS中的run loop。
這是在「從I/O模型到Netty(一)」中提到過的EventLoop的概念,在Netty4中,則真正實現了這樣一個概念。在Netty4的類裏赫然能看到EventLoop的接口,但Netty4裏的EventLoop和其餘語言中Runtime級別的EventLoop仍是有很大的區別的,其更像是一個執行預約義隊列中任務的線程(繼承自java.util.concurrent.ScheduledExecutorService
,看下圖中EventLoop的繼承結構。
其中,io.netty.channel.SingleThreadEventLoop
比較重要,從它的名字就能看出來,它指的是單個線程的EventLoop
,在Netty4的事件模型中,每個EventLoop都有一個分配的線程,全部的I/O操做(也會使用事件進行傳遞)和事件的處理都是在這個線程中完成的。其執行邏輯以下圖所示:
在本文以後的內容中有時候會不區分「EventLoop、EventExecutor和線程」,「EventExecutorGroup和線程池」的概念
代碼4中是上圖中邏輯的實現代碼。
//代碼4
@Override
public void execute(Runnable task) {
if (task == null) {
throw new NullPointerException("task");
}
boolean inEventLoop = inEventLoop();
if (inEventLoop) {
addTask(task);
} else {
startThread();
addTask(task);
if (isShutdown() && removeTask(task)) {
reject();
}
}
if (!addTaskWakesUp && wakesUpForTask(task)) {
wakeup(inEventLoop);
}
}複製代碼
線程模型直接反應了一個程序在運行時是如何去「分配和執行任務」的。對於Netty而言,其基本的線程模型能夠理解爲以前介紹到的Reactor模型,只是在其之上作了一些擴展。好比說在服務端,最重要的I/O事件應該算是鏈接請求(CONNECT)事件了,它直接關係到了服務端程序的吞吐量,因此在Netty的線程模型中就設計了一個單獨的線程去處理這個請求,其基本模型以下圖所示:
因爲篇幅所限,本文討論的線程模型將只關注服務端,客戶端固然也一樣重要。
簡單來講,Netty中的管道(ChannelPipe)能夠認爲就是一個Handler的容器,裏邊存放了兩種EventHandler(io.netty.channel.ChannelInboundHandler
和io.netty.channel.ChannelOutboundHandler
)。一個網絡請求從創建鏈接開始到獲得回覆的過程,就是在這個管道中流入而後流出的過程,Netty的文檔中是這麼描述管道的:
從Channel或者
ChannelHandlerContext
而來的I/O請求
|
+---------------------------------------------------+---------------+
| ChannelPipeline | |
| \|/ |
| +---------------------+ +-----------+----------+ |
| | Inbound Handler N | | Outbound Handler 1 | |
| +----------+----------+ +-----------+----------+ |
| /|\ | |
| | \|/ |
| +----------+----------+ +-----------+----------+ |
| | Inbound Handler N-1 | | Outbound Handler 2 | |
| +----------+----------+ +-----------+----------+ |
| /|\ . |
| . . |
| ChannelHandlerContext.fireIN_EVT() ChannelHandlerContext.OUT_EVT()|
| [ method call] [method call] |
| . . |
| . \|/ |
| +----------+----------+ +-----------+----------+ |
| | Inbound Handler 2 | | Outbound Handler M-1 | |
| +----------+----------+ +-----------+----------+ |
| /|\ | |
| | \|/ |
| +----------+----------+ +-----------+----------+ |
| | Inbound Handler 1 | | Outbound Handler M | |
| +----------+----------+ +-----------+----------+ |
| /|\ | |
+---------------+-----------------------------------+---------------+
| \|/
+---------------+-----------------------------------+---------------+
| [ Socket.read() ] [ Socket.write() ] |
+-------------------------------------------------------------------+複製代碼
簡單說,就是一個事件在管道里的順序是從第一個Inbound的Handler開始,執行到最後一個,當一個Outbound事件發生時,它是從相反的方向執行到第一個。
實際的實現是這樣的,管道能夠被認爲是一個有序的Handler的序列(鏈表,見代碼5),當一個Inbound事件發生時,它會從序列的最頭部依次經過每個Handler,若是這個Handler是Inbound類型,那麼就被執行,不然依次日後。當一個Outbound事件發生時,它會從這個序列的當前位置開始執行,判斷是不是Outbound類型,直至最頭部。
//代碼5
@Override
public final ChannelPipeline addLast(EventExecutorGroup group, String name, ChannelHandler handler) {
final AbstractChannelHandlerContext newCtx;
synchronized (this) {
checkMultiplicity(handler);
newCtx = newContext(group, filterName(name, handler), handler);
addLast0(newCtx);
。。。省略一些別的代碼
}
}
。。。省略一些別的代碼
private void addLast0(AbstractChannelHandlerContext newCtx) {
AbstractChannelHandlerContext prev = tail.prev;
newCtx.prev = prev;
newCtx.next = tail;
prev.next = newCtx;
tail.prev = newCtx;
}複製代碼
每個事件往管道的更深處發送須要Handler自身顯式觸發。
在Netty3裏,Inbound和Outbound的概念分別叫Upstream和Downstream,從上一節的圖中能很清晰的看出來,一個往上,一個往下。
若是在上邊所說的某一個Handler中包含一個很耗時的操做,那麼處理I/O的線程就會形成阻塞,致使這個線程遲遲不能被回收並用以處理新的請求,因此在Netty3中引入了除I/O線程池以外的另外一個線程池來處理業務邏輯。用戶能夠經過org.jboss.netty.handler.execution.ExecutionHandler
來實現本身的業務線程池,它同時實現了UpstreamHandler和DownstreamHandler。
ExecutionHandler的引入給Netty帶來不少問題,原本Netty一直秉持着I/O處理串行化(一個事件只被一個線程處理)的理念,可是在ExecutionHandler的場景下則會有多個線程參與到這個事件的處理中來,同時也增長了開發的複雜度,用戶須要關心額外的多線程編程的東西。
Netty3中還有一個接口org.jboss.netty.channel.ChannelSink
用來提供統一的「把Downstream寫入底層」的API,在Netty4中已經看不到了。
我在本身的電腦上運行Netty4中自帶的Echo的例子,使用VisualVM查看其線程列表,截圖以下:
而後依次啓動10個client對這個server進行鏈接,截圖以下:
本文中全部的講述都是基於NIO的,因此這裏看到啓動的線程池是io.netty.channel.nio.NioEventLoopGroup
的實例,其繼承了類io.netty.util.concurrent.DefaultThreadFactory
,Netty4中再也不須要使用Java的線程池,因此線程的名稱和Netty3中有些不一樣,大體的規則是線程的名稱爲<線程池的類名(第一個字母小寫)>-<線程池啓動的順序>-<線程啓動的順序>
。
能夠看到Netty運行起來以後有這樣幾個線程:
一個小的細節,從上圖能夠看到Boss線程池是第二個被實例化的,其實還有一個線程池
GlobalEventExecutor
會被第一個實例化,它在Netty的整個生命週期都會存在。
在Netty4中線程是按照以下方式工做的:
io.netty.util.concurrent.DefaultEventExecutorGroup
),將耗時的Handler放入其中執行。其流程以下圖所示:
Netty3與4相比,大體的思想都是同樣的,可是實現上有一些略微的不一樣,在Netty3.7源碼中的EchoServer執行後其線程列表以下:
Netty3與Netty4不一樣的地方有:
org.jboss.netty.handler.execution.ExecutionHandler
,而在新的線程模型中,Netty提供了io.netty.util.concurrent.DefaultEventExecutorGroup
來實現這種切換。第一次看到Netty時想,WTH,它跟Jetty有什麼關係,怎麼長得這麼像。
後來去逛了Netty的網站,看(xiang)了(xi)一(yue)看(du)最新的User Guide,看到了一段話讓我一會兒樂了。
Some users might already have found other network application framework that claims to have the same advantage, and you might want to ask what makes Netty so different from them. The answer is the philosophy it is built on. Netty is designed to give you the most comfortable experience both in terms of the API and the implementation from the day one. It is not something tangible but you will realize that this philosophy will make your life much easier as you read this guide and play with Netty.
粗體的文字大意是說,「Netty的好,不能用言語來表達,可是隻要你去使用它,你能體會的到蘊藏在其中的哲學,它會讓你的生活更加容易。」這個X裝的我給💯。
由於要準備這篇內容,去搜了一下Netty的歷史,原來它最先是一個叫Trustin Lee
的人寫的。而後用了十多年的積累才造就了今天Netty這樣一個開箱即用的基於事件驅動的NIO網絡框架。