WatchService
WatchService is a Java NIO.2 API introduced in Java 7 that monitors file system directories for changes using OS-level event notification rather than polling. Where polling checks file metadata (modification time, size) at intervals and misses changes between polls while wasting CPU, WatchService registers directories with the OS notification subsystem — inotify on Linux, FSEvents/kqueue on macOS, ReadDirectoryChangesW on Windows — and blocks until the OS delivers an event. Events are reported as WatchEvent objects with a kind (ENTRY_CREATE, ENTRY_DELETE, ENTRY_MODIFY) and a context (the filename relative to the watched directory). Multiple directories can be watched by a single WatchService. The API is the foundation for build tools (Gradle, Maven daemon file watching), hot configuration reload in servers, IDE file change detection, and incremental compilation pipelines. This entry covers the complete WatchService lifecycle from creation to event processing, all event kinds and their OS-level semantics, overflow handling, the distinction between recursive and non-recursive watching, platform-specific behavior differences, high-volume event handling and debouncing patterns, and the relationship between WatchService and the broader NIO.2 FileSystem provider model.
WatchService Lifecycle — Registration, Event Loop, and Key Management
// ── Complete WatchService lifecycle ──────────────────────────────────
Path watchDir = Path.of("/home/user/config");
try (WatchService watcher = FileSystems.getDefault().newWatchService()) {
// Register directory for create, modify, and delete events:
WatchKey key = watchDir.register(watcher,
StandardWatchEventKinds.ENTRY_CREATE,
StandardWatchEventKinds.ENTRY_MODIFY,
StandardWatchEventKinds.ENTRY_DELETE
);
System.out.println("Watching: " + watchDir);
System.out.println("Key valid: " + key.isValid()); // true
// Event loop:
while (true) {
WatchKey signalledKey;
try {
signalledKey = watcher.take(); // blocks until event available
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
break;
} catch (ClosedWatchServiceException e) {
System.out.println("WatchService closed");
break;
}
// Process all pending events for this key:
for (WatchEvent<?> event : signalledKey.pollEvents()) {
WatchEvent.Kind<?> kind = event.kind();
// Always check for overflow — can occur even if not registered:
if (kind == StandardWatchEventKinds.OVERFLOW) {
System.err.println("Event overflow! Some events may have been lost.");
rescheduleFullScan(); // do a full directory scan to catch up
continue;
}
// Context is the filename (relative to watched directory):
@SuppressWarnings("unchecked")
WatchEvent<Path> pathEvent = (WatchEvent<Path>) event;
Path fileName = pathEvent.context();
Path fullPath = watchDir.resolve(fileName); // make absolute
if (kind == StandardWatchEventKinds.ENTRY_CREATE) {
System.out.println("Created: " + fullPath);
onFileCreated(fullPath);
} else if (kind == StandardWatchEventKinds.ENTRY_MODIFY) {
System.out.println("Modified: " + fullPath);
onFileModified(fullPath);
} else if (kind == StandardWatchEventKinds.ENTRY_DELETE) {
System.out.println("Deleted: " + fullPath);
onFileDeleted(fullPath);
}
}
// CRITICAL: reset() to receive further events for this key
boolean stillValid = signalledKey.reset();
if (!stillValid) {
System.out.println("Watched directory no longer accessible: " + watchDir);
break; // directory was deleted or became inaccessible
}
}
} // watcher.close() called here
// ── Watching multiple directories with one WatchService ──────────────
try (WatchService watcher = FileSystems.getDefault().newWatchService()) {
Map<WatchKey, Path> keyToDir = new HashMap<>();
// Register multiple directories:
Path[] dirs = {
Path.of("/etc/myapp"),
Path.of("/var/myapp/config"),
Path.of("/home/user/.myapp")
};
for (Path dir : dirs) {
WatchKey k = dir.register(watcher,
StandardWatchEventKinds.ENTRY_CREATE,
StandardWatchEventKinds.ENTRY_MODIFY,
StandardWatchEventKinds.ENTRY_DELETE);
keyToDir.put(k, dir);
System.out.println("Watching: " + dir);
}
while (true) {
WatchKey key = watcher.take();
Path dir = keyToDir.get(key); // which directory triggered?
for (WatchEvent<?> event : key.pollEvents()) {
if (event.kind() == StandardWatchEventKinds.OVERFLOW) continue;
@SuppressWarnings("unchecked")
Path file = dir.resolve(((WatchEvent<Path>) event).context());
System.out.printf("[%s] %s: %s%n", event.kind().name(), dir.getFileName(), file);
}
if (!key.reset()) {
keyToDir.remove(key);
System.out.println("Directory no longer accessible: " + dir);
if (keyToDir.isEmpty()) break; // no more directories to watch
}
}
}Event Semantics, Overflow, Platform Differences, and Recursive Watching
// ── Recursive watching — register all subdirectories ─────────────────
void watchRecursively(Path root) throws IOException, InterruptedException {
WatchService watcher = FileSystems.getDefault().newWatchService();
Map<WatchKey, Path> keys = new HashMap<>();
// Register all existing directories in the tree:
Files.walkFileTree(root, new SimpleFileVisitor<>() {
@Override
public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs)
throws IOException {
registerDir(dir, watcher, keys);
return FileVisitResult.CONTINUE;
}
});
System.out.println("Watching " + keys.size() + " directories recursively");
while (true) {
WatchKey key = watcher.take();
Path dir = keys.get(key);
for (WatchEvent<?> event : key.pollEvents()) {
WatchEvent.Kind<?> kind = event.kind();
if (kind == StandardWatchEventKinds.OVERFLOW) continue;
@SuppressWarnings("unchecked")
Path filename = ((WatchEvent<Path>) event).context();
Path fullPath = dir.resolve(filename);
// Register new subdirectories as they are created:
if (kind == StandardWatchEventKinds.ENTRY_CREATE) {
if (Files.isDirectory(fullPath, LinkOption.NOFOLLOW_LINKS)) {
// Register this new directory and all its children:
Files.walkFileTree(fullPath, new SimpleFileVisitor<>() {
@Override
public FileVisitResult preVisitDirectory(Path d, BasicFileAttributes a)
throws IOException {
registerDir(d, watcher, keys);
return FileVisitResult.CONTINUE;
}
});
}
System.out.println("Created: " + fullPath);
} else if (kind == StandardWatchEventKinds.ENTRY_MODIFY) {
System.out.println("Modified: " + fullPath);
} else if (kind == StandardWatchEventKinds.ENTRY_DELETE) {
System.out.println("Deleted: " + fullPath);
}
}
if (!key.reset()) {
keys.remove(key);
System.out.println("Directory removed: " + dir);
if (keys.isEmpty()) break;
}
}
}
void registerDir(Path dir, WatchService watcher, Map<WatchKey, Path> keys)
throws IOException {
WatchKey key = dir.register(watcher,
StandardWatchEventKinds.ENTRY_CREATE,
StandardWatchEventKinds.ENTRY_MODIFY,
StandardWatchEventKinds.ENTRY_DELETE);
keys.put(key, dir);
}
// ── Platform-specific sensitivity modifier ─────────────────────────────
// On macOS/Linux where Java polls internally:
import com.sun.nio.file.SensitivityWatchEventModifier;
Path macDir = Path.of("/Users/user/project");
try (WatchService watcher = FileSystems.getDefault().newWatchService()) {
macDir.register(watcher,
new WatchEvent.Kind<?>[]{
StandardWatchEventKinds.ENTRY_CREATE,
StandardWatchEventKinds.ENTRY_MODIFY,
StandardWatchEventKinds.ENTRY_DELETE
},
SensitivityWatchEventModifier.HIGH // poll every 2 seconds instead of default
);
// Without HIGH: macOS may batch events with up to 10-second delay
}
// ── Overflow handling ─────────────────────────────────────────────────
for (WatchEvent<?> event : key.pollEvents()) {
if (event.kind() == StandardWatchEventKinds.OVERFLOW) {
int lostEvents = event.count(); // may be 0 if count unknown
System.err.printf("OVERFLOW: ~%d events lost — performing full scan%n",
lostEvents);
// After overflow: scan the directory to catch up:
try (Stream<Path> entries = Files.list(watchDir)) {
entries.forEach(p -> {
if (!knownFiles.contains(p)) {
System.out.println("Missed CREATE: " + p);
onFileCreated(p);
knownFiles.add(p);
}
});
}
// Check for deleted files:
knownFiles.removeIf(p -> {
if (!Files.exists(p)) {
System.out.println("Missed DELETE: " + p);
onFileDeleted(p);
return true;
}
return false;
});
continue;
}
// ... normal event processing
}Debouncing, Hot Reload Pattern, and Background Thread Design
// ── Debounced hot configuration reload ───────────────────────────────
class ConfigWatcher {
private final Path configDir;
private final AtomicReference<Config> current;
private final ScheduledExecutorService debounceScheduler;
private final Map<Path, ScheduledFuture<?>> pendingReloads = new ConcurrentHashMap<>();
private final long debounceDelayMs;
private volatile Thread watchThread;
ConfigWatcher(Path configDir, Config initial, long debounceDelayMs) {
this.configDir = configDir;
this.current = new AtomicReference<>(initial);
this.debounceDelayMs = debounceDelayMs;
this.debounceScheduler = Executors.newSingleThreadScheduledExecutor(r -> {
Thread t = new Thread(r, "config-reload");
t.setDaemon(true);
return t;
});
}
Config getConfig() { return current.get(); } // always returns consistent snapshot
void start() {
watchThread = new Thread(this::watchLoop, "config-watcher");
watchThread.setDaemon(true); // don't block JVM shutdown
watchThread.start();
}
void stop() {
watchThread.interrupt(); // interrupts the take() call
debounceScheduler.shutdown();
}
private void watchLoop() {
try (WatchService watcher = FileSystems.getDefault().newWatchService()) {
configDir.register(watcher,
StandardWatchEventKinds.ENTRY_CREATE,
StandardWatchEventKinds.ENTRY_MODIFY,
StandardWatchEventKinds.ENTRY_DELETE);
while (!Thread.currentThread().isInterrupted()) {
WatchKey key;
try {
key = watcher.take(); // blocks until event
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return;
}
for (WatchEvent<?> event : key.pollEvents()) {
if (event.kind() == StandardWatchEventKinds.OVERFLOW) continue;
@SuppressWarnings("unchecked")
Path file = configDir.resolve(((WatchEvent<Path>) event).context());
if (!file.toString().endsWith(".json") &&
!file.toString().endsWith(".yaml")) continue; // only config files
scheduleReload(file);
}
if (!key.reset()) break;
}
} catch (IOException e) {
System.err.println("WatchService error: " + e.getMessage());
}
}
private void scheduleReload(Path file) {
// Cancel any existing pending reload for this file:
ScheduledFuture<?> existing = pendingReloads.get(file);
if (existing != null) existing.cancel(false);
// Schedule reload after debounce delay:
ScheduledFuture<?> future = debounceScheduler.schedule(() -> {
pendingReloads.remove(file);
reloadConfig(file);
}, debounceDelayMs, TimeUnit.MILLISECONDS);
pendingReloads.put(file, future);
}
private void reloadConfig(Path file) {
try {
System.out.println("Reloading config: " + file);
Config newConfig = Config.load(configDir); // load entire config dir
current.set(newConfig); // ATOMIC swap — request threads always see consistent state
System.out.println("Config reloaded successfully");
} catch (Exception e) {
System.err.println("Config reload failed — keeping old config: " + e.getMessage());
// current.get() still returns old config — no partial state
}
}
}
// Usage:
Config initial = Config.load(Path.of("/etc/myapp"));
ConfigWatcher watcher = new ConfigWatcher(
Path.of("/etc/myapp"), initial, 300 // 300ms debounce
);
watcher.start();
// Request handler thread:
void handleRequest(Request req, Response res) {
Config config = watcher.getConfig(); // always gets a complete, consistent Config
res.write(config.processRequest(req));
}
// ── WatchService in a virtual thread (Java 21+) ───────────────────────
// Virtual threads make blocking WatchService operations trivial:
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
executor.submit(() -> {
try (WatchService ws = FileSystems.getDefault().newWatchService()) {
Path.of("/watch/dir").register(ws,
StandardWatchEventKinds.ENTRY_MODIFY);
while (true) {
WatchKey k = ws.take(); // blocks virtual thread (unmounts carrier)
k.pollEvents().forEach(e ->
System.out.println("Event: " + e.context())
);
k.reset();
}
} catch (Exception e) { e.printStackTrace(); }
});
}