Java网络编程06 - IO详解

IO详解

一:什么是IO

对于任何程序设计语言而言,输入输出(Input/Output)系统都是非常核心的功能。

程序运行需要数据,数据的获取往往需要跟外部系统进行通信,外部系统可能是文件、数据库、其他程序、网络、IO设备等等。

外部系统比较复杂多变,那么我们有必要通过某种手段进行抽象、屏蔽外部的差异,从而实现更加便捷的编程。

输入I - 从外部系统获取数据叫做I

输入(Input)指的是:可以让程序从外部系统获得数据(核心含义是“读”,读取外部数据)。

常见的应用有:

  • 读取硬盘上的文件内容到程序。例如:播放器打开一个视频文件、word打开一个doc文件。
  • 读取网络上某个位置内容到程序。例如:浏览器中输入网址后,打开该网址对应的网页内容;下载网络上某个网址的文件。
  • 读取数据库系统的数据到程序。
  • 读取某些硬件系统数据到程序。例如:车载电脑读取雷达扫描信息到程序;温控系统等。

输出O - 将数据写入到外部系统叫做O

输出(Output)指的是:程序输出数据给外部系统从而可以操作外部系统(核心含义是“写”,将数据写出到外部系统)。

常见的应用有:

  • 将数据写到硬盘中。例如:我们编辑完一个word文档后,将内容写到硬盘上进行保存。
  • 将数据写到数据库系统中。例如:我们注册一个网站会员,实际就是后台程序向数据库中写入一条记录。
  • 将数据写到某些硬件系统中。例如:导弹系统导航程序将新的路径输出到飞控子系统,飞控子系统根据数据修正飞行路径。

IO 工作原理

在这里插入图片描述

  1. 首先在网络的网卡上或本地存储设备中准备数据,然后调用read()函数。
  2. 调用read()函数后,由内核将网络/本地数据读取到内核缓冲区中。
  3. 读取完成后向CPU发送一个中断信号,通知CPU对数据进行后续处理
  4. CPU将内核中的数据写入到对应的程序缓冲区或网络Socket接收缓冲区中
  5. 数据全部写入到缓冲区后,应用程序开始对数据开始实际的处理

在这里插入图片描述

1:数据源

数据源DataSource,提供数据的原始媒介。常见的数据源有:数据库、文件、其他程序、内存、网络连接、IO设备。

数据源分为:源设备、目标设备。

  • 源设备:为程序提供数据,一般对应输入流。
  • 目标设备:程序数据的目的地,一般对应输出流。

在这里插入图片描述

2:流的概念

流是一个抽象、动态的概念,是一连串连续动态的数据集合

对于输入流而言,数据源就像水箱,流(Stream)就像水管中流动着的水流,程序就是我们最终的用户。我们通过流(AStream)将数据源(Source)中的数据(information)输送到程序(Program)中。

对于输出流而言,目标数据源就是目的地(dest),我们通过流(AStream)将程序(Program)中的数据(information)输送到目的数据源(dest)中。

输入/输出流的划分是相对程序而言的,并不是相对数据源。

在这里插入图片描述

3:四大IO抽象类

InputStream/OutputStreamReader/Writer类是所有IO流类的抽象父类

3.1:InputStream

此抽象类是表示字节输入流的所有类的父类(外部系统 -> 数据)。

InputSteam是一个抽象类,它不可以实例化。

数据的读取需要由它的子类来实现。根据节点的不同,它派生了不同的节点流子类。

继承自InputSteam的流都是用于向程序中输入数据,且数据的单位为字节( 8 bit)。

常用方法:

  • int read():读取一个字节的数据,并将字节的值作为int类型返回( 0 - 255 之间的一个值)。如果未读出字节则返回-1
  • void close():关闭输入流对象,释放相关系统资源。
3.2:OutputStream

此抽象类是表示字节输出流的所有类的父类(数据 -> 外部系统)。

输出流接收输出字节并将这些字节发送到某个目的地。

常用方法:

  • void write(intn):向目的地中写入一个字节。
  • void close():关闭输出流对象,释放相关系统资源。
3.3:Reader

Reader用于读取的字符流抽象类,数据单位为字符(外部系统 -> 数据)。

常用方法:

  • int read(): 读取一个字符的数据,并将字符的值作为int类型返回(Unicode值)。如果未读出字符则返回-1
  • void close() : 关闭流对象,释放相关系统资源。
3.4:Writer

Writer用于输出的字符流抽象类,数据单位为字符(数据 -> 外部系统)。

常用方法:

  • void write(intn): 向输出流中写入一个字符。
  • void close() : 关闭输出流对象,释放相关系统资源。

4:流的分类

4.1:按照流的方向分类
  • 输入流:数据流从数据源到程序(以InputStream、Reader结尾的流)。
  • 输出流:数据流从程序到目的地(以OutPutStream、Writer结尾的流)。
4.2:按处理的数据单元分类:
  • 字节流:以字节为单位获取数据,命名上以Stream结尾的流一般是字节流,如FileInputStream、FileOutputStream。
  • 字符流:以字符为单位获取数据,命名上以Reader/Writer结尾的流一般是字符流,如FileReader、FileWriter。
4.3:按处理对象不同分类:
  • 节点流:可以直接从数据源或目的地读写数据,如FileInputStream、FileReader、DataInputStream等。
  • 处理流:不直接连接到数据源或目的地,是”处理流的流”。通过对其他流的处理提高程序的性能,如BufferedInputStream、BufferedReader等。处理流也叫包装流。

节点流属于IO操作的第一线,所有的操作必须通过他们进行,处理流可以对节点流进行包装,提高性能或者灵活性

在这里插入图片描述

5:流的关系体系

在这里插入图片描述

二:常用流对象

1:文件字节流

FileInputStream通过字节的方式读取文件,适合读取所有类型的文件(图像、视频、文本文件等)。

Java也提供了FileReader专门读取文本文件。

FileOutputStream 通过字节的方式写数据到文件中,适合所有类型的文件。

Java也提供了FileWriter专门写入文本文件。

1.1:FileInputStream
//创建字节输入流对象
fis = new FileInputStream("d:/a.txt");
// 存储读出来的内容
StringBuilder sb = new StringBuilder();
// 记录本次读到的字节数
int temp = 0;
// 如果还没有读到文件的结尾,就一直读
while ((temp = fis.read()) != -1) {
    System.out.println(temp);
    sb.append((char) temp);
}
System.out.println(sb);
1.2:FileOutputStream
FileInputStream fis = null;
FileOutputStream fos = null;
try {
    //创建文件字节输入流对象
    fis = new FileInputStream("d:/1.png"); 
    // 创建文件字节输出流对象
    fos = new FileOutputStream("d:/2.png");
    int temp = 0;
    // 输入流每读到一个字符
    while ((temp = fis.read()) != -1) {
        fos.write(temp);
    }
}
1.3:缓冲区提高效率

方式一

通过创建一个指定长度的字节数组作为缓冲区,以此来提高IO流的读写效率。该方式适用于读取较大图片时的缓冲区定义。

⚠️ 缓冲区的长度一定是 2 的整数幂。一般情况下1024 长度较为合适。

FileInputStream fis = null;
FileOutputStream fos = null;
//创建文件字节输入流对象
fis = new FileInputStream("d:/1.png");
//创建文件字节输出流对象
fos = new FileOutputStream("d:/3.png");
//创建一个缓冲区,提高读写效率
byte[] buff = new byte[1024];
int temp = 0;
while ((temp = fis.read(buff)) != -1) {
    fos.write(buff, 0, temp);
}

方式二

通过创建一个字节数组作为缓冲区,数组长度是通过输入流对象的available()返回当前文件的预估长度来定义的。

在读写文件时,是在一次读写操作中完成文件读写操作的。

⚠️ 如果文件过大,那么对内存的占用也是比较大的。所以大文件不建议使用该方法。

FileInputStream fis = null;
FileOutputStream fos = null;
try {
    //创建文件字节输入流对象 
    fis = new FileInputStream("d:/itbz.jpg");
    //创建文件字节输出流对象 
    fos = new FileOutputStream("d:/cc.jpg");
    //创建一个缓冲区,提高读写效率 
    byte[] buff = new byte[fis.available()];
    fis.read(buff);
    //将数据从内存中写入到磁盘中。 
    fos.write(buff);
}
1.4:字节缓冲流提高效率

缓冲流是一种处理流:本身并不具有IO流的读取与写入功能,只是在别的流上加上缓冲功能提高效率,就像是把别的流包装起来一样

当对文件或者其他数据源进行频繁的读写操作时,效率比较低,这时如果使用缓冲流就能够更高效的读写信息。

因为缓冲流是先将数据缓存起来,然后当缓存区存满后或者手动刷新时再一次性的读取到程序或写入目的地

因此,缓冲流还是很重要的,我们在IO操作时记得加上缓冲流来提升性能。

BufferedInputStream和BufferedOutputStream这两个流是缓冲字节流,通过内部缓存数组来提高操作流的效率

FileInputStream fis = null;
FileOutputStream fos = null;
BufferedInputStream bis = null;
BufferedOutputStream bos = null;
try {
    fis = new FileInputStream("d:/itbz.jpg");
    bis = new BufferedInputStream(fis); 
    
    fos = new FileOutputStream("d:/ff.jpg");
    bos = new BufferedOutputStream(fos);

    //缓冲流中的 byte 数组长度默认是 8192
    int temp = 0;
    while ((temp = bis.read()) != -1) {
        bos.write(temp);
    }

}

2:文件字符流

如果我们处理的是文本文件,也可以使用文件字符流,它以字符为单位进行操作。

2.1:FileReader
public static void main(String[] args) {
    try (FileReader frd = new FileReader("d:/a.txt")) {
        int temp = 0;
        while ((temp = frd.read()) != -1) {
            System.out.println((char) temp);
        }
    } catch (Exception e) {
        e.printStackTrace();
    }
}
2.2:FileWriter
public static void main(String[] args) {
    try (FileWriter fw = new FileWriter("d:/sxt.txt");
         FileWriter fw2 = new FileWriter("d:/sxt.txt", true)) {
        //创建字符输出流对象 
        fw.write("你好 崔海达\r\n");
        fw.write("你好 张三\r\n");
        fw.flush();
        // 默认是覆盖文件内容,如果设置为true 则在末尾进行拼接
        fw2.write("何以解忧\r\n 唯有杜康");
        fw2.flush();
    } catch (Exception e) {
        e.printStackTrace();
    }
}
2.3:字符缓冲流提高效率

应用一:文件读取

// 输入字符缓冲流示例
public static void main(String[] args) {
    FileReader fr = null;
    BufferedReader br = null;
    try {
        fr = new FileReader("d:/sxt.txt");
        // 字符流 -> 字符缓冲流
        br = new BufferedReader(fr);
        String temp = "";
        // 一行一行的读取
        while ((temp = br.readLine()) != null) {
            System.out.println(temp);
        }
    } catch (Exception e) {
        e.printStackTrace();
    }
}

应用二:文件拷贝

public static void copyFile(String src, String des) {
    BufferedReader br = null;
    BufferedWriter bw = null;
    try {
        br = new BufferedReader(new FileReader(src));
        bw = new BufferedWriter(new FileWriter(des));
        String temp = "";
        // 读一行,写一行
        while ((temp = br.readLine()) != null) {
            bw.write(temp);
            bw.newLine();
        }
        // 注意flush
        bw.flush();
    } catch (Exception e) {
        e.printStackTrace();
    }
}

🎉 当以文件作为数据源或目标时,除了可以使用字符串作为文件以及位置的指定以外,我们也可以使用File类指定。

示例:为文件的内容添加行号

package com.cui.commonboot.myio;

import java.io.BufferedReader;
import java.io.BufferedWriter;
import java.io.FileReader;
import java.io.FileWriter;

/**
 * <p>
 * 功能描述:为文件的内容添加行号
 * </p>
 *
 * @author cui haida
 * @date 2023/12/14/20:34
 */
public class Test01 {
    public static void main(String[] args) {
        try (BufferedReader br = new BufferedReader(new FileReader("d:/from.txt"));
             BufferedWriter bw = new BufferedWriter(new FileWriter("d:/to.txt"))) {

            String temp = ""; // 存储读出来的每一行的内容
            int index = 1; // 记录行号
            while ((temp = br.readLine()) != null) {
                bw.write(index + ": " + temp);
                bw.newLine(); // 声明换行
                index++; // 行号 ++
            }
            bw.flush(); // flush

        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

3:转换流

InputStreamReader/OutputStreamWriter用来实现将字节流转化成字符流

3.1:键盘输入到屏幕输出

System.in是字节流对象,代表键盘的输入

如果我们想按行接收用户的输入时,就必须用到缓冲字符流BufferedReader特有的方法readLine()

但是在创建BufferedReader的构造方法的参数必须是一个Reader对象,这时候我们的转换流InputStreamReader就派上用场了。

而System.out也是字节流对象,代表输出到显示器

按行读取用户的输入后,并且要将读取的一行字符串直接显示到控制台

这时就需要用到字符流的write(Stringstr)方法,所以我们要使用OutputStreamWriter将字节流转化为字符流。(还可以解决中文乱码的问题)

BufferedReader br = null;
BufferedWriter bw = null;
try {
    br = new BufferedReader(new InputStreamReader(System.in));
    bw = new BufferedWriter(new OutputStreamWriter(System.out));
    while (true) {
        bw.write("请输入:");
        bw.flush();
        String input = br.readLine();
        if ("exit".equals(input)) {
            break;
        }
        bw.write("你输入的是:" + input);
        bw.newLine();
        bw.flush();
    }
}
3.2:读取文本文件并添加行号
BufferedReader br = null;
BufferedWriter bw = null;
try {
    //可以通过执行编码防止出现中文乱码
    br = new BufferedReader(new InputStreamReader(new FileInputStream("d:/sxt.txt"), "utf-8"));
    bw = new BufferedWriter(new OutputStreamWriter(new FileOutputStream("d:/sxt3.txt"), "utf-8"));
    String temp = "";
    int i = 1;
    while ((temp = br.readLine()) != null) {
        bw.write(i + ": " + temp);
        bw.newLine();
        i++;
    }
    bw.flush();
}

4:序列化和网络的支持

4.1:序列化

序列化操作就是将对象转换成为字节序列,一般需要进行网络之间的信息传递的时候需要使用

常用的两个关键字是:Serializable & transient

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

  • 序列化的类需要实现 Serializable 接口,它只是一个标准,没有任何方法需要实现

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

private static class A implements Serializable {
    
    private static final long serialVersionUID = 1L;
    
    private int x;
    private String y;
    // 下面这个属性将不会被序列化
    private transient float elementData;

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

    @Override
    public String toString() {
        return "x = " + x + "  " + "y = " + y;
    }
}
4.2:网络支持

主要有下面四个方面的支持:

  1. InetAddress: 用于表示网络上的硬件资源,即 IP 地址;

  2. URL: 统一资源定位符;

  3. Sockets: 使用 TCP 协议实现网络通信;

  4. Datagram: 使用 UDP 协议实现网络通信

InetAddress

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

InetAddress.getByName(String host);
InetAddress.getByAddress(byte[] address);

URL(重要)

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

public static void main(String[] args) throws IOException {
    // 构建URL对象
    URL url = new URL("http://www.baidu.com");
    // url -> stream
    InputStream is = url.openStream();
    // 转换流 stream -> reader,注意指定字符编码
    InputStreamReader isr = new InputStreamReader(is, "utf-8");
    // reader -> 缓冲区
    BufferedReader br = new BufferedReader(isr);
    String line;
    while ((line = br.readLine()) != null) {
        System.out.println(line);
    }
    br.close();
}

Sockets(BIO模型)

  • ServerSocket: 服务器端类
  • Socket: 客户端类
  • 服务器和客户端通过 InputStream 和 OutputStream 进行输入输出。

三:IO模型(重中之重)

1:同步阻塞式IO(BIO)

BIO是同步阻塞模型,也是最初的IO模型

当调用内核read()函数之后,内核在执行数据准备,复制阶段的IO操作时,应用线程都是阻塞的

在这里插入图片描述
在同步阻塞模型中,程序中的线程发起IO调用后,会一直挂起等待,直至数据成功拷贝至程序缓冲区才会继续往下执行。

当本次IO操作还在执行时,又出现多个IO调用,比如多个网络数据到来,此刻该如何处理呢?

采用多线程实现,包括最初的IO模型也的确是这样实现的,也就是当出现一个新的IO调用时,服务器就会多一条线程去处理:

在这里插入图片描述
在Tomcat中,存在一个处理请求的线程池,该线程池声明了核心线程数以及最大线程数

当并发请求数超出配置的最大线程数时,会将客户端的请求加入请求队列中等待,防止并发过高造成创建大量线程,从而引发系统崩溃。

2:同步非阻塞式IO(NIO)

NIO(Non-Blocking-IO)同步非阻塞模型,从字面意思上来说就是:调用read()函数的线程并不会阻塞,而是可以正常运行

在这里插入图片描述
当应用程序中发起IO调用后,内核并不阻塞当前线程,而是立马返回一个“数据未就绪”的信息给应用程序

应用程序这边则一直反复轮询去问内核:数据有没有准备好?

直到最终数据准备好了之后,内核返回“数据已就绪”状态,紧接着再由进程去处理数据…

是不是听上去没啥用,因此目前大多数的NIO技术并非采用这种多线程的模型,而是基于单线程的多路复用模型实现的

2.1:多路复用模型

原生的NIO到底有什么问题呢,分析一下就知道:由于线程在不断的轮询查看数据是否准备就绪,造成CPU开销较大。

既然说是由于大量无效的轮询造成CPU占用过高,那么等内核中的数据准备好了之后,再去询问数据是否就绪不就可以了

在这里插入图片描述

在多路复用模型中,内核仅有一条线程负责处理所有连接

所有网络请求/连接(Socket)都会利用通道Channel注册到选择器上,然后监听器负责监听所有的连接,过程如下:

在这里插入图片描述

  1. 当出现一个IO操作时,会通过调用内核提供的多路复用函数,将当前连接注册到监听器上
  2. 当监听器发现该连接的数据准备就绪后,会返回一个可读条件给用户进程
  3. 然后用户进程拷贝内核准备好的数据进行处理(这里实际是读取Socket缓冲区中的数据)。

系统调用

本意是指调用内核所提供的API接口函数。

  • recvfrom()函数则是指经Socket套接字接收数据,主要用于网络IO操作。
  • read()函数则是指从本地读取数据,主要用于本地的文件IO操作。
2.1.1:select、poll、epoll

IO 多路复用有三种实现方式:selectpollepoll

select机制

客户端操作服务器时就会产生这三种文件描述符(简称fd):

  • writefds(写)
  • readfds(读)
  • exceptfds(异常)。

select 会阻塞住监视3类文件描述符,等有数据、可读、可写、出异常或超时就会返回

返回后通过遍历 fdset 整个数组来找到就绪的描述符 fd,然后进行对应的 IO 操作。

优点:几乎在所有的平台上支持,跨平台支持性好

缺点:

  • 由于是采用轮询方式全盘扫描,会随着文件描述符 FD 数量增多而性能下降

  • 每次调用select(),都需要把fd集合从用户态拷贝到内核态,并进行遍历(消息传递都是从内核到用户空间)

  • 单个进程打开的fd是有限制(通过FD_SETSIZE设置)的,默认是1024个,可修改宏定义,但是效率仍然慢

poll机制

基本原理与select一致,也是轮询+遍历。唯一的区别就是poll没有最大文件描述符限制(链表存储)

缺点也是和select差不多

  • 由于是采用轮询方式全盘扫描,会随着文件描述符 FD 数量增多而性能下降
  • 每次调用poll(),都需要把fd集合从用户态拷贝到内核态,并进行遍历

epoll机制

没有fd个数限制,用户态拷贝到内核态只需要一次,使用时间通知机制来触发

通过epoll_ctl注册fd,一旦fd就绪就会通过callback回调机制来激活对应fd,进行相关IO操作

epoll之所以高性能是得益于它的三个函数:

  • epoll_create()系统启动时,在Linux内核里面申请一个B+树结构文件系统,返回epoll对象,也是一个fd
  • epoll_ctl() 每新建一个连接,都通过该函数操作 epoll 对象,在这个对象里面修改添加删除对应的链接fd,绑定一个callback()
  • epoll_wait() 轮训所有的callback集合,并完成对应的 IO 操作

优点

  • 没 fd 这个限制,所支持的 FD 上限是操作系统的最大文件句柄数,1G 内存大概支持 10 万个句柄。
  • 效率提高,使用回调通知而不是轮询的方式,不会随着 FD 数目的增加效率下降。
  • 内核和用户空间mmap同一块内存实现(mmap 是一种内存映射文件的方法,即将一个文件或者其它对象映射到进程的地址空间)

缺点:epoll 只能工作在linux下。

水平触发(LT)与边缘触发(ET):epoll有epoll LTepoll ET两种触发模式,LT是默认的模式,ET是“高速”模式

  • LT模式下,只要这个fd还有数据可读,每次epoll_wait都会返回它的事件,提醒用户程序去操作
  • ET模式下,它只会提示一次,直到下次再有数据流入之前都不会再提示了

select vs poll vs epoll

在这里插入图片描述

在这里插入图片描述

2.1.2:Reactor & Proactor

传统IO模型

在客户端连接不多,并发量不大的情况下是可以运行得很好的;

在海量并发的情况下,这种模式就显得力不从心了

对于传统IO模型,其主要是一个Server对接N个客户端,在客户端连接之后,为每个客户端都分配一个执行线程。如下图是该模型的一个演示


传统IO的特点

  • 每个客户端连接到达之后,服务端会分配一个线程给该客户端,该线程会处理包括读取数据,解码,业务计算,编码,以及发送数据整个过程;
  • 同一时刻,服务端的吞吐量与服务器所提供的线程数量是呈线性关系的。

主要存在的问题

  • 服务器的并发量对服务端能够创建的线程数有很大的依赖关系,但是服务器线程却是不能无限增长的;
  • 服务端每个线程不仅要进行IO读写操作,而且还需要进行业务计算;
  • 服务端在获取客户端连接,读取数据,以及写入数据的过程都是阻塞类型的,在网络状况不好的情况下,这将极大的降低服务器每个线程的利用率,从而降低服务器吞吐量。

Reactor事件驱动模型

jdk 1.4中就提供了一套非阻塞IOAPI

API本质上是以事件驱动来处理网络事件的,而Reactor是基于该API提出的一套IO模型。

Reactor模型中,主要有四个角色:客户端连接,Reactor,Acceptor和Handler

Acceptor会不断地接收客户端的连接,然后将接收到的连接交由Reactor进行分发,最后有具体的Handler进行处理。

  • Reactor:派发器,负责监听和分配事件,并将事件分派给对应的 Handler。新的事件包含连接建立就绪、读就绪、写就绪等。
  • Acceptor:请求连接器,处理客户端新连接。Reactor 接收到 client 端的连接事件后,会将其转发给 Acceptor,由 Acceptor 接收 Client 的连接,创建对应的 Handler,并向 Reactor 注册此 Handler。
  • Handler:请求处理器,负责事件的处理,将自身与事件绑定,执行非阻塞读/写任务,完成 channel 的读入,完成处理业务逻辑后,负责将结果写出 channel。可用资源池来管理。

在这里插入图片描述

单 Reactor 单线程模型

在这里插入图片描述
处理过程

Reactor 线程通过 select 监听事件,收到事件后通过 Dispatch 进行分发

  • 如果是连接建立事件,则将事件分发给 Acceptor,Acceptor 会通过 accept() 方法获取连接,并创建一个Handler 对象来处理后续的响应事件

  • 如果是IO读写事件,则 Reactor 会将该事件交由当前连接的 Handler 来处理,Handler 会完成 read -> 业务处理 -> send 的完整业务流程

优缺点

单 Reactor 单线程模型的优点在于将所有处理逻辑放在一个线程中实现,没有多线程、进程通信、竞争的问题。

但该模型在性能与可靠性方面存在比较严重的问题:

性能:只在代码上进行组件的区分,整体操作还是单线程,无法充分利用 CPU 资源,并且 Handler 业务处理部分没有异步,一个 Reactor 既要负责处理连接请求,又要负责处理读写请求,一般来说处理连接请求是很快的,但处理读写请求时涉及到业务逻辑处理,相对慢很多。所以 Reactor 在处理读写请求时,其他请求只能等着,容易造成系统的性能瓶颈

可靠性:一旦 Reactor 线程意外中断或者进入死循环,会导致整个系统通信模块不可用,不能接收和处理外部消息,造成节点故障。所以该单Reactor单进程模型不适用于计算密集型的场景,只适用于业务处理非常快速的场景。Redis的线程模型就是基于单 Reactor 单线程模型实现的,因为 Redis 业务处理主要是在内存中完成,操作的速度是很快的,性能瓶颈不在 CPU 上,所以 Redis 对于命令的处理是单进程的。

单 Reactor 多线程模型

为了解决单Reactor单线程模型存在的性能问题,就演进出了单 Reactor 多线程模型,该模型在事件处理器部分采用了多线程【线程池】

在这里插入图片描述
处理流程

  1. Reactor 线程通过 select 监听事件,收到事件后通过 Dispatch 进行分发

  2. 如果是连接建立事件,则将事件分发给 Acceptor,Acceptor 会通过 accept() 方法获取连接,并创建一个Handler 对象来处理后续的响应事件

  3. 如果是IO读写事件,则 Reactor 会将该事件交由当前连接对应的 Handler 来处理

  4. Handler 只负责接收和响应事件,通过 read 接收数据后,将数据发送给后面的 Worker 线程池进行业务处理。

  5. Worker 线程池再分配线程进行业务处理,完成后将响应结果发给 Handler 进行处理。

  6. Handler 收到响应结果后通过 send 将响应结果返回给 Client。

优缺点

相对于第一种模型来说,在处理业务逻辑,也就是获取到 IO 读写事件之后,交由线程池来处理,Handler 收到响应后通过 send 将响应结果返回给客户端。这样可以降低 Reactor 的性能开销,从而更专注的做事件分发工作了,提升整个应用的吞吐,并且 Handler 使用了多线程模式,可以充分利用 CPU 的性能。

但是这个模型存在的问题:

(1)Handler 使用多线程模式,自然带来了多线程竞争资源的开销,同时涉及共享数据的互斥和保护机制,实现比较复杂

(2)单个 Reactor 承担所有事件的监听、分发和响应,对于高并发场景,容易造成性能瓶颈。

主从 Reactor 多线程模型

主从 Reactor 多线程模型将 Reactor 分成两部分:

  • MainReactor:只负责处理连接建立事件,通过 select 监听 server socket,将建立的 socketChannel 指定注册给 subReactor,通常一个线程就可以了

  • SubReactor:负责读写事件,维护自己的 selector,基于 MainReactor 注册的 SocketChannel 进行多路分离 IO 读写事件,读写网络数据,并将业务处理交由 worker 线程池来完成。SubReactor 的个数一般和 CPU 个数相同

在这里插入图片描述
处理过程

主线程中的 MainReactor 对象通过 select 监听事件,接收到事件后通过 Dispatch 进行分发:

  • 如果事件类型为连接建立事件则分发给 Acceptor 进行连接建立

    • 从主线程池中随机选择一个 Reactor 线程作为 Acceptor 线程,用于绑定监听端口,接收客户端连接
    • Acceptor 线程接收客户端连接请求之后创建新的 SocketChannel,将其注册到主线程池的其它 Reactor 线程上,由其负责接入认证、IP黑白名单过滤、握手等操作。
    • 上面完成之后,业务层的链路正式建立,将 SocketChannel 从主线程池的 Reactor 线程的多路复用器上摘除,重新注册到 SubReactor 线程池的线程上,并创建一个 Handler 用于处理各种连接事件
  • 如果接收到的不是连接建立事件,则分发给 SubReactor,SubReactor 调用当前连接对应的 Handler 进行处理

    • Handler 通过 read 读取数据后,将数据分发给 Worker 线程池进行业务处理,Worker 线程池则分配线程进行业务处理,完成后将响应结果发给 Handler
    • Handler 收到响应结果后通过 send 将响应结果返回给 Client

优缺点

主从 Reactor 多线程模型的优点在于主线程和子线程分工明确,主线程只负责接收新连接,子线程负责完成后续的业务处理,同时主线程和子线程的交互也很简单,子线程接收主线程的连接后,只管业务处理即可,无须关注主线程,可以直接在子线程将处理结果发送给客户端。

该 Reactor 模型适用于高并发场景,并且 Netty 网络通信框架也是采用这种实现

2.2:信号驱动模型

信号驱动IO模型(Signal-Driven-IO)是一种偏异步IO的模型

在该模型中引入了信号驱动的概念,在用户进程中首先会创建一个SIGIO信号处理程序,然后基于信号的模型进行处理

在这里插入图片描述

  1. 首先用户进程中会创建一个Sigio信号处理程序,然后会系统调用sigaction信号处理函数
  2. 紧接着内核会直接让用户进程中的线程返回,用户进程可在这期间干别的工作
  3. 当内核中的数据准备好之后,内核会生成一个Sigio信号,通知对应的用户进程数据已准备就绪
  4. 然后由用户进程在触发一个recvfrom的系统调用,从内核中将数据拷贝出来进行处理。

信号驱动模型相较于之前的模型而言,从一定意义上实现了异步【准备阶段非阻塞】,但数据的复制阶段却依旧是同步阻塞执行的。

3:异步非阻塞式IO(AIO)

该模型是真正意义上的异步非阻塞式IO,代表数据准备与复制阶段都是异步非阻塞的:

在这里插入图片描述
在AIO模型中,同样会基于信号驱动实现:

  1. 在最开始会先调用aio_read()sigaction()函数
  2. 然后用户进程中会创建出一个信号处理程序,同时用户进程可立马返回执行其他操作
  3. 在数据写入到内核、且从内核拷贝到用户缓冲区后,内核会通知对应的用户进程对数据进行处理。

在AIO模型中,真正意义上的实现了异步非阻塞,从始至终用户进程只需要发起一次系统调用,后续的所有IO操作由内核完成

最后在数据拷贝至程序缓冲区后,通知用户进程处理即可。

4:举个例子总结一下

还是用煮泡面的问题解释一下:

  • 准备阶段:烧水、拆泡面、倒调料、倒水。
  • 等待阶段:等泡面熟。
  • 事件前提:B要吃泡面,A听到后开始去煮。

BIO:A煮泡面时,B从头到尾等待,期间不干任何事情就等泡面煮好。

NIO:A煮泡面时,让B先回去坐着等,B期间动不动过来问一下泡面有没有好。

  • 多路复用:和BIO过程相差无几,主要区别在于多个请求时不同,单个不会有提升。

  • 信号驱动:A煮泡面时,让B先回去坐着等,并且给了B一个铃铛,准备阶段完成后,A摇一下铃铛通知B把泡面端走,然后B等泡面熟了开吃

AIO:A煮泡面时,让B先回去坐着等,并且给了B一个铃铛,当泡面熟了后摇一下铃铛通知B开吃举个例子

5:Java中的IO模型

5.1:Java_BIO

BIO就是Java的传统IO模型,与其相关的实现都位于java.io包下

其通信原理是:

  1. 客户端、服务端之间通过Socket套接字建立管道连接
  2. 然后从管道中获取对应的输入/输出流
  3. 最后利用输入/输出流对象实现发送/接收信息

服务器端:要使用Socket编程,我们首先要编写服务器端程序

package socket.tcp;

import java.io.*;
import java.net.ServerSocket;
import java.net.Socket;
import java.nio.charset.StandardCharsets;

/**
 * @author cuihaida
 */
public class TcpServer {
    public static void main(String[] args) throws IOException {
        // 声明server socket, 指定绑定的端口是6666
        ServerSocket serverSocket = new ServerSocket(6666);
        System.out.println("server is running...");
        while (true) {
            // 1:等待客户端的连接
            // 在BIO中,这个位置是阻塞的,NIO中这个位置是非阻塞的
            // NIO中没有接收到客户端的连接会直接返回一个状态码,将进行其他的任务
            // 而传统的BIO中,将会一直阻塞,直到有客户端连接
            Socket accept = serverSocket.accept();
            // 获取客户端的地址
            System.out.println("connected from " + accept.getRemoteSocketAddress());
            // 2: 创建一个线程处理当前的客户端的连接
            Thread t = new Handler(accept);
            // 3: 启动线程 
            t.start();
        }
    }
}


/**
 * 对客户端的连接的处理
 */
class Handler extends Thread {

    Socket sock;

    /**
     * 拿到这个客户端
     * @param sock 客户端对象
     */
    public Handler(Socket sock) {
        this.sock = sock;
    }
    @Override
    public void run() {
        // 截取Socket流,获取输入流(外部系统 —> 数据)和输出流(数据 -> 外部系统)
        try (InputStream input = this.sock.getInputStream()) {
            try (OutputStream output = this.sock.getOutputStream()) {
                // 通过流进行消息的处理
                handle(input, output);
            }
        } catch (Exception e) {
            try {
                // 关闭客户端的连接
                this.sock.close();
            } catch (IOException ioe) {
                System.out.println("IO异常");
            }
            System.out.println("client disconnected.");
        }
    }

    /**
     * 具体处理
     * @param input 输入 
     * @param output 输出 
     * @throws IOException 异常
     */
    private void handle(InputStream input, OutputStream output) throws IOException {
        // 在流的基础上创建对应的buffer读写
        // output -> 转成writer -> 封装到buffer writer -> 写数据write给外部系统
        BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(output, StandardCharsets.UTF_8));
        // input -> 转成reader -> 封装到buffer reader -> 从外部系统重读取数据read
        BufferedReader reader = new BufferedReader(new InputStreamReader(input, StandardCharsets.UTF_8));
        // 自己先发一个hello
        writer.write("hello\n");
        writer.flush();
        while (true) {
            // 拿到客户端的消息, 如果是bye,自己也发一个bye, 结束
            String s = reader.readLine();
            if ("bye".equals(s)) {
                writer.write("bye\n");
                writer.flush();
                break;
            }
            // 正常消息的处理
            writer.write("ok: " + s + "\n");
            writer.flush();
        }
    }
}

客户端

package socket.tcp;

import java.io.*;
import java.net.Socket;
import java.nio.charset.StandardCharsets;
import java.util.Scanner;

/**
 * @author cuihaida
 */
public class TcpClient {
    public static void main(String[] args) throws IOException {
        // 连接指定服务器和端口
        Socket sock = new Socket("localhost", 6666);
        // 截取Socket流 -> 从socket中获取到inputstream & outputStream
        try (InputStream input = sock.getInputStream()) {
            try (OutputStream output = sock.getOutputStream()) {
                // 处理措施
                handle(input, output);
            }
        }
        sock.close(); // 沟通完成之后,关闭socket连接
        System.out.println("disconnected.");
    }

    private static void handle(InputStream input, OutputStream output) throws IOException {
        // 在流上建立相应的buffer用于读写
        BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(output, StandardCharsets.UTF_8));
        BufferedReader reader = new BufferedReader(new InputStreamReader(input, StandardCharsets.UTF_8));
        Scanner scanner = new Scanner(System.in);
        System.out.println("[server] " + reader.readLine());
        while(true) {
            System.out.print(">>> ");
            // 读取一行输入
            String s = scanner.nextLine();
            // 将要发送的内容放到输出流上
            writer.write(s);
            writer.newLine();
            writer.flush();
            // 拿到服务器发的消息
            String resp = reader.readLine();
            System.out.println("<<< " + resp);
            // 当输入的是bye的时候结束
            if ("bye".equals(resp)) {
                break;
            }
        }
    }
}

执行过程原理很简单:

  1. 服务端启动后会执行accept()方法等待客户端连接到来。
  2. 客户端启动后会通过IP及端口,与服务端通过Socket套接字建立连接。
  3. 然后双方各自从套接字中获取输入/输出流,并通过流对象发送/接收消息。

在这里插入图片描述

客户端一直没有发送消息过来,服务端则会一直等待下去,从而服务端陷入阻塞状态。

同理,由于客户端也一直在等待服务端的消息,如若服务端一直未响应消息回来,客户端也会陷入阻塞状态

5.2:Java_NIO

Java-NIO则是JDK1.4中新引入的API,它在BIO功能的基础上实现了非阻塞式的特性,其所有实现都位于java.nio包下。

NIO是一种基于通道、面向缓冲区的IO操作

相较BIO而言,它能够更为高效的对数据进行读写操作,同时与原先的BIO使用方式也大有不同。

NIO有三大核心组件:Buffer缓冲区,Channel通道,Selector选择器

Buffer负责存取数据,Channel负责传输数据,而Selector则会决定操作那个通道中的数据

5.2.1:Buffer缓冲区

缓冲区其实本质上就是一块支持读/写操作的内存,底层是由多个内存页组成的数组,我们可以将其称之为内存块

在Java中这块内存则被封装成了Buffer对象,需要使用可直接通过已提供的API对这块内存进行操作和管理:

// 缓冲区抽象类
public abstract class Buffer {
    // 标记位,与mark()、reset()方法配合使用,
    // 可通过mark()标记一个索引位置,后续可随时调用reset()恢复到该位置
    private int mark = -1;
    // 操作位,下一个要读取或写入的数据索引
    private int position = 0;
    // 限制位,表示缓冲区中可允许操作的容量,超出限制后的位置不能操作
    private int limit;
    // 缓冲区的容量,类似于声明数组时的容量
    private int capacity;
    long address;
    
    // 清空缓冲区数据并返回对缓冲区的引用指针
    // (其实调用该方法后缓冲区中的数据依然存在,只是处于不可访问状态)
    // 该方法还有个作用:就是调用该方法后会从读模式切换回写模式
    public final Buffer clear();
    // 调用该方法后会将缓冲区从写模式切换为读模式
    public final Buffer flip();
    // 获取缓冲区的容量大小
    public final int capacity();
    // 判断缓冲区中是否还有数据
    public final boolean hasRemaining();
    // 获取缓冲区的界限大小
    public final int limit();
    // 设置缓冲区的界限大小
    public final Buffer limit(int n);
    // 对缓冲区设置标记位
    public final Buffer mark();
    // 返回缓冲区当前的操作索引位置
    public final int position();
    // 更改缓冲区当前的操作索引位置
    public final Buffer position(int n);
    // 获取当前索引位与界限之间的元素数量
    public final int remaining();
    // 将当前索引转到之前标记的索引位置
    public final Buffer reset();
    // 重置操作索引位并清空之前的标记
    public final Buffer rewind();
    // 省略其他不常用的方法.....
}

当缓冲区被创建出来后,同一时刻只能处于读/写中的一个状态,同一时间内不存在即可读也可写的情况。

position, capacity, limit

  • position:表示当前操作的索引位置(下一个要读/写数据的下标)。
  • capacity:表示当前缓冲区的容量大小。
  • limit:表示当前可允许操作的最大元素位置(不是下标,是正常数字)。

新建一个大小为8个字节的缓冲区,此时position为0,而limit = capacity = 8。

在这里插入图片描述
从输入通道中读取5个字节数据写入缓冲区中,此时position移动设置为5,limit保持不变。

在这里插入图片描述
在将缓冲区的数据写到输出通道之前,需要先调用flip()方法,这个方法将limit设置为当前position,并将position设置为0

在这里插入图片描述
从缓冲区中取4个字节到输出缓冲中,此时position设为4。

在这里插入图片描述
最后需要调用clear()方法来清空缓冲区,此时position和limit都被设置为最初位置。

在这里插入图片描述
🎉 Buffer类仅是一个抽象类,所以并不能直接使用,因此当我们需要使用缓冲区时,需要实例化它的子类,但它的子类有几十之多,但一般较为常用的子类就只有八大基本数据类型的缓冲区

// 举一个例子加深一下印象
package com.cui.commonboot.nio;

import java.nio.ByteBuffer;
import java.util.Arrays;
import java.util.List;

public class Test01 {
    public static void main(String[] args) {
        // 声明一个大小为1024的本地直接内存缓冲
        ByteBuffer byteBuffer = ByteBuffer.allocateDirect(1024);
        System.out.println("放入参数前");
        doPrint(byteBuffer); // pos = 0, limit = 1024, cap = 1024

        List<Integer> list = Arrays.asList(1, 2, 3, 4, 5);

        System.out.println("-----------put ------------");
        System.out.println("-------放入三个数据---------");
        byte bt = 1;
        byteBuffer.put(bt);
        byteBuffer.put(bt);
        byteBuffer.put(bt);

        System.out.println("放入数据后");
        doPrint(byteBuffer); // pos = 3, limit = 1024, cap = 1024

        // 读写切换
        byteBuffer.flip();

        byteBuffer.get();
        System.out.println("---------读取后参数是 ----------");
        doPrint(byteBuffer); // pos = 1, limit = 3, cap = 1024 [limit = 3表示只有三个可读]

        byteBuffer.rewind();
        System.out.println("-----------恢复后的参数是 ---------");
        doPrint(byteBuffer); // pos = 0, limit = 3, cap = 1024 [pos -> 0]

        // 清空缓冲区
        // 这里只是恢复了各个属性的值,但是缓冲区里的数据依然存在
        // 下次写入的时候会覆盖缓冲区中之前的数据
        byteBuffer.clear();
        System.out.println("--------- 清空后的参数是 ---------");
        doPrint(byteBuffer); // pos = 0, limit = 1024, cap = 1024
        System.out.println("--------- 清空后获得数据 ---------");
        System.out.println(byteBuffer.get()); // 1 还是能get到
    }

    public static void doPrint(ByteBuffer byteBuffer) {
        System.out.println("position is : " + byteBuffer.position());
        System.out.println("limit is : " + byteBuffer.limit());
        System.out.println("capacity is : " + byteBuffer.capacity());
        System.out.println("-------------------------");
    }
}

buffer缓冲区的使用方式

  1. 当需要使用缓冲区时,都是通过xxxBuffer.allocate(n)的方式创建,例如创建一个容量为1024的缓冲区
ByteBuffer byteBuffer = ByteBuffer.allocateDirect(1024); // 本地直接内存
ByteBuffer byteBuffer = ByteBuffer.allocate(1024); // 堆内存
  1. 当需要使用该缓冲区时,都是通过其提供的get/put类方法进行操作,所有Buffer子类都会提供的两类方法,具体如下:
// 读取缓冲区中的单个元素(根据position决定读取哪个元素)
public abstract xxx get();
// 读取指定索引位置的字节(不会移动position)
public abstract xxx get(int index);
// 批量读取多个元素放入到dst数组中
public abstract xxxBuffer get(xxx[] dst);
// 根据指定的偏移量(起始下标)和长度,将对应的元素读取到dst数组中
public abstract xxxBuffer get(xxx[] dst, int offset, int length);

// 将单个元素写入缓冲区中(根据position决定写入位置)
public abstract xxxBuffer put(xxx b);
// 将多个元素写入缓冲区中(根据position决定写入位置)
public abstract xxxBuffer put(xxx[] src);
// 将另一个缓冲区写入进当前缓冲区中(根据position决定写入位置)
public abstract xxxBuffer put(xxxBuffer src);
// 向缓冲区的指定位置写入单个元素(不会移动position)
public abstract xxxBuffer put(int index, xxx b);
// 根据指定的偏移量和长度,将多个元素写入缓冲区中
public abstract xxxBuffer put(xxx[] src, int offset, int length);
  1. 在使用缓冲区的时候都会遵循如下步骤:

创建对应类型的缓冲区 -> 通过put往缓冲区中写入数据 -> 调用flip()方法将缓冲区转换为读模式 -> 通过get从缓冲区中读取数据

-> 调用clear()、compact()方法清空缓冲区数据

Buffer缓冲区的分类

Java中的缓冲区也被分为了两大类:本地直接内存缓冲区与堆内存缓冲区

前面Buffer类的所有子实现类xxxBuffer本质上还是抽象类,每个子抽象类都会有DirectXxxBuffer、HeapXxxBuffer两个具体实现类

这两者的主要区别在于:创建缓冲区的内存是位于堆空间之内还是之外。

  • 直接内存缓冲区的性能会高于堆内存缓冲区,但申请后却需要自行手动管理,所以直接内存缓冲区的安全风险要高一些
  • 堆内存缓冲区由于处于堆空间中,会有GC机制自动管理

在这里插入图片描述
🎉 如若追求更好的IO性能,或IO数据过于庞大时,可通过xxxBuffer.allocateDirect()方法创建本地缓冲区使用,也可以通过isDirect()方法来判断一个缓冲区是否基于本地内存创建。

5.2.2:Channel通道

NIO中的通道与BIO中的流对象类似,但BIO中要么是输入流,要么是输出流,通常流操作都是单向传输的。

而通道的功能也是用于传输数据,但它却是一个双向通道,代表着我们即可以从通道中读取对端数据,也可以使用通道向对端发送数据

这个通道可以是一个本地文件的IO连接,也可以是一个网络Socket套接字连接:

// NIO包中定义的Channel通道接口
public interface Channel extends Closeable {
    // 判断通道是否处于开启状态
    public boolean isOpen();
    // 关闭通道
    public void close() throws IOException;
}

可以很明显看出,Channel通道仅被定义成了一个接口,具体的实现都在其子类下,Channel中常用的子类如下:

  • FileChannel:用于读取、写入、映射和操作本地文件的通道抽象类。
// 文件拷贝代码块
FileChannel sourceChannel = null;  
FileChannel destinationChannel = null;  

try {  
    // 建立输入文件流通道
    FileInputStream fis = new FileInputStream("source.txt");  
    sourceChannel = fis.getChannel();  
    // 建立输出文件流通道
    FileOutputStream fos = new FileOutputStream("destination.txt");  
    destinationChannel = fos.getChannel();  
    // transferFrom拷贝
    destinationChannel.transferFrom(sourceChannel, 0, sourceChannel.size());  
}
  • DatagramChannel:读写网络IO中UDP数据的通道抽象类。
  • SocketChannel:读写网络IO中TCP数据的通道抽象类。
  • ServerSocketChannel:类似于BIO的ServerSocket,用于监听TCP连接的通道抽象类。

Channel通道在Java中是三层定义:顶级接口→二级抽象类→三级实现类。

但由于Channel接口子类实现颇多,这里用最常用的ServerSocketChannel、SocketChannel举例分析

ServerSocketChannel

  • ServerSocketChannel的作用与BIO中的ServerSocket类似,主要负责监听客户端到来的Socket连接
  • ServerSocketChannel只负责管理客户端连接,并不负责数据传输
// 服务端通道抽象类
public abstract class ServerSocketChannel
    extends AbstractSelectableChannel
    implements NetworkChannel
{
    // 构造方法:需要传递一个选择器进行初始化构建
    protected ServerSocketChannel(SelectorProvider provider);
    // 打开一个ServerSocketChannel通道
    public static ServerSocketChannel open() throws IOException;
    // 绑定一个IP地址作为服务端
    public final ServerSocketChannel bind(SocketAddress local);
    // 绑定一个IP并设置并发连接数大小,超出后的连接全部拒绝
    public abstract ServerSocketChannel bind(SocketAddress local, int backlog);
    // 监听客户端连接的方法(会发生阻塞的方法)
    public abstract SocketChannel accept() throws IOException;
    // 获取一个ServerSocket对象
    public abstract ServerSocket socket();
    // .....省略其他方法......
}
// 1.打开一个ServerSocketChannel监听
ServerSocketChannel ssc = ServerSocketChannel.open();

// 2.绑定监听的IP地址与端口号
ssc.bind(new InetSocketAddress("127.0.0.1",8888));
// 也可以这样绑定
// ssc.socket().bind(new InetSocketAddress("127.0.0.1",8888));

// 3.监听客户端连接
while(true) {
    // 不断尝试获取客户端的socket连接
    SocketChannel sc = ssc.accept();
    // 如果为null则代表没有连接到来,非空代表有连接
    if (sc != null){
        // 处理客户端连接.....
    }
}

SocketChannel

  • 管理类:如打开通道、连接远程地址、绑定地址、注册选择器、关闭通道等。
  • 操作类:读取/写入数据、批量读取/写入、自定义读取/写入等。
  • 查询类:检查是否打开连接、是否建立了连接、是否正在连接等。
public abstract class SocketChannel extends AbstractSelectableChannel
    implements ByteChannel, ScatteringByteChannel, 
               GatheringByteChannel, NetworkChannel{
    // 打开一个通道
    public static SocketChannel open();
    // 根据指定的远程地址,打开一个通道
    public static SocketChannel open(SocketAddress remote);
    // 如果调用open()方法时未给定地址,可以通过该方法连接远程地址
    public abstract boolean connect(SocketAddress remote);
    // 将当前通道绑定到本地套接字地址上
    public abstract SocketChannel bind(SocketAddress local);
    // 把当前通道注册到Selector选择器上:
    // sel:要注册的选择器、ops:事件类型、att:共享属性。
    public final SelectionKey register(Selector sel,int ops,Object att);
    // 省略其他......
    // 关闭通道    
    public final void close();
    
    // 向通道中写入数据,数据通过缓冲区的方式传递
    public abstract int write(ByteBuffer src);
    // 根据给定的起始下标和数量,将缓冲区数组中的数据写入到通道中
    public abstract long write(ByteBuffer[] srcs,int offset,int length);
    // 向通道中批量写入数据,批量写入一个缓冲区数组    
    public final long write(ByteBuffer[] srcs);
    // 从通道中读取数据(读取的数据放入到dst缓冲区中)
    public abstract int read(ByteBuffer dst);
    // 根据给定的起始下标和元素数据,在通道中批量读取数据
    public abstract long read(ByteBuffer[] dsts,int offset,int length);
    // 从通道中批量读取数据,结果放入dits缓冲区数组中
    public final long read(ByteBuffer[] dsts);
    
    // 返回当前通道绑定的本地套接字地址
    public abstract SocketAddress getLocalAddress();
    // 判断目前是否与远程地址建立上了连接关系
    public abstract boolean isConnected();
    // 判断目前是否与远程地址正在建立连接
    public abstract boolean isConnectionPending();
    // 获取当前通道连接的远程地址,null代表未连接
    public abstract SocketAddress getRemoteAddress();
    // 设置阻塞模式,true代表阻塞,false代表非阻塞
    public final SelectableChannel configureBlocking(boolean block);
    // 判断目前通道是否为打开状态
    public final boolean isOpen();
}
5.2.3:Selector选择器

Selector是NIO的核心组件,它可以负责监控一个或多个Channel通道

选择器能够检测出那些通道中的数据已经准备就绪,可以支持读取/写入了,因此一条线程通过绑定一个选择器,就可以实现对多个通道进行管理,最终达到一条线程处理多个连接的效果,能够在很大程度上提升网络连接的效率

在这里插入图片描述

public abstract class Selector implements Closeable {
    // 创建一个选择器
    public static Selector open() throws IOException;
    // 判断一个选择器是否已打开
    public abstract boolean isOpen();
    // 获取创建当前选择器的生产者对象
    public abstract SelectorProvider provider();
    // 获取所有注册在当前选择的通道连接
    public abstract Set<SelectionKey> keys();
    // 获取所有数据已准备就绪的通道连接
    public abstract Set<SelectionKey> selectedKeys();
    // 非阻塞式获取就绪的通道,如若没有就绪的通道则会立即返回
    public abstract int selectNow() throws IOException;
    // 在指定时间内,阻塞获取已注册的通道中准备就绪的通道数量
    public abstract int select(long timeout) throws IOException;
    // 获取已注册的通道中准备就绪的通道数量(阻塞式)
    public abstract int select() throws IOException;
    // 唤醒调用Selector.select()方法阻塞后的线程
    public abstract Selector wakeup();
    // 关闭创建的选择器(不会关闭通道)
    public abstract void close() throws IOException;
}

当想要实现非阻塞式IO时,那必然需要用到Selector选择器,它可以帮我们实现一个线程管理多个连接的功能。

但如若想要使用选择器,那需先将对应的通道注册到选择器上,然后再调用选择器的select方法去监听注册的所有通道。

不过在向选择器注册通道时,需要为通道绑定一个或多个事件,注册后选择器会根据通道的事件进行切换

只有当通道读/写事件发生时,才会触发读写,因而可通过Selector选择器实现一条线程管理多个通道。

选择器一共支持4种事件:

  • SelectionKey.OP_READ/1:读取就绪事件,通道内的数据已就绪可被读取。
  • SelectionKey.OP_WRITE/4:写入就绪事件,一个通道正在等待数据写入。
  • SelectionKey.OP_CONNECT/8:连接就绪事件,通道已成功连接到服务端。
  • SelectionKey.OP_ACCEPT/16:接收就绪事件,服务端通道已准备好接收新的连接。
// 开启selector选择器
Selector selector = Selector.open();
// 把当前通道注册到Selector选择器上:
// sel:要注册的选择器、ops:事件类型 <- 这里是accept类型
server.register(selector, SelectionKey.OP_ACCEPT);

当一个通道注册时,会为其绑定对应的事件,当该通道触发了一个事件,就代表着该事件已经准备就绪,可以被线程操作了

// 可用异或的方式绑定好几个事件
int event = SelectionKey.OP_READ | SelectionKey.OP_WRITE; 

一条通道除开可以绑定多个事件外,还能注册多个选择器,但同一选择器只能注册一次,如多次注册相同选择器就会报错。

⚠️ 并非所有的通道都可使用选择器,比如FileChannel无法支持非阻塞特性,因此不能与Selector一起使用

⚠️ 并非所有的事件都支持任意通道,比如OP_ACCEPT事件则仅能提供给ServerSocketChannel使用。

package io_study.base;

import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.SelectionKey;
import java.nio.channels.Selector;
import java.nio.channels.ServerSocketChannel;
import java.nio.channels.SocketChannel;
import java.util.Iterator;
import java.util.Set;

/**
 * <p>
 * 功能描述:nio 多路复用器模型
 * </p>
 *
 * @author cui haida
 * @date 2024/02/29/13:07
 */
public class NioSelectorTest {
    public static void main(String[] args) throws IOException, InterruptedException {
        // 创建NioServerSocket
        ServerSocketChannel channel = ServerSocketChannel.open();
        // 绑定监听的端口为9000
        channel.socket().bind(new InetSocketAddress(9000));
        // 设置为非阻塞
        channel.configureBlocking(false);
        // 打开多路复用器Selector处理channel,创建epoll
        // epoll_create <---- 声明一个epoll实例
        // 底层就是创建了一个linux epoll 实例,底层通过c语言调用epoll_create方法,返回一个非负作为文件描述符
        // 这里面有一个事件集合,epollArray
        Selector selector = Selector.open();
        // selector中有一个channels,用于存储注册到这里的channel
        // 将channel注册到selector上,并指定对连接事件感兴趣
        // 底层是调用pollWrapper.add(fd), fd -> socketChannel添加到内部的集合中[所有的channel]
        // fd => 文件描述符,Linux的内核为高效管理已被打开的文件所创建的索引,用这个索引可以找到文件
        channel.register(selector, SelectionKey.OP_ACCEPT);
        System.out.println("服务器启动成功");

        while (true) {
            // 阻塞等待需要处理的事件的发生
            // 如果注册的所有的channel的所有事件都没有发生,将会阻塞

            // 底层一路调用,调用的是Linux内核的两个方法:epoll_wait() & epoll_ctl

            // epoll_ctl <-- 监听转移到就绪数组【channels -> rdList <--- 通过os的中断程序实现】
            // 到真正的注册绑定updateRegistrations(),先回调用epollCtl方法进行事件的绑定
            // epoll_ctl(int epfd, int op, int fd, struct epoll_event *event) fd[channel]

            // epoll_wait <-- 监听就绪队列,就绪队列中有的就处理,没有就绪的channel将会阻塞
            // epoll中海还有一个队列就是就绪阻塞队列rdList, 如果有channel的事件就绪,会循环处理
            // 如果没有事件就绪,就会调用epoll_wait方法进行阻塞
            selector.select();

            // 获取selector中注册的全部事件的SelectionKey的实例
            Set<SelectionKey> selectionKeys = selector.selectedKeys();
            Iterator<SelectionKey> iterator = selectionKeys.iterator();

            // 遍历迭代器进行处理
            while (iterator.hasNext()) {
                SelectionKey key = iterator.next();
                // 对各种事件进行判断处理
                if (key.isAcceptable()) { // 如果是连接事件
                    ServerSocketChannel server = (ServerSocketChannel) key.channel();
                    // 接受客户端连接,拿到连接通道
                    SocketChannel socketChannel = server.accept();
                    socketChannel.configureBlocking(false);
                    // 连接通道注册到selector中
                    // 这里只关注了读的事件,如果需要给客户端发送数据,可以注册写的事件
                    socketChannel.register(selector, SelectionKey.OP_READ);
                } else if (key.isReadable()) { // 如果是读事件,进行读取和打印
                    SocketChannel read = (SocketChannel) key.channel();
                    ByteBuffer buffer = ByteBuffer.allocate(6); // 定义字节缓冲区存储读取的内容
                    int len = read.read(buffer); // 将读就绪的channel的内容读到buffer中
                    if (len > 0) {
                        // todo业务处理
                        System.out.println("接受到消息:" + new String(buffer.array()));
                        iterator.remove();
                    } else if (len == -1) { // 如果客户端断开了连接,关闭socket
                        System.out.println("客户端断开了连接");
                        read.close();
                    }
                }
            }
        }
    }
}
5.3:Java_AIO

AIO中,所有创建的通道都会直接在OS上注册监听,当出现IO请求时,会先由操作系统接收、准备、拷贝好数据,然后再通知监听对应通道的程序处理数据

package com.cui.commonboot.nio.aio;

import java.net.InetSocketAddress;
import java.nio.channels.AsynchronousChannelGroup;
import java.nio.channels.AsynchronousServerSocketChannel;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

/**
 * <p>
 * 功能描述:
 * </p>
 *
 * @author cui haida
 * @date 2023/12/16/10:51
 */
public class AioServer {
    // 线程池:用于接收客户端连接到来,这个线程池不负责处理客户端的IO业务(推荐自定义pool)
    // 主要作用:处理到来的IO事件和派发CompletionHandler(接收OS的异步回调)
    private ExecutorService servicePool = Executors.newFixedThreadPool(2);
    // 异步通道的分组管理,目的是为了资源共享,也承接了之前NIO中的Selector工作。
    private AsynchronousChannelGroup group;
    // 异步的服务端通道,类似于NIO中的ServerSocketChannel
    private AsynchronousServerSocketChannel serverChannel;

    public AioServer(String ip,int port) {
        try {
            // 使用线程组,绑定线程池,通过多线程技术监听客户端连接
            group = AsynchronousChannelGroup.withThreadPool(servicePool);
            // 创建AIO服务端通道,并通过线程组对到来的客户端连接进行管理
            serverChannel = AsynchronousServerSocketChannel.open(group);
            // 为服务端通道绑定IP地址与端口
            serverChannel.bind(new InetSocketAddress(ip,port));
            System.out.println(">>>>>>>...AIO服务端启动...>>>>>>>>");
            // 第一个参数:作为处理器的附加参数(你想传啥都行)
            // 第二个参数:注册一个提供给OS回调的处理器
            serverChannel.accept(this, new AioHandler());
            // 这里主要是为了阻塞住主线程退出,确保服务端的正常运行。
            // 与CompletableFuture相同,主线程退出后无法获取回调
            Thread.sleep(100000);
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

    // 关闭服务端的方法
    public void serverDown(){
        try {
            serverChannel.close();
            group.shutdown();
            servicePool.shutdown();
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

    // 获取服务端通道的方法
    public AsynchronousServerSocketChannel getServerChannel(){
        return this.serverChannel;
    }

    public static void main(String[] args){
        // 创建一个AIO的服务端
        AioServer server = new AioServer("127.0.0.1",8888);
        // 关闭AIO服务端
        server.serverDown();
    }
}
package com.cui.commonboot.nio.aio;

import java.nio.ByteBuffer;
import java.nio.channels.AsynchronousSocketChannel;
import java.nio.channels.CompletionHandler;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

/**
 * <p>
 * 功能描述:AIO回调
 * </p>
 *
 * @author cui haida
 * @date 2023/12/16/10:51
 */
public class AioHandler implements CompletionHandler<AsynchronousSocketChannel, AioServer> {

    // 负责具体IO业务处理的线程池
    private ExecutorService IoDisposePool = Executors.newFixedThreadPool(2);

    // 操作系统IO操作处理成功的回调函数
    @Override
    public void completed(AsynchronousSocketChannel client, AioServer server) {
        /**
         * 调用监听方法继续监听其他客户端连接,
         * 这里不会由于递归调用导致堆栈溢出,
         * 因为发起accept监听的线程和IO回调的线程并非同一个
         * */
        server.getServerChannel().accept(server,this);
        // 将接下来的IO数据处理业务丢给线程池IoDisposePool处理
        IoDisposePool.submit(()->{
            // 创建一个字节缓冲区,用于接收数据
            ByteBuffer readBuffer = ByteBuffer.allocate(1024);
            /**
             * 第一个参数:客户端数据的中转缓冲区(分散读取时使用)
             * 第二个参数:存放OS处理好的客户端数据缓冲区(OS会主动将数据放进来)
             * 第三个参数:对于IO数据的具体业务操作。
             * */
            client.read(readBuffer,readBuffer,
                    new CompletionHandler<Integer,ByteBuffer>(){
                        /**
                         * 第一个参数:读取到的客户端IO数据的长度
                         * 第二个参数:存放IO数据的缓冲区(对应上述read()方法的第二个参数)
                         * */
                        @Override
                        public void completed(Integer length, ByteBuffer buffer) {
                            // length代表数据的字节数,不为-1代表通道未关闭
                            if (length != -1){
                                // 将缓冲区转换为读取模式
                                buffer.flip();
                                // 输出接收到的客户端数据
                                System.out.println("服务端收到信息:" +
                                        new String(buffer.array(),0,buffer.remaining()));
                                // 将处理完后的缓冲区清空
                                buffer.clear();

                                // 向客户端写回数据
                                String msg = "我是服务端-server!";
                                buffer.put(msg.getBytes());
                                buffer.flip();
                                client.write(buffer);
                            }
                        }
                        @Override
                        public void failed(Throwable exc, ByteBuffer attachment) {
                            exc.printStackTrace();
                        }
                    });
        });
    }

    // 操作系统处理IO数据时,出现异常的回调函数
    @Override
    public void failed(Throwable exc, AioServer attachment) {
        // 打印异常的堆栈信息
        exc.printStackTrace();
    }
}
package com.cui.commonboot.nio.aio;

import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.AsynchronousSocketChannel;

/**
 * <p>
 * 功能描述:aio客户端
 * </p>
 *
 * @author cui haida
 * @date 2023/12/16/10:51
 */
public class AioClient {
    // 客户端的Socket异步通道
    private AsynchronousSocketChannel channel;

    // 客户端的构造方法,创建一个AIO客户端
    public AioClient(String ip,int port){
        try {
            // 打开一个异步的socket通道
            channel = AsynchronousSocketChannel.open();
            // 与指定的IP、端口号建立通道连接(阻塞等待连接完成后再操作)
            // 如果不加.get(),同时启动多个客户端会抛出如下异常信息:
            //      java.nio.channels.NotYetConnectedException
            // 这是由于建立连接也是异步的,所以未建立连接直接通信会报错
            channel.connect(new InetSocketAddress(ip,port)).get();
            System.out.println(">>>>>>>...AIO客户端启动...>>>>>>>>");
        } catch (Exception e){
            e.printStackTrace();
        }
    }

    // 客户端向通道中写入数据(往服务端发送数据)的方法
    public void clientWrite(String msg){
        ByteBuffer buffer = ByteBuffer.allocate(1024);
        buffer.put(msg.getBytes());
        buffer.flip();
        this.channel.write(buffer);
    }

    // 客户端从通道中读取数据(接收服务端数据)的方法
    public void clientRead(){
        ByteBuffer buffer = ByteBuffer.allocate(1024);
        try {
            // 阻塞读取服务端传输的数据
            this.channel.read(buffer).get();
            buffer.flip();
            System.out.println("客户端收到信息:" +
                    new String(buffer.array(),0,buffer.remaining()));

        } catch (Exception e) {
            e.printStackTrace();
        }
    }

    // 关闭客户端通道连接的方法
    public void clientDown(){
        try {
            channel.close();
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

    public static void main(String[] args){
        // 创建一个AIO客户端,并与指定的地址建立连接
        AioClient clientA = new AioClient("127.0.0.1",8888);
        // 向服务端发送数据
        clientA.clientWrite("我是客户端-client_1!");
        // 读取服务端返回的数据
        clientA.clientRead();
        // 关闭客户端的通道连接
        clientA.clientDown();

        // 创建一个AIO客户端,并与指定的地址建立连接
        AioClient clientB = new AioClient("127.0.0.1",8888);
        // 向服务端发送数据
        clientB.clientWrite("我是客户端-client_2!");
        // 读取服务端返回的数据
        clientB.clientRead();
        // 关闭客户端的通道连接
        clientB.clientDown();
    }
}
5.3.1:异步通道分组

AsynchronousChannelGroup主要是用来管理异步通道的分组,也可以实现线程资源的共享

在创建分组时可以为其绑定一个或多个线程池,然后创建通道时,可以指定分组,如下:

group = AsynchronousChannelGroup.withThreadPool(servicePool);
serverChannel = AsynchronousServerSocketChannel.open(group);

上面首先创建了一个group分组并绑定了一个线程池,然后在创建服务端通道将其分配到了group这个分组中,那此时连接serverChannel的所有客户端通道,都会共享servicePool这个线程池的线程资源。这个线程池中的线程,则负责类似于NIOSelector的工作。

5.3.2:异步回调处理

在这里插入图片描述
CompletionHandler则是AIO较为核心的一部分,主要是用于Server服务端的

CompletionHandler则作为异步IO数据结果的回调接口,用于定义操作系统在处理好IO数据之后的回调工作。

CompletionHandler接口中主要存在completed()、failed()两个方法,分别对应IO数据处理成功、失败的回调工作。

🎉 对于AIO的回调工作,也允许通过Future处理,但最好还是定义CompletionHandler处理。

5.3.3:AIO的底层实现
  • Java-BIO本质上是同步调用内核所提供的read()/write()/recvfrom()等函数实现的。
  • Java-NIO则是通过调用内核所提供的select/poll/epoll/kqueue等函数实现。

Java-AIO这种异步非阻塞式IO也是由操作系统进行支持的,在Windows系统中提供了一种异步IO技术:IOCP(I/O Completion Port,所以Windows下的Java-AIO则是依赖于这种机制实现。

Linux系统中由于没有这种异步IO技术,所以Java-AIOLinux环境中使用的还是epoll这种多路复用技术进行模拟实现的。

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值