传统的 IO 流仍是有不少缺陷的,尤为它的阻塞性加上磁盘读写原本就慢,会致使 CPU 使用效率大大下降。spring
因此,jdk 1.4 发布了 NIO 包,NIO 的文件读写设计颠覆了传统 IO 的设计,采用通道+缓存区使得新式的 IO 操做直接面向缓存区,而且是非阻塞的,对于效率的提高真不是一点两点,咱们一块儿来看看。编程
咱们说过,NIO 的核心就是通道和缓存区,因此它们的工做模式是这样的:小程序
通道有点相似 IO 中的流,但不一样的是,同一个通道既容许读也容许写,而任意一个流要么是读流要么是写流。数组
可是你要明白一点,通道和流同样都是须要基于物理文件的,而每一个流或者通道都经过文件指针操做文件,这里说的「通道是双向的」也是有前提的,那就是通道基于随机访问文件『RandomAccessFile』的可读可写文件指针。浏览器
『RandomAccessFile』是既可读又可写的,因此基于它的通道是双向的,因此,「通道是双向的」这句话是有前提的,不能断章取义。缓存
基本的通道类型有以下一些:性能优化
FileChannel 是基于文件的通道,SocketChannel 和 ServerSocketChannel 用于网络 TCP 套接字数据报读写,DatagramChannel 是用于网络 UDP 套接字数据报读写。服务器
通道不能单独存在,它永远须要绑定一个缓存区,全部的数据只会存在于缓存区中,不管你是写或是读,必然是缓存区经过通道到达磁盘文件,或是磁盘文件经过通道到达缓存区。微信
即缓存区是数据的「起点」,也是「终点」,具体这些通道到底有哪些不一样以及该如何使用,基本实现如何,咱们介绍完『缓存区』概念后,再作详细学习。网络
Buffer 是全部具体缓存区的基类,是一个抽象类,它的实现类有不少,包含各类类型数据的缓存。
咱们以 ByteBuffer 为例进行学习,其他的缓存区也都是基于字节缓存区的,只不过多了一步字节转换过程而已,MappedByteBuffer 是一个特殊的缓存方式,咱们会单独介绍。
Buffer 中有几个重要的成员属性,咱们了解一下:
mark 属性咱们已经不陌生了,用于重复读。capacity 描述缓存区容量,即整个缓存区最大能存储多少数据量。address 用于操做直接内存,区别于 jvm 内存,这一点待会说明。
而 position 和 limit 我想用一张图结合解释:
因为缓存区是读写共存的,因此不一样的模式下,这两个变量的值也具备不一样的意义。
写模式下,所谓写模式就是将缓存区中的内容写入通道。position 表明下一个字节应该被写出去的字节在缓存区中的位置,limit 表示最后一个待写字节在缓存区的位置。
读模式下,所谓读模式就是从通道读取数据到缓存区。position 表明下一个读出来的字节应当存储在缓存区的位置,limit 等于 capacity。
相关的读写操做细节,待会会和你们一块儿看源码,以加深对通道和缓存区协做工做的原理,这里咱们先讨论一个你们可能没怎么关注过的一个问题。
JVM 内存划分为栈和堆,这是你们深刻脑海的知识,可是其实划分给 JVM 的还有一块堆外内存,也就是直接内存,不少人不知道这块内存是干什么用的。
这是一块物理内存,专门用于 JVM 和 IO 设备打交道,Java 底层使用 C 语言的 API 调用操做系统与 IO 设备进行交互。
例如,Java 内存中有一个字节数组,如今调用流将它写入磁盘文件,那么 JVM 首先会将这个字节数组先拷贝一份到堆外内存中,而后调用 C 语言 API 指明将某个连续地址范围的数据写入磁盘。
读操做也是相似,而 JVM 额外作的拷贝工做也是有意义的,由于 JVM 是基于自动垃圾回收机制运行的,全部内存中的数据会在 GC 时不停的被移动,若是你调用系统 API 告诉操做系统将内存某某位置的内存写入磁盘,而此时发生 GC 移动了该部分数据,GC 结束后操做系统是否是就写错数据了。
因此,JVM 对于与外围 IO 设备交互的状况下,都会将内存数据复制一份到堆外内存中,而后调用系统 API 间接的写入磁盘,读也是相似的。因为堆外内存不受 GC 管理,因此用完必定得记得释放。
理解这一个小知识是看懂源码实现的前提,否则你可能不知道代码实现者在作什么。好了,那咱们就先来看看读操做的基本使用与源码实现。
咱们看这么一段代码,这段代码我大体分红了四个部分,第一部分用于获取文件通道,第二部分用于分配缓存区并完成读操做,第三部分用于将缓存区中数据进行打印,第四部分为关闭通道链接。
第一部分:
getChannel 方法用于获取一个文件相关的通道实例,具体实现以下:
getChannel 方法会调用 FileChannelImpl 的工厂方法构建一个 FileChannelImpl 实例,FileChannelImpl 是抽象类 FileChannel 的一个子类实现。
构成 FileChannelImpl 实例所需的必要参数有,该文件的文件指针,该文件的完整路径,读写权限等。
第二部分:
Buffer 的基本结构咱们上述已经简单介绍了,这里再也不赘述了,所谓的缓存区,本质上就是字节数组。
ByteBuffer 实例的构建是经过工厂模式产生的,必须指定参数 capacity 做为内部字节数组的容量。HeapByteBuffer 是虚拟机的堆上内存,全部数据都将存储在堆空间,咱们不久将会介绍它的一个兄弟,DirectByteBuffer,它被分配在堆外内存中,具体的一会说。
这个 HeapByteBuffer 的构造状况咱们不妨跟进去看看:
调用父类的构造方法,初始化咱们在 ByteBuffer 中提过的一些属性值,如 position,capacity,mark,limit,offset 以及字节数组 hb。
接着,咱们看看这个 read 方法的调用链。
这个 read 方法是子类 FileChannelImpl 对父类 FileChannel read 方法的重写。这个方法不是读操做的核心,咱们简单归纳一下,该方法首先会拿到当前通道实例的锁,若是没有被其余线程占有,那么占有该锁,并调用 IOUtil 的 read 方法。
IOUtil 的 read 方法内部也调用了不少方法,有的甚至是本地方法,这里只简单介绍一下整个 read 方法的大致逻辑,具体细节留待你们自行学习。
首先判断咱们的 ByteBuffer 实例是否是一个 DirectBuffer,也就是判断当前的 ByteBuffer 实例是否是被分配在直接内存中,若是是,那么将调用readIntoNativeBuffer 方法从磁盘读取数据直接放入 ByteBuffer 实例所在的直接内存中。
不然,虚拟机将在直接内存区域分配一块内存,该内存区域的首地址存储在 var5 实例的 address 属性中。
接着从磁盘读取数据放入 var5 所表明的直接内存区域中。
最后,put 方法会将 var5 所表明的直接内存区域中的数据写入到 var1 所表明的堆内缓存区并释放临时建立的直接内存空间。
这样,咱们传入的缓存区中就成功的被读入了数据。写操做是相反的,你们能够自行类比,反正堆内数据想要到达磁盘就一定要通过堆外内存的复制过程。
第三第四部分比较简单,这里再也不赘述了。提醒一下,想要更好的使用这个通道和缓存区进行文件读写操做,你就必定得对缓存区的几个变量的值时刻把握住,position 和 limit 当前的值是什么,大体什么位置,必定得清晰,不然这个读写共存的缓存区可能会让你晕头转向。
Selector 是 Java NIO 的一个组件,它用于监听多个 Channel 的各类状态,用于管理多个 Channel。但本质上因为 FileChannel 不支持注册选择器,因此 Selector 通常被认为是服务于网络套接字通道的。
而你们口中的「NIO 是非阻塞的」,准确来讲,指的是网络编程中客户端与服务端链接交换数据的过程是非阻塞的。普通的文件读写依然是阻塞的,和 IO 是同样的,这一点可能不少初学者会懵,包括我当时也总想不通为何说 NIO 的文件读写是非阻塞的,明明就是阻塞的。
建立一个选择器通常是经过 Selector 的工厂方法,Selector.open :
而一个通道想要注册到某个选择器中,必须调整模式为非阻塞模式,例如:
以上代码是注册一个通道到选择器中的最简单版本,支持注册选择器的通道都有一个 register 方法,该方法就是用于注册当前实例通道到指定选择器的。
该方法的第一个参数就是目标选择器,第二个参数实际上是一个二进制掩码,它指明当前选择器感兴趣当前通道的哪些事件。以枚举类型提供了如下几种取值:
int OP_READ = 1 << 0;
int OP_WRITE = 1 << 2;
int OP_CONNECT = 1 << 3;
int OP_ACCEPT = 1 << 4;
这种用二进制掩码来表示某些状态的机制,咱们在讲述虚拟机类类文件结构的时候也遇到过,它就是用一个二进制位来描述一种状态。
register 方法会返回一个 SelectionKey 实例,该实例表明的就是选择器与通道的一个关联关系。你能够调用它的 selector 方法返回当前相关联的选择器实例,也能够调用它的 channel 方法返回当前关联关系中的通道实例。
除此以外,SelectionKey 的 readyOps 方法将返回当前选择感兴趣当前通道中事件中准备就绪的事件集合,依然返回的一个整型数值,也就是一个二进制掩码。
例如:
假如 readySet 的值为 13,二进制 「0000 1101」,从后向前数,第一位为 1,第三位为 1,第四位为 1,那么说明选择器关联的通道,读就绪、写就绪,链接就绪。
因此,当咱们注册一个通道到选择器以后,就能够经过返回的 SelectionKey 实例监听该通道的各类事件。
固然,一旦某个选择器中注册了多个通道,咱们不可能一个一个的记录它们注册时返回的 SelectionKey 实例来监听通道事件,选择器应当有方法返回全部注册成功的通道相关的 SelectionKey 实例。
selectedKeys 方法会返回选择器中注册成功的全部通道的 SelectionKey 实例集合。咱们经过这个集合的 SelectionKey 实例,能够获得全部通道的事件就绪状况并进行相应的处理操做。
下面咱们以一个简单的客户端服务端链接通信的实例应用一下上述理论知识:
服务端代码:
这段小程序的运行的实际效果是这样的,客户端创建请求到服务端,待请求彻底创建,客户端会去检查服务端是否有数据写回,而服务端的任务就很简单了,接受任意客户端的请求链接并为它写回一段数据。
别看整个过程很简单,但只要你有一点模糊的地方,你这个功能就不可能实现,不信你试试,尤为是加了选择器的客户端代码,更值得你们一行一行分析。提醒一点的是,你们应更多的关注于哪些方法是阻塞的,哪些是非阻塞的,这会有助于分析代码。
这其实也算一个最最简单的服务器客户端请求模型了,理解了这一点相信会有助于理解浏览器与 Web 服务器的工做原理的,这里我就再也不带你们分析了,有任何不一样见解的也欢迎给我留言,我们一块儿学习探讨。
想必你也能发现,加了选择器的代码会复杂不少,也并不必定高效于原来的代码,这实际上是由于你的功能比较简单,并不涉及大量通道处理,逻辑一旦复杂起来,选择器给你带来的好处会很是明显。
其实,NIO 中还有一块 AIO ,也就是异步 IO 并无介绍,由于异步 IO 涉及到不少其余方面知识,这里暂时不作介绍,后续文章将单独介绍异步任务等相关内容。
若是你们想学习如下路线内容,在此我向你们推荐一个架构学习交流群。交流学习群号:478030634 里面会分享一些资深架构师录制的视频录像:有Spring,MyBatis,Netty源码分析,高并发、高性能、分布式、微服务架构的原理,JVM性能优化、分布式架构等这些成为架构师必备的知识体系。还能领取免费的学习资源,目前受益良多
注:关注做者微信公众号,了解更多分布式架构、微服务、netty、MySQL、spring、、性能优化、等知识点。公众号:《Java烂猪皮》