一、 Java NIO 【Netty系列】

目录

一、 Java NIO 【Netty系列】

1、NIO概述

2、NIO的三大核心组件

Channel (通道)

Buffer(缓冲区)

Selector(选择器)


一、 Java NIO 【Netty系列】

最近在B站(ps:小破站真不错!!!)学习Netty相关的视频,有了输入当然就要输出啦,不然脑子就堵死了,开个玩笑,要养成记笔记的习惯(ps:因为大佬都是这么说的),所以在这里做一个笔记的记录,有所感,有所悟。想去看视频的,B站链接在文章末供上。

ok,言归正传,在学习Netty之前,我们先了解一下NIO,因为Netty本质是一个NIO框架。

1、NIO概述

Java支持三种网络编程模型I/O模式:BIO(同步阻塞)、NIO(同步非阻塞)、AIO(异步非阻塞)

BIO是同步阻塞的,客户端和服务端进行通信,客户端每一个请求,服务端就启动一个线程进行处理,这样请求多了,就会产生大量线程,可以使用线程池的方式解决创建线程多的问题,但是依然对服务端的资源要求较高。

AIO是异步非阻塞的,服务器实现模式为一个有效请求一个线程,客户端的I/O请求都是由OS先完成了再通知服务器应用去启动线程进行处理。

NIO与BIO一个比较重要的不同,是我们使用BIO的时候往往会引入多线程,每个连接一个单独的线程;而NIO则是使用单线程或者只使用少量的多线程,每个连接共用一个线程(有一个多路复用器,把所有连接都注册到多路复用器上,轮询到连接有I/O请求就进行处理)。Java NIO 全称 java non-blocking IO,是指 JDK 提供的新 API。从 JDK1.4 开始,Java 提供了一系列改进的输入/输出的新特性,被统称为 NIO(即 New IO),是同步非阻塞的。这里涉及一个【同步、异步】【阻塞、非阻塞】问题,在这里说明一下。

在Unix的IO模型中

1、同步:用户程序要参与把数据拷贝到程序缓冲区

2、异步:用户程序发起IO请求后,不等待数据,同时操作系统内核负责I/O操作把数据从内核拷贝到用户程序的缓冲区后通知应用程序。数据拷贝是由操作系统内核完成,用户程序从一开始就没有等待数据,发起请求后不参与任何IO操作,等内核通知完成。

1、阻塞:用户程序发起IO操作请求后等待数据返回

2、非阻塞:用户程序发起IO操作请求后不等待数据,而是调用会立即返回一个标志信息告知条件不满足,数据未准备好,从而用户请求程序继续执行其它任务。执行完其它任务,用户程序会主动轮询查看IO操作条件是否满足,如果满足,则用户程序亲自参与拷贝数据动作

总结来说,同步和异步的区别就是数据的拷贝是否需要操作系统来完成,阻塞和非阻塞的区别就是发起一个I/O请求后是否有立刻返回一个标志信息而不让请求线程等待。

2、NIO的三大核心组件

Java NIO 由以下几个核心部分组成:

  • Channel(通道)
  • Buffer(缓冲区)
  • Selector(选择器)

Channel (通道)

所有的 IO 在NIO 中都从一个Channel 开始。Channel 有点像流,不过Channel 是双向的。数据可以从Channel读到Buffer中,也可以从Buffer 写到Channel中。这个是必须的,就是说Channel 必须和 Buffer 一起使用,数据总是从Channel 读到 Buffer 去,又或者从 Buffer 写入到 Channel 中去,不存在直接操作 Channel 来读写数据。

Channel是一个接口,定义了 IO 操作的连接与关闭。

public interface Channel extends Closeable {

    /**
     * 判断此通道是否处于打开状态。 
     */
    public boolean isOpen();

    /**
     *关闭此通道。
     */
    public void close() throws IOException;

}

Java NIO中的一些主要Channel的实现类:

  • FileChannel :用于文件的数据读写,
  • DatagramChannel:用于通过 UDP 读写网络中的数据,
  • SocketChannel:用于通过 TCP 读写网络中的数据,客户端,
  • ServerSocketChannel:用于通过 TCP 读写网络中的数据,服务端。

【ServerSocketChanne 类似 ServerSocket , SocketChannel 类似 Socket】

NIO 的通道(Channel) 和 流(Stream)的区别:

  • 通道可以同时进行读写,而流只能读或者只能写
  • 通道可以实现异步读写数据
  • 通道可以从缓冲读数据,也可以写数据到缓冲

我们通过一个代码例子来看一下

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

    // 读取一个文件,rw表示允许访问文件的方式,有r/rw/rws/rwd
    RandomAccessFile aFile = new RandomAccessFile ("1.txt", "rw");
    // 通过流获取一个Channel
    FileChannel inChannel = aFile.getChannel ();

    // 创建一个 ByteBuffer 对象,分配48个 byte
    ByteBuffer buf = ByteBuffer.allocate (48);

    // 从通道读取数据到 ByteBuffer 去,返回读取的字节数
    int bytesRead = inChannel.read (buf);

    // 文件末尾为-1就结束,否则循环读取(大于48字节)
    while (bytesRead != -1) {

        // 这里就是 ByteBuffer 对象分配的大小,
        System.out.println ("Read " + bytesRead);
        // 读写模式切换(前面是Channel写数据到Buffer,接下来要从Buffer中读数据,所以要切换)
        // 首先读取数据到Buffer,然后反转Buffer,接着再从Buffer中读取数据
        // limit = position;position = 0;mark = -1;
        buf.flip ();

        // 从Buffer中读取数据进行遍历
        while (buf.hasRemaining ()) {
            System.out.print ((char) buf.get ());
        }
        // 输出中文
        //System.out.println (new String (buf.array (),"utf-8"));

        // “清空数据”,但不是删除数据,只是把下面的标志位复位,继续下次读写数据
        // position = 0;limit = capacity; mark = -1;
        buf.clear ();
        // 再次读取,是否满足while条件,存在数据就继续循环,否则结束
        bytesRead = inChannel.read (buf);
    }
    // 关闭流,也就关闭通道了
    aFile.close ();
}

flip()方法是翻转缓冲区。

 

clear()方法是清空缓冲区的数据,但不是删除数据,只是把标志复位了。

 

Buffer(缓冲区)

缓冲区本质上是一块可以写入数据,然后可以从中读取数据的内存。这块内存被包装成NIO Buffer对象,并提供了一组方法,用来方便的访问该块内存。

Java NIO中的一些主要Buffer的实现:

  • ByteBuffer
  • CharBuffer
  • DoubleBuffer
  • FloatBuffer
  • IntBuffer
  • LongBuffer
  • ShortBuffer

使用Buffer读写数据一般遵循以下四个步骤:

  1. 写入数据到Buffer
  2. 调用flip()方法
  3. 从Buffer中读取数据
  4. 调用clear()方法或者compact()方法

注意:

写数据到Buffer有两种方式:

1、从Channel写到Buffer(我们称从Channel读出数据写入到Buffer)

2、通过Buffer的put()方法写到Buffer中

 

从Buffer读数据也有两种方式:

1、从Buffer读取数据到Channel(我们称从Buffer读取数据写入到Channel)

2、通过Buffer的get()方法从Buffer中读取数据

当向buffer写入数据时,buffer会记录下写了多少数据。一旦要读取数据,需要通过flip()方法将Buffer从写模式切换到读模式。在读模式下,可以读取之前写入到buffer的所有数据。

一旦读完了所有的数据,就需要清空缓冲区,让它可以再次被写入。有两种方式能清空缓冲区:调用clear()或compact()方法。clear()方法会清空整个缓冲区。compact()方法只会清除已经读过的数据。任何未读的数据都被移到缓冲区的起始处,新写入的数据将放到缓冲区未读数据的后面。

注意:

如果调用的是clear()方法,position将被设回0,limit被设置成 capacity的值。换句话说,Buffer 被清空了。Buffer中的数据并未清除,只是这些标记告诉我们可以从哪里开始往Buffer里写数据。

如果Buffer中有一些未读的数据,调用clear()方法,数据将“被遗忘”,意味着不再有任何标记会告诉你哪些数据被读过,哪些还没有。

如果Buffer中仍有未读的数据,且后续还需要这些数据,但是此时想要先先写些数据,那么使用compact()方法。

compact()方法将所有未读的数据拷贝到Buffer起始处。然后将position设到最后一个未读元素正后面。limit属性依然像clear()方法一样,设置成capacity。现在Buffer准备好写数据了,但是不会覆盖未读的数据。

Buffer 中有 4 个非常重要的属性:capacity、limit、position、mark

position和limit的含义取决于Buffer处在读模式还是写模式。不管Buffer处在什么模式,capacity的含义总是一样的

capacity:容量,Buffer能容纳的数据元素的最大值,这个属性在Buffer创建的时候复制,并且不能被修改,一旦容量满了,只能清空Buffer才能继续写数据。

limit(分读、写模式):在写模式下,Buffer的limit表示你最多能往Buffer里写多少数据。 写模式下,limit等于Buffer的capacity。当切换Buffer到读模式时, limit表示你最多能读到多少数据。因此,当切换Buffer到读模式时,limit会被设置成写模式下的position值。换句话说,你能读到之前写入的所有数据(limit被设置成已写数据的数量,这个值在写模式下就是position)。

position(分读、写模式):当你写数据到Buffer中时,position表示当前的位置。初始的position值为0.当一个byte、long等数据写到Buffer后, position会向前移动到下一个可插入数据的Buffer单元。position最大可为capacity – 1。当读取数据时,也是从某个特定位置读。当将Buffer从写模式切换到读模式,position会被重置为0。当从Buffer的position处读取数据时,position向前移动到下一个可读的位置。

mark:标记,通过 #mark() 方法,记录当前 position。

从图中,我们可以看到,两种模式下,position 和 limit 属性分别代表不同的含义。

  • 写模式下,每往 Buffer 中写入一个值,position 就自动加 1 ,代表下一次的写入位置。
  • 读模式下,每从 Buffer 中读取一个值,position 就自动加 1 ,代表下一次的读取位置。( 和写模式类似 )
  • 写模式下,代表最大能写入的数据上限位置,这个时候 limit 等于 capacity 。
  • 读模式下,在 Buffer 完成所有数据写入后,通过调用 #flip() 方法,切换到读模式。此时,limit 等于 Buffer 中实际的数据大小。因为 Buffer 不一定被写满,所以不能使用 capacity 作为实际的数据大小。

flip:如果要读取 Buffer 中的数据,需要切换模式,从写模式切换到读模式。

public final Buffer flip() {
    limit = position; // 设置读取上限
    position = 0; // 重置 position
    mark = -1; // 清空 mark
    return this;
}

rewind:该方法主要针对读模式,我们可以重新读取和写入数据到Buffer。

public final Buffer rewind() {
    position = 0; // 重置 position
    mark = -1; // 清空 mark
    return this;
}

clear:该方法主要针对写模式,我们可以“重置”Buffer里的标志,并不是删除数据,然后进行读取和写入数据到Buffer。

public final Buffer clear() {
    position = 0; // 重置 position
    limit = capacity; // 恢复 limit 为 capacity
    mark = -1; // 清空 mark
    return this;
}

clear()与compact()方法(再此说明)

一旦读完Buffer中的数据,需要让Buffer准备好再次被写入。可以通过clear()或compact()方法来完成。

如果调用的是clear()方法,position将被设回0,limit被设置成 capacity的值。换句话说,Buffer 被清空了。Buffer中的数据并未清除,只是这些标记告诉我们可以从哪里开始往Buffer里写数据。

如果Buffer中有一些未读的数据,调用clear()方法,数据将“被遗忘”,意味着不再有任何标记会告诉你哪些数据被读过,哪些还没有。

如果Buffer中仍有未读的数据,且后续还需要这些数据,但是此时想要先先写些数据,那么使用compact()方法。

compact()方法将所有未读的数据拷贝到Buffer起始处。然后将position设到最后一个未读元素正后面。limit属性依然像clear()方法一样,设置成capacity。现在Buffer准备好写数据了,但是不会覆盖未读的数据

mark()与reset()方法

通过调用Buffer.mark()方法,可以标记Buffer中的一个特定position。之后可以通过调用Buffer.reset()方法恢复到这个position

buffer.mark();
buffer.reset();  //设置position恢复到mark.

Selector(选择器)

Java NIO 引入 Selector 的概念,它是 Java NIO 得以实现非阻塞 IO 操作的最最最关键。

Selector,顾名思义,一般称为选择器。它是 Java NIO 核心组件中的一个,用于轮询一个或多个 NIO Channel 的状态是否处于可读、可写。如此,一个线程就可以管理多个 Channel ,也就说可以管理多个网络连接。也因此,Selector 也被称为多路复用器。

我们可以注册多个 Channel 到一个 Selector 中。而 Selector 内部的机制,就可以自动的为我们不断的执行查询( select )操作,判断这些注册的 Channel 是否有已就绪的 IO 事件( 例如可读,可写,网络连接已完成 )。

通过这样的机制,一个线程通过使用一个 Selector ,就可以非常简单且高效的来管理多个 Channel 了。


Selector是轮询步骤:

  • 首先,需要将Channel注册到Selector中,这样Selector才知道哪些Channel是需要管理的。
  • 然后Selector就会不断的轮询这些注册的Channel,发现某个Channel有读写事件时,就会把这个Channel加入到一个SelectionKey 集合中去,
  • 最后根据SelectionKey 里的key判断是连接事件、接收事件、读事件、写事件,做对应的业务处理

仅用单个线程来处理多个Channels的好处是,只需要更少的线程来处理通道。事实上,可以只用一个线程处理所有的通道。对于操作系统来说,线程之间上下文切换的开销很大,而且每个线程都要占用系统的一些资源(如内存)。因此,使用的线程越少越好。但是如果一个线程使用了多个Channel,必然会造成每个Channel处理的效率减低。所以Netty的实现就是通过n个线程去处理多个Channel,这个n代表的线程数默认是 CPU个数 * 2

下面看一段代码:

// 创建一个Selector对象
Selector selector = Selector.open();
// 创建一个ServerSocketChannel 对象
ServerSocketChannel channel = new ServerSocketChannel();
// 设置为非阻塞模式
channel.configureBlocking(false);
// 将 Channel 注册到 Selector ,返回一个SelectionKey 对象
SelectionKey key = channel.register(selector, SelectionKey.OP_READ);

在上面,我们通过Selector.open()创建了一个Selector对象,然后为了让Selecto去管理Channel,我们需要把Channel注册到Selector中去,在此之前需要把Channel设置为非阻塞模式。这也说明了FileChannel 是不能够注册到 Selector中的,因为它是阻塞的。

register()方法的第二个参数。这是一个“interest集合”,意思是在通过Selector监听Channel时对什么事件感兴趣。可以监听四种不同类型的事件:

  • Connect :连接完成事件( TCP 连接 ),仅适用于客户端,对应 SelectionKey.OP_CONNECT 。
  • Accept :接受新连接事件,仅适用于服务端,应 SelectionKey.OP_ACCEPT 。
  • Read :读事件,适用于两端,对应 SelectionKey.OP_READ ,表示 Buffer 可读。
  • Write :写时间,适用于两端,对应 SelectionKey.OP_WRITE ,表示 Buffer 可写。

Channel 触发了一个事件,意思是该事件已经就绪:

  • 一个 Client Socket Channel 成功连接到另一个服务器,称为“连接就绪”。
  • 一个 Server Socket Channel 准备好接收新进入的连接,称为“接收就绪”。
  • 一个有数据可读的 Channel ,可以说是“读就绪”。
  • 一个等待写数据的 Channel ,可以说是“写就绪”。

因为 Selector 可以对 Channel 的多个事件感兴趣,所以在我们想要注册 Channel 的多个事件到 Selector 中时,可以使用或运算 | 来组合多个事件。示例代码如下:

int interestSet = SelectionKey.OP_READ | SelectionKey.OP_WRITE;

实际使用时,我们会有改变 Selector 对 Channel 感兴趣的事件集合,可以通过再次调用 #register(Selector selector, int interestSet) 方法来进行变更。示例代码如下:

channel.register(selector, SelectionKey.OP_READ);
channel.register(selector, SelectionKey.OP_READ | SelectionKey.OP_WRITE);

  • 初始时,Selector 仅对 Channel 的 SelectionKey.OP_READ 事件感兴趣。
  • 修改后,Selector 仅对 Channel的 SelectionKey.OP_READ和 SelectionKey.OP_WRITE) 事件都感兴趣。

当向Selector注册Channel时,register()方法会返回一个SelectionKey对象。这个对象包含了一些你感兴趣的属性:

  • interest集合
  • ready集合
  • Channel
  • Selector
  • 附加的对象(可选)
// 通过interestOps()方法,返回感兴趣的集合
int interestSet = selectionKey.interestOps();
// 判断对哪些事件感兴趣
boolean isInterestedInAccept  = (interestSet & SelectionKey.OP_ACCEPT) == SelectionKey.OP_ACCEPT;
boolean isInterestedInConnect = interestSet & SelectionKey.OP_CONNECT;
boolean isInterestedInRead    = interestSet & SelectionKey.OP_READ;
boolean isInterestedInWrite   = interestSet & SelectionKey.OP_WRITE;
//检测channel中什么事件或操作已经就绪
int readySet = selectionKey.readyOps();
// 判断哪些事件已就绪
selectionKey.isAcceptable();
selectionKey.isConnectable();
selectionKey.isReadable();
selectionKey.isWritable();

// 从SelectionKey访问Channel和Selector
Channel  channel  = selectionKey.channel();
Selector selector = selectionKey.selector();

可以将一个对象或者更多信息附着到SelectionKey上,这样就能方便的识别某个给定的通道。例如,可以附加 与通道一起使用的Buffer,或是包含聚集数据的某个对象。使用方法如下

selectionKey.attach(theObject);
Object attachedObj = selectionKey.attachment();

还可以在用register()方法向Selector注册Channel的时候附加对象。如:

SelectionKey key = channel.register(selector, SelectionKey.OP_READ, theObject);

一旦向Selector注册了一或多个通道,就可以调用几个重载的select()方法。这些方法返回你所感兴趣的事件(如连接、接受、读或写)已经准备就绪的那些通道。换句话说,如果你对“读就绪”的通道感兴趣,select()方法会返回读事件已经就绪的那些通道。

// 阻塞到至少有一个通道在你注册的事件上就绪了
int select()
// 有一个超时时间,阻塞到一定时间就返回
int select(long timeout)
// 不会阻塞,不管什么通道就绪都立刻返回
int selectNow()

select()方法返回的int值表示有多少通道已经就绪。也就是说自上次调用select()方法后有多少通道变成就绪状态。如果调用select()方法,因为有一个通道变成就绪状态,返回了1,若再次调用select()方法,如果另一个通道就绪了,它会再次返回1。如果对第一个就绪的channel没有做任何操作,现在就有两个就绪的通道,但在每次select()方法调用之间,只有一个通道就绪了。

一旦调用了select()方法,并且返回值表明有一个或更多个通道就绪了,然后可以通过调用selector的selectedKeys()方法,访问“已选择键集(selected key set)”中的就绪通道。如下所示:

Set selectedKeys = selector.selectedKeys();
// 我们通过遍历selectedKeys来获取每一个就绪事件
Iterator keyIterator = selectedKeys.iterator();
// 循环遍历已选择键集中的每个键
while(keyIterator.hasNext()) {
    // 获取各个键所对应的通道的就绪事件
    SelectionKey key = keyIterator.next();
    if(key.isAcceptable()) {
        // ServerSocketChannel接受了一个连接。
    } else if (key.isConnectable()) {
        // 与远程服务器建立了连接。
    } else if (key.isReadable()) {
        // 通道已准备就绪,可以读
    } else if (key.isWritable()) {
        // 通道已准备就绪,可以写
    }
    // 手动移除key,防止重复处理
    keyIterator.remove();
}

注意每次迭代末尾的keyIterator.remove()调用。Selector不会自己从已选择键集中移除SelectionKey实例。必须在处理完通道时自己移除。下次该通道变成就绪时,Selector会再次将其放入已选择键集中。

SelectionKey.channel()方法返回的通道需要转型成你要处理的类型,如ServerSocketChannel或SocketChannel等。

某个线程调用select()方法后阻塞了,即使没有通道已经就绪,也有办法让其从select()方法返回。只要让其它线程在第一个线程调用select()方法的那个对象上调用Selector.wakeup()方法即可。阻塞在select()方法上的线程会立马返回。

如果有其它线程调用了wakeup()方法,但当前没有线程阻塞在select()方法上,下个调用select()方法的线程会立即“醒来(wake up)”。

用完Selector后调用其close()方法会关闭该Selector,且使注册到该Selector上的所有SelectionKey实例无效。通道本身并不会关闭。

如下是一个简单的 Selector 示例,创建一个 Selector ,并将一个 Channel注册到这个 Selector上( Channel 的初始化过程略去 ),然后持续轮询这个 Selector 的四种事件( 接受,连接,读,写 )是否就绪。代码如下:

// 创建 Selector
Selector selector = Selector.open();
// 注册 Channel 到 Selector 中
channel.configureBlocking(false);
SelectionKey key = channel.register(selector, SelectionKey.OP_READ);
while (true) {
      // 通过 Selector 选择 Channel 
	int readyChannels = selector.select();
	if (readyChannels == 0) {
	   continue;
	}
	// 获得可操作的 Channel
	Set selectedKeys = selector.selectedKeys();
	// 遍历 SelectionKey 数组
	Iterator<SelectionKey> keyIterator = selectedKeys.iterator();
	while (keyIterator.hasNext()) {
		SelectionKey key = keyIterator.next();
		if(key.isAcceptable()) {
        // ServerSocketChannel接受了一个连接。
        } else if (key.isConnectable()) {
            // 与远程服务器建立了连接。
        } else if (key.isReadable()) {
            // 通道已准备就绪,可以读
        } else if (key.isWritable()) {
            // 通道已准备就绪,可以写
        }
        // 手动移除key,防止重复处理
        keyIterator.remove();
	}
}

参考文章如下:

Java NIO系列教程(六) Selector

 

小破站链接:https://www.bilibili.com/video/BV1DJ411m7NR

一起学习,一起进步,加油!!!

 

 

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

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值