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<SelectionKey> set = selector.selectedKeys();
                Iterator<SelectionKey> 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<SelectionKey> set = selector.selectedKeys();
                Iterator<SelectionKey> 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缓存已经处于就绪状态了,自然不会阻塞了。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 216,039评论 6 498
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 92,223评论 3 392
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 161,916评论 0 351
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,009评论 1 291
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,030评论 6 388
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,011评论 1 295
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,934评论 3 416
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,754评论 0 271
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,202评论 1 309
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,433评论 2 331
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,590评论 1 346
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,321评论 5 342
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,917评论 3 325
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,568评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,738评论 1 268
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,583评论 2 368
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,482评论 2 352

推荐阅读更多精彩内容