BIO
阻塞io
FileInputStream in = new FileInputStream(file);
byte bytes[] = new byte[1024];
in.read(bytes); //这里阻塞
read()方法,系统调用,从用户态进入到内核态,发生上下文切换,在内核态调用文件系统的方法,在DMA控制下读取数据到内核空间缓冲区,读取完成后发生中断,再从内核空间buffer区拷贝数据到洪湖空间buffer区
-
内核缓冲区作为一个中间缓冲区。用来“适配”用户缓冲区的“任意大小”和每次读磁盘块的固定大小。
-
用户缓冲区位于用户态空间,而DMA读取数据这种操作涉及到底层的硬件,硬件是不能直接访问用户态空间的。
NIO
非阻塞io
在调用读取方法时,如果数据没有准备好不会阻塞
ServerSocketChannel ss = ServerSocketChannel.open();
ss.bind(new InetSocketAddress("host", 1));
System.out.println(" NIO server started ... ");
ss.configureBlocking(false);
while (true) {
// 新线程里面处理
final SocketChannel socket = ss.accept();//阻塞方法
socket.configureBlocking(false);
ByteBuffer byteBuffer = ByteBuffer.allocate(1024);
int read = socket.read(byteBuffer);
byteBuffer.flip();
System.out.println("请求:" + new String(byteBuffer.array()));
String resp = "服务器响应";
byteBuffer.get(resp.getBytes());
socket.write(byteBuffer);
// 新线程里面处理
}
IO multiplexing 多路复用IO
IO多路复用(IO Multiplexing) 是这么一种机制:程序注册一组socket文件描述符给操作系统,表示“我要监视这些fd是否有IO事件发生,有了就告诉程序处理”。
IO多路复用是要和NIO一起使用的。尽管在操作系统级别,NIO和IO多路复用是两个相对独立的事情。NIO仅仅是指IO API总是能立刻返回,不会被Blocking;而IO多路复用仅仅是操作系统提供的一种便利的通知机制。操作系统并不会强制这俩必须得一起用——你可以用NIO,但不用IO多路复用,就像上一节中的代码;也可以只用IO多路复用 + BIO,这时效果还是当前线程被卡住。但是,IO多路复用和NIO是要配合一起使用才有实际意义
单个线程就可以同时处理多个网络连接。内核负责轮询所有socket,当某个socket有数据到达了,就通知用户进程。多路复用在Linux内核代码迭代过程中依次支持了三种调用,即SELECT、POLL、EPOLL三种多路复用的网络I/O模型
select
-
句柄上限- 默认打开的FD有限制,1024个。
-
重复初始化-每次调用 select(),需要把 fd 集合从用户态拷贝到内核态,内核进行遍历。
-
逐个排查所有FD状态效率不高。
int play_select()
{
fd_set rfds;
FD_ZERO(&rfds);
FD_SET(STDIN_FILENO, &rfds);
fd_set wfds;
FD_ZERO(&wfds);
FD_SET(STDOUT_FILENO, &wfds);
struct timeval tv;
tv.tv_sec = 5;
tv.tv_usec = 0;
int retval = select(STDOUT_FILENO + 1, &rfds, &wfds, NULL, &tv);
if (retval < 0)
{
printf("select error\n");
return -1;
}
if (retval == 0)
{
printf("time out\n");
return -1;
}
// 需要对所有句柄进行轮询遍历
if(FD_ISSET(STDIN_FILENO, &rfds))
{
printf("can read\n");
}
if(FD_ISSET(STDOUT_FILENO, &wfds))
{
printf("can write\n");
}
return 0;
}
poll
-
设计新的数据结构(链表)提供使用效率。
-
poll和select相比在本质上变化不大,只是poll没有了select方式的最大文件描述符数量的限制。
-
缺点:逐个排查所有FD状态效率不高。
int play_poll()
{
pollfd pfd[2];
int nfds = 2;
pfd[0].fd = STDIN_FILENO;
pfd[0].events = POLLIN;
pfd[1].fd = STDOUT_FILENO;
pfd[1].events = POLLOUT;
int timeout_ms = 5000; // 5000毫秒
int retval = poll(pfd, nfds, timeout_ms);
if (retval < 0)
{
printf("poll error\n");
return -1;
}
if (retval == 0)
{
printf("time out\n");
return -1;
}
// 需要对所有句柄进行轮询遍历
if(pfd[0].revents & POLLIN)
{
printf("can read\n");
}
if(pfd[1].revents & POLLOUT)
{
printf("can write\n");
}
return 0;
}
epoll
没有fd个数限制,用户态拷贝到内核态只需要一次,使用事件通知机制来触发。通过epoll_ctl注册fd,一旦fd就绪就会通过callback回调机制来激活对应fd,进行相关的I/O操作。
- 跨平台,Linux 支持最好。
- 底层实现复杂。
- 同步。
int play_epoll()
{
epoll_event evReq[10];
epoll_event evRsp[10];
evReq[0].data.fd = STDIN_FILENO;
evReq[0].events = EPOLLIN;
evReq[1].data.fd = STDOUT_FILENO;
evReq[1].events = EPOLLOUT;
// 创建管理句柄
int epollFd = epoll_create(10);
// 添加以便于管理
epoll_ctl(epollFd, EPOLL_CTL_ADD, evReq[0].data.fd, evReq);
epoll_ctl(epollFd, EPOLL_CTL_ADD, evReq[1].data.fd, evReq + 1);
int timeout_ms = 5000; // 5000毫秒
int retval = epoll_wait(epollFd, evRsp, 10, timeout_ms);
if (retval < 0)
{
printf("epoll error\n");
close(epollFd);
return -1;
}
if (retval == 0)
{
printf("time out\n");
close(epollFd);
return -1;
}
// 无需遍历所有句柄,只需直接取结果就行,如下evRsp[i]对应的句柄一定是"就绪的(active)"
for (int i = 0; i < retval; ++i)
{
printf("active fd is %d\n", evRsp[i].data.fd);
}
close(epollFd);
return 0;
}
同步、异步
- 同步:API调用返回时调用者就知道操作的结果如何了(实际读取/写入了多少字节)。
- 异步:相对于同步,API调用返回时调用者不知道操作的结果,后面才会回调通知结果。(取数据在另外的线程)
零拷贝
零拷贝技术是操作系统底层支持的
zerocopy技术的目标就是提高IO密集型JAVA应用程序的性能。在本文的前面部分介绍了:IO操作需要数据频繁地在内核缓冲区和用户缓冲区之间拷贝,而zerocopy技术可以减少这种拷贝的次数,同时也降低了上下文切换(用户态与内核态之间的切换)的次数。
一、经典服务器读取文件返回流程
File.read(fileDesc, buf, len);
Socket.send(socket, buf, len);
从代码上看,就是两步操作。第一步:将文件读入buf;第二步:将 buf 中的数据通过socket发送出去。但是,这两步操作需要四次上下文切换(用户态与内核态之间的切换) 和 四次拷贝操作才能完成。
内核缓冲区的作用:
缓存,内核缓冲区确实提高了读操作的性能
异步写
用户空间请求的数据块跟磁盘读取的数据块不一致时,可以在缓冲区整合成用户空间需要的大小
当需要传输的数据远远大于内核缓冲区的大小时,内核缓冲区就会成为瓶颈
二、mmap
buf = mmap(diskfd, len);
write(sockfd, buf, len);
应用程序调用 mmap ,磁盘文件中的数据通过 DMA 拷贝到内核缓冲区,接着操作系统会将这个缓冲区与应用程序共享,这样就不用往用户空间拷贝。应用程序调用write ,操作系统直接将数据从内核缓冲区拷贝到 socket 缓冲区,最后再通过 DMA 拷贝到网卡发出去。
缺点:
mmap 隐藏着一个陷阱,当 mmap 一个文件时,如果这个文件被另一个进程所截获,那么 write 系统调用会因为访问非法地址被 SIGBUS 信号终止,SIGBUS 默认会杀死进程并产生一个 coredump,如果服务器被这样终止了,那损失就可能不小了。
加锁解决
三、senfile
sendfile 是只发生在内核态的数据传输接口,没有用户态的参与,自然避免了用户态数据拷贝,涉及到三次数据拷贝和二次上下文切换,感觉也才减少了一次数据拷贝嘛,但这里已经不涉及用户空间的缓冲区了
四、DMA 辅助的 sendfile
这种方法借助硬件的帮助,在数据从内核缓冲区到 socket 缓冲区这一步操作上,并不是拷贝数据,而是拷贝缓冲区描述符,待完成后,DMA 引擎直接将数据从内核缓冲区拷贝到协议引擎中去,避免了最后一次拷贝。
这里一共只有两次拷贝 和 两次上下文切换。而且这两次拷贝都是DMA copy,并不需要CPU干预
缺点:
只适用于将数据从文件拷贝到套接字上。
五、splice
splice 去掉 sendfile 的使用范围限制,可以用于任意两个文件描述符中传输数据。
java堆外内存(直接内存)
堆内存jvm管理的,同样也是gc 的主要工作区域,可以由jvm自动回收垃圾。
堆外内存区别:
-
直接内存是在堆外,不受jvm管理,申请过多不会引起gc
-
写数据的时候,若数据在堆上,则需要从堆上拷贝到堆外,操作系统才可以去操作这个拷贝的数据,如果本来就是堆外的数据,就不用复制到堆外了,
为什么不直接操作堆上的数据?
因为Java 有gc,gc 可能不会回收要被写的数据,但是可能会移动它(把已用内存压缩在一边,清除内存碎片),操作系统是通过内存地址去操作内存的,内存地址变了,这些写到文件或者网络里的数据可能并不是我们想要写的数据,也有可能产生很多未知的错误
writeData内存区域(堆内) --> 临时的DirectByteBuffer(堆外) --> 系统内核空间buffer --> 网卡
DirectByteBuffer
ByteBuffer byteBuffer = ByteBuffer.allocateDirect(10240);
byteBuffer.put((byte) 1);
释放堆外内存
使用完DirectByteBuffer对象只要把对象引用置为空,等待垃圾回收器去回收该对象即可,DirectByteBuffer对象被回收的时候 它的 虚引用也就是Cleaner 对象会被放到 ReferenceQueue 中,然后专门有一个ReferenceHandle 线程去处理这个队列 若发现从队列里取出的是Cleaner 对象则会执行 clean()方法,clean 方法会调用 thunk 属性的run方法 对于DirectByteBuffer来说这个thunk就是其嵌套类对象,主要看run 方法 就是释放内存。
创建buffer对象的时候同时创建了一个cleaner
private static class Deallocator
implements Runnable
{
private static Unsafe unsafe = Unsafe.getUnsafe();
private long address;
private long size;
private int capacity;
private Deallocator(long address, long size, int capacity) {
assert (address != 0);
this.address = address;
this.size = size;
this.capacity = capacity;
}
public void run() {
if (address == 0) {
// Paranoia
return;
}
//回收堆外内存
unsafe.freeMemory(address);
//内存地址置为零,防止重复回收
address = 0;
Bits.unreserveMemory(size, capacity);
}
}
MappedByteBuffer
A direct byte buffer whose content is a memory-mapped region of a file
内存映射,把文件映射到堆外内存,像内存一样访问文件
MappedByteBuffer主要用在对大文件的读写或对实时性要求比较高的程序当中
// 通过 RandomAccessFile 创建对应的文件操作类,第二个参数 rw 代表该操作类可对其做读写操作
RandomAccessFile raf = new RandomAccessFile("fileName", "rw");
// 获取操作文件的通道
FileChannel fc = raf.getChannel();
// 也可以通过FileChannel的open来打开对应的fc
// FileChannel fc = FileChannel.open(Paths.get("/usr/local/test.txt"),StandardOpenOption.WRITE);
// 把文件映射到内存
MappedByteBuffer mbb = fc.map(FileChannel.MapMode.READ_WRITE, 0, (int) fc.size());
// 读写文件
mbb.putInt(4);
mbb.put("test".getBytes());
mbb.force();
mbb.position(0);
mbb.getInt();
mbb.get(new byte["test".getBytes().size()]);