前言
在之前的面试中,每每问到关于 Java I/O 方面的东西都感觉自己吃了大亏..所以这里抢救一下..来深入的了解一下在Java之中的 I/O 到底是怎么回事..文章可能说明类的文字有点儿多,希望能耐心读完..
什么是 I/O?
学习过计算机相关课程的童鞋应该都知道,I/O 即输入Input/ 输出Output的缩写,最容易让人联想到的就是屏幕这样的输出设备以及键盘鼠标这一类的输入设备, 其广义上的定义就是:数据在内部存储器和外部存储器或其他周边设备之间的输入和输出;
我们可以从定义上看到问题的核心就是: 数据/ 输入/ 输出 ,在Java中,主要就是涉及到磁盘 I/O 和网络 I/O 两种了;
简单理解Java 流(Stream)
通常我们说 I/O 都会涉及到诸如输入流、输出流这样的概念,那么什么是流呢?流是一个抽象但形象的概念,你可以简单理解成 一个数据的序列 ,输入流表示从一个源读取数据,输出流则表示向一个目标写数据,在Java程序中,对于数据的输入和输出都是采用 “流” 这样的方式进行的,其设备可以是文件、网络、内存等;
流具有方向性,至于是输入流还是输出流则是一个相对的概念,一般以程序为参考,如果数据的流向是程序至设备,我们成为输出流,反之我们称为输入流。
可以将流想象成一个“水流管道”,水流就在这管道中形成了,自然就出现了方向的概念。
“流”,代表了任何有能力产出数据的数据源对象或有能力接受数据的接收端对象,它屏蔽了实际的 I/O 设备中处理数据的细节——摘自《Think in Java》
参考资料:深入理解 Java中的 流 (Stream):
Java中的 I/O 类库的基本架构
I/O 问题是任何编程语言都无法回避的问题,因为 I/O 操作是人机交互的核心,是机器获取和交换信息的主要渠道,所以如何设计 I/O 系统变成了一大难题,特别是在当今大流量大数据的时代,I/O 问题尤其突出,很容易称为一个性能的瓶颈,也正因为如此,在 I/O 库上也一直在做持续的优化,例如JDK1.4引入的 NIO,JDK1.7引入的 N IO 2.0,都一定程度上的提升了 I/O 的性能;
Java的 I/O 操作类在包 java.io下,有将近80个类,这些类大概可以分成如下 4 组:
- 基于字节操作的 I/O 接口: InputStream 和 OutputStream;
- 基于字符操作的 I/O 接口:Writer 和 Reader;
- 基于磁盘操作的 I/O 接口:File;
- 基于网络操作的 I/O 接口: Socket ;
前两组主要是传输数据的数据格式,后两组主要是传输数据的方式,虽然Socket类并不在java.io包下,但这里仍然把它们划分在了一起; I/O 只是人机交互的一种手段,除了它们能够完成这个交互功能外,我们更多的应该是关注如何提高它的运行效率;
00.基于字节的 I/O 操作接口
基于字节的 I/O 操作的接口输入和输出分别对应是 InputStream 和 OutputStream,InputStream 的类层次结构如下图:
输入流根据数据类型和操作方式又被划分成若干个子类,每个子类分别处理不同操作类型,OutputStream 输出流的类层次结构也是类似,如下图所示:
这里就不详细解释每个子类如何使用了,如果感兴趣可以自己去看一下JDK的源码,而且的话从类名也能大致看出一二该类是在处理怎样的一些东西..这里需要说明两点:
1)操作数据的方式是可以组合使用的:
例如:
OutputStream out = new BufferedOutputStream(new ObjectOutputStream(new FileOutputStream("fileName"));
2)必须要指定流最终写到什么地方:
要么是写到磁盘,要么是写到网络中,但重点是你必须说明这一点,而且你会发现其实SocketOutputStream是属于FileOutputStream下的,也就是说写网络实际上也是写文件,只不过写网络还有一步需要处理,就是让底层的操作系统知道我这个数据是需要传送到其他地方而不是本地磁盘上的;
01.基于字符的 I/O 操作接口
不管是磁盘还是网络传输,最小的存储单元都是字节, 而不是字符,所以 I/O 操作的都是字节而不是字符,但是在我们日常的程序中操作的数据几乎都是字符,所以为了操作方便当然要提供一个可以直接写字符的 I/O 接口。而且从字符到字节必须经过编码转换,而这个编码又非常耗时,还经常出现乱码的问题,所以 I/O 的编码问题经常是让人头疼的问题,关于这个问题有一篇深度好文推荐一下:《深入分析 Java 中的中文编码问题》
下图是写字符的 I/O 操作接口涉及到的类,Writer 类提供了一个抽象方法 write(char cbuf[], int off, int len) 由子类去实现:
读字符的操作接口也有类似的类结构,如下图所示:
读字符的操作接口中也是 int read(char cbuf[], int off, int len),返回读到的 n 个字节数, 不管是 Writer 还是 Reader 类它们都只定义了读取或写入的数据字符的方式 ,也就是怎么写或读,但是并没有规定数据要写到哪去,写到哪去就是我们后面要讨论的基于磁盘和网络的工作机制。
01.字节与字符的转化接口
另外数据持久化或网络传输都是以字节进行的,所以必须要有字符到字节或字节到字符的转化。字符到字节需要转化,其中读的转化过程如下图所示:
InputStreamReader 类是字节到字符的转化桥梁,InputStream 到 Reader 的过程要指定编码字符集,否则将采用操作系统默认字符集,很可能会出现乱码问题。StreamDecoder 正是完成字节到字符的解码的实现类。也就是当你用如下方式读取一个文件时:
FileReader 类就是按照上面的工作方式读取文件的,FileReader 是继承了 InputStreamReader 类,实际上是读取文件流,然后通过 StreamDecoder 解码成 char,只不过这里的解码字符集是默认字符集。
写入也是类似的过程如下图所示:
通过 OutputStreamWriter 类完成,字符到字节的编码过程,由 StreamEncoder 完成编码过程。
磁盘 I/O 的工作机制
在介绍 Java 读取和写入磁盘文件之前,先来看看应用程序访问文件有哪几种方式;
几种访问文件的方式
我们知道, 读取和写入文件 I/O 操作都调用的是操作系统提供给我们的接口 ,因为磁盘设备是归操作系统管的,而只要是系统调用都可能存在内核空间地址和用户空间地址切换的问题,这是为了保证用户进程不能直接操作内核,保证内核的安全而设计的,现代的操作系统将虚拟空间划分成了内核空间和用户空间两部分并实现了隔离,但是这样虽然保证了内核程序运行的安全性,但是也必然存在数据可能需要从内核空间向用户用户空间复制的问题;
如果遇到非常耗时的操作,如磁盘 I/O,数据从磁盘复制到内核空间,然后又从内核空间复制到用户空间,将会非常耗时, 这时操作系统为了加速 I/O 访问,在内核空间使用 缓存 机制, 也就是将从磁盘读取的文件按照一定的组织方式进行缓存,入股用户程序访问的是同一段磁盘地址的空间数据,那么操作系统将从内核缓存中直接取出返回给用户程序,这样就可以减少 I/O 的响应时间;
00. 标准访问文件的方式
读取的方式是,当应用程序调用read()接口时:
- ①操作系统首先检查在 内核的高速缓存中是否存在需要的数据 ,如果有,那么直接从缓存中返回;
- ②如果没有,则 从磁盘中读取,然后缓存在操作系统的缓存中;
写入的方式是,当应用程序调用write()接口时:
- 从用户地址空间复制到内核地址空间的缓存中 ,这时对用户程序来说写操作就已经完成了,至于什么时候在写到磁盘中由操作系统决定,除非显示地调用了 sync 同步命令;
01.直接 I/O 方式
所谓的直接 I/O 的方式就是应用程序直接访问磁盘数据,而不经过操作系统内核数据缓冲区, 这样做的目的是减少一次从内核缓冲区到用户程序缓存的数据复制;
这种访问文件的方式通常是在对数据的缓存管理由应用程序实现的数据库管理系统中,如在数据库管理系统中,系统明确地知道应该缓存哪些数据,应该失效哪些数据,还可以对一些热点数据做预加载,提前将热点数据加载到内存,可以加速数据的访问效率,而这些情况如果是交给操作系统进行缓存,那么操作系统将不知道哪些数据是热点数据,哪些是只会访问一次的数据,因为它只是简单的缓存最近一次从磁盘读取的数据而已;
但是直接 I/O 也有负面影响,如果访问的数据不再应用程序缓存之中,那么每次数据都会直接从磁盘进行加载,这种直接加载会非常缓慢,因此直接 I/O 通常与 异步 I/O 进行结合以达到更好的性能;
10.内存映射的方式
内存映射是指将硬盘上文件的位置与进程逻辑地址空间中一块大小相同的区域一一对应,当要访问内存中一段数据时,转换为访问文件的某一段数据。 这种方式的目的同样是减少数据在用户空间和内核空间之间的拷贝操作。 当大量数据需要传输的时候,采用内存映射方式去访问文件会获得比较好的效率。
同步和异步访问文件的方式
另外还有两种方式,一种是数据的读取和写入都是同步操作的同步方式,另一种是是当访问数据的 线程 发出请求之后,线程会接着去处理其他事情,而不是阻塞等待的异步访问方式,但从笔者就《深入分析 Java Web技术内幕》一书中的内容来看,这两种方式更像是对标准访问方式的一个具体说明,是标准访问方式对应的两种不同处理方法,知道就好了…
Java 访问磁盘文件
我们知道数据在磁盘的唯一最小描述就是文件,也就是说上层应用程序只能通过文件来操作磁盘上的数据,文件也是操作系统和磁盘驱动器交互的一个最小单元。值得注意的是 Java 中通常的 File 并不代表一个真实存在的文件对象,当你通过指定一个路径描述符时,它就会返回一个代表这个路径相关联的一个虚拟对象,这个可能是一个真实存在的文件或者是一个包含多个文件的目录。为何要这样设计?因为大部分情况下,我们并不关心这个文件是否真的存在,而是关心这个文件到底如何操作。例如我们手机里通常存了几百个朋友的电话号码,但是我们通常关心的是我有没有这个朋友的电话号码,或者这个电话号码是什么,但是这个电话号码到底能不能打通,我们并不是时时刻刻都去检查,而只有在真正要给他打电话时才会看这个电话能不能用。也就是使用这个电话记录要比打这个电话的次数多很多。
何时真正会要检查一个文件存不存?就是在真正要读取这个文件时,例如 FileInputStream 类都是操作一个文件的接口,注意到在创建一个 FileInputStream 对象时,会创建一个 FileDescriptor 对象,其实这个对象就是真正代表一个存在的文件对象的描述,当我们在操作一个文件对象时可以通过 getFD() 方法获取真正操作的与底层操作系统关联的文件描述。例如可以调用 FileDescriptor.sync() 方法将操作系统缓存中的数据强制刷新到物理磁盘中。
下面以上文读取文件的程序为例,介绍下如何从磁盘读取一段文本字符。如下图所示:
当传入一个文件路径,将会根据这个路径创建一个 File 对象来标识这个文件,然后将会根据这个 File 对象创建真正读取文件的操作对象,这时将会真正创建一个关联真实存在的磁盘文件的文件描述符 FileDescriptor,通过这个对象可以直接控制这个磁盘文件。由于我们需要读取的是字符格式,所以需要 StreamDecoder 类将 byte 解码为 char 格式,至于如何从磁盘驱动器上读取一段数据,由操作系统帮我们完成。至于操作系统是如何将数据持久化到磁盘以及如何建立数据结构需要根据当前操作系统使用何种文件系统来回答,至于文件系统的相关细节可以参考另外的文章。
参考文章:深入分析 Java I/O 的工作机制
关于这一part,我们只需要了解一下就可以,我也是直接复制就完事儿…
Java 序列化 技术
Java序列化就是将一个对象转化成一串二进制表示的字节数组,通过保存或转移这些字节数据来达到持久化的目的。 需要持久化,对象必须继承 java.io.Serializable 接口,或者将其转为字节数组,用于网络传输;
一个实际的序列化例子
第一步:创建一个用于序列化的对象
为了具体说明序列化在Java中是如何运作的,我们来写一个实际的例子,首先我们来写一个用于序列化的对象,然后实现上述的接口:
注意:一个类的对象想要序列化成功,必须满足两个条件
- ①实现上述的接口;
- ②保证该类的所有属性必须都是可序列化的,如果不希望某个属性序列化(例如一些敏感信息),可以加上transient关键字;
第二步:序列化对象
如下的代码完成了实例化一个 People 对象并其序列化到D盘的根目录下的一个操作,这里呢按照 Java 的标准约定将文件的后缀写成 .ser 的样子,你也可以写成其他的…
第三步:反序列化对象
下面的程序完成了对刚才我们序列化的文件还原成一个People对象的过程,并获取了其中的参数,但是注意,由于我们希望 age 属性是短暂的加入了transient关键字, 所以我们无法获取到序列化时 People 的 age 属性:
输出结果如下:
Deserialized People...
Name: 我没有三颗心脏
Age:
serialVersionUID的作用
上述的例子中我们完成了对一个 People 对象序列化和反序列化的过程,我们现在来做一点简单的修改,例如把age字段的transient关键字去掉:
然后我们再运行我们刚才反序列化的代码,会发现,这个时候程序竟然报错了,说是serialVersionUID不一致:
事实上,如果你经常看别人的代码的话,或许会有留意到诸如这样的代码:
private static final long serialVersionUID = L;
就这一长串的东西也不知道是在干嘛的,但这其实 是为了保证序列化版本的兼容性,即在版本升级后序列化仍保持对象的唯一性; 我们通过上述的修改也感受到了其中的一二, 但是问题是:我们并没有在需要序列化的对象中写任何关于这个UID的代码呀?
这是个有趣的问题,通常情况下,如果我们实现了序列化接口,但是没有自己显式的声明这个UID的话,那么JVM就会根据该类的类名、属性名、方法名等自己计算出一个独一无二的变量值,然后将这个变量值一同序列化到文件之中,而在反序列化的时候同样,会根据该类计算出一个独一无二的变量然后进行比较,不一致就会报错,但是我怀着强烈的好奇心去反编译了一下.class文件,并没有发现编译器写了UDI这一类的东西,我看《深入分析 Java Web 技术内幕》中说,实际上是写到了二进制文件里面了;
- 不显式声明的缺点: 一旦写好了某一个类,那么想要修改就不行了,所以我们最好自己显式的去声明;
- 显式声明的方式: ①使用默认的1L作用UID;②根据类名、接口名等生成一个64位的哈希字段,现在的编译器如IDEA、Eclipse都有这样的功能,大家感兴趣去了解下;
序列化用来干什么?
虽然我们上面的程序成功将一个对象序列化保存到磁盘,然后从磁盘还原,但是这样的功能到底可以应用在哪些场景?到底可以干一些什么样的事情呢?下面举一些在实际应用中的例子:
- Web服务器中保存Session对象,如Tomcat会在服务器关闭时把session序列化存储到一个名为session.ser的文件之中,这个过程称为session的钝化;
- 网络上传输对象,如分布式应用等;
关于序列化的一些细节
1.如果一个类没有实现Serializable接口,但是它的基类实现了,那么这个类也是可以序列化的;
2.相反,如果一个类实现了Serializable接口,但是它的父类没有实现,那么这个类还是可以序列化(Object是所有类的父类),但是序列化该子类对象,然后反序列化后输出父类定义的某变量的数值,会发现该变量数值与序列化时的数值不同(一般为null或者其他默认值),而且这个父类里面必须有无参的构造方法,不然子类反序列化的时候会报错。
了解到这里就可以了,更多的细节感兴趣的童鞋可以自行去搜索引擎搜索..
网络 I/O 工作机制
数据从一台主机发送到网络中的另一台主机需要经过很多步骤,首先双方需要有沟通的意向,然后要有能够沟通的物理渠道(物理链路),其次,还要保障双方能够正常的进行交流,例如语言一致的问题、说话顺序的问题等等等;
Java Socket 的工作机制
看到有地方说: 网络 I/O 的实质其实就是对 Socket 的读取; 那Socket 这个概念没有对应到一个具体的实体,它是描述计算机之间完成相互通信一种抽象功能。打个比方,可以把 Socket 比作为两个城市之间的交通工具,有了它,就可以在城市之间来回穿梭了。交通工具有多种,每种交通工具也有相应的交通规则。Socket 也一样,也有多种。大部分情况下我们使用的都是基于 TCP /IP 的流套接字,它是一种稳定的通信协议。
下图是典型的基于 Socket 的通信的场景:
主机 A 的应用程序要能和主机 B 的应用程序通信,必须通过 Socket 建立连接,而建立 Socket 连接必须需要底层 TCP/IP 协议来建立 TCP 连接。建立 TCP 连接需要底层 IP 协议来寻址网络中的主机。我们知道网络层使用的 IP 协议可以帮助我们根据 IP 地址来找到目标主机,但是一台主机上可能运行着多个应用程序,如何才能与指定的应用程序通信就要通过 TCP 或 UPD 的地址也就是端口号来指定。这样就可以通过一个 Socket 实例唯一代表一个主机上的一个应用程序的通信链路了。
建立通信链路
当客户端要与服务端通信,客户端首先要创建一个 Socket 实例,操作系统将为这个 Socket 实例分配一个没有被使用的本地端口号,并创建一个包含本地和远程地址和端口号的套接字数据结构,这个数据结构将一直保存在系统中直到这个连接关闭。在创建 Socket 实例的构造函数正确返回之前,将要进行 TCP 的三次握手协议 ,TCP 握手协议完成后,Socket 实例对象将创建完成,否则将抛出 IOException 错误。
与之对应的服务端将创建一个 ServerSocket 实例,ServerSocket 创建比较简单只要指定的端口号没有被占用,一般实例创建都会成功,同时操作系统也会为 ServerSocket 实例创建一个底层数据结构,这个数据结构中包含指定监听的端口号和包含监听地址的通配符,通常情况下都是“*”即监听所有地址。之后当调用 accept() 方法时,将进入阻塞状态,等待客户端的请求。当一个新的请求到来时,将为这个连接创建一个新的套接字数据结构,该套接字数据的信息包含的地址和端口信息正是请求源地址和端口。这个新创建的数据结构将会关联到 ServerSocket 实例的一个未完成的连接数据结构列表中,注意这时服务端与之对应的 Socket 实例并没有完成创建,而要等到与客户端的三次握手完成后,这个服务端的 Socket 实例才会返回,并将这个 Socket 实例对应的数据结构从未完成列表中移到已完成列表中。所以 ServerSocket 所关联的列表中每个数据结构,都代表与一个客户端的建立的 TCP 连接。
数据传输
传输数据是我们建立连接的主要目的,如何通过 Socket 传输数据,下面将详细介绍。
当连接已经建立成功,服务端和客户端都会拥有一个 Socket 实例,每个 Socket 实例都有一个 InputStream 和 OutputStream,正是通过这两个对象来交换数据。同时我们也知道网络 I/O 都是以字节流传输的。当 Socket 对象创建时,操作系统将会为 InputStream 和 OutputStream 分别分配一定大小的缓冲区,数据的写入和读取都是通过这个缓存区完成的。写入端将数据写到 OutputStream 对应的 SendQ 队列中,当队列填满时,数据将被发送到另一端 InputStream 的 RecvQ 队列中,如果这时 RecvQ 已经满了,那么 OutputStream 的 write 方法将会阻塞直到 RecvQ 队列有足够的空间容纳 SendQ 发送的数据。值得特别注意的是,这个缓存区的大小以及写入端的速度和读取端的速度非常影响这个连接的数据传输效率,由于可能会发生阻塞,所以网络 I/O 与磁盘 I/O 在数据的写入和读取还要有一个协调的过程,如果两边同时传送数据时可能会产生死锁,在后面 NIO 部分将介绍避免这种情况。
NIO 的工作方式
BIO 带来的挑战
BIO 即阻塞 I/O,不管是磁盘 I/O 还是网络 I/O,数据在写入 OutputStream 或者从 InputStream 读取时都有可能会阻塞。 一旦有线程阻塞将会失去 CPU 的使用权,这在当前的大规模访问量和有性能要求情况下是不能接受的。 虽然当前的网络 I/O 有一些解决办法,如一个客户端一个处理线程,出现阻塞时只是一个线程阻塞而不会影响其它线程工作,还有为了减少系统线程的开销,采用线程池的办法来减少线程创建和回收的成本,但是有一些使用场景仍然是无法解决的。 如当前一些需要大量 HTTP 长连接的情况 ,像淘宝现在使用的 Web 旺旺项目,服务端需要同时保持几百万的 HTTP 连接,但是并不是每时每刻这些连接都在传输数据,这种情况下不可能同时创建这么多线程来保持连接。 即使线程的数量不是问题,仍然有一些问题还是无法避免的。 如这种情况, 我们想给某些客户端更高的服务优先级 ,很难通过设计线程的优先级来完成,另外一种情况是, 我们需要让每个客户端的请求在服务端可能需要访问一些竞争资源,由于这些客户端是在不同线程中,因此需要同步,而往往要实现这些同步操作要远远比用单线程复杂很多。 以上这些情况都说明,我们需要另外一种新的 I/O 操作方式。
NIO 的工作机制
很多人都把NIO翻译成New IO,但我更觉得No-Block IO更接近它的本意,也就是非阻塞式IO,它虽然是非阻塞式的,但它是同步的,我们先看一下 NIO 涉及到的关联类图,如下:
上图中有两个关键类: Channel 和 Selector ,它们是 NIO 中两个核心概念。我们还用前面的城市交通工具来继续比喻 NIO 的工作方式,这里的 Channel 要比 Socket 更加具体,它可以比作为某种具体的交通工具,如汽车或是高铁等,而 Selector 可以比作为一个车站的车辆运行调度系统,它将负责监控每辆车的当前运行状态:是已经出战还是在路上等等,也就是它可以轮询每个 Channel 的状态。 这里还有一个 Buffer 类 ,它也比 Stream 更加具体化,我们可以将它比作为车上的座位,Channel 是汽车的话就是汽车上的座位,高铁上就是高铁上的座位,它始终是一个具体的概念,与 Stream 不同。Stream 只能代表是一个座位,至于是什么座位由你自己去想象,也就是你在去上车之前并不知道,这个车上是否还有没有座位了,也不知道上的是什么车,因为你并不能选择,这些信息都已经被封装在了运输工具(Socket)里面了,对你是透明的。
NIO 引入了 Channel、Buffer 和 Selector 就是想把这些信息具体化,让程序员有机会控制它们 ,如:当我们调用 write() 往 SendQ 写数据时,当一次写的数据超过 SendQ 长度是需要按照 SendQ 的长度进行分割,这个过程中需要有将用户空间数据和内核地址空间进行切换,而这个切换不是你可以控制的。而在 Buffer 中我们可以控制 Buffer 的 capacity,并且是否扩容以及如何扩容都可以控制。
理解了这些概念后我们看一下,实际上它们是如何工作的,下面是典型的一段 NIO 代码:
调用 Selector 的静态工厂创建一个选择器,创建一个服务端的 Channel 绑定到一个 Socket 对象,并把这个通信信道注册到选择器上,把这个通信信道设置为非阻塞模式。然后就可以调用 Selector 的 selectedKeys 方法来检查已经注册在这个选择器上的所有通信信道是否有需要的事件发生,如果有某个事件发生时,将会返回所有的 SelectionKey,通过这个对象 Channel 方法就可以取得这个通信信道对象从而可以读取通信的数据,而这里读取的数据是 Buffer,这个 Buffer 是我们可以控制的缓冲器。
在上面的这段程序中,是将 Server 端的监听连接请求的事件和处理请求的事件放在一个线程中,但是在实际应用中,我们通常会把它们放在两个线程中, 一个线程专门负责监听客户端的连接请求,而且是阻塞方式执行的;另外一个线程专门来处理请求,这个专门处理请求的线程才会真正采用 NIO 的方式 ,像 Web 服务器 Tomcat 和 Jetty 都是这个处理方式,关于 Tomcat 和 Jetty 的 NIO 处理方式可以参考文章《 Jetty 的工作原理和与 Tomcat 的比较》。
下图是描述了基于 NIO 工作方式的 Socket 请求的处理过程:
上图中的 Selector 可以同时监听一组通信信道(Channel)上的 I/O 状态,前提是这个 Selector 要已经注册到这些通信信道中。选择器 Selector 可以调用 select() 方法检查已经注册的通信信道上的是否有 I/O 已经准备好,如果没有至少一个信道 I/O 状态有变化,那么 select 方法会阻塞等待或在超时时间后会返回 0。上图中如果有多个信道有数据,那么将会将这些数据分配到对应的数据 Buffer 中。所以关键的地方是有一个线程来处理所有连接的数据交互,每个连接的数据交互都不是阻塞方式,所以可以同时处理大量的连接请求。
Buffer 的工作方式
上面介绍了 Selector 将检测到有通信信道 I/O 有数据传输时,通过 selelct() 取得 SocketChannel,将数据读取或写入 Buffer 缓冲区。下面讨论一下 Buffer 如何接受和写出数据?
Buffer 可以简单的理解为一组基本数据类型的元素列表,它通过几个变量来保存这个数据的当前位置状态,也就是有四个索引。如下表所示:
索引
说明
在实际操作数据时它们有如下关系图:
我们通过 ByteBuffer.allocate(11) 方法创建一个 11 个 byte 的数组缓冲区,初始状态如上图所示,position 的位置为 0,capacity 和 limit 默认都是数组长度。当我们写入 5 个字节时位置变化如下图所示:
这时底层操作系统就可以从缓冲区中正确读取这 5 个字节数据发送出去了。在下一次写数据之前我们在调一下 clear() 方法。缓冲区的索引状态又回到初始位置。
这里还要说明一下 mark,当我们调用 mark() 时,它将记录当前 position 的前一个位置,当我们调用 reset 时,position 将恢复 mark 记录下来的值。
还有一点需要说明,通过 Channel 获取的 I/O 数据首先要经过操作系统的 Socket 缓冲区再将数据复制到 Buffer 中,这个的操作系统缓冲区就是底层的 TCP 协议关联的 RecvQ 或者 SendQ 队列,从操作系统缓冲区到用户缓冲区复制数据比较耗性能,Buffer 提供了另外一种直接操作操作系统缓冲区的的方式即 ByteBuffer.allocateDirector(size),这个方法返回的 byteBuffer 就是与底层存储空间关联的缓冲区,它的操作方式与 linux2.4 内核的 sendfile 操作方式类似。
Java NIO 实例
上面从 NIO 中引入了一些概念,下面我们对这些概念再来进行简单的复述和补充:
- 缓冲区Buffer: 缓冲区是一个对象,里面存的是数据,NIO进行通讯,传递的数据,都包装到Buffer中,Buffer是一个抽象类。子类有ByteBuffer、CharBuffer等,常用的是字节缓冲区,也就是ByteBuffer;
- 通道Channel: channel是一个通道,通道就是通流某种物质的管道,在这里就是通流数据,他和流的不同之处就在于,流是单向的,只能向一个方向流动,而通道是一个管道,有两端,是双向的,可以进行读操作,也可以写操作,或者两者同时进行;
- 多路复用器Selector: 多路复用器是一个大管家,他管理着通道,通道把自己注册到Selector上面,Selector会 轮询 注册到自己的管道,通过判断这个管道的不同的状态,来进行相应的操作;
NIO 工作机制的核心思想就是: 客户端和服务器端都是使用的通道,通道具有事件,可以将事件注册到多路复选器上,事件有就绪和非就绪两种状态,就绪的状态会放到多路复选器的就绪键的集合中,起一个线程不断地去轮询就绪的状态,根据不同的状态做不同的处理
参考资料:
NIO 和 IO 的主要区别
- 面向流与面向缓冲.
- Java NIO和IO之间第一个最大的区别是,IO是面向流的,NIO是面向缓冲区的。Java IO面向流意味着每次从流中读一个或多个字节,直至读取所有字节,它们没有被缓存在任何地方。此外,它不能前后移动流中的数据。如果需要前后移动从流中读取的数据,需要先将它缓存到一个缓冲区。Java NIO的缓冲导向方法略有不同。数据读取到一个它稍后处理的缓冲区,需要时可在缓冲区中前后移动。这就增加了处理过程中的灵活性。
- 阻塞与非阻塞IO
- Java IO的各种流是阻塞的。这意味着,当一个线程调用read() 或 write()时,该线程被阻塞,直到有一些数据被读取,或数据完全写入。该线程在此期间不能再干任何事情了。Java NIO的非阻塞模式,使一个线程从某通道发送请求读取数据,但是它仅能得到目前可用的数据,如果目前没有数据可用时,该线程可以继续做其他的事情。非阻塞写也是如此。一个线程请求写入一些数据到某通道,但不需要等待它完全写入,这个线程同时可以去做别的事情。线程通常将非阻塞IO的空闲时间用于在其它通道上执行IO操作,所以一个单独的线程现在可以管理多个输入和输出通道(channel)。
- 选择器(Selectors)
- Java NIO的选择器允许一个单独的线程来监视多个输入通道,你可以注册多个通道使用一个选择器,然后使用一个单独的线程来“选择”通道:这些通道里已经有可以处理的输入,或者选择已准备写入的通道。这种选择机制,使得一个单独的线程很容易来管理多个通道。
Java AIO 简单了解
AIO就是异步非阻塞IO,A就是asynchronous的意思,因为NIO1.0虽然面向缓冲,利用多路复选器实现了同步非阻塞IO,可是在NIO1.0中需要使用一个线程不断去轮询就绪集合,开销也是比较大的,所以在jdk1.7中扩展了NIO,称之为NIO2.0,NIO2.0中引入了AIO,此外NIO2.0中还引入了异步文件通道,那么究竟是怎么实现异步的呢?
AIO 有三个特点,它的特点也可以说明它是如何完成异步这样的操作的:
- ①读完了再通知我;
- ②不会加快 I/O,只是在读完后进行通知;
- ③使用回调函数,进行业务处理;
AIO 的核心原理就是: 对客户端和服务器端的各种操作进行回调函数的注册(通过实现一个CompletionHandler接口,其中定义了一个completed的成功操作方法和一个fail的失败方法)。在完成某个操作之后,就会自己去调用该注册到该操作的回调函数,达到异步的效果。
BIO/ NIO/ AIO 的简单理解
我们在这里假设一个烧了一排开水的场景, BIO(同步阻塞IO) 的做法就是,叫一个线程停留在一个水壶那,直到这个水壶烧开我再去处理下一个水壶; NIO(准备好再通知我,同步非阻塞IO) 的做法就是叫一个线程不断地去询问每个水壶的状态,看看是否有水壶的状态发生了变化,变化则再去做相应的处理; AIO(读完了再通知我,异步非阻塞IO) 的做法是在每个水壶上都安装一个装置,当水壶烧开之后就会自动通知我水壶烧开了让我做相应的处理;
如果还觉得理解起来有困难的童鞋建议阅读以下这篇文章,相信会有收获:编程技术/2016-06-25-十分钟了解BIO-NIO-AIO.html
BIO、NIO、AIO适用场景分析
- BIO方式适用于连接数目比较小且固定的架构 ,这种方式对服务器资源要求比较高,并发局限于应用中,JDK1.4以前的唯一选择,但程序直观简单易理解。
- NIO方式适用于连接数目多且连接比较短(轻操作)的架构 ,比如聊天服务器,并发局限于应用中,编程比较复杂,JDK1.4开始支持。
- AIO方式使用于连接数目多且连接比较长(重操作)的架构 ,比如相册服务器,充分调用OS参与并发操作,编程比较复杂,JDK7开始支持。
简单总结
这篇文章大量复制粘贴到《深入分析 Java Web 技术内幕》第二节“深入分析 Java I/O 的工作机制”的内容,没办法确实很多描述性的概念以及说明,自己的说明也没有达到用简单语言能描述复杂事物的程度..所以可能看起来这篇文章会有那么点儿难以下咽..我自己的话也是为了写着一篇文章查了很多资料,书也是翻了很多很多遍才对Java 中的 I/O 相关的知识有所熟悉,不过耗费的时间也是值得的,同时也希望观看文章的你能够有所收获,也欢迎各位指正!