Java NIO网络编程深度解析
在掌握了FileChannel与Buffer的核心机制后,让我们将视野转向更复杂的网络通信领域。作为NIO三大核心组件之一,SocketChannel通过其独特的非阻塞特性,彻底改变了传统IO模型的交互范式。传统BIO模型中,套接字的accept/read/write操作可能因等待资源而永久阻塞线程,导致线程利用率低下。
阻塞机制带来以下技术挑战:
- 不可预测的等待时间:网络延迟、丢包或故障会导致操作耗时从纳秒级到分钟级的巨大波动
- 线程资源浪费:每个连接需要一个独立线程处理,万级并发即需万级线程支撑
- 响应延迟累积:阻塞操作形成级联等待,显著增加系统整体响应时间
非阻塞IO的革命性突破
在非阻塞式信道上调用一个方法总是会立即返回,这种调用的返回值指示了所请求的操作完成的程度。
例如,在一个非阻塞式ServerSocketChannel上调用accept()方法,如果有连接请求来了,则返回客户端SocketChannel,否则返回null。NIO通过Channel.configureBlocking(false)
配置,使这些关键操作具备以下特性:
NIO非阻塞通信模型技术解析
在传统IO模型中,网络操作存在固有的阻塞特性,典型表现为:
- 连接建立阻塞:调用accept()方法时,若客户端未发起连接请求,线程将进入永久等待状态
- 数据读取阻塞:执行read()方法时,若接收缓冲区无数据,线程将持续挂起直至新数据到达
代码案例
这里先举一个TCP应用案例,客户端采用NIO实现,而服务端依旧使用IO实现。
客户端代码
public static void client(){
ByteBuffer buffer = ByteBuffer.allocate(1024);
SocketChannel socketChannel = null;
try
{
socketChannel = SocketChannel.open();
socketChannel.configureBlocking(false);
socketChannel.connect(new InetSocketAddress("10.10.195.115",8080));
if(socketChannel.finishConnect())
{
int i=0;
while(true)
{
TimeUnit.SECONDS.sleep(1);
String info = "I'm "+i+++"-th information from client";
buffer.clear();
buffer.put(info.getBytes());
buffer.flip();
while(buffer.hasRemaining()){
System.out.println(buffer);
socketChannel.write(buffer);
}
}
}
}
catch (IOException | InterruptedException e)
{
e.printStackTrace();
}
finally{
try{
if(socketChannel!=null){
socketChannel.close();
}
}catch(IOException e){
e.printStackTrace();
}
}
}
服务端代码
public static void server(){
ServerSocket serverSocket = null;
InputStream in = null;
try
{
serverSocket = new ServerSocket(8080);
int recvMsgSize = 0;
byte[] recvBuf = new byte[1024];
while(true){
Socket clntSocket = serverSocket.accept();
SocketAddress clientAddress = clntSocket.getRemoteSocketAddress();
System.out.println("Handling client at "+clientAddress);
in = clntSocket.getInputStream();
while((recvMsgSize=in.read(recvBuf))!=-1){
byte[] temp = new byte[recvMsgSize];
System.arraycopy(recvBuf, 0, temp, 0, recvMsgSize);
System.out.println(new String(temp));
}
}
}
catch (IOException e)
{
e.printStackTrace();
}
finally{
try{
if(serverSocket!=null){
serverSocket.close();
}
if(in!=null){
in.close();
}
}catch(IOException e){
e.printStackTrace();
}
}
}
总结SocketChannel用例分析
打开
socketChannel = SocketChannel.open();
socketChannel.connect(new InetSocketAddress("xx.xx.xx.xx",port));
关闭
serverSocket.close();
读取数据
String info = "I'm "+i+++"-th information from client";
buffer.clear();
buffer.put(info.getBytes());
buffer.flip();
while(buffer.hasRemaining()){
System.out.println(buffer);
socketChannel.write(buffer);
}
注意,
SocketChannel.write()
方法的调用是在一个while循环中的,write()方法无法保证能写多少字节到SocketChannel。所以,我们重复调用write()
直到Buffer没有要写的字节为止。
非阻塞模式下,read()方法在尚未读取到任何数据时可能就返回了。所以需要关注它的int返回值,它会告诉你读取了多少字节。
TCP服务端的NIO写法
在前面的案例中,我们刻意保留了Selector这一关键组件的悬念。作为NIO的核心调度器,Selector通过其多路复用监听机制,彻底解决了传统IO模型中的"忙等"顽疾。这种创新设计在即时通讯(IM)等超高并发场景中展现出无与伦比的优势。
传统忙等模式的困境
以典型IM服务器为例,当存在百万级长连接时:
Selector选择器
要破解传统通信中的"死等"难题,我们可以把任务拆成两步:首先安排专人观察所有连接通道,一旦发现某个通道能收发数据,就立刻通知工作线程来处理。就像餐厅里服务员盯着所有餐桌,哪桌举手就立刻过去服务,这样大家就不用傻等着,效率自然就上来了,NIO的选择器就实现了这样的功能。
Selector选择器就是一个多路开关选择器,因为一个选择器能够管理多个信道上的I/O操作,一个Selector实例可以同时检查一组信道的I/O状态。
选择器相当于智能交通调度中心,能同时监控多路通道。传统方式则需逐个排查通道状态,像地毯式搜索,有任务就丢给线程池,没有就继续下个循环,效率低下。
传统轮询机制
然而如果用传统的方式来处理这么多客户端,使用的方法是循环地一个一个地去检查所有的客户端是否有I/O操作,如果当前客户端有I/O操作,则可能把当前客户端扔给一个线程池去处理,如果没有I/O操作则进行下一个轮询,当所有的客户端都轮询过了又接着从头开始轮询;
这种方法是非常笨而且也非常浪费资源,因为大部分客户端是没有I/O操作,我们也要去检查;
选择器的轮询
而Selector就不一样了,它在内部可以同时管理多个I/O,当一个信道有I/O操作的时候,他会通知Selector,Selector就是记住这个信道有I/O操作,并且知道是何种I/O操作,是读呢?是写呢?还是接受新的连接;
所以如果使用Selector,它返回的结果只有两种结果,一种是0,即在你调用的时刻没有任何客户端需要I/O操作,另一种结果是一组需要I/O操作的客户端,这是你就根本不需要再检查了,因为它返回给你的肯定是你想要的。这样一种通知的方式比那种主动轮询的方式要高效得多!
三步玩转选择器
- 创建实例:
Selector selector = Selector.open()
- 注册监控:
channel.register(selector, 关注事件)
(注意是通道主动注册) - 启动监控:
selector.select()
阻塞等待,有就绪通道返回数量,无响应超时返回0,单线程即可监控多路通道。
TCP服务端代码改写成NIO的方式
public class ServerConnect
{
private static final int BUF_SIZE=1024;
private static final int PORT = 8080;
private static final int TIMEOUT = 3000;
public static void main(String[] args)
{
selector();
}
public static void handleAccept(SelectionKey key) throws IOException{
ServerSocketChannel ssChannel = (ServerSocketChannel)key.channel();
SocketChannel sc = ssChannel.accept();
sc.configureBlocking(false);
sc.register(key.selector(), SelectionKey.OP_READ,ByteBuffer.allocateDirect(BUF_SIZE));
}
public static void handleRead(SelectionKey key) throws IOException{
SocketChannel sc = (SocketChannel)key.channel();
ByteBuffer buf = (ByteBuffer)key.attachment();
long bytesRead = sc.read(buf);
while(bytesRead>0){
buf.flip();
while(buf.hasRemaining()){
System.out.print((char)buf.get());
}
System.out.println();
buf.clear();
bytesRead = sc.read(buf);
}
if(bytesRead == -1){
sc.close();
}
}
public static void handleWrite(SelectionKey key) throws IOException{
ByteBuffer buf = (ByteBuffer)key.attachment();
buf.flip();
SocketChannel sc = (SocketChannel) key.channel();
while(buf.hasRemaining()){
sc.write(buf);
}
buf.compact();
}
public static void selector() {
Selector selector = null;
ServerSocketChannel ssc = null;
try{
selector = Selector.open();
ssc= ServerSocketChannel.open();
ssc.socket().bind(new InetSocketAddress(PORT));
ssc.configureBlocking(false);
ssc.register(selector, SelectionKey.OP_ACCEPT);
while(true){
if(selector.select(TIMEOUT) == 0){
System.out.println("==");
continue;
}
Iterator<SelectionKey> iter = selector.selectedKeys().iterator();
while(iter.hasNext()){
SelectionKey key = iter.next();
if(key.isAcceptable()){
handleAccept(key);
}
if(key.isReadable()){
handleRead(key);
}
if(key.isWritable() && key.isValid()){
handleWrite(key);
}
if(key.isConnectable()){
System.out.println("isConnectable = true");
}
iter.remove();
}
}
}catch(IOException e){
e.printStackTrace();
}finally{
try{
if(selector!=null){
selector.close();
}
if(ssc!=null){
ssc.close();
}
}catch(IOException e){
e.printStackTrace();
}
}
}
}
ServerSocketChannel
打开ServerSocketChannel
ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
关闭ServerSocketChannel:
serverSocketChannel.close();
监听新进来的连接
while(true){
SocketChannel socketChannel = serverSocketChannel.accept();
}
ServerSocketChannel通过configureBlocking(false)配置即可进入非阻塞模式。该模式下,accept()操作展现与传统阻塞模型截然不同的行为特性:
ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
serverSocketChannel.socket().bind(new InetSocketAddress(9999));
serverSocketChannel.configureBlocking(false);
while (true)
{
SocketChannel socketChannel = serverSocketChannel.accept();
// 非阻塞accept标准处理模板
if (socketChannel != null)
{
// 处理有效连接
}
}
该模式特别适用于需要处理大量并发连接但单个连接数据量较小的场景,如即时通讯服务、实时竞价系统等。通过合理设置线程池大小和缓冲区参数,可在保证低延迟的同时实现高吞吐量。
建议配合Java NIO的Selector机制使用,构建完整的事件驱动型网络服务器。
Selector执行器
Selector的创建:Selector selector = Selector.open();
为了将Channel和Selector配合使用,必须将Channel注册到Selector上,通过SelectableChannel.register()方法来实现:
ServerSocketChannel ssc= ServerSocketChannel.open();
ssc.socket().bind(new InetSocketAddress(PORT));
ssc.configureBlocking(false);
ssc.register(selector, SelectionKey.OP_ACCEPT);
注意,与Selector一起使用时,Channel必须处于非阻塞模式下,这意味着不能将FileChannel与Selector一起使用,因为FileChannel不能切换到非阻塞模式。而套接字通道都可以。
ServerSocketChannel的register方法
register()方法的第二个参数,这是一个“interest集合”,意思是在通过Selector监听Channel时对什么事件感兴趣。可以监听四种不同类型的事件:
事件就绪、连接就绪和接收就绪
通道触发事件表征其已具备执行特定操作的条件,在通信框架中,不同就绪状态具有明确的语义界定:
-
连接就绪状态(SelectionKey.OP_CONNECT) :当通信通道成功建立与其他服务节点的稳定连接时,该状态被定义为"连接就绪"。
- 此状态表明通道已完成握手协议,具备数据传输的前提条件。
-
接收就绪状态(SelectionKey.OP_ACCEPT) :服务器套接字通道的特定状态,表示该通道已配置完成监听端口并初始化连接队列。
- 随时可接纳新的客户端接入请求。
-
读就绪状态(SelectionKey.OP_READ) :当通道的接收缓冲区存在待处理数据时,即进入"读就绪"状态。
- 该状态触发读取操作的有效性校验,确保数据获取的实时性。
-
写就绪状态(SelectionKey.OP_WRITE) :通道发送缓冲区出现可写入空间时,被定义为"写就绪"。
- 此状态标志着通道已具备承载新数据的传输能力,此时执行写操作可获得最佳吞吐量。
SelectionKey
当向Selector注册Channel时,register()方法会返回一个SelectionKey对象。这个对象包含了一些你感兴趣的属性:
- 关注事件集(interest集合) :指通道注册时声明的需监听事件类型集合,包含READ/WRITE/CONNECT/ACCEPT等IO操作标识。该集合决定了选择器的事件监测范围,是通道与选择器交互的核心配置参数。
可以通过SelectionKey读写interest集合。
- 就绪事件集(Ready集合) :当前已触发且可处理的事件集合(是通道已经准备就绪的操作的集合),由选择器通过底层IO机制检测生成。该集合动态反映通道的实际可操作状态,是事件驱动模型的核心决策依据。
int readySet = selectionKey.readyOps();
可以用像检测interest集合那样的方法,来检测channel中什么事件或操作已经就绪。但是,也可以使用以下四个方法,它们都会返回一个布尔类型:
selectionKey.isConnectable();
selectionKey.isAcceptable();
selectionKey.isReadable();
selectionKey.isWritable();
从SelectionKey访问Channel和Selector很简单。如下:
Channel channel = selectionKey.channel();
Selector selector = selectionKey.selector();
- 通信通道(Channel) :作为全双工数据传输的抽象载体,提供异步IO操作能力。根据功能差异细分为SocketChannel(客户端)、ServerSocketChannel(服务端)等子类,支持非阻塞模式下的高效数据传输。
-
事件选择器(Selector) :实现多路复用IO模式的核心组件,通过epoll/kqueue等系统调用监控多个通道的就绪状态。采用红黑树结构管理注册通道,提供高效的事件分派机制。
-
上下文载体(Attachment Object) :可选绑定的用户自定义对象,用于存储通道相关的业务上下文信息。通过该机制可实现协议解析器、会话管理器等业务组件与通道的解耦关联。
通过Selector选择通道
当完成通道向事件选择器Selector的注册流程后,开发者可调用其提供的多形态选择操作接口。该接口通过高效的多路复用机制,持续监测注册通道的事件就绪状态,最终返回与预设关注条件相匹配的可操作通道集合。具体运作机制如下:
当选择器执行选择操作时,其内部实现将:
- 遍历所有已注册通道的"关注事件集"
- 通过操作系统级IO多路复用机制(如Linux epoll)检测通道状态
- 筛选出满足预设事件触发条件的通道实例
- 将这些通道按照事件类型分类存入"就绪集合"
以读操作监测为例,若开发者在通道注册时指定了READ事件关注标识,选择器将仅返回那些接收缓冲区存在有效数据且可立即进行读取操作的通道。
下面是select()方法:
int select()
int select(long timeout)
int selectNow()
select()
:阻塞到至少有一个通道在你注册的事件上就绪了。select(long timeout)
:最长会阻塞timeout毫秒(参数)。selectNow()
:不会阻塞,不管什么通道就绪都立刻返回,此方法执行非阻塞的选择操作。如果自从前一次选择操作后,没有通道变成可选择的,则此方法直接返回零。
select()方法返回的int值表示有多少通道已经就绪,自上次调用select()方法后有多少通道变成就绪状态。如果调用select()方法,因为有一个通道变成就绪状态,返回了1,若再次调用select()方法,如果另一个通道就绪了,它会再次返回1。
如果对第一个就绪的channel没有做任何操作,现在就有两个就绪的通道,但在每次select()方法调用之间,只有一个通道就绪了。
一旦调用了select()方法,并且返回值表明有一个或更多个通道就绪了,然后可以通过调用selector的selectedKeys()方法,访问“已选择键集(selected key set)”中的就绪通道。如下所示:
Set selectedKeys = selector.selectedKeys();
当像Selector注册Channel时,Channel.register()方法会返回一个SelectionKey 对象。这个对象代表了注册到该Selector的通道。可以通过SelectionKey的selectedKeySet()方法访问这些对象。
注意,每次迭代末尾的keyIterator.remove()调用。Selector不会自己从已选择键集中移除SelectionKey实例。必须在处理完通道时自己移除。下次该通道变成就绪时,Selector会再次将其放入已选择键集中。