传统网络编程

首先看一段最传统的Java网络编程。

服务端代码如下:

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
public class IOServer {

public static void main(String[] args) throws IOException {
ServerSocket serverSocket = new ServerSocket(8000);
// 接收新连接的线程
new Thread(() -> {
while (true) {
try {
// 阻塞方法获取新的连接
Socket socket = serverSocket.accept();

// 每一个新的连接都创建一个线程,负责读取数据
new Thread(() -> {
int len;
byte[] data = new byte[1024];
try {
InputStream inputStream = socket.getInputStream();
// 按字节流读取数据
while ((len = inputStream.read(data)) != -1) {
System.out.println(new String(data, 0, len));
}
} catch (IOException e) {
e.printStackTrace();
}
}).start();

} catch (IOException e) {
e.printStackTrace();
}
}
}).start();
}

}

客户端代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class IOClient {

public static void main(String[] args) {
// 我自己运行时,这里只创建了一次连接,然后没3s发送一次数据
new Thread(() -> {
try {
Socket socket = new Socket("127.0.0.1", 8000);
while (true) {
// 网络传输都是传输的字节
socket.getOutputStream().write((new Date() + " hello world").getBytes());
Thread.sleep(3000);
}
} catch (IOException | InterruptedException e) {
e.printStackTrace();
}
}).start();
}

}

可以看到,服务端启动时创建了一个线程,建立了ServerSocket,循环的等待客户端的连接。客户端每次连接,服务端都会创建一个新的线程去处理请求,然后读取数据。

这种模型在客户端数量比较小的时候是没问题的,但是如果客户端数量较多,服务端就需要创建很多的线程,线程的创建比较消耗成本,而且线程数过多时,CPU调度导致的线程切换也会影响性能。

主要有以下三个问题:

  1. 创建太多线程,而线程是很宝贵的资源,同一时刻大量线程处于阻塞会浪费资源。
  2. 线程切换效率低下。
  3. IO编程中,数据传输都是以字节为单位。

为了解决上述问题,引入了NIO,即同步非阻塞IO。

NIO

在NIO中,只会创建一个线程去进行while循环,该线程监控所有的客户端连接,而不会像传统的有多少个客户端连接就创建多少个线程去进行while循环。

传统的模型中,在每一时刻,只有少数线程会有数据读写需求,而没有数据需要读写的就白白浪费了CPU资源。NIO中,通过将所有的客户端连接都注册到selector上,通过检测selector上是否有数据数据需要读写,就可以达到批量读取数据的目的。

这样,就解决了创建太多线程浪费资源和CPU频繁切换线程导致的性能问题。而传统IO是面向字节流,读取数据后需要自己缓存,流中就不存在该数据。而NIO的读写是面向Buffer的,解决了数据只能用字节传输的问题。

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
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
public class NIOServer {

public static void main(String[] args) throws IOException {
Selector serverSelector = Selector.open();
Selector clientSelector = Selector.open();

new Thread(() -> {
try {
// 对应IO编程中服务端启动
ServerSocketChannel listenerChannel = ServerSocketChannel.open();
listenerChannel.socket().bind(new InetSocketAddress(8000));
listenerChannel.configureBlocking(false);
listenerChannel.register(serverSelector, SelectionKey.OP_ACCEPT);

while (true) {
// 检测是否有新的连接,1指的是阻塞时间为1ms
if (serverSelector.select(1) > 0) {
Set<SelectionKey> set = serverSelector.selectedKeys();
Iterator<SelectionKey> keyIterator = set.iterator();
// 遍历所有有数据的SelectionKey
while (keyIterator.hasNext()) {
SelectionKey selectionKey = keyIterator.next();
if (selectionKey.isAcceptable()) {
try {
// 这里就是区别,当有一个新的连接请求到来时,不再创建一个线程,而是直接注册到clientSelector
SocketChannel clientChannel = ((ServerSocketChannel) selectionKey.channel()).accept();
clientChannel.configureBlocking(false);
clientChannel.register(clientSelector, SelectionKey.OP_READ);
} finally {
// 每个客户端只注册一次就可以了
keyIterator.remove();
}
}
}
}
}
} catch (IOException e) {
e.printStackTrace();
}
}).start();

new Thread(() -> {
try {
while (true) {
// 批量轮询是否有哪些连接的数据可读,1同样为阻塞时间
if (clientSelector.select(1) > 0) {
Set<SelectionKey> set = clientSelector.selectedKeys();
Iterator<SelectionKey> keyIterator = set.iterator();
// 轮询所有已经注册了的客户端,然后判断哪一个可读
while (keyIterator.hasNext()) {
SelectionKey key = keyIterator.next();
// 这里是判断可读
if (key.isReadable()) {
try {
SocketChannel clientChannel = (SocketChannel) key.channel();
ByteBuffer byteBuffer = ByteBuffer.allocate(1024);
// 面向buffer
clientChannel.read(byteBuffer);
byteBuffer.flip();
System.out.println(Charset.defaultCharset().newDecoder().decode(byteBuffer).toString());
} finally {
// 这里为什么移除,是否是因为每个客户端传输一次数据后,就从注册中移除
keyIterator.remove();
key.interestOps(SelectionKey.OP_READ);
}
}
}
}
}
} catch (Exception ignored) {

}
}).start();
}

}

NIO编程的核心思路:

  1. NIO 模型中通常会有两个线程,每个线程绑定一个轮询器 selector ,在上面的例子中 serverSelector负责轮询是否有新的连接,clientSelector负责轮询连接是否有数据可读。
  2. 服务端监测到新的连接之后,不再创建一个新的线程,而是直接将新连接绑定到clientSelector上, 这样就不用 IO 模型中 每个连接一个while循环。
  3. clientSelector被一个 while 死循环包裹着,如果在某一时刻有多条连接有数据可读,那么通过 clientSelector.select(1)方法可以轮询出来,进而批量处理。
  4. 数据读写面向Buffer。

但是不难看出,整个编码变得极其复杂。所有,就有了Netty。