JAVA IO模型
BIO
Blocking IO,同步并阻塞方式,应用程序向OS请求网络IO操作,然后会等待IO操作完成。客户端与服务器端连接,一个连接创建一个线程。
适用于连接数目比较小且固定的架构,对服务器要求比较高
简单流程:
- 服务端启动一个ServerSocket。
- 客户端启动Socket对服务端通讯,默认情况下服务端对每个客户建立一个线程。
- 客户端发送请求后,先检测服务端是否有线程响应,没有则等待或被拒绝。
- 有响应,客户端线程等待请求结束返回响应,再继续执行。
NIO
Non-Blocking IO,同步非阻塞,一个线程处理多个请求,客户端发送的连接请求会注册到多路复用器上,多路复用器轮询到IO请求会处理
适用于连接数目多且连接比较短的架构,比如聊天服务器,弹幕系统,服务器间通讯。
Selector、Channel和Buffer的关系
- 每个Channel都会对应一个Buffer
- Selector对应一个线程,一个线程对应多个Channel
- 程序切换到哪个channel是由事件决定的。
- Selector会根据不同的事件在各个通道切换
- Buffer就是一个内存块,底层有个数组
- 数据的读取写入是通过Buffer,可以双向读写,需要filp方法切换
- channel是双向的,可以返回底层操作系统的情况,比如Linux,底层操作系统的通道就是双向的
Buffer
Buffer是一个抽象类,子类有:
- ByteBuffer
- CharBuffer
- ShortBuffer
- IntBuffer
- LongBuffer
- FloatBuffer
- DoubleBuffer
在子类中有一个数组hb用于缓冲的实现。
// Invariants: mark <= position <= limit <= capacity
// 标记,调用mark()可以将position的值赋给mark,reset()恢复position
private int mark = -1;
// 位置,下一个要读或写的偏移量
private int position = 0;
// 缓冲区当前的终点,不能对大于limit的位置修改,limit位置可以修改
private int limit;
// 容量,可容纳最大数据量,不可修改
private int capacity;
Channel
- 通道可以同时进行读写
- 通道可以实现异步读写数据
- 通道可以从缓冲读,可以写到缓冲
Channel是一个接口
public interface Channel extends Closeable {
public boolean isOpen();
public void close() throws IOException;
}
常用的Channel类有FileChannel(文件读写)、DatagramChannel(UDP数据读写)、ServerSocketChannel和SocketChannel(这俩TCP数据读写)
在各种channel所依托的流如果关闭其read()方法会返回-1,其余返回0
Selector
用一个线程处理多个客户端连接,就会用到选择器,Selector可以检测多个注册的通道上是否有事件发生(多个Channel以事件的方式可以注册到同一个Selector)
只有在 连接/通道 真正有读写事件发生时,才会进行读写,大大减少了系统开销,避免了多线程上下文切换导致的开销。
Selector是一个抽象类,常用方法有
open( ):得到一个选择器对象selectedKeys( ):获得内部集合所有等待IO操作的selectionKeyselect(long timeout):监控所有注册通道,当有IO操作发生时将对应的SelectionKey加入到内部集合并且返回,参数超时时间,超时返回有事件发生的SelectionKeyselect():阻塞直到注册的通道有事件到达,返回有事件发生的SelectionKeyselcetNow():不阻塞,立马返回wakeuo():唤醒selectorkeys():返回当前所有注册在selector中channel的selectionKey
一个 SelectionKey 表示了一个特定的channel通道对象和一个特定的selector选择器对象之间的注册关系。
🛑SelectionKey在被轮询后需要remove(),selector不会自己删除selectedKeys()集合中的selectionKey,如果不人工remove(),将导致下次select()的时候selectedKeys()中仍有上次轮询留下来的信息,这样必然会出现错误。
🛑注册过的channel信息会以SelectionKey的形式存储在selector.keys()中。keys()中的成员是不需要被删除的(以此来记录channel信息)。
SelectionKey
SelectionKey表示Selector与网络通道是注册关系
NIO非阻塞网络编程
实现:
-
客户端连接时,会通过ServerSocketChannel得到SocketChannel。
-
将SocketChannel注册到Selector上,
register(Selector sel, int ops),一个Selector可以注册多个SocketChannel。 -
注册后返回一个SelectionKey。
// 读事件 public static final int OP_READ = 1 << 0; // 写事件 public static final int OP_WRITE = 1 << 2; // 连接事件 public static final int OP_CONNECT = 1 << 3; // 接受连接事件 public static final int OP_ACCEPT = 1 << 4; -
Selector进行监听,
select( )返回有事件发生的通道个数。 -
进一步得到各个SelectionKey。
-
在通过SelectionKey反向获取SocketChannel。
-
通过得到的Channel完成处理。
服务端实现
@Slf4j(topic = "Server")
public class NIOServer {
public static void main(String[] args) throws IOException {
//创建选择器(open是一个工厂方法)
Selector selector = Selector.open();
// 创建ServerSocketChannel绑定套接字
ServerSocketChannel ssChannel = ServerSocketChannel.open();
ssChannel.socket().bind(new InetSocketAddress("127.0.0.1", 8888));
// 设置非阻塞
ssChannel.configureBlocking(false);
// 将通道注册到选择器上,接受连接事件操作
ssChannel.register(selector, SelectionKey.OP_ACCEPT);
// 循环监听
while (true) {
//监听事件,会阻塞直到有至少一个事件到达
selector.select();
//获取到达的事件
Set<SelectionKey> keys = selector.selectedKeys();
//使用迭代器遍历
Iterator<SelectionKey> keyIterator = keys.iterator();
while (keyIterator.hasNext()) {
SelectionKey key = keyIterator.next();
//对应OP_ACCEPT通道事件,客户端连接先执行这个代码
if (key.isAcceptable()) {
ServerSocketChannel ssChannel1 = (ServerSocketChannel) key.channel();
// 服务器会为每个新连接创建一个 SocketChannel
SocketChannel sChannel = ssChannel1.accept();
sChannel.configureBlocking(false);
log.info("socketChannel HashCode: {}", sChannel.hashCode());
// 这个新连接主要用于从客户端读取数据
sChannel.register(selector, SelectionKey.OP_READ, ByteBuffer.allocate(1024));
} else if (key.isReadable()) {
// key反向获取对应Channel
SocketChannel sChannel = (SocketChannel) key.channel();
// 拿到之前放进去的ByteBuffer
ByteBuffer buffer = (ByteBuffer) key.attachment();
sChannel.read(buffer);
// 读写反转limit=position,position = 0
buffer.flip();
log.info(new String(buffer.array(), buffer.position(), buffer.limit()));
sChannel.close();
}
// 从SectionKeys集合中移除当前已处理的SelectionKey,重复操作
keyIterator.remove();
}
}
}
客户端实现
@Slf4j(topic = "Client")
public class NiOClient {
public static void main(String[] args) throws IOException, IOException {
// Socket socket = new Socket("127.0.0.1", 8888);
// OutputStream out = socket.getOutputStream();
// String s = "hello world";
// out.write(s.getBytes());
// out.close();
SocketChannel socketChannel = SocketChannel.open();
socketChannel.configureBlocking(false);
InetSocketAddress inetSocketAddress = new InetSocketAddress("127.0.0.1",8888);
if(!socketChannel.connect(inetSocketAddress)) {
while (!socketChannel.finishConnect()){
}
}
String str = "你好,啊";
// 无需指定大小,直接就是你传进的byte数组大小
ByteBuffer buffer = ByteBuffer.wrap(str.getBytes(StandardCharsets.UTF_8));
socketChannel.write(buffer);
}
}
NIO与零拷贝
零拷贝
这要从Linux说起:Linux系统中一切皆文件,很多活动本质上都是读写操作。
一般的数据拷贝过程:
- 当应用程序读取磁盘数据时,调用read( )从用户态到内核态,该过程由cpu完成
- 之后CPU发送I/O请求,磁盘收到请求后开始准备数据
- 磁盘将数据传送到磁盘的缓冲区中,然后发送I/O中断
- CPU收到中断后开始拷贝数据,然后由read()返回,再从内核态转换成用户态
直接内存访问(Direct Memory Access)方式是一种硬件直接访问内存的一种方式:
-
读数据;
- 调用read()函数,用户态切换内核态,状态切换一次;
- DMA控制器将数据从磁盘拷贝到内核缓冲区,1次DMA拷贝;
- CPU将数据从内核缓冲区复制到用户缓冲区,1次CPU拷贝;
- read()函数返回,用户态切换回用户态,2次状态切换;
-
写数据;
- 调用write()函数,用户态切换内核态,1次切换;
- CPU将用户缓冲区数据拷贝到内核缓冲区,1次CPU拷贝;
- DMA将数据从内核缓冲区复制到套接字的缓冲区,1次DMA拷贝;
- write()函数返回,内核态切换回用户态,2次切换;
零拷贝是网络编程的关键,很多性能优化都离不开零拷贝
在java程序中,常用的零拷贝有mmap(内存映射)和sendFile
mmap内存映射
内存映射文件是 一种读写数据的方法,比常规的流或者通道读写要快,但是会有一些安全问题。
内存映射文件是一个文件到一块内存的映射。使用内存映射文件处理存储于磁盘的文件时,不比对文件执行IO操作。在Linux中,mmap实现了内核中读缓冲区域用户空间缓冲区的映射,从而实现二者的缓冲区共享。这样就减少了一次cpu拷贝。
- 用户进程通过
mmap()向操作系统内核发起IO调用,用户态切换为内核态。 - CPU利用DMA控制器,把数据从硬盘中拷贝到内核缓冲区。
- 内核态切换回用户态,
write()返回。 - 用户进程通过
write()向操作系统内核发起IO调用,用户态切换为内核态。 - CPU将内核缓冲区的数据拷贝到的socket缓冲区。
- CPU利用DMA控制器,把数据从socket缓冲区拷贝到网卡,内核态切换回用户态,write调用返回。
一次读+写4次上下文切换,3次数据拷贝
MappedByteBuffer 类继承自ByteBuffer,子类DirectByteBuffer内部维护了一个缓存数组偏移量arrayBaseOffset
FileChannel提供了map()方法把文件映射到虚拟内存,可以整个文件映射,也可以分段映射
@Test
public void mappedByteBufferTest() throws IOException {
/*
* MappedByteBuffer 可让文件直接在内存(堆外内存)修改,操作系统不需要拷贝一次
*/
RandomAccessFile randomAccessFile = new RandomAccessFile("h1.txt", "rw");
FileChannel fileChannel = randomAccessFile.getChannel();
// FileChannel.MapMode.READ_WRITE 读写模式
// 0 可以修改的起始位置
// 映射到内存的大小,即将文件多少个字节映射到内存
// 可直接修改的范围是0-5
MappedByteBuffer mappedByteBuffer = fileChannel.map(FileChannel.MapMode.READ_WRITE, 0, 5);
mappedByteBuffer.put(0, (byte) 'H');
mappedByteBuffer.put(1, (byte) 'H');
mappedByteBuffer.put(2, (byte) 'H');
mappedByteBuffer.put(3, (byte) 'H');
mappedByteBuffer.put(4, (byte) 'H');
randomAccessFile.close();
}
sendFile系统调用
建立两个文件之间的传输通道
- 用户进程发起
sendfile系统调用,用户态到内核态 - DMA控制器,把数据从硬盘中拷贝到内核缓冲区。
- CPU将读缓冲区中数据拷贝到socket缓冲区
- DMA控制器,异步把数据从socket缓冲区拷贝到网卡,
- 内核态到回用户态,
sendfile调用返回。
2次上下文切换,最少3次数据拷贝
AIO
Asynchronous I/O,异步非阻塞,无论是客户端的连接请求还是读写请求都会异步执行, 由操作系统完成后回调通知服务端程序启动线程去处理
适用于连接数目多且连接比较长的架构,相册服务器
| BIO | NIO | AIO | |
|---|---|---|---|
| IO模型 | 同步阻塞 | 同步非阻塞(多路复用) | 异步非阻塞 |
| 编程难度 | 简单 | 复杂 | 复杂 |
| 可靠性 | 差 | 好 | 好 |
| 吞吐量 | 低 | 高 | 高 |
NIO存在的问题
在NIO中,若Selector的轮询结果为空,也没有wakeup()或新消息处理,会发生空轮询导致CPU占用100%。
前置知识:
JDK1.5引入epoll机制。epoll是linux2.6内核的系统调用,设计目的就是替代select、poll线性复杂度的模型,epoll的时间复杂度是O(1)。epoll在高并发场景表现优秀。
文件描述符(File Descriptor——fd) :linux内核利用文件描述符来访问文件,打开显存文件或新建文件时,内核会返回一个文件描述符。读写文件也需要使用文件描述符来指定待读写的文件。
文件句柄:Windows下的概念,句柄是各种对象的标识符,它是一个非负整数,也用于定位文件数据在内存中的位置。
linux下的文件描述符其实就相当于windows下的句柄。文件句柄只是windows众多句柄中的一种类型而已。
对于操作系统会有这样一个需求:如何在一个进程(线程)中处理多个文件,或者监听多个文件的IO事件。
select、poll和epoll都是操作系统中实现IO多路复用的方法。
select
select方法本质就是维护了一个文件描述符数组(32位系统MAX=1024,64位MAX=2048),依次实现IO多路复用,select会监视文件描述符的变化。
select()机制中提供了fd_set数据结构,实际上是一个long类型的数组,每个数组元素都能与以打开的文件描述符建立联系。
/proc/sys/fs/file-max中指定了系统范围内所有进程可打开的文件的数量限制
当select方法被调用,首先需要将fd_set从用户空间拷贝到内核空间,然后内核用poll机制(非多路复用的poll)返回一个fd准备就绪个数。方法返回后需要轮询fd_set,检查发生IO事件的fd。
缺陷:
- 使用轮询,效率低。
- 会导致用户空间和内核空间频繁拷贝数据。
poll
poll和select很类似,只不过poll维护的是一个链表,单个进程监听的fd不再有数量限制,但是和select相同的轮询和复制问题依然存在。
epoll
epoll全称EventPoll,是linux内核实现IO多路复用的模型。
在Linux中,selector和poll监听文件描述符list,进行线性的查找,复杂度O(1)。
epoll使用了内核文件级别的回调机制,复杂度O(1)。
epoll基于事件驱动,给每个fd注册一个回调函数,当对应的fd有IO事件发生时就调用这个回调函数,将这个fd放入一个链表中,这样客户端可以直接从链表中获得发生IO事件的fd,从而达到O(1)级别的监听。
epoll有的三个系统调用:epoll_create, epoll_ctl, epoll_wait
JDK中epoll的实现
理论上无客户端连接时Selector.select() 方法会阻塞,但空轮询bug导致:即使无客户端连接,NIO照样不断的从select本应该阻塞的Selector.select()中wake up出来,导致CPU100%问题。