NIO 实战
我第一次用 NIO 写聊天室,踩了三个通宵的坑。
那是我接手的一个即时通讯项目,最初用 BIO 写的,100 个在线用户还勉强能跑。后来产品搞了个活动,用户蹭蹭往上涨,服务端直接 OOM 了。
痛定思痛,我决定上 NIO。
聊天服务器:踩坑记录
java
public class NIOChatServer {
private final Selector selector = Selector.open();
private final Map<String, SocketChannel> clients = new ConcurrentHashMap<>();
public static void main(String[] args) throws IOException {
new NIOChatServer().start(8080);
}
public void start(int port) throws IOException {
ServerSocketChannel serverChannel = ServerSocketChannel.open();
serverChannel.socket().bind(new InetSocketAddress(port));
serverChannel.configureBlocking(false);
serverChannel.register(selector, SelectionKey.OP_ACCEPT);
System.out.println("聊天服务器启动,端口 " + port);
while (true) {
selector.select();
for (SelectionKey key : selector.selectedKeys()) {
if (key.isAcceptable()) {
handleAccept(key);
}
if (key.isReadable()) {
handleRead(key);
}
}
selector.selectedKeys().clear();
}
}
private void handleAccept(SelectionKey key) throws IOException {
ServerSocketChannel server = (ServerSocketChannel) key.channel();
SocketChannel client = server.accept();
client.configureBlocking(false);
client.register(selector, SelectionKey.OP_READ);
clients.put(client.getRemoteAddress().toString(), client);
broadcast("用户 " + getClientName(client) + " 加入了聊天\n");
sendTo(client, "欢迎!当前在线 " + clients.size() + " 人\n");
}
private void handleRead(SelectionKey key) throws IOException {
SocketChannel client = (SocketChannel) key.channel();
ByteBuffer buffer = ByteBuffer.allocate(512);
int read = client.read(buffer);
if (read > 0) {
buffer.flip();
byte[] data = new byte[buffer.remaining()];
buffer.get(data);
String message = new String(data, StandardCharsets.UTF_8);
if (message.trim().equals("/quit")) {
clients.remove(client.getRemoteAddress().toString());
client.close();
broadcast("用户 " + getClientName(client) + " 离开了\n");
} else {
String formatted = getClientName(client) + ": " + message;
broadcast(formatted);
}
} else if (read == -1) {
clients.remove(client.getRemoteAddress().toString());
client.close();
broadcast("用户 " + getClientName(client) + " 断开了\n");
}
}
private void broadcast(String message) {
ByteBuffer buffer = ByteBuffer.wrap(message.getBytes(StandardCharsets.UTF_8));
for (SocketChannel client : clients.values()) {
try {
while (buffer.hasRemaining()) {
client.write(buffer);
}
buffer.rewind();
} catch (IOException e) {
// 忽略
}
}
}
private void sendTo(SocketChannel client, String message) throws IOException {
ByteBuffer buffer = ByteBuffer.wrap(message.getBytes(StandardCharsets.UTF_8));
while (buffer.hasRemaining()) {
client.write(buffer);
}
}
private String getClientName(SocketChannel client) {
return "Client-" + client.getRemoteAddress().toString().split(":")[1];
}
}坑一:broadcast 的 buffer 没重置
java
// ❌ 第一版代码
private void broadcast(String message) {
ByteBuffer buffer = ByteBuffer.wrap(message.getBytes(StandardCharsets.UTF_8));
for (SocketChannel client : clients.values()) {
while (buffer.hasRemaining()) {
client.write(buffer); // 写完第一个 client 后,buffer 没重置
}
// 后面的 client 收不到消息
}
}
// ✅ 修正
private void broadcast(String message) {
ByteBuffer buffer = ByteBuffer.wrap(message.getBytes(StandardCharsets.UTF_8));
for (SocketChannel client : clients.values()) {
try {
while (buffer.hasRemaining()) {
client.write(buffer);
}
buffer.rewind(); // 重置 position=0,给下一个 client 用
} catch (IOException e) {
// 忽略
}
}
}坑二:没处理半包
如果用户发了一条很长的消息(比如粘贴了一段代码),TCP 可能会分包到达。第一版代码直接 read() 就处理,结果消息截断了。
解决方案是加一个协议解析器,按 \n 分行处理:
java
// 每个客户端维护一个读缓冲区
private Map<SocketChannel, ByteBuffer> readBuffers = new ConcurrentHashMap<>();
private void handleRead(SelectionKey key) throws IOException {
SocketChannel client = (SocketChannel) key.channel();
ByteBuffer buffer = readBuffers.computeIfAbsent(client, k -> ByteBuffer.allocate(1024));
int read = client.read(buffer);
if (read > 0) {
buffer.flip();
// 按行处理
while (buffer.hasRemaining()) {
byte b = buffer.get();
if (b == '\n') {
// 完整一行,解析
processMessage(client, lineBuilder.toString());
lineBuilder = new StringBuilder();
} else {
lineBuilder.append((char) b);
}
}
buffer.compact(); // 压缩未处理完的数据
}
}文件传输服务器:直接缓冲区的好处
NIO 的 FileChannel 配合直接缓冲区,文件复制性能可以接近系统原生:
java
public class NIOFileServer {
// 基础版:简单复制
public static void copyFile(String src, String dst) throws IOException {
try (
FileChannel in = new FileInputStream(src).getChannel();
FileChannel out = new FileOutputStream(dst).getChannel()
) {
ByteBuffer buffer = ByteBuffer.allocateDirect(8192);
while (in.read(buffer) != -1) {
buffer.flip();
out.write(buffer);
buffer.clear();
}
}
}
// 进阶版:带进度显示
public static void copyFileWithProgress(String src, String dst)
throws IOException {
FileChannel in = new FileInputStream(src).getChannel();
FileChannel out = new FileOutputStream(dst).getChannel();
long size = in.size();
long transferred = 0;
ByteBuffer buffer = ByteBuffer.allocateDirect(8192);
try {
while (transferred < size) {
buffer.clear();
long read = in.read(buffer);
buffer.flip();
long written = out.write(buffer);
transferred += written;
int percent = (int) (transferred * 100 / size);
System.out.printf("传输进度: %d%%\r", percent);
}
System.out.println();
} finally {
in.close();
out.close();
}
}
}为什么用 allocateDirect()?
因为 FileChannel 和网络 socket 打交道时,直接缓冲区可以避免数据在 JVM 堆和系统内核之间来回复制。文件越大,这个优化越明显。
经验总结
写完这个项目,我总结了 NIO 开发的几条血泪教训:
- selectedKeys 必须清空——不清空的话,同一个事件会被处理 N 次
- buffer 必须重置或压缩——广播消息时尤其容易踩坑
- 处理半包问题——TCP 不保证消息边界
- 非阻塞模式下 accept/read/write 都可能返回 null/0/-1——都要处理
- 生产环境用 Netty——自己写的 NIO 总有考虑不周的地方
直接用 NIO 写生产代码是勇敢的选择,但大多数时候你只是在重复造轮子。
如果你的项目不是专注文档解析或网络协议教育,直接上 Netty 吧。
下一节,我们来看看 NIO 的增强版——NIO.2。
