Java IO

2020/01/20 Knowledge

参考来源

目录

一、概览

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

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

二、磁盘操作

File 类可以用于表示文件和目录的信息,但是它不表示文件的内容。

递归地列出一个目录下所有文件:

public static void listAllFiles(File dir) {
    if (dir == null || !dir.exists()) {
        return;
    }
    if (dir.isFile()) {
        System.out.println(dir.getName());
        return;
    }
    for (File file : dir.listFiles()) {
        listAllFiles(file);
    }
}

三、字节操作

实现文件复制

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();
}
/**
 * @Author: fuhua
 * @Date: 2020/1/1 10:12 下午
 */
class Solution {

    public static void main(String[] args) {
        
        Path path = Paths.get("/Users/fuhua/Desktop/DAJ-S-LSTM.docx");
        Path path2 = Paths.get("/Users/fuhua/Desktop/DAJ-S-LSTM2.docx");
        Files.copy(path, path2);
    }
}

装饰者模式

装饰者模式:

被装饰者(Cake ConcreteComponent),装饰的组件(Decorator,一般是一个抽象类),装饰蛋糕的具体组件(蛋糕上的水果 Fruit ConcreteDecorator 继承自 Decorator)都继承同一个类(Sweet Component)。
Decorator 是一个抽象类,因为装饰的组件可能很多,都用这一个。
Fruit 继承自装饰组件 Decorator。Fruit 里又一个 Sweet 的对象变量,被装饰对象就赋值在这个变量上。这样一个装饰器可以一直装饰别的组件,装饰也可以累加了。

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

  • InputStream 是抽象组件(都继承自它,Component);
  • FileInputStream 是 InputStream 的子类,属于具体组件,提供了字节流的输入操作(被装饰者,ConcreteComponent);
  • FilterInputStream 属于抽象装饰者,装饰者用于装饰组件,为组件提供额外的功能(抽象装饰者,Decorator)。例如 BufferedInputStream(具体装饰者,ConcreteDecorator)为 FileInputStream 提供缓存的功能。

装饰者模式

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

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

BufferedInputStream 继承自 FilterInputStream,FilterInputStream 内部有个变量 in:InputStream 是被装饰的对象。

protected volatile InputStream in;

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

四、字符操作

编码与解码

编码就是把字符转换为字节,而解码是把字节重新组合成字符。

如果编码和解码过程使用不同的编码方式那么就出现了乱码。

  • GBK 编码中,中文字符占 2 个字节,英文字符占 1 个字节;
  • UTF-8 编码中,中文字符占 3 个或 4 个字节,英文字符占 1 个字节;
  • UTF-16be 编码中,中文字符和英文字符都占 2 个字节。

UTF-16be 中的 be 指的是 Big Endian,也就是大端。相应地也有 UTF-16le,le 指的是 Little Endian,也就是小端。

Java 的内存编码使用双字节编码 UTF-16be,这不是指 Java 只支持这一种编码方式,而是说 char 这种类型使用 UTF-16be 进行编码。char 类型占 16 位,也就是两个字节,Java 使用这种双字节编码是为了让一个中文或者一个英文都能使用一个 char 来存储。

char a = '好'; // 这是合法的
System.out.println(a);

String 的编码方式

String 可以看成一个字符序列,可以指定一个编码方式将它编码为字节序列,也可以指定一个编码方式将一个字节序列解码为 String。

String str1 = "中文";
byte[] bytes = str1.getBytes("UTF-8");
String str2 = new String(bytes, "UTF-8");
System.out.println(str2);

在调用无参数 getBytes() 方法时,默认的编码方式不是 UTF-16be。双字节编码的好处是可以使用一个 char 存储中文和英文,而将 String 转为 bytes[] 字节数组就不再需要这个好处,因此也就不再需要双字节编码。getBytes() 的默认编码方式与平台有关,一般为 UTF-8。

Reader 与 Writer

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

  • InputStreamReader 实现从字节流解码成字符流;
  • OutputStreamWriter 实现字符流编码成为字节流。
FileInputStream fis=new FileInputStream("/Users/fuhua/a.txt");
// File f = new File("/Users/fuhua/a.txt");
// FileInputStream fis=new FileInputStream(f);
try {
    InputStreamReader isr=new InputStreamReader(fis,"utf8");
                      
    BufferedReader br=new BufferedReader(isr);     
            
    String line;     
    while((line=br.readLine()) != null){     
        System.out.println(line);     
    }     
} catch (Exception e) {     
    // TODO Auto-generated catch block     
    e.printStackTrace();     
}    

Reader 和 InputStream 区别

InputStream 是表示字节输入流的所有类的超类

Reader 是用于读取字符流的抽象类

InputStream 提供的是字节流的读取,而非文本读取,这是和Reader 类的根本区别。

即用 Reader 读取出来的是 char 数组或者 String,使用 InputStream 读取出来的是 byte 数组。

实现逐行输出文本文件的内容

public static void readFileContent(String filePath) throws IOException {

    FileReader fileReader = new FileReader(filePath); // FileReader 继承自 InputStreamReader
    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();
}

五、对象操作

序列化

序列化就是将一个对象转换成字节序列,方便存储和传输。

  • 序列化:ObjectOutputStream.writeObject()
  • 反序列化:ObjectInputStream.readObject()

不会对静态变量进行序列化,因为序列化只是保存对象的状态,静态变量属于类的状态。

Serializable

序列化的类需要实现 Serializable 接口(标识接口),它只是一个标准,没有任何方法需要实现,但是如果不去实现它的话而进行序列化,会抛出异常。

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 {

    private int x;
    private String y;

    A(int x, String y) {
        this.x = x;
        this.y = y;
    }

    @Override
    public String toString() {
        return "x = " + x + "  " + "y = " + y;
    }
}

transient

transient 关键字可以使一些属性不会被序列化。

ArrayList 中存储数据的数组 elementData 是用 transient 修饰的,因为这个数组是动态扩展的,并不是所有的空间都被使用(它空间大小的 2 的 n 次方,使用空间肯定没填满),因此就不需要所有的内容都被序列化。通过重写序列化和反序列化方法,使得可以只序列化数组中有内容的那部分数据。

private transient Object[] elementData;

六、网络操作

Java 中的网络支持:

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

InetAddress

没有公有的构造函数,只能通过静态方法来创建实例。

InetAddress address=InetAddress.getByName("www.baidu.com");
System.out.println(address);// www.baidu.com/119.75.213.61
InetAddress[] addresses=InetAddress.getAllByName("www.baidu.com");
for(InetAddress addr:addresses)
{
    System.out.println(addr);
}
// www.baidu.com/119.75.213.61
// www.baidu.com/119.75.216.30

InetAddress.getByAddress(byte[] address);

URL

可以直接从 URL 中读取字节流数据。

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 进行输入输出。

Socket

public class Server {
    public static void main(String[] args) {
 
        try  {
            //创建一个服务器socket,即serversocket,指定绑定的端口,并监听此端口
            ServerSocket serverSocket = new ServerSocket(8888);
            //调用accept()方法开始监听,等待客户端的连接
            System.out.println("***服务器即将启动,等待客户端的连接***");
            Socket socket = serverSocket.accept();
            //获取输入流,并读入客户端的信息
            InputStream in = socket.getInputStream(); //字节输入流
            InputStreamReader inreader = new InputStreamReader(in); //把字节输入流转换为字符流
            BufferedReader br = new BufferedReader(inreader); //为输入流添加缓冲
            String info = null;
            while((info = br.readLine())!=null){
                System.out.println("我是服务器,客户端说:"+info);
    
            }
            socket.shutdownInput();//关闭输入流
 
            //获取输出流,相应客户端的信息
            OutputStream outputStream = socket.getOutputStream();
            PrintWriter printWriter = new PrintWriter(outputStream);//包装为打印流
            printWriter.write("欢迎您!");
            printWriter.flush(); //刷新缓冲
            socket.shutdownOutput();
 
            //关闭资源
            printWriter.close();
            outputStream.close();
 
            br.close();
            inreader.close();
            in.close();
            socket.close();
            serverSocket.close();
 
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}
public class Client {
    public static void main(String[] args) {
        //创建客户端socket建立连接,指定服务器地址和端口
        try {
            Socket socket = new Socket("127.0.0.1",8888);
            //获取输出流,向服务器端发送信息
            OutputStream outputStream = socket.getOutputStream();//字节输出流
            PrintWriter pw = new PrintWriter(outputStream); //将输出流包装为打印流
            pw.write("用户名:admin;密码:123");
            pw.flush();
            socket.shutdownOutput();
 
            //获取输入流,读取服务器端的响应
            InputStream inputStream = socket.getInputStream();
            BufferedReader br = new BufferedReader(new InputStreamReader(inputStream));
            String info = null;
            while((info = br.readLine())!=null){
                System.out.println("我是客户端,服务器说:"+info);
 
            }
            socket.shutdownInput();
 
            //关闭资源
            br.close();
            inputStream.close();
            pw.close();
            outputStream.close();
            socket.close();
 
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

Datagram(个人没用过)

  • DatagramSocket:通信类
  • DatagramPacket:数据包类

七、NIO

新的输入/输出 (NIO) 库是在 JDK 1.4 中引入的,弥补了原来的 I/O 的不足,提供了高速的、面向块的 I/O。

流与块

I/O 与 NIO 最重要的区别是数据打包和传输的方式,I/O 以流的方式处理数据,而 NIO 以块的方式处理数据。

面向流的 I/O 一次处理一个字节数据:一个输入流产生一个字节数据,一个输出流消费一个字节数据。为流式数据创建过滤器非常容易,链接几个过滤器,以便每个过滤器只负责复杂处理机制的一部分。不利的一面是,面向流的 I/O 通常相当慢。

面向块的 I/O 一次处理一个数据块(也就是指定大小的 ByteBuffer),按块处理数据比按流处理数据要快得多。但是面向块的 I/O 缺少一些面向流的 I/O 所具有的优雅性和简单性。

I/O 包和 NIO 已经很好地集成了,java.io.* 已经以 NIO 为基础重新实现了,所以现在它可以利用 NIO 的一些特性。例如,java.io.* 包中的一些类包含以块的形式读写数据的方法,这使得即使在面向流的系统中,处理速度也会更快。

直接内存

直接内存(Direct Memory)并不是虚拟机运行时数据区的一部分,也不是 Java 虚拟机规范中定义的内存区域。

在 JDK 1.4 中新加入了 NIO,引入了一种基于通道(Channel)与缓冲区(Buffer)的 I/O 方式,它可以使用 Native 函数库直接分配堆外内存,然后通过一个存储在 Java 堆中的 DirectByteBuffer 对象作为这块内存的引用进行操作。这样能在一些场景中显著提高性能,因为避免了在 Java 堆和 Native 堆中来回复制数据。

显然,本机直接内存的分配不会受到 Java 堆大小的限制,但是,既然是内存,肯定还是会受到本机总内存(包括 RAM 以及 SWAP 区或者分页文件)大小以及处理器寻址空间的限制。服务器管理员在配置虚拟机参数时,会根据实际内存设置 -Xmx 等参数信息,但经常忽略直接内存,使得各个内存区域总和大于物理内存限制(包括物理的和操作系统级的限制),从而导致动态扩展时出现 OutOfMemoryError 异常。

通道与缓冲区

1. 通道

可以通过 InputStream/OutputStream 开启或者 RandomAccessFile。使用 Stream 只能读或写,RandomAccessFile 可以同时读写。

通道 Channel 是对原 I/O 包中的流的模拟,可以通过它读取和写入数据。

通道与流的不同之处在于,流只能在一个方向上移动(一个流必须是 InputStream 或者 OutputStream 的子类),而通道是双向的,可以用于读、写或者同时用于读写

通道包括以下类型:

  • FileChannel:从文件中读写数据;
    • read(ByteBuffer dst, long position) 和 write(ByteBuffer src, long position) 方法可以直接在文件指定字节位置读取或写入;
  • DatagramChannel:通过 UDP 读写网络中数据;
  • SocketChannel:通过 TCP 读写网络中数据;
  • ServerSocketChannel:可以监听新进来的 TCP 连接,对每一个新进来的连接都会创建一个 SocketChannel。

2. 缓冲区

发送给一个通道的所有数据都必须首先放到缓冲区中,同样地,从通道中读取的任何数据都要先读到缓冲区中。也就是说,不会直接对通道进行读写数据,而是要先经过缓冲区

缓冲区实质上是一个数组,但它不仅仅是一个数组。缓冲区提供了对数据的结构化访问,而且还可以跟踪系统的读/写进程。

缓冲区包括以下类型:

  • ByteBuffer
    • 属性:
      • Capacity 属性:容量,即可以容纳的最大数据量;在缓冲区创建时被设定并且不能改变
      • Limit 属性:表示缓冲区的当前终点,不能对缓冲区超过极限的位置进行读写操作。且极限是可以修改的
      • Position 属性:位置,下一个要被读或写的元素的索引,每次读写缓冲区数据时都会改变改值,为下次读写作准备
      • Mark 属性:标记,调用mark() 来设置mark=position,再调用 reset() 可以让 position 恢复到标记的位置
    • 实例化:
      • ByteBuffer.allocate(int capacity):从堆空间中分配一个容量大小为 capacity 的 byte 数组作为缓冲区的 byte 数据存储器
      • ByteBuffer.allocateDirect(int capacity):不使用 JVM 堆栈而是通过操作系统来创建内存块用作缓冲区,它与当前操作系统能够更好的耦合,因此能进一步提高 I/O 操作速度。但是分配直接缓冲区的系统开销很大,因此只有在缓冲区较大并长期存在,或者需要经常重用时,才使用这种缓冲区。当 Java 程序接收到外部传来的数据时,首先是被系统内存所获取,然后在由系统内存复制拷贝到 JVM 内存中供 Java 程序使用。所以在第二种分配方式中,可以省去复制这一步操作,效率上会有所提高。但是系统级内存的分配比起 JVM 内存的分配要耗时得多,所以并不是任何时候 allocateDirect 的操作效率都是最高的。
    • 方法:
      • flip 方法:把当前 position 的位置设为 limit。position 设为 0。
      • reset 方法:把position设置成mark的值,相当于之前做过一个标记,现在要退回到之前标记的地方
  • CharBuffer
  • ShortBuffer
  • IntBuffer
  • LongBuffer
  • FloatBuffer
  • DoubleBuffer

ByteBuffer 操作示例

状态变量的改变过程举例:

  1. 新建一个大小为 8 个字节的缓冲区,此时 position 为 0,而 limit = capacity = 8。capacity 变量不会改变,下面的讨论会忽略它。

new

  1. 从输入通道中读取 5 个字节数据写入缓冲区中,此时 position 为 5,limit 保持不变。

2

  1. 在将缓冲区的数据写到输出通道之前,需要先调用 flip() 方法,这个方法将 limit 设置为当前 position,并将 position 设置为 0。

3

  1. 从缓冲区中取 4 个字节到输出缓冲中,此时 position 设为 4。

4

  1. 最后需要调用 clear() 方法来清空缓冲区,此时 position 和 limit 都被设置为最初位置。

5

文件 NIO 实例

以下展示了使用 NIO 快速复制文件的实例:

public static void fastCopy(String src, String dist) throws IOException {

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

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

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

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

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

    while (true) {

        /* 从输入通道中读取数据到缓冲区中 */
        // read 的读取大小都是 buffer 的大小,从当前 channel 的位置开始读取
        int r = fcin.read(buffer); // 返回读取到的大小,返回 -1 证明

        // fileChannelAT.read(byteBufferAT, (long) beginIndex * (long) TA_SIZE); 这个方式可以让 channel 从第二个参数的位置(bytes)开始读取

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

        /* 切换读写 */
        buffer.flip(); // 要回到 0 位置再读

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

        /* 清空缓冲区 */
        buffer.clear();
    }
}

选择器

NIO 常常被叫做非阻塞(Non-Blocking) IO,主要是因为 NIO 在网络通信中的非阻塞特性被广泛使用。

NIO 实现了 IO 多路复用中的 Reactor 模型,一个线程 Thread 使用一个选择器 Selector 通过轮询的方式去监听多个通道 Channel 上的事件,从而让一个线程就可以处理多个事件

通过配置监听的通道 Channel 为非阻塞,那么当 Channel 上的 IO 事件还未到达时,就不会进入阻塞状态一直等待,而是继续轮询其它 Channel,找到 IO 事件已经到达的 Channel 执行。

因为创建和切换线程的开销很大,因此使用一个线程来处理多个事件而不是一个线程处理一个事件,对于 IO 密集型的应用具有很好地性能。

应该注意的是,只有套接字 Channel 才能配置为非阻塞,而 FileChannel 不能,为 FileChannel 配置非阻塞也没有意义。

selector

1. 创建选择器

Selector selector = Selector.open();

在 linux 中,选择器是由 epoll 实现的。

public static SelectorProvider create() {
    String osname = AccessController
        .doPrivileged(new GetPropertyAction("os.name"));
    if (osname.equals("SunOS"))
        return createProvider("sun.nio.ch.DevPollSelectorProvider");
    if (osname.equals("Linux"))
        return createProvider("sun.nio.ch.EPollSelectorProvider");
    return new sun.nio.ch.PollSelectorProvider();
}

poll 方法有一个非常大的缺陷。poll 函数的返回值是一个整数,得到了这个返回值以后,我们还是要逐个去检查,比如说,有一万个 socket 同时 poll,返回值是 3,我们还是只能去遍历这一万个 socket,看看它们是否有 IO 动作。这就很低效了,于是,就有了 epoll 的改进,epoll 可以直接通过“输出参数”(可以理解为 C 语言中的指针类型的参数),一个 epoll_event 数组,直接获得这三个 socket,这就比较快了。

2. 将通道注册到选择器上

ServerSocketChannel ssChannel = ServerSocketChannel.open();
ssChannel.configureBlocking(false);
ssChannel.register(selector, SelectionKey.OP_ACCEPT);

通道必须配置为非阻塞模式,否则使用选择器就没有任何意义了,因为如果通道在某个事件上被阻塞,那么服务器就不能响应其它事件,必须等待这个事件处理完毕才能去处理其它事件,显然这和选择器的作用背道而驰。

JAVA NIO 共定义了四种操作类型:OP_READ、OP_WRITE、OP_CONNECT、OP_ACCEPT,分别对应读、写、请求连接、接受连接等网络 Socket 操作。ServerSocketChannel 和 SocketChannel 可以注册自己感兴趣的操作类型,当对应操作类型的就绪条件满足时 OS 会通知 channel。

  • OP_READ:当操作系统读缓冲区有数据可读时就绪。并非时刻都有数据可读,所以一般需要注册该操作,仅当有就绪时才发起读操作,有的放矢,避免浪费CPU。
  • OP_WRITE:当操作系统写缓冲区有空闲空间时就绪。一般情况下写缓冲区都有空闲空间,小块数据直接写入即可,没必要注册该操作类型,否则该条件不断就绪浪费CPU;但如果是写密集型的任务,比如文件下载等,缓冲区很可能满,注册该操作类型就很有必要,同时注意写完后取消注册。
  • OP_CONNECT:当SocketChannel.connect()请求连接成功后就绪。该操作只给客户端使用。
  • OP_ACCEPT:当接收到一个客户端连接请求时就绪。该操作只给服务器使用。

在将通道注册到选择器上时,还需要指定要注册的具体事件,主要有以下几类:

public static final int OP_READ = 1 << 0;
public static final int OP_WRITE = 1 << 2;
public static final int OP_CONNECT = 1 << 3;
public static final int OP_ACCEPT = 1 << 4;

可以看出每个事件可以被当成一个位域(占一位),从而组成事件集整数。例如:

int interestSet = SelectionKey.OP_READ | SelectionKey.OP_WRITE;

一个 selector 可以注册多个类型。比如 socket 时刚开始只监听 ServerSocketChannel 的 accept,后来在监听已经 accept 的 SocketChannel 的 read 内容。

3. 监听事件

int num = selector.select();

使用 select() 来监听到达的事件,它会一直阻塞直到有至少一个事件到达。当然也可能有多重操作的数据。

4. 获取到达的事件

Set<SelectionKey> keys = selector.selectedKeys(); // 这步拿到的是所有 selector 注册后的 keys,有可能有可接受的,有可能有可读的
Iterator<SelectionKey> keyIterator = keys.iterator();
while (keyIterator.hasNext()) {
    SelectionKey key = keyIterator.next();
    if (key.isAcceptable()) {
        // ...
    } else if (key.isReadable()) {
        // ...
    }
    keyIterator.remove();
}

5. 事件循环

因为一次 select() 调用不能处理完所有的事件,并且服务器端有可能需要一直监听事件,因此服务器端处理事件的代码一般会放在一个死循环内。

// 第一轮可接受后,他 accept 注册了等待可读,所以这时就是一个两轮的
while (true) {
    int num = selector.select();
    Set<SelectionKey> keys = selector.selectedKeys();
    Iterator<SelectionKey> keyIterator = keys.iterator();
    while (keyIterator.hasNext()) {
        SelectionKey key = keyIterator.next();
        if (key.isAcceptable()) {
            // ...
        } else if (key.isReadable()) {
            // ...
        }
        keyIterator.remove();
    }
}

Reactor模式

Reactor

在Reactor模式中,包含如下角色

  • Reactor 将I/O事件发派给对应的Handler
  • Acceptor 处理客户端连接请求
  • Handlers 执行非阻塞读/写

套接字 NIO 实例(reactor)

public class NIOServer {

    public static void main(String[] args) throws IOException {

        Selector selector = Selector.open();

        ServerSocketChannel ssChannel = ServerSocketChannel.open();
        ssChannel.configureBlocking(false);
        ssChannel.register(selector, SelectionKey.OP_ACCEPT); // 注册有新接收的时候可以选择

        ServerSocket serverSocket = ssChannel.socket();
        InetSocketAddress address = new InetSocketAddress("127.0.0.1", 8888);
        serverSocket.bind(address);

        while (true) {

            selector.select(); // 让选择器一直做选择
            Set<SelectionKey> keys = selector.selectedKeys(); // 获取所有的注册到选择器并触发的
            Iterator<SelectionKey> keyIterator = keys.iterator();

            while (keyIterator.hasNext()) {

                SelectionKey key = keyIterator.next();
                // 接受操作时的处理内容
                if (key.isAcceptable()) {

                    ServerSocketChannel ssChannel1 = (ServerSocketChannel) key.channel();

                    // 服务器会为每个新连接创建一个 SocketChannel
                    SocketChannel sChannel = ssChannel1.accept();
                    sChannel.configureBlocking(false);

                    // 新的通道注册给选择器,让选择器可以监控到他
                    sChannel.register(selector, SelectionKey.OP_READ);

                // 有读操作时的操作
                } else if (key.isReadable()) {

                    SocketChannel sChannel = (SocketChannel) key.channel();
                    System.out.println(readDataFromSocketChannel(sChannel));
                    sChannel.close();
                }

                keyIterator.remove();
            }
        }
    }

    private static String readDataFromSocketChannel(SocketChannel sChannel) throws IOException {

        ByteBuffer buffer = ByteBuffer.allocate(1024);
        StringBuilder data = new StringBuilder();

        while (true) {

            buffer.clear();
            int n = sChannel.read(buffer);
            if (n == -1) {
                break;
            }
            buffer.flip();
            int limit = buffer.limit();
            char[] dst = new char[limit];
            for (int i = 0; i < limit; i++) {
                dst[i] = (char) buffer.get(i);
            }
            data.append(dst);
            buffer.clear();
        }
        return data.toString();
    }
}
public class NIOClient {

    public static void main(String[] args) throws IOException {
        Socket socket = new Socket("127.0.0.1", 8888);
        OutputStream out = socket.getOutputStream();
        String s = "hello world";
        out.write(s.getBytes());
        out.close();
    }
}

从上示代码中可以看到,多个 Channel 可以注册到同一个 Selector 对象上,实现了一个线程同时监控多个请求状态(Channel)。同时注册时需要指定它所关注的事件,例如上示代码中 ssChannel 对象只注册了 OP_ACCEPT 事件,而 sChannel 对象只注册了 OP_READ 事件。

对比

NIO 与普通 I/O 的区别主要有以下两点:

  • NIO 是非阻塞的;
  • NIO 面向块,I/O 面向流。

八、BIO NIO AIO(Asynchronous I/O)总结

  • 同步: 同步就是发起一个调用后,被调用者未处理完请求之前,调用不返回。
  • 异步: 异步就是发起一个调用后,立刻得到被调用者的回应表示已接收到请求,但是被调用者并没有返回结果,此时我们可以处理其他的请求,被调用者通常依靠事件,回调等机制来通知调用者其返回结果。

同步和异步的区别最大在于异步的话调用者不需要等待处理结果,被调用者会通过回调等机制来通知调用者其返回结果。

  • 阻塞: 阻塞就是发起一个请求,调用者一直等待请求结果返回,也就是当前线程会被挂起,无法从事其他任务,只有当条件就绪才能继续。
  • 非阻塞: 非阻塞就是发起一个请求,调用者不用一直等着结果返回,可以先去干其他事情。

举个生活中简单的例子,你妈妈让你烧水,小时候你比较笨啊,在那里傻等着水开(同步阻塞)。等你稍微再长大一点,你知道每次烧水的空隙可以去干点其他事,然后只需要时不时来看看水开了没有(同步非阻塞)。后来,你们家用上了水开了会发出声音的壶,这样你就只需要听到响声后就知道水开了,在这期间你可以随便干自己的事情,你需要去倒水了(异步非阻塞)。

1. BIO

同步阻塞I/O模式,数据的读取写入必须阻塞在一个线程内等待其完成。

采用 BIO 通信模型 的服务端,通常由一个独立的 Acceptor 线程负责监听客户端的连接。我们一般通过在while(true) 循环中服务端会调用 accept() 方法等待接收客户端的连接的方式监听请求,请求一旦接收到一个连接请求,就可以建立通信套接字在这个通信套接字上进行读写操作,此时不能再接收其他客户端连接请求,只能等待同当前连接的客户端的操作执行完成, 不过可以通过多线程来支持多个客户端的连接,如上图所示。

如果要让 BIO 通信模型 能够同时处理多个客户端请求,就必须使用多线程(主要原因是 socket.accept()、socket.read()、socket.write() 涉及的三个主要函数都是同步阻塞的),也就是说它在接收到客户端连接请求之后为每个客户端创建一个新的线程进行链路处理,处理完成之后,通过输出流返回应答给客户端,线程销毁。这就是典型的一请求一应答通信模型 。我们可以设想一下如果这个连接不做任何事情的话就会造成不必要的线程开销,不过可以通过线程池机制改善,线程池还可以让线程的创建和回收成本相对较低。使用 FixedThreadPool 可以有效的控制了线程的最大数量,保证了系统有限的资源的控制,实现了 N(客户端请求数量):M(处理客户端请求的线程数量)的伪异步 I/O 模型(N 可以远远大于 M),下面一节中会详细介绍到。

在 Java 虚拟机中,线程是宝贵的资源,线程的创建和销毁成本很高,除此之外,线程的切换成本也是很高的。尤其在 Linux 这样的操作系统中,线程本质上就是一个进程,创建和销毁线程都是重量级的系统函数。如果并发访问量增加会导致线程数急剧膨胀可能会导致线程堆栈溢出、创建新线程失败等问题,最终导致进程宕机或者僵死,不能对外提供服务。

伪异步 IO

为了解决同步阻塞 I/O 面临的一个链路需要一个线程处理的问题,后来有人对它的线程模型进行了优化一一一后端通过一个线程池来处理多个客户端的请求接入,形成客户端个数 M:线程池最大线程数 N 的比例关系,其中 M 可以远远大于 N。通过线程池可以灵活地调配线程资源,设置线程的最大值,防止由于海量并发接入导致线程耗尽。

2. NIO

NIO是一种同步非阻塞的I/O模型,在Java 1.4 中引入了 NIO 框架,对应 java.nio 包,提供了 Channel , Selector,Buffer等抽象。

NIO 中的 N 可以理解为 Non-blocking,不单纯是 New。它支持面向缓冲的,基于通道的 I/O 操作方法。 NIO 提供了与传统 BIO 模型中的 Socket 和 ServerSocket 相对应的 SocketChannel 和 ServerSocketChannel 两种不同的套接字通道实现,两种通道都支持阻塞和非阻塞两种模式。阻塞模式使用就像传统中的支持一样,比较简单,但是性能和可靠性都不好;非阻塞模式正好与之相反。对于低负载、低并发的应用程序,可以使用同步阻塞 I/O 来提升开发速率和更好的维护性;对于高负载、高并发的(网络)应用,应使用 NIO 的非阻塞模式来开发。

NIO 特性

如果是在面试中回答这个问题,我觉得首先肯定要从 NIO 流是非阻塞 IO 而 IO 流是阻塞 IO 说起。然后,可以从 NIO 的 3 个核心组件/特性为 NIO 带来的一些改进来分析。如果,你把这些都回答上了我觉得你对于 NIO 就有了更为深入一点的认识,面试官问到你这个问题,你也能很轻松的回答上来了。

  1. Non-blocking IO(非阻塞IO)

    IO 流是阻塞的,NIO 流是不阻塞的。

    Java NIO 使我们可以进行非阻塞 IO 操作。比如说,单线程中从通道读取数据到 buffer,同时可以继续做别的事情,当数据读取到 buffer 中后,线程再继续处理数据。写数据也是一样的。另外,非阻塞写也是如此。一个线程请求写入一些数据到某通道,但不需要等待它完全写入,这个线程同时可以去做别的事情。

    Java IO 的各种流是阻塞的。这意味着,当一个线程调用 read() 或 write() 时,该线程被阻塞,直到有一些数据被读取,或数据完全写入。该线程在此期间不能再干任何事情了

  2. IO 面向流(Stream oriented),而 NIO 面向缓冲区(Buffer oriented)。

    Buffer是一个对象,它包含一些要写入或者要读出的数据。在 NIO 类库中加入 Buffer 对象,体现了新库与原I/O的一个重要区别。在面向流的I/O中·可以将数据直接写入或者将数据直接读到 Stream 对象中。虽然 Stream 中也有 Buffer 开头的扩展类,但只是流的包装类,还是从流读到缓冲区,而 NIO 却是直接读到 Buffer 中进行操作。

    在 NIO 厍中,所有数据都是用缓冲区处理的。在读取数据时,它是直接读到缓冲区中的; 在写入数据时,写入到缓冲区中。任何时候访问 NIO 中的数据,都是通过缓冲区进行操作。

    最常用的缓冲区是 ByteBuffer,一个 ByteBuffer 提供了一组功能用于操作 byte 数组。除了ByteBuffer,还有其他的一些缓冲区,事实上,每一种 Java 基本类型(除了Boolean类型)都对应有一种缓冲区。

  3. Channel(通道)

    NIO 通过Channel(通道) 进行读写。

    通道是双向的,可读也可写,而流的读写是单向的。无论读写,通道只能和 Buffer 交互。因为 Buffer,通道可以异步地读写。

  4. Selector(选择器)

    NIO 有选择器,而 IO 没有。

    选择器用于使用单个线程处理多个通道。因此,它需要较少的线程来处理这些通道。线程之间的切换对于操作系统来说是昂贵的。 因此,为了提高系统效率选择器是有用的。

NIO 读数据和写数据方式

通常来说 NIO 中的所有 IO 都是从 Channel(通道) 开始的。

  • 从通道进行数据读取:创建一个缓冲区,然后请求通道读取数据。
  • 从通道进行数据写入:创建一个缓冲区,填充数据,并要求通道写入数据。

NIO核心组件简单介绍

NIO 包含下面几个核心的组件:

  • Channel(通道)
  • Buffer(缓冲区)
  • Selector(选择器)

整个 NIO 体系包含的类远远不止这三个,只能说这三个是 NIO 体系的“核心API”。

原生 NIO 的问题

为什么大家都不愿意用 JDK 原生 NIO 进行开发呢?从上面的代码中大家都可以看出来,是真的难用!除了编程复杂、编程模型难之外,它还有以下让人诟病的问题:

  • JDK 的 NIO 底层由 epoll 实现,该实现饱受诟病的空轮询 bug 会导致 cpu 飙升 100%
  • 项目庞大之后,自行实现的 NIO 很容易出现各类 bug,维护成本较高,上面这一坨代码我都不能保证没有 bug

Netty 的出现很大程度上改善了 JDK 原生 NIO 所存在的一些让人难以忍受的问题。

3. AIO

AIO 也就是 NIO 2。在 Java 7 中引入了 NIO 的改进版 NIO 2,它是异步非阻塞的 IO 模型。异步 IO 是基于事件和回调机制实现的,也就是应用操作之后会直接返回,不会堵塞在那里,当后台处理完成,操作系统会通知相应的线程进行后续的操作。

AIO 是异步IO的缩写,虽然 NIO 在网络操作中,提供了非阻塞的方法,但是 NIO 的 IO 行为还是同步的。对于 NIO 来说,我们的业务线程是在 IO 操作准备好时,得到通知,接着就由这个线程自行进行 IO 操作,IO操作本身是同步的。

Search

    Table of Contents