NIO,BIO,AIO
阻塞IO,非阻塞IO,IO多路复用
- 阻塞IO:发起 I/O 请求后,会一直阻塞,直到数据就绪并完成拷贝
- 非阻塞IO:发起 I/O 请求后立即返回,需要轮询是否准备好数据
- IO多路复用:一个线程监控多个 I/O
- 异步IO:I/O 操作由操作系统内核完成,完成后主动通知应用程序(通过回调),无需轮询或阻塞,当 I/O 完成时自动调用对应方法处理数据。
NIO,BIO,AIO
- BIO (Blocking I/O):同步阻塞 I/O 模式。Stream
- NIO (New I/O):同步非阻塞模式。
- AIO (Asynchronous I/O):异步非阻塞 I/O 模型。
Java基础网络编程里面就属于BIO,由于是同步阻塞,为了处理多个连接请求通常会为每个连接开辟一个线程。采用线程池也避免不了线程仅能处理一个 socket 连接。
- 弊端:消耗大量线程资源,频繁的上下文切换,突发的流量会导致程序或者系统崩溃
I/O多路复用:Linux系统中用于同时监控多个文件描述符状态变化的方法。大部分Java应用都是直接部署在Linux上,而NIO使用了Linux提供的多路复用接口。
- select: 用位图来表示文件描述符集合,线程轮询的所有的文件描述符是否有事件发生。每次调用都会出现一次位图在用户空间和内核空间的拷贝。
- poll:解决select文件描述符数量有限的问题,依然有拷贝。
- epoll:使用共享内存技术避免拷贝的性能损失,使用红黑树管理文件描述符,事件触发时高效查询描述符,然后根据编程监听的事件放入到就绪队列中,用户线程每次调用都会直接拿到就绪的文件描述符。 水平触发:poll和epoll默认模式,只要文件描述符处于"就绪"状态,就会持续通知(如数据没有读取完毕,描述符依然处于"就绪"状态) 边缘触发:文件描述符状态发生变化时通知一次
短链接和长连接
- 短连接:建立TCP连接后,进行一次数据发送和响应后就关闭 HTTP/1.0的请求头中Connection默认为close,也就是短链接
- 长连接:建立TCP连接后,进行了多次发送和响应才关闭 HTTP/1.0请求头中Connection默认为keep-alive,也就是长连接
零拷贝
数据传递时减少拷贝次数提升效率,inux下有系统调用sendfile() 提供零拷贝接口。
在Java中
传统文件数据拷贝流向(目标文件为网卡或者磁盘)
文件 → 内核缓冲区 → 用户空间缓冲区 → 内核缓冲区 → 目标文件
(DMA拷贝) (CPU拷贝) (CPU拷贝) (DMA拷贝)
- 4次拷贝,2次cpu状态切换(上下文切换)
零拷贝:Java中channel的transferTo()能够实现0拷贝
文件 → 内核缓冲区 → 目标文件
(DMA拷贝) (DMA拷贝)
- 避免了内核缓冲区到用户缓冲区的拷贝,以及cpu状态切换(上下文切换)
在Java中JVM 堆内的 byte[] 地址不固定的,因为GC会移动对象。在操作系统层面,IO的需要传递一个明确的内存用于存放数据,所以Java的又多了一层堆外和堆内内存的数据拷贝。
源文件 → 内核页缓存 → 临时堆外缓冲区 → JVM堆内byte[] → 临时堆外缓冲区 → 内核页缓存 → 目标文件
(DMA) (CPU) (CPU) (CPU) (CPU) (DMA)
可以通过DirectByteBuffer 将堆外内存映射到 jvm 内存中来直接访问使用,减少这次拷贝。GC在回收DirectByteBuffer时,DirectByteBuffer会自己释放掉对应的直接内存。
ByteBuffer directBuf = ByteBuffer.allocateDirect(4096);
Java BIO
基于Stream
// 服务器端
ServerSocket server = new ServerSocket(8080);
Socket client = server.accept(); // 阻塞等待连接
InputStream in = client.getInputStream();
OutputStream out = client.getOutputStream();
byte[] buffer = new byte[1024];
int len = in.read(buffer); // 阻塞直到有数据
out.write("Hello".getBytes()); // 阻塞直到数据写入
Stream
- 单向,只读或者只写
- 属于阻塞IO
Java NIO
- 双向,配合buffer读写数据
- 支持阻塞和非阻塞IO,支持selector
- 支持零拷贝
channel :双向,能够读写数据
- FileChannel:文件数据传输
- DatagramChannel:UDP编程
- SocketChannel:TCP
- ServerSocketChannel:TCP主要用作服务器
buffer:缓冲区,配合channel使用,读取数据到channel,常用ByteBuffer
- ByteBuffer: 实现类 MappedByteBuffer, DirectByteBuffer:ByteBuffer.allocateDirect(size) HeapByteBuffer:创建方式ByteBuffer.allocate(size)
ByteBuffer
非线程安全
- compact : 保留没有读的,继续写入(切换到写)
- clear:清除缓冲区数据(切换到写)
- flip:切换读模式
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;
import java.io.FileInputStream;
public class SuperSimpleExample {
public static void main(String[] args) {
try {
// 1. 创建水管(连接到文件的通道)
FileChannel channel = new FileInputStream("test.txt").getChannel();
// 2. 准备一个水桶(容量为10字节的缓冲区)
ByteBuffer buffer = ByteBuffer.allocate(10);
// 3. 用水管往水桶里接水(从通道读取数据到缓冲区)
int bytesRead = channel.read(buffer);
System.out.println("读取了 " + bytesRead + " 字节数据");
// 4. 告诉水桶:现在我要从里面倒水了!(切换为读模式)
buffer.flip();
// 5. 从水桶里倒水(从缓冲区读取数据)
System.out.println("文件内容是:");
while (buffer.hasRemaining()) {
System.out.print((char) buffer.get());
}
// 6. 关闭水管
channel.close();
} catch (Exception e) {
e.printStackTrace();
}
}
}
常用API
Bytebuffer buf = ByteBuffer.allocate(16); // 创建
int readBytes = channel.read(buf); // 用channel的read写入
buf.put((byte)127); // put写入
int writeBytes = channel.write(buf); // channel的write
byte b = buf.get();// get获取,get(int i),不会让position变化
buf.rewind(); // 重置position指针
buf.mark();//标记,调用reset后position会回到标记位置,rewind和flip会清除mark
// 字符串和缓冲区的转换
ByteBuffer buffer1 = StandardCharsets.UTF_8.encode("你好");
ByteBuffer buffer2 = Charset.forName("utf-8").encode("你好");
// channel一次性写多个buffer
channel.write(new ByteBuffer[]{d, e});
// 分散读取数据到多个buffer
channel.read(new ByteBuffer[]{a, b, c});
FileChannel
- 通过 FileInputStream 获取的 channel 只能读
- 通过 FileOutputStream 获取的 channel 只能写
- 通过 RandomAccessFile 是否能读写根据构造 RandomAccessFile 时的读写模式决定
读取数据
int readBytes = channel.read(buffer);
long pos = channel.position(); // -1 表示没有内容
channel.position(newPos); // 设置位置
写入数据
ByteBuffer buffer = ...;
buffer.put(...); // 存入数据
buffer.flip(); // 切换读模式
// channel的write无法保证一次性写完
while(buffer.hasRemaining()) {
channel.write(buffer);
}
// position到达文件末尾,新内容和旧内容之间会有空洞(00)
关闭
// 文件关闭会间接关闭channel
channel.close();
其他
channel.size();//文件大小
channel.force(true);//立即写入磁盘
from.transferTo(0, from.size(), to); // 两个channel间传递数据 from 传递到 to , 而且采用零拷贝技术
SocketChannel/ServerSocketChannel
传输数据就往channel里面写即可
ServerSocketChannel
- 创建channel:ServerSocketChannel.open();
- 绑定端口:ssc.bind(new InetSocketAddress(8080));
- 获取建立的连接channel:accept()在默认会阻塞等待连接,返回一个SocketChannel对象
- 配置非阻塞:配置为非阻塞 ssc.configureBlocking(false);
SocketChannel
- 创建:SocketChannel.open();
- 连接:sc.connect(new InetSocketAddress("localhost", 8080));
- 配置非阻塞:sc.configureBlocking(false);
ByteBuffer buffer = ByteBuffer.allocate(16);
ServerSocketChannel ssc = ServerSocketChannel.open();
ssc.configureBlocking(false);
ssc.bind(new InetSocketAddress(8080));
List<SocketChannel> channels = new ArrayList<>();
while (true) {
// 接受新连接
SocketChannel sc = ssc.accept();
if (sc != null) {
log.debug("connected... {}", sc);
sc.configureBlocking(false);
channels.add(sc);
}
// 记录需要关闭的连接
List<SocketChannel> closedChannels = new ArrayList<>();
for (SocketChannel channel : channels) {
try {
int read = channel.read(buffer);
// -1说明客户端主动关闭了
if (read == -1) {
// 标记为需要关闭
closedChannels.add(channel);
continue;
}
if (read > 0) {
buffer.flip();
debugRead(buffer);
buffer.clear();
log.debug("after read...{}", channel);
}
} catch (IOException e) {
// 发生异常,标记为需要关闭
closedChannels.add(channel);
}
}
// 清理已关闭的连接
for (SocketChannel closedChannel : closedChannels) {
log.debug("关闭连接: {}", closedChannel);
try {
closedChannel.close();
} catch (IOException e) {
// 忽略关闭异常
}
channels.remove(closedChannel);
}
}
Selector
selector
thread
channel
channel
channel
selector:管理channel,当channel发生事件时,就会让线程进行处理
适用于连接数多,但是每个连接处理速度快
-
创建: Selector.open();
-
绑定Channel事件
channel.configureBlocking(false); // 必须非阻塞 SelectionKey key = channel.register(selector, 事件); // 绑定到对应的selector事件类型:
connect - 客户端连接成功时触发 accept - 服务器端成功接受连接时触发 read - 数据可读入时触发,有因为接收能力弱,数据暂不能读入的情况 write - 数据可写出时触发,有因为发送能力弱,数据暂不能写出的情况 -
监听事件
int count = selector.select(); // 阻塞直到事件发生 int count = selector.select(long timeout); // 事件发生或者超时 //select:只有出现调用 selector.wakeup()/ selector.close()/selector 所在线程 interrupt/事件发生才会不阻塞==>selector被阻塞,注册的方法就执行不了了 int count = selector.selectNow(); // 不阻塞 -
获取事件
Set<SelectionKey> keys = selector.selectedKeys(); // SelectionKey 的一些api // key.isAcceptable() : 是否是accept的事件类型 // key.channel() : 获取对应channel注意类型转换 // key.isReadable() : 是否为读类型 // key.cancel() : 取消事件 // key.attachment() :获取attachment //attachment是可以附加到 SelectionKey 上的任意对象 // 创建一个 ByteBuffer 作为附件,这个在写操作时是很有用 ByteBuffer buffer = ByteBuffer.allocate(16); // 将这个 buffer 关联到 selectionKey 上 SelectionKey scKey = sc.register(selector, 0, buffer); // 第三个参数就是 attachment -
处理事件:不处理下次任然和发生,不处理可以取消,nio 底层使用的是水平触发
判断事件类型,通过key.channel()获取到channel后,完成对应处理逻辑,要从set中移除对应key,不然第二次处理时可能会有空指针一次(如读操作,对应channel可能已经被断开连接释放掉了)
Selector selector = Selector.open();
ssc.register(selector, SelectionKey.OP_ACCEPT);
while (true) {
selector.select(); // 阻塞直到有事件
Iterator<SelectionKey> iter = selector.selectedKeys().iterator();
while (iter.hasNext()) {
SelectionKey key = iter.next();
iter.remove(); // 从集合中移除next对应的元素
if (key.isAcceptable()) {
// 处理新连接
SocketChannel sc = ssc.accept();
sc.configureBlocking(false);
sc.register(selector, SelectionKey.OP_READ);
} else if (key.isReadable()) {
SocketChannel channel = (SocketChannel) key.channel();
try {
int read = channel.read(buffer);
if (read == -1) {
// 连接关闭,自动清理
key.cancel();
channel.close();
} else if (read > 0) {
buffer.flip();
debugRead(buffer);
buffer.clear();
}
} catch (IOException e) {
// 异常时自动清理
key.cancel();
channel.close();
}
}
}
}
为什么还需要写事件?
通常来说,监听到成功建立连接了,直接写即可。写事件主要原因是无法一次性将缓冲区数据全部写完。
每个TCP连接都有一个发送缓冲区,当这个缓冲区满时
- 非阻塞模式:
write()立即返回,只写入能容纳的数据量 - 阻塞模式:
write()会阻塞,直到所有数据被放入缓冲区
如果不用写事件,建立完连接后直接写使用buffer.hasRemaining()判断缓冲区是否还有数据,然后循环继续写,网络状况不好时会出现多次写入为0,意味着cpu在空转。
DatagramChannel
UDP是无连接的,客户端和服务端代码一致。
DatagramChannel channel = DatagramChannel.open();
channel.configureBlocking(false);
channel.socket().bind(new InetSocketAddress(9999));
Selector selector = Selector.open();
channel.register(selector, SelectionKey.OP_READ);
while (true) {
selector.select(); // 阻塞直到有通道就绪(但不是 receive 阻塞)
Iterator<SelectionKey> keys = selector.selectedKeys().iterator();
while (keys.hasNext()) {
SelectionKey key = keys.next();
keys.remove();
if (key.isReadable()) {
ByteBuffer buf = ByteBuffer.allocate(1024);
SocketAddress client = channel.receive(buf); // 此时肯定有数据,不会阻塞
// 处理数据...
}
}
}
Java AIO
JVM会拉起一个守护线程去执行对应回调函数。
JVM线程
- 用户线程:主动创建的线程(可以设置为守护线程)或者main线程
- 守护线程:后台服务线程,如垃圾回收和AIO回调,当所有用户线程结束时,JVM 会强制终止所有守护线程并退出。
public class AIOServer {
public static void main(String[] args) throws Exception {
AsynchronousServerSocketChannel server = AsynchronousServerSocketChannel.open()
.bind(new InetSocketAddress(8080));
System.out.println("AIO Server 启动,监听 8080 端口...");
// 接受连接(异步)
server.accept(null, new CompletionHandler<AsynchronousSocketChannel, Object>() {
@Override
public void completed(AsynchronousSocketChannel client, Object attachment) {
// 继续接受下一个连接
server.accept(null, this);
try {
ByteBuffer buffer = ByteBuffer.allocate(1024);
// 异步读取数据
client.read(buffer, buffer, new CompletionHandler<Integer, ByteBuffer>() {
@Override
public void completed(Integer result, ByteBuffer buf) {
buf.flip();
System.out.println("收到数据: " + new String(buf.array(), 0, result));
// 回写响应
client.write(ByteBuffer.wrap("Hello from AIO Server!".getBytes()));
client.close();
}
@Override
public void failed(Throwable exc, ByteBuffer buf) {
exc.printStackTrace();
}
});
} catch (Exception e) {
e.printStackTrace();
}
}
@Override
public void failed(Throwable exc, Object attachment) {
exc.printStackTrace();
}
});
// 防止主线程退出
System.in.read();
}
}