Java - NIO

NIO 簡介

JDK1.4中引入了新的Java I/O類,在package java.nio.*中,目的是提高速度。
NIO一開始是"New Input/Output"的縮寫。不過,已經過了那么長時間了,已經不再"New"了。目前,普遍認可的觀念是,NIO是"No-Blocking Input/Output"的縮寫。

NIO的核心是什么?
Channel, Buffer, Selector組成了NIO的核心API。

三者的協作關系是:
Channel如同煤礦,存儲著資源(在程序中就是數據)
Buffer如同運煤的卡車(即緩存)
Selector如同一個調度中心

怎么理解三者關系?
我們假設挖出來的煤最小運輸單位是“框”,NIO出現之前的IO是每挖出一“框”煤,就運輸一次。很顯然,這樣很耗費資源,效率很低。NIO的做法是每挖出一“框”煤,先放到卡車(即Buffer)中,卡車滿了才統一運送一次,這樣效率就提高了。

一般情況下,會有很多煤礦在同時挖煤。在主干道(線程)只有一個的情況下,我們不希望某個煤礦在不需要運輸的時候占用主干道(阻塞的IO會一直占用線程,即主干道)。這時,需要所有的煤礦(Channel)都到Selector處注冊。Selector會挨個詢問所有的煤礦(Channel),有沒有煤要運輸,如果有,則允許使用主干道運輸。

可見,Channel總是跟Buffer打交道。要read的數據從Buffer中讀取,要write的數據先寫入到Buffer中。而Selector則監控著所有的Channel。

image.png

Channel

簡介

NIO中的所有IO操作要從Channel開始。Channel有點像BIO中的Stream(即“流”),但是又有點區別:

  • Stream是單向的,只能讀或者只能寫。Channel是雙向的。
  • Stream是阻塞的,Channel可以是阻塞的,也可以是非阻塞的。
  • Stream中的數據可以選擇性的讀入到Buffer中,但是Channel中的數據必須先讀入到Buffer中。

Channel接口只有兩個方法

public interface Channel extends Closeable {
    //Channel是否打開
    public boolean isOpen();
    //關閉Channel
    public void close() throws IOException;
}

常見Channel

  • FileChannel - 文件IO
  • DatagramChannel - UDP
  • ServerSocketChannel - TCP Server
  • SocketChannel - TCP Client

實際上,Channel大致可以分為兩類:

  1. 負責文件讀寫的FileChannel
  2. 負責網絡讀寫的SelectableChannel

SelectableChannel的常見實現類有:

  • DatagramChannel
  • ServerSocketChannel
  • SocketChannel

其中DatagramChannel用來進行UDP通信,ServerSocketChannelSocketChannel分別用在TCP通信的Server端和Client端。

FileChannel
FileChannel的繼承關系:

image.png

FileChannel的底層實現參見深入淺出NIO Channel和Buffer

FileChannel的典型用法示例:

//打開一個文件
FileOutputStream aFile = new FileOutputStream("data/nio-data.txt", "rw");
//獲取FileChannel
FileChannel inChannel = aFile.getChannel();
//讀取數據到ByteBuffer
ByteBuffer buf = ByteBuffer.allocate(48);
int bytesRead = inChannel.read(buf);


//開始寫入數據
//準備數據
String newData = "New String to write to file";
ByteBuffer buf = ByteBuffer.allocate(48);
buf.clear();
buf.put(newData.getBytes());
buf.flip();

//向文件中寫入數據
while(buf.hasRemaining()) {
    channel.write(buf);
}

//關閉FileCHannel
channel.close();

FileChannel的其他使用詳情請參見Java NIO系列教程(七) FileChannel

ServerSocketChannel
首先看類ServerSocketChannel中的成員:

image.png

從中可以發現,ServerSocketChannel并沒有readwrite方法。也就是說ServerSocketChannel不負責數據讀寫。
accept()方法返回一個SocketChannel類型,根據經驗我們猜測,SocketChannel類才是真正負責數據讀寫的類。這個我們會在后面驗證。

ServerSocketChannel的繼承關系:

image.png

ServerSocketChannel的創建是通過靜態方法open()

ServerSocketChannel srvChannel = ServerSocketChannel.open();

SocketChannel
類成員:

image.png

可以看出,SocketChannel中有readwrite方法,很顯然,能夠執行數據的讀寫操作。

通過分析其繼承關系(如下圖)發現,


image.png

SocketChannel實現了ReadableByteChannel接口和WritableByteChannel接口。從名稱上就能看出,這兩個接口分別負責數據的讀和寫。因此,SocketChannel會負責數據從網絡中讀取和寫入到網絡中的功能。

SocketChannel的創建是通過靜態方法open()

SocketChannel srvChannel = SocketChannel.open();

DatagramChannel

image.png

DatagramChannel典型使用示例

int port = 8080;
//打開channel
DatagramChannel channel = DatagramChannel.open();
//綁定本地地址
channel.socket().bind(new InetSocketAddress(port));

//準備接收數據到ByteBuffer
ByteBuffer buf = ByteBuffer.allocate(48);
buf.clear();
channel.receive(buf);

//準備發送數據
String newData = "New String to write to file";
ByteBuffer buf = ByteBuffer.allocate(48);
buf.clear();
buf.put(newData.getBytes());
buf.flip();
//發送數據
int bytesSent = channel.send(buf, new InetSocketAddress("jenkov.com", 80));

關于connect,由于UDP是無連接的,連接到特定地址并不會像TCP通道那樣創建一個真正的連接。而是鎖住DatagramChannel,讓其只能從特定地址收發數據。

channel.connect(new InetSocketAddress("jenkov.com", 80));

Buffer

簡介

Buffer是NIO和BIO的一個重要區別。
BIO是面向Stream的,可以將數據直接寫入或者讀出到Stream中
NIO是面向Buffer的,所有數據的讀取都需要經過Buffer。

《Thinking in Java》中是這么描述的:

我們可以將NIO想象成一個煤礦,Channel是包含煤(即數據)的庫礦藏,Buffer則是運送礦藏的卡車。我們并沒有直接和Channel打交道,我們只是和Buffer交互,并把Buffer派送到Channel。

Buffer本質上是一個數組。很顯然,它不可能僅僅是個數組,還提供了對數據的結構化訪問,以及維護讀寫位置信息。這些額外的功能是通過Buffer中的幾個變量來輔助實現的:

  • capacity:緩存數組大小
  • position:初始值為0。position表示當前可以寫入或讀取數據的位置。當寫入或讀取一個數據后, position向前移動到下一個位置。
  • limit
    • 寫模式下,limit表示最多能往Buffer里寫多少數據,等于capacity值。
    • 讀模式下,limit表示最多可以讀取多少數據。
  • mark:初始值為-1,用于備份當前的position

Buffer上述部分成員移動示意圖如下:

image.png

原理

Buffer是個抽象類,只定義了數據緩存的部分功能和接口,并不負責實際的數據存儲。實際數據存儲在其派生類中實現:

image.png

數據在不同的派生類中是怎么存儲的?
經過源碼得知,每個派生類中都有一個數組,數組類型與派生類對應。如ByteBuffer中有byte[] hb;數組,CharBuffer中有char[] hb;數組,DoubleBuffer中有double[] hb數組。

public abstract class ByteBuffer extends Buffer implements Comparable<ByteBuffer>{
    final byte[] hb;                  // Non-null only for heap buffers
}
public abstract class CharBuffer extends Buffer{
    final char[] hb;                  // Non-null only for heap buffers
}

使用

如何讀數據?
對于只讀操作,必須顯示地使用靜態allocate()方法來分配ByteBuffer
代碼示例:

//sc是SocketChannel的一個實例
ByteBuffer buffer = ByteBuffer.allocate(1024);
int readBytes = sc.read(buffer);
buffer.flip();

注意:調用完成read()方法后,須要調用Bufferflip()方法。這是為何?
剛才有講Buffer中的position變量會在read()調用的時候向下移動。但是write或者復制數據的時候,選取的數據是positionlimit之間的數據。這時就需要將position賦值給limit,同時position重置為0。flip()方法就是做這件事的:

public final Buffer flip() {
    limit = position;
    position = 0;
    mark = -1;
    return this;
}

如何寫數據?
寫數據時,首先需要通過Buffer派生類中的put()方法放入數據。
代碼示例:

String response = "Hello World";
ByteBuffer buffer = ByteBuffer.allocate(1024);
buffer.put(response.getBytes());
buffer.flip();
channel.write(buffer);

注意:這里調用put()方法后也須調用flip()方法,原理同上。

clear()方法
clear()方法能對緩沖區中的內部指針重排,從而復用Buffer。需要復用時,須調用。

public final Buffer clear() {
    position = 0;
    limit = capacity;
    mark = -1;
    return this;
}

get()方法
get()方法存在于部分派生類中,如ByteBuffer。目的是數據的復制。

//sc是SocketChannel的一個實例
ByteBuffer buffer = ByteBuffer.allocate(1024);
int readBytes = sc.read(buffer);
buffer.flip();
byte[] bytes = new byte(buffer.remaining());
//將數據復制到bytes中
buffer.get(bytes);

Selector

簡介

Selector是NIO的核心。

我們知道,在阻塞IO中,等待數據的時間相對于實際數據操作的時間是非常非常長的。如下圖所示:


image.png

阻塞IO中,大部分時間沒有被利用起來,白白占用著線程寶貴的資源。Selector的思想就是去除這些無用的等待。

Java Selector借鑒了Linux中的select/poll/epoll模型。其特點如下圖所示:

image.png

Selector維護了一個數組,數組中元素是跟Channel對應的封裝類型SelectionKey。使用時,需要不斷遍歷數組,如果其中某個或者某幾個Key有數據讀寫的需求,會在遍歷的時候被檢測到,然后進行實際的數據讀寫操作。這樣一來,等待數據的時間就被去除了。

使用

創建Selector
Selector通過靜態函數open()創建,JDK注釋為:

Opens a selector.
The new selector is created by invoking the SelectorProvider.provider().openSelector() method

代碼示例:

Selector selector = Selector.open();

遍歷Selector
Selector遍歷代碼示例:

Set selectionKeys = selector.selectedKeys();
Iterator it = selectionKeys.iterator();
while(it.hasNext()){
    SelectionKey key = (SelectionKey)it.next();
    ServerSocketChannel channel = (ServerSocketChannel)key.channel();
    或
    SocketChannel channel = (SocketChannel)key.channel();
}

Channel加入到Selector的數組中?
ServerSocketChannelSocketChannel中有register()函數,可注冊到Selector的數組中:

public final SelectionKey register(Selector sel, int ops);

Selector sel: Selector的一個對象
int ops: 可取值有:

  • OP_READ: 表示當有數據要讀時,激活Channel
  • OP_WRITE: 表示當有數據要寫時,激活Channel
  • OP_CONNECT: 表示連接到了Server時,激活Channel
  • OP_ACCEPT: 表示有Client請求連接時,激活Channel

代碼示例:

SocketChannel sc = (ServerSocketChannel)serverSocketChannel.accept();
sc.register(selector, SelectionKey.OP_READ);

select/poll還是epoll

linux操作系統方面多路復用技術有三種常用的機制:select、poll和epoll。
三者的介紹在這里select/poll/epoll...

epoll無輪詢,使用callback機制,比select/poll的效率要高。但是使用時,究竟是使用的epoll還是select/poll?這個是跟操作系統相關的。

一般來說,select有最大fd限制,默認1024,很小被使用。常用的是poll和epoll,因此我們可暫不考慮select。

究竟使用poll還是epoll,是由sun.nio.ch.DefaultSelectorProvider類中的create()函數定義的。

Java NIO根據操作系統不同, 針對nio中的Selector有不同的實現
所以毋須特別指定, Oracle jdk會自動選擇合適的Selector。
如果想設置特定的Selector,可以屬性:

-Djava.nio.channels.spi.SelectorProvider=sun.nio.ch.EPollSelectorProvider

Linux
Linux 在2.6之后才支持epoll。在create()函數中,檢測了Linux內核的版本,只有不小于2.6的時候才使用epoll,即EPollSelectorProvider,否則使用poll,即PollSelectorProviderDevPollSelectorProvider

public static SelectorProvider create() {
    String osname = AccessController.doPrivileged(
        new GetPropertyAction("os.name"));
    if ("SunOS".equals(osname)) {
        return new sun.nio.ch.DevPollSelectorProvider();
    }

    // use EPollSelectorProvider for Linux kernels >= 2.6
    if ("Linux".equals(osname)) {
        String osversion = AccessController.doPrivileged(
            new GetPropertyAction("os.version"));
        String[] vers = osversion.split("\\.", 0);
        if (vers.length >= 2) {
            try {
                int major = Integer.parseInt(vers[0]);
                int minor = Integer.parseInt(vers[1]);
                if (major > 2 || (major == 2 && minor >= 6)) {
                    return new sun.nio.ch.EPollSelectorProvider();
                }
            } catch (NumberFormatException x) {
                // format not recognized
            }
        }
    }

    return new sun.nio.ch.PollSelectorProvider();
}

MAC
MAC中epoll是使用其替代品kqueue,即KQueueSelectorProvider

public static SelectorProvider create() {
   return new KQueueSelectorProvider();
}

Windows
Windows不支持epoll,因此只能使用poll

NIO編程示例

Server端示例

Server端序列圖(出自《Netty權威指南》)


image.png
Selector selector = Selector.open();
//創建服務端接收Channel
ServerSocketChannel servChannel = ServerSocketChannel.open();
//設置成非阻塞的
servChannel.configureBlocking(false);
int port = 8080;
//綁定地址
servChannel.socket().bind(new InetSocketAddress(port), 1024);

//將servChannel注冊到selector
servChannel.register(selector, SelectionKey.OP_ACCETP);

while(true){
    selector.select(1000);
    Set<SelectionKey> selectionKeys = selector.selectedKeys();
    Iterator<SelectionKey> it = selectionKeys.iterator();
    SelectionKey = key = null;
    while(it.hasNext()){
        key = it.next();

        if(key.isValid()){
            //如果服務端接收Channel就緒,則開始accept請求
            if(key.isAcceptable()){
                ServerSocketChannel ssc = (ServerSocketChannel)key.channel();
                SocketChannel sc = ssc.accept();
                sc.configureBlocking(false);
                //將新加入的channel注冊到selector
                sc.registrer(selector, SelectionKey.OP_READ);
            }
            //如果數據channel可讀
            if(key.isReadable()){
                //開始讀
                SocketChannel sc = (SocketChannel)key.channel();
                ByteBuffer readBuffer = ByteBuffer.allocate(1024);
                int readBytes = sc.read(readBuffer);//將數據讀取到緩沖區readBuffer。
                if(readBytes > 0){
                    readBuffer.flip();
                }
            }
        }
    }
}

Client端示例

Client端序列圖(出自《Netty權威指南》)


image.png
SocketChannel client = SocketChannel.open();
client.connet(new InetSocketAddress(host, port));
client.register(selector, SelectionKey.OP_READ);
//Write data  -- 略

Selector selector = Selector.open();
while(true){
    selector.select();
    //遍歷selector -- 同Server,略
}

NIO框架

大多數情況下,不建議直接使用JDK NIO類庫,而是使用一些已有的NIO框架。

為什么不使用原生的NIO編程?

  • NIO類庫API繁多,都需要熟練掌握
  • 需要其他額外的技能,如多線程技術
  • 需要解決各種可靠性問題,如斷線重連、半包讀寫、網絡擁塞等
  • JDK NIO bug。如epoll bug,會導致Selector空輪訓,導致CPU 100%。

常見NIO框架

  • Netty
  • Vert.x
  • Xnio
  • Grizzly
  • Apache Mina

NIO框架不少,Netty是其中的佼佼者!Netty在工程中被廣泛應用,其中包含大型公司如Apple,Facebook,Google,Instagram等。Netty介紹請見Netty...

引申
NIO編程困難
epoll bug
網絡可靠性問題

  • TCP半包/粘包問題

參考

?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 227,818評論 6 531
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 98,185評論 3 414
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事?!?“怎么了?”我有些...
    開封第一講書人閱讀 175,656評論 0 373
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 62,647評論 1 309
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,446評論 6 405
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 54,951評論 1 321
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,041評論 3 440
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,189評論 0 287
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 48,718評論 1 333
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 40,602評論 3 354
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 42,800評論 1 369
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,316評論 5 358
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,045評論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,419評論 0 26
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,671評論 1 281
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,420評論 3 390
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 47,755評論 2 371

推薦閱讀更多精彩內容