编程进阶网 编程进阶网
首页
  • 计算机原理
  • 操作系统
  • 网络协议
  • 数据库原理
  • 面向对象
  • 设计原则
  • 设计模式
  • 系统架构
  • 性能优化
  • 编程原理
  • 方案设计
  • 稳定可靠
  • 工程运维
  • 基础认知
  • 线性结构
  • 树与哈希
  • 工业级实现
  • 算法思想
  • 实战与综合
  • 算法题考核
  • C语言入门
  • C综合案例
  • C专栏博客
  • C标准集库
  • C++入门教程
  • C++综合案例
  • C++专栏博客
  • C++开发技巧
  • Java入门教程
  • Java综合案例
  • Java专栏博客
  • Go入门教程
  • Go综合案例
  • Go专栏博客
  • Go开发技巧
  • JavaScript入门
  • JavaScript高级
  • Android库解读
  • Android专栏
  • Android智能硬件
  • iOS ObjC入门
  • iOS Swift入门
  • iOS入门精通
  • Web之Html手册
  • Web之TypeScript
  • Web之Vue高级进阶
  • Linux之QML入门
  • Linux之QT核心库
  • Linux实践开发
  • Python教程
  • Shell&Bash教程
  • 工具脚本
  • 自动化脚本
  • 质量保障
  • 产品思考
  • 软实力
  • 开发流程
  • Git应用
  • 技术模版
  • 技术规范
  • Markdown
  • Mermaid
  • 开源协议
  • JSON工具
  • 文本工具
  • 图片处理
  • 文档转化
  • 代码压缩
  • 关于我
  • 自我精进
  • 职场管理
  • 职场面试
  • 心情杂货
  • 友情链接

杨充

专注编程 · 终身学习者
首页
  • 计算机原理
  • 操作系统
  • 网络协议
  • 数据库原理
  • 面向对象
  • 设计原则
  • 设计模式
  • 系统架构
  • 性能优化
  • 编程原理
  • 方案设计
  • 稳定可靠
  • 工程运维
  • 基础认知
  • 线性结构
  • 树与哈希
  • 工业级实现
  • 算法思想
  • 实战与综合
  • 算法题考核
  • C语言入门
  • C综合案例
  • C专栏博客
  • C标准集库
  • C++入门教程
  • C++综合案例
  • C++专栏博客
  • C++开发技巧
  • Java入门教程
  • Java综合案例
  • Java专栏博客
  • Go入门教程
  • Go综合案例
  • Go专栏博客
  • Go开发技巧
  • JavaScript入门
  • JavaScript高级
  • Android库解读
  • Android专栏
  • Android智能硬件
  • iOS ObjC入门
  • iOS Swift入门
  • iOS入门精通
  • Web之Html手册
  • Web之TypeScript
  • Web之Vue高级进阶
  • Linux之QML入门
  • Linux之QT核心库
  • Linux实践开发
  • Python教程
  • Shell&Bash教程
  • 工具脚本
  • 自动化脚本
  • 质量保障
  • 产品思考
  • 软实力
  • 开发流程
  • Git应用
  • 技术模版
  • 技术规范
  • Markdown
  • Mermaid
  • 开源协议
  • JSON工具
  • 文本工具
  • 图片处理
  • 文档转化
  • 代码压缩
  • 关于我
  • 自我精进
  • 职场管理
  • 职场面试
  • 心情杂货
  • 友情链接
  • README
  • C语言入门精通

  • Cpp入门到精通

  • Java入门精通

    • README
    • 入门教程

    • 综合案例

    • 专栏博客

      • README
      • JVM内存模型与对象
      • 类加载与双亲委派
      • 垃圾回收与GC调优
      • 异常体系与JVM机制
      • 字节码指令集javap实战
      • JIT编译与去优化机制
      • JVM性能诊断工具链
      • OOM八大现场全景剖析
      • JVM参数调优全景图
      • GraalVM与AOT编译原理
      • HashMap底层哈希设计
      • String不可变与常量池
      • ArrayList与LinkedList源码
      • ConcurrentHashMap并发
      • TreeMap与红黑树原理
      • LinkedHashMap与LRU实现
      • Java数字类型原理
      • Object通用方法的契约
      • 泛型擦除与类型系统
      • 枚举原理与最佳实践
      • 注解原理与编译期处理
      • Lambda与引用底层原理
      • Stream原理与流水线设计
      • Optional设计原理
      • Record密封类与模式
      • 反射机制与动态代理
      • MethodHandle与VarHandle
      • 三大字节码框架对比
      • JavaAgent与Instrumentation机制
      • AOP三种实现路线对比
      • synchronized与锁升级
      • volatile与JMM内存模型
      • 线程池核心源码设计
      • Thread线程生命周期
      • AQS同步框架源码
      • 并发锁三剑客
      • CAS和Atomic深入分析
      • 五大同步器对比
      • CompletableFuture异步
      • IO模型演进BIO到AIO
      • ByteBuffer与堆外内存
      • 序列化原理与替代方案
      • 文件IO与NIO.2
        • 1. 案例引入
          • 1.1 一段诡异的代码
          • 1.2 顺藤摸到根因
          • 1.3 我们要回答什么
        • 2. 架构概览
          • 2.1 三层API纵剖
          • 2.2 为什么要NIO2
        • 3. Path与文件系统
          • 3.1 Path对象抽象
          • 3.2 FileSystem多态
          • 3.3 路径解析规则
          • 3.4 与File的取舍
        • 4. Files工具大全
          • 4.1 读写四件套
          • 4.2 复制与移动
          • 4.3 原子写入秘诀
          • 4.4 属性视图体系
          • 4.5 walk递归遍历
        • 5. WatchService原理
          • 5.1 三大核心抽象
          • 5.2 inotify底层映射
          • 5.3 事件类型与上下文
          • 5.4 重复事件去抖
          • 5.5 平台差异坑表
        • 6. 内存映射mmap
          • 6.1 FileChannel.map
          • 6.2 三种映射模式
          • 6.3 缺页中断流程
          • 6.4 大文件分段映射
          • 6.5 Unmap的难题
        • 7. 文件锁与并发
          • 7.1 进程级文件锁
          • 7.2 共享锁与排他锁
          • 7.3 跨进程协调实战
          • 7.4 锁与映射的陷阱
        • 8. 临时文件与清理
          • 8.1 createTempFile
          • 8.2 DELETEONCLOSE
          • 8.3 ShutdownHook清理
        • 9. 真实场景选型
          • 9.1 配置热更新
          • 9.2 日志切割归档
          • 9.3 大文件随机读写
          • 9.4 跨进程通信选型
        • 10. 综合案例串讲
          • 10.1 案例真相揭晓
          • 10.2 一个文件的一生
          • 10.3 设计哲学回扣
          • 10.4 速查表
      • 面向对象的真意
      • JDK设计模式上
      • JDK设计模式下
      • SPI与模块化设计
  • Go入门到精通

  • JavaScript入门

  • CodeX
  • Java入门精通
  • 专栏博客
杨充
2026-06-02
目录

文件IO与NIO.2

# 43.文件IO与NIO.2

# 目录介绍

  • 1. 案例引入
    • 1.1 一段诡异的代码
    • 1.2 顺藤摸到根因
    • 1.3 我们要回答什么
  • 2. 架构概览
    • 2.1 三层API纵剖
    • 2.2 为什么要NIO2
  • 3. Path与文件系统
    • 3.1 Path对象抽象
    • 3.2 FileSystem多态
    • 3.3 路径解析规则
    • 3.4 与File的取舍
  • 4. Files工具大全
    • 4.1 读写四件套
    • 4.2 复制与移动
    • 4.3 原子写入秘诀
    • 4.4 属性视图体系
    • 4.5 walk递归遍历
  • 5. WatchService原理
    • 5.1 三大核心抽象
    • 5.2 inotify底层映射
    • 5.3 事件类型与上下文
    • 5.4 重复事件去抖
    • 5.5 平台差异坑表
  • 6. 内存映射mmap
    • 6.1 FileChannel.map
    • 6.2 三种映射模式
    • 6.3 缺页中断流程
    • 6.4 大文件分段映射
    • 6.5 Unmap的难题
  • 7. 文件锁与并发
    • 7.1 进程级文件锁
    • 7.2 共享锁与排他锁
    • 7.3 跨进程协调实战
    • 7.4 锁与映射的陷阱
  • 8. 临时文件与清理
    • 8.1 createTempFile
    • 8.2 DELETE_ON_CLOSE
    • 8.3 ShutdownHook清理
  • 9. 真实场景选型
    • 9.1 配置热更新
    • 9.2 日志切割归档
    • 9.3 大文件随机读写
    • 9.4 跨进程通信选型
  • 10. 综合案例串讲
    • 10.1 案例真相揭晓
    • 10.2 一个文件的一生
    • 10.3 设计哲学回扣
    • 10.4 速查表

# 1. 案例引入

# 1.1 一段诡异的代码

线上有个"配置中心降级版"——本地一个 app.yaml,运维改完文件,应用进程秒级感知并热更新。逻辑看似平平无奇:

public class ConfigWatcher {

    private static final Path CONFIG = Paths.get("/data/app/app.yaml");
    private volatile Map<String, Object> snapshot;

    public void start() throws IOException {
        snapshot = parse(Files.readAllBytes(CONFIG));

        WatchService ws = FileSystems.getDefault().newWatchService();
        CONFIG.getParent().register(ws,
                StandardWatchEventKinds.ENTRY_MODIFY);

        new Thread(() -> {
            while (true) {
                WatchKey key = ws.take();
                for (WatchEvent<?> ev : key.pollEvents()) {
                    Path changed = (Path) ev.context();
                    if (changed.toString().equals("app.yaml")) {
                        // 重新读取并解析
                        snapshot = parse(Files.readAllBytes(CONFIG));
                        log.info("config reloaded");
                    }
                }
                key.reset();
            }
        }, "cfg-watcher").start();
    }
}
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

现象 ①:运维用 vim 改了 app.yaml,事件没触发,应用感知不到。 现象 ②:运维改用 echo > app.yaml 覆盖,事件触发了,但日志打印 config reloaded 三次,每次解析的内容不一样,第一次甚至解析出空 Map。 现象 ③:日志切割脚本 mv app.log app.log.20240801 后,应用进程仍然往 app.log.20240801 里写日志,新建的 app.log 一直是空的。

三个问题搅在一起,业务侧第二天早上看监控才发现配置没生效。

# 1.2 顺藤摸到根因

我们逐条复盘:

  • 现象 ①——vim 修改文件并不是原地写,而是先写 .app.yaml.swp → :wq 时把临时文件 rename 成 app.yaml,旧的 inode 直接被替换。ENTRY_MODIFY 监听的是同一个 inode 上的写入事件,rename 触发的是 ENTRY_DELETE + ENTRY_CREATE。
  • 现象 ②——echo > app.yaml 是先 truncate(0) 再 write,文件系统层面是两次写,inotify 上报两个 IN_MODIFY 事件;加上保存动作可能再触发一次 IN_CLOSE_WRITE,于是回调跑了 3 次。第一次读到的是被 truncate 后还没写回的空文件。
  • 现象 ③——Linux 上文件描述符指向的是 inode 而不是路径,mv 之后 fd 还连着旧 inode,应用继续往旧 inode 写。这就是为什么所有日志框架都得提供 reopen 钩子(logrotate 的 copytruncate 与 create+postrotate 区别也在这)。

把这些原理串起来,至少藏着 7 个 NIO.2 知识点:

① ENTRY_MODIFY 到底监听的是什么内核事件?            → 第5章
② 为什么 vim 编辑触发不了 ENTRY_MODIFY?              → 第5章
③ 为什么一次保存能触发 2~3 次事件?                   → 第5章
④ 怎么读一个"正在被写"的文件才能拿到完整快照?       → 第4章「原子写入」
⑤ Files.readAllBytes 和 FileChannel.read 哪个更快? → 第6章
⑥ 大文件随机读怎么不加载到堆里?                     → 第6章「mmap」
⑦ 为什么 mv 之后旧 fd 还能写?inode 与路径的关系?    → 第7、9章
1
2
3
4
5
6
7

# 1.3 我们要回答什么

这个事故就是本篇主线。我们沿着 NIO.2 的三大支柱——Path 抽象、Files 工具集、WatchService 监听——往下走,再加上 mmap、文件锁两个底层武器,最终在第 10 章把 7 个问号一个一个解开。

本篇路线:

flowchart LR
    A[NIO.2 全景] --> B[Path 抽象]
    A --> C[Files 工具集]
    A --> D[WatchService 监听]
    B --> E[FileChannel/mmap]
    C --> E
    D --> F[文件锁/临时文件]
    E --> G[场景选型与综合案例]
    F --> G
1
2
3
4
5
6
7
8
9

# 2. 架构概览

# 2.1 三层API纵剖

Java 文件 IO 共有三代 API,至今同时存活,写代码时要清楚自己站在哪一层:

┌────────────────────────────────────────────────────────────┐
│           应用层  Files.readAllBytes / Path                │  ← NIO.2  (JDK 7+)
├────────────────────────────────────────────────────────────┤
│  通道层  FileChannel.read/write/map/transferTo             │  ← NIO    (JDK 1.4+)
├────────────────────────────────────────────────────────────┤
│  流  层  FileInputStream / FileOutputStream / RandomAccess │  ← IO     (JDK 1.0+)
├────────────────────────────────────────────────────────────┤
│            JNI 层  open / read / write / mmap / close      │  ← 系统调用
└────────────────────────────────────────────────────────────┘
1
2
3
4
5
6
7
8
9

层级映射关系:

维度 java.io java.nio (Channel) java.nio.file (NIO.2)
路径抽象 File File Path
工具入口 自己写循环 Channels.newXxx Files.xxx 50+ 个静态方法
文件系统 只能本地 只能本地 FileSystem SPI 可挂 ZIP/Jimfs/HDFS
监听 无 无 WatchService
异步 无 半异步(Selector) AsynchronousFileChannel
大文件 拷贝 buffer 直接通道 FileChannel.map 内存映射

# 2.2 为什么要NIO2

java.io.File 用了快 20 年,问题积重难返:

疑惑:File 已经能 create/delete/exists 了,为什么 JDK 7 还要重做一遍?

论证:

  1. 错误吞掉——File.delete() 失败只返回 false,不告诉你为啥(权限不足?文件不存在?目录不空?被占用?)。NIO.2 的 Files.delete(path) 直接抛 NoSuchFileException / DirectoryNotEmptyException / AccessDeniedException。
  2. 不支持符号链接——File 只能粗暴跟随符号链接,无法区分 link 本身和 link 指向的文件。NIO.2 提供 LinkOption.NOFOLLOW_LINKS。
  3. 没有原子操作——renameTo 在 Windows 上跨盘符就失败,且不保证原子性。NIO.2 提供 Files.move(src, dst, ATOMIC_MOVE)。
  4. 缺少元数据视图——File 只能拿 lastModified() 一个时间戳,拿不到 access time、create time、POSIX 权限位、ACL。NIO.2 提供 BasicFileAttributes / PosixFileAttributes / DosFileAttributes / AclFileAttributeView。
  5. 不可扩展——File 写死本地文件系统。NIO.2 通过 FileSystemProvider SPI 让 ZIP、内存文件系统(Jimfs)、HDFS 都能用同一套 Path 操作。
  6. 没有目录监听——只能轮询 lastModified,开销巨大。NIO.2 提供 WatchService 直通 inotify/kqueue/ReadDirectoryChangesW。

结论:NIO.2 不是给 NIO 打补丁,是把整个文件系统抽象重做了一遍。File 仍然存在,但只在历史代码里活着——新代码一律用 Path。

# 3. Path与文件系统

# 3.1 Path对象抽象

Path 是 NIO.2 的核心入口,本质是一个路径段序列,不是真实文件——它甚至可以指向一个不存在的位置。

Path p = Paths.get("/data/app/app.yaml");
// 等价于  FileSystems.getDefault().getPath("/data/app/app.yaml")

p.getFileName();      // app.yaml          (Path 类型)
p.getParent();        // /data/app
p.getRoot();          // /                 (Linux) 或 C:\ (Windows)
p.getNameCount();     // 3                 (data, app, app.yaml)
p.getName(0);         // data
p.subpath(0, 2);      // data/app          (左闭右开)
p.normalize();        // 折叠 . 与 ..
p.toAbsolutePath();   // 补全为绝对路径    (不真正访问文件系统)
p.toRealPath();       // 解析符号链接      (会访问文件系统)
1
2
3
4
5
6
7
8
9
10
11
12

关键区分:

方法 是否触达文件系统 用途
toAbsolutePath() ❌ 纯字符串拼接 把相对路径补全
toRealPath() ✅ 必须文件存在 解析 ..、符号链接、大小写规范化
normalize() ❌ 仅做语法折叠

# 3.2 FileSystem多态

Path 不绑定本地磁盘——它绑定的是 FileSystem:

// 默认本地文件系统
FileSystem local = FileSystems.getDefault();

// 把一个 zip 挂载成文件系统!
try (FileSystem zip = FileSystems.newFileSystem(
        URI.create("jar:file:/tmp/a.zip"), Map.of())) {
    Path inside = zip.getPath("/conf/app.yaml");
    String text = Files.readString(inside);
    // 同一套 Files API,零特例代码
}
1
2
3
4
5
6
7
8
9
10

ZIP 挂载是 JDK 自带的 zipfs.jar 提供的。这就是 SPI 的威力——Maven、Gradle、Spring Boot 解析 jar 内资源都用这套机制。

flowchart TB
    A[FileSystemProvider SPI] --> B[本地 file://<br/>nio.fs.UnixFileSystemProvider]
    A --> C[ZIP jar://<br/>zipfs]
    A --> D[内存 jimfs://<br/>Jimfs 第三方]
    A --> E[HDFS hdfs://<br/>Hadoop 第三方]
    B & C & D & E --> F[统一 Path / Files API]
1
2
3
4
5
6

# 3.3 路径解析规则

resolve 与 relativize 是一对常被搞混的方法:

Path base = Paths.get("/data/app");

base.resolve("logs/a.log");        // /data/app/logs/a.log  (拼接)
base.resolve("/etc/passwd");       // /etc/passwd            (绝对路径覆盖!)
base.resolve(Paths.get(""));       // /data/app              (空路径无效果)

Path other = Paths.get("/data/app/logs/a.log");
base.relativize(other);            // logs/a.log             (求相对路径)
1
2
3
4
5
6
7
8

疑惑:为什么 resolve("/etc/passwd") 没有报错而是覆盖了 base?

论证:JDK 把 resolve 设计成"如果参数是绝对路径,直接返回参数本身"。这其实贴合 Unix cd /etc 的语义——绝对路径自带定位能力。但它就是新人写文件路径拼接时最大的注入漏洞来源——用户传入 ../../etc/passwd 触发的是穿越,而传入 /etc/passwd 直接逃出 base 目录。

结论:任何把外部输入拼到 Path 的代码,必须:

Path safe = base.resolve(userInput).normalize();
if (!safe.startsWith(base)) {
    throw new SecurityException("path traversal");
}
1
2
3
4

# 3.4 与File的取舍

场景 推荐
新代码 Path
与老 API 互操作 path.toFile() / file.toPath() 互转,零开销
只判断 exists Files.exists(p) 比 f.exists() 慢一点(多一次 stat),但更准确
跨文件系统 必须 Path(ZIP/Jimfs)

# 4. Files工具大全

Files 类有 60+ 个静态方法,绝大部分场景都不需要再写循环。

# 4.1 读写四件套

// 全量读
byte[]      bs   = Files.readAllBytes(path);
String      str  = Files.readString(path, UTF_8);                 // JDK 11+
List<String> lns = Files.readAllLines(path, UTF_8);

// 流式读 (大文件)
try (Stream<String> s = Files.lines(path, UTF_8)) {               // 必须 try-with-resources!
    s.filter(l -> l.startsWith("ERROR")).forEach(System.out::println);
}
try (BufferedReader r = Files.newBufferedReader(path, UTF_8)) {}

// 全量写
Files.write(path, bs);
Files.writeString(path, str, UTF_8);                              // JDK 11+

// 流式写
try (BufferedWriter w = Files.newBufferedWriter(path, UTF_8,
        CREATE, TRUNCATE_EXISTING)) {
    w.write(line);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

易错点:Files.lines(path) 返回的 Stream 持有打开的文件句柄,必须 try-with-resources。否则文件句柄泄漏,linux 默认 ulimit 1024,几万条配置就把进程打挂。

# 4.2 复制与移动

// 复制:默认不覆盖、不复制属性、跟随符号链接
Files.copy(src, dst,
        StandardCopyOption.REPLACE_EXISTING,
        StandardCopyOption.COPY_ATTRIBUTES,
        LinkOption.NOFOLLOW_LINKS);

// 移动:可指定原子语义
Files.move(src, dst,
        StandardCopyOption.ATOMIC_MOVE,        // 原子替换 (rename 系统调用)
        StandardCopyOption.REPLACE_EXISTING);
1
2
3
4
5
6
7
8
9
10
选项 行为
REPLACE_EXISTING 目标存在时覆盖
COPY_ATTRIBUTES 保留 owner/permission/timestamps
ATOMIC_MOVE 调用 rename(2),跨文件系统会抛 AtomicMoveNotSupportedException
NOFOLLOW_LINKS 操作链接本身而非目标

ATOMIC_MOVE 在同一文件系统内部对应一次 rename(2) 系统调用——单一原子操作,要么成功要么失败,不存在"半新半旧"的中间态。这是后面"原子写入"的基石。

# 4.3 原子写入秘诀

回到第 1 章现象 ②:直接 Files.write(CONFIG, newContent) 会先 truncate 再写,监听端读到空文件。正确的原子写入模板:

public static void atomicWrite(Path target, byte[] content) throws IOException {
    Path tmp = target.resolveSibling(target.getFileName() + ".tmp." + ThreadLocalRandom.current().nextLong());
    try {
        Files.write(tmp, content, CREATE, TRUNCATE_EXISTING, WRITE);
        Files.move(tmp, target, ATOMIC_MOVE, REPLACE_EXISTING);   // ★ 一次 rename 替换 inode
    } catch (IOException e) {
        Files.deleteIfExists(tmp);
        throw e;
    }
}
1
2
3
4
5
6
7
8
9
10

论证:

  1. 监听端永远只看到完整的旧内容或完整的新内容,绝不会看到"写到一半"的状态。
  2. rename(2) 在 POSIX 上保证原子,目录项的更新是单条 inode 操作。
  3. 不需要文件锁——读侧用 Files.readAllBytes 拿到的就是替换前或替换后的两个 inode 中的一个,互不干扰。

结论:所有"配置/索引/检查点"类的写入,模板就是"写临时 + 原子改名"。所有数据库的 WAL、Kafka 的 segment、Etcd 的 wal 都是这个套路。

# 4.4 属性视图体系

NIO.2 把文件属性按操作系统能力分组成视图:

flowchart TB
    A[FileAttributeView 基接口] --> B[BasicFileAttributeView<br/>所有平台]
    A --> C[PosixFileAttributeView<br/>Linux/macOS]
    A --> D[DosFileAttributeView<br/>Windows]
    A --> E[AclFileAttributeView<br/>NTFS/ZFS]
    A --> F[UserDefinedFileAttributeView<br/>扩展属性 xattr]
1
2
3
4
5
6
BasicFileAttributes a = Files.readAttributes(path, BasicFileAttributes.class);
a.creationTime();      // 创建时间   (Linux ext4 才有,ext2/3 没有)
a.lastAccessTime();    // 访问时间   (mount -o noatime 后会失效)
a.lastModifiedTime();
a.size();
a.isRegularFile();
a.isSymbolicLink();
a.fileKey();           // ★ 设备号+inode 唯一标识,跨路径判等

// POSIX 权限
PosixFileAttributes p = Files.readAttributes(path, PosixFileAttributes.class);
p.permissions();       // Set<PosixFilePermission> = {OWNER_READ, OWNER_WRITE, ...}
p.owner();
p.group();
1
2
3
4
5
6
7
8
9
10
11
12
13
14

fileKey() 的妙用:判断两个 Path 是否指向同一个文件(含硬链接、符号链接),不能用 equals,必须比 fileKey。

# 4.5 walk递归遍历

// 找出 /data 下所有 100MB 以上的 .log 文件
try (Stream<Path> s = Files.walk(Paths.get("/data"), 5)) {       // maxDepth=5
    s.filter(p -> p.toString().endsWith(".log"))
     .filter(p -> {
         try { return Files.size(p) > 100L * 1024 * 1024; }
         catch (IOException e) { return false; }
     })
     .forEach(System.out::println);
}
1
2
3
4
5
6
7
8
9

Files.walk 是深度优先的懒加载流,遇到不可读的子目录默认会抛 UncheckedIOException 打断遍历。生产代码推荐 Files.walkFileTree(start, visitor):

Files.walkFileTree(root, new SimpleFileVisitor<>() {
    @Override public FileVisitResult visitFile(Path file, BasicFileAttributes attr) {
        if (attr.size() > 100 << 20) System.out.println(file);
        return FileVisitResult.CONTINUE;
    }
    @Override public FileVisitResult visitFileFailed(Path file, IOException e) {
        return FileVisitResult.CONTINUE;     // 跳过不可读文件,不打断
    }
});
1
2
3
4
5
6
7
8
9

FileVisitor 提供了 preVisitDirectory / postVisitDirectory 两个钩子,这是实现"删除非空目录"的唯一干净写法:

Files.walkFileTree(dir, new SimpleFileVisitor<>() {
    @Override public FileVisitResult visitFile(Path f, BasicFileAttributes a) throws IOException {
        Files.delete(f); return CONTINUE;
    }
    @Override public FileVisitResult postVisitDirectory(Path d, IOException e) throws IOException {
        Files.delete(d); return CONTINUE;
    }
});
1
2
3
4
5
6
7
8

# 5. WatchService原理

# 5.1 三大核心抽象

WatchService 由三个对象编织而成:

┌─────────────────────────────────────────────────────┐
│  WatchService    ← 服务,相当于一个事件队列          │
│  ├── WatchKey    ← 注册凭证,每个被监听目录一个      │
│  └── WatchEvent  ← 单条事件,含 kind + count + ctx   │
└─────────────────────────────────────────────────────┘
1
2
3
4
5
WatchService ws = FileSystems.getDefault().newWatchService();
WatchKey key = dir.register(ws,
        ENTRY_CREATE, ENTRY_MODIFY, ENTRY_DELETE);

while (true) {
    WatchKey k = ws.take();                       // 阻塞直到有事件
    for (WatchEvent<?> ev : k.pollEvents()) {
        WatchEvent.Kind<?> kind = ev.kind();      // CREATE/MODIFY/DELETE/OVERFLOW
        Path ctx = (Path) ev.context();           // 相对于注册目录的文件名
        int count = ev.count();                   // 同类事件合并次数
    }
    if (!k.reset()) break;                        // ★ 必须 reset,否则不再收到事件
}
1
2
3
4
5
6
7
8
9
10
11
12
13

生命周期铁律:

  1. take() / poll() 拿到 key 后,必须 调用 pollEvents() 取走事件。
  2. 取走事件后,必须调用 key.reset() 把 key 放回服务,否则下次事件不会推送。
  3. key.cancel() 注销监听。
  4. 关闭 WatchService 后,所有阻塞的 take() 抛 ClosedWatchServiceException。

# 5.2 inotify底层映射

不同操作系统底层实现不同,JDK 帮我们抹平:

OS 底层 实现类
Linux inotify_init1 + inotify_add_watch sun.nio.fs.LinuxWatchService
macOS kqueue + EVFILT_VNODE sun.nio.fs.BsdFileSystem (轮询包装)
Windows ReadDirectoryChangesW (IOCP) sun.nio.fs.WindowsWatchService
Solaris FEN sun.nio.fs.SolarisWatchService

Linux 上的事件映射(这是排查现象 ① 的关键):

Java 事件 inotify 掩码
ENTRY_CREATE IN_CREATE + IN_MOVED_TO
ENTRY_DELETE IN_DELETE + IN_MOVED_FROM
ENTRY_MODIFY IN_MODIFY + IN_ATTRIB
OVERFLOW IN_Q_OVERFLOW(事件队列满)

回答现象 ①:vim :wq 触发的是 IN_MOVED_FROM(.swp) + IN_MOVED_TO(app.yaml),对应 Java 的 DELETE + CREATE,不是 MODIFY!所以监听 ENTRY_MODIFY 必然漏掉。

最佳实践:监听所有三种事件,并且对 CREATE/MODIFY 走同一处理路径:

dir.register(ws, ENTRY_CREATE, ENTRY_MODIFY, ENTRY_DELETE);
1

# 5.3 事件类型与上下文

OVERFLOW 是个特殊事件——当文件变更速率超过 inotify 队列容量(Linux 默认 16384 条),内核会丢事件并合并成一条 OVERFLOW。监听器必须显式处理:

for (WatchEvent<?> ev : k.pollEvents()) {
    if (ev.kind() == OVERFLOW) {
        // 全量重新扫描目录,因为我们不知道丢了什么
        rescanDirectory(dir);
        continue;
    }
    // ...
}
1
2
3
4
5
6
7
8

调大队列:

echo 1048576 | sudo tee /proc/sys/fs/inotify/max_queued_events
1

# 5.4 重复事件去抖

回答现象 ②:echo > app.yaml 触发 truncate + write,对应 2~3 个 IN_MODIFY 事件,回调跑了 3 次。任何文件监听器都必须做去抖:

private final ScheduledExecutorService timer =
        Executors.newSingleThreadScheduledExecutor();
private volatile ScheduledFuture<?> pending;

void onChange(Path path) {
    if (pending != null) pending.cancel(false);
    pending = timer.schedule(() -> reload(path), 200, TimeUnit.MILLISECONDS);
}
1
2
3
4
5
6
7
8

疑惑:为什么去抖要用"取消+重新调度"而不是"加锁串行执行"?

论证:

  1. 高频事件下,串行执行会让回调任务排队,最后一个任务可能晚执行几秒才完成。
  2. 我们关心的是"文件停止变更后的最终状态",中间态没有处理价值。
  3. 取消+重新调度的语义是 "安静期 200ms 后再触发"——和 RxJS 的 debounce 同一思路。

结论:文件监听 = 内核事件 + 应用层去抖,缺一不可。

# 5.5 平台差异坑表

行为 Linux macOS Windows
注册子目录递归 ❌ 必须自己遍历 ❌ ❌
跟踪 rename DELETE+CREATE DELETE+CREATE DELETE+CREATE
删除目录后 key 是否失效 reset() 返回 false 同 同
监听网络文件系统(NFS/SMB) ❌ inotify 不支持 部分 ❌
监听 /proc /sys ❌ 伪文件系统 — —
事件延迟 < 1ms 5~10s(kqueue 周期性扫描) < 1ms

macOS 是最大的雷——JDK 在 macOS 上没用 FSEvents,而是用 kqueue + 轮询,延迟可达 10 秒。开发者本机调试时常常以为代码 OK,部署到 Linux 才发现行为不一致。生产监听服务只在 Linux 上靠谱。

# 6. 内存映射mmap

# 6.1 FileChannel.map

NIO.2 没有重新设计 mmap,仍然依赖 java.nio.channels.FileChannel。但 NIO.2 让 FileChannel.open(path, ...) 比 new RandomAccessFile().getChannel() 更优雅:

try (FileChannel ch = FileChannel.open(path, READ)) {
    MappedByteBuffer mbb = ch.map(MapMode.READ_ONLY, 0, ch.size());
    // mbb 现在是一片直接映射到文件的虚拟内存
    while (mbb.hasRemaining()) {
        byte b = mbb.get();
    }
}
1
2
3
4
5
6
7

关键事实:

  • MappedByteBuffer 继承自 ByteBuffer,但它的"内存"不在堆里、也不严格意义上在堆外——它在内核 page cache 里。
  • 第一次访问某 page 触发缺页中断 → 内核从磁盘读取 → 填充到 page cache → 用户态访问。
  • 文件大小 ≤ 2GB(int size 限制)。大文件需要分段。

# 6.2 三种映射模式

ch.map(MapMode.READ_ONLY,    0, len);   // 只读
ch.map(MapMode.READ_WRITE,   0, len);   // 读写,回写到磁盘
ch.map(MapMode.PRIVATE,      0, len);   // copy-on-write,不回写
1
2
3

底层映射:

READ_ONLY    → mmap(PROT_READ,  MAP_SHARED)
READ_WRITE   → mmap(PROT_READ|PROT_WRITE, MAP_SHARED)
PRIVATE      → mmap(PROT_READ|PROT_WRITE, MAP_PRIVATE)   ← 写时复制
1
2
3

# 6.3 缺页中断流程

sequenceDiagram
    participant App as 应用线程
    participant CPU as CPU/MMU
    participant Kernel as 内核
    participant Disk as 磁盘

    App->>CPU: mbb.get(offset)
    CPU->>CPU: 查 TLB / 页表
    Note over CPU: 页未映射
    CPU->>Kernel: Page Fault
    Kernel->>Kernel: 检查 page cache
    alt cache 命中
        Kernel->>CPU: 建立页表映射
    else cache 未命中
        Kernel->>Disk: read 该 page (4KB)
        Disk-->>Kernel: 数据
        Kernel->>Kernel: 填入 page cache
        Kernel->>CPU: 建立页表映射
    end
    CPU-->>App: 返回 byte 值
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

性能特征:

操作 FileInputStream.read FileChannel.read mmap
拷贝次数 2 (内核→用户堆) 1 (内核→堆外) 0(用户态直接访问 page cache)
系统调用次数 多次 read 多次 read 仅缺页中断
大文件随机读 慢 中 快
顺序遍历小文件 中 中 慢(缺页频繁)
跨进程共享 ❌ ❌ ✅ MAP_SHARED

结论:mmap 不是"更快的 read",是"另一种范式"——把磁盘当内存用。适合大文件随机访问、跨进程共享、零拷贝转发;不适合一次性顺序遍历的小文件。

# 6.4 大文件分段映射

size 是 int,单次映射上限 ≈ 2GB。处理 100GB 文件用分段映射 + LRU 切换:

public class HugeFile implements AutoCloseable {
    private static final long CHUNK = 1L << 30;          // 1GB 一段
    private final FileChannel ch;
    private final long size;
    private final Map<Long, MappedByteBuffer> cache = new LinkedHashMap<>(8, 0.75f, true) {
        @Override protected boolean removeEldestEntry(Map.Entry<Long, MappedByteBuffer> e) {
            return size() > 4;     // 最多映射 4GB 内存
        }
    };

    public HugeFile(Path p) throws IOException {
        this.ch = FileChannel.open(p, READ);
        this.size = ch.size();
    }

    public byte readAt(long pos) throws IOException {
        long chunkIdx = pos / CHUNK;
        MappedByteBuffer mbb = cache.computeIfAbsent(chunkIdx, idx -> {
            try {
                long off = idx * CHUNK;
                long len = Math.min(CHUNK, size - off);
                return ch.map(MapMode.READ_ONLY, off, len);
            } catch (IOException e) { throw new UncheckedIOException(e); }
        });
        return mbb.get((int)(pos - chunkIdx * CHUNK));
    }

    @Override public void close() throws IOException { ch.close(); }
}
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

# 6.5 Unmap的难题

MappedByteBuffer 没有 close() / unmap() 方法——这是 NIO 最被诟病的设计之一。

疑惑:为什么不能显式释放?

论证:

  1. JDK 设计者担心悬垂指针——如果用户 unmap 后还在访问,JVM 会 segfault。
  2. 实际回收依赖 Cleaner + 幻引用,等到 MappedByteBuffer 被 GC,对应的 mmap 区域才会 munmap。
  3. 但这个"等 GC"在大堆 G1 / ZGC 下可能拖到几分钟,Windows 上更致命——文件句柄一直被占,连删都删不掉。

逃生通道:

// JDK 9 之前 (反射)
Method cleaner = MappedByteBuffer.class.getDeclaredMethod("cleaner");
cleaner.setAccessible(true);
((sun.misc.Cleaner) cleaner.invoke(mbb)).clean();

// JDK 9+ 推荐
Unsafe unsafe = ...;          // 通过反射拿
unsafe.invokeCleaner(mbb);
1
2
3
4
5
6
7
8

结论:生产上对 mmap 的回收必须显式调用 Unsafe.invokeCleaner,特别是 Windows + 长生命周期映射场景。Lucene、RocksDB、Kafka 全都自己写了这套逃生通道。

# 7. 文件锁与并发

# 7.1 进程级文件锁

FileLock 是 OS 级锁,跨进程有效,对应 fcntl(F_SETLK):

try (FileChannel ch = FileChannel.open(lockFile, CREATE, READ, WRITE);
     FileLock lock = ch.lock()) {                 // 阻塞直到拿到锁
    // 临界区
} // try-with-resources 自动 release
1
2
3
4
方法 行为
lock() 阻塞直到拿到独占锁
tryLock() 拿不到立即返回 null
lock(pos, size, shared) 锁文件的一段
tryLock(pos, size, shared) 同上但非阻塞

# 7.2 共享锁与排他锁

ch.lock(0, Long.MAX_VALUE, false);   // 排他锁:同一时刻只有一个持有者
ch.lock(0, Long.MAX_VALUE, true);    // 共享锁:多个读者可同时持有
1
2

疑惑:FileLock 在同一 JVM 内的多个线程之间是互斥的吗?

论证:

  1. POSIX fcntl 锁是进程级——同一进程对同一文件多次加锁不会阻塞,会直接返回成功,不阻塞同进程的其他线程。
  2. JDK 在 FileChannelImpl 里维护了一个 fileLockTable,单进程内对同一区域重复加锁会抛 OverlappingFileLockException。
  3. 但不同 FileChannel 实例对同一区域加锁,JDK 不会拦——这是个常见踩坑点。

结论:FileLock 设计目标是跨进程协调,不是替代 ReentrantLock。同 JVM 内的线程互斥应该用 synchronized / ReentrantLock。

# 7.3 跨进程协调实战

场景:单实例守护进程——保证同一台机器只能跑一个实例:

public class SingleInstance {
    public static void acquire(Path pidFile) throws IOException {
        FileChannel ch = FileChannel.open(pidFile, CREATE, READ, WRITE);
        FileLock lock = ch.tryLock();
        if (lock == null) {
            System.err.println("another instance is running");
            System.exit(1);
        }
        // 写入 pid,便于运维查
        ch.truncate(0);
        ch.write(ByteBuffer.wrap(String.valueOf(ProcessHandle.current().pid()).getBytes()));
        // 注意:故意不 close ch / lock,让它跟随进程死亡释放
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14

关键细节:进程异常退出时,OS 会自动释放该进程持有的所有文件锁——这就是为什么不需要 ShutdownHook 来清理锁。

# 7.4 锁与映射的陷阱

坑:FileLock 锁住的区域和 MappedByteBuffer 映射的区域交叉时,Linux 上锁不影响 mmap 访问——fcntl 锁是建议性锁(advisory),mmap 完全绕过。

正确做法:要么所有进程都通过 read/write + lock 协议读写;要么所有进程都用 mmap + 自己设计的锁字(比如 mmap 区域第一个字节作为 spin lock)。不要混用。

# 8. 临时文件与清理

# 8.1 createTempFile

Path tmp = Files.createTempFile(
        Paths.get("/data/work"),    // 父目录,null 用 java.io.tmpdir
        "upload-", ".tmp",          // 前缀、后缀
        PosixFilePermissions.asFileAttribute(            // 关键:限定权限
                PosixFilePermissions.fromString("rw-------")));
1
2
3
4
5

疑惑:为什么要显式指定权限?

论证:默认 umask 077 在大多数发行版没问题,但 Docker 镜像里 umask 经常是 000(root 用户),导致临时文件全世界可读——敏感信息泄露。createTempFile 的属性参数能保证创建瞬间就具备目标权限,避免 chmod 之间的 TOCTOU 漏洞。

结论:临时文件的权限要在创建瞬间确定,不能事后 chmod。

# 8.2 DELETE_ON_CLOSE

try (FileChannel ch = FileChannel.open(tmp,
        WRITE, CREATE_NEW, DELETE_ON_CLOSE)) {
    // 用完关闭时,文件自动删除
}
1
2
3
4

DELETE_ON_CLOSE 在 Linux 上对应 打开文件后立即 unlink——文件名从目录消失,但 fd 还活着,进程可继续读写。进程崩溃时内核回收 fd 也会一并回收 inode,比 ShutdownHook 更可靠。

# 8.3 ShutdownHook清理

针对不能 DELETE_ON_CLOSE 的临时目录树:

Path tmpDir = Files.createTempDirectory("workspace-");
Runtime.getRuntime().addShutdownHook(new Thread(() -> {
    try {
        Files.walkFileTree(tmpDir, new SimpleFileVisitor<>() {
            @Override public FileVisitResult visitFile(Path f, BasicFileAttributes a) throws IOException {
                Files.deleteIfExists(f); return CONTINUE;
            }
            @Override public FileVisitResult postVisitDirectory(Path d, IOException e) throws IOException {
                Files.deleteIfExists(d); return CONTINUE;
            }
        });
    } catch (IOException ignore) {}
}));
1
2
3
4
5
6
7
8
9
10
11
12
13

警告:ShutdownHook kill -9 不会跑,且不允许阻塞(会被 JVM 强制中断)。生产上对临时文件的清理应该在启动时扫描并删除上次的残留,而不是只依赖关闭时清理。

# 9. 真实场景选型

# 9.1 配置热更新

正确架构(解决第 1 章三大现象):

public class HotConfig {
    private final Path file;
    private volatile Snapshot snapshot;
    private final Debouncer debouncer = new Debouncer(200);

    public HotConfig(Path file) throws IOException {
        this.file = file;
        this.snapshot = load();

        WatchService ws = FileSystems.getDefault().newWatchService();
        file.getParent().register(ws,
                ENTRY_CREATE, ENTRY_MODIFY, ENTRY_DELETE);   // ★ 监听三类事件

        Thread t = new Thread(() -> {
            while (true) {
                try {
                    WatchKey k = ws.take();
                    boolean hit = false;
                    for (WatchEvent<?> ev : k.pollEvents()) {
                        if (ev.kind() == OVERFLOW) { hit = true; continue; }
                        Path ctx = (Path) ev.context();
                        if (file.getFileName().equals(ctx)) hit = true;
                    }
                    if (hit) debouncer.call(() -> {           // ★ 去抖
                        try { snapshot = load(); }
                        catch (IOException e) { log.error("reload fail", e); }
                    });
                    if (!k.reset()) break;
                } catch (InterruptedException e) { return; }
            }
        }, "cfg-watcher");
        t.setDaemon(true); t.start();
    }

    private Snapshot load() throws IOException {
        // 用 readAllBytes 读快照——配合写侧的 atomicWrite 即可保证完整
        byte[] raw = Files.readAllBytes(file);
        return Snapshot.parse(raw);
    }
}
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

配套的写侧(运维或部署脚本):

echo "$NEW_CONTENT" > /data/app/app.yaml.tmp
mv -f /data/app/app.yaml.tmp /data/app/app.yaml          # ★ 原子替换
1
2

# 9.2 日志切割归档

回答现象 ③:mv app.log app.log.20240801 后,应用 fd 还指向旧 inode。两套行业标准方案:

方案 流程 优点 缺点
create + reopen mv → 通知应用关闭并重开 fd 干净,无丢日志 需要 SIGHUP 协议
copytruncate cp → truncate(0) 应用零改动 拷贝期间日志可能丢失

Logback / Log4j2 的 RollingFileAppender 内部用方案一:监听文件大小或日期,应用主动 rename + reopen,不依赖外部脚本。

# 9.3 大文件随机读写

// 反例:用 InputStream 跳到 5GB 处读 1KB
try (InputStream in = Files.newInputStream(big)) {
    in.skip(5L << 30);   // 内部仍然在读取并丢弃 5GB
    in.read(buf);
}

// 正例 1:FileChannel.position
try (FileChannel ch = FileChannel.open(big, READ)) {
    ch.position(5L << 30);
    ch.read(ByteBuffer.wrap(buf));
}

// 正例 2:mmap 分段(前面 6.4 节)
1
2
3
4
5
6
7
8
9
10
11
12
13

# 9.4 跨进程通信选型

场景 推荐
单实例守护进程 FileLock.tryLock
高频小消息 Unix Domain Socket (JDK 16+ UnixDomainSocketAddress)
大块数据共享 mmap + 文件锁 (Chronicle Queue 这么干)
一次性配置传递 普通文件 + 原子写入

# 10. 综合案例串讲

# 10.1 案例真相揭晓

回到第 1 章 ConfigWatcher,7 个疑问现在能逐条作答:

# 疑问 答案
① ENTRY_MODIFY 监听什么? Linux 上对应 IN_MODIFY + IN_ATTRIB,要求同一 inode 上的写入。见 5.2。
② 为什么 vim 触发不了? vim 是 swap-rename 流程,对应 DELETE+CREATE,不是 MODIFY。见 5.2。
③ 为什么一次保存触发多次? echo > 是 truncate + write 两次系统调用,inotify 上报多次。必须去抖。见 5.4。
④ 怎么读"正在被写"的文件? 写侧改成"写临时 + 原子改名",读侧 Files.readAllBytes 永远读到完整快照。见 4.3。
⑤ readAllBytes vs FileChannel? 小文件(< 1MB)readAllBytes,大文件随机读 FileChannel.map。见 6.3。
⑥ 大文件随机读怎么不爆堆? mmap 把数据放在 page cache,不进 Java 堆。注意 unmap 难题。见 6.4 / 6.5。
⑦ mv 之后 fd 为什么还能写? Unix fd 绑定 inode 不绑定路径,rename 改的是目录项,旧 fd 仍指向旧 inode。日志切割必须 reopen。见 9.2。

# 10.2 一个文件的一生

把 app.yaml 的一次热更新完整串起来:

sequenceDiagram
    participant Ops as 运维脚本
    participant FS as 文件系统
    participant Inotify as 内核inotify
    participant App as Java应用

    Ops->>FS: write /data/app/app.yaml.tmp
    FS-->>Ops: ok (新 inode N2)
    Ops->>FS: rename app.yaml.tmp → app.yaml
    FS->>FS: 目录项更新, 旧 inode N1 引用计数→0 (异步释放)
    FS->>Inotify: IN_MOVED_FROM(.tmp) + IN_MOVED_TO(app.yaml)
    Inotify->>App: WatchEvent(ENTRY_DELETE) + ENTRY_CREATE

    App->>App: Debouncer 收集事件, 200ms 安静期
    Note over App: 期间可能再来若干 MODIFY,<br/>都被合并取消重排
    App->>App: 200ms 后触发 reload
    App->>FS: Files.readAllBytes(app.yaml)
    FS-->>App: byte[]  (来自 inode N2 的完整内容)
    App->>App: snapshot = parse(bytes)  (volatile 写)
    Note over App: 业务线程下次读 snapshot<br/>即生效(happens-before)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

这条链路串起本篇 80% 知识点:Path 抽象、Files.readAllBytes、原子 rename、inotify 事件映射、WatchService 三大对象、去抖、volatile 可见性。

# 10.3 设计哲学回扣

NIO.2 这一套 API,背后藏着 4 条跨语言通用的设计哲学:

  1. 机制与策略分离——FileSystem SPI 把"怎么访问文件"抽出去,本地磁盘、ZIP、内存、HDFS 共用一套 Path API。这就是 Mechanism, not Policy。
  2. 失败要响亮,不要沉默——File.delete() 返回 boolean 把根因吞了,Files.delete() 抛具体异常告诉你"为什么"。好 API 的第一原则是失败时给出可定位的信息。
  3. 原子操作是分布式系统的原子——ATOMIC_MOVE 让"配置/索引/检查点"的更新永远只有"完整旧/完整新"两态。WAL、CoW、LSM-tree 都建立在这条原语上。
  4. 事件 + 去抖 = 响应式系统的最小单元——内核给的是"发生了什么",应用层做的是"什么时候响应"。两者解耦,监听器才能稳定。

# 10.4 速查表

API 速查:

任务 一行代码
全量读文本 Files.readString(p, UTF_8)
流式读大文件 try (var s = Files.lines(p, UTF_8))
原子写 写临时 + Files.move(tmp, target, ATOMIC_MOVE, REPLACE_EXISTING)
创建临时文件 Files.createTempFile(dir, "p-", ".tmp", attrs)
递归删目录 Files.walkFileTree(d, deletingVisitor)
找出所有 .log Files.walk(root).filter(...)
监听目录 dir.register(ws, CREATE, MODIFY, DELETE)
内存映射 ch.map(READ_ONLY, 0, ch.size())
进程级独占锁 ch.tryLock()
解符号链接 p.toRealPath()
获取 inode Files.readAttributes(p, BasicFileAttributes.class).fileKey()

避坑速查:

现象 原因 解法
vim 改文件监听不到 rename 触发 DELETE+CREATE 不是 MODIFY 监听全部三类事件
一次保存触发多次回调 truncate+write 双事件 200ms debounce
配置读到空文件 写到一半被读 写侧用 ATOMIC_MOVE
日志切割后写不到新文件 fd 绑定 inode 应用收 SIGHUP 后 reopen
Files.lines 文件句柄泄漏 Stream 持有 fd try-with-resources
MappedByteBuffer 不释放 无 unmap 方法 Unsafe.invokeCleaner
macOS 监听延迟 10s kqueue 轮询 生产只在 Linux 跑
路径拼接被穿越 resolve 绝对路径覆盖 normalize().startsWith(base)

下一篇我们顺着"文件 IO 的尽头是序列化协议"这条线,进入 44.序列化原理与替代方案,把 Java 序列化的 AC ED 魔数、五大魔法钩子,以及 Protobuf/Kryo/Hessian 的横向对比讲透。

上次更新: 2026/06/10, 11:13:41
序列化原理与替代方案
面向对象的真意

← 序列化原理与替代方案 面向对象的真意→

最近更新
01
信号崩溃快速排查
06-15
02
CoreDump破案
06-15
03
perf火焰图实战
06-15
更多文章>
Theme by Vdoing | Copyright © 2019-2026 杨充 | MIT License | 桂ICP备2024034950号 | 桂公网安备45142202000030
  • 跟随系统
  • 浅色模式
  • 深色模式
  • 阅读模式