一. 概述
¥1. 同步与异步
- 同步 :两个同步任务相互依赖,并且一个任务必须以依赖于另一任务的某种方式执行。 比如在A->B事件模型中,你需要先完成 A 才能执行B。 再换句话说,同步调用中被调用者未处理完请求之前,调用不返回,调用者会一直等待结果的返回。
- 异步: 两个异步的任务是完全独立的,一方的执行不需要等待另外一方的执行。再换句话说,异步调用中一调用就返回结果不需要等待结果返回,当结果返回的时候通过回调函数或者其他方式拿着结果再做相关事情
¥2. 阻塞与非阻塞
- 阻塞: 阻塞就是发起一个请求,调用者一直等待请求结果返回,也就是当前线程会被挂起,无法从事其他任务,只有当条件就绪才能继续。
- 非阻塞: 非阻塞就是发起一个请求,调用者不用一直等着结果返回,可以先去干其他事情。
同步/异步是从行为角度描述事物的,而阻塞和非阻塞描述的当前事物的状态(等待调用结果时的状态)。
3. Socket
- socket是操作系统提供的网络编程接口,他封装了对于TCP/IP协议栈的支持,用于进程间的通信。
- 当有连接接入主机以后,操作系统自动为其分配一个socket套接字,套接字绑定着一个IP与端口号。通过socket接口,可以获取tcp连接的输入流和输出流,并且通过他们进行读取和写入此操作。
4. 什么是零拷贝,零拷贝的实现方式
零拷贝并不是指在数据的传输过程中发生拷贝的次数为零,而是指数据在传输过程中从内核空间到用户空间之间的数据拷贝次数为零。通过减少数据在用户空间和内核空间之间的复制次数,零拷贝技术可以极大地提升I/O的性能,尤其是在大文件传输、网络通信等场景中具有显著的优势。
实现方式:
- 直接内存访问(DMA)
DMA是一种硬件技术,允许外设(如网卡)直接访问内存,绕过CPU的参与,从而实现高速数据传输。在零拷贝中,DMA控制器被用来直接将数据从外设(如磁盘或网卡)传输到内存,或者从内存传输到外设,而无需CPU的干预 - 内存区域映射
内存区域映射技术(如mmap)允许程序将文件或设备直接映射到内存中,通过内存地址操作文件内容,而不需要显式地读取和写入文件。在零拷贝中,这种技术被用来将应用程序的内存区域映射到内核空间中的某个内核缓冲区,从而避免了数据在内核缓冲区之间的拷贝操作。 - 文件描述符传递
文件描述符传递是一种在进程间传递文件描述符的机制,它可以在不共享实际文件内容的情况下传递文件的打开句柄。在零拷贝中,这种机制被用来在进程间共享打开的文件,从而避免了数据的实际拷贝操作。 - 内核缓冲区重映射
内核缓冲区重映射技术将应用程序的内存直接映射到网络协议栈的内存中,避免了数据在内核缓冲区之间的拷贝。这种技术通过减少数据在内核空间和用户空间之间的传输次数,提高了数据传输的效率和性能。
5. 零拷贝的Java实现
NIO提供了对零拷贝的支持,尤其是FileChannel类提供了重要的API,如transferTo()和transferFrom(),这些方法可以高效地在文件、通道、网络之间传输数据。此外,mmap(Memory Mapped File)也是Java中实现零拷贝的一种方式,它允许程序将文件直接映射到内存中,通过内存地址操作文件内容。
public static void 传统拷贝方式() throws IOException {
FileInputStream fis = new FileInputStream("source.txt");
FileOutputStream fos = new FileOutputStream("destination.txt");
byte[] buffer = new byte[1024];
int bytesRead;
while ((bytesRead = fis.read(buffer)) != -1) {
fos.write(buffer, 0, bytesRead);
}
fis.close();
fos.close();
}
/**
* 这里使用 transferTo() 方法,文件数据直接通过内核缓冲区从磁盘读取并传输到目标通道,不需要再通过用户空间进行数据传递,极大地减少了 CPU 和内存的使用。
* @throws IOException
*/
public static void 零拷贝方式() throws IOException {
FileChannel sourceChannel = new FileInputStream("source.txt").getChannel();
FileChannel destChannel = new FileOutputStream("destination.txt").getChannel();
sourceChannel.transferTo(0, sourceChannel.size(), destChannel);
sourceChannel.close();
destChannel.close();
}
/**
* mmap(Memory Mapped File)是另一种零拷贝的实现方式,它允许程序将文件直接映射到内存中,通过内存地址操作文件内容,而不需要显式地读取和写入文件。
* 在 Java 中,mmap 可以通过 FileChannel.map() 方法实现,支持将文件的部分或全部内容映射到内存中。文件映射到内存后,访问文件的方式就像访问内存中的数组一样,操作系统会自动管理内存和文件之间的同步。
* @throws IOException
*/
public static void 内存映射文件方式mmap() throws IOException {
RandomAccessFile file = new RandomAccessFile("source.txt", "r");
FileChannel channel = file.getChannel();
MappedByteBuffer buffer = channel.map(FileChannel.MapMode.READ_ONLY, 0, channel.size());
for (int i = 0; i < buffer.limit(); i++) {
System.out.print((char) buffer.get());
}
channel.close();
file.close();
二. BIO
1. BIO示例
服务端代码(客户端类似)
public class Server {
public static void main(String[] args) throws IOException {
ServerSocket serverSocket = new ServerSocket(8080);
while (true){
Socket accept = serverSocket.accept();
InputStream in = accept.getInputStream();
byte[] buffer = new byte[1024];
int len;
while ((len = in.read(buffer))!=-1){
System.out.println(new String(buffer,0,len));
}
System.out.println("接收完毕");
in.close();
accept.close();
}
}
}
¥2. BIO的弊端
- 上述代码中的
socket.accept()
、socket.read()
均是同步阻塞的,如果没有接收到连接的Socket或客户端传来的数据,则会一直阻塞住,无法继续执行。如果采用单线程模型,则一个连接阻塞住,其他连接均无法被处理。 - 优化方案
- 为每个连接socket创建一个线程,每个线程独立处理请求,即使一个请求阻塞也不会干扰其他线程。但是,如果连接数过高,则创建大量线程可能导致CPU资源被耗尽
- 采用线程池处理连接,避免创建过多的线程。但是如果线程池中的工作线程都被阻塞住了,那么其他连接也无法被正确处理。
因此,只要底层仍然是同步阻塞的BIO模型,就无法从根本上解决问题。
三. I/O模型
1. Socket流数据流向
网络数据从到达服务器至到达进程,主要需经过两步:
- 等待网络上数据分组到达,然后将数据复制到内核缓冲区
- 将数据从内核缓冲区复制到进程
其中第一步检查用户缓冲区是否准备好了数据,这个操作需执行系统调用recevfrom,不同的IO模型在这一步中有不同的处理
¥2. IO模型
- 阻塞IO :线程发现数据未完全到达,会阻塞在系统调用recevfrom上,并且等待数据准备就绪以后才会返回。(该模型两步均阻塞)
- 非阻塞IO : 不阻塞在系统调用recevfrom,如果数据未完全到达,read()直接返回error,避免线程阻塞的开销。(该模型第一步非阻塞,第二步阻塞)
- IO多路复用:使用IO多路复用器管理socket,每当用户程序接受到socket请求,将请求托管给多路复用器进行监控,当程序对请求感兴趣的事件发生时,多路复用器以某种方式通知或是用户程序自己轮询请求,以便获取就绪的socket,然后只需使用一个线程进行轮询,多个线程处理就绪请求即可。
IO多路复用避免了每个socket请求都需要一个线程去处理,而是使用事件驱动的方式,让少数的线程去处理多数socket的IO请求。(该模型第一步非阻塞,第二步阻塞) - 信号驱动:内核中的数据准备完成则主动回调通知应用读取数据,使用较少
- 异步非阻塞IO(AIO):用户发起read()后直接返回,内核等待数据到达且数据拷贝完成后再去通知用户read完成,但Linux暂时对AIO的支持不是很好。(该模型第一步非阻塞,第二步也非阻塞)
四. NIO
1. NIO示例
public class NIOServer {
public static void main(String[] args) throws IOException {
// 1. serverSelector负责轮询是否有新的连接,服务端监测到新的连接之后,不再创建一个新的线程,
// 而是直接将新连接绑定到clientSelector上,这样就不用 IO 模型中 1w 个 while 循环在死等
Selector serverSelector = Selector.open();
// 2. clientSelector负责轮询连接是否有数据可读
Selector clientSelector = Selector.open();
new Thread(() -> {
try {
// 对应IO编程中服务端启动
ServerSocketChannel listenerChannel = ServerSocketChannel.open();
listenerChannel.socket().bind(new InetSocketAddress(3333));
listenerChannel.configureBlocking(false);
listenerChannel.register(serverSelector, SelectionKey.OP_ACCEPT);
while (true) {
// 监测是否有新的连接,这里的1指的是阻塞的时间为 1ms
if (serverSelector.select(1) > 0) {
Set<SelectionKey> set = serverSelector.selectedKeys();
Iterator<SelectionKey> keyIterator = set.iterator();
while (keyIterator.hasNext()) {
SelectionKey key = keyIterator.next();
if (key.isAcceptable()) {
try {
// (1) 每来一个新连接,不需要创建一个线程,而是直接注册到clientSelector
SocketChannel clientChannel = ((ServerSocketChannel) key.channel()).accept();
clientChannel.configureBlocking(false);
clientChannel.register(clientSelector, SelectionKey.OP_READ);
} finally {
keyIterator.remove();
}
}
}
}
}
} catch (IOException ignored) {
}
}).start();
new Thread(() -> {
try {
while (true) {
// (2) 批量轮询是否有哪些连接有数据可读,这里的1指的是阻塞的时间为 1ms
if (clientSelector.select(1) > 0) {
Set<SelectionKey> set = clientSelector.selectedKeys();
Iterator<SelectionKey> keyIterator = set.iterator();
while (keyIterator.hasNext()) {
SelectionKey key = keyIterator.next();
if (key.isReadable()) {
try {
SocketChannel clientChannel = (SocketChannel) key.channel();
ByteBuffer byteBuffer = ByteBuffer.allocate(1024);
// (3) 面向 Buffer
clientChannel.read(byteBuffer);
byteBuffer.flip();
System.out.println(
Charset.defaultCharset().newDecoder().decode(byteBuffer).toString());
} finally {
keyIterator.remove();
key.interestOps(SelectionKey.OP_READ);
}
}
}
}
}
} catch (IOException ignored) {
}
}).start();
}
}
2. NIO实现步骤
根据上述示例,可总结得到NIO的实现步骤:
- Selector.open()获得多路复用器Selector
- 创建ServerSocketChannel并绑定端口号
- configureBlocking(false)将ServerSocketChannel设置为非阻塞
- listenerChannel.register(serverSelector, SelectionKey.OP_ACCEPT)将服务器通道注册到Selector上,监听接收事件
- 轮询调用Selector.select(),如果有接收事件发生,则获取连接上的SocketChannel
- 将该 SocketChannel 注册到Selector上,监听读写事件
- 如果发生读写事件则做相应的处理
总的来讲,NIO编程十分复杂,此外还存在其他的问题:
- JDK 的 NIO 底层由 epoll 实现,该实现饱受诟病的空轮询 bug 会导致 cpu 飙升 100%
- 项目庞大之后,自行实现的 NIO 很容易出现各类 bug,维护成本较高,上面这一坨代码我都不能保证没有 bug
因此,Netty对JDK 原生 NIO进行了封装,简化了NIO的开发
3. NIO实现原理(Linux epoll)
- Selector本质就是对IO多路复用器的封装,IO多路复用器一般基于linux的epoll来实现。
- epoll采用回调机制,当某个事件准备就绪,则回调通知epoll进行对应操作(而不是主动轮询)
- 实现上
- epoll函数会在内核空间开辟一个特殊的数据结构,红黑树,树节点中存放的是一个socket描述符以及用户程序感兴趣的事件类型。同时epoll还会维护一个链表。用于存储已经就绪的socket描述符节点。
- 由Linux内核完成对红黑树的维护,当事件到达时,内核将就绪的socket节点加入链表中,用户程序可以直接访问这个链表以便获取就绪的socket。
4. epoll与select和poll的区别
- epoll没有最大并发数限制
- epoll采用回调而不是轮询,只处理活跃的连接,在效率上有很大的提升
- 使用mmap使内核和用户空间共享一块内存空间,减少了复制开销(只需复制一次)
(1)select,poll实现需要自己不断轮询所有fd集合,直到设备就绪,期间可能要睡眠和唤醒多次交替。而epoll其实也需要调用epoll_wait不断轮询就绪链表,期间也可能多次睡眠和唤醒交替,但是它是设备就绪时,调用回调函数,把就绪fd放入就绪链表中,并唤醒在epoll_wait中进入睡眠的进程。虽然都要睡眠和交替,但是select和poll在“醒着”的时候要遍历整个fd集合,而epoll在“醒着”的时候只要判断一下就绪链表是否为空就行了,这节省了大量的CPU时间。这就是回调机制带来的性能提升。
(2)select,poll每次调用都要把fd集合从用户态往内核态拷贝一次,并且要把current往设备等待队列中挂一次,而epoll只要一次拷贝,而且把current往等待队列上挂也只挂一次(在epoll_wait的开始,注意这里的等待队列并不是设备等待队列,只是一个epoll内部定义的等待队列)。这也能节省不少的开销。
五. AIO
1. AIO概念
- AIO 也就是 NIO 2。在 Java 7 中引入了 NIO 的改进版 NIO 2,它是异步非阻塞的IO模型。
- 异步 IO 是基于事件和回调机制实现的,也就是应用操作之后会直接返回,不会堵塞在那里,当后台处理完成,操作系统会通知相应的线程进行后续的操作。
- 但AIO需要充分调用操作系统参与,不同操作系统在性能上会有很大差别
2. AIO示例
public class AIOServer {
public void startListen(int port) throws InterruptedException {
try {
AsynchronousServerSocketChannel serverSocketChannel = AsynchronousServerSocketChannel.open();
serverSocketChannel.bind(new InetSocketAddress(port));
serverSocketChannel.accept(null,new CompletionHandler<AsynchronousSocketChannel,Void>() {
@Override
public void completed(AsynchronousSocketChannel socketChannel, Void attachment) {
serverSocketChannel.accept(null,this); //收到连接后,应该调用accept方法等待新的连接进来
ByteBuffer byteBuffer = ByteBuffer.allocate(1024);
socketChannel.read(byteBuffer,byteBuffer, new CompletionHandler<Integer,ByteBuffer>() {
@Override
public void completed(Integer num, ByteBuffer attachment) {
if (num > 0){
attachment.flip();
System.out.println(new String(attachment.array()).trim());
}else {
try {
socketChannel.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
@Override
public void failed(Throwable exc, ByteBuffer attachment) {
System.out.println("read error");
exc.printStackTrace();
}
});
}
@Override
public void failed(Throwable exc, Void attachment) {
System.out.println("accept error");
exc.printStackTrace();
}
});
} catch (IOException e) {
e.printStackTrace();
}
//模拟去做其他事情
while (true){
Thread.sleep(1000);
}
}
public static void main(String[] args) throws InterruptedException {
AIOServer aioServer = new AIOServer();
aioServer.startListen(8080);
}
}