前言
Java 程序员每天都会接触文件和网络 I/O。
读取文件:
byte[] data = Files.readAllBytes(Path.of("config.json"));
写入日志:
Files.writeString(
Path.of("application.log"),
"service started\n",
StandardOpenOption.CREATE,
StandardOpenOption.APPEND
);
建立网络连接:
Socket socket = new Socket("example.com", 80);
启动一个 Java 程序后,我们还可以在 Linux 中看到:
/proc/<PID>/fd/0
/proc/<PID>/fd/1
/proc/<PID>/fd/2
继续打开文件或创建 Socket,又会出现:
3
4
5
6
这些数字就是文件描述符。
但如果继续向下追问,很多问题就没有表面看起来那么简单了:
Linux 为什么说“一切皆文件”?
硬盘、终端、管道和 Socket 明明完全不同,为什么都能使用文件描述符操作?
/home/user/a.txt这个路径是怎样找到磁盘数据的?
inode 保存文件名吗?
硬链接和软链接都能访问同一份内容,它们在底层有什么本质区别?
文件描述符中是否真的保存着当前读写偏移量?
两个进程同时打开一个文件,它们的偏移量是否互相影响?
ls > result.txt 2>&1为什么能够把两种输出写进同一个文件?
为什么
ls 2>&1 > result.txt的结果却不一样?
管道符
|是不是把一个进程的内存直接交给了另一个进程?
Java 调用
flush()后,数据是否已经进入硬盘?
文件读取没有命中 Page Cache 时,是否会触发缺页异常?
/dev/shm是不是交换分区?
这些问题分别涉及:
虚拟文件系统 VFS
目录项 dentry
inode
挂载 mount
文件描述符 fd
打开文件描述 open file description
重定向
管道
Page Cache
脏页与回写
系统调用
设备 I/O
它们看似属于不同章节,实际上构成了一条完整的 I/O 链路:
路径
↓
VFS 路径解析
↓
dentry 与 inode
↓
open()
↓
文件描述符
↓
打开文件描述
↓
read() / write()
↓
Page Cache
↓
文件系统与块设备层
↓
设备驱动
↓
存储设备
这篇文章,我们就沿着这条链路,从“一切皆文件”开始,逐步理解 Linux 文件系统和 I/O 的底层模型。
一、“一切皆文件”到底是什么意思?
1. 它不是说所有资源都存储在磁盘文件中
“一切皆文件”是一种常见的概括,但如果按照字面理解,很容易产生误解。
Linux 并不是说:
键盘是磁盘文件
网络连接是磁盘文件
进程是磁盘文件
内存也是磁盘文件
它真正强调的是:
Linux 尽量使用统一的文件接口和文件描述符模型,访问不同类型的内核对象。
普通文件可以使用:
read(fd, buffer, size);
write(fd, buffer, size);
close(fd);
管道可以使用:
read(pipe_fd, buffer, size);
write(pipe_fd, buffer, size);
Socket 同样可以使用:
read(socket_fd, buffer, size);
write(socket_fd, buffer, size);
close(socket_fd);
终端、设备节点、事件通知对象等,也经常通过文件描述符暴露给用户程序。
/proc/<PID>/fd 中除了普通文件,还可能看到:
socket:[2248868]
pipe:[2249012]
anon_inode:[eventpoll]
anon_inode:[eventfd]
Linux 会通过 /proc/<PID>/fd 为进程打开的文件描述符提供可观察入口;普通文件通常显示为路径,管道和 Socket 显示为带 inode 编号的特殊目标,而 epoll、eventfd 等对象则可能显示为 anon_inode。
因此,“一切皆文件”更准确的理解是:
不同资源
↓
被抽象成可打开的内核对象
↓
通过文件描述符引用
↓
使用相对统一的系统调用操作
2. 统一接口不代表所有对象能力相同
虽然普通文件、Socket 和管道都能用文件描述符表示,但它们支持的操作并不完全一样。
普通磁盘文件通常支持:
read
write
lseek
mmap
fsync
管道通常支持:
read
write
poll
close
但不能对匿名管道执行有意义的随机定位:
lseek(pipe_fd, 100, SEEK_SET);
字符设备是否支持随机定位、内存映射或特殊控制,也由具体驱动决定。
因此:
文件描述符统一了对象的引用方式,但没有抹平对象之间的能力差异。
对于设备特有操作,Linux 还提供:
ioctl(fd, request, argument);
让驱动程序实现设备专属控制。
二、Linux 有哪些文件类型?
使用:
ls -l
时,权限字符串的第一个字符表示文件类型。
常见类型包括:
| 标记 | 类型 | 示例 |
|---|---|---|
- | 普通文件 | 文本、程序、图片 |
d | 目录 | /home、/etc |
l | 符号链接 | 指向另一个路径 |
c | 字符设备 | 终端、部分设备节点 |
b | 块设备 | 磁盘、分区、Loop 设备 |
p | FIFO 命名管道 | 具有路径名的管道 |
s | Socket | Unix Domain Socket |
inode 的文件类型字段可以区分普通文件、目录、符号链接、块设备、字符设备、FIFO 和 Socket 等对象。
1. 普通文件
普通文件主要保存字节序列。
Linux 内核并不关心一个普通文件究竟是:
Java 源代码
JPEG 图片
JSON
压缩包
可执行程序
文件格式通常由用户空间程序解释。
2. 目录也是一种文件系统对象
目录不是“装着文件内容的盒子”。
从文件系统角度看,目录主要维护:
名称
↓
inode
之间的关联。
可以粗略理解为:
目录 /project
├── README.md → inode 1001
├── pom.xml → inode 1002
└── src → inode 1003
文件名属于目录项,而不是文件数据本身。
3. 字符设备与块设备
字符设备通常以字节流或设备定义的方式交互,例如:
终端
串口
/dev/null
/dev/zero
块设备则向内核提供可按块寻址的存储接口,例如:
/dev/sda
/dev/nvme0n1
/dev/loop0
不过,把二者简单理解成:
字符设备只能顺序读
块设备一定可以任意随机读
也不够严谨。
设备真正支持哪些操作,最终由驱动和设备特性决定。
三、VFS 为什么能够统一不同文件系统?
1. 不同目录可能来自完全不同的文件系统
一个 Linux 系统中可能同时存在:
ext4
XFS
Btrfs
tmpfs
procfs
sysfs
NFS
OverlayFS
例如:
/ → ext4
/boot → 独立 ext4 分区
/dev/shm → tmpfs
/proc → procfs
/sys → sysfs
/mnt/share → NFS
应用程序访问这些路径时,仍然可以使用相同的接口:
open();
read();
write();
close();
提供这层统一抽象的就是:
VFS
Virtual File System
虚拟文件系统
VFS 并不是一种磁盘格式。
它是 Linux 内核中的抽象层,为不同文件系统定义统一对象模型和操作接口。
2. VFS 的几个核心对象
理解 VFS,可以先抓住四个重要对象:
superblock
inode
dentry
file
Superblock
代表一个已经挂载的文件系统实例。
它描述:
文件系统类型
块大小
挂载状态
文件系统操作
根目录信息
inode
代表一个具体的文件系统对象。
例如:
普通文件
目录
FIFO
设备节点
inode 通常保存:
文件类型
权限
所有者
大小
时间戳
硬链接计数
文件系统内部数据定位信息
dentry
dentry 可以理解为目录项缓存。
它主要把:
父目录
+
文件名
关联到某个 inode。
例如:
父目录 inode 100
文件名 config.yaml
↓
文件 inode 205
file
这里的 file 不是磁盘上的文件,而是:
某一次打开操作在内核中的运行时对象。
它通常对应后文要讲的:
打开文件描述
open file description
VFS 文档说明,dentry 通常指向 inode;同一个 inode 可以被多个 dentry 指向,这正是硬链接能够存在的基础。
3. 从路径到 inode
假设程序打开:
/home/alice/project/config.yaml
内核需要逐层解析:
/
↓
home
↓
alice
↓
project
↓
config.yaml
每一层都涉及:
当前目录 inode
+
下一级名称
↓
查找 dentry
↓
获得下一级 inode
因此,路径解析的核心不是“拿着完整字符串去磁盘搜索”,而是逐级完成:
目录项名称 → inode
的转换。
四、挂载到底做了什么?
1. Linux 只有一棵以 / 为根的路径树
Windows 常见的是:
C:
D:
E:
Linux 则把不同文件系统接入同一棵路径树:
/
├── boot
├── home
├── proc
├── dev
├── mnt
└── var
挂载的本质是:
把某个文件系统的根,连接到当前路径树中的一个目录。
例如:
mount /dev/sdb1 /mnt/data
可以理解为:
/dev/sdb1 中的文件系统根
↓
显示在 /mnt/data
mount 会让内核把源文件系统附着到目标目录;挂载期间,目标目录原有内容会暂时不可见,路径会指向新挂载文件系统的根。
2. 挂载不会删除目录原来的内容
假设:
/mnt/data
└── old.txt
然后将一个文件系统挂载到 /mnt/data:
mount /dev/sdb1 /mnt/data
此时看到的可能变成:
/mnt/data
├── image.jpg
└── database.db
old.txt 并没有被删除,只是被挂载点遮住。
卸载后:
umount /mnt/data
原来的内容会再次出现。
3. 挂载关系是命名空间的一部分
现代 Linux 中,进程可以处于不同的 Mount Namespace。
不同 Mount Namespace 中的进程,可以看到不同的挂载树:
进程 A 看见:
/mnt/data → 文件系统 A
进程 B 看见:
/mnt/data → 文件系统 B
Mount Namespace 隔离的是不同进程所看到的挂载列表和目录层次,这也是容器文件系统视图隔离的重要基础。
五、inode 到底是什么?
1. inode 不保存文件名
inode 保存文件对象的元数据,但通常不保存文件名。
文件名保存在目录项中:
目录项:
report.txt → inode 6821
inode 可能保存:
类型
权限
UID
GID
文件大小
时间戳
硬链接数量
数据块映射
所以:
文件名
≠
文件本体
更准确的模型是:
文件名
↓
目录项 dentry
↓
inode
↓
文件数据
2. inode 编号不是全系统唯一
经常有人说:
每个文件都有唯一的 inode 号。
这句话需要补充范围。
正确说法是:
inode 编号只保证在同一个文件系统内唯一。
两个不同文件系统完全可能出现相同的 inode 编号。
这也是普通硬链接不能跨文件系统创建的原因之一。
查看 inode 可以使用:
stat file.txt
或者:
ls -li file.txt
输出中可能看到:
6821 -rw-r--r-- 1 user user 128 file.txt
其中:
6821
就是该目录项指向的 inode 编号。
六、硬链接为什么没有“原文件”?
1. 创建硬链接
echo "hello" > source.txt
ln source.txt hard-link.txt
查看 inode:
ls -li source.txt hard-link.txt
可能得到:
6821 -rw-r--r-- 2 user user 6 source.txt
6821 -rw-r--r-- 2 user user 6 hard-link.txt
两个名称拥有相同 inode:
source.txt ─┐
├──→ inode 6821 → 文件数据
hard-link.txt ─┘
硬链接并不是:
hard-link.txt → source.txt
而是:
source.txt → inode 6821
hard-link.txt → inode 6821
两个名称地位相同。
Linux 的 link() 会为现有文件创建一个新名称,两个名称都引用同一个文件对象,无法从 inode 层面区分哪个名称是“原始名称”。
2. 修改任意名称都会看到相同内容
echo "world" >> hard-link.txt
cat source.txt
结果为:
hello
world
因为两条路径最终访问的是同一个 inode 和同一份文件数据。
3. 删除一个名称不等于删除文件数据
执行:
rm source.txt
只是在目录中删除:
source.txt → inode 6821
此时:
hard-link.txt → inode 6821
仍然存在,文件数据不会消失。
inode 中的硬链接计数会从:
2 → 1
只有当:
硬链接计数归零
+
没有进程继续打开该文件
时,文件系统才可以真正回收相关数据。
这也解释了一个常见现象:
日志文件已经被
rm删除,但进程仍然打开着它,磁盘空间可能暂时不会释放。
4. 硬链接的限制
普通硬链接通常:
不能跨文件系统
不能由普通用户随意链接目录
如果要跨文件系统或链接目录,通常应使用符号链接。
七、软链接保存的是什么?
1. 创建软链接
ln -s source.txt symbolic-link.txt
查看:
ls -li source.txt symbolic-link.txt
两个文件会有不同的 inode:
6821 -rw-r--r-- 1 user user 6 source.txt
6910 lrwxrwxrwx 1 user user 10 symbolic-link.txt -> source.txt
结构为:
symbolic-link.txt
↓
自己的 inode
↓
保存字符串 "source.txt"
↓
再次进行路径解析
↓
source.txt 的 inode
符号链接本身是一个独立文件,它保存的是目标路径,而不是直接共享目标文件的 inode 或数据块。
2. 为什么修改软链接能改变目标内容?
当程序执行:
echo "new" >> symbolic-link.txt
内核会先解析符号链接保存的路径:
symbolic-link.txt
↓
source.txt
↓
目标 inode
最终写入的是目标文件。
并不是因为:
软链接和目标共享同一数据块
而是因为软链接被路径解析过程继续跟随到了目标文件。
3. 为什么删除目标后软链接会失效?
rm source.txt
cat symbolic-link.txt
此时符号链接仍然保存:
source.txt
但该路径已经无法找到目标,因此成为:
悬空链接
Dangling Symbolic Link
软链接自身并没有被删除,只是目标路径失效了。
4. 软链接和硬链接对比
硬链接:
多个名称 → 同一个 inode
软链接:
链接文件自己的 inode
↓
保存目标路径
↓
再次解析目标
因此:
| 特征 | 硬链接 | 软链接 |
|---|---|---|
| inode 是否相同 | 相同 | 不同 |
| 是否增加目标链接计数 | 是 | 否 |
| 是否可跨文件系统 | 否 | 可以 |
| 目标删除后 | 其他硬链接仍有效 | 变成悬空链接 |
| 是否可链接目录 | 通常不允许 | 可以 |
八、文件描述符到底是什么?
1. fd 只是一个整数索引
程序执行:
int fd = open("data.txt", O_RDONLY);
可能返回:
fd = 3
3 并不是文件地址,也不是 inode 编号。
它只是当前进程文件描述符表中的一个索引:
进程文件描述符表
┌────┬──────────────────────┐
│ 0 │ 标准输入 │
│ 1 │ 标准输出 │
│ 2 │ 标准错误 │
│ 3 │ 某个打开文件描述 │
└────┴──────────────────────┘
不同进程可以同时拥有 fd 3:
进程 A 的 fd 3 → a.txt
进程 B 的 fd 3 → socket
二者没有冲突。
文件描述符的唯一性范围只是:
当前进程
2. 三层模型
理解文件描述符,最重要的是区分三层:
进程文件描述符表
↓
打开文件描述
↓
inode / 文件对象
完整结构可以画成:
进程
┌─────────────────────┐
│ fd 3 ───────────────┼────┐
│ fd 4 ───────────────┼──┐ │
└─────────────────────┘ │ │
│ │
┌──────▼─▼─────────┐
│ 打开文件描述 │
│ file offset │
│ status flags │
│ access mode │
└────────┬──────────┘
│
▼
dentry / inode
│
▼
文件数据
open() 会创建一个新的打开文件描述,打开文件描述记录当前文件偏移量和文件状态标志;返回给进程的 fd 只是对这个打开文件描述的引用。
3. 偏移量不直接属于 fd 数字
课程中经常说:
文件描述符中保存了偏移量。
为了便于理解可以这样描述,但更准确的说法是:
偏移量属于打开文件描述,fd 指向打开文件描述。
这一区别非常重要,因为多个 fd 有时可以指向同一个打开文件描述。
九、两个进程打开同一文件,偏移量是否独立?
答案是:
取决于它们是否引用同一个打开文件描述。
1. 分别调用 open:通常偏移量独立
进程 A:
int fdA = open("data.txt", O_RDONLY);
进程 B:
int fdB = open("data.txt", O_RDONLY);
两次独立的 open() 会创建两个打开文件描述:
进程 A fd 3
↓
打开文件描述 A
offset = 100
↓
同一个 inode
进程 B fd 3
↓
打开文件描述 B
offset = 0
↓
同一个 inode
它们访问同一个文件数据,但偏移量互相独立。
2. dup:两个 fd 共享偏移量
int fd1 = open("data.txt", O_RDONLY);
int fd2 = dup(fd1);
此时:
fd1 ─┐
├──→ 同一个打开文件描述 → 同一个偏移量
fd2 ─┘
如果通过 fd1 读取 100 字节,fd2 看到的偏移量也会向后移动。
dup() 创建的新 fd 与原 fd 引用同一个打开文件描述,因此共享文件偏移量和文件状态标志。
3. fork:父子进程可能共享偏移量
父进程打开文件后执行 fork():
父进程 fd 3 ─┐
├──→ 同一个打开文件描述
子进程 fd 3 ─┘
父子进程各自有独立的文件描述符表,但对应 fd 引用的是同一个打开文件描述,所以共享偏移量。
因此,不能简单说:
不同进程的文件偏移量一定独立
更准确的规则是:
独立 open
↓
通常是不同打开文件描述
↓
偏移量独立
dup / fork 继承
↓
可能引用同一个打开文件描述
↓
偏移量共享
4. read 如何改变偏移量?
对支持定位的文件执行:
read(fd, buffer, 100);
内核会从当前偏移量开始读取,并将偏移量增加实际读取的字节数。
如果希望在指定位置读取,但不修改共享偏移量,可以使用:
pread(fd, buffer, size, position);
类似地,指定位置写入可以使用:
pwrite(fd, buffer, size, position);
十、怎样查看一个进程打开了什么?
1. /proc/<PID>/fd
查看当前 Bash 的 PID:
echo $$
查看它的文件描述符:
ls -l /proc/$$/fd
可能看到:
0 -> /dev/pts/0
1 -> /dev/pts/0
2 -> /dev/pts/0
255 -> /dev/pts/0
/proc/<PID>/fd 中每个条目的名称就是 fd 编号,条目本身是指向实际对象的符号链接;0、1、2 分别代表标准输入、标准输出和标准错误。
2. 打开一个自定义 fd
在 Bash 中:
exec 8< data.txt
再次查看:
ls -l /proc/$$/fd
会出现:
8 -> /path/to/data.txt
从 fd 8 读取一行:
read line <&8
echo "$line"
关闭 fd:
exec 8<&-
3. lsof
查看当前进程打开的对象:
lsof -p $$
查看偏移量等信息:
lsof -o -p $$
需要注意,lsof 输出中的:
cwd
rtd
txt
mem
不全是普通数字 fd。
它们分别可能表示:
cwd:当前工作目录
rtd:进程根目录
txt:可执行程序代码
mem:内存映射文件
十一、0、1、2 为什么如此重要?
Unix 进程通常约定:
0 → stdin → 标准输入
1 → stdout → 标准输出
2 → stderr → 标准错误
Java 中大致对应:
System.in
System.out
System.err
不过,标准输入输出并不天然等于:
键盘和显示器
它们只是进程启动时继承的文件描述符。
例如,通过 SSH 登录服务器时:
stdin
stdout
stderr
↓
伪终端 /dev/pts/N
↓
SSH 连接
↓
本地终端窗口
所以 Java 执行:
System.out.println("hello");
时,数据并不是“直接打印到显示器”。
更准确的链路是:
Java PrintStream
↓
底层输出流
↓
文件描述符 1
↓
伪终端设备
↓
SSH 服务
↓
网络连接
↓
本地终端
十二、重定向到底改变了什么?
1. 重定向由 Shell 在程序启动前完成
执行:
ls > result.txt
并不是 ls 程序自己理解了 >。
Bash 会先:
打开 result.txt
↓
得到一个 fd
↓
让标准输出 fd 1 指向这个打开文件
↓
再启动 ls
因此 ls 仍然只是向:
fd 1
写数据。
它并不知道 fd 1 此时指向终端还是文件。
Bash 官方手册说明,重定向由 Shell 在命令执行前处理,可以打开、关闭或复制文件描述符。
2. 输出重定向
ls > result.txt
等价于:
ls 1> result.txt
如果文件已存在,普通 > 默认会截断文件。
追加写入:
ls >> result.txt
等价于:
ls 1>> result.txt
3. 错误输出重定向
只重定向标准错误:
ls /not-exist 2> error.txt
此时:
stdout → 终端
stderr → error.txt
分别重定向:
command > normal.log 2> error.log
4. 2>&1 不是把 2 写入名为 1 的文件
2>&1
表示:
让 fd 2 成为 fd 1 当前目标的副本
其中 & 告诉 Shell:
右边的 1 是文件描述符
而不是文件名
如果写成:
2>1
含义是:
把 stderr 写入一个名为 1 的文件
5. 为什么重定向顺序非常重要?
下面两条命令不等价:
command > all.log 2>&1
command 2>&1 > all.log
第一条
command > all.log 2>&1
按从左到右处理:
第一步:
fd 1 → all.log
第二步:
fd 2 → fd 1 当前指向的位置
→ all.log
最终:
stdout → all.log
stderr → all.log
第二条
command 2>&1 > all.log
处理过程:
初始:
fd 1 → 终端
fd 2 → 终端
第一步:
fd 2 → fd 1 当前目标
→ 终端
第二步:
fd 1 → all.log
最终:
stdout → all.log
stderr → 终端
Bash 会严格按照重定向出现的顺序,从左到右处理,因此两种写法结果不同。
6. 文件描述符编号与操作符之间为什么不能有空格?
command 2>error.log
这里的 2 是重定向目标 fd。
但:
command 2 > error.log
Shell 可能把 2 当作传给程序的普通参数,再把标准输出重定向到文件。
因此:
2>file
中的 fd 编号与操作符之间不能分开。
十三、管道是怎样连接两个进程的?
1. 管道不是把两个进程的内存连在一起
执行:
cat data.txt | grep error
Shell 会创建一个内核管道。
管道具有:
读端
写端
并返回两个文件描述符。
然后 Shell 大致会完成:
cat 的 stdout fd 1
↓
管道写端
grep 的 stdin fd 0
↓
管道读端
完整结构为:
cat 进程
fd 1
│
▼
管道写端
┌──────────────────┐
│ 内核管道缓冲区 │
└──────────────────┘
管道读端
│
▼
fd 0
grep 进程
Linux 管道是一个单向字节流通道,创建管道会得到分别表示读端和写端的两个 fd;字节流本身没有消息边界。
2. 管道有容量限制
管道并不是无限大的。
如果写入者持续写,而读取者不消费:
管道缓冲区逐渐填满
↓
写入者可能阻塞
应用程序不应该依赖某个固定的管道容量。Linux 的默认容量和限制会受到内核版本及系统配置影响,也可以通过 fcntl 查询或调整。
3. 为什么管道可以组合命令?
例如获取文件第八行:
head -n 8 data.txt | tail -n 1
流程为:
head 读取文件前 8 行
↓ stdout
管道
↓ stdin
tail 只保留最后 1 行
管道的价值不在于 head 和 tail 知道彼此存在。
而是它们都遵循统一约定:
从 stdin 读取
向 stdout 写入
因此 Shell 可以自由重新连接它们。
十四、为什么管道中的变量经常“丢失”?
1. Bash 的管道元素通常在子进程环境中执行
看下面的命令:
value=0
echo hello | {
read text
value=100
}
echo "$value"
结果通常仍是:
0
因为管道中的命令通常在各自的子 Shell 环境中执行。
子进程修改自己的变量,不会反向修改父 Shell。
Bash 手册规定,多命令管道中的各个元素通常在独立子 Shell 中执行;开启 lastpipe 且未启用作业控制时,最后一个元素可能由当前 Shell 执行。
2. 花括号并不总能避免子进程
单独执行:
{
value=100
}
花括号中的命令通常在当前 Shell 环境执行。
但放入管道后:
echo hello | {
read text
value=100
}
整个花括号代码块是管道元素,因此仍可能在子 Shell 环境执行。
3. 小括号明确创建子 Shell
value=0
(
value=100
echo "$value"
)
echo "$value"
输出:
100
0
小括号中的修改只存在于子 Shell。
4. $$ 和 $BASHPID 为什么可能不同?
Bash 中:
echo $$
表示调用 Shell 的 PID。
在某些子 Shell 环境中,$$ 仍会展开为调用 Shell 的 PID,而:
echo "$BASHPID"
表示当前 Bash 进程的实际 PID。
因此,观察管道子进程时,更适合使用:
echo "$BASHPID"
而不能仅根据 $$ 判断是否创建了子进程。
5. 普通变量和环境变量
父 Shell 定义:
name=alice
启动新的 Bash:
bash
echo "$name"
通常无法读取。
导出后:
export name=alice
bash
echo "$name"
子进程可以继承。
但继承是:
父进程环境的副本
子进程修改后,仍然不能回写父进程环境。
十五、一个普通文件如何变成块设备?
1. 创建磁盘镜像文件
可以先创建一个 100 MiB 的普通文件:
truncate -s 100M disk.img
也可以使用:
dd if=/dev/zero of=disk.img bs=1M count=100 status=progress
/dev/zero 是一个字符设备,读取它会持续得到零字节。
这里的:
bs=1M
count=100
表示进行 100 次、每次 1 MiB 的复制。
需要注意:
dd 的 bs
≠
文件系统块大小
≠
硬盘物理扇区大小
≠
Page Cache 页面大小
bs 只是这次 dd 操作使用的输入输出块大小。
2. 绑定 Loop 设备
sudo losetup --find --show disk.img
可能返回:
/dev/loop0
Loop 设备会把普通文件中的数据块映射成一个块设备:
disk.img
↓
/dev/loop0
↓
表现为块设备
Linux Loop 设备本身是块设备,但其数据块可以映射到普通文件,而不一定映射到真实硬盘设备。
3. 创建文件系统
sudo mkfs.ext4 /dev/loop0
这一步会在 disk.img 对应的字节空间中写入:
Superblock
inode 表
块位图
目录结构
日志等文件系统元数据
此时:
disk.img
不再只是“一堆零”,而是包含了一个 ext4 文件系统。
4. 挂载
sudo mkdir -p /mnt/disk-lab
sudo mount /dev/loop0 /mnt/disk-lab
然后:
echo "hello" | sudo tee /mnt/disk-lab/hello.txt
数据链路为:
/mnt/disk-lab/hello.txt
↓
VFS
↓
ext4
↓
/dev/loop0
↓
disk.img
这说明:
一个普通文件可以作为另一个文件系统的底层存储。
5. 卸载与清理
sudo umount /mnt/disk-lab
sudo losetup -d /dev/loop0
卸载后,挂载目录中的文件不可见,但数据仍保存在:
disk.img
重新绑定并挂载,就可以再次访问。
十六、chroot 为什么不是容器?
1. chroot 改变的是路径解析根目录
假设我们准备一个目录:
/mnt/disk-lab
├── bin
├── lib
├── lib64
└── data
执行:
sudo chroot /mnt/disk-lab /bin/bash
在新 Bash 中访问:
/
实际上会从:
/mnt/disk-lab
开始解析。
chroot() 会改变调用进程解析以 / 开头路径时使用的根目录,并由其子进程继承。
2. 为什么只复制 bash 还不够?
/bin/bash 通常是动态链接程序。
查看依赖:
ldd /bin/bash
可能得到:
libtinfo.so
libc.so
动态链接器
如果新根目录中没有这些库:
chroot: failed to run command '/bin/bash'
因此需要把程序和依赖库放到新根目录中的对应位置。
进入后,如果只复制了 Bash,没有复制 ls、cat 等程序,那么:
ls
会提示找不到。
但 Bash 内置命令仍然可用,例如:
echo
cd
pwd
read
printf
3. chroot 只改变根目录,不等于安全沙箱
chroot() 不会自动隔离:
进程 ID
网络
用户
主机名
IPC
资源限制
内核
它也不会自动关闭原有文件描述符或改变当前工作目录。
官方手册明确指出,chroot() 只修改路径解析的一部分,不应被单独当作完整安全沙箱;已有 fd 甚至可能继续访问新根目录之外的对象。
现代容器还会组合:
Mount Namespace
PID Namespace
Network Namespace
UTS Namespace
IPC Namespace
User Namespace
Cgroup
Capabilities
Seccomp
namespace 会把全局系统资源包装成进程看到的隔离实例,是实现容器的重要基础。
因此:
chroot
只改变文件路径视图的一部分
容器
是文件系统视图、进程、网络、权限和资源控制的组合
十七、Page Cache 为什么存在?
1. 存储设备远慢于内存和 CPU
如果应用每读取一个字节,都必须等待一次真实设备操作:
应用调用 read
↓
设备读取
↓
等待完成
↓
返回一个字节
性能会非常差。
因此,Linux 会使用内存缓存文件数据:
Page Cache
页缓存
读取文件时,内核会先检查数据是否已经存在于 Page Cache。
read()
↓
Page Cache 中是否存在?
├── 是:直接复制给用户空间
└── 否:发起底层 I/O,读取后放入 Page Cache
Linux 的文件读取路径会在 Page Cache 中查找相应 folio;顺序访问时,内核还可能执行预读,将尚未显式请求的数据提前读入缓存。
2. Page Cache 不一定永远以固定 4 KiB 为对象
在很多 x86-64 系统中,基础页大小通常是:
4 KiB
因此初学时常把 Page Cache 理解成一组 4 KiB 页面。
但现代 Linux 内核广泛使用:
folio
表示一个或多个连续页面组成的内存管理对象。
基础页大小也与体系结构和内核配置有关。
所以更准确的表达是:
Page Cache 以页面或 folio 为基本管理对象,4 KiB 是常见值,而不是所有系统永远固定的唯一大小。
3. 多个进程会共享同一份文件缓存
进程 A 和进程 B 分别打开同一文件:
进程 A → 打开文件描述 A ─┐
├→ 同一个 inode → 同一份 Page Cache
进程 B → 打开文件描述 B ─┘
它们可以拥有不同偏移量,却共享相同的文件缓存数据。
因此:
偏移量通常属于打开文件描述
文件缓存通常关联文件对象
这是两个不同层次。
十八、一次普通 read() 经历了什么?
假设 Java 执行:
byte[] data = Files.readAllBytes(Path.of("data.txt"));
可以抽象为:
Java API
↓
JDK 本地调用
↓
read() 等系统调用
↓
进入 Linux 内核
↓
根据 fd 找到打开文件描述
↓
找到文件和当前位置
↓
查询 Page Cache
1. Page Cache 命中
Page Cache 命中
↓
从内核缓存复制到用户空间缓冲区
↓
更新文件偏移量
↓
read() 返回
这种情况下不需要真实读取磁盘。
2. Page Cache 未命中
Page Cache 未命中
↓
文件系统准备 I/O 请求
↓
块设备层与驱动提交请求
↓
当前任务可能进入睡眠
↓
设备完成数据传输
↓
完成通知或中断
↓
数据进入 Page Cache
↓
唤醒等待任务
↓
复制数据到用户空间
3. 普通 read() 未命中不是缺页异常
这里需要纠正一个常见误区。
当程序执行普通:
read(fd, buffer, size);
而 Page Cache 中没有数据时,通常表现为:
系统调用中的阻塞式文件 I/O
它不等于用户地址空间发生缺页异常。
真正典型的文件缺页场景是:
mmap()
把文件映射到虚拟地址空间后,CPU 第一次访问尚未建立物理页面的映射:
访问 mmap 地址
↓
Page Fault
↓
内核从文件准备页面
↓
更新页表
↓
重新执行原指令
Linux 内存管理接口分别描述了系统调用读取触发 Page Cache 预读,以及内存映射区域发生 Page Fault 时通过 filemap_fault() 装入文件数据的过程。
因此:
普通 read() 缓存未命中
≠
缺页异常
访问尚未装入的 mmap 页面
=
可能触发缺页异常
十九、DMA 是否负责完成整个 I/O?
DMA 的全称是:
Direct Memory Access
直接内存访问
它的作用是让设备能够在 CPU 不逐字节搬运数据的情况下,与内存进行数据传输。
没有 DMA 时,可以粗略想象为:
设备
↓
CPU 反复搬运
↓
内存
使用 DMA 后:
CPU 配置 I/O 请求和缓冲区
↓
设备或 DMA 引擎传输数据
↓
CPU 同时执行其他任务
↓
传输完成后通知 CPU
不过,把 DMA 描述成:
完全不需要 CPU 的独立协处理器
并不准确。
CPU 和内核仍然需要:
构建 I/O 请求
设置描述符
配置设备
管理内存映射
处理中断或完成队列
处理错误
唤醒任务
DMA 主要减少的是:
CPU 亲自搬运每一个数据字节的开销
而不是让 CPU 和操作系统完全退出 I/O 流程。
二十、写文件为什么没有立即落盘?
1. Buffered I/O 存在两层缓存
Java 程序可能使用:
BufferedOutputStream output =
new BufferedOutputStream(new FileOutputStream("data.log"));
此时至少可能存在:
Java 用户空间缓冲区
↓
Linux Page Cache
↓
存储设备
写入过程为:
Java write()
↓
先进入 BufferedOutputStream
↓
缓冲区满或 flush()
↓
进入 FileOutputStream
↓
write() 系统调用
↓
写入 Page Cache
↓
页面被标记为 Dirty
↓
稍后回写存储设备
2. Java flush() 不等于磁盘持久化
调用:
output.flush();
主要保证:
Java 缓冲区中的数据
↓
写入底层输出流
如果底层是文件,数据通常只是交给了操作系统。
Java 官方文档明确指出,普通流的 flush() 只能保证数据传递到操作系统提供的目标,并不保证已经写入物理磁盘。
3. write() 成功也不代表已经落盘
Linux write() 返回成功只表示内核接受了数据。
它并不保证数据已经提交到持久存储;部分错误甚至可能到后续 write()、fsync() 或 close() 时才被发现。
同样:
close(fd);
成功也不能单独保证数据已经物理落盘。
4. 什么是脏页?
Page Cache 页面被修改后,内存内容比存储设备中的内容更新。
此时页面会被标记为:
Dirty
脏
可以理解为:
Page Cache:新数据
磁盘:旧数据
内核之后会通过回写机制,把脏数据异步写入后端文件系统。
5. 脏页什么时候写回?
不能简单写死为:
每 5 秒写一次
达到物理内存 10% 就写
Linux 的回写行为由多个可配置参数共同控制,例如:
dirty_background_bytes
dirty_background_ratio
dirty_bytes
dirty_ratio
dirty_expire_centisecs
dirty_writeback_centisecs
其中:
dirty_background_*
决定后台回写何时开始
dirty_*
决定产生脏页的进程何时需要参与回写或受到限制
dirty_expire_centisecs
决定脏数据多久后具备被回写的资格
这些阈值基于可用内存等指标,并非永远固定为某个百分比或时间。
查看当前设置:
sysctl vm.dirty_background_ratio
sysctl vm.dirty_ratio
sysctl vm.dirty_expire_centisecs
sysctl vm.dirty_writeback_centisecs
6. 怎样要求数据持久化?
Linux 中通常使用:
fsync(fd);
或者:
fdatasync(fd);
fsync() 会同步文件数据以及需要的文件元数据;如果还需要保证新文件名对应的目录项已经持久化,某些严格场景还需要对父目录执行 fsync()。
Java 中可以使用:
try (FileOutputStream output = new FileOutputStream("data.log")) {
output.write(data);
output.flush();
output.getFD().sync();
}
或者:
try (FileChannel channel = FileChannel.open(
Path.of("data.log"),
StandardOpenOption.CREATE,
StandardOpenOption.WRITE
)) {
channel.write(buffer);
channel.force(true);
}
FileDescriptor.sync() 会在可能的情况下要求系统缓冲区同步到底层设备;如果还有 Java 用户空间缓冲区,应先执行 flush()。
二十一、多个线程写同一个文件一定会互相覆盖吗?
这要区分多个问题。
1. 是否共享打开文件描述?
如果多个线程使用同一个 Java FileOutputStream:
多个线程
↓
同一个 Java 对象
↓
同一个 fd
↓
同一个打开文件描述
↓
共享偏移量
它们需要考虑:
写入顺序
数据是否交错
高层缓冲区线程安全
业务记录边界
如果各自独立打开文件:
线程 A open
线程 B open
则可能拥有独立打开文件描述和独立偏移量。
如果都从偏移量 0 开始写,就可能覆盖同一区域。
2. Page Cache 不是导致覆盖的根本原因
即使没有 Page Cache,两个执行者写入相同文件区间也可能覆盖。
问题的核心是:
它们是否写入同一位置
写入操作是否构成需要的原子单位
是否建立同步协议
Page Cache 只是文件数据被缓存和修改的位置,不会自动解决业务级写入竞争。
3. O_APPEND 能解决什么?
以追加模式打开文件时,每次写入会在写操作前定位到文件尾部。
但它并不能自动保证:
多次 write 调用组成的一条业务记录
不会与其他线程的多次调用交错。
例如一条日志被拆成三次写入:
时间
级别
消息正文
其他线程仍可能插入自己的输出。
通常应该让一条记录尽量通过一次写操作提交,或者在应用层进行串行化。
二十二、/dev/shm 和 Swap 有什么区别?
1. /dev/shm 通常是 tmpfs
查看:
findmnt /dev/shm
可能看到:
tmpfs on /dev/shm type tmpfs
tmpfs 是一种内容位于虚拟内存中的文件系统,Linux 通常把 /dev/shm 作为 POSIX 共享内存对象的实现位置。
2. tmpfs 不等于“永远只占 RAM”
tmpfs 的数据通常使用内存管理系统中的页面。
在允许的情况下,其中部分页面可能使用 Swap 作为后备存储。
因此:
tmpfs
≠
固定驻留物理内存的 RAM Disk
tmpfs
≠
Swap 分区
更准确地说:
tmpfs
是一个内存型文件系统
Swap
是匿名内存和部分 tmpfs 页面可使用的后备存储
3. Page Cache 不会简单地全部写入 Swap
文件支持的干净 Page Cache 页面通常可以直接丢弃。
以后再次需要时,从原文件重新读取即可。
匿名内存没有原始文件作为后备,例如:
Java 堆
线程栈
匿名 mmap
这些页面在内存压力下才更可能被换出到 Swap。
因此:
Swap 主要不是用来保存所有 Page Cache
文件缓存与匿名内存的回收方式并不相同。
二十三、/proc 为什么看起来像文件系统?
/proc 是一个伪文件系统。
它并不是把所有内容持久化到磁盘,而是作为内核数据结构的接口。
例如:
/proc/cpuinfo
/proc/meminfo
/proc/<PID>/status
/proc/<PID>/fd
/proc/<PID>/maps
/proc/<PID>/mountinfo
读取这些“文件”时,内核会根据当前状态生成内容。
Linux 内核文档将 procfs 描述为内核内部数据结构的接口,可以用于查询系统信息,并通过部分条目修改运行时参数。
这再次体现了“一切皆文件”的价值:
内核状态
↓
通过文件系统路径暴露
↓
可以使用 cat、grep、read 等通用工具观察
不过:
/proc 中的进程是文件
并不是严格含义。
准确地说是:
内核把进程及系统状态通过伪文件系统接口呈现出来。
二十四、系统调用现在还是通过 int 0x80 吗?
int 0x80 是经典 32 位 x86 Linux 系统调用入口。
在原生 x86-64 Linux 中,更常见的是:
syscall
不同 CPU 架构和 ABI 使用不同的系统调用进入方式。
因此不能统一写成:
所有 Linux 系统调用都是 int 0x80
更准确的抽象是:
用户程序调用 libc 或 JDK
↓
准备系统调用号与参数
↓
执行当前架构规定的系统调用入口指令
↓
CPU 切换到内核态
↓
内核执行系统调用处理
↓
返回用户态
对 Java 程序员而言,需要掌握的是:
用户态
↓
系统调用边界
↓
内核态
而不是把某一条特定架构指令当作所有系统的固定实现。
二十五、一次 Java 文件写入的完整链路
假设执行:
try (BufferedOutputStream output =
new BufferedOutputStream(
new FileOutputStream("order.log", true))) {
output.write("order created\n".getBytes(StandardCharsets.UTF_8));
output.flush();
}
完整过程可以抽象为:
Java 字符串
↓
编码成字节
↓
BufferedOutputStream 用户空间缓冲区
↓
flush()
↓
FileOutputStream
↓
文件描述符
↓
write() 系统调用
↓
VFS
↓
打开文件描述
↓
inode 对应的 Page Cache
↓
页面被修改并标记为 Dirty
↓
write() 返回
↓
内核后台回写
↓
具体文件系统
↓
块设备层
↓
设备驱动
↓
存储设备
需要特别注意三个“完成”不是一回事:
Java write() 完成
可能只是写入用户空间缓冲区
Java flush() 完成
通常表示交给底层输出流或操作系统
Linux write() 完成
通常表示内核接受了数据
fsync() / force() 完成
才用于请求持久化到存储设备
这也是数据库、消息队列和日志系统为什么如此重视:
fsync
WAL
Group Commit
刷盘策略
数据文件与目录元数据同步
二十六、几个容易混淆的概念
1. VFS 就是 Linux 内存中的一棵目录树吗?
不完全准确。
VFS 是内核文件系统抽象层。
它结合:
挂载命名空间
dentry
inode
不同文件系统实现
向进程呈现统一的路径层次。
2. 每个文件都有全局唯一 inode
错误。
inode 编号只保证在一个文件系统内唯一。
3. inode 保存文件名
通常错误。
文件名主要保存在目录项中,目录项指向 inode。
4. 硬链接指向原文件
错误。
两个名称平等地指向同一个 inode,并不存在必须保留的“原文件名”。
5. 软链接和目标共享数据块
错误。
软链接保存目标路径,访问时会再次解析目标。
6. fd 对象中直接保存偏移量
不够准确。
fd 是进程表中的整数索引,偏移量属于它引用的打开文件描述。
7. 不同进程打开同一文件,偏移量一定独立
不一定。
独立调用 open() 时通常独立;通过 fork() 继承或 dup() 复制的 fd 可能共享打开文件描述和偏移量。
8. 文件数据在内存中永远只有一份
不严谨。
同一文件的 Page Cache 通常共享,但应用自己的用户空间缓冲区、私有映射、复制数据等都可能产生额外副本。
9. Socket 是一种真实磁盘文件
错误。
Socket 是内核通信对象,可以通过文件描述符引用,但通常没有普通磁盘文件的数据语义。
10. 匿名管道用户无法观察
不完全正确。
匿名管道没有普通路径名,但可以在:
/proc/<PID>/fd
lsof
中观察到类似:
pipe:[inode]
的引用。
11. 管道两边一定各创建一个新的 Bash
不一定。
外部命令通常是独立进程;Shell 内建命令和代码块的执行方式受 Bash 实现及 lastpipe 等选项影响。
应记住语义规则,而不是假设每一侧都必然是一个重新初始化的 Bash。
12. $$ 永远是当前真实进程 PID
不准确。
在 Bash 的某些子 Shell 环境中,$$ 仍表示调用 Shell 的 PID;查看当前 Bash 进程更适合使用 $BASHPID。
13. read() 未命中 Page Cache 就会缺页
错误。
普通 read() 通常在系统调用内部发起阻塞 I/O;访问尚未装入的文件映射地址才是典型文件缺页场景。
14. Page Cache 永远是固定 4 KiB
不准确。
4 KiB 是常见基础页大小,但现代内核还使用 folio,具体页面大小也取决于体系结构和配置。
15. flush() 代表数据已经落盘
错误。
Java flush() 主要清理应用层缓冲区;Linux write() 成功也不保证持久化。需要持久性时应使用 fsync()、FileDescriptor.sync() 或 FileChannel.force() 等机制。
16. /dev/shm 就是 Swap
错误。
/dev/shm 通常是 tmpfs;tmpfs 可以使用 Swap,但它本身不是交换分区。
17. chroot 就是一个容器
错误。
chroot 主要改变根路径解析;容器还依赖多种 namespace、cgroup、能力控制和其他隔离机制。
二十七、总结
Linux 的“一切皆文件”并不是说所有资源都是磁盘文件。
它真正表达的是:
Linux 尽量使用文件描述符和统一 I/O 接口,操作普通文件、设备、管道、Socket 以及其他内核对象。
VFS 为不同文件系统提供统一抽象:
路径
↓
dentry
↓
inode
↓
具体文件系统
挂载把不同文件系统接入同一棵路径树:
设备或虚拟文件系统
↓
挂载点
↓
统一路径空间
inode 保存文件对象的元数据,但通常不保存文件名。
硬链接的本质是:
多个目录项
↓
同一个 inode
软链接的本质是:
独立文件
↓
保存目标路径
↓
再次进行路径解析
文件描述符只是进程内的整数索引:
fd
↓
打开文件描述
↓
dentry / inode
文件偏移量属于打开文件描述。
独立调用 open() 通常创建独立偏移量;dup() 和 fork() 则可能让不同 fd 共享同一个打开文件描述。
重定向不是程序自身的功能,而是 Shell 在程序启动前重新连接文件描述符:
stdout
stderr
stdin
↓
文件、终端或其他 fd
重定向按从左到右处理,所以:
command > all.log 2>&1
与:
command 2>&1 > all.log
含义不同。
管道通过内核缓冲区连接两个进程:
左侧 stdout
↓
管道写端
↓
内核字节流
↓
管道读端
↓
右侧 stdin
Page Cache 缓存文件数据,降低真实设备 I/O 次数。
读取时:
read()
↓
Page Cache
↓
必要时读取设备
写入时:
write()
↓
Page Cache
↓
Dirty Page
↓
异步回写
Java flush()、Linux write() 返回和数据真正持久化,是三个不同层次。
需要明确持久化保证时,应该使用:
fsync
FileDescriptor.sync
FileChannel.force
最后,可以用一条完整链路概括本文:
Java I/O API
↓
文件描述符
↓
打开文件描述
↓
VFS
↓
dentry 与 inode
↓
Page Cache
↓
具体文件系统
↓
块设备与驱动
↓
真实存储设备
当这条链路建立起来以后,硬链接、软链接、重定向、管道、Loop 设备、chroot、Page Cache 和脏页就不再是彼此孤立的命令或概念,而是 Linux 统一 I/O 模型中的不同组成部分。
评论区