02.Netty與NIO之前世今生
¶ JavaNIO三件套
在 NIO 中有幾個核心物件需要掌握:緩衝區(Buffer)、選擇器(Selector)、通道(Channel)
¶ 緩衝區Buffer
¶ Buffer操作基本API
緩衝區實際上是一個容器物件,更直接的說,其實就是一個數組,在 NIO 庫中,所有資料都是用緩衝區處理的。在讀 取資料時,它是直接讀到緩衝區中的; 在寫入資料時,它也是寫入到緩衝區中的;任何時候訪問 NIO 中的資料,都 是將它放到緩衝區中。而在面向流 I/O 系統中,所有資料都是直接寫入或者直接將資料讀取到 Stream 物件中。 在 NIO 中,所有的緩衝區型別都繼承於抽象類 Buffer,最常用的就是 ByteBuffer,對於 Java 中的基本型別,基本都有 一個具體 Buffer 型別與之相對應,它們之間的繼承關係如下圖所示:
public class IntBufferDemo { public static void main(String[] args) { // 分配新的int緩衝區,引數為緩衝區容量 // 新緩衝區的當前位置將為零,其界限(限制位置)將為其容量。它將具有一個底層實現陣列,其陣列偏移量將為零。 IntBuffer buffer = IntBuffer.allocate(8); for (int i = 0; i < buffer.capacity(); ++i) { int j = 2 * (i + 1); // 將給定整數寫入此緩衝區的當前位置,當前位置遞增 buffer.put(j); } // 重設此緩衝區,將限制設定為當前位置,然後將當前位置設定為0 buffer.flip(); // 檢視在當前位置和限制位置之間是否有元素 while (buffer.hasRemaining()) { // 讀取此緩衝區當前位置的整數,然後當前位置遞增 int j = buffer.get(); System.out.print(j + " "); } } }
¶buffer-的基本的原理
我們說緩衝區物件本質上是一個數組,但它其實是一個特殊的陣列,緩衝區物件內建了一些機制, 能夠跟蹤和記錄緩衝區的狀態變化情況,如果我們使用 get()方法從緩衝區獲取資料或者使用 put()方法把資料寫入緩衝 區,都會引起緩衝區狀態的變化。
在緩衝區中,最重要的屬性有下面三個,它們一起合作完成對緩衝區內部狀態的變化跟蹤:
position:指定下一個將要被寫入或者讀取的元素索引,它的值由 get()/put()方法自動更新,在新建立一個 Buffer 物件 時,position 被初始化為 0。
limit:指定還有多少資料需要取出(在從緩衝區寫入通道時),或者還有多少空間可以放入資料(在從通道讀入緩衝區時)。
capacity:指定了可以儲存在緩衝區中的最大資料容量,實際上,它指定了底層陣列的大小,或者至少是指定了准許我 們使用的底層陣列的容量。
以上三個屬性值之間有一些相對大小的關係:0 <= position <= limit <= capacity。如果我們建立一個新的容量大小為 10 的 ByteBuffer 物件,在初始化的時候,position 設定為 0,limit 和 capacity 被設定為 10,在以後使用 ByteBuffer 物件過程中,capacity 的值不會再發生變化,而其它兩個個將會隨著使用而變化。
public class BufferDemo {
//put/get
public static void main(String args[]) throws Exception {
//這用用的是檔案IO處理 test.txt 裡面的內容為Tom.
FileInputStream fin = new FileInputStream("D://test.txt");
//建立檔案的操作管道
FileChannel fc = fin.getChannel();
//分配一個10個大小緩衝區,說白了就是分配一個10個大小的byte陣列
ByteBuffer buffer = ByteBuffer.allocate(10);
output("初始化", buffer);
//先讀一下 讀取了四個內容 position 移動到了4
fc.read(buffer);
output("呼叫read()", buffer);
//準備操作之前,先鎖定操作範圍 切換讀寫模式。將 position 移動到0 切換成了讀模式。所以position 要從0開始讀
buffer.flip();
output("呼叫flip()", buffer);
//判斷有沒有可讀資料 使用讀的模式讀取內容
while (buffer.remaining() > 0) {
// 每讀取一次資料。 position 移動一位元素
byte b = buffer.get();
// System.out.print(((char)b));
}
output("呼叫get()", buffer);
//可以理解為解鎖 解鎖之後恢復position位置
buffer.clear();
output("呼叫clear()", buffer);
//最後把管道關閉
fin.close();
}
//把這個緩衝裡面實時狀態給答應出來
public static void output(String step, ByteBuffer buffer) {
System.out.println(step + " : ");
//容量,陣列大小
System.out.print("capacity: " + buffer.capacity() + ", ");
//當前操作資料所在的位置,也可以叫做遊標
System.out.print("position: " + buffer.position() + ", ");
//鎖定值,flip,資料操作範圍索引只能在position - limit 之間
System.out.println("limit: " + buffer.limit());
System.out.println();
}
}
執行結果如下:
執行結果我們已經看到,下面呢對以上結果進行圖解,四個屬性值分別如圖所示:
我們可以從通道中讀取一些資料到緩衝區中,注意從通道讀取資料,相當於往緩衝區中寫入資料。如果讀取 4 個自己 的資料,則此時 position 的值為 4,即下一個將要被寫入的位元組索引為 4,而 limit 仍然是 10,如下圖所示:
下一步把讀取的資料寫入到輸出通道中,相當於從緩衝區中讀取資料,在此之前,必須呼叫 flip()方法,該方法將會完 成兩件事情:
- 把 limit 設定為當前的 position 值
- 把 position 設定為 0
由於 position 被設定為 0,所以可以保證在下一步輸出時讀取到的是緩衝區中的第一個位元組,而 limit 被設定為當前的 position,可以保證讀取的資料正好是之前寫入到緩衝區中的資料,如下圖所示:
現在呼叫 get()方法從緩衝區中讀取資料寫入到輸出通道,這會導致 position 的增加而 limit 保持不變,但 position 不 會超過 limit 的值,所以在讀取我們之前寫入到緩衝區中的 4 個自己之後,position 和 limit 的值都為 4,如下圖所示:
在從緩衝區中讀取資料完畢後,limit 的值仍然保持在我們呼叫 flip()方法時的值,呼叫 clear()方法能夠把所有的狀態變 化設定為初始化時的值,如下圖所示:
¶3緩衝區的分配
在建立一個緩衝區物件時,會呼叫靜態方法 allocate()來指定緩衝區的容量,其實呼叫 allocate()相當於建立了一個指定大小的陣列,並把它包裝為緩衝區物件。或者我們也可以直接將一個現有的陣列,包裝為緩衝區對 象,如下示例程式碼所示:
public class BufferWrap {
public void myMethod() {
// 分配指定大小的緩衝區
ByteBuffer buffer1 = ByteBuffer.allocate(10);
// 包裝一個現有的陣列
byte array[] = new byte[10];
ByteBuffer buffer2 = ByteBuffer.wrap( array );
}
}
¶4緩衝區分片
在 NIO 中,除了可以分配或者包裝一個緩衝區物件外,還可以根據現有的緩衝區物件來建立一個子緩衝區,即在現有緩衝區上切 出一片來作為一個新的緩衝區,但現有的緩衝區與建立的子緩衝區在底層陣列層面上是資料共享的,也就是說,子緩衝區相當於是 現有緩衝區的一個檢視視窗。呼叫 slice()方法可以建立一個子緩衝區,讓我們通過例子來看一下:
/**
* 緩衝區分片
*/
public class BufferSlice {
static public void main( String args[] ) throws Exception {
ByteBuffer buffer = ByteBuffer.allocate( 10 );
// 緩衝區中的資料0-9
for (int i=0; i<buffer.capacity(); ++i) {
buffer.put( (byte)i );
}
// 建立子緩衝區
buffer.position( 3 );
buffer.limit( 7 );
// 這樣子得到的資料位 3 - 7 的位置的資料新建一個緩衝區
ByteBuffer slice = buffer.slice();
// 改變子緩衝區的內容
for (int i=0; i<slice.capacity(); ++i) {
byte b = slice.get( i );
b *= 10;
slice.put( i, b );
}
buffer.position( 0 );
buffer.limit( buffer.capacity() );
while (buffer.remaining()>0) {
System.out.println( buffer.get() );
}
}
}
在該示例中,分配了一個容量大小為 10 的緩衝區,並在其中放入了資料 0-9,而在該緩衝區基礎之上又建立了一個子緩衝區,並 改變子緩衝區中的內容,從最後輸出的結果來看,只有子緩衝區“可見的”那部分資料發生了變化,並且說明子緩衝區與原緩衝區是 資料共享的,輸出結果如下所示:
¶5只讀緩衝區
只讀緩衝區非常簡單,可以讀取它們,但是不能向它們寫入資料。可以通過呼叫緩衝區的 asReadOnlyBuffer()方法,將任何常規緩 衝區轉 換為只讀緩衝區,這個方法返回一個與原緩衝區完全相同的緩衝區,並與原緩衝區共享資料,只不過它是隻讀的。如果原 緩衝區的內容發生了變化,只讀緩衝區的內容也隨之發生變化:
public class ReadOnlyBuffer {
static public void main( String args[] ) throws Exception {
ByteBuffer buffer = ByteBuffer.allocate( 10 );
// 緩衝區中的資料0-9
for (int i=0; i<buffer.capacity(); ++i) {
buffer.put( (byte)i );
}
// 建立只讀緩衝區
ByteBuffer readonly = buffer.asReadOnlyBuffer();
// 改變原緩衝區的內容
for (int i=0; i<buffer.capacity(); ++i) {
byte b = buffer.get( i );
b *= 10;
buffer.put( i, b );
}
readonly.position(0);
readonly.limit(buffer.capacity());
// 只讀緩衝區的內容也隨之改變
while (readonly.remaining()>0) {
System.out.println( readonly.get());
}
}
}
如果嘗試修改只讀緩衝區的內容,則會報 ReadOnlyBufferException 異常。只讀緩衝區對於保護資料很有用。在將緩衝區傳遞給某 個 物件的方法時,無法知道這個方法是否會修改緩衝區中的資料。建立一個只讀的緩衝區可以保證該緩衝區不會被修改。只可以 把常規緩衝區轉換為只讀緩衝區,而不能將只讀的緩衝區轉換為可寫的緩衝區。
¶6.直接緩衝區
直接緩衝區是為加快 I/O 速度,使用一種特殊方式為其分配記憶體的緩衝區,JDK 文件中的描述為:給定一個直接位元組緩衝區,Java 虛擬機器將盡最大努力直接對它執行本機 I/O 操作。也就是說,它會在每一次呼叫底層作業系統的本機 I/O 操作之前(或之後),嘗試避免將緩衝區的內容拷貝到一箇中間緩衝區中或者從一箇中間緩衝區中拷貝資料。要分配直接緩衝區,需要呼叫 allocateDirect() 方法,而不是 allocate()方法,使用方式與普通緩衝區並無區別,如下面的拷貝檔案示例:
/**
* 直接緩衝區
* Zero Copy 減少了一個拷貝的過程
*/
public class DirectBuffer {
static public void main( String args[] ) throws Exception {
//在Java裡面存的只是緩衝區的引用地址
//管理效率
//首先我們從磁碟上讀取剛才我們寫出的檔案內容
String infile = "D://test.txt";
FileInputStream fin = new FileInputStream( infile );
FileChannel fcin = fin.getChannel();
//把剛剛讀取的內容寫入到一個新的檔案中
String outfile = String.format("D://testcopy.txt");
FileOutputStream fout = new FileOutputStream(outfile);
FileChannel fcout = fout.getChannel();
// 使用allocateDirect,而不是allocate
ByteBuffer buffer = ByteBuffer.allocateDirect(1024);
while (true) {
buffer.clear();
int r = fcin.read(buffer);
if (r==-1) {
break;
}
buffer.flip();
fcout.write(buffer);
}
}
}
¶7.記憶體對映
記憶體對映是一種讀和寫檔案資料的方法,它可以比常規的基於流或者基於通道的 I/O 快的多。記憶體對映檔案 I/O 是通過使檔案中的 資料出現為 記憶體陣列的內容來完成的,這其初聽起來似乎不過就是將整個檔案讀到記憶體中,但是事實上並不是這樣。一般來說, 只有檔案中實際讀取或者寫入的部分才會對映到記憶體中。如下面的示例程式碼:
public class MappedBuffer {
static private final int start = 0;
static private final int size = 26;
static public void main( String args[] ) throws Exception {
RandomAccessFile raf = new RandomAccessFile( "D://test.txt", "rw" );
FileChannel fc = raf.getChannel();
//把緩衝區跟檔案系統進行一個對映關聯
//只要操作緩衝區裡面的內容,檔案內容也會跟著改變
MappedByteBuffer mbb = fc.map(FileChannel.MapMode.READ_WRITE,start, size );
mbb.put( 0, (byte)97 ); //a
mbb.put( 25, (byte)122 ); //z
raf.close();
}
}
¶212-選擇器-selector
傳統的 Server/Client 模式會基於 TPR(Thread per Request),伺服器會為每個客戶端請求建立一個執行緒,由該執行緒單獨負責處理 一個客戶請求。這種模式帶來的一個問題就是執行緒數量的劇增,大量的執行緒會增大伺服器的開銷。大多數的實現為了避免這個問題, 都採用了執行緒池模型,並設定執行緒池執行緒的最大數量,這又帶來了新的問題,如果執行緒池中有 200 個執行緒,而有 200 個使用者都在 進行大檔案下載,會導致第201個使用者的請求無法及時處理,即便第201個使用者只想請求一個幾KB大小的頁面。傳統的 Server/Client 模式如下圖所示:
NIO 中非阻塞 I/O 採用了基於 Reactor 模式的工作方式,I/O 呼叫不會被阻塞,相反是註冊感興趣的特定 I/O 事件,如可讀資料到 達,新的套接字連線等等,在發生特定事件時,系統再通知我們。NIO 中實現非阻塞 I/O 的核心物件就是 Selector,Selector 就是 註冊各種 I/O 事件地方,而且當那些事件發生時,就是這個物件告訴我們所發生的事件,如下圖所示:
從圖中可以看出,當有讀或寫等任何註冊的事件發生時,可以從 Selector 中獲得相應的 SelectionKey,同時從 SelectionKey 中可 以找到發生的事件和該事件所發生的具體的 SelectableChannel,以獲得客戶端傳送過來的資料。
使用 NIO 中非阻塞 I/O 編寫伺服器處理程式,大體上可以分為下面三個步驟:
1.向 Selector 物件註冊感興趣的事件。
2.從 Selector 中獲取感興趣的事件。
3.根據不同的事件進行相應的處理。
/*
* 註冊事件
*/
private Selector getSelector() throws IOException {
// 建立 Selector 物件
Selector sel = Selector.open();
// 建立可選擇通道,並配置為非阻塞模式
ServerSocketChannel server = ServerSocketChannel.open();
server.configureBlocking(false);
// 繫結通道到指定埠
ServerSocket socket = server.socket();
InetSocketAddress address = new InetSocketAddress(port);
socket.bind(address);
// 向 Selector 中註冊感興趣的事件
server.register(sel, SelectionKey.OP_ACCEPT);
return sel;
}
建立了 ServerSocketChannel 物件,並呼叫 configureBlocking()方法,配置為非阻塞模式,接下來的三行程式碼把該通道繫結到指定 埠,最後向 Selector 中註冊事件,此處指定的是引數是 OP_ACCEPT,即指定我們想要監聽 accept 事件,也就是新的連線發 生 時所產生的事件,對於 ServerSocketChannel 通道來說,我們唯一可以指定的引數就是 OP_ACCEPT。
從 Selector 中獲取感興趣的事件,即開始監聽,進入內部迴圈:
public void listen(){
System.out.println("listen on " + this.port + ".");
try {
//輪詢主執行緒
while (true){
//大堂經理再叫號
selector.select();
//每次都拿到所有的號子
Set<SelectionKey> keys = selector.selectedKeys();
Iterator<SelectionKey> iter = keys.iterator();
//不斷地迭代,就叫輪詢
//同步體現在這裡,因為每次只能拿一個key,每次只能處理一種狀態
while (iter.hasNext()){
SelectionKey key = iter.next();
iter.remove();
//每一個key代表一種狀態
//沒一個號對應一個業務
//資料就緒、資料可讀、資料可寫 等等等等
process(key);
}
}
} catch (IOException e) {
e.printStackTrace();
}
}
在非阻塞 I/O 中,內部迴圈模式基本都是遵循這種方式。首先呼叫 select()方法,該方法會阻塞,直到至少有一個事件發生,然後 再使用 selectedKeys()方法獲取發生事件的 SelectionKey,再使用迭代器進行迴圈。
最後一步就是根據不同的事件,編寫相應的處理程式碼:
private void process(SelectionKey key) throws IOException {
//針對於每一種狀態給一個反應
if(key.isAcceptable()){
ServerSocketChannel server = (ServerSocketChannel)key.channel();
//這個方法體現非阻塞,不管你資料有沒有準備好
//你給我一個狀態和反饋
SocketChannel channel = server.accept();
//一定一定要記得設定為非阻塞
channel.configureBlocking(false);
//當資料準備就緒的時候,將狀態改為可讀
key = channel.register(selector,SelectionKey.OP_READ);
}
else if(key.isReadable()){
//key.channel 從多路複用器中拿到客戶端的引用
SocketChannel channel = (SocketChannel)key.channel();
int len = channel.read(buffer);
if(len > 0){
buffer.flip();
String content = new String(buffer.array(),0,len);
key = channel.register(selector,SelectionKey.OP_WRITE);
//在key上攜帶一個附件,一會再寫出去
key.attach(content);
System.out.println("讀取內容:" + content);
}
}
else if(key.isWritable()){
SocketChannel channel = (SocketChannel)key.channel();
String content = (String)key.attachment();
channel.write(ByteBuffer.wrap(("輸出:" + content).getBytes()));
channel.close();
}
}
此處分別判斷是接受請求、讀資料還是寫事件,分別作不同的處理。在 Java1.4 之前的 I/O 系統中,提供的都是面向流的 I/O 系統,系統一次一個位元組地處理資料,一個輸入流產生一個位元組的資料,一個輸出流消費一個位元組的資料,面向流的 I/O 速度非常慢,而在 Java 1.4 中推出了 NIO,這是一個面向塊的 I/O 系統,系統以塊的方式處理處理,每一個操作在 一步中產生或者消費一個數據庫,按塊處理要比按位元組處理資料快的多。
¶213-通道-channel
通道是一個物件,通過它可以讀取和寫入資料,當然了所有資料都通過 Buffer 物件來處理。我們永遠不會將位元組直接寫入通道中,相反是將資料寫入包含一個或者多個位元組的緩衝區。同樣不會直接從通道中讀取位元組,而是將資料從通 道讀入緩衝區,再從緩衝區獲取這個位元組。
在 NIO 中,提供了多種通道物件,而所有的通道物件都實現了 Channel 介面。它們之間的繼承關係如下圖所示:
¶1.使用-nio-讀取數
任何時候讀取資料,都不是直接從通道讀取,而是從通道讀取到緩衝區。所以使用 NIO 讀取資料可 以分為下面三個步驟:
1.從 FileInputStream 獲取 Channel
2.建立 Buffer
3.將資料從 Channel 讀取到 Buffer
¶2.使用-nio-寫入資料
使用 NIO 寫入資料與讀取資料的過程類似,同樣資料不是直接寫入通道,而是寫入緩衝區,可以分為下面三個步驟:
1.從 FileInputStream 獲取 Channel。
2.建立 Buffer。
3.將資料從 Channel 寫入到 Buffer 中。
public class FileInputDemo {
static public void main( String args[] ) throws Exception {
FileInputStream fin = new FileInputStream("E://test.txt");
// 獲取通道
FileChannel fc = fin.getChannel();
// 建立緩衝區
ByteBuffer buffer = ByteBuffer.allocate(1024);
// 讀取資料到緩衝區
fc.read(buffer);
buffer.flip();
while (buffer.remaining() > 0) {
byte b = buffer.get();
System.out.print(((char)b));
}
fin.close();
}
}
public class FileOutputDemo {
static private final byte message[] = { 83, 111, 109, 101, 32, 98, 121, 116, 101, 115, 46 };
static public void main( String args[] ) throws Exception {
FileOutputStream fout = new FileOutputStream( "E://test.txt" );
FileChannel fc = fout.getChannel();
ByteBuffer buffer = ByteBuffer.allocate( 1024 );
for (int i=0; i<message.length; ++i) {
buffer.put( message[i] );
}
buffer.flip();
fc.write( buffer );
fout.close();
}
}
¶3.io-多路複用
目前流行的多路複用 IO 實現主要包括四種:select、poll、epoll、kqueue。
多路複用 IO 技術最適用的是“高併發”場景,所謂高併發是指 1 毫秒內至少同時有上千個連線請求準備好。其他情 況下多路複用 IO 技術發揮不出來它的優勢。另一方面,使用 JAVA NIO 進行功能實現,相對於傳統的 Socket 套接字 實現要複雜一些,所以實際應用中,需要根據自己的業務需求進行技術選擇。
¶nio-原始碼初探
¶反應堆-reactor
阻塞 I/O 在呼叫 InputStream.read()方法時是阻塞的,它會一直等到資料到來時(或超時)才會返回;同樣,在呼叫 ServerSocket.accept()方法時,也會一直阻塞到有客戶端連線才會 返回,每個客戶端連線過來後,服務端都會啟動一個執行緒去處理該客戶端的請求。阻塞 I/O 的通訊模型示意圖如下:
如果你細細分析,一定會發現阻塞 I/O 存在一些缺點。根據阻塞 I/O 通訊模型,我總結了它的兩點缺點:
1.當客戶端多時,會建立大量的處理執行緒。且每個執行緒都要佔用棧空間和一些 CPU 時間
- 阻塞可能帶來頻繁的上下文切換,且大部分上下文切換可能是無意義的。在這種情況下非阻塞式 I/O 就有了它的應 用前景。
Java NIO 是在 jdk1.4 開始使用的,它既可以說成“新 I/O”,也可以說成非阻塞式 I/O。下面是 Java NIO 的工作原理:
1.由一個專門的執行緒來處理所有的 IO 事件,並負責分發。
2.事件驅動機制:事件到的時候觸發,而不是同步的去監視事件。
3.執行緒通訊:執行緒之間通過 wait,notify 等方式通訊。保證每次上下文切換都是有意義的。減少無謂的執行緒切換。
¶netty-與-nio
¶netty-支援的功能與特性
1.框架設計優雅,底層模型隨意切換適應不同的網路協議要求。
2.提供很多標準的協議、安全、編碼解碼的支援。
3.解決了很多 NIO 不易用的問題。
4.社群更為活躍,在很多開源框架中使用,如 Dubbo、RocketMQ、Spark 等
1.底層核心有:Zero-Copy-Capable Buffer,非常易用的靈拷貝 Buffer(這個內容很有意思,稍後專門來說);統一的 API;標準可擴充套件的時間模型
2.傳輸方面的支援有:管道通訊(具體不知道幹啥的,還請老司機指教);Http 隧道;TCP 與 UDP
3.協議方面的支援有:基於原始文字和二進位制的協議;解壓縮;大檔案傳輸;流媒體傳輸;protobuf 編解碼;安全認 證;http 和 websocket
¶netty-採用-nio-而非-aio-的理由
1.Netty 不看重 Windows 上的使用,在 Linux 系統上,AIO 的底層實現仍使用 EPOLL,沒有很好實現 AIO,因此在性 能上沒有明顯的優勢,而且被 JDK 封裝了一層不容易深度優化
2.Netty 整體架構是 reactor 模型, 而 AIO 是 proactor 模型, 混合在一起會非常混亂,把 AIO 也改造成 reactor 模型看起 來是把 epoll 繞個彎又繞回來
3.AIO還有個缺點是接收資料需要預先分配快取, 而不是NIO那種需要接收時才需要分配快取, 所以對連線數量非常大 但流量小的情況, 記憶體浪費很多
4.Linux 上 AIO 不夠成熟,處理回撥結果速度跟不到處理需求,比如外賣員太少,顧客太多,供不應求,造成處理速度 有瓶頸(待驗證)
基於BIO實現tomcat
基於Netty實現tomcat