java nio原理和使用_java nio原理

在2018年十月份的十多次面试中,几乎每一场面试都会nio,可见nio的重要性。每当面试官问到nio的时候,我都会从操作系统层面的IO多路复用说起,只要说的明白,一般这一关就算过了。

首先要搞清楚nio中n的含义。如果告诉别人n的意思是new,那就给人留下很不好的印象了,这里的n一般是被理解为non-blocking。传统的InputStream/OutputStream体系的读写操作都是阻塞的,为什么它们是阻塞的?这是因为当调用读写操作时,上层应用程序并不知道底层的socket缓冲区是否是就绪的,如果正好是就绪的,那么不会阻塞;如果不是就绪的,就会阻塞直到socket缓冲区就绪。一般情况下,socket写缓冲区大部分时间是就绪的,而读缓冲区由于没有数据到来而处于等待状态。

所以,阻塞和非阻塞的区别在于,阻塞IO在进行IO操作时可能需要阻塞等待,而非阻塞IO在进行IO操作时不需要等待。因为后者在进行IO操作时通过一定的机制得知底层socket已经准备就绪了,至于机制,别急,各位看官往下接着看。

这里谈到了阻塞非阻塞的概念,在IO中还有一个容易混淆的概念是同步和异步。上面说了传统InputStream/OutputStream体系是阻塞的,而nio是非阻塞的。注意这里并没有说nio是异步的,因为它本身就不支持异步。nio还需要再经过一层封装才能实现异步的功能,java中netty、nima都是封装nio实现异步的框架。

那么什么是异步,或者说同步和异步有什么区别呢?同步是指A对B发生调用时,直到B完成了操作才返回结果给A;而异步是指A对B发生调用时,B会马上返回一个状态给A,当B操作完成时,通过某种方式来通知A。写过前端js代码的同学肯定能对异步有比较好的理解,在前端js代码中经常会设置异步回调函数。这里的异步回调函数就是当前面的操作完成时能够得到通知,进而做后续的处理。所以,说到异步,总是离不开另一个词汇:回调。

言归正传,说回nio。nio中,面向开发人员的组件主要有三个:ByteBuffer、Channel、Selector。ok,先上一段nio的demo代码。

server端:

public class NioServerTest {

public static void main(String[] args) throws Exception {

Selector selector = Selector.open();

ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();

serverSocketChannel.configureBlocking(false);

serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT);

serverSocketChannel.socket().bind(new InetSocketAddress("127.0.0.1", 3000));

while (true) {

int num = selector.select();

if (num > 0) {

Set set = selector.selectedKeys();

Iterator it = set.iterator();

while (it.hasNext()) {

SelectionKey sk = it.next();

if (sk.isAcceptable()) {

System.out.println("sk.isAcceptable()");

SocketChannel sc = serverSocketChannel.accept();

sc.configureBlocking(false);

sc.register(selector, SelectionKey.OP_READ);

} else if (sk.isReadable()) {

System.out.println("sk.isReadable()");

SocketChannel sc = (SocketChannel) sk.channel();

ByteBuffer byteBuffer = ByteBuffer.allocate(3);

sc.read(byteBuffer);

String s = new String(byteBuffer.array()).trim();

System.out.println(s);

sc.write(ByteBuffer.wrap(s.getBytes()));

} else if (sk.isWritable()) {

System.out.println("sk.isWritable()");

} else if (sk.isConnectable()) {

System.out.println("sk.isConnectable()");

}

it.remove();

}

}

}

}

}

client端:

public class NioClientTest {

public static void main(String[] args) throws Exception {

Selector selector = Selector.open();

SocketChannel sc = SocketChannel.open(new InetSocketAddress("127.0.0.1", 3000));

sc.configureBlocking(false);

sc.register(selector, SelectionKey.OP_READ | SelectionKey.OP_CONNECT);

while (true) {

int num = selector.select();

if (num > 0) {

Set set = selector.selectedKeys();

Iterator it = set.iterator();

while (it.hasNext()) {

SelectionKey sk = it.next();

if (sk.isAcceptable()) {

System.out.println("sk.isAcceptable()");

} else if (sk.isReadable()) {

System.out.println("sk.isReadable()");

SocketChannel sc2 = (SocketChannel) sk.channel();

ByteBuffer bb = ByteBuffer.allocate(256);

sc2.read(bb);

System.out.println(new String(bb.array()).trim());

} else if (sk.isWritable()) {

System.out.println("sk.isWritable()");

} else if (sk.isConnectable()) {

System.out.println("sk.isConnectable()");

}

it.remove();

}

}

sc.write(ByteBuffer.wrap(("hello world" + (int)(Math.random()*1000)).getBytes()));

Thread.sleep(1000L);

}

}

}

下面,我们来详细分析nio的ByteBuffer、Channel、Selector三个组件。

ByteBuffer

ByteBuffer可以理解为操作一段内存的工具类。Channel的数据读写都是通过ByteBuffer,而不像传统IO那样直接操作流。这也是nio和bio很重要的一个不同点,nio的读写是面向缓冲的,bio的读写则是面向流的。

其内部维护了一个字节数组和3个位置相关的变量:

final byte[] hb;

private int position =0;

private int limit;

private int capacity;

其使用步骤:调用put()写入数据,当需要读数据时,先flip()切换为读模式,然后就可以get()了。从position到limit表示数组中可以操作(读/写)的部分,当limit达到capacity时,就不能读/写了。如:初始状态limit==capacity,当put()时,position不断增加,直到limit。当flip()时,position变为0,limit变为之前position的位置,就变成读模式了,从position的位置开始读。ByteBuffer还提供了mark/reset来实现做标记,以便重复读。

ByteBuffer有多个实现类,用来实现便捷地操作不同基本类型的数据。另外,ByteBuffer根据操作的内存不同,可分为HeapByteBuffer和DirectByteBuffer。前者是在jvm的堆中分配一个数组,后者则是通过Unsafe.allocateMemory()来申请一块堆外内存,并保存内存地址,所有的操作都是通过计算内存地址然后做读写操作。ByteBuffer提供了便捷方法:wrap()、array(),前者是将字符串封装成ByteBuffer,后者是讲ByteBuffer导成字符串。后面会单独写文章详细介绍堆外内存的相关知识。

Channel

nio中Channel可以理解为是对Socket的封装,read、write、connect、bind都是非阻塞的,能够实现非阻塞主要是依赖不同操作系统支持的IO多路复用技术,如window下的IOCP、Linux下的Epoll、Mac下的KQueue。针对不同的操作系统,jvm做了不同的封装,在Mac下KQueue的操作是封装为KQueueSelectorImpl以及KQueueArrayWrapper。

Channel的实现类有四个:FileChannel、SocketChannel、ServerSocketChannel、DatagramChannel。在nio编程中常用的是SocketChannel、ServerSocketChannel,下面简单说下这两个。

SocketChannel负责客户端连接的读写。其读写操作都是通过ByteBuffer,写的时候先将数据写到ByteBuffer,再由ByteBuffer传递给内核,内核传递给网卡,最终由网卡发送出去。读则是先将socket缓冲区的数据经过内核放到ByteBuffer中,再由SocketChannel从ByteBuffer中读到数据。当Selector监听到读就绪时调用read,读已经是就绪的,可以一次读完并返回,如果没有读完会继续触发读就绪事件;写操作道理类似。

ServerSocketChannel负责服务端监听端口、接收客户端连接。它可以理解为封装了ServerSocket,它没有读写方法,只有bind()、accept()方法。bind()是封装了socket的bind、listen操作,bind做的事情是将socket和地址/端口绑定,listen的作用是监听端口,并提供参数设置全连接队列大小,默认50,全连接队列和三次握手的知识有关,有兴趣的可以自己去查查资料;accept()是去读全连接队列,如果没有socket连接到来就会阻塞,如果有则返回第一个。nio中通常使用Selector去监听是否有客户端连接到来,如果有连接就绪事件,应用程序就可以去accept(),这样就避免了accept时的阻塞。

SocketChannel和ServerSocketChannel都提供了open()来快速创建其实现类的实例。

Selector

Selector能够监听多个Channel的多种IO事件。只要将channel注册到Selector上,那么当channel有IO就绪事件到来时,Selector.select()就会返回就绪Channel的数量,接下来就可以通过selectedKey()拿到所有就绪的Channel,进而处理Channel。IO就绪事件分为4类:读就绪(OP_READ)、写就绪(OP_WRITE)、连接就绪(OP_CONNECT)、接收就绪(OP_ACCEPT)。

在mac os上,是用KQueueSelectorImpl作为Selector的实现类,它利用操作系统提供的KQueue模型,Selector.select()最终其实是调用KQueueArrayWrapper的poll(),后者返回就绪数量,并且会把就绪事件对应的socket的文件描述符放到一个指定的队列中,调用getDiscriptor(int i)可以得到队列中的某个元素,然后再根据返回的FD去找到对应的Channel(对应关系在Selector中维护)。

Selector重要的方法:wakeup、select、register

wakeup():是通过操作系统的管道实现的。当创建一个Selector时,会同时创建一个管道,管道分为两头,一头是读的,一头是写的,将读的这头的socket信息注册到Selector中,当需要wakeup时,就往写的那头随便写一个字节就好了。

register():,新建一个SelectionKeyImpl,然后向KQueueArrayWrapper的updateList添加一个元素,其中封装了channel和interestOps,当selector.select()时会一一取出updatelist中的Update,注册到kqueue事件,然后调用kevent0()去监听是否有就绪事件。

select():,会去调用KQueueArrayWrapper的poll(),其中会将updateList中的Update一一注册kqueue事件,然后去调用kevent0()阻塞监听就绪事件。

要理解Selector的工作原理,光看代码是不够的,因为其中相关的类涉及到很多的本地方法调用,而大部分的人没有勇气也没有能力或者说精力去看JVM源码。要理解Selector的工作原理,其核心是要理解上面提到的IO多路复用模型。当明白了IO多路复用是怎么回事之后,猜也能猜到Selector的工作原理了。关于IO多路复用,网上有很多的资料,下面我们简单说下。

以Linux下的epoll为例,我们说说IO多路复用。

epoll模型中,有三个核心的系统调用:epoll_create、epoll_ctrl、epoll_wait

epoll_create:系统启动时分配内存,构造一棵红黑树。

epoll_ctrl:将一个socket信息和感兴趣的事件注册到红黑树中,并绑定一个回调函数,当内核收到该socket的相关IO事件就绪时,将socket信息写到一个列表中。

epoll_wait:读上面说到的那个列表

以上关于IO多路复用模型的描述忽略了很多东西,因为它不是本文要说的重点,而是结合nio说说nio如何通过IO多路复用模型来实现非阻塞的io调用。当调用register()时,其实就是将socket信息和感兴趣的事件注册到内核;selector.select()时阻塞去读上面说的那个列表,当列表有数据时,将读到的socket信息保存起来;read、write、accept都是selector.select()之后才会去执行的操作,因为此时socket缓存已经处于就绪状态了,自然不会阻塞了。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值