NIO的Selector模型是我第一次感受到了单线程处理万级连接的魅力。相比传统IO的阻塞读写,Channel加Buffer的组合简直是为高性能网络编程量身定做。不过说实话,NIO的API设计得确实不太友好,入门门槛有点高。这篇文章带你一步步搞懂它。
Java NIO详解
NIO与传统IO有啥区别?
用过传统IO的都知道,一个线程读文件的时候啥也干不了,只能干等。NIO的核心思想就是非阻塞,一个线程可以同时管理多个连接。
| 特性 | 传统IO | NIO |
|---|---|---|
| 操作方式 | 阻塞式 | 非阻塞式 |
| 数据单位 | 字节流/字符流 | 缓冲区 |
| 处理模式 | 面向流 | 面向通道 |
| 并发处理 | 多线程 | 单线程处理多个连接 |
NIO的三个核心组件
1. Buffer(缓冲区)
Buffer就是一块连续的内存,所有的NIO操作都围绕着它转。理解Buffer的几个属性是关键——position、limit、capacity,搞不清楚这三个,代码跑出来全是乱码。
常用的Buffer类型:
ByteBuffer、CharBuffer、IntBuffer、LongBuffer等
Buffer的核心属性:
- capacity:容量,创建后不能改
- position:当前位置,下一个要读写的位置
- limit:限制位置,不能读写超过这个位置
- mark:标记位置,可以用
reset()跳回来
// 创建ByteBufferByteBuffer buffer = ByteBuffer.allocate(1024); // 堆内缓冲区ByteBuffer directBuffer = ByteBuffer.allocateDirect(1024); // 直接缓冲区(堆外)
// 写入数据buffer.put((byte) 1);buffer.put(new byte[]{2, 3, 4});
// 切换到读模式——这个最容易忘!buffer.flip();
// 读取数据byte b1 = buffer.get();byte[] bytes = new byte[3];buffer.get(bytes);
// 清空缓冲区,切回写模式buffer.clear();
// 压缩缓冲区——保留未读的数据buffer.compact();
// 标记和重置buffer.mark();// 读了一些数据...buffer.reset(); // 回到标记位置划重点:flip()和clear()是最容易搞混的。我刚开始用的时候,经常忘记flip(),结果读出来全是空数据,debug了半天才发现。
2. Channel(通道)
Channel是双向的,既可以读也可以写,这点跟传统IO的流不一样(InputStream只能读,OutputStream只能写)。
常用的Channel类型:
FileChannel:文件操作SocketChannel:TCP网络ServerSocketChannel:服务端监听DatagramChannel:UDP通信
// FileChannel 读写示例RandomAccessFile file = new RandomAccessFile("data.txt", "rw");FileChannel fileChannel = file.getChannel();
ByteBuffer buffer = ByteBuffer.allocate(1024);
// 从文件读到缓冲区int bytesRead = fileChannel.read(buffer);while (bytesRead != -1) { buffer.flip(); while (buffer.hasRemaining()) { System.out.print((char) buffer.get()); } buffer.clear(); bytesRead = fileChannel.read(buffer);}
// 写入数据到文件buffer.put("Hello, NIO!".getBytes());buffer.flip();fileChannel.write(buffer);
fileChannel.close();file.close();3. Selector(选择器)
Selector是NIO最牛的地方——一个线程管几千个连接。它通过事件驱动的方式,轮询检查哪些通道有数据可读/可写。
// 创建选择器Selector selector = Selector.open();
// 打开ServerSocketChannel,设置为非阻塞ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();serverSocketChannel.bind(new InetSocketAddress(8080));serverSocketChannel.configureBlocking(false);
// 注册到选择器,关注ACCEPT事件serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT);
while (true) { // 阻塞直到有事件发生 int readyChannels = selector.select(); if (readyChannels == 0) continue;
Set<SelectionKey> selectionKeys = selector.selectedKeys(); Iterator<SelectionKey> iterator = selectionKeys.iterator();
while (iterator.hasNext()) { SelectionKey key = iterator.next();
if (key.isAcceptable()) { // 新连接来了 ServerSocketChannel serverChannel = (ServerSocketChannel) key.channel(); SocketChannel socketChannel = serverChannel.accept(); socketChannel.configureBlocking(false); socketChannel.register(selector, SelectionKey.OP_READ); } else if (key.isReadable()) { // 有数据可读 SocketChannel socketChannel = (SocketChannel) key.channel(); ByteBuffer buffer = ByteBuffer.allocate(1024); int bytesRead = socketChannel.read(buffer); if (bytesRead == -1) { socketChannel.close(); key.cancel(); } else if (bytesRead > 0) { buffer.flip(); byte[] data = new byte[buffer.remaining()]; buffer.get(data); System.out.println("收到: " + new String(data)); } }
// 重要!要移除已处理的key iterator.remove(); }}SelectionKey的事件类型:
OP_ACCEPT:接受连接OP_CONNECT:连接完成OP_READ:可读OP_WRITE:可写
NIO的实用技巧
1. 非阻塞IO
设置configureBlocking(false)后,读写操作不会阻塞,没数据就直接返回0或-1:
SocketChannel socketChannel = SocketChannel.open();socketChannel.configureBlocking(false);
// 非阻塞连接boolean connected = socketChannel.connect(new InetSocketAddress("localhost", 8080));if (!connected) { while (!socketChannel.finishConnect()) { // 可以做其他事情 System.out.println("连接中..."); }}
// 非阻塞读取ByteBuffer buffer = ByteBuffer.allocate(1024);int bytesRead = socketChannel.read(buffer);// bytesRead可能是0(没数据)、-1(连接关闭)、>0(有数据)2. 直接缓冲区(Direct Buffer)
直接缓冲区分配在堆外内存,避免了数据在堆内和堆外之间的拷贝,大文件场景下性能提升明显:
ByteBuffer directBuffer = ByteBuffer.allocateDirect(1024);但要注意:创建和销毁直接缓冲区的成本比堆内缓冲区高,要复用,不要频繁创建。
3. 内存映射文件
大文件操作的神器——把文件映射到内存,直接操作内存就像操作文件一样:
FileChannel fileChannel = new RandomAccessFile("data.txt", "rw").getChannel();MappedByteBuffer mappedBuffer = fileChannel.map( FileChannel.MapMode.READ_WRITE, 0, fileChannel.size());
// 直接修改内存中的数据for (int i = 0; i < mappedBuffer.limit(); i++) { byte b = mappedBuffer.get(i); mappedBuffer.put(i, (byte) (b + 1));}
fileChannel.close();4. 分散/聚集IO
一次性读写多个缓冲区,适合处理协议数据(比如头部和体部分开):
// 分散读取ByteBuffer header = ByteBuffer.allocate(100);ByteBuffer body = ByteBuffer.allocate(1024);ByteBuffer[] buffers = {header, body};socketChannel.read(buffers);
// 聚集写入ByteBuffer headerBuf = ByteBuffer.wrap("HTTP/1.1 200 OK\r\n".getBytes());ByteBuffer bodyBuf = ByteBuffer.wrap("Hello, World!\r\n".getBytes());socketChannel.write(new ByteBuffer[]{headerBuf, bodyBuf});实战贴士
- Buffer用完记得 flip():写入数据后要切换到读模式,这个步骤最容易忘
- Selector的key要手动移除:
iterator.remove()不调用的话,下次轮询还会处理同一个事件 - 直接缓冲区要复用:创建成本高,不要频繁allocateDirect
- 非阻塞IO不是万能的:简单场景用传统IO更省事,NIO的复杂度不一定值得
- 内存映射文件不能超过物理内存:否则会OOM
常见坑
- Buffer状态管理不当:忘记
flip()、clear()、compact(),导致数据错乱。我刚开始写NIO的时候,经常读出来一堆乱码,debug半天才发现是position没搞对。 - Selector key重复处理:没有在循环里
iterator.remove(),同一个事件处理了多次,导致各种奇怪的问题。 - 通道没关闭:资源泄漏,特别是在处理网络连接时,连接数一多就炸了。
- 直接缓冲区频繁创建:堆外内存泄漏,GC管不了堆外内存,只能自己注意。
如果这篇文章对你有帮助,欢迎分享给更多人!
部分信息可能已经过时






