首页 / 资讯中心 / 文章详情

Java目录监控与WatchService文件监听实践

Java目录监控与WatchService文件监听实践 ★ FEATURED ARTICLE
简介这是一份面向Java初学者及需要文件监控实践案例的开发者的源码程序核心功能是按约5秒周期扫描指定目录动态显示文件数量与大小变化帮助判断目录内容是否发生变动可用于跟踪项目产物、监控日志生成也可延伸到游戏存档或运行日志观察等场景。压缩包整体约7KB文件总数与类型明细上游暂无有效数据包体轻量适合快速解压查看与本地运行。已有156人浏览学习。内容围绕Java文件读写与事件监听展开涉及File属性读取、nio.file中的WatchService、WatchKey与WatchEvent处理以及commons-io的FileAlterationObserver等常见实现同时体现扫描线程与界面更新分离、定时节拍控制资源占用的思路。对想理解目录监控、事件回调与多线程响应性的读者可用作练手项目并为日志监控、产物跟踪等小工具开发提供参考。1. 目录监控这件事文件写入完成后程序就要有响应服务器上有个目录别人不断往里放文件你需要盯着它什么时候多了一个、什么时候被改到一半。把它写成定时任务去轮询扫慢了会漏文件扫快了又让 CPU 白白消耗。这个 Java 目录监控程序解决的就是这件事用 JDK 自带的 WatchService 挂到指定目录上文件一旦创建、修改、删除就回调出来再按规则写入日志或交给下游处理。它不需要数据库也不需要部署到容器一个 main 方法就能启动。这类源码包在分类目录里常被归到「游戏娱乐」一栏原因是真有不少人拿它做截图自动归档、素材热更新、录屏文件整理这类小活。手里有“文件一到就要处理”的需求、又不想为这点事引入一整套中间件的开发者最适合先从这份 Java 源码开始读。工程本身可编译控制台和界面两种运行方式都有改起来不用先花三天熟悉框架。2. 技术选型与工程结构一个 main 加三个包没有多余分层2.1 目录结构逐层看源码包解压后是个标准 Java 工程没有 Maven 也能编译。常见的目录结构是这样watchdir/ ├── src/ │ └── com/example/watchdir/ │ ├── Main.java # 入口解析参数、选控制台还是界面 │ ├── core/ │ │ ├── DirWatcher.java # 真正干活的事件循环 │ │ ├── EventType.java # 枚举CREATE / MODIFY / DELETE │ │ └── FileEventListener.java # 回调接口 │ ├── filter/ │ │ └── NameFilter.java # 扩展名、正则过滤 │ ├── ui/ │ │ └── MonitorFrame.java # Swing 界面可不要 │ └── util/ │ └── LogWriter.java # 事件落盘 ├── lib/ # 第三方 jar可选 └── run.bat / run.sh # 双击就能跑这种分法的好处很直接core包不依赖 Swing也不读取配置文件谁要嵌入自己的项目把core和filter两个包复制走就行。ui只负责把FileEventListener吐出来的事件追加到表格里删掉界面照样能运行。我见过不少同类小工具把监听逻辑直接抄进按钮的点击回调里结果一关窗口线程就断事件全丢这是最容易踩的坑。2.2 三种监控方案的取舍Java 里监控目录无非三条路选错了后面全是麻烦方案原理延迟资源占用适用场景WatchService操作系统 inotify / ReadDirectoryChangesW毫秒级一个阻塞线程本机目录JDK 7单线程轮询定时Files.list比对快照取决于间隔目录大时 IO 高网络盘、跨机器挂载Commons IO FileAlterationMonitor内部仍是轮询秒级同上老项目、要求少依赖这份源码走的是第一条也就是java.nio.file.WatchService。它是 JDK 原生能力不用引任何 jar事件由内核推过来不用你反复比对目录。代价是它对“网络映射盘”这类文件系统经常不灵——内核收不到远端变更通知程序就静默不动了。所以工程里通常还留了一个轮询兜底开关配置里把mode改成poll就退化成定时扫描。常见做法是本地盘用 WatchService挂载盘和虚拟机共享目录用轮询两者共用同一套回调和过滤逻辑切换只改一个枚举值。这样界面层、日志层、业务层都不用动。2.3 版本与依赖编译前先确认WatchService 从 JDK 7 开始有Files.readAttributes、StandardWatchEventKinds这些也都是 7 之后的 API。所以在pom.xml或build.gradle里source/target不能低于 1.7如果源码里用了var或者List.of那实际要求是 11 以上编译前先跑一遍java -version确认不然报的错会让你误以为是代码写错了。依赖方面纯 WatchService 版本零第三方依赖只有两种情况会引 jar一是要用commons-io做文件拷贝二是界面用 FlatLaf 换皮。如果只做监听和日志lib目录可以是空的这一点对新手很友好——不用先配私服、不用等依赖下载javac直接就能编。提示拿到源码先看src里有没有module-info.java。有的话是模块化工程编译命令要加--module-path别照着老教程直接javac *.java会报找不到模块。3. 核心链路拆解从注册监听、事件队列到回调分发3.1 注册目录与事件掩码第一步是把目录挂到 WatchService 上。这一步只做一次但要选对事件类型// DirWatcher.java —— 注册被监控目录 public void watch(Path dir) throws IOException { WatchService ws FileSystems.getDefault().newWatchService(); // 三种事件全要很多编辑器保存文件是「写临时文件 - 改名」只监听 CREATE 会漏 WatchKey key dir.register(ws, StandardWatchEventKinds.ENTRY_CREATE, StandardWatchEventKinds.ENTRY_MODIFY, StandardWatchEventKinds.ENTRY_DELETE); this.watchService ws; this.rootKey key; this.watchedDir dir; }register的返回值WatchKey是后续取事件的凭据必须存住。掩码里三个常量各管一件事ENTRY_CREATE管新建和重命名进来ENTRY_MODIFY管内容被写ENTRY_DELETE管删除和移走。少注册一个对应事件就永远收不到而且不会报错只能靠现象排查。注意register只对当前这一层目录生效子目录要单独注册。工程里递归开关打开时是对整棵树做一次深度遍历、每个目录各注册一次并把WatchKey和目录路径放进一个Map里存起来。3.2 事件循环take 与 poll 的差别不止是阻塞事件循环是整个程序的根本写法基本固定// DirWatcher.java —— 事件循环 while (running) { WatchKey key; try { key watchService.take(); // 阻塞等待没有事件时线程挂起不烧 CPU } catch (InterruptedException e) { Thread.currentThread().interrupt(); // 保留中断标记让上层能优雅退出 break; } catch (ClosedWatchServiceException e) { break; // 服务已关闭正常退出 } Path parent (Path) key.watchable(); // 事件发生在哪个目录 for (WatchEvent? event : key.pollEvents()) { if (event.kind() StandardWatchEventKinds.OVERFLOW) { log.warn(事件队列溢出目录 {} 可能有遗漏, parent); continue; // 溢出时 context 是 null不能 resolve } Path child parent.resolve((Path) event.context()); dispatcher.dispatch(event.kind(), child); } if (!key.reset()) { // 必须 reset否则这个 key 再也不产生事件 watched.remove(key); // 目录被删或不可访问移出监听集合 if (watched.isEmpty()) break; } }这里每个参数都有讲究。take()会一直阻塞适合常驻进程poll()不阻塞、poll(long, TimeUnit)带超时适合需要定期检查running标志的场景。event.context()返回的是相对路径必须用key.watchable()补全否则拿到的是个光秃秃的文件名。reset()是最容易忘的一行不调用这个WatchKey就进入无效状态现象是“程序还在跑但一个事件都不来”非常隐蔽。3.3 文件真的写完了吗去抖与稳定判定收到ENTRY_CREATE不代表文件写完了。往目录里拷一个 500MB 的包内核可能在开头就抛出创建事件这时候去读只能读到半截。这就是为什么很多人的程序在本地测没事一上生产就出现“读到 0 字节”的问题。工程里的做法是等文件大小稳定// DirWatcher.java —— 两次采样大小一致才认为写完了 private boolean waitUntilStable(Path file, long timeoutMillis) { long deadline System.currentTimeMillis() timeoutMillis; long lastSize -1L; while (System.currentTimeMillis() deadline) { if (!Files.exists(file)) return false; // 中途被删了放弃 long size Files.size(file); if (size lastSize size 0) return true; // 大小没变认为写完 lastSize size; LockSupport.parkNanos(Duration.ofMillis(200).toNanos()); } return false; // 超时交给上层决定丢不丢 }参数上timeoutMillis别设太大3000 到 5000 毫秒够用采样间隔 200 毫秒是经验值太密没意义太疏大文件会多等。size 0这个判断是为了避开“文件刚创建还是空”的那一瞬。有些实现会再加一道Files.isWritable检查但在 Windows 上这个判断不可靠还是看大小变化稳。3.4 回调分发把事件交给业务而不是写在循环里事件循环里只做三件事取事件、补全路径、丢给分发器。业务逻辑一律走接口// FileEventListener.java —— 业务方只实现这个接口 public interface FileEventListener { void onCreate(Path file); void onModify(Path file); void onDelete(Path file); }分发器持有一个ListFileEventListener遍历调用单个监听器抛异常就try/catch掉并记日志绝不允许多个监听器互相影响。这样不管是控制台打印、写日志文件还是界面刷新表格都只是注册一个实现类的事。还有一层是过滤。目录里天天有一堆.tmp、.swp、~结尾的中间文件全往下游丢会引发各种异常// NameFilter.java —— 先过滤再分发 private static final Pattern IGNORE Pattern.compile(.*(\\.tmp|\\.swp|\\.part|~)$); public boolean accept(Path file) { String name file.getFileName().toString(); if (IGNORE.matcher(name).matches()) return false; if (name.startsWith(.)) return false; // 隐藏文件macOS 上特别多 return exts.isEmpty() || exts.contains(ext(name)); // exts 为空表示全放行 }exts从命令行参数读比如-e jpg,png,mp4只关心这几种。这一步在分发之前做能挡掉后面 80% 的不必要麻烦。4. 跑起来编译、参数与两种启动方式4.1 编译javac 直接编和 Maven 打包没有构建工具时最稳的是先用javac跑一次# Linux / macOS javac -encoding UTF-8 -d out $(find src -name *.java) java -cp out com.example.watchdir.Main -d /data/inbox -o ./events.log:: Windows文件名太长时先把源文件列表存成文件再编译 dir /s /b src\*.java sources.txt javac -encoding UTF-8 -d out sources.txt java -cp out com.example.watchdir.Main -d D:\inbox -o .\events.log-encoding UTF-8不能省。源码里只要有中文注释或者中文日志Windows 默认 GBK 编译就会报“编码 GBK 的不可映射字符”这是新手最常见的编译失败原因。-d out把 class 输出到独立目录源码目录保持干净。有pom.xml的话用 Maven 更省事mvn -q clean package之后target下会出一个带Main-Class的 jar直接java -jar就能跑。如果打出来的 jar 说“没有主清单属性”说明maven-jar-plugin里没配mainClass补上就行。4.2 命令行参数怎么传工程里常见的是四个参数参数名如果和源码不同按Main.java里的解析改即可参数含义默认值示例-d/--dir被监控目录无必填/data/inbox-o/--out事件日志文件控制台输出./events.log-e/--ext只关心的扩展名全部jpg,png,mp4-r/--recursive是否递归子目录falsetrue参数解析在Main里手写了一个循环没用第三方库所以传参格式很严格-d /path中间有空格写成-d/path会解析失败。日志默认追加模式写长期跑要注意轮转不然文件会越来越大。4.3 界面模式与打包成可执行 jar带 Swing 界面的版本Main里判断有没有-ui参数有就SwingUtilities.invokeLater起窗口。窗口上两个按钮选目录、开始/停止。开始之后事件往上追加最多保留 500 行超了从头删避免长时间运行把内存撑爆。给不装 JDK 的机器用得打成可执行 jarmvn clean package # 生成 target/watchdir-1.0.jar双击或 java -jar 运行 java -jar target/watchdir-1.0.jar -d D:\inbox -e jpg,png如果连java命令都没有只能靠jpackageJDK 14打成 exe 或者 dmg那个体积会大不少一般内部工具没必要。注意分发 jar 的时候别把out目录和src一起打包加个.gitignore或者.gitattributes不然压缩包会莫名其妙变大好几倍。4.4 第一次验证造几个文件试试跑起来之后别急着接业务先手写验证一遍。开两个终端一个跑程序一个做操作# 终端 A跑起来 java -cp out com.example.watchdir.Main -d ./test-inbox -e txt # 终端 B依次造三种情况 echo hello ./test-inbox/a.txt # 期望CREATE MODIFY echo more ./test-inbox/a.txt # 期望MODIFY rm ./test-inbox/a.txt # 期望DELETE echo temp ./test-inbox/b.tmp # 期望无输出被过滤器挡掉四种现象都对上说明监听、过滤、回调这条链是通的。如果 CREATE 有、MODIFY 没有先回头查register的掩码如果 DELETE 有但路径不对查resolve有没有少写。这一步花五分钟比后面在业务里怀疑监听逻辑强得多。5. 避坑与排查跑起来之后才会遇到的五个问题5.1 本层目录有反应子目录里的文件没动静现象监控/data/inbox往里放文件能收到事件放进/data/inbox/sub/就完全静默程序也不报错。 原因WatchService只对register过的那一层目录生效不会自动往下递归这是 API 设计如此不是 bug。 解决递归开关打开时用Files.walk或手写栈遍历一遍把每个子目录都register一次WatchKey和路径的映射存进MapWatchKey, Path。另外要处理运行期新建子目录的情况——收到子目录的 CREATE 事件后补注册一次否则新建的目录后续事件还是收不到。5.2 保存一次文件触发了三次 MODIFY现象在编辑器里改一个文件保存日志里刷出两三条 MODIFY下游被重复处理。 原因很多编辑器的保存策略是“写临时文件 → 删除原文件 → 改名”内核层面就是三次事件有些系统对write也会分块上报。 解决加去抖。用MapPath, Long记最后一次处理时间同一路径 300 毫秒内只处理一次或者在分发前对(事件类型, 路径)做一次短窗口合并。别用Thread.sleep在循环里硬等会把整个事件线程卡住后面的目录事件全积压。5.3 刚创建就去读读到 0 字节或报文件被占用现象onCreate里直接Files.readAllBytes偶尔拿到空数组Windows 上还会抛“另一个程序正在使用此文件”。 原因创建事件在文件内容写完之前就抛出来了尤其是拷贝大文件时事件和写入是并行的。 解决用 3.3 里的稳定判定两次采样大小一致再读。判定不通过就丢进重试队列隔 500 毫秒再试超过重试上限记一条 warning 然后放弃。放弃比无限重试好——目录里躺着一个永远写不完的临时文件时无限重试会把线程池占满。5.4 跑两三天就不动了但进程还在现象程序常驻前几天正常某天开始一条事件都不来jstack看线程卡在take()上。 原因主要有两种。一是某个WatchKey没有reset()那个目录永久失效二是 Linux 上inotify的 watch 数量有上限目录树特别大时注册到一半就失败了register抛的异常如果被吞掉现象就是部分目录永远没有事件。 解决reset()写在for循环外面、每个 key 处理完必调返回 false 就把 key 从 Map 里删掉。Linux 上先看cat /proc/sys/fs/inotify/max_user_watches目录多的话调大或者干脆对这个场景改用轮询模式。日志里把注册失败的目录名打出来别让异常黑匣子化。5.5 中文文件名变问号路径匹配全失效现象文件叫测试.jpg日志里打印成???.jpg按扩展名过滤的规则还能用但按文件名做的业务匹配全废。 原因JVM 启动时的file.encoding和系统默认编码不一致Windows 上是重灾区。 解决启动加-Dfile.encodingUTF-8打印路径统一用path.toString()而不是自己拼字符串日志文件用OutputStreamWriter显式指定 UTF-8别依赖平台默认。控制台本身编码不对的话输出还是会花但落盘的日志是对的——以落盘为准排查。6. 二次开发从“能收到事件”改到“能进业务”拿到这份源码很多人第一反应是把main里的路径改成自己的然后开跑。能跑但接进业务三天就会顶不住——因为它只会通知事件不会判断这个事件该不该往下走也不会在失败时保留补偿记录。真正要改的是两处。第一处是规则层。别在onCreate里写 if-else抽成一条条规则配置化// Rule.java —— 每条规则匹配什么、做什么、失败了怎么办 public class Rule { private String ext; // 关心的扩展名 private String targetDir; // 处理后挪到哪 private boolean deleteSource; // 处理完是否删源文件 public boolean matches(Path file) { return ext null || file.toString().endsWith(. ext); } }规则从rules.properties读改行为不用重新编译这在现场调试时能省下不少来回。第二处是投递层事件处理成功再确认失败进重试队列重试三次还不行就落到failed/目录人工兜底。清单文件和失败目录是这套东西唯一的补救手段不要省。改完怎么验证我一般做三步先造 100 个文件用脚本一次性灌进去看事件数对不对得上再把某个文件权限改成只读看失败分支会不会把文件挪进failed/最后让程序挂着跑一晚上第二天看日志里有没有 OVERFLOW 告警。三步都过才敢接到真实目录上。改造点改动位置验证方式规则配置化新增Rulerules.properties改配置不重启行为跟着变失败重试dispatcher里加队列只读文件能进failed/事件积压监控循环里统计 OVERFLOW灌 1000 个文件看告警血泪经验是别把业务逻辑写进事件循环线程一个大文件处理十秒后面所有目录的事件全堵在那十秒里。改成“循环只入队、业务用线程池消费”问题立刻消失。从那以后我每次接这类监听需求都强制先跑一遍灌文件的压测再谈功能——不然上线第一天就会被“文件延迟半小时才处理”教做人。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?
咨询建站