在上一篇《你好,NIO》博客中的提到了NIO使用中半包粘包问题,解决方案是用帧状态机,这一篇单独摘出来细说一下。为什么凑齐一帧必须用"状态机"?
先说下我最初的想法,估计也是大多数人的第一反应:
java ByteBuffer header = ByteBuffer.allocate(4);
while (header.hasRemaining()) {
channel.read(header); // 头没凑齐就一直读
}
header.flip();
int len = header.getInt();
ByteBuffer body = ByteBuffer.allocate(len);
while (body.hasRemaining()) {
channel.read(body); // 内容没凑齐就一直读
}
// 凑齐了,处理消息
看起来天经地义——"没读满就继续读,直到凑齐为止",BIO 时代不就这么写的吗?但注意:这里是非阻塞通道(configureBlocking(false))。非阻塞的 read() 在没有数据时不会等待,而是立刻返回 0。
于是这段代码在两个场景下会出大事:
半包(一条消息被拆成多次到达)—— CPU 空转客户端发了一条 3000 字节的消息,真实网络上它被 TCP 拆成多个段,先后到达:
在了解NIO之前先看下BIO它的缺点是什么,为什么最终像Tomcat服务器、Netty网络编程框架都选了NIO? 先看下BIO服务端代码看下他做了什么。
java try (ServerSocket serverSocket = new ServerSocket(PORT)) {
while (true) {
Socket socket = serverSocket.accept(); // ① 阻塞点1:等待客户端连接
System.out.println("[新连接] " + socket.getRemoteSocketAddress());
new Thread(new ClientHandle(socket)).start(); // 每个连接分配一个独立线程
}
} catch (IOException e) {
throw new RuntimeException(e);
}
java// ClientHandle 内部
BufferedReader reader = new BufferedReader(
new InputStreamReader(socket.getInputStream())
);
String message;
while ((message = reader.readLine()) != null) { // ② 阻塞点2:等待客户端发数据
System.out.println("收到消息:" + message);
}
BIO = Blocking I/O(同步阻塞 I/O)。当线程发起 I/O 调用后,如果数据未就绪,调用不会返回,线程被操作系统挂起、原地干等 —— 注意是“挂起不占 CPU”,不是“耗时长”。Java 里 BIO 的落地 = java.io 流 + java.net 阻塞式 Socket。典型场景是网络:客户端 socket.getInputStream().read() 在对端不发数据时一直挂着,服务端 accept() 也阻塞等连接 —— 两个阻塞点叠加,导致经典 BIO 服务端只能"一个连接一个线程”,这就是它在高并发下崩掉的根源,也是下一节 NIO 登场的原因。
在正式进入阻塞式案例学习前,先铺垫一些 IO 基础。IO 指的是程序与外部数据源/目的地之间的数据交换,分为输入流和输出流。参照系是程序(JVM)本身:
常见的使用案例:
FileInputStream/FileOutputStream、FileReader/FileWriterByteArray*Stream、StringReader/StringWritersocket.getInputStream() 返回的就是 InputStream Socket 本身在 java.net 包System.in(InputStream)、System.out(PrintStream) Scanner 的默认来源另外 Socket 并不是 java.io的类,但它暴露的接口就是 InputStream —— 这意味着“从网络读”和“从文件读”在代码层面是同一套 API,缓冲流、装饰器可同用。流的抽象屏蔽了设备差异,这也是 java.io设计里让人感觉惊喜的一笔。当然这只是理论,等我敲完代码再对比下可能感觉会更深。
字符流、字节流在学习初期让我感觉非常混沌。为什么要有字节流和字符流?谁在前谁在后?我该如何梳理并进行高效的运用?
结果显而易见,字节流在前。JDK 1.0只有字节流,物理世界里所有 I/O 设备 —— 磁盘、网卡、键盘 —— 读写的最小单位都是字节,这就出现了一个问题,读取出来的字节都是010101这种电信号,需要程序员对照着UTF-8或者GBK编码表手动的去翻译,流程包括必须知道编码->手动解码->处理边界,很容易出现乱码。另外程序中处理最多的是文本而不是字节。
于是JDK1.1推出了 Reader/Writer字符流,对于字节流来说,字符流的出现简直是救世主,因为你不需要手动解码了,你用 FileReader指定UTF-8编码调用 reader.read(),系统会自动帮你“翻译”,比如 E4 BD A0这三个字节合在一起就是“你”它不会像字节流那样把三个字节拆开给你。而且字符流有缓冲池,底层虽然还是用字节去硬盘里拿数据但 Reader内部有一个默认8192字符大小的缓冲区。它每次从硬盘读字节时,会尽可能多地读然后偷偷在后台把字节凑成完整的汉字放进缓冲区。当你调用 read()时,它直接从缓冲区把拼好的“你”字给你,绝不给你半截字节