JDK中nio编程的三大件

nio过程:

channel注册到selector的时候会指定该通道需要selector监听的事件类型

发送程序使用channel向fd中写入数据,完成之后fd会产生一个写就绪的事件,

selector轮询的时候会发现到该事件,并将该事件对应的通道取出来进行处理(将fd中的数据通过socket进行发送)

接收程序将channel注册到selector时会指定监听读就绪事件,

socket接收到数据写入fd中,fd产生一个读就绪事件,

selector轮询的时候会发现到该事件,并将该事件对应的通道取出来进行处理(将fd中的数据读取到应用中)

Linux系统中selector 底层采用epoll模型,监听多个fd的状态(满、非满、空、非空)

epoll

epoll是在2.6内核中提出的,是之前的select和poll的增强版本。相对于select和poll来说,epoll更加灵活,没有描述符限制。epoll使用一个文件描述符管理多个描述符,将用户关系的文件描述符的事件存放到内核的一个事件表中,这样在用户空间和内核空间的copy只需一次。

基本原理:epoll支持水平触发和边缘触发,最大的特点在于边缘触发,它只告诉进程哪些fd刚刚变为就绪态,并且只会通知一次。还有一个特点是,epoll使用“事件”的就绪通知方式,通过epollctl注册fd,一旦该fd就绪,内核就会采用类似callback的回调机制来激活该fd,epollwait便可以收到通知。

epoll的优点:

1、没有最大并发连接的限制,能打开的FD的上限远大于1024(1G的内存上能监听约10万个端口)。

2、效率提升,不是轮询的方式,不会随着FD数目的增加效率下降。 只有活跃可用的FD才会调用callback函数;即Epoll最大的优点就在于它只管你“活跃”的连接,而跟连接总数无关,因此在实际的网络环境中,Epoll的效率就会远远高于select和poll。

3、内存拷贝,利用mmap()文件映射内存加速与内核空间的消息传递;即epoll使用mmap减少复制开销。

channel 与 fd 通信过程使用 buffer 组织数据

因此nio三大件是 channel buffer selector

1. Channel

每个连接 至少对应一个fd,每个fd至少对应一个channel

channel是nio过程中jvm内存中的对象,类似于inputStream和outStream,但又有些不同:

  • 通道既可以读取数据,又可以写数据到通道,有read和write方法。但流的读写通常是单向的,只有read或只有write。

  • 通道读写是异步进行的,突出的是非阻塞。流的读写则是同步阻塞的。

  • 通道中的数据总是要先读到一个Buffer,或者总是要从一个Buffer中写入。

常见的channel:

  1. FileChannel 从文件中读写数据(不作了解)。
  2. DatagramChannel 能通过UDP读写网络中的数据。
  3. SocketChannel 能通过TCP读写网络中的数据。
  4. ServerSocketChannel可以监听新进来的TCP连接,像Web服务器那样。对每一个新进来的连接都会创建一个SocketChannel。
1
2
3
4
5
6
7
8
9
 /**
* Reads a sequence of bytes from this channel into the given buffer.
*/
public int read(ByteBuffer dst) throws IOException;

/**
* Writes a sequence of bytes to this channel from the given buffer.
*/
public int write(ByteBuffer src) throws IOException;

注意对Channel的read和write的理解:

  • channel.read(buffer) 意思是通过channel Read from fd to buffer
  • channel.write(buffer) 意思是通过channel Write to fd from buffer

2. Buffer

在channel中传输的是buffer中的数据,而不是buffer对象。buffer是应用程序用来组织传输数据的对象。

使用Buffer读写数据一般遵循以下四个步骤(buffer为读写主体):

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

说明:

  • 当向buffer写入数据时,buffer会记录下写了多少数据。一旦要读取数据,需要通过flip()方法将Buffer从写模式切换到读模式。在读模式下,可以读取之前写入到buffer的所有数据。
  • 一旦读完了所有的数据,就需要清空缓冲区,让它可以再次被写入。有两种方式能清空缓冲区:调用clear()或compact()方法。clear()方法会清空整个缓冲区。compact()方法只会清除已经读过的数据。任何未读的数据都被移到缓冲区的起始处,新写入的数据将放到缓冲区未读数据的后面。

    2.1 Buffer抽象类

    Buffer抽象类中定义的常用方法:
  • Buffer flip() flip方法将Buffer从写模式切换到读模式。调用flip()方法会将position设回0,并将limit设置成之前position的值。
  • Buffer rewind() 将position设回0,所以你可以重读Buffer中的所有数据。limit保持不变,仍然表示能从Buffer中读取多少个元素(byte、char等)
  • int remaining() 返回position到limit之间的元素个数(未读出元素个数)
  • boolean hasRemaining() 返回是否还有未读出的数据
  • boolean isReadOnly() 是否此buffer只能读出
  • Buffer mark() 可以标记Buffer中的一个特定position,之后可以通过调用Buffer.reset()方法恢复到这个position。
  • Buffer reset() 恢复到mark()标记的状态
  • Buffer clear() 重置position、limit、capacity和mark,从读模式转换成写模式

此外Buffer还声明了几个抽象方法如下,这些方法都是在Buffer的子类中定义的

1
2
3
4
5
boolean hasArray();
boolean isReadOnly();
Object array();
int arrayOffset();
boolean isDirect();

2.2 Buffer的类型

Java NIO 有以下Buffer类型:

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

这些类都是Buffer的子类,其实也是抽象类,它们在Buffer抽象类的基础上扩展了与数据类型相关的功能,下面以ByteBuffer为例介绍

扩展的常用方法:

  • ByteBuffer compact() 将所有未读的数据拷贝到Buffer起始处。然后将position设到最后一个未读元素正后面。limit属性依然像clear()方法一样,设置成capacity。
  • byte get() 获取position所指的byte,并且position加1
  • byte get(int index) 获取指定位置的byte
  • ByteBuffer put(byte b) 将指定的byte写入buffer
  • ByteBuffer put(int index,byte b) 将指定的byte写入buffer的指定位置
  • …许多的不同类型的get/put操作

3. Selector

  1. 创建:调用Selector类的静态方法open()创建selector对象
1
Selector selector = Selector.open();
  1. 注册通道:调用Channel的实例方法将通道注册到selector上
    1
    2
    channel.configureBlocking(false);
    SelectionKey key = channel.register(selector,Selectionkey.OP_READ);

与Selector一起使用时,Channel必须处于非阻塞模式下。这意味着不能将FileChannel与Selector一起使用,因为FileChannel不能切换到非阻塞模式。而套接字通道都可以。

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

  • connect
  • accept
  • read
  • write

当以上四种事件就绪的时候,会触发对应的通道事件,通道事件会被selector发现。

  1. 客户端channel成功连接到一个服务器称为“连接就绪”。 –OP_CONNECT
  2. 一个服务器 socket channel准备好接收新进入的连接称为“接收就绪”。 – OP_ACCEPT
  3. 一个有数据可读的通道可以说是“读就绪”。– OP_READ
  4. 一个通道等待写数据可以说是“写就绪”。 –OP_WRITE

这四种事件用SelectionKey的四个常量来表示:

  • SelectionKey.OP_CONNECT
  • SelectionKey.OP_ACCEPT
  • SelectionKey.OP_READ
  • SelectionKey.OP_WRITE

如果对不止一种事件感兴趣,那么可以用“位或”操作符将常量连接起来,如下:

1
int interestSet = SelectionKey.OP_READ | SelectionKey.OP_WRITE;

3.1 SelectionKey

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

  • interest集合
  • ready集合
  • Channel
  • Selector
  • 附件对象(可选)

interest集合

可以通过SelectionKey读写interest集合,像这样:

1
2
3
4
5
6
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;

可以看到,用“位与”操作interest 集合和给定的SelectionKey常量,可以确定某个确定的事件是否在interest 集合中。

ready集合

ready 集合是通道已经准备就绪的操作的集合,是四个常量通过‘或’运算生成的。在一次选择(Selection)之后,你会首先访问这个ready set。Selection将在下一小节进行解释。可以这样访问ready集合:

1
int readySet = selectionKey.readyOps();

可以用像检测interest集合那样的方法,来检测channel中什么事件或操作已经就绪。但是,也可以使用以下四个方法,它们都会返回一个布尔类型:

1
2
3
4
selectionKey.isAcceptable();
selectionKey.isConnectable();
selectionKey.isReadable();
selectionKey.isWritable();

Channel & Selector
从SelectionKey访问Channel和Selector很简单。如下:

1
2
Channel  channel  = selectionKey.channel();
Selector selector = selectionKey.selector();

在程序中需要对返回的channel做类型转换

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

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

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

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

3.2 Selector选择通道

select()

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

三种select:

  • int select() 阻塞方法,阻塞到至少有一个通道在注册的事件上就绪。
  • int select(long timeout) 超时返回的阻塞方法
  • int selectNow() 非阻塞方法,不管是否有通道就绪,立即返回。如果自上次select之后没有通道就绪,直接返回0

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

selectedKeys()

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

1
Set selectedKeys = selector.selectedKeys();

可以遍历这个已选择的键集合来访问就绪通道,像这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
Iterator<SelectionKey> iter = selector.selectedKeys().iterator();
while(iter.hasNext()){
SelectionKey key = iter.next();
if(key.isAcceptable()){
handleAccept(key);
}
if(key.isReadable()){
handleRead(key);
}
if(key.isWritable() && key.isValid()){
handleWrite(key);
}
if(key.isConnectable()){
System.out.println("isConnectable = true");
}
iter.remove();
}

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

wakeUp()

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

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

close()

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