Java I/O 工作机制(一) —— Java 的 I/O 类库的基本架构

Java 的 I/O 类库的基本架构

Java 的 I/O 操作类在包 java.io 下,有将近 80 个类。

按数据格式分类:

  • 面向字节(Byte)操作的 I/O 接口:InputStream 和 OutputStream
  • 面向字符(Character)操作的 I/O 接口:Writer 和 Reader

按作用位置分类:

  • 基于磁盘操作的 I/O 接口:File
  • 基于网络操作的 I/O 接口:Socket(不在java.io中)

1. IO数据格式

(1)面向字节:操作以8位为单位对二进制数据进行操作,不对数据进行转换。这些类都是InputStream 和 OutputStream的子类。以InputStream/OutputStream为后缀的类都是字节流,可以处理所有类型的数据。

(2)面向字符:操作以字符为单位,读时将二进制数据转换为字符,写时将字符转换为二进制数据Writer 和 Reader的子类,以Writer/Reader为后缀的都是字符流。

硬盘上所有的文件都是以字节形式保存,字符只在内存中才会形成。即只在处理纯文本文件时,优先考虑使用字符流,除此之外都用字节流。

InputStream 相关类层次结构:(OutputStream类似)

Writer 相关类层次结构:(Reader类似)

其中:

字符流:

  • InputStreamReader/OutputStreamWriter 是字节流转化为字符流的桥转换器。
  • BufferReader/BufferWriter 逐行读写流,可用于较大的文本文件。是过滤流,需要用其他的节点流做参数构造对象。

字节流:

  • FileInputStream/FileOutputStream 文件输入输出流。
  • PipedInputStream/PipedOutputStream 管道里,线程交互时使用。
  • ObjectInputStream/ObjectOutputStream 对象流,实现对象序列化

读写操作实例:

/**
 * 使用FileReader进行读取文件,然后FileWriter写入另一个文件
 */
@Test
public void testFileReaderAndFileWriter() throws IOException {
    FileReader fileReader = new FileReader("h:\haha.txt");
    char[] buff = new char[512];
    StringBuffer stringBuffer = new StringBuffer();

    while (fileReader.read(buff) > 0) {
        stringBuffer.append(buff);
    }
    System.out.println(stringBuffer.toString());

    FileWriter fileWriter = new FileWriter("h:\haha2.txt");
    fileWriter.write(stringBuffer.toString().trim());

    fileWriter.close();
    System.out.println("写入文件成功");
}
/**
 * 使用InputStreamReader进行读取文件,然后用OutputStreamWriter写入文件
 */
@Test
public void testInputStreamReader() throws IOException {
    //操作数据的方式是可以组合的,此处FileInputStream读出的字节流用InputStreamReader转化为了字符流对象
    InputStreamReader inputStreamReader = new InputStreamReader(new FileInputStream("h:\haha.txt"), "utf-8");
    char[] buff = new char[512];
    StringBuffer stringBuffer = new StringBuffer();
    while (inputStreamReader.read(buff) > 0) {
        stringBuffer.append(buff);
    }
    System.out.println(stringBuffer.toString());
  
  //写文件时,要指定写入的地方(网络或本地)、路径   OutputStreamWriter outputStreamWriter
= new OutputStreamWriter(new FileOutputStream("h:\haha2.txt"), "utf-8");   outputStreamWriter.write(stringBuffer.toString().trim());   outputStreamWriter.close(); } @Test public void testIntputStream2() throws IOException { InputStreamReader inputStreamReader = new InputStreamReader(new StringBufferInputStream("hello world")); char[] buff = new char[512]; int n = inputStreamReader.read(buff); System.out.println(n); System.out.println(buff); }

FileReader类继承了InputStreamReader,FileReader读取文件流,通过StreamDecoder解码成char,其解码字符集使用的是默认字符集。在Java中,我们应该使用File对象来判断某个文件是否存在,如果我们用FileOutputStream或者FileWriter打开,那么它肯定会被覆盖。

2. IO发生位置

(1) 磁盘IO工作机制

前面介绍了基本的 Java I/O 的操作接口,这些接口主要定义了如何操作数据,以及介绍了操作两种数据结构:字节和字符的方式。还有一个关键问题就是数据写到何处,其中一个主要方式就是将数据持久化到物理磁盘,下面将介绍如何将数据持久化到物理磁盘的过程。

数据在磁盘的唯一最小描述是文件,也就是说上层应用程序只能通过文件来操作磁盘上的数据,文件也是操作系统和磁盘驱动器交互的一个最小单元。值得注意的是 Java 中通常的 File 并不代表一个真实存在的文件对象,当你通过指定一个路径描述符时,它就会返回一个代表这个路径相关联的一个虚拟对象,这个可能是一个真实存在的文件或者是一个包含多个文件的目录。

何时真正会要检查一个文件存不存?就是在真正要读取这个文件时,例如 FileInputStream 类都是操作一个文件的接口,注意到在创建一个 FileInputStream 对象时,会创建一个 FileDescriptor 对象,其实这个对象就是真正代表一个存在的文件对象的描述,当我们在操作一个文件对象时可以通过 getFD() 方法获取真正操作的与底层操作系统关联的文件描述。例如可以调用 FileDescriptor.sync() 方法将操作系统缓存中的数据强制刷新到物理磁盘中。

从磁盘读取文件过程:

当传入一个文件路径,将会根据这个路径创建一个 File 对象来标识这个文件,然后将会根据这个 File 对象创建真正读取文件的操作对象,这时将会真正创建一个关联真实存在的磁盘文件的文件描述符 FileDescriptor,通过这个对象可以直接控制这个磁盘文件。由于我们需要读取的是字符格式,所以需要 StreamDecoder 类将 byte 解码为 char 格式,至于如何从磁盘驱动器上读取一段数据,由操作系统帮我们完成。

(2)网络IO工作机制(Socket)

Socket 描述计算机之间完成相互通信一种抽象功能。Socket 也一样有多种,大部分情况下我们使用的都是基于 TCP/IP 的流套接字,它是一种稳定的通信协议。

下典型的基于 Socket 的通信的场景:

主机 A 的应用程序要能和主机 B 的应用程序通信,必须通过 Socket 建立连接,而建立 Socket 连接必须需要底层 TCP/IP 协议来建立 TCP 连接。建立 TCP 连接需要底层 IP 协议来寻址网络中的主机。我们知道网络层使用的 IP 协议可以帮助我们根据 IP 地址来找到目标主机,但是一台主机上可能运行着多个应用程序,如何才能与指定的应用程序通信就要通过 TCP 或 UPD 的地址也就是端口号来指定。这样就可以通过一个 Socket 实例唯一代表一个主机上的一个应用程序的通信链路了。

(TCP/UDP:找端口号,从而与应用程序通信。IP:找主机)

建立通信链路

当客户端要与服务端通信,客户端首先要创建一个 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 部分将介绍避免这种情况。

3. IO调优

提升磁盘 I/O 性能通常的方法:

  1. 增加缓存,减少磁盘访问次数
  2. 优化磁盘的管理系统,设计最优的磁盘访问策略,以及磁盘的寻址策略(在底层操作系统层面考虑)
  3. 设计合理的磁盘存储数据块,以及访问这些数据块的策略(在应用层面考虑)。如我们可以给存放的数据设计索引,通过寻址索引来加快和减少磁盘的访问,还有可以采用异步和非阻塞的方式加快磁盘的访问效率。
  4. 应用合理的 RAID 策略提升磁盘 IO

网络 I/O 优化通常有一些基本处理原则:

  1. 减少网络交互次数:1)在需要网络交互的两端会设置缓存,比如 Oracle 的 JDBC 驱动程序提供了对查询的 SQL 结果的缓存,在客户端和数据库端都有,可以有效的减少对数据库的访问。2)合并访问请求:如在查询数据库时,我们要查 10 个 id,我可以每次查一个 id,也可以一次查 10 个 id。再比如在访问一个页面时通过会有多个 js 或 css 的文件,我们可以将多个 js 文件合并在一个 HTTP 链接中,每个文件用逗号隔开,然后发送到后端 Web 服务器根据这个 URL 链接,再拆分出各个文件,然后打包再一并发回给前端浏览器。这些都是常用的减少网络 I/O 的办法。
  2. 减少网络传输数据量的大小:减少网络数据量的办法通常是将数据压缩后再传输,如 HTTP 请求中,通常 Web 服务器将请求的 Web 页面 gzip 压缩后在传输给浏览器。还有就是通过设计简单的协议,尽量通过读取协议头来获取有用的价值信息。
  3. 尽量减少编码:通常在网络 I/O 中数据传输都是以字节形式的,也就是通常要序列化。但是我们发送要传输的数据都是字符形式的,从字符到字节必须编码。但是这个编码过程是比较耗时的,所以在要经过网络 I/O 传输时,尽量直接以字节形式发送。也就是尽量提前将字符转化为字节,或者减少字符到字节的转化过程。
  4. 根据应用场景设计合适的交互方式:所谓的交互场景主要包括同步与异步、阻塞与非阻塞方式。

参考链接:https://www.ibm.com/developerworks/cn/java/j-lo-javaio/

原文地址:https://www.cnblogs.com/IUbanana/p/7245103.html