Java目录监控:WatchService+轮询兜底自动归档文件

发布时间:2026/10/26 2:18:30
Java目录监控:WatchService+轮询兜底自动归档文件

简介:这份压缩包是一套面向Java初学者与中级开发者的目录文件监控示例源码,围绕实时掌握指定文件夹内文件数量、大小及变化情况展开,可用于项目进度跟踪、日志生成观察,也可延伸到游戏存档目录与运行日志监控等场景。程序体现每5秒轮询扫描、java.io.File属性读取、java.nio.file.WatchService事件监听,以及借助FileAlterationObserver等工具简化回调的思路;同时通过多线程将目录扫描与界面刷新分离,兼顾响应性与资源消耗。压缩包约7KB,文件总数字段显示为0、主要文件类型明细暂未提供,包体轻量,适合作为文件I/O与事件驱动编程的参考样例。目前已有156人浏览学习,读者可从中梳理监控任务拆分、变化检测和节拍控制等实现要点。

1. 从一堆塞进同一个文件夹的录屏说起:这个 Java 监控目录程序到底解决什么问题

做游戏娱乐类内容的人大概都遇到过这种场面:录屏、截图、导出的音频先丢进同一个 inbox 目录,一天下来几百个文件混在一起,改名的、挪位置的、按日期归档的全靠手点,一小时就没了。这份 Java 源码做的事情很朴素——盯住一个目录,文件一落进来立刻感知,然后按你定的规矩分发处理。它适合两类人:一是想把「素材整理」这一步彻底自动化的开发者,二是刚学完 Java IO 和 NIO、想找个能跑起来又能改得动的小项目练手的同学。代码量不大,但没有写成玩具,该有的去重、写完判断、线程池分发、日志都留着。

2. 项目定位与技术选型:JDK 自带 WatchService 打底,轮询兜底

2.1 解压之后的目录结构:四个包各管一段

解开压缩包,看到的不是一堆平铺的.java文件,而是按职责切成了config、watch、core、handler、util五块。这种分法在小工具里算讲究的,好处很直接:你想换一套文件处理逻辑,只动handler包就行,监听层一行都不用碰;你想换监控方式,只动watch包,业务层也不知道。

目录骨架大致是这样,不同版本的包名可能略有出入,但分层是一致的:

DirectoryMonitor/ ├── pom.xml # Maven 构建,编译级别 1.8 ├── monitor.properties # 唯一的外部配置入口 ├── run.bat / run.sh # 免打包快捷启动脚本 ├── logs/ # 运行日志与事件流水 └── src/main/java/com/example/monitor/ ├── MonitorApplication.java # main 方法,装配所有组件 ├── config/MonitorConfig.java # 读 properties,做参数校验 ├── watch/WatchServiceListener.java # 事件监听主循环 ├── watch/PollingScanner.java # 定时扫描兜底 ├── core/FileEvent.java # 事件数据载体 ├── core/FileSettleChecker.java # 判断文件是否写完 ├── core/Dispatcher.java # 去重 + 线程池分发 ├── handler/FileHandler.java # 处理接口 ├── handler/ArchiveHandler.java # 默认实现:按类型归档 ├── util/PathMatcherUtil.java # 通配符白名单/黑名单 └── util/EventLog.java # 落盘日志
目录职责改动频率
config解析monitor.properties,把字符串转成Path、Duration低
watch监听目录变化,产生原始事件低
core去重、写完判断、线程池调度中
handler真正处理文件(归档、改名、入库、调接口)高
util路径匹配、日志输出低

看清楚这张表,后面读源码就有路线了:监听层只负责「告诉我发生了什么」,core负责「这次事件靠不靠谱、能不能处理」,handler负责「处理成什么样」。三层之间的契约只有一个FileEvent对象,这是整份代码里最值得学的一处设计。

2.2 技术栈:只吃 JDK,不引第三方监控库

pom.xml里的依赖极少,除了 JUnit 做测试,运行时不需要任何外部 jar。文件监控这部分直接用java.nio.file.WatchService——JDK 7 之后它已经把 Linux 的 inotify、Windows 的 ReadDirectoryChangesW、macOS 的 FSEvents 统一封装起来了,调同一套 API 就能跨平台拿到目录变更通知。

常见做法有三种,源码选的是第一种:

方案依赖实时性主要限制
WatchServiceJDK 自带毫秒级,内核推事件不递归、事件会合并、可能丢
定时轮询Files.walkJDK 自带取决于间隔CPU 开销随文件数线性上涨
第三方文件监控库需要额外 jar毫秒级版本兼容、打包体积变大

选WatchService的理由很实在:这是个拿 rar 发出来的小工具,用户双击run.bat就得能跑,多一个 jar 就多一次「class not found」的翻车机会。但它有个绕不开的短板——只监听注册过的那一层目录,子目录得自己递归注册;事件还会被内核合并,短时间内同一个文件改十次你可能只收到两三条ENTRY_MODIFY。所以源码里留了PollingScanner做兜底:监听层报了OVERFLOW,或者某些网络盘、虚拟机共享目录压根不产生事件的时候,轮询能把它捞回来。

2.3 运行环境与依赖清单

跑起来之前先确认环境,这一步别省:

# JDK 8 及以上都能编,源码编译级别写的是 1.8 java -version # Maven 3.6+,如果只跑不改编,装个 JRE 加现成 jar 也行 mvn -v # 顺手看一眼当前用户对目标目录有没有读写权限 ls -ld /data/inbox

几个实际会卡住的点:JDK 版本低于 8 会直接编译失败,Files.getLastModifiedTime那套 API 是 7 才有的;Windows 上路径里带空格(比如D:\My Files\inbox)时,配置文件和启动脚本里的引号要写对,否则Path解析出来是断的;Linux 上如果目标目录挂载在网络文件系统上,WatchService大概率注册得上但收不到事件,得把轮询开关打开。这些不是玄学,都是踩过一次就记住的事。

3. 核心链路拆解:一个文件落盘之后走的四步

3.1 第一步:注册监听目录,把 WatchKey 和 Path 对上号

WatchService最反直觉的地方是:它返回的WatchKey只告诉你「哪个注册句柄有动静」,不告诉你「哪个目录」。所以必须自己维护一张Map<WatchKey, Path>反查表。源码里这段是整个项目的地基,值得逐行看:

public class WatchServiceListener implements Runnable { private final WatchService watchService; // WatchKey 只标识注册句柄,真实目录要靠这张表反查 private final Map<WatchKey, Path> keyToDir = new ConcurrentHashMap<>(); private final MonitorConfig config; private final Dispatcher dispatcher; public WatchServiceListener(MonitorConfig config, Dispatcher dispatcher) throws IOException { this.config = config; this.dispatcher = dispatcher; this.watchService = FileSystems.getDefault().newWatchService(); for (Path root : config.getRoots()) { registerRecursive(root); // 入口目录逐个注册 } } private void registerRecursive(Path dir) throws IOException { WatchKey key = dir.register(watchService, StandardWatchEventKinds.ENTRY_CREATE, StandardWatchEventKinds.ENTRY_MODIFY, StandardWatchEventKinds.ENTRY_DELETE); keyToDir.put(key, dir); if (!config.isRecursive()) { return; } try (DirectoryStream<Path> children = Files.newDirectoryStream(dir)) { for (Path child : children) { // 软链接要跳过,否则可能绕成死循环 if (Files.isDirectory(child) && !Files.isSymbolicLink(child)) { registerRecursive(child); } } } } @Override public void run() { while (!Thread.currentThread().isInterrupted()) { WatchKey key; try { key = watchService.take(); // 阻塞等待,有事件才返回 } catch (InterruptedException e) { Thread.currentThread().interrupt(); return; } catch (ClosedWatchServiceException e) { return; // 服务被关,正常退出 } Path dir = keyToDir.get(key); if (dir == null) { key.reset(); continue; } for (WatchEvent<?> event : key.pollEvents()) { if (event.kind() == StandardWatchEventKinds.OVERFLOW) { dispatcher.onOverflow(dir); // 内核丢事件了,交给轮询补 continue; } // context() 只给文件名,不是完整路径,必须和目录拼 Path child = dir.resolve((Path) event.context()); if (event.kind() == StandardWatchEventKinds.ENTRY_CREATE && Files.isDirectory(child)) { try { registerRecursive(child); // 新建子目录要补注册,否则里面瞎了 } catch (IOException ignored) { } } dispatcher.submit(child, event.kind()); } // reset 必须调,不调这个 key 之后再也收不到事件 if (!key.reset()) { keyToDir.remove(key); // 目录被删或权限没了,注册失效 } } } }

几个关键参数得说清楚。watchService.take()是阻塞版本,线程会一直挂在这儿不耗 CPU,比poll()加sleep干净;key.pollEvents()取完事件后必须调key.reset(),这是新手最容易漏的一行,漏了的表现是「程序启动了,第一个事件收到了,之后就没动静了」,排查起来特别费时间。event.context()返回的是相对路径(其实就是文件名),所以必须dir.resolve(...)拼回绝对路径,直接在多级目录场景下拿它当路径用,会得到一堆找不到的文件。

还有一个容易误解的点:ENTRY_CREATE在「新建子目录」和「文件被移入目录」两种情况下都会触发。前者需要补注册,后者需要走处理流程,所以代码里先判断Files.isDirectory(child)再决定往哪条路走。

3.2 第二步:判断文件是不是真的写完了

这是目录监控里最容易被低估的一环。文件刚被创建的时候,ENTRY_CREATE就来了,可此时复制进程可能才写了 3KB,你上去就Files.move,轻则拿到半个文件,重则抛FileSystemException: 另一个程序正在使用此文件。源码里用了一个独立的FileSettleChecker做二次确认:

public class FileSettleChecker { private final long settleWaitMs; // 两轮采样间隔,默认 800ms private final int maxRound; // 最多采样几轮,默认 10 轮 public boolean waitUntilSettled(Path file) throws InterruptedException { long lastSize = -1L; long lastModified = -1L; for (int i = 0; i < maxRound; i++) { if (!Files.exists(file)) { return false; // 等待期间被删了,直接放弃 } long size = Files.size(file); long modified = Files.getLastModifiedTime(file).toMillis(); // 两轮采样完全一致且非空,才认为写完了 if (size == lastSize && modified == lastModified && size > 0) { return true; } lastSize = size; lastModified = modified; Thread.sleep(settleWaitMs); } return false; // 超时,交给上层记日志跳过 } }

参数怎么调:settleWaitMs决定灵敏度,本地小文件 300~500ms 够用,几十 MB 的录屏文件建议 800ms 到 1s,否则大文件还在写的时候两轮采样恰好一样长,会误判;maxRound是保险丝,十轮还没写完基本说明这个文件被别的进程长期占用了,再等下去就是死等。

size > 0这个判断也别删。很多工具(包括一些下载器和剪辑软件)会先创建一个 0 字节的占位文件,写完再补内容,这时候两轮采样都是 0,光看「两次一样」会直接把空文件判成写完了。

在 Windows 上还可以再加一层保险——尝试独占打开文件:

private boolean tryLockExclusive(Path file) { try (RandomAccessFile raf = new RandomAccessFile(file.toFile(), "rw"); FileChannel channel = raf.getChannel()) { FileLock lock = channel.tryLock(); // 拿不到锁说明还有别的进程在写 if (lock == null) { return false; } lock.release(); return true; } catch (IOException e) { return false; } }

这个技巧在 Linux 上意义不大(那边更依赖lsof判断),但 Windows 场景下能挡掉一大批「文件正在被使用」的异常。

3.3 第三步:事件去重与线程池分发

同一份文件被写一次,ENTRY_MODIFY可能连来三四条,不去重的话后面handler会被重复调用,归档逻辑里就是「文件不存在」的异常刷屏。Dispatcher用了一个时间窗做去重,同时把处理任务丢进线程池:

public class Dispatcher { private final ThreadPoolExecutor pool; private final Map<String, Long> lastSeen = new ConcurrentHashMap<>(); private final long dedupWindowMs; // 去重窗口,默认 500ms public Dispatcher(MonitorConfig config, FileHandler handler) { // 有界队列 + CallerRunsPolicy,队列满时让监听线程自己执行 // 相当于天然限流:宁可慢一点,也不把内存撑爆 BlockingQueue<Runnable> queue = new LinkedBlockingQueue<>(config.getQueueCapacity()); this.pool = new ThreadPoolExecutor( config.getWorkerThreads(), config.getWorkerThreads(), 0L, TimeUnit.MILLISECONDS, queue, new ThreadPoolExecutor.CallerRunsPolicy()); this.dedupWindowMs = config.getDedupWindowMs(); } public void submit(Path path, WatchEvent.Kind<?> kind) { String key = path.toAbsolutePath() + "|" + kind.name(); long now = System.currentTimeMillis(); Long prev = lastSeen.put(key, now); if (prev != null && now - prev < dedupWindowMs) { return; // 窗口内的重复事件直接丢掉 } pool.execute(() -> dispatch(path, kind)); } private void dispatch(Path path, WatchEvent.Kind<?> kind) { try { FileEvent event = new FileEvent(path, kind, System.currentTimeMillis()); if (!PathMatcherUtil.matches(path, config)) { return; // 白名单/黑名单过滤在业务之前 } handler.handle(event); } catch (Exception e) { EventLog.error("处理失败 " + path, e); } } }

线程数worker.threads默认给 4,够处理绝大多数场景。别一上手就调到 32,处理逻辑要是涉及磁盘 IO,线程越多上下文切换越凶,吞吐反而不涨。队列容量queue.capacity建议给到 2000 以上——目录监控的峰值往往出现在「用户一次性拖进来 500 个文件」这种时刻,队列小了会频繁触发CallerRunsPolicy,监听线程被业务卡住,事件就开始堆积。

lastSeen这张表要记得清,源码里有个后台线程每 30 秒清一次过期条目,不然跑上一周内存就悄悄涨上去了,这种慢性问题最容易被忽略。

3.4 第四步:交给 Handler,落日志与失败记录

处理层只认一个接口,这是整个项目最值得抄的设计:

public interface FileHandler { void handle(FileEvent event) throws Exception; }

默认实现ArchiveHandler干的事很具体:按扩展名分到images/、videos/、audio/三个子目录,文件名前面拼时间戳防重名:

public class ArchiveHandler implements FileHandler { private final Path outputRoot; private final Map<String, String> typeMapping = new HashMap<>(); @Override public void handle(FileEvent event) throws Exception { Path src = event.getPath(); String ext = getExtension(src); // 小写化的扩展名 String category = typeMapping.getOrDefault(ext, "others"); Path targetDir = outputRoot.resolve(category) .resolve(LocalDate.now().toString()); // 再按日期分一层 Files.createDirectories(targetDir); String fileName = System.currentTimeMillis() + "_" + src.getFileName(); Path target = targetDir.resolve(fileName); Files.move(src, target, StandardCopyOption.REPLACE_EXISTING); EventLog.info("归档 " + src + " -> " + target); } private String getExtension(Path path) { String name = path.getFileName().toString(); int dot = name.lastIndexOf('.'); return dot < 0 ? "" : name.substring(dot + 1).toLowerCase(); } }

换成自己的业务只要实现FileHandler:要入库就写DbInsertHandler,要调接口就写UploadHandler,要按内容哈希去重就写DedupHandler。源码里MonitorApplication是按配置里的handler.class反射加载的,改配置就能换实现,不用重新打包。

日志这块源码走的是「双通道」:一条logs/monitor.log记人类看的流水,一条logs/events.csv记机器分析的明细(时间戳、事件类型、完整路径、文件大小)。后者在做统计的时候特别省事,直接丢进表格工具就能看出一周里哪个时段文件最多。

4. 把程序跑起来:配置项、启动命令与验证方法

4.1 monitor.properties 每一项该填什么

配置文件是唯一的入口,改这里比改代码安全得多:

配置项示例值说明
monitor.rootsD:/inbox,/data/drop监控根目录,多个用英文逗号分隔
monitor.recursivetrue是否递归注册子目录
monitor.poll.interval3000轮询兜底间隔,单位毫秒,0 表示关闭
monitor.settle.wait800两次采样间隔,大文件往上调
monitor.settle.round10最多采样轮数
monitor.dedup.window500事件去重窗口,单位毫秒
monitor.worker.threads4处理线程数
monitor.queue.capacity2000任务队列长度
monitor.include*.jpg,*.png,*.mp4,*.wav白名单,留空表示全收
monitor.exclude*.tmp,*.part,*.crdownload黑名单,优先级高于白名单
monitor.handler.classcom.example.monitor.handler.ArchiveHandler处理实现类全限定名
monitor.log.dir./logs日志目录

两个细节值得单独提:exclude里一定要有临时文件后缀,浏览器下载中的.crdownload、剪辑软件导出的.part、迅雷的.td,这些中途文件被当成成品处理是最高频的翻车点;include和exclude同时命中时以exclude为准,这个优先级在PathMatcherUtil里写死了,改的时候注意别顺手调反。

4.2 打包与启动:jar 和脚本两种方式

# 方式一:打成一个可执行 fat jar,拷到别的机器上直接 java -jar 就能跑 mvn clean package -DskipTests java -jar target/directory-monitor-1.0.jar -c monitor.properties # 方式二:不打包,用 classpath 直接跑(改代码调试时更顺手) mvn compile java -cp "target/classes:lib/*" com.example.monitor.MonitorApplication # Windows 下 classpath 分隔符是分号,注意别照抄上面的冒号 java -cp "target/classes;lib/*" com.example.monitor.MonitorApplication

-c参数指定配置文件路径,不传就默认读工作目录下的monitor.properties。想让它当后台服务跑,Linux 上写个 systemd unit 或者nohup java -jar ... > /dev/null 2>&1 &都行,但要注意工作目录——配置文件里的相对路径./logs是相对启动目录算的,用 service 启动时工作目录往往是/,日志会落到奇怪的地方去,建议全部改成绝对路径。

启动成功的标志是控制台打出这几行:先打印解析后的配置摘要(目录数、线程数、是否递归),再打印每个注册成功的目录,最后是watching ...。如果配置里写的目录不存在,源码的处理是打一条 warn 然后跳过,不中断启动——这个行为看个人口味,我一般会把它改成直接退出,省得半夜发现程序在跑但一个目录都没盯住。

4.3 造几个文件验证链路是否通

不要拿真实素材目录做第一次测试,新建一个空目录专门验证:

# 造一个临时验证目录 mkdir -p /tmp/monitor-test/inbox /tmp/monitor-test/out # 逐个造文件,观察日志的变化(每个之间隔一两秒,模拟真实落盘节奏) echo "hello" > /tmp/monitor-test/inbox/sample-1.txt sleep 2 cp /some/large-video.mp4 /tmp/monitor-test/inbox/ # 大文件,看写完判断是否生效 sleep 2 mkdir /tmp/monitor-test/inbox/sub && echo hi > /tmp/monitor-test/inbox/sub/nested.txt # 实时跟日志,重点看三件事:事件有没有丢、归档路径对不对、有没有重复处理 tail -f logs/monitor.log

预期看到的行为是:小文件大约 1 秒内被归档;大文件要等复制结束才动;sub/nested.txt这种二级目录里的文件也能被抓到(验证递归注册补注册那段逻辑)。如果大文件在复制到一半就被Files.move走了,八成是settle.wait设太小,或者FileSettleChecker那段被改坏了。想验证去重是否生效,用一个循环在 1 秒内连续echo十次同一个文件,日志里对应的归档记录应该只有一两条。

4.4 轮询兜底:什么时候必须打开

public class PollingScanner implements Runnable { private final Map<Path, Long> snapshot = new ConcurrentHashMap<>(); // 路径 -> 修改时间 private final long intervalMs; @Override public void run() { while (!Thread.currentThread().isInterrupted()) { try (Stream<Path> walk = Files.walk(config.getRoot(), config.isRecursive() ? Integer.MAX_VALUE : 1)) { walk.filter(Files::isRegularFile).forEach(p -> { long mtime = Files.getLastModifiedTime(p).toMillis(); Long old = snapshot.put(p, mtime); if (old == null) { dispatcher.submit(p, StandardWatchEventKinds.ENTRY_CREATE); } else if (old != mtime) { dispatcher.submit(p, StandardWatchEventKinds.ENTRY_MODIFY); } }); Thread.sleep(intervalMs); } catch (Exception e) { EventLog.error("轮询异常", e); } } } }

这段代码的价值不在功能,在于「监听层失聪时的后悔药」。三种情况必须打开:目录挂在网络共享盘或虚拟机的共享目录上(内核事件经常传不过来)、Linux 上 inotify 的 watch 数量被打满(表现为注册时抛IOException: Too many open files)、目录会被整个删除重建(原来的WatchKey已经失效)。代价是文件多的时候 CPU 会涨,所以poll.interval别设成 200ms,3000ms 起步比较合适。

5. 避坑与常见问题排查:五个真实翻车点

5.1 事件相关的三个坑

现象一:程序启动了,日志里一条事件都没有。原因:WatchKey用完没调reset(),第一次pollEvents()之后这个 key 就进入失效状态,后续事件全被丢弃。 解决:检查run()循环末尾有没有if (!key.reset()) { keyToDir.remove(key); },这行漏了是新手翻车率最高的地方。顺手也检查一下take()是不是被换成了poll()且没加等待,那样会变成一个空转的死循环。

现象二:目录多的时候只有第一层有反应,子目录里的文件完全没动静。原因:WatchService天生不递归,源码里的registerRecursive只会在启动时和「新建子目录」时被调用,如果你在注册完之后才往监控目录里复制一整棵树,中间那些层级可能没被注册到。 解决:确认monitor.recursive=true;如果目录树是外部一次性同步进来的,把轮询兜底开上,别指望事件层能补全。

现象三:日志里偶尔出现一条OVERFLOW,之后一段时间的事件都对不上。原因:内核的事件队列满了(短时间内文件变更太密集),JDK 只能给你一个OVERFLOW告诉你「丢了一些,具体丢了什么不知道」。 解决:源码收到OVERFLOW会调dispatcher.onOverflow(dir),实现就是触发一次全量轮询扫描对齐差异。如果你的版本里这段被注释掉了,务必自己补上,不然丢事件是无声的,用户只会说「昨天有几个文件没归档」。

5.2 文件处理相关的坑

现象四:归档出来的图片打不开,大小只有原文件的一半。原因:写完判断形同虚设——settle.wait设成了 100ms,settle.round只有 2,大文件在两轮采样之间大小恰好没变(缓冲区刷盘节奏导致的),被误判成已完成。 解决:把采样间隔调到 800ms 以上、轮数调到 10,并且确保size == lastSize && modified == lastModified是「连续两轮」都成立,而不是只比一次;Windows 上再叠一层独占打开判断。

现象五:处理中文文件名时日志里出现乱码,归档后的路径也找不到文件。原因:文件名编码和 JVM 默认字符集不一致,常见于在 Windows 上打 jar 然后丢到 Linux 上跑,file.encoding和sun.jnu.encoding没对齐。 解决:启动参数里显式指定-Dfile.encoding=UTF-8 -Dsun.jnu.encoding=UTF-8,配置文件也统一存成 UTF-8 无 BOM;别用new String(bytes)这种依赖平台默认编码的写法,读配置一律显式传StandardCharsets.UTF_8。

5.3 运行环境相关的坑

Linux 上如果监控目录特别多(几千个子目录),注册阶段会抛Too many open files,那是 inotify 的 watch 数量上限到了,cat /proc/sys/fs/inotify/max_user_watches看一眼当前值,按需调大或者改成分层监控;Windows 上如果配置里写的是相对路径、启动脚本又不小心在别的盘符下执行,Path会解析到意想不到的地方,日志里看到路径不对先怀疑这个;还有个小坑是监控目录本身被删掉重建后,旧WatchKey已经失效,reset()返回 false 之后程序不会自动重新注册,得靠轮询或者重启才恢复。

6. 二次开发:把监控器改成你自己的业务管道

这份源码最容易上手的地方,就是它把「监听」和「处理」切开得足够干净,改造几乎只发生在handler包。我按从轻到重的顺序给三条路子。

第一条,只换处理逻辑。写一个类实现FileHandler,把handler.class配置指向它就行,不动其他任何代码。

public class UploadHandler implements FileHandler { private final HttpClient client = HttpClient.newHttpClient(); @Override public void handle(FileEvent event) throws Exception { // 只有白名单命中的文件才会走到这里,不用重复判断后缀 HttpRequest req = HttpRequest.newBuilder() .uri(URI.create(config.getUploadUrl())) .header("Content-Type", "application/octet-stream") .POST(HttpRequest.BodyPublishers.ofFile(event.getPath())) .build(); HttpResponse<String> resp = client.send(req, HttpResponse.BodyHandlers.ofString()); if (resp.statusCode() != 200) { throw new IOException("上传失败: " + resp.statusCode()); // 抛出后由 Dispatcher 记日志 } } }

第二条,加一条处理链。FileHandler是接口,天然可以组合:写一个CompositeHandler持有一个List<FileHandler>,按顺序执行,前一个成功才走下一个。想加个「先算哈希查重、再上传、最后删源文件」的流程,就是把三个实现串起来,业务代码不用改一行。

第三条,改事件来源。如果监控的目录规模大到WatchService扛不住(比如几十万个文件),把watch包换成一个基于Files.walkFileTree的增量扫描器,只要它最终调用的还是dispatcher.submit(path, kind),core和handler完全无感。

改造完怎么验证?我的做法是准备三组固定样本:一个正常小文件、一个 500MB 以上的大文件、一个带中文和空格的路径,跑一遍看日志里三条是否都归档成功且路径正确;然后开两个线程同时往目录里灌 200 个文件,看队列有没有被CallerRunsPolicy卡住、lastSeen那张表有没有无限增长。

需要的提醒是:Dispatcher里的异常是被吞掉记日志的,处理失败不会自动重试。真要做可靠管道,得在handler里自己加失败落盘 + 补偿队列,别指望框架帮你兜。这个坑我踩过——上线第一周有 30 多个文件因为目标接口限流静默失败,日志里躺着几百行处理失败,没人看。从那以后我每次接目录监控的活,都强制给自己加一条「失败事件单独落一个文件,第二天早上扫一眼」的习惯,再小的工具也加。希望帮到你。

本文还有配套的精品资源,点击获取