本文介绍操作系统I/O工作原理,Java I/O设计,基本使用,开源项目中实现高性能I/O常见方法和实现,彻底搞懂高性能I/O之道 基础概念在介绍I/O原理之前,先重温几个基础概念:
操作系统:管理计算机硬件与软件资源的系统软件内核:操作系统的核心软件,负责管理系统的进程、内存、设备驱动程序、文件和网络系统等等,为应用程序提供对计算机硬件的安全访问服务
为了避免用户进程直接操作内核,保证内核安全,操作系统将内存寻址空间划分为两部分:内核空间(Kernel-space),供内核程序使用用户空间(User-space),供用户进程使用 为了安全,内核空间和用户空间是隔离的,即使用户的程序崩溃了,内核也不受影响
计算机中的数据是基于随着时间变换高低电压信号传输的,这些数据信号连续不断,有着固定的传输方向,类似水管中水的流动,因此抽象数据流(I/O流)的概念:指一组有顺序的、有起点和终点的字节集合, 抽象出数据流的作用:实现程序逻辑与底层硬件解耦,通过引入数据流作为程序与硬件设备之间的抽象层,面向通用的数据流输入输出接口编程,而不是具体硬件特性,程序和底层硬件可以独立灵活替换和扩展 I/O 工作原理1 磁盘I/O典型I/O读写磁盘工作原理如下:
值得注意的是:
2 网络I/O这里先以最经典的阻塞式I/O模型介绍:
值得注意的是:
Java I/O设计1 I/O分类Java中对数据流进行具体化和实现,关于Java数据流一般关注以下几个点:
从/向一个特定的IO设备(如磁盘,网络)或者存储对象(如内存数组)读/写数据的流,称为节点流; 对一个已有流进行连接和封装,通过封装后的流来实现数据的读/写功能,称为处理流(或称为过滤流); 2 I/O操作接口java.io包下有一堆I/O操作类,初学时看了容易搞不懂,其实仔细观察其中还是有规律:这些I/O操作类都是在继承4个基本抽象流的基础上,要么是节点流,要么是处理流 2.1 四个基本抽象流java.io包中包含了流式I/O所需要的所有类,java.io包中有四个基本抽象流,分别处理字节流和字符流:
2.2 节点流节点流I/O类名由节点流类型 + 抽象流类型组成,常见节点类型有:
节点流的创建通常是在构造函数传入数据源,例如: 2.3 处理流处理流I/O类名由对已有流封装的功能 + 抽象流类型组成,常见功能有:
处理流的应用了适配器/装饰模式,转换/扩展已有流,处理流的创建通常是在构造函数传入已有的节点流或处理流: 3 Java NIO3.1 标准I/O存在问题Java NIO(New I/O)是一个可以替代标准Java I/O API的IO API(从Java 1.4开始),Java NIO提供了与标准I/O不同的I/O工作方式,目的是为了解决标准 I/O存在的以下问题:
标准I/O处理,完成一次完整的数据读写,至少需要从底层硬件读到内核空间,再读到用户文件,又从用户空间写入内核空间,再写入底层硬件 此外,底层通过write、read等函数进行I/O系统调用时,需要传入数据所在缓冲区起始地址和长度由于JVM GC的存在,导致对象在堆中的位置往往会发生移动,移动后传入系统函数的地址参数就不是真正的缓冲区地址了 可能导致读写出错,为了解决上面的问题,使用标准I/O进行系统调用时,还会额外导致一次数据拷贝:把数据从JVM的堆内拷贝到堆外的连续空间内存(堆外内存) 所以总共经历6次数据拷贝,执行效率较低
传统的网络I/O处理中,由于请求建立连接(connect),读取网络I/O数据(read),发送数据(send)等操作是线程阻塞的 以上面服务端程序为例,当请求连接已建立,读取请求消息,服务端调用read方法时,客户端数据可能还没就绪(例如客户端数据还在写入中或者传输中),线程需要在read方法阻塞等待直到数据就绪 为了实现服务端并发响应,每个连接需要独立的线程单独处理,当并发请求量大时为了维护连接,内存、线程切换开销过大 3.2 BufferJava NIO核心三大核心组件是Buffer(缓冲区)、Channel(通道)、Selector Buffer提供了常用于I/O操作的字节缓冲区,常见的缓存区有ByteBuffer, CharBuffer, DoubleBuffer, FloatBuffer, IntBuffer, LongBuffer, ShortBuffer,分别对应基本数据类型: byte, char, double, float, int, long, short,下面介绍主要以最常用的ByteBuffer为例,Buffer底层支持Java堆外内存和堆内内存 堆外内存是指与堆内存相对应的,把内存对象分配在JVM堆以外的内存,这些内存直接受操作系统管理(而不是虚拟机,相比堆内内存,I/O操作中使用堆外内存的优势在于:
ByteBuffer底层基于堆外内存的分配和释放基于malloc和free函数,对外allocateDirect方法可以申请分配堆外内存,并返回继承ByteBuffer类的DirectByteBuffer对象: 堆外内存的回收基于DirectByteBuffer的成员变量Cleaner类,提供clean方法可以用于主动回收,Netty中大部分堆外内存通过记录定位Cleaner的存在,主动调用clean方法来回收;另外,当DirectByteBuffer对象被GC时,关联的堆外内存也会被回收
堆外内存基于基础ByteBuffer类的DirectByteBuffer类成员变量:Cleaner对象,这个Cleaner对象会在合适的时候执行unsafe.freeMemory(address),从而回收这块堆外内存 Buffer可以见到理解为一组基本数据类型,存储地址连续的的数组,支持读写操作,对应读模式和写模式,通过几个变量来保存这个数据的当前位置状态:capacity、 position、 limit:
3.3 ChannelChannel(通道)的概念可以类比I/O流对象,NIO中I/O操作主要基于Channel:从Channel进行数据读取 :创建一个缓冲区,然后请求Channel读取数据 从Channel进行数据写入 :创建一个缓冲区,填充数据,请求Channel写入数据 Channel和流非常相似,主要有以下几点区别:
Java NIO中最重要的几个Channel的实现:
基于标准I/O中,我们第一步可能要像下面这样获取输入流,按字节把磁盘上的数据读取到程序中,再进行下一步操作,而在NIO编程中,需要先获取Channel,再进行读写
3.4 SelectorSelector(选择器) ,它是Java NIO核心组件中的一个,用于检查一个或多个NIO Channel(通道)的状态是否处于可读、可写。实现单线程管理多个Channel,也就是可以管理多个网络连接 Selector核心在于基于操作系统提供的I/O复用功能,单个线程可以同时监视多个连接描述符,一旦某个连接就绪(一般是读就绪或者写就绪),能够通知程序进行相应的读写操作,常见有select、poll、epoll等不同实现 Java NIO Selector基本工作原理如下:
示例如下,完整可运行代码已经上传github(https://github.com/caison/caison-blog-demo):
高性能I/O优化(经典)下面结合业界热门开源项目介绍高性能I/O的优化 1 零拷贝(很多中间件都用到了该技术),请参考https://www.ibm.com/developerworks/cn/linux/l-cn-zerocopy1/零拷贝(zero copy)技术,用于在数据读写中减少甚至完全避免不必要的CPU拷贝,减少内存带宽的占用,提高执行效率,零拷贝有几种不同的实现原理,下面介绍常见开源项目中零拷贝实现 1.1 Kafka零拷贝Kafka基于Linux 2.1内核提供,并在2.4 内核改进的的sendfile函数 + 硬件提供的DMA Gather Copy实现零拷贝,将文件通过socket传送 函数通过一次系统调用完成了文件的传送,减少了原来read/write方式的模式切换。同时减少了数据的copy, sendfile的详细过程如下: 基本流程如下:
相比传统的I/O方式,sendfile + DMA Gather Copy方式实现的零拷贝,数据拷贝次数从4次降为2次,系统调用从2次降为1次,用户进程上下文切换次数从4次变成2次DMA Copy,大大提高处理效率 Kafka底层基于java.nio包下的FileChannel的transferTo: transferTo将FileChannel关联的文件发送到指定channel,当Comsumer消费数据,Kafka Server基于FileChannel将文件中的消息数据发送到SocketChannel 1.2 RocketMQ零拷贝RocketMQ基于mmap + write的方式实现零拷贝:mmap() 可以将内核中缓冲区的地址与用户空间的缓冲区进行映射,实现数据共享,省去了将数据从内核缓冲区拷贝到用户缓冲区 mmap + write 实现零拷贝的基本流程如下:
RocketMQ中消息基于mmap实现存储和加载的逻辑写在org.apache.rocketmq.store.MappedFile中,内部实现基于nio提供的java.nio.MappedByteBuffer,基于FileChannel的map方法得到mmap的缓冲区: 查询CommitLog的消息时,基于mappedByteBuffer偏移量pos,数据大小size查询:
因此,MappedFile数据保存CommitLog刷盘有2种方式:
RocketMQ 基于 mmap+write 实现零拷贝,适用于业务级消息这种小块文件的数据持久化和传输, Kafka 基于 sendfile 这种零拷贝方式,适用于系统日志消息这种高吞吐量的大块文件的数据持久化和传输
1.3 Netty零拷贝Netty 的零拷贝分为两种:
2 多路复用Netty中对Java NIO功能封装优化之后,实现I/O多路复用代码优雅了很多: 3 页缓存(PageCache)页缓存(PageCache)是操作系统对文件的缓存,用来减少对磁盘的 I/O 操作,以页为单位的,内容就是磁盘上的物理块,页缓存能帮助程序对文件进行顺序读写的速度几乎接近于内存的读写速度,主要原因就是由于OS使用PageCache机制对读写访问操作进行了性能优化: 页缓存读取策略:当进程发起一个读操作 (比如,进程发起一个 read() 系统调用),它首先会检查需要的数据是否在页缓存中:
页缓存写策略:当进程发起write系统调用写数据到文件中,先写到页缓存,然后方法返回。此时数据还没有真正的保存到文件中去,Linux 仅仅将页缓存中的这一页数据标记为“脏”,并且被加入到脏页链表中 然后,由flusher 回写线程周期性将脏页链表中的页写到磁盘,让磁盘中的数据和内存中保持一致,最后清理“脏”标识。在以下三种情况下,脏页会被写回磁盘:
RocketMQ中,ConsumeQueue逻辑消费队列存储的数据较少,并且是顺序读取,在page cache机制的预读取作用下,Consume Queue文件的读性能几乎接近读内存,即使在有消息堆积情况下也不会影响性能,提供了2种消息刷盘策略:
Kafka实现消息高性能读写也利用了页缓存,这里不再展开 参考《深入理解Linux内核 —— Daniel P.Bovet》 Netty之Java堆外内存扫盲贴 ——江南白衣 Java NIO?看这一篇就够了!——朱小厮 RocketMQ 消息存储流程 —— Zhao Kun(赵坤) 一文理解Netty模型架构 ——caison
来自:https://mp.weixin.qq.com/s?__biz=MzU1OTc4MjI2OA==&mid=2247483780&idx=1&sn=915c36664fc8c97ad16fd50... |
|