qinghuan
← 回到知识探索

后端现场 / 可下载小项目

文件已经加锁,为什么同一程序换个通道还会抛异常?

用同一 JVM 的两个文件通道和两个自建 JVM,观察重叠文件锁的异常、null 与成功获取,分清线程互斥和进程间协作。

展开本文目录
  1. 下载完整练习包
  2. 先说清锁的是哪个文件、哪一段字节
  3. 同一 JVM 里,重叠请求不是第二把线程锁
  4. 另一 JVM 竞争时,tryLock 的 null 才有这层含义
  5. 通道关闭和锁释放也属于实验顺序
  6. 保存单文件,实跑并核对每项观察
  7. 拿到文件锁,不等于所有程序都会自动守规矩
  8. 记住这三点
  9. 别把例子用过头
  10. 自己试一小步
  11. 查原始资料
开始前,会这些就够了
  • 读过 Java 字节流与 try-with-resources,知道文件通道需要关闭。
  • 能区分线程、JVM 进程与文件路径;不要求掌握操作系统锁实现。
  • 使用完整 JDK 17 或更新版本,允许程序创建自己的临时目录和子进程。
  • 需要补基础时:先补字节流和资源关闭
遇到不熟悉的词,先看这 2 个解释
文件锁范围
文件锁针对从某个字节位置开始、长度固定的一段区域。判断冲突时要比较区域是否重叠。
JVM 级文件锁
Java 文件锁代表整个 JVM 持有;同一 JVM 再用另一个通道申请重叠区域,仍然属于同一持有方范围。
打开完整术语小抄 ↗

两个后台任务都要更新同一份文件。你想到在写入前拿文件锁,再在完成后释放,以为这样就像 synchronized 一样让两个任务排队。结果第二次获取没有等到第一把锁释放,而是抛出了 OverlappingFileLockException。

这个实验先固定文件和字节范围,再只改变“请求来自哪里”:同一通道、同一 JVM 的另一通道,以及另一个 JVM。三个位置调用的都是文件锁 API,却不能把失败统一解释为返回 null。

把例子带到自己的电脑上

下载完整练习包

需要 JDK 17 或更新版本,使用 Java 标准库;制作时实测 Windows / JDK 17.0.20.1 / 21.0.12。使用标准库,不需要安装第三方包。下载 ZIP 后选择“全部提取”,进入能看到 README.md 的文件夹。

下载 ZIP · 5 个文件
包里有什么
  • FileLockScopeDemo.java:完整可运行示例
  • README.md:运行说明与独立练习
  • observed.stdout.txt:本次实际输出
  • verification.json:源码和产物验证记录
  • SHA256SUMS.txt:内容摘要

在这个文件夹打开 PowerShell,逐行运行

java -version
javac -version
javac -encoding UTF-8 --release 17 FileLockScopeDemo.java
java -cp . FileLockScopeDemo

解压到独立练习文件夹,在含 FileLockScopeDemo.java 的目录打开终端运行。Windows本地自建16字节临时文件;同通道/同JVM另一通道重叠异常、另一JVM竞争null、释放后获取;两个隐藏子进程均回收。JDK17/21各8项检查;未测试网络盘、共享锁或不配合写入。

找不到命令或文件时,先按 README 检查环境和所在目录。文中标出的阅读时间只估算文章篇幅,安装环境和动手练习可按自己的节奏进行。

先说清锁的是哪个文件、哪一段字节

FileLock 表示文件中的一个范围。程序新建一个十六字节的临时文件,对从位置零开始、长度十六的区域请求独占锁。这等价于本例中的 [0,16);文件大小、锁起点与锁长度分别是不同信息。

不要从文件名相同就开始猜测时序。这里两个通道都由同一个 Path 打开,子进程也使用父进程创建的这个文件,因此比较的是同一资源和同一重叠范围。实验没有链接路径、网络盘或不同文件提供者。

锁的范围在获取时确定。文件以后变长,并不自动让已有有限范围覆盖新增部分。本例刻意保持固定大小,先把所有权和结果看清;范围变化留给后面的独立练习。

同一 JVM 里,重叠请求不是第二把线程锁

父进程取得第一把锁后,先从同一通道调用 tryLock,再从另一个已打开的通道调用 tryLock。两次都捕获 OverlappingFileLockException,原有锁仍然有效。换一个 FileChannel 对象没有把请求变成另一个 JVM。

官方 API 把文件锁的持有范围定义在整个 JVM 层面,并明确说明它不适合用于同一 JVM 内多个线程的文件访问控制。因此这里的异常不是“还没等够久”,也不是应该被反复吞掉的普通空结果。

同一进程内部若需要让多个任务依次进入更新步骤,应另外使用明确的线程协调方式,并让相关代码共同遵守。文件锁处理跨进程协作的部分,线程锁处理本进程中的进入次序,设计时要分别说明谁参与、谁释放。

另一 JVM 竞争时,tryLock 的 null 才有这层含义

在第一把锁仍被持有时,父进程启动第一个子 JVM。子进程只尝试同一范围一次,结果为 null,然后立即退出。父进程保持持锁直到它退出,所以没有靠 sleep 猜测哪一方先到。

随后父进程释放原有锁,再启动第二个子 JVM。它这次成功得到 FileLock,并在退出前关闭它。两次子进程的代码相同,变化只有原来那把锁是否已经释放。

tryLock 不等待可用位置;重叠锁由另一程序持有时可以返回 null,其他失败仍可能抛异常。不能把 catch 所有异常后返回 null 当成等价实现,否则关闭通道、权限或文件系统错误也会被误报为普通竞争。

通道关闭和锁释放也属于实验顺序

源码把两个父进程通道都保留到持锁阶段结束。这样做有实际原因:部分系统关闭同一文件的某个通道可能影响这个 JVM 在该文件上的锁。若刚测完第二通道就把它关闭,再去观察子进程,容易把生命周期变化混进结果。

子进程输出重定向到这个实验自己的临时文件,避免读取管道成为新的等待因素。Windows 使用 javaw.exe 隐藏窗口,父进程给每个子进程五秒完成期限;异常路径也会结束并等待自己创建的子进程。这个期限是保护措施,不是性能结论。

锁与通道由 try-with-resources 关闭,全部步骤结束后才删除新建临时目录。源码不遍历用户工作目录,也不要求你指定要锁的真实业务文件。运行时使用无参数入口即可。

保存单文件,实跑并核对每项观察

把代码保存为 FileLockScopeDemo.java,在这个文件所在目录执行下方命令。需要完整 JDK 17 或更新版本;只有 java 而没有 javac 的环境还不能完成这里的编译步骤。无需 Maven、外部依赖或 IDE 插件。

2026 年 10 月 17 日已使用 Microsoft OpenJDK 17.0.20.1 和 21.0.12,各自在新建临时目录中以 --release 17 编译并执行。两版输出相同,每次完成 8 项检查。下方保留实际输出,失败时不会打印 PASS。

展开收起代码 · 小实验java · 94 行

长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。

小实验 · java
import java.io.IOException;
import java.nio.channels.FileChannel;
import java.nio.channels.FileLock;
import java.nio.channels.OverlappingFileLockException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.util.Comparator;
import java.util.Locale;
import java.util.concurrent.TimeUnit;

/** Only locks a newly-created temporary file; never accepts a user file. */
public class FileLockScopeDemo {
    private static int checks;

    private static void check(boolean condition, String message) {
        if (!condition) throw new AssertionError(message);
        checks++;
    }

    private static boolean overlaps(FileChannel channel) throws IOException {
        try (FileLock unexpected = channel.tryLock(0, 16, false)) {
            return false;
        } catch (OverlappingFileLockException expected) {
            return true;
        }
    }

    private static String child(Path directory, Path file, String label) throws Exception {
        boolean windows = System.getProperty("os.name").toLowerCase(Locale.ROOT).contains("win");
        // javaw is the no-console Windows launcher. Output goes to our temporary file.
        Path java = Path.of(System.getProperty("java.home"), "bin", windows ? "javaw.exe" : "java");
        Path output = directory.resolve(label + ".out");
        Process process = new ProcessBuilder(java.toString(), "-cp", System.getProperty("java.class.path"),
                FileLockScopeDemo.class.getName(), "--child", file.toString())
                .redirectErrorStream(true).redirectOutput(output.toFile()).start();
        try {
            process.getOutputStream().close();
            if (!process.waitFor(5, TimeUnit.SECONDS)) throw new IOException("child deadline");
            String result = Files.readString(output).strip();
            check(process.exitValue() == 0, "child failed: " + result);
            return result;
        } finally {
            if (process.isAlive()) {
                process.destroyForcibly();
                if (!process.waitFor(5, TimeUnit.SECONDS)) throw new IOException("child not reaped");
            }
            process.getInputStream().close();
            process.getErrorStream().close();
            process.getOutputStream().close();
        }
    }

    private static void removeOwnedDirectory(Path directory) throws IOException {
        try (var paths = Files.walk(directory)) {
            for (Path path : paths.sorted(Comparator.reverseOrder()).toList()) Files.delete(path);
        }
    }

    public static void main(String[] args) throws Exception {
        if (args.length == 2 && args[0].equals("--child")) {
            // This branch is invoked only by the parent with its own temporary file.
            try (FileChannel channel = FileChannel.open(Path.of(args[1]), StandardOpenOption.WRITE);
                 FileLock lock = channel.tryLock(0, 16, false)) {
                System.out.println(lock == null ? "other_jvm=null" : "other_jvm=acquired");
            }
            return;
        }
        if (args.length != 0) throw new IllegalArgumentException("run without arguments");
        Path directory = Files.createTempDirectory("qinghuan-filelock-");
        try {
            Path file = directory.resolve("owned.bin");
            Files.write(file, new byte[16]);
            // Keep BOTH channels open until after the held lock is released.
            try (FileChannel first = FileChannel.open(file, StandardOpenOption.WRITE);
                 FileChannel second = FileChannel.open(file, StandardOpenOption.WRITE)) {
                try (FileLock held = first.lock(0, 16, false)) {
                    check(overlaps(first), "same channel must reject overlap");
                    check(overlaps(second), "another channel in same JVM must reject overlap");
                    check(held.isValid(), "original lock still held");
                    check(child(directory, file, "held").equals("other_jvm=null"), "child must see contention");
                    System.out.println("same_channel=OverlappingFileLockException; same_jvm_other_channel=OverlappingFileLockException");
                    System.out.println("while_parent_holds: other_jvm=null");
                }
                check(child(directory, file, "released").equals("other_jvm=acquired"), "released lock must be available");
                System.out.println("after_parent_release: other_jvm=acquired");
            }
        } finally {
            removeOwnedDirectory(directory);
        }
        check(!Files.exists(directory), "temporary directory removed");
        System.out.println("PASS checks=" + checks + "; children_reaped=true; temporary_files_cleaned=true");
    }
}

怎样运行:在源码所在目录,先单独执行 javac -encoding UTF-8 --release 17 FileLockScopeDemo.java;编译成功后,再执行 java -cp . FileLockScopeDemo。两条命令分别回车。2026-10-17 已在 Windows、JDK 17.0.20.1 / 21.0.12 编译运行此份源码并通过检查。Windows本地自建16字节临时文件;同通道/同JVM另一通道重叠异常、另一JVM竞争null、释放后获取;两个隐藏子进程均回收。JDK17/21各8项检查;未测试网络盘、共享锁或不配合写入。

运行后对照结果
same_channel=OverlappingFileLockException; same_jvm_other_channel=OverlappingFileLockException
while_parent_holds: other_jvm=null
after_parent_release: other_jvm=acquired
PASS checks=8; children_reaped=true; temporary_files_cleaned=true

拿到文件锁,不等于所有程序都会自动守规矩

文件锁映射到底层系统能力,但是否强制阻止不配合的读写,存在平台差异。跨平台设计应要求参与者遵守同一锁定协议,不能只看到本机两个配合的 JVM 顺利交接,就宣布任意程序都无法绕开文件更新规则。

本次只验证 Windows 本地临时文件、独占重叠范围与两个自建 JVM 的交接。没有运行网络文件系统、共享锁、映射文件或强行绕过锁的写入,也没有把它包装为数据库事务或分布式锁。

排错时至少记录文件身份、请求范围、是否同一 JVM、获取方式、实际返回或异常,以及通道关闭顺序。这些事实比只留一句“加锁失败”更能解释程序到底走到了哪一步。

记住这三点

  • 同一 JVM 的重叠文件锁请求会抛 OverlappingFileLockException,换通道不改变这个范围。
  • 另一程序持有重叠锁时,tryLock 可以返回 null;释放后才有机会成功。
  • 线程协调、文件锁范围和资源关闭顺序要分别设计。

别把例子用过头

  • 把文件锁当作同 JVM 内线程排队的直接替代品。
  • 把所有异常转成 null,丢失关闭通道或其他 I/O 失败。
  • 在对照中提前关闭相关通道,改变锁的生命周期。
  • 从两个配合 JVM 的成功推断任意程序、网络盘都具备相同行为。

自己试一小步

如果同一文件里有互不重叠的两个区域,哪些获取会冲突?

  1. 复制项目,将自建文件扩大为三十二字节,明确列出两段区间。
  2. 分别为同一 JVM 和另一 JVM 设计重叠、相邻但不重叠的请求,先写下预测。
  3. 实际获取后记录锁的 position、size、是否得到对象、是否为 null 以及异常类型。
  4. 让全部参与者释放资源,提交区间表、源码、JDK 版本与实际输出。

做到这些,就算完成

  • 范围采用明确的起点和长度,没有把末端包含关系写反。
  • 同 JVM 与另一个 JVM 的结果分列,失败与异常未混写。
  • 只使用项目新建的临时文件,子进程有完成期限和回收。
  • 不能把一次本机结果写成所有文件系统的行为保证。

查原始资料

想确认一个细节,先看这份资料对应的部分,不必从头读完所有文档。