0%

(三)NIO - 非阻塞式网络通信

1. 阻塞与非阻塞

传统的 IO 流都是阻塞式的。也就是说,当一个线程调用 read() 或 write()时,该线程被阻塞,直到有一些数据被读取或写入,该线程在此期间不能执行其他任务。因此,在完成网络通信进行 IO 操作时,由于线程会阻塞,所以服务器端必须为每个客户端都提供一个独立的线程进行处理,当服务器端需要处理大量客户端时,性能急剧下降。

Java NIO 是非阻塞模式的。当线程从某通道进行读写数据时,若没有数据可用时,该线程可以进行其他任务。线程通常将非阻塞 IO 的空闲时间用于在其他通道上执行 IO 操作,所以单独的线程可以管理多个输入和输出通道。因此, NIO 可以让服务器端使用一个或有限几个线程来同时处理连接到服务器端的所有客户端。

下面演示了基于阻塞式的客户端和服务器之间的网络通信,具体代码如下所示:

客户端代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
//注:为了方便演示,这里直接将异常抛出,并直接close
@Test
public void client() throws IOException {
//1. 获取客户端通道
SocketChannel sChannel = SocketChannel.open(new InetSocketAddress("127.0.0.1", 9898));
//2. 获取文件通道
FileChannel fChannel = FileChannel.open(Paths.get("1.txt"), StandardOpenOption.READ);

//3. 分配指定大小的缓冲区
ByteBuffer buf = ByteBuffer.allocate(1024);

//4.读取文件通道的数据并写入缓冲区中
while (fChannel.read(buf) != -1) {
//5. 读取缓冲区的数据写入客户端通道中
buf.flip();
sChannel.write(buf);
buf.clear();
}
//须告知服务器客户端的数据已经发送完毕
sChannel.shutdownOutput();

//6.客户端接收服务端的反馈信息
int len = -1;
while ((len = sChannel.read(buf)) != -1) {
buf.flip();
//将缓冲区的数据打印出来
System.out.println(new String(buf.array(), 0, len));
buf.clear();
}

//7.关闭通道
fChannel.close();
sChannel.close();
}

服务器端代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
@Test
public void server() throws IOException {
//1. 获取服务器通道
ServerSocketChannel ssChannel = ServerSocketChannel.open();

//2. 获取文件通道
FileChannel outChannel = FileChannel.open(Paths.get("2.txt"),
StandardOpenOption.WRITE, StandardOpenOption.CREATE);

//3. 绑定与客户端的连接
ssChannel.bind(new InetSocketAddress(9898));

//4. 绑定之后,通过服务器通道获取客户端连接的通道
SocketChannel sChannel = ssChannel.accept();

//5. 分配指定大小的缓冲区
ByteBuffer buf = ByteBuffer.allocate(1024);

//6.接收客户端的数据,并保存到本地
while (sChannel.read(buf) != -1) {
buf.flip();
outChannel.write(buf);
buf.clear();
}
//须告知客户端服务器的数据已经接收完毕
sChannel.shutdownInput();

//7. 发送数据给客户端
buf.put("服务器成功接收到客户端发来的数据!".getBytes());
buf.flip();
sChannel.write(buf);

//8. 关闭通道
outChannel.close();
sChannel.close();
ssChannel.close();
}

需要强调的是,如果注释掉客户端和服务器代码中的shutdownOutput和shutdownInput方法后,此时运行会一直处于阻塞状态。因为服务器无法确定客户端输出数据是否已经结束,也就是无法确定是否还需要执行sChannel.write(buf);该调用,那么运行就会一直处于阻塞状态。服务器端同理。

shutdownOutput 该方法的作用就是断开Socket的输出流,但是不关闭通道。调用该方法之后,我们无法再向通道中写入数据了,也就是表明客户端的输出已经结束。shutdownInput方法同理。

2. 选择器的使用

前面我们已经说了,NIO是非阻塞模式的,而非阻塞的实现的核心就是选择器。选择器(Selector) 是 SelectableChannle 对象的多路复用器, Selector 可以同时监控多个 SelectableChannel 的 IO 状况,也就是说,利用 Selector可使一个单独的线程管理多个 Channel。

SelectableChannle 的结构如下图:
在这里插入图片描述
下面列出来了Selector的一些常用的方法及其描述:
在这里插入图片描述
另外,我们可以通过Selector的静态方法open来获取选择器:

1
Selector selector = Selector.open();

并可以使用通道的register方法向选择器注册通道:

1
ssChannel.register(selector, SelectionKey.OP_ACCEPT);

ssChannel为SocketServetChannel的一个对象,调用register方法时不仅指定了选择器,同时还指定了SelectKey.OP_ACCEPT,即选择器对通道指定的监听事件,这里为接收事件。

SelectionKey: 表示 SelectableChannel 和 Selector 之间的注册关系。每次向选择器注册通道时就会选择一个事件(选择键)。SelectionKey可以监听的事件类型的常量表示如下:

  • 读 : SelectionKey.OP_READ
  • 写 : SelectionKey.OP_WRITE
  • 连接 : SelectionKey.OP_CONNECT
  • 接收 : SelectionKey.OP_ACCEPT

SelectKey的一些常用方法及其描述如下图示:
在这里插入图片描述

2.1 示例

下面的示例使用NIO实现了多人聊天室的功能。服务器通道使用了configureBlocking(false)将通道切换为非阻塞模式,并向选择器注册该通道时指定了监听服务器接收的事件,然后轮询的使用Selector的select方法来判断这些接收事件是否就绪。这些操作表明了只有服务器接收到监听事件时(select方法返回值大于0)才去处理接收事件,此时线程可以去干别的事而不用一直等待接收监听。

服务器端的代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
@Test
public void server() throws IOException {
//1. 获取通道
ServerSocketChannel ssChannel = ServerSocketChannel.open();
//2. 切换为非阻塞模式
ssChannel.configureBlocking(false);
//3. 绑定连接
ssChannel.bind(new InetSocketAddress(9898));
//4. 获取选择器
Selector selector = Selector.open();
//5. 将通道注册到选择器上,并且执行“监听接收事件”
ssChannel.register(selector, SelectionKey.OP_ACCEPT);

//6. 轮询式的获取选择器上已经“准备就绪”的事件
//select()返回值表示“准备就绪”的事件的数量
while (selector.select() > 0) {
//7. 获取当前选择器中所有已就绪的监听事件的“选择键”
Iterator<SelectionKey> it = selector.selectedKeys().iterator();
while (it.hasNext()) {
//8. 获取准备“就绪”的事件
SelectionKey key = it.next();

//9. 判断具体是什么事件准备就绪
if (key.isAcceptable()) {
//10. 若是接收事件就绪,获取客户端连接
SocketChannel sChannel = ssChannel.accept();

//11. 将客户端通道切换非阻塞模式
sChannel.configureBlocking(false);
//12. 将通道注册到选择器上,客户端执行“读”就绪事件
sChannel.register(selector, SelectionKey.OP_READ);
} else if (key.isReadable()) {
//13. 若是读事件就绪,则进行数据读取
SocketChannel sChannel = (SocketChannel) key.channel();
//14,读取数据
ByteBuffer buf = ByteBuffer.allocate(1024);

int len = -1;
while ((len = sChannel.read(buf)) > 0) {
buf.flip();
System.out.println(new String(buf.array(), 0, len));
buf.clear();
}
}
//15. 取消选择键
it.remove();

}//while
}//while
}

客户端的代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
@Test
public void client() throws IOException {
//1. 获取通道
SocketChannel sChannel = SocketChannel.open(new InetSocketAddress("127.0.0.1", 9898));
//2. 切换成非阻塞模式
sChannel.configureBlocking(false);
//3. 分配执行大小的缓冲区
ByteBuffer buf = ByteBuffer.allocate(1024);
//4. 发送数据给客户端
Scanner scan = new Scanner(System.in);
while (scan.hasNext()) {
String content = scan.nextLine();
if (content.equals("exit"))
break;
buf.put((new Date() + ":" + content).getBytes());
buf.flip();
//读取缓冲区并将数据写入通道中
sChannel.write(buf);
buf.clear();
}
scan.close();
//5. 关闭通道
sChannel.close();
}

当多个用户输出数据时,服务器端都会接收到数据并打印在控制台上:

1
2
Mon Apr 01 11:11:12 CST 2019:你好 
Mon Apr 01 11:11:20 CST 2019:hello

3. DatagramChannel 数据传输

Java NIO中的DatagramChannel是一个能收发UDP包的通道。下面使用该通道实现多人聊天室功能,具体代码如下所示:

接收端代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
@Test
public void receive() throws IOException {
DatagramChannel dc = DatagramChannel.open();
dc.configureBlocking(false);
dc.bind(new InetSocketAddress(9898));
// 获取选择器
Selector selector = Selector.open();
// 注册通道
dc.register(selector, SelectionKey.OP_READ);

while (selector.select() > 0) {
Iterator<SelectionKey> it = selector.selectedKeys().iterator();
while (it.hasNext()) {
SelectionKey key = it.next();
if (key.isReadable()) {
// 进行数据读取
ByteBuffer buf = ByteBuffer.allocate(1024);
//接收数据包的数据并写进缓冲区中
dc.receive(buf);
buf.flip();
System.out.println(new String(buf.array(), 0, buf.limit()));
buf.clear();
// 取消选择器
it.remove();
} // if
} // while
} // while

}

发送端代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@Test
public void send() throws IOException {
DatagramChannel dc = DatagramChannel.open();
dc.configureBlocking(false);
ByteBuffer buf = ByteBuffer.allocate(1024);
Scanner scan = new Scanner(System.in);

while (scan.hasNext()) {
String content = scan.next();
buf.put((new Date().toString() + "\n" + content).getBytes());
buf.flip();
// 发送数据
dc.send(buf, new InetSocketAddress("127.0.0.1", 9898));
buf.clear();
}
scan.close();
dc.close();
}

4. Pipe 管道数据传输

Java NIO 管道是2个线程之间的单向数据连接。Pipe有一个source通道和一个sink通道。数据会被写到sink通道,从source通道读取。
在这里插入图片描述
下面演示一下管道之间的数据传输示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
public class TestPipe {

private Pipe pipe = null;

public TestPipe() throws IOException {
//获取管道
pipe = Pipe.open();
}

public void testSink() throws IOException {
//1. 创建缓冲区
ByteBuffer buffer = ByteBuffer.allocate(1024);
//2.获取sink通道
Pipe.SinkChannel sink = pipe.sink();
//3. 向通道中写入数据
buffer.put("通过单向管道写入数据".getBytes());
buffer.flip();
sink.write(buffer);
//4. 关闭通道
sink.close();
}

public void testSource() throws IOException {
//1. 创建缓冲区
ByteBuffer buffer = ByteBuffer.allocate(1024);
//2. 创建source通道
Pipe.SourceChannel source = pipe.source();
int len = source.read(buffer);
System.out.println(new String(buffer.array(), 0, len));//输出:通过单向管道写入数据
//3. 关闭通道
source.close();
}

public static void main(String[] args) throws IOException {
TestPipe testPipe = new TestPipe();
testPipe.testSink();
testPipe.testSource();
}
}
------ 本文结束------