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阅读 203,324评论 5 476
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,303评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,192评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,555评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,569评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,566评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,927评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,583评论 0 257
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,827评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,590评论 2 320
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,669评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,365评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,941评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,928评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,159评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,880评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,399评论 2 342

推荐阅读更多精彩内容