I would implement this as follows:
interface FileOperator {
public void operate(File file);
}
class FileProxy {
private static final ConcurrentHashMap<URI, FileProxy> map =
new ConcurrentHashMap<>();
private final Semaphore mutex = new Semaphore(1, true);
private final File file;
private final URI key;
private FileProxy(File file) {
this.file = file;
this.key = file.toURI();
}
public static void operate(URI uri, FileOperator operator) {
FileProxy curProxy = map.get(uri);
if(curProxy == null) {
FileProxy newProxy = new FileProxy(new File(uri));
FileProxy curProxy = map.putIfAbsent(newProxy.key, newProxy);
if(curProxy == null) {
curProxy = newProxy; // FileProxy was not in the map
}
}
try {
curProxy.mutex.acquire();
operator.operate(curProxy.file);
} finally {
curProxy.mutex.release();
}
}
}
The threads that are using a file implement FileOperator or something similar. Files are hidden behind a FileProxy that maintains a static ConcurrentHashMap of key (URI, or absolute path, or some other file invariant) value (FileProxy) pairs. Each FileProxy maintains a Semaphore that acts as a mutex - this is initialized with one permit. When the static operate method is called, a new FileProxy is created from the URI if none exists; the FileOperator is then added to the FileProxy queue; acquire is called on the mutex to ensure that only one thread can operate on the file at a time; and finally the FileOperator does its thing.
In this implementation, FileProxy objects are never removed from the ConcurrentHashMap - if this is a problem then a solution is to wrap the FileProxy objects in a WeakReference or SoftReference so that they can be garbage collected, and then call map.replace if reference.get() == null to ensure that only one thread replaces the GC'd reference.