从一道面试题开始-Java新建线程启动一次IO都做了什么

前言

笔者在前几天的一次面试中被面试官问到了一个很发散的问题,Java 新建线程启动一次 IO 都做了什么?

这个问题想简单的话,两三句话就说完了。但是这是我们展示自己知识体系的好机会,加上我认为当时自己的回答不太健全,所以我们从头捋顺一下相关知识点,将它们串联起来。

本文阅读的 java 源码为 jdk-23.0.2 ,版本较新,可能会涉及新版本特性。和老版本 1.8 的源码布局稍有不同。这是我们选择的 openjdk 源码仓库:

https://github.com/openjdk/jdk/releases/tag/jdk-23%2B2

java源码的目录为:

jdk/src/java.base/share/classes/java

,这里存放了很多重要的基础包。

1、启动一次 IO ,都做了什么?

1.1 JAVA 的 IO 工具类

要确定都做了什么,我们首先要限定我们IO方法的具体调用路径。

众所周知, java 的 IO 分为 同步IO异步IO 两种。 java.io 包中为我们提供了 同步IO 工具,而 java.nio 包中为我们提供的是 异步IO 工具。

所谓的 同步与异步 指的是在 从发出IO指令到IO内容可用 这个过程中 启动IO的线程的运行状态。在 同步IO 中,线程在发出 IO 指令后就自动地进入了等待阻塞状态,直到目标数据准备好(或者说全部加载到了内存中的某个可以被读取的位置)时,线程才会被唤醒,执行下一步的处理操作。而 异步IO 中,发出IO指令的线程不会被自动阻塞,而是可以继续执行某些工作(当然,这些工作必须是与所读取数据无关的,或者你也可以手动地阻塞掉它们)。

清晰理解了 同步与异步 概念后,我们可以看看这两个包都提供了哪些工具类,然后用这些工具组装一个启动 IO 的工具函数。

1.1.1 java.io

alt text

当我们打开 java.io 包后,可以发现这个包里的类一眼望不到头,但事实上我们可以将其归类为几个部分:

Stream 代表的字节流 IO 工具

alt text

以及 Reader 和 Writer 表示的 字符流 IO 工具

alt text

以及其它更多的接口、工具类、异常。

这里不得不提一嘴的是 java 的 字符流 工具。字节流与字符流其实都是一个基于流式数据的上层封装。所谓的流式数据,是 2进制 数据的通用表示方法,也就是一串 0101010101 。把这种序列从一端开始顺序读取的过程,就像一条单向的河流,一去不回。

而我们都知道,现代二进制计算机的最小电子元件是一个 01 开关,也就是一个 bit 。但是由于硬件的大幅进步,每个芯片、外存、上的01开关数量过于巨大,仍然一次性处理一个 bit 的效率实在是太慢了。所以现代计算机往往采用 4bytes 对齐的方式同时批处理 32个 bit 或者 64 个 bit。

IO 也是一样,现在计算机的 IO 一般都是通过总线批量进行若干个 bit 数据的 IO ,一次性能够向处理器输送的数据也是远远大于 1 bit 的。所以为了最高效地利用输送过来的全部数据,IO的最小处理单位往往是远大于 1bit 的,也就是我们常说的批处理思想。

在这个思想上层,高级语言往往会提供各种粒度的批处理工具。java 提供的工具便是 字节 和 字符 流。字节流的处理单位是 4bit 也就是 1字节,它的处理粒度较细,适合高性能的编解码运算。比如常见的数据的二进制序列化和反序列化功能:

假定我们要自行编写一个能够持久化存储 32bit 的 int、32bit 的 float、32bit 的 unsign int 三种类型数据列表的序列化和反序列化器。能够将下面这类序列持久化并在下次启动程序时复原成一个列表:

int 2
float 2.22
un int 22
int 88
float 1.2

一个简单且高效的序列化协议是:
| 1byte 反序列化为 无符号正整数,用于存储共有多少个数据 | n byte 首部,分别用于代表每个数据的类型,由于只有三种类型,每个数据的类型只需要占用 2bit 即可| data1 2 | data2 2.22 | data3 22| ...... |

这样,存储空间几乎没有任何浪费( n byte 首部 可能会存在一些浪费)。

明白了字节流的优点,我们自然会产生一个问题,为什么 java 会维护一个官方的字符流工具呢?都用性能好的字节流不行吗?

要回答这个问题,我们要从 java 的历史开看。 java 是一个 web 原生的语言,其重要应用场景就是网络处理,或者说作为网络服务器开发语言。因此, java 天然地与文字处理有密切的关系。文字处理中重要的编码协议 unicode 是变长的,所以人手再写一个编码解码器太蠢了,又因为 java 已经拥有了字符类 char ,写一个自动编解码工具的工作量不是很大,因此 java 选择将字符处理工具直接集成在了工具包里。

事实上, Reader 和 Writer 的本质还是在 字节流 工具上封装一层解码编码工具,下图是 FileReader 的重要构造函数:

alt text

可以看到其构造函数直接 new 了一个 FileInputStream 对象作为描述符传递进了父构造函数(InputStreamReader)

alt text

alt text

而在 InputStreamReader 的构造函数中,也就是上图,我们可以发现这里做了两个操作:1、设置了对应编码集的解码器;2、缓存了作为参数传递进来的 InputStream 实例对象。

alt text

InputStream 是一个父虚类,它是上面红框里面所有类的父类,我们随便挑一个实现类来阅读下:

alt text

可以看到,我们选择的 ByteArrayInputStream 实际上就是一个把 byte 数组的读取行为封装为字节流的工具类。

alt text

而 ByteArrayInputStream 的 read 函数也是简单的把 byte 数组的读取行为转为了对某些字节的 copy 操作而已。

我们已经明白了无论是 字符流 还是 字节流,其底层的编解码都是以 字节流 实现的。接着,我们随便 new 一个 FileInputStream ,调用其 read 函数,把文件的前10个字节拷贝到内存中的数组 cbuf 中,供后续操作使用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 创建文件对象,包含了文件在操作系统中的描述符
File file = new File("从一道面试题开始-Java新建线程启动一次IO都做了什么.md");
FileInputStream fileInputStream;
// 内存中的操作对象
byte[] cbuf = new byte[10];

try {

fileInputStream = new FileInputStream(file); // 通过文件描述符打开文件,开启输入流
fileInputStream.read(cbuf, 0, 10); // 读取文件的前10个字节
fileInputStream.close(); // 关闭输入流

} catch (Exception ex) {
ex.printStackTrace();
return;
}

// 处理内存中的数据副本 cbuf ......

// 处理完数据后,如果想要把结果写回文件还要重新开启输出流 ......

我们会发现它的注释中对特意描述了阻塞行为:

alt text

这也是 java.io 包中 IO 工具的特性,在数据没准备好之前,也就是 FileInputStream.read(cbuf, 0, 10) 没有完成之前,我们这个线程都是阻塞状态的。说到这里,可能有的同学还是不明白,到底是哪里 sleep 了还是 await 了才造成了当前线程的阻塞呢?

我们接着来继续阅读 read 函数:

alt text

read 函数有两个分支调用:

alt text

可以看到两个分支最后都是调用了 readBytes 实现的读取数据。但这里 readBytes 直接用一个 native 修饰符阻断了阅读。native 也是八股里面的老朋友了,即 jvm 根据不同平台实现的本地方法,使用的具体语言都是使用 c/c++ 。下面是一些相关资料,不了解的朋友可以拓展阅读一下:

Java安全详谈-JNI 底层分析 https://qftm.github.io/2022/05/29/Java-JVM-JNI/

Java IO 学习(五)跟踪三个文件IO方法的调用链 https://www.cnblogs.com/darcy-yuan/p/17591027.html

自己实现一个 native 方法 https://houbb.github.io/2020/07/19/java-basic-06-native

在 Windows 平台上,实现 native 方法的一个简单做法是,通过 javah 工具生成包含 native 方法的 class 文件对应的的本地方法 .h 头文件,实现后,编译成动态链接库文件.dll ,就可以直接直接在 java 代码中加载,如下面的 java 代码:

1
2
3
4
5
6
7
8
9
10
11
/**
* @author binbin.hou https://houbb.github.io/2020/07/19/java-basic-06-native
*/
public class Hello {
public native void h();
static{
System.load("D:\\code\\cpp\\hello\\bin\\Release\\hello.dll"); // 加载动态链接库
}
public static void main(String[] args){
new Hello().h();
}

IO_Readnative 方法实现中调用系统接口的宏定义,在 C/C++ 中,宏是一种编译工具,常用的场景是根据不同操作系统,编译相同函数的不同版本,也就是我们下图中的 windowsunix 系统两个版本的相同 native 方法的不同实现:

alt text

由于我们的 java 程序一般都部署运行在 Linux 服务器上,所以这里不深入阅读 Windows 的读文件接口都做了什么,只以 Linux 为例。上图中红框圈出来的部分是两个重要部分, FD 是操作系统的文件描述符,而 下面的 handleRead 函数定义,在 头文件的 .c 文件中实现了:

alt text

可以看到, handleRead 实际上就是用 宏RESTARTABLE 包装了 read(fd, buf, len) 函数。

alt text

宏RESTARTABLE 要做的不停地执行第一条指令,把其返回结果赋值给 result 。而我们这里的 result 是用户内存空间中的 ssize_t 对象,这里的返回值其实是 Linux 内核函数的返回结果,我们接下来深入阅读 jdk 的原生 native 方法 handleRead 中,引入的 unistd.h 头文件中定义的 read(fd, buf, len) 函数到底在做什么:

1
2
3
4
5
6
7
8
9
#include <unistd.h>

ssize_t
handleRead(FD fd, void *buf, jint len)
{
ssize_t result;
RESTARTABLE(read(fd, buf, len), result); // read(fd, buf, len) 函数是 Linux 内核头文件 unistd.h 中定义的读文件接口
return result;
}

要阅读 Liunx 内核源码,我们便无法在 Windows 的环境下方便地阅读(实际上可以通过下载 Linux 内核代码,将源码包添加到 VSCode 的 include path 中实现动态依赖解析。但对我而言太麻烦了,有一台自带源码的 Ubuntu 为什么还要折磨自己呢,笑)。如果不想阅读 Linux 内核,你也可以选择阅读 WindowsIO 接口,下两图是笔者在 Windows 上直接阅读 ReadFile 接口的示例:

alt text

alt text

说远了,我们再回到 Linuxread(fd, buf, len) 函数,直接读源码其实还是相对复杂的,但我们可以很多社区文档以及官方文档下手,如:

【高级编程】Linux read系统调用 https://cloud.tencent.com/developer/article/1058901
https://zhuanlan.zhihu.com/p/608617884
https://wuxiaoleisuperlei.github.io/2017/12/09/read/

这里强推第一篇文章以及下面这篇文章:

操作系统用户态和内核态之间的切换过程是什么_用户进程从用户态切换到内核态 https://cloud.tencent.com/developer/article/2131452

简而言之, read 函数是一个 Linux 的系统调用,也就是说,接下来执行的这段代码是涉及到操作系统管理的资源的。因此, Linux 会触发 线程/进程 的上下文切换,把线程的上下文从 用户态 提升至 内核态 ,从而执行内核态权限的代码 sys_read() 发出真正的 IO 。我们把整个调用流程用下面的三个步骤表示:

1
用户c/c++代码 -> read(fd, buf, len) -> sys_read()

其中 上下文切换便发生在

1
read(fd, buf, len) -> sys_read()

这两个步骤中间。

完成了上下文切换,当前线程的上下文就变成了 内核态 ,接着便会进行一系列的文件内容查找。我们这里用博客 https://cloud.tencent.com/developer/article/1058901 中的一张图来进一步分析这个过程都做了什么,详细的就不赘述了:

alt text

从图中看出:对于磁盘的一次读请求,首先经过虚拟文件系统层(vfs layer),其次是具体的文件系统层(例如 ext2),接下来是 cache 层(page cache 层)、通用块层(generic block layer)、IO 调度层(I/O scheduler layer)、块设备驱动层(block device driver layer),最后是物理块设备层(block device layer)。

但是这里我必须要额外说一句的是 page cache 层。 page cacheLinux 的读缓存层,它会适当地把一些文件的内容缓存在内存中,以避免每次读取时都启动 IO 。我们常用的内存查看指令 free 就展示了 page cache 的当前大小,下图中我的主机就有 33G 内存都用做了 cache (实际上这里同时包含了读写缓存,在 Linux 中读写缓存的意义是不同的。读缓存是将外存的数据放在内存中,减少不必要的IO;而写缓存是将需要写回外存的数据缓存在内存中,供批量写回使用,减少频繁的写入操作带来的额外消耗,尤其是类似对同一块数据进行频繁修改的情况)。

alt text

因此, sys_read 实际上会优先查找 page cache 中是否有目标块,并不一定会直接启动外存的 IO

清楚了 进程线程上下文(用户、内核态) 的概念,我们接下来要关注的是数据到底拷贝了几次?

深入理解 Linux的 I/O 系统 https://zhuanlan.zhihu.com/p/427193419

这里我推荐阅读上面的这篇博文,引用他的一张图:

alt text

实际上, read 函数是走 缓存 的,如果 缓存 没有,cpu 会首先将数据从磁盘加载数据到内核空间的读缓存(Read Buffer)中,再从读缓存拷贝到用户进程的页内存中。这个过程中,出现了一次 DMA COPY,并不是由 CPU 负责。DMA 拷贝未完成之前, 线程/进程 会被阻塞,直到 DMA 拷贝完成。接着 CPU 会启动第一次拷贝,把数据从内核态空间 读缓存 拷贝到用户态空间的指定位置,即我们调用 read 函数时传入的地址,这里出现了第一次 CPU COPY 。此时,用户态空间的制定地址处,已经存放好了我们要读取的若干字节内容, read_sys 的责任就结束了,因此,线程还会从 内核态 切换到 用户态 ,然后继续执行用户编写的 c/c++ 代码。在我们的 native 方法中,执行的便是宏编译后的代码,即:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
ssize_t
handleRead(FD fd, void *buf, jint len)
{
ssize_t result;
RESTARTABLE(read(fd, buf, len), result);
return result;
}
/*
* Retry the operation if it is interrupted
*/
#define RESTARTABLE(_cmd, _result) \
do { \
_result = _cmd; \ // 将_cmd的结果赋值给_result
} while ((_result == -1) && (errno == EINTR))

// 两个函数编译得到的:_result = read(fd, buf, len);

我们再把上面的过程做一下复述,如果只是准备面试,背下来这段就可以了:

1
2
3
4
5
1、当进程发起一个读取操作时,如果数据不在缓存中,会发生缺页中断,然后进程会被阻塞,进入睡眠状态,直到数据准备好。

2、这时候,DMA开始工作,将数据从磁盘拷贝到内核缓冲区(也在内存中)。而在这个DMA传输过程中,进程确实是处于阻塞状态,因为它在等待数据就绪。

3、DMA传输完成后,会触发中断,操作系统唤醒进程,然后进程继续进行,将数据从内核缓冲区复制到用户空间(这需要CPU的参与)

我们这里忽略了 read 函数的返回值是怎么从内核态 sys_read 一层层传递过来的,想了解的同学可以进一步学习 进程上下文切换 会做的栈保存等机制来思考这个问题。

读到这里,我们其实已经把 FileInputStream.read() 函数的全流程调用栈读完了。回想一下,这里面包含了很多知识点:

1、字节流、字符流 的区别和关系,它们的优点

2、native 方法是怎么实现的

3、如何阅读编写 一个 native 方法,以及 readBytes 这个 native 方法到底调用了什么

4、Linux 中 read() 函数都做了什么,为什么调用它的 线程 or 进程 会被阻塞

5、read() 函数触发的系统调用中,上下文切换是为什么

6、Linux 的文件缓存机制, read() 函数和缓存层的交互关系

我们在后续的实现中,同步 IO 就直接选择简单的 FileInputStream 进行讲解,原因是 FileInputStream 的内存拷贝不涉及到解码,是直接对二进制流的全拷贝,性能相对较高且更接近底层,不用讲解上面封装的应用功能。

1.1.1 java.nio

阻塞 IO 的模型非常简单。我们在上面的小节里面详细地阅读了阻塞 IO 为什么是阻塞的,其核心原因是阻塞 IO 直接把当前的线程作为 IO 线程,通过在 IO 过程中动态地升级线程的上下文等级实现读取数据至内存。这个过程中,待读取数据的目前状态(是否已经被加载到 os page cache,是否已经完成了 os page cache 到 user memory 的拷贝)是被封装了起来的,其对程序员是透明的。

这种处理逻辑是相当简单的,能够应付几乎大部分的应用场景,但与简单相对的是灵活性不足,这种不足主要体现在一条线程只能同时使用或处理一条 IO 流。如下面的两段代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
public class IOTest {
/**
* 死循环,不断读取输入文件的前10个字节,并输出第一个字符到控制台
*/
public static void main(String[] args) {
Scanner scanner = new Scanner(System.in); // 创建标准输入流扫描器
while (true) {
String fileName = scanner.nextLine().trim(); // 阻塞等待用户输入

File file = new File(fileName);
byte[] cbuf = new byte[10];

try (FileInputStream fileInputStream = new FileInputStream(file)) {
fileInputStream.read(cbuf, 0, 10); // 读取文件内容(阻塞直到数据就绪或流结束)
// TODO: 对 cbuf 做一些自定义的操作
} catch (FileNotFoundException e) {
System.err.println("文件不存在: " + fileName);
} catch (IOException e) {
System.err.println("读取错误: " + e.getMessage());
} catch (Exception e) {
System.err.println("未知错误: " + e.getClass().getSimpleName());
}
}
scanner.close();
}
}

上面的这段代码的功能是读取一个输入的文件名,把文件的前十个byte读取进内存,然后使用这些数据做一些操作。这个功能被死循环包括,因此除非程序被强制杀死,会一直持续这项任务。值得我们关注的是,每次输入一个文件名,上面这段程序都会在处理完上一个文件后,才能够读取下一个输入。因此,上面这段程序只能使用 CPU 中的一个运算核心,即使 CPU 有多个内核。我们可以认为,此时系统的处理瓶颈在于处理输入的速度。多核 CPU 很明显可以让我们同时读取多个不相干文件并同时进行处理,但我们的单线程阻塞机制导致我们无法有效地利用多核。

上述功能可以被理解为数据库 select 方法的一部分,select 可以粗略地划分为 接收 sql 并解析,读取本地文件并运算,返回结果 三个步骤。我们将客户端与服务器的概念简化为命令行的人工输入,并忽略了输出功能,只关注 输入 & input 这两个环节。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
public class IOTest {

public static void main(String[] args) {
Scanner scanner = new Scanner(System.in);

while (true) {
String fileName = scanner.nextLine().trim();

// 为每个文件创建独立线程
new Thread(new FileReadTask(fileName)).start();

// 主线程继续执行其他任务
System.out.println("主线程继续监听输入...");
}

scanner.close();
}


static class FileReadTask implements Runnable {
private final String fileName;

public FileReadTask(String fileName) {
this.fileName = fileName;
}

@Override
public void run() {
final String threadName = Thread.currentThread().getName();

File file = new File(fileName);
byte[] buffer = new byte[10];

try (FileInputStream fis = new FileInputStream(file)) {
int bytesRead = fis.read(buffer);
// TODO: 对 cbuf 做一些自定义的操作
} catch (FileNotFoundException e) {
System.err.printf("[%s] 文件不存在: %s\n", threadName, fileName);
} catch (IOException e) {
System.err.printf("[%s] 读取错误: %s\n", threadName, e.getMessage());
}
}
}
}

为了提高系统的吞吐速率(或者说QPS),我们自然地想到了同时处理多条输入的优化方法。也就是接收参数的线程不阻塞,它只负责启动读取线程,这样,每个输入都能被立刻响应。具体实现可以阅读上面的这段代码。这样的实现方式可以很容易地跑满 CPU ,无论有多少个核心,我们都可以通过新增无数个线程把 CPU 占满,让每个线程在被短暂阻塞期间,CPU 都有一个其它的线程在跑。这大大增加了我们这个函数的吞吐率,把性能瓶颈从:

1
2
fileInputStream.read(cbuf, 0, 10);                  // 读取文件内容(阻塞直到数据就绪或流结束)
// TODO: 对 cbuf 做一些自定义的操作

转到了有多少个 CPU 核心。通过将串行转为并行,我们大大提升了系统的吞吐率。我们已经优化的很棒了,但是这里还有没有优化空间呢?当然有!

1、过量输入缓存。线程数最好不要超过 CPU 核心数过多,否则大量线程都是等待 CPU 的状态,存储这些线程的相关信息会占用大量的内存(线程栈1M、本地变量若干......,),因此我们可以选择适当新建线程,超过系统处理能力的请求,将其输入的参数缓存,待后续处理。

2、线程复用。每来一个 input ,我们都要新建线程、启动、销毁,这一套流程消耗太大了,我们可以考虑线程复用技术,通过向线程池提交不同的任务,动态地处理变化的参数,避免线程构造销毁的开销。

关于线程池的线程复用技术,可以阅读这篇博文来理解:https://juejin.cn/post/6844904205623246861

到这里,引用 [https://zhuanlan.zhihu.com/p/651946800] 的一句话来说,我们其实已经实现了一个 N (客户端请求数量)大于 M (服务端处理客户端请求的线程数量)的 I/O 模型。我们这里并没有引入 Socket ,而是使用了手动在命令行输入指令,代替多客户端同时连接,以更清晰简单地解释吞吐率的概念。

闲话:Socket 编程

回归 NIO

alt text

我们打开 java.nio 包后发现,这里面的类更是多的吓人。但是大部分的面经都很少描述 NIO 相关的内容,我们。

从 Linux 内核角度探秘 JDK NIO 文件读写本质 https://mp.weixin.qq.com/s?__biz=Mzg2MzU3Mjc3Ng==&mid=2247486623&idx=1&sn=0cafed9e89b60d678d8c88dc7689abda&chksm=ce77cad8f90043ceaaca732aaaa7cb692c1d23eeb6c07de84f0ad690ab92d758945807239cee&token=1276722624&lang=zh_CN#rd

2、启动一个线程,都做了什么?

1.2 JAVA 的线程

alt text

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41

import java.io.BufferedReader;
import java.io.File;
import java.io.FileNotFoundException;
import java.io.FileReader;
import java.io.IOException;

public class Test {
public static void main(String[] args) {

Thread thread = new Thread(new Runnable() {

@Override
public void run() {

// 创建文件对象
File file = new File("src/main/resources/从一道面试题开始-Java新建线程启动一次IO都做了什么.md");

// 创建FileReader对象
FileReader fileReader;
try {
fileReader = new FileReader(file);
} catch (FileNotFoundException ex) {
ex.printStackTrace();
return;
}

// 读取文件 从一道面试题开始-Java新建线程启动一次IO都做了什么.md
BufferedReader bufferedReader = new BufferedReader(fileReader);
try {
bufferedReader.readLine();
bufferedReader.close();
} catch (IOException ex) {
ex.printStackTrace();
}
}
});

thread.start();
}
}