1、基本常识
Socket 是应用层与 TCP/IP 协议族通信的中间软件抽象层,是一组接口,使用了门面模式对应用层隐藏了传输层以下的实现细节。TCP 用主机的 IP 地址加上主机端口号作为 TCP 连接的端点,该端点叫做套接字 Socket。
比如三次握手,调用 Socket.connect() 就能完成,应用开发者无须关心如何具体实现三次握手。
长连接与短连接没有哪一个更好之说,只是结合具体业务使用哪一个更合适。
任何网络通信编程关注的三件事:
- 连接(客户端连接服务器,服务器接收客户端的连接)
- 读网络数据
- 写网络数据
常见的网络编程方式有三种:
- BIO:阻塞式 IO,当线程无法读取到数据或无法写入数据时,线程会进入阻塞状态
- NIO:非阻塞式 IO,也称 IO 多路复用,即一个线程为多个客户端执行读写操作。当一个客户端无法读写数据即将陷入阻塞状态之前,线程会切换到其他客户端的读写工作中,避免阻塞带来的效率低下问题
- AIO:异步 IO,Linux 的异步 IO 实际上是通过 NIO 实现的,而 Windows 才提供了真正的异步 IO,因此在 Linux 和 Java 这一侧关注的是 BIO 与 NIO
2、BIO
服务端通过 ServerSocket 获取到客户端的连接 Socket,为每个连接分配一个单独的线程,通过 IO 流进行同步阻塞式通信:
java
public class Server {
// 别用 CachedThreadPool,与 new Thread() 没啥区别
private static ExecutorService executorService = Executors.newFixedThreadPool(
Runtime.getRuntime().availableProcessors() * 2);
public static void main(String[] args) {
ServerSocket serverSocket = null;
try {
serverSocket = new ServerSocket();
serverSocket.bind(new InetSocketAddress(10001));
System.out.println("Start server...");
while (true) {
executorService.submit(new ServerTask(serverSocket.accept()));
}
} catch (IOException e) {
e.printStackTrace();
} finally {
try {
if (serverSocket != null) {
serverSocket.close();
}
} catch (IOException e) {
e.printStackTrace();
}
}
}
static class ServerTask implements Runnable {
private Socket socket;
public ServerTask(Socket socket) {
this.socket = socket;
}
@Override
public void run() {
try (ObjectInputStream objectInputStream = new ObjectInputStream(socket.getInputStream());
ObjectOutputStream objectOutputStream = new ObjectOutputStream(socket.getOutputStream())) {
String userName = objectInputStream.readUTF();
objectOutputStream.writeUTF("Hello," + userName);
objectOutputStream.flush();
} catch (IOException e) {
e.printStackTrace();
}
}
}
}
客户端使用 Socket 连接绑定服务器端口后与服务器通信:
java
public class Client {
public static void main(String[] args) throws IOException {
Socket socket = new Socket();
socket.connect(new InetSocketAddress("127.0.0.1", 10001));
try (ObjectOutputStream objectOutputStream = new ObjectOutputStream(socket.getOutputStream());
ObjectInputStream objectInputStream = new ObjectInputStream(socket.getInputStream())) {
objectOutputStream.writeUTF("James");
objectOutputStream.flush();
System.out.println(objectInputStream.readUTF());
} finally {
socket.close();
}
}
}
3、NIO
首先要清楚一点,在配置参数相同的情况下,单次网络通信,BIO 的效率是比 NIO 高的,但是由于 NIO 中一个服务端线程可以与多个客户端通信,所以 NIO 这个 IO 多路复用的机制,总体上比 BIO 效率更高。从成本角度考虑,NIO 节省成本,而 BIO 则是以成本换效率。
3.1 三大核心组件
NIO 的三大核心组件:
- Selector:选择器,也称为轮询代理器或事件订阅器,可以在一个单独的线程中操作 Selector 选择不同的 Channel,从而实现在一个线程中管理多个通道。应用程序向 Selector 注册需要其关注的 Channel 以及 Channel 感兴趣的 IO 事件,Selector 内则保存已经注册的 Channel 的容器
- Channels:通道,是应用程序与操作系统读写数据的渠道,通道中的数据总是先读到 Buffer 或从 Buffer 写入。Selector 注册的是 SelectableChannel,其子类 ServerSocketChannel 支持应用程序向操作系统注册 IO 多路复用的端口监听,同时支持 TCP 和 UDP;而另一个子类 SocketChannel 则是 TCP Socket 的监听通道
- Buffer:本质上就是一个数组,其内存被包装成 Buffer 对象并提供了方便访问该内存的方法。仅与 Channel 做数据交换。
3.2 重要概念 SelectionKey
除此之外还有一个重要概念 SelectionKey,表示 SelectableChannel 在 Selector 中注册的标识。Channel 向 Selector 注册时,会创建 SelectionKey 建立 Channel 与 Selector 的联系,同时维护 Channel 事件。
SelectionKey 有四种类型:
- OP_READ:操作系统读缓冲区可读,并非所有时刻都有数据可读,因此需要注册该操作
- OP_WRITE:操作系统写缓冲区有空闲空间,一般情况下都有空闲空间,因此没必要注册该类型,否则浪费 CPU;但如果是写密集型的任务,比如下载文件,缓冲区可能会满,此时就需要注册该操作类型,并在写完后取消注册
- OP_CONNECT:只给客户端使用,在 SocketChannel.connect() 连接成功后就绪
- OP_ACCEPT:只给服务器使用,在接收到客户端连接请求时就绪
这四种类型也再次阐明了网络编程关注的三件事:连接(客户端连接服务器,服务器接收客户端的连接)、读、写网络数据。
不同的 Channel 允许注册的事件类型不同:
- 服务器 ServerSocketChannel:仅 OP_ACCEPT
- 服务器 SocketChannel:OP_READ、OP_WRITE
- 客户端 SocketChannel:OP_READ、OP_WRITE 和 OP_CONNECT
3.3 Buffer
缓冲区本质上是一块可以写入数据,然后可以从中读取数据的内存(其实就是数组),这块内存被包装成 NIO Buffer 对象,并提供了一组方法,用来方便的访问该块内存。
Buffer 位于 Channel 和应用程序之间。应用程序对外写数据时,是写到 Buffer,由 Channel 将 Buffer 中的数据读出并发送出去;读也是类似的,数据是先从 Channel 读到 Buffer 后,应用程序再读 Buffer 中的数据。
Buffer 有三个重要属性:
- capacity:内存容量,只能写 capacity 个 byte、long、char 类型数据,Buffer 满了之后需要通过读数据或清除数据将其清空后,才能继续写数据
- position:表示操作数据的位置,写模式下,每写完一个数据会向下移动一个单位,最大为 capacity - 1;读模式下,每读完一个数据会向前移动到下一个可读的位置。读写模式切换时,position 会被重置为 0
- limit:写模式下表示最多能向 Buffer 中写多少数据,此时 limit 等于 capacity;读模式下表示最多能读到多少数据,切换到读模式时,limit 会被置为写模式下的 position,即可读取此前所有写入的数据
Buffer 既可以读也可以写,需要通过 flip() 从写模式切换到读模式,而当读完数据后,可以通过 clear() 或 compact() 清理缓冲区并切换成写模式,其中前者会清空整个缓冲区,而后者则只清除已经读取过的数据。
完整的通信结构如下:

大致步骤:
- 服务端 ServerSocketChannel 向 Selector 注册 OP_ACCEPT
- 客户端连接服务器,Selector 会通知 ServerSocketChannel 连接事件,此时 ServerSocketChannel 可以产生一个 SocketChannel 与客户端进行通信,并注册 OP_READ
- 客户端发送数据,Selector 会通知服务端的 SocketChannel 读取数据,这些数据会被写入 Buffer,服务器的应用程序可以从 Buffer 中读取这些数据
- 当服务器的应用程序发送应答消息给客户端时,是向 Buffer 中写入数据,SocketChannel 会从 Buffer 中读取这些数据并发送出去
BIO 时,假如分三次向对端写 100 个字节,那么就要进行三次系统调用。而使用 NIO,可以将 100 个字节写入 Buffer,从 Buffer 读取数据再进行一次系统调用就可以发送数据了。由于系统调用会消耗大量系统资源,所以 NIO 是提升了性能的。类似的,BIO 在读取数据时,不论从系统读取到多少数据都要经过一次系统调用交给应用程序,而 NIO 可以将从操作系统读取的数据先存入 Buffer 中,然后从 Buffer 通过一次系统调用传输给应用程序。
3.4 NIO 编程实践
基础使用代码见 GitHub 上相关章节,注意事项见课程文档。这里主要说一下在读写数据时为什么一般不注册写事件 OP_WRITE。
一般情况下,服务器在写数据时,是不注册 OP_WRITE 直接通过 SocketChannel.write() 写的:
java
private void handleInput(SelectionKey key) throws IOException {
// 由于 SelectionKey 是可以取消的,因此使用前需要先判断是否可用
if (key.isValid()) {
if (key.isAcceptable()) {
// 只有 ServerSocketChannel 才关注 OP_ACCEPT
ServerSocketChannel ssc = (ServerSocketChannel) key.channel();
// 获取和客户端通信的 Socket
SocketChannel sc = ssc.accept();
System.out.println("有客户端连接");
sc.configureBlocking(false);
sc.register(selector, SelectionKey.OP_READ);
}
// 读数据
if (key.isReadable()) {
SocketChannel sc = (SocketChannel) key.channel();
// 如果要读取的数据多于 1024 字节,那么读事件会被触发多次直到读完
ByteBuffer buffer = ByteBuffer.allocate(1024);
int readBytes = sc.read(buffer);
if (readBytes > 0) {
// 因为 Channel 写入了 Buffer,因此读的时候需要进行模式切换
buffer.flip();
// 读取数据做业务处理
byte[] bytes = new byte[readBytes];
buffer.get(bytes);
String message = new String(bytes, "UTF-8");
System.out.println("服务器收到消息: " + message);
String result = Const.response(message);
// 发送应答消息
doWrite(sc, result);
} else if (readBytes < 0) {
// 小于 0 说明链路已经关闭,释放资源
key.cancel();
sc.close();
}
}
}
}
private void doWrite(SocketChannel sc, String result) throws IOException {
byte[] bytes = result.getBytes();
ByteBuffer writeBuffer = ByteBuffer.allocate(bytes.length);
// 将字节数组复制到 writeBuffer
writeBuffer.put(bytes);
// 切换到读模式
writeBuffer.flip();
sc.write(writeBuffer);
}
假如想在 OP_WRITE 下向客户端写数据,就要修改为如下这样:
java
private void handleInput(SelectionKey key) throws IOException {
// 由于 SelectionKey 是可以取消的,因此使用前需要先判断是否可用
if (key.isValid()) {
...
// 添加写数据逻辑
if (key.isWritable()) {
System.out.println("writable...");
SocketChannel sc = (SocketChannel) key.channel();
ByteBuffer attachment = (ByteBuffer) key.attachment();
if (attachment.hasRemaining()) {
System.out.println("write :" + sc.write(attachment) + " byte");
} else {
// 写完数据后要取消对写事件的注册,否则系统会一直通知写事件
key.interestOps(SelectionKey.OP_READ);
}
}
}
}
private void doWrite(SocketChannel sc, String result) throws IOException {
byte[] bytes = result.getBytes();
ByteBuffer writeBuffer = ByteBuffer.allocate(bytes.length);
// 将字节数组复制到 writeBuffer
writeBuffer.put(bytes);
// 切换到读模式
writeBuffer.flip();
// sc.write(writeBuffer);
// register() 注册哪一个事件就只关注该事件,因此这里在注册写事件时不要忘了读,同时将 writeBuffer
// 作为附件也一并注册
sc.register(selector, SelectionKey.OP_WRITE | SelectionKey.OP_READ, writeBuffer);
}
之所以在写完之后要取消对写事件的关注,主要是因为读写事件的触发机制是不一样的。当客户端向服务器发送数据时,服务端有数据可读,就会触发 OP_READ 事件。
而 OP_WRITE 则不同,当通信双方 Socket 连接成功后,操作系统会为每个 Socket 创建两个操作系统级别的缓存(注意并不是应用程序中用到的 Buffer,应用程序是感知不到这个缓存的,这个缓存在操作系统内核中),一个输出缓存,一个输入缓存。当输入缓存中有对端发来的数据时,就会触发 OP_READ 事件,而输出缓存中,只要有空闲空间,就会一直不停的触发 OP_WRITE。
通常都是要写的数据非常多,数据量大于缓冲区需要多次写的时候,才注册 OP_WRITE。
3.5 Reactor 模式
Reactor 翻译为"反应器",可以延伸为"倒置"、"控制逆转",即事件处理程序不调用反应器,而是向反应器注册一个事件处理器,当事件到来时调用事件处理程序做出反应。这种控制逆转又称为"好莱坞法则"。
NIO 的 Selector 就扮演着 Reactor 的角色。Reactor 模式又可以分为三种流程:
- 单线程 Reactor 模式:服务器全程只使用一个线程,即 IO 操作(accept、read、write)与业务操作(decode、compute、encode)都在一个线程上处理。这样有一个问题增大 IO 响应的时间。示意图如下:
- 单线程 Reactor,工作者线程池:添加工作者线程池,将非 IO 操作从 Reactor 线程中移出交给工作者线程池执行。这种模式在处理大并发、大数据量的业务时是不合适的。因为面对成百上千的 IO 操作,一个线程的处理能力始终是有限的。再比如读取 10M 的数据,在读取时其他 IO 操作是无法进行的。示意图如下:
- 多 Reactor 线程模式:针对第二种模式的缺点,再引入一个 Reactor 线程池。Reactor 线程池中的每一 Reactor 线程都会有自己的 Selector、线程和分发的事件循环逻辑。mainReactor 可以只有一个,但 subReactor 一般会有多个。mainReactor 线程主要负责接收客户端的连接请求,然后将接收到的 SocketChannel 传递给 subReactor,由 subReactor 来完成和客户端的通信。示意图如下:
Reactor 模式看似与观察者模式很像,二者的主要区别是观察者模式与单个事件源关联,而反应器模式则与多个事件源关联。当一个主体发生改变时,所有依属体都得到通知。