通用Client
public static void main(String[] args) throws IOException {
SocketChannel channel = SocketChannel.open(new InetSocketAddress(25565));
// 注:Buffer大小最好为有效数据大小 -> 防止服务端解码异常
channel.write(ByteBuffer.wrap("传输的信息".getBytes(StandardCharsets.UTF_8)));
channel.close();
}
阻塞模式
Server
private static final ByteBuffer BUFFER = ByteBuffer.allocate(1024);
public static void main(String[] args) throws IOException {
ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
serverSocketChannel.bind(new InetSocketAddress(25565));
while (true) {
SocketChannel socketChannel = serverSocketChannel.accept(); // 阻塞在此处
socketChannel.read(BUFFER);
BUFFER.flip();
System.out.println(StandardCharsets.UTF_8.decode(BUFFER));
BUFFER.clear();
}
}
非阻塞模式
Server - 轮询
public static void main(String[] args) throws IOException {
ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
serverSocketChannel.configureBlocking(false); // 非阻塞模式
serverSocketChannel.bind(new InetSocketAddress(25565));
List<SocketChannel> channels = new ArrayList<>();
while (true) {
SocketChannel socketChannel = serverSocketChannel.accept();
if (socketChannel != null) { // 连接建立
socketChannel.configureBlocking(false); // 非阻塞模式
channels.add(socketChannel);
}
for (SocketChannel channel : channels) {
ByteBuffer BUFFER = ByteBuffer.allocate(1024);
int read = channel.read(BUFFER); // 非阻塞模式
if (read > 0) {
BUFFER.flip();
System.out.println(StandardCharsets.UTF_8.decode(BUFFER));
BUFFER.clear();
}
}
}
}
Server - Selector
注意 每注册一个 SocketChannel 都会多出一个 SelectionKey。我们调用 remove() 仅是从 selectedKeys 集合中移除本次调用到的 key ( Selector 自己不会移除),防止下次 select() 时集合中仍存有先前处理过的 key。 key 的 OP_ACCEPT 状态处理完成后就被迭代器 remove(),而 OP_READ 状态在套接字连接的过程中一直存在,故每次 select() 时 selectedKeys 中都会加入正在进行传输的 SocketChanne l ! ! ! 在保证数据读完且客户端连接断开的情况下( read() == -1 )可以通过 cancel() 永久移除该 key,否则默认数据传输未完成,在每个下一次轮询时重新加入该key,耗时耗力!
public static void main(String[] args) throws IOException {
// 1. 创建 Selector 管理多个 Channel
Selector selector = Selector.open();
ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
serverSocketChannel.configureBlocking(false); // 非阻塞模式
// 2. 建立 Selector 和 Channel 的联系(注册)
serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT);
serverSocketChannel.bind(new InetSocketAddress(25565));
while (true) {
// 3. 查询事件,若存在则加入到 selectedKeys
selector.select();
// 4. 处理事件,selectedKeys 内包含所有发生的事件
Iterator<SelectionKey> iterator = selector.selectedKeys().iterator();
while (iterator.hasNext()) {
SelectionKey key = iterator.next();
// 处理 key 时要手动从 selectedKeys 集合中删除,否则下次获取时 key 上无对应 Channel 调用会空指针
iterator.remove();
if (key.isAcceptable()) { // 是否是 Accept 事件
// 向对应端口的 ServerSocketChannel 注册 OP_READ 监听
ServerSocketChannel serverChannel = (ServerSocketChannel) key.channel();
SocketChannel socketChannel = serverChannel.accept();
socketChannel.configureBlocking(false);
socketChannel.register(selector, SelectionKey.OP_READ);
} else if (key.isReadable()) {
try {
System.out.println(key);
SocketChannel channel = (SocketChannel) key.channel();
ByteBuffer buffer = ByteBuffer.allocate(32);
int length;
while ((length = channel.read(buffer)) > 0) {
buffer.flip();
System.out.println(new String(buffer.array(), 0, length));
buffer.clear();
}
// -1 : 客户端断开
if (length == -1) key.cancel();
} catch (IOException e) {
log.warn("客户端断开连接: ", e);
key.cancel();
}
}
}
}
}
消息边界
当 数据长度 > 缓存区大小 时,数据会分开发送导致服务端解码异常,称为消息边界问题
可能出现的问题
-
黏包 - 多个消息合并在同一 Buffer 中发出
-
半包 - 由于 Buffer 大小限制,消息被分割发出
解决方案
-
固定 Buffer 长度
多用于传输固定范围内长度的数据,服务端固定一个 Buffer 大小 >= 消息长度,客户端发送消息时未达到最大长度则补齐进行发送。(浪费带宽)
-
分隔符
在两消息间加入特定分隔符,服务端接受时逐行比对进行消息分割接收,可能存在 Buffer 扩容问题。(效率低)
-
TLV 格式
TLV - Type、Length、Value。规定数个字节的头文件长度记录消息长度,服务端基于此可有效进行消息接收。类似 Http 式。( Buffer 需要提前分配,若过大影响服务端吞吐量)
ByteBuffer大小分配
-
每个 Channel 都需要记录可能被切分的消息,因为 ByteBuffer 不能被多个 Channel 共同使用,因此需要为每个 Channel 维护一个独立的 ByteBuffer
-
ByteBuffer 不能太大,比如一个 ByteBuffer 1Mb 的话,要支持百万连接就要 1Tb 内存,因此需要设计大小可变的 ByteBuffer
-
一种思路是首先分配一个较小的 buffer,例如 4k,如果发现数据不够,再分配 8k 的 buffer,将4k buffer 内容拷贝至 8k buffer,优点是消息连续容易处理,缺点是数据拷贝耗费性能,参考实现
-
另一种思路是用多个数组组成 buffer,一个数组不够,把多出来的内容写入新的数组,与前面的区别是消息存储不连续解析复杂,优点是避免了拷贝引起的性能损耗
-
多线程优化
多核 Worker 分配 SocketChannel
public static void main(String[] args) throws IOException {
Thread.currentThread().setName("Boss");
ServerSocketChannel ssc = ServerSocketChannel.open();
ssc.bind(new InetSocketAddress(25565));
ssc.configureBlocking(false);
Selector selector = Selector.open();
ssc.register(selector, SelectionKey.OP_ACCEPT);
Worker[] workers = new Worker[Runtime.getRuntime().availableProcessors()];
for (int i = 0; i < workers.length; ++ i) {
workers[i] = new Worker("worker-" + i);
}
AtomicInteger integer = new AtomicInteger();
while (true) {
selector.select();
Iterator<SelectionKey> iterator = selector.selectedKeys().iterator();
while (iterator.hasNext()) {
SelectionKey key = iterator.next();
iterator.remove();
if (key.isAcceptable()) {
SocketChannel sc = ssc.accept();
log.debug("connect {}", sc);
sc.configureBlocking(false);
workers[integer.getAndIncrement() % workers.length].register(sc);
}
}
}
}
/**
* Worker 的实际意义就是
* 对于每个工作线程都新建一个 Selector 对 SocketChannel
* 进行拆分管理,每个连接被均匀分配到每个工作线程上。
* */
private static class Worker implements Runnable {
private final String name;
private Thread thread;
private Selector selector;
public Worker(String name) {
this.name = name;
}
public void register(SocketChannel sc) throws IOException {
if (thread == null) {
thread = new Thread(this, name);
selector = Selector.open();
thread.start();
}
// 唤醒 Worker 线程执行注册操作,在下一次循环中进行 select() 筛选事件监听
selector.wakeup();
SelectionKey register = sc.register(selector, SelectionKey.OP_READ);
log.debug("register {}", register);
}
@Override
public void run() {
while (true) {
try {
// Selector#select() 会阻塞,故使用队列延迟执行 SocketChannel 注册
selector.select();
Iterator<SelectionKey> iterator = selector.selectedKeys().iterator();
while (iterator.hasNext()) {
SelectionKey key = iterator.next();
iterator.remove();
if (key.isReadable()) {
log.debug("read...");
ByteBuffer buffer = ByteBuffer.allocate(16);
SocketChannel sc = (SocketChannel) key.channel();
sc.read(buffer);
buffer.flip();
System.out.println(Charset.defaultCharset().decode(buffer));
}
}
} catch (IOException e) {
e.printStackTrace();
}
}
}
}
如何拿到 cpu 个数
Runtime.getRuntime().availableProcessors() 如果工作在 docker 容器下,因为容器不是物理隔离的,会拿到物理 cpu 个数,而不是容器申请时的个数
这个问题直到 jdk 10 才修复,使用 jvm 参数 UseContainerSupport 配置,默认开启