携手创作,共同成长!这是我参与「掘金日新计划 · 8 月更文挑战」的第2天,点击查看活动详情
一切还要从JavaNIO说起。
JavaNIO是基于IO多路复用技术,NIO(new IO) 弥补了老式的OIO同步阻塞的不足
他们有何不同呢?
- OIO面向流, NIO面向缓冲区
因为是面向流的,所以导致OIO只能顺序读取
- OIO阻塞, NIO非阻塞
- OIO没用选择器,NIO有选择器,但需要底层支持
JavaNIO有三个核心组件
- Channel 通道
- Buffer 缓冲区
- Selector选择器
可以理解为,buffer是存储数据的地方,channel是运输数据的载体,select用于检查多个channel状态变更
Buffer类
能够覆盖所有基本类型,还包括用于内存映射的MappedByteBuffer
Byte,Char, Double, Float, Int , Long, Short
buffer类的基本属性
capacity--容量
position--当前位置
limit--读写的最大上限
mark--标记当前位置,并且能够在reset()将position回到标记的位置
重要方法
allocate()--创建缓冲区
put()--写入到缓冲区
flip() -- 翻转,将写模式切换成读模式
get()--从缓冲区取
rewind()倒带相当于重新读缓冲区里的东西
mark()和reset()
clear()清空缓冲区并且变为写模式。
Channel类
四种分类:
- FileChannel
- SocketChannel
- ServerSocketChannel
- DatagramChannel
Selector类
channel将自己的事件注册到Selector中
IO事件类型有以下四种:
- 可读 SelectionKey.OP_READ
- 可写 SelectionKey.OP_WRITE
- 连接 SelectionKey.OP_CONNECT
- 接收 SelectionKey.OP_ACCEPT
为什么FileChannel不能被选择器复用?
- 因为没有继承SelectableChannel
SelectableChannel 提供了实现通道的可选择性所需要的公共方法
- FilChannel是阻塞的,不能设置成非阻塞
public class NoBlockServer {
public static void main(String[] args) throws IOException {
// 1.获取通道
ServerSocketChannel server = ServerSocketChannel.open();
// 2.切换成非阻塞模式
server.configureBlocking(false);
// 3. 绑定连接
server.bind(new InetSocketAddress(6666));
// 4. 获取选择器
Selector selector = Selector.open();
// 4.1将通道注册到选择器上,指定接收“监听通道”事件
server.register(selector, SelectionKey.OP_ACCEPT);
// 5. 轮训地获取选择器上已“就绪”的事件--->只要select()>0,说明已就绪
while (selector.select() > 0) {
// 6. 获取当前选择器所有注册的“选择键”(已就绪的监听事件)
Iterator<SelectionKey> iterator = selector.selectedKeys().iterator();
// 7. 获取已“就绪”的事件,(不同的事件做不同的事)
while (iterator.hasNext()) {
SelectionKey selectionKey = iterator.next();
// 接收事件就绪
if (selectionKey.isAcceptable()) {
// 8. 获取客户端的链接
SocketChannel client = server.accept();
// 8.1 切换成非阻塞状态
client.configureBlocking(false);
// 8.2 注册到选择器上-->拿到客户端的连接为了读取通道的数据(监听读就绪事件)
client.register(selector, SelectionKey.OP_READ);
} else if (selectionKey.isReadable()) { // 读事件就绪
// 9. 获取当前选择器读就绪状态的通道
SocketChannel client = (SocketChannel) selectionKey.channel();
// 9.1读取数据
ByteBuffer buffer = ByteBuffer.allocate(1024);
// 9.2得到文件通道,将客户端传递过来的图片写到本地项目下(写模式、没有则创建)
FileChannel outChannel = FileChannel.open(Paths.get("2.png"), StandardOpenOption.WRITE, StandardOpenOption.CREATE);
while (client.read(buffer) > 0) {
// 在读之前都要切换成读模式
buffer.flip();
outChannel.write(buffer);
// 读完切换成写模式,能让管道继续读取文件的数据
buffer.clear();
}
}
// 10. 取消选择键(已经处理过的事件,就应该取消掉了)
iterator.remove();
}
}
}
}
public class NoBlockClient {
public static void main(String[] args) throws IOException {
// 1. 获取通道
SocketChannel socketChannel = SocketChannel.open(new InetSocketAddress("127.0.0.1", 6666));
// 1.1切换成非阻塞模式
socketChannel.configureBlocking(false);
// 1.2获取选择器
Selector selector = Selector.open();
// 1.3将通道注册到选择器中,获取服务端返回的数据
socketChannel.register(selector, SelectionKey.OP_READ);
// 2. 发送一张图片给服务端吧
FileChannel fileChannel = FileChannel.open(Paths.get("X:\\Users\\ozc\\Desktop\\面试造火箭\\1.png"), StandardOpenOption.READ);
// 3.要使用NIO,有了Channel,就必然要有Buffer,Buffer是与数据打交道的呢
ByteBuffer buffer = ByteBuffer.allocate(1024);
// 4.读取本地文件(图片),发送到服务器
while (fileChannel.read(buffer) != -1) {
// 在读之前都要切换成读模式
buffer.flip();
socketChannel.write(buffer);
// 读完切换成写模式,能让管道继续读取文件的数据
buffer.clear();
}
// 5. 轮训地获取选择器上已“就绪”的事件--->只要select()>0,说明已就绪
while (selector.select() > 0) {
// 6. 获取当前选择器所有注册的“选择键”(已就绪的监听事件)
Iterator<SelectionKey> iterator = selector.selectedKeys().iterator();
// 7. 获取已“就绪”的事件,(不同的事件做不同的事)
while (iterator.hasNext()) {
SelectionKey selectionKey = iterator.next();
// 8. 读事件就绪
if (selectionKey.isReadable()) {
// 8.1得到对应的通道
SocketChannel channel = (SocketChannel) selectionKey.channel();
ByteBuffer responseBuffer = ByteBuffer.allocate(1024);
// 9. 知道服务端要返回响应的数据给客户端,客户端在这里接收
int readBytes = channel.read(responseBuffer);
if (readBytes > 0) {
// 切换读模式
responseBuffer.flip();
System.out.println(new String(responseBuffer.array(), 0, readBytes));
}
}
// 10. 取消选择键(已经处理过的事件,就应该取消掉了)
iterator.remove();
}
}
}
}
Linux下的IO多路复用技术
文件句柄数的限制
默认1024,也就是一个进程最多接受1024个socket连接
从基础开始讲起,文件句柄,也叫文件描述符。
在linux中文件分为,普通文件、目录文件、链接文件、和设备文件
文件描述符是内核为了高效管理已被打开的文件所创建的索引。是一个非负整数(通常是小数)
ulimit 用来显示-修改当前用户进程的一些信息,-n是与文件句柄相关。
三种修改句柄方式
ulimit -n 10000 当终端工具退出时就会失效
向 /etc/rc.local添加 ulimit -SHn 10000
终极 解除 修改极限配置文件
soft nofile 10000
hard nofile 10000 ElasticSearch必须修改
此外,linux对文件的操作实际上是通过文件描述符,而IO复用模型就是通过一个线程监控多个文件描述符,当某个文件描述符准备就绪时,就去通知程序做处理。这样不仅单个连接处理的快,还能处理更多的连接。
linux下的IO复用模型就是select/epoll函数
select和epoll的区别
select
函数支持的最大的连接数是1024或者2048,因为在select函数要传入fd_set参数(看操作系统的位数)
fd_set是bitmap的数据结构,可以简单理解为只要位为0,就是数据没到缓冲区,为1 到了缓冲区
select函数做的就是每次将fd_set遍历,有变化就通知处理。
epoll
使用epoll_event结构体来处理,不存在最大连接数的限制。并且不是通过遍历的方式,简单理解就是epoll把就绪的文件描述符专门维护了一块空间,每次从就绪列表里边拿。
零拷贝
和JavaNIO相关的另一个概念是零拷贝
传统IO情况下,当用户程序发起一次读请求,会调用read相关的系统函数,然后会从用户态切换到内核态,随后CPU告诉DMA去磁盘把数据拷贝到内核空间。内核缓冲区有数据后,CPU就把内核缓冲区数据拷贝到用户缓冲区,最终用户程序拿到数据。
DMA是直接内存访问,允许外部设备直接与内存设备进行数据传输不需要CPU参与的技术
为了内核安全,所以将操作系统划分为用户空间和**内核空间,**读数据会有状态切换。
零拷贝是指计算机执行IO操作时,CPU不需要将数据从一个存储区域复制到另一个存储区域,从而可以减少上下文切换以及CPU的拷贝时间。它是一种I/O操作优化技术。
- mmap--内核缓冲区与用户缓冲区共享
- sendfile--系统底层函数的支持
下面是比较详细的解释
传统IO过程
- read :把数据从磁盘读取到内核缓冲区,再拷贝到用户缓冲区
- write:先把数据写入到socket缓冲区,最后写入网卡设备
过程
- 用户程序通过read函数向内核发起IO,从用户态切换为内核态,然后再通过DMA将数据从磁盘读到内核缓冲区
- CPU将内核缓冲区数据拷贝到用户缓冲区,然后read函数返回,从内核态切换到用户态。
- 用户程序通过write函数向内核发起IO,从用户态切换到内核态,然后CPU将数据从用户缓冲区拷贝到内核socket缓冲区,然后write返回,从内核态切换到用户态。
- 最后异步传输到网卡。
可以看出,发生了四次状态切换,CPU拷贝两次。
零拷贝指在进行数据IO时, 数据在用户态下经历了零次CPU拷贝。通过减少内核缓冲区和用户缓冲区之间不必要的cpu数据传输,与用户态和内核态的切换磁环,降低开销提高性能。零拷贝基于PageCache,提升访问缓存数据的性能,同时IO合并与预读(顺序读比随机读性能好)解决机械磁盘寻址慢。
Linux中的零拷贝方式
mmap
mmap就是操作系统把内核缓冲区与用户程序共享,也可以说是将用户空间内存映射到内核空间,映射成功后,用户对这段内存区域的修改可以直接反映到内核空间,反过来也一样。正因如此,就不需要在用户态与内核态之间拷贝数据。
过程:
- 用户程序调用mmap发起IO,从用户态切换到内核态,通过DMA将数据从磁盘拷贝到内核缓冲区。
- mmap返回,从内核态切换到用户态,不需要将数据从内核缓冲区复制到用户缓冲区,因为共享。
- 用户程序通过write发起IO,从用户态切换到内核态,CPU将数据从内核缓冲区复制到内核socket缓冲区,返回内核态切换为用户态。
- DMA异步将socket缓冲区拷贝到网卡
sendfile
由于调用read或者write一定会发生两次上下文切换,所以想要减少状态切换,那就把read和write合并起来,在内核中完成磁盘与网卡的数据交互
过程:
- 用户程序发起sendfile,用户态切换到内核态,DMA将数据从磁盘复制到内存缓冲区
- CPU将数据从内核缓冲区复制到socket缓冲区
- sendfile系统调用返回,从内核态切换到用户态
- DMA异步将socket数据复制到网卡
带DMA收集拷贝功能的sendfile
sendfile升级后,引入了SG-DMA,就是对DMA拷贝加入了scatter、gather操作,可以直接将内存缓冲区数据复制到网卡,无需再复制到socket缓冲区,减少CPU拷贝次数。
过程:
- 用户程序发起sendfile调用,从用户态切换到内核态,DMA将数据从磁盘拷贝到内核缓冲区
- 不需要CPU将数据复制到socket缓冲区,而是将文件描述符信息复制到socket缓冲区,该描述符包含 内核缓冲区的内存地址、内核缓冲区的偏移量
- sendfile返回,从内核态切换到用户态
- DMA根据socket缓冲区中描述的地址和偏移量直接将内核缓冲区复制到网卡。
零拷贝的缺点:不允许进程对文件内容做加工再发送,比如数据压缩
零拷贝的应用场景
JavaNIO
- mmap+ write
上文提到过buffer中有个叫mappedBuffer的家伙,同时Filechannel提供了map方法,可以再一个打开的文件和MappedByteBuffer之间建立一个虚拟内存映射,map底层是通过mmap实现的,因此磁盘文件到缓冲区后,用户和内核共享缓冲区。
public class MmapTest {
public static void main(String[] args) {
try {
FileChannel readChannel = FileChannel.open(Paths.get("./jay.txt"), StandardOpenOption.READ);
MappedByteBuffer data = readChannel.map(FileChannel.MapMode.READ_ONLY, 0, 1024 * 1024 * 40);
FileChannel writeChannel = FileChannel.open(Paths.get("./siting.txt"), StandardOpenOption.WRITE, StandardOpenOption.CREATE);
//数据传输
writeChannel.write(data);
readChannel.close();
writeChannel.close();
}catch (Exception e){
System.out.println(e.getMessage());
}
}
}
-
sendfile
FileChannel 的 transferTo、transferFrom 如果操作系统底层支持的话,transferTo、transferFrom也会使用 sendfile 零拷贝技术来实现数据的传输
@Override
public long transferFrom(FileChannel fileChannel, long position, long count) throws IOException {
return fileChannel.transferTo(position, count, socketChannel);
}
public class SendFileTest {
public static void main(String[] args) {
try {
FileChannel readChannel = FileChannel.open(Paths.get("./jay.txt"), StandardOpenOption.READ);
long len = readChannel.size();
long position = readChannel.position();
FileChannel writeChannel = FileChannel.open(Paths.get("./siting.txt"), StandardOpenOption.WRITE, StandardOpenOption.CREATE);
//数据传输
readChannel.transferTo(position, len, writeChannel);
readChannel.close();
writeChannel.close();
} catch (Exception e) {
System.out.println(e.getMessage());
}
}
}
Netty框架
Netty 的零拷贝主要体现在下面五个方面:
(1)在网络通信上,Netty 的接收和发送 ByteBuffer 采用直接内存,使用堆外直接内存进行 Socket 读写,不需要进行字节缓冲区的二次拷贝。如果使用传统的堆内存进行 Socket 读写,JVM 会将堆内存 Buffer 拷贝一份到直接内存中(为什么拷贝?因为 JVM 会发生 GC 垃圾回收,数据的内存地址会发生变化,直接将堆内的内存地址传给内核,内存地址一旦变了就内核读不到数据了),然后才写入 Socket 中。相比于堆外直接内存,消息在发送过程中多了一次缓冲区的内存拷贝。
(2)在文件传输上,Netty 的通过 FileRegion 包装的 FileChannel.tranferTo 实现文件传输,它可以直接将文件缓冲区的数据发送到目标 Channel,避免了传统通过循环 write 方式导致的内存拷贝问题。
(3)在缓存操作上,Netty 提供了CompositeByteBuf 类,它可以将多个 ByteBuf 合并为一个逻辑上的 ByteBuf,避免了各个 ByteBuf 之间的拷贝。
(4)通过 wrap 操作,我们可以将byte[]数组、ByteBuf、ByteBuffer等包装成一个Netty ByteBuf对象,进而避免了拷贝操作。
(5)ByteBuf 支持 slice 操作,因此可以将 ByteBuf 分解为多个共享同一个存储区域的 ByteBuf,避免了内存的拷贝。
kafka
Kafka 的索引文件使用的是 mmap + write 方式,数据文件使用的是 sendfile 方式