Java网络编程(三):NIO核心组件Channel通道详解

1 Channel接口体系结构

1.1 Channel到底是什么

Channel(通道)是Java NIO的核心概念,你可以把它理解为数据传输的管道。和传统的Stream不同,Channel有几个很实用的特点:

Channel既能读数据,也能写数据,就像一条双向车道。传统的InputStream只能读,OutputStream只能写,需要分别处理。

更重要的是,Channel可以设置成非阻塞模式。什么意思?就是读写数据时不会卡住程序,没数据就继续干别的事,这对高并发应用特别有用。

Channel还能配合Selector使用,一个线程就能管理成百上千个连接。想象一下,以前需要1000个服务员的餐厅,现在只要几个就够了。

从技术实现上看,Channel把操作系统底层的I/O操作包装了一下,让我们用起来更方便。不用关心底层怎么传输数据,调用几个方法就行。

1.2 Channel的家族关系

Java NIO的Channel接口设计得很有层次感:

复制代码
Channel (interface)
├── ReadableByteChannel (interface)
│   └── ScatteringByteChannel (interface)
├── WritableByteChannel (interface)
│   └── GatheringByteChannel (interface)
├── ByteChannel (interface) 
│   └── SeekableByteChannel (interface)
└── InterruptibleChannel (interface)
    └── SelectableChannel (abstract class)
        ├── AbstractSelectableChannel (abstract class)
        │   ├── SocketChannel
        │   ├── ServerSocketChannel
        │   ├── DatagramChannel
        │   └── Pipe.SinkChannel/SourceChannel
        └── ...

这样设计的好处是,不同类型的Channel可以共享一些基础功能,但又能保持自己的特色。

1.3 常用的Channel类型

Java NIO提供了好几种Channel,每种都有自己的用途:

Channel类型 替代了什么 主要用来干什么
FileChannel FileInputStream/FileOutputStream 读写文件
SocketChannel Socket TCP客户端连接
ServerSocketChannel ServerSocket TCP服务器监听
DatagramChannel DatagramSocket UDP通信
Pipe.SinkChannel/SourceChannel PipedOutputStream/PipedInputStream 线程间传数据

做网络编程的话,SocketChannel和ServerSocketChannel用得最多。一个负责连接服务器,一个负责接受连接,配合起来就能搭建高性能的网络应用。

2 SocketChannel和ServerSocketChannel详解

2.1 ServerSocketChannel:服务器端的门卫

ServerSocketChannel就是服务器端用来接收客户端连接的,可以理解为传统ServerSocket的升级版。最大的区别是,它支持非阻塞操作,不会让程序傻等着。

2.1.1 怎么创建和配置
java 复制代码
// 创建ServerSocketChannel
ServerSocketChannel serverChannel = ServerSocketChannel.open();

// 绑定端口,就像给门牌号
serverChannel.bind(new InetSocketAddress(8080));

// 设置为非阻塞模式,这是关键
serverChannel.configureBlocking(false);

// 如果需要,还能拿到传统的ServerSocket
ServerSocket serverSocket = serverChannel.socket();
2.1.2 常用方法
  • accept():接受客户端连接,返回一个SocketChannel
  • bind(SocketAddress):绑定到指定地址和端口
  • configureBlocking(boolean):设置是否阻塞,false表示非阻塞
  • isOpen():检查Channel是否还开着
  • close():关闭Channel,释放资源

2.2 SocketChannel:客户端的连接器

SocketChannel是客户端用来连接服务器的,相当于传统Socket的NIO版本。它有两个身份:既可以主动连接服务器,也可以作为服务器接收到的客户端连接。

2.2.1 客户端怎么连接
java 复制代码
// 创建SocketChannel
SocketChannel socketChannel = SocketChannel.open();

// 设置为非阻塞模式
socketChannel.configureBlocking(false);

// 尝试连接服务器
boolean connected = socketChannel.connect(new InetSocketAddress("localhost", 8080));

// 非阻塞模式下,connect可能还没连上就返回了
if (!connected) {
    // 需要等连接真正建立
    while (!socketChannel.finishConnect()) {
        // 这期间可以干点别的事
        Thread.sleep(100);
    }
}

System.out.println("连接成功!");
2.2.2 常用方法

SocketChannel的主要方法:

  • open():创建一个新的SocketChannel
  • connect(SocketAddress):连接到指定地址,非阻塞模式下可能立即返回
  • finishConnect():完成连接,配合connect()使用
  • isConnected():检查是否已经连上了
  • isConnectionPending():检查连接是否还在进行中
  • read(ByteBuffer):从通道读数据到缓冲区
  • write(ByteBuffer):把缓冲区的数据写到通道
  • configureBlocking(boolean):设置阻塞模式
  • close():关闭连接

2.3 物联网平台中的应用

在物联网平台的网络通信模块中,ServerSocketChannel和SocketChannel通常用于以下场景:

  1. 设备网关服务:使用ServerSocketChannel接收来自设备的连接请求
  2. 实时数据采集:使用SocketChannel建立与传感器设备的高效连接
  3. 命令下发系统:通过SocketChannel向设备发送控制指令
  4. 多设备并发管理:结合Selector实现单线程管理多设备连接

3 阻塞模式vs非阻塞模式

3.1 两种模式有什么区别

Channel有两种工作方式:阻塞模式和非阻塞模式。这是NIO比传统I/O强的地方。

简单来说:

  • 阻塞模式:就像排队买奶茶,必须等前面的人买完才轮到你,期间什么都干不了
  • 非阻塞模式:像网上点外卖,下单后可以继续干别的,偶尔看看外卖到了没
特性 阻塞模式 非阻塞模式
等待方式 傻等着,直到操作完成 立即返回,不等结果
返回结果 返回真实结果 可能返回"还没好"的标志
线程利用 一个线程只能干一件事 一个线程能同时处理多件事
编程难度 简单,符合直觉 稍微复杂,需要轮询检查
适合场景 连接少,要求简单 连接多,要求高性能

3.2 怎么切换模式

Channel默认是阻塞模式,可以这样切换:

java 复制代码
// 设置为非阻塞模式
channel.configureBlocking(false);

// 设置为阻塞模式
channel.configureBlocking(true);

// 检查当前是什么模式
boolean isBlocking = channel.isBlocking();

3.3 两种模式具体有什么不同

3.3.1 ServerSocketChannel接收连接时
  • 阻塞模式:程序会卡在accept()这里,直到真的有客户端连过来
  • 非阻塞模式:accept()立即返回,有连接就返回SocketChannel,没连接就返回null
java 复制代码
// 阻塞模式的例子
ServerSocketChannel serverChannel = ServerSocketChannel.open();
serverChannel.bind(new InetSocketAddress(8888));
// 程序会卡在这里等连接
SocketChannel socketChannel = serverChannel.accept();

// 非阻塞模式的例子
ServerSocketChannel serverChannel = ServerSocketChannel.open();
serverChannel.bind(new InetSocketAddress(8888));
serverChannel.configureBlocking(false);
// 立即返回,可能是null
SocketChannel socketChannel = serverChannel.accept();
if (socketChannel != null) {
    // 有新连接,处理一下
}
3.3.2 SocketChannel的各种操作

不同模式下,SocketChannel的行为也不一样:

连接操作 connect()

  • 阻塞模式:程序等着,直到连接成功或失败
  • 非阻塞模式:立即返回,后面用finishConnect()检查是否连上

读数据 read()

  • 阻塞模式:等着,直到读到数据或连接断了
  • 非阻塞模式:立即返回,返回值是读到的字节数,可能是0

写数据 write()

  • 阻塞模式:等着,直到数据全部写完
  • 非阻塞模式:立即返回,返回值是实际写入的字节数,可能比预期少

3.4 该选哪种模式

选择哪种模式主要看你的应用场景:

选择阻塞模式的情况:

  • 连接数不多,几十个就够了
  • 逻辑简单,不想搞太复杂
  • 对性能要求不高,够用就行

选择非阻塞模式的情况:

  • 连接数很多,成百上千个
  • 要求响应快,不能让用户等太久
  • 服务器资源有限,一个线程要干多个活

对于物联网平台来说,设备连接数量通常比较多,而且服务器资源宝贵,所以非阻塞模式用得更多。

4 Channel的连接、读写和关闭操作

4.1 连接操作

4.1.1 服务器端怎么接收连接
java 复制代码
// 创建服务器通道,相当于开了个门店
ServerSocketChannel serverChannel = ServerSocketChannel.open();
serverChannel.bind(new InetSocketAddress(8888));  // 选个门牌号
serverChannel.configureBlocking(false);           // 设置成非阻塞,不傻等

// 找个管家(Selector)来帮忙看门
Selector selector = Selector.open();
serverChannel.register(selector, SelectionKey.OP_ACCEPT);  // 告诉管家关注新客户

// 开始营业,无限循环处理客户
while (true) {
    if (selector.select() > 0) {  // 管家检查有没有事情要处理
        Iterator<SelectionKey> keyIterator = selector.selectedKeys().iterator();
        while (keyIterator.hasNext()) {
            SelectionKey key = keyIterator.next();
            keyIterator.remove();  // 处理完就移除,避免重复处理
            
            if (key.isAcceptable()) {  // 有新客户要进门
                ServerSocketChannel server = (ServerSocketChannel) key.channel();
                SocketChannel clientChannel = server.accept();  // 接待新客户
                clientChannel.configureBlocking(false);         // 客户也设置成非阻塞
                clientChannel.register(selector, SelectionKey.OP_READ);  // 关注客户的消息
                System.out.println("新客户来了: " + clientChannel.getRemoteAddress());
            }
            // 处理其他事件...
        }
    }
}
4.1.2 客户端怎么连接服务器
java 复制代码
// 创建客户端通道,准备去连接服务器
SocketChannel socketChannel = SocketChannel.open();
socketChannel.configureBlocking(false);  // 设置非阻塞,连接时不等待

// 也找个管家来帮忙
Selector selector = Selector.open();
socketChannel.register(selector, SelectionKey.OP_CONNECT);  // 关注连接事件

// 开始尝试连接
socketChannel.connect(new InetSocketAddress("localhost", 8888));

// 等待连接结果
while (true) {
    if (selector.select() > 0) {  // 检查有没有事件发生
        Iterator<SelectionKey> keyIterator = selector.selectedKeys().iterator();
        while (keyIterator.hasNext()) {
            SelectionKey key = keyIterator.next();
            keyIterator.remove();
            
            if (key.isConnectable()) {  // 连接有结果了
                SocketChannel channel = (SocketChannel) key.channel();
                if (channel.isConnectionPending()) {  // 连接还在进行中
                    channel.finishConnect();  // 完成连接
                    System.out.println("连上服务器了!");
                    // 现在可以关注读事件了
                    channel.register(selector, SelectionKey.OP_READ);
                    // 先打个招呼
                    ByteBuffer buffer = ByteBuffer.wrap("Hello Server".getBytes());
                    channel.write(buffer);
                }
            }
            // 处理其他事件...
        }
    }
}

4.2 读写操作

Channel读写数据都要通过ByteBuffer,这是NIO的特色。就像传菜要用盘子一样,数据传输要用Buffer。

4.2.1 怎么读数据
java 复制代码
// 准备一个盘子(缓冲区)来装数据
ByteBuffer buffer = ByteBuffer.allocate(1024);

// 从通道读数据到盘子里
int bytesRead = socketChannel.read(buffer);

if (bytesRead > 0) {
    // 翻转盘子,准备取数据(从写模式切换到读模式)
    buffer.flip();
    
    // 把盘子里的数据倒出来
    byte[] data = new byte[buffer.remaining()];
    buffer.get(data);
    String message = new String(data);
    System.out.println("收到消息: " + message);
    
    // 洗干净盘子,准备下次用
    buffer.clear();
} else if (bytesRead == -1) {
    // 对方挂断了连接
    socketChannel.close();
}
4.2.2 怎么写数据
java 复制代码
// 准备要发送的消息
String message = "Hello Client";
ByteBuffer buffer = ByteBuffer.wrap(message.getBytes());

// 把数据写到通道里,可能需要多次写入
while (buffer.hasRemaining()) {
    socketChannel.write(buffer);
}

// 更安全的写法,防止写不完
int totalWritten = 0;
int bytesWritten;
while (totalWritten < message.length()) {
    bytesWritten = socketChannel.write(buffer);
    if (bytesWritten <= 0) {
        // 对方接收缓冲区满了,暂时写不进去
        break;
    }
    totalWritten += bytesWritten;
}
4.2.3 分散读取和聚集写入

Channel还有个高级功能:可以同时操作多个Buffer,就像用多个盘子一起传菜:

java 复制代码
// 分散读取:一次读取分别放到不同的Buffer里
ByteBuffer header = ByteBuffer.allocate(128);   // 消息头的盘子
ByteBuffer body = ByteBuffer.allocate(1024);    // 消息体的盘子
ByteBuffer[] buffers = {header, body};
long bytesRead = socketChannel.read(buffers);   // 一次性读到两个盘子里

// 聚集写入:把多个Buffer的数据一次性写出去
ByteBuffer header = ByteBuffer.allocate(128);
ByteBuffer body = ByteBuffer.allocate(1024);
// 往两个盘子里装数据...
ByteBuffer[] buffers = {header, body};
long bytesWritten = socketChannel.write(buffers);  // 一次性把两个盘子的数据都发出去

4.3 关闭操作

用完Channel记得关闭,不然会浪费系统资源:

java 复制代码
try {
    // 使用Channel做各种操作
    // ...
} finally {
    if (socketChannel != null && socketChannel.isOpen()) {
        socketChannel.close();  // 手动关门
    }
}

// 更简单的写法,自动关闭
try (SocketChannel socketChannel = SocketChannel.open()) {
    // 使用Channel做各种操作
    // ...
} // Java会自动帮你关门

关闭Channel之后:

  • 系统资源被释放
  • 网络连接断开
  • 如果注册了Selector,也会自动取消注册

4.4 异常处理

使用Channel时可能遇到各种问题,要做好异常处理:

java 复制代码
try {
    SocketChannel socketChannel = SocketChannel.open();
    socketChannel.connect(new InetSocketAddress("localhost", 8080));
    
    ByteBuffer buffer = ByteBuffer.allocate(1024);
    int bytesRead = socketChannel.read(buffer);
    
} catch (ConnectException e) {
    System.err.println("连不上服务器: " + e.getMessage());
} catch (SocketTimeoutException e) {
    System.err.println("连接等太久了: " + e.getMessage());
} catch (IOException e) {
    System.err.println("网络出问题了: " + e.getMessage());
} catch (Exception e) {
    System.err.println("出了其他问题: " + e.getMessage());
} finally {
    // 不管怎样都要清理资源
    if (socketChannel != null && socketChannel.isOpen()) {
        try {
            socketChannel.close();
        } catch (IOException e) {
            System.err.println("关闭连接时又出错了: " + e.getMessage());
        }
    }
}

5 总结

Channel就是NIO的核心,它让网络编程变得更灵活。和传统Socket比起来,Channel有这些好处:

  1. 省资源:一个线程可以管理很多连接,不用每个连接都开一个线程
  2. 速度快:数据直接在缓冲区里操作,减少了复制
  3. 能扩展:配合Selector可以处理成千上万的连接
  4. 控制精细:想怎么操作就怎么操作

如果你要做高并发的系统,比如聊天服务器、游戏服务器,Channel绝对是个好选择。当然,刚开始可能觉得有点复杂,但用熟了就会发现它的强大。

下一篇我们聊聊Buffer,看看它是怎么和Channel配合工作的。

相关推荐
憧憬成为原神糕手3 小时前
Udp 和 Tcp socket的一般编程套路(笔记)
网络·tcp/ip·udp
Yeats_Liao3 小时前
Java网络编程(六):NIO vs BIO性能对比与场景选择
java·网络·nio
LeoZY_3 小时前
开源超级终端PuTTY改进之:增加点对点网络协议IocHub,实现跨网段远程登录
运维·网络·stm32·嵌入式硬件·网络协议·运维开发
Akshsjsjenjd3 小时前
Tomcat 简介与 Linux 环境部署
java·linux·tomcat
qq_569384124 小时前
Jenkins(速通版)
java·kubernetes·jenkins
青云交4 小时前
Java 大视界 -- Java 大数据在智能教育学习效果评估与教学质量改进中的深度应用(414)
java·flink 实时计算·java 大数据·智能教育·学习效果评估·教学质量改进·spark 离线分析
Charles豪4 小时前
MR、AR、VR:技术浪潮下安卓应用的未来走向
android·java·人工智能·xr·mr
TeleostNaCl4 小时前
SMBJ 简单使用指南 实现在 Java/Android 程序中访问 SMB 服务器
android·java·运维·服务器·经验分享·kotlin
我不是混子4 小时前
Java的SPI机制详解
java·后端