Java 的 I/O 大概可以分成以下几类:

  • 磁盘操作:File
  • 字节操作:InputStream 和 OutputStream
  • 字符操作:Reader 和 Writer
  • 对象操作:Serializable
  • 网络操作:Socket
  • 新的输入/输出:NIO

本地操作

File

Java I/O - 图1

  1. // 遍历一个目录下所有文件
  2. public static void listAllFiles(File dir) {
  3. if (dir == null || !dir.exists()) {
  4. return;
  5. }
  6. if (dir.isFile()) {
  7. System.out.println(dir.getName());
  8. return;
  9. }
  10. for (File file : dir.listFiles()) {
  11. listAllFiles(file);
  12. }
  13. }

字节流

Java I/O - 图2

// 实现文件复制
public static void copyFile(String src, String dist) throws IOException {
    FileInputStream in = new FileInputStream(src);
    FileOutputStream out = new FileOutputStream(dist);

    byte[] buffer = new byte[20 * 1024];
    int cnt;

    // read() 最多读取 buffer.length 个字节
    // 返回的是实际读取的个数
    // 返回 -1 的时候表示读到 eof,即文件尾
    while ((cnt = in.read(buffer, 0, buffer.length)) != -1) {
        out.write(buffer, 0, cnt);
    }

    in.close();
    out.close();
}

Java I/O 使用了装饰者模式来实现。以 InputStream 为例,

  • InputStream 是抽象组件;
  • FileInputStream 是 InputStream 的子类,属于具体组件,提供了字节流的输入操作;
  • FilterInputStream 属于抽象装饰者,装饰者用于装饰组件,为组件提供额外的功能。例如 BufferedInputStream 为 FileInputStream 提供缓存的功能。

image.png

实例化一个具有缓存功能的字节流对象时,只需要在 FileInputStream 对象上再套一层 BufferedInputStream 对象即可。

FileInputStream fileInputStream = new FileInputStream(filePath);
BufferedInputStream bufferedInputStream = new BufferedInputStream(fileInputStream);

DataInputStream 装饰者提供了对更多数据类型进行输入的操作,比如 int、double 等基本类型。

字符流

  • 不管是磁盘还是网络传输,最小的存储单元都是字节,而不是字符。但是在程序中操作的通常是字符形式的数据,因此需要提供对字符进行操作的方法。

Java I/O - 图4

// 逐行读取文本内容并输出
public static void readFileContent(String filePath) throws IOException {

    FileReader fileReader = new FileReader(filePath);
    BufferedReader bufferedReader = new BufferedReader(fileReader);

    String line;
    while ((line = bufferedReader.readLine()) != null) {
        System.out.println(line);
    }

    // 装饰者模式使得 BufferedReader 组合了一个 Reader 对象
    // 在调用 BufferedReader 的 close() 方法时会去调用 Reader 的 close() 方法
    // 因此只要一个 close() 调用即可
    bufferedReader.close();
}

对象序列化

  • 将Java对象的原始数据类型和图形写入OutputStream。 可以使用ObjectInputStream读取(重构)对象。可以通过使用流的文件来实现对象的持久存储。 如果流是网络套接字流,则可以在另一个主机上或另一个进程中重构对象
  • 一个对象如果想被序列化,该对象所属的类必须实现Serializable接口
  • Serializable接口是一个标记接口,实现该接口不需要重写任何方法 ```java public static void main(String[] args) throws IOException, ClassNotFoundException {

    A a1 = new A(123, “abc”); String objectFile = “file/a1”;

    ObjectOutputStream objectOutputStream = new ObjectOutputStream(new FileOutputStream(objectFile)); objectOutputStream.writeObject(a1); objectOutputStream.close();

    ObjectInputStream objectInputStream = new ObjectInputStream(new FileInputStream(objectFile)); A a2 = (A) objectInputStream.readObject(); objectInputStream.close(); System.out.println(a2); }

private static class A implements Serializable { // … }


- 用对象序列化流序列化了一个对象后,假如我们修改了对象所属的类文件,读取数据会不会出问题呢?

当序列化运行时检测到类中的以下问题之一时抛出 java.io.InvalidClassException:<br />    1、类的串行版本与从流中读取的类描述符的类型不匹配<br />    2、该类包含未知的数据类型<br />    3、该类没有可访问的无参数构造函数

- 如果出问题了,如何解决呢?

给对象所属的类加一个值:private static final long serialVersionUID = 42L;

com.itheima_03.Student; local class incompatible: stream classdesc serialVersionUID = -3743788623620386195, local class serialVersionUID = -247282590948908173



- 如果一个对象中的某个成员变量的值不想被序列化,又该如何实现呢?

private transient int age;<br />此时,如果在反序列化类中访问该变量,则会得到该变量的默认初始化值


<a name="To74s"></a>
# 网络操作
Java 中的网络支持:

- InetAddress:用于表示网络上的硬件资源,即 IP 地址;
- URL:统一资源定位符;
- Sockets:使用 TCP 协议实现网络通信;
- Datagram:使用 UDP 协议实现网络通信。

- **Address**

没有公有的构造函数,只能通过静态方法来创建实例。<br />`InetAddress.getByName(String host);` <br />`InetAddress.getByAddress(byte[] address);`

- **URL**

可以直接从 URL 中读取字节流数据。
```java
public static void main(String[] args) throws IOException {

     URL url = new URL("http://www.baidu.com");

     /* 字节流 */
     InputStream is = url.openStream();

     /* 字符流 */
     InputStreamReader isr = new InputStreamReader(is, "utf-8");

     /* 提供缓存功能 */
     BufferedReader br = new BufferedReader(isr);

     String line;
     while ((line = br.readLine()) != null) {
         System.out.println(line);
     }

     br.close();
 }
  • Sockets
    • ServerSocket:服务器端类
    • Socket:客户端类
    • 服务器和客户端通过 InputStream 和 OutputStream 进行输入输出。

image.png

  • Datagram

    • DatagramSocket:通信类
    • DatagramPacket:数据包类
  • TCP编程 ```java // 客户端 public class ClientTest { public static void main(String[] args) throws IOException {

      // 创建客户端socket
      Socket socket = new Socket("192.168.3.243", 12345);
    
      // 获取输出流
      OutputStream os = socket.getOutputStream();
      os.write("hello server".getBytes());
    
      // 接收并解析服务端返回数据
      InputStream is = socket.getInputStream();
      byte[] bytes = new byte[1024];
      int len = is.read(bytes);
      System.out.println(new String(bytes, 0, len));
    
      // 关闭流
      socket.close();
    

    } }

// 服务端 public class ServerTest { public static void main(String[] args) throws IOException { // 创建服务端socket对象 ServerSocket serverSocket = new ServerSocket(12345); Socket socket = serverSocket.accept();

    // 接收客户端信息并解析
    InputStream is = socket.getInputStream();
    byte[] bytes = new byte[1024];
    int len = is.read(bytes);
    System.out.println(new String(bytes, 0, len));

    // 获取输出流进行反馈
    OutputStream os = socket.getOutputStream();
    os.write("feedback by server".getBytes());

    // 关闭资源
    serverSocket.close();
}

}



<a name="FYifb"></a>
# NIO

- 为了保证操作系统的稳定性和安全性,一个进程的地址空间划分为 用户空间(User space) 和 内核空间(Kernel space ) 。
- 像我们平常运行的应用程序都是运行在用户空间,只有内核空间才能进行系统态级别的资源有关的操作,比如文件管理、进程通信、内存管理等等。也就是说,我们想要进行 IO 操作,一定是要依赖内核空间的能力。并且,用户空间的程序不能直接访问内核空间。
- 当想要执行 IO 操作时,由于没有执行这些操作的权限,只能发起系统调用请求操作系统帮忙完成。因此,用户进程想要执行 IO 操作的话,必须通过 系统调用 来间接访问内核空间

UNIX 系统下, IO 模型一共有 5 种: 同步阻塞 I/O、同步非阻塞 I/O、I/O 多路复用、信号驱动 I/O 和异步 I/O。

<a name="kI60v"></a>
## I/O 模型概述

**同步阻塞 I/O**

- 阻塞模式下,相关方法都会导致线程暂停 
   - ServerSocketChannel.accept 会在没有连接建立时让线程暂停
   - SocketChannel.read 会在没有数据可读时让线程暂停
   - 阻塞的表现其实就是线程暂停了,暂停期间不会占用 cpu,但线程相当于闲置
- 单线程下,阻塞方法之间相互影响,几乎不能正常工作,需要多线程支持
- 但多线程下,有新的问题,体现在以下方面 
   - 32 位 jvm 一个线程 320k,64 位 jvm 一个线程 1024k,如果连接数过多,必然导致 OOM,并且线程太多,反而会因为频繁上下文切换导致性能降低
   - 可以采用线程池技术来减少线程数和线程上下文切换,但治标不治本,如果有很多连接建立,但长时间 inactive,会阻塞线程池中所有线程,因此不适合长连接,只适合短连接

**同步非阻塞 I/O**

- 非阻塞模式下,相关方法都不会让线程暂停 
   - 在 ServerSocketChannel.accept 在没有连接建立时,会返回 null,继续运行
   - SocketChannel.read 在没有数据可读时,会返回 0,但线程不必阻塞,可以去执行其它 SocketChannel 的 read 或是去执行 ServerSocketChannel.accept
   - 写数据时,线程只是等待数据写入 Channel 即可,无需等 Channel 通过网络把数据发送出去
- 但非阻塞模式下,即使没有连接建立,和可读数据,线程仍然在不断运行,白白浪费了 cpu
- 数据复制过程中,线程实际还是阻塞的(AIO 改进的地方)


![image.png](https://cdn.nlark.com/yuque/0/2021/png/21563681/1638514013191-cf9284e7-e134-4cd1-8de1-3598e990573b.png#clientId=u52661b06-23a3-4&crop=0&crop=0&crop=1&crop=1&from=paste&height=382&id=ua589b64a&margin=%5Bobject%20Object%5D&name=image.png&originHeight=382&originWidth=756&originalType=binary&ratio=1&rotation=0&showTitle=false&size=66669&status=done&style=none&taskId=u15c2c752-bf64-424d-8ad1-2d1d9758a83&title=&width=756)

- **基于单线程的多路复用(NIO)**

单线程可以配合 Selector 完成对多个 Channel 可读写事件的监控,这称之为多路复用

- 多路复用仅针对网络 IO、普通文件 IO 没法利用多路复用
- 如果使用不含 Selector 的非阻塞模式,线程大部分时间都在做无用功,而 Selector 能够保证 
   - 有可连接事件时才去连接
   - 有可读事件才去读取
   - 有可写事件才去写入 
- 限于网络传输能力,Channel 未必时时可写,一旦 Channel 可写,会触发 Selector 的可写事件

![image.png](https://cdn.nlark.com/yuque/0/2021/png/21563681/1638514184071-595dadc6-da4d-48fa-82eb-60e87e8f2741.png#clientId=u52661b06-23a3-4&crop=0&crop=0&crop=1&crop=1&from=paste&height=499&id=u6eb269ff&margin=%5Bobject%20Object%5D&name=image.png&originHeight=664&originWidth=558&originalType=binary&ratio=1&rotation=0&showTitle=false&size=58321&status=done&style=none&taskId=u95682fc2-1f70-4fbc-b4fe-85eef3b66ec&title=&width=419)<br /> 

- **基于多线程的异步 I/O (AIO)**

异步 IO 是基于事件和回调机制实现的,也就是应用操作之后会直接返回,不会堵塞在那里,当后台处理完成,操作系统会通知相应的线程进行后续的操作。

   - 异步意味着,在进行读写操作时,线程不必等待结果,而是将来由操作系统来通过回调方式由另外的线程来获得结果

异步模型需要底层操作系统(Kernel)提供支持

   - Windows 系统通过 IOCP 实现了真正的异步 IO
   - Linux 系统异步 IO 在 2.6 版本引入,但其底层实现还是用多路复用模拟了异步 IO,性能没有优势


<a name="jGUgV"></a>
## NIO 基础

- I/O 与 NIO 最重要的区别是数据打包和传输的方式,I/O 以流的方式处理数据,而 NIO 以块的方式处理数据。
- 面向流的 I/O 一次处理一个字节数据:一个输入流产生一个字节数据,一个输出流消费一个字节数据。为流式数据创建过滤器非常容易,链接几个过滤器,以便每个过滤器只负责复杂处理机制的一部分。不利的一面是,面向流的 I/O 通常相当慢。
- 面向块的 I/O 一次处理一个数据块,按块处理数据比按流处理数据要快得多。但是面向块的 I/O 缺少一些面向流的 I/O 所具有的优雅性和简单性。
- I/O 包和 NIO 已经很好地集成了,java.io.* 已经以 NIO 为基础重新实现了,所以现在它可以利用 NIO 的一些特性。例如,java.io.* 包中的一些类包含以块的形式读写数据的方法,这使得即使在面向流的系统中,处理速度也会更快。

**NIO中三大组件:通道、缓冲区、选择器**


- **通道**

通道 Channel 是对原 I/O 包中的流的模拟,可以通过它读取和写入数据。<br />通道与流的不同之处在于,流只能在一个方向上移动(一个流必须是 InputStream 或者 OutputStream 的子类),而通道是双向的,可以用于读、写或者同时用于读写。

通道包括以下类型:

- FileChannel:从文件中读写数据;
- DatagramChannel:通过 UDP 读写网络中数据;
- SocketChannel:通过 TCP 读写网络中数据;
- ServerSocketChannel:可以监听新进来的 TCP 连接,对每一个新进来的连接都会创建一个 SocketChannel。

- **缓冲区**

发送给一个通道的所有数据都必须首先放到缓冲区中,同样地,从通道中读取的任何数据都要先读到缓冲区中。也就是说,不会直接对通道进行读写数据,而是要先经过缓冲区。<br />缓冲区实质上是一个数组,但它不仅仅是一个数组。缓冲区提供了对数据的结构化访问,而且还可以跟踪系统的读/写进程。

缓冲区包括以下类型:

- ByteBuffer
- CharBuffer
- ShortBuffer
- IntBuffer
- LongBuffer
- FloatBuffer
- DoubleBuffer

缓冲区状态变量

- capacity:最大容量;
- position:当前已经读写的字节数;
- limit:还可以读写的字节数。

![](https://gitee.com/jy-liu6277/pic4-note/raw/master/20211027144834.png#crop=0&crop=0&crop=1&crop=1&id=c5SIP&originHeight=277&originWidth=969&originalType=binary&ratio=1&rotation=0&showTitle=false&status=done&style=none&title=)


```java
// NIO复制文件的实例
public static void fastCopy(String src, String dist) throws IOException {

    /* 获得源文件的输入字节流 */
    FileInputStream fin = new FileInputStream(src);

    /* 获取输入字节流的文件通道 */
    FileChannel fcin = fin.getChannel();

    /* 获取目标文件的输出字节流 */
    FileOutputStream fout = new FileOutputStream(dist);

    /* 获取输出字节流的文件通道 */
    FileChannel fcout = fout.getChannel();

    /* 为缓冲区分配 1024 个字节 */
    ByteBuffer buffer = ByteBuffer.allocateDirect(1024);

    while (true) {
        /* 从输入通道中读取数据到缓冲区中 */
        int r = fcin.read(buffer);

        /* read() 返回 -1 表示 EOF */
        if (r == -1) {
            break;
        }

        /* 切换读写 */
        buffer.flip();

        /* 把缓冲区的内容写入输出文件中 */
        fcout.write(buffer);

        /* 清空缓冲区 */
        buffer.clear();
    }
}
  • 选择器

NIO 常常被叫做非阻塞 IO,主要是因为 NIO 在网络通信中的非阻塞特性被广泛使用。
NIO 实现了 IO 多路复用中的 Reactor 模型,一个线程 Thread 使用一个选择器 Selector 通过轮询的方式去监听多个通道 Channel 上的事件,从而让一个线程就可以处理多个事件。
通过配置监听的通道 Channel 为非阻塞,那么当 Channel 上的 IO 事件还未到达时,就不会进入阻塞状态一直等待,而是继续轮询其它 Channel,找到 IO 事件已经到达的 Channel 执行。
因为创建和切换线程的开销很大,因此使用一个线程来处理多个事件而不是一个线程处理一个事件,对于 IO 密集型的应用具有很好地性能。
应该注意的是,只有套接字 Channel 才能配置为非阻塞,而 FileChannel 不能,为 FileChannel 配置非阻塞也没有意义。

@Slf4j
public class ChannelDemo6 {
    public static void main(String[] args) {
        try (ServerSocketChannel channel = ServerSocketChannel.open()) {
            channel.bind(new InetSocketAddress(8080));
            System.out.println(channel);
            // 开启Selector
            Selector selector = Selector.open();
            // 绑定Channel事件
            channel.configureBlocking(false);
            channel.register(selector, SelectionKey.OP_ACCEPT);

            // 死循环持续工作
            while (true) {
                // 监听Channel事件,阻塞直到绑定事件发生
                int count = selector.select();

                // 获取所有事件
                Set<SelectionKey> keys = selector.selectedKeys();

                // 遍历所有事件,逐一处理
                Iterator<SelectionKey> iter = keys.iterator();
                while (iter.hasNext()) {
                    SelectionKey key = iter.next();
                    // 判断事件类型
                    if (key.isAcceptable()) {
                        ServerSocketChannel c = (ServerSocketChannel) key.channel();
                        // 必须处理
                        SocketChannel sc = c.accept();
                        log.debug("{}", sc);
                    }
                    // 处理完毕,必须将事件移除
                    iter.remove();
                }
            }
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

零拷贝

传统 I/O 将文件写出的过程
Java I/O - 图6

  1. java 本身并不具备 IO 读写能力,因此 read 方法调用后,要从 java 程序的用户态切换至内核态,去调用操作系统(Kernel)的读能力,将数据读入内核缓冲区。这期间用户线程阻塞,操作系统使用 DMA(Direct Memory Access)来实现文件读,其间也不会使用 cpu

    DMA 也可以理解为硬件单元,用来解放 cpu 完成文件 IO

  2. 内核态切换回用户态,将数据从内核缓冲区读入用户缓冲区(即 byte[] buf),这期间 cpu 会参与拷贝,无法利用 DMA

  3. 调用 write 方法,这时将数据从用户缓冲区(byte[] buf)写入 socket 缓冲区,cpu 会参与拷贝
  4. 接下来要向网卡写数据,这项能力 java 又不具备,因此又得从用户态切换至内核态,调用操作系统的写能力,使用 DMA 将 socket 缓冲区的数据写入网卡,不会使用 cpu

所谓的【零拷贝】,并不是真正无拷贝,而是不会拷贝重复数据到 JVM 内存中,零拷贝的优点有

  • 更少的用户态与内核态的切换
  • 不利用 cpu 计算,减少 cpu 缓存伪共享
  • 零拷贝适合小文件传输

NIO 通过 DirectByteBuf 对这一过程进行优化

  • ByteBuffer.allocate(10) HeapByteBuffer 使用的还是 java 内存
  • ByteBuffer.allocateDirect(10) DirectByteBuffer 使用的是操作系统内存

image.png
大部分步骤与优化前相同,不再赘述。唯有一点:java 可以使用 DirectByteBuf 将堆外内存映射到 jvm 内存中来直接访问使用

  • 这块内存不受 JVM 垃圾回收的影响,因此内存地址固定,有助于 IO 读写
  • java 中的 DirectByteBuf 对象仅维护了此内存的虚引用,内存回收分成两步
    • DirectByteBuf 对象被垃圾回收,将虚引用加入引用队列
    • 通过专门线程访问引用队列,根据虚引用释放堆外内存
  • 减少了一次数据拷贝,用户态与内核态的切换次数没有减少

进一步优化(底层采用了 linux 2.1 后提供的 sendFile 方法),java 中对应着两个 channel 调用 transferTo/transferFrom 方法拷贝数据
image.png

  1. java 调用 transferTo 方法后,要从 java 程序的用户态切换至内核态,使用 DMA将数据读入内核缓冲区,不会使用 cpu
  2. 数据从内核缓冲区传输到 socket 缓冲区,cpu 会参与拷贝
  3. 最后使用 DMA 将 socket 缓冲区的数据写入网卡,不会使用 cpu

可以看到

  • 只发生了一次用户态与内核态的切换
  • 数据拷贝了 3 次