上篇文章(【死磕 NIO】— 深入分析Channel和FileChannel)已经详细介绍了 FileChannel的核心原理及相关API,了解了FileChannel是用来读写和映射一个系统文件的 Channel,其实他还有很牛逼的功能就是:跨进程文件锁。
说一个场景有多个进程同时操作某一个文件,并行往文件中写数据,请问如何保证写入文件的内容是正确的?可能有小伙伴说加分布式锁,可以解决问题,但是有点儿重了。
有没有更加轻量级的方案呢? 多进程文件锁:FileLock。
FileLock
FileLock是文件锁,它能保证同一时间只有一个进程(程序)能够修改它,或者都只可以读,这样就解决了多进程间的同步文件,保证了安全性。但是需要注意的是,它进程级别的,不是线程级别的,他可以解决多个进程并发访问同一个文件的问题,但是它不适用于控制同一个进程中多个线程对一个文件的访问。这也是为什么它叫做 多进程文件锁,而不是 多线程文件锁。
FileLock一般都是从FileChannel 中获取,FileChannel 提供了三个方法用以获取 FileLock。
public abstract FileLock lock(long position, long size, boolean shared) throws IOException;
public final FileLock lock() throws IOException;
public abstract FileLock tryLock(long position, long size, boolean shared) throws IOException;
public final FileLock tryLock() throws IOException;
- lock() 是阻塞式的,它要阻塞进程直到锁可以获得,或调用
lock()
的线程中断,或调用lock()
的通道关闭。 - **tryLock()**是非阻塞式的,它设法获取锁,但如果不能获得,例如因为其他一些进程已经持有相同的锁,而且不共享时,它将直接从方法调用返回。
lock()
和tryLock()
方法有三个参数,如下:
- position:锁定文件中的开始位置
- size:锁定文件中的内容长度
- shared:是否使用共享锁。true为共享锁;false为独占锁。
共享锁和独占锁的区别,大明哥就不解释了。
示例
不使用文件锁来读写文件
首先我们不使用文件锁来进行多进程间文件读写,进程1往文件中写数据,进程2读取文件的大小。
- 进程1
RandomAccessFile randomAccessFile = new RandomAccessFile("/Users/chenssy/Downloads/filelock.txt","rw");
FileChannel fileChannel = randomAccessFile.getChannel();
// 这里是独占锁
//FileLock fileLock = fileChannel.lock();
System.out.println("进程 1 开始写内容:" + LocalTime.now());
for(int i = 1 ; i <= 10 ; i++) {
randomAccessFile.writeChars("chenssy_" + i);
// 等待两秒
TimeUnit.SECONDS.sleep(2);
}
System.out.println("进程 1 完成写内容:" + LocalTime.now());
// 完成后要释放掉锁
//fileLock.release();
fileChannel.close();
randomAccessFile.close();
- 进程2
RandomAccessFile randomAccessFile = new RandomAccessFile("/Users/chenssy/Downloads/filelock.txt","rw");
FileChannel fileChannel = randomAccessFile.getChannel();
// 这里是独占锁
//FileLock fileLock = fileChannel.lock();
System.out.println("开始读文件的时间:" + LocalTime.now());
for(int i = 0 ; i < 10 ; i++) {
// 这里直接读文件的大小
System.out.println("文件大小为:" + randomAccessFile.length());
// 这里等待 1 秒
TimeUnit.SECONDS.sleep(1);
}
System.out.println("结束读文件的时间:" + LocalTime.now());
// 完成后要释放掉锁
//fileLock.release();
fileChannel.close();
randomAccessFile.close();
运行结果
- 进程1
- 进程2
从这个结果可以非常清晰看到,进程1和进程2是同时执行的。进程1一边往文件中写,进程2是一边在读的
使用文件锁读写文件
这里我们使用文件锁来进行多进程间文件读写,依然使用上面的程序,只需要将对应的注释放开即可。执行结果
- 进程1
- 进程2
从这里可以看到,进程2是等进程1释放掉锁后才开始执行的。同时由于进程1已经将数据全部写入文件了,所以进程2读取文件的大小是一样的。从这里可以看出 ** FileLock确实是可以解决多进程访问同一个文件的并发安全问题。**
同进程不同线程进行文件读写
在开始就说到,FileLock是不适用同一进程不同线程之间文件的访问。因为你根本无法在一个进程中不同线程同时对一个文件进行加锁操作,如果线程1对文件进行了加锁操作,这时线程2也来进行加锁操作的话,则会直接抛出异常:java.nio.channels.OverlappingFileLockException
。
当然我们可以通过另外一种方式来规避,如下:
FileLock fileLock;
while (true){
try{
fileLock = fileChannel.tryLock();
break;
} catch (Exception e) {
System.out.println("其他线程已经获取该文件锁了,当前线程休眠 2 秒再获取");
TimeUnit.SECONDS.sleep(2);
}
}
将上面获取锁的部分用这段代码替换,执行结果又如下两种:
- 线程1先获取文件锁
- 线程2先获取文件锁
这种方式虽然也可以实现多线程访问同一个文件,但是不建议这样操作!!!
源码分析
下面我们以 FileLock lock(long position, long size, boolean shared)
为例简单分析下文件锁的源码。lock()
方法是由FileChannel的子类 FileChannelImpl来实现的。
public FileLock lock(long position, long size, boolean shared) throws IOException {
// 确认文件已经打开 , 即判断open标识位
ensureOpen();
if (shared && !readable)
throw new NonReadableChannelException();
if (!shared && !writable)
throw new NonWritableChannelException();
// 创建 FileLock 对象
FileLockImpl fli = new FileLockImpl(this, position, size, shared);
// 创建 FileLockTable 对象
FileLockTable flt = fileLockTable();
flt.add(fli);
boolean completed = false;
int ti = -1;
try {
// 标记开始IO操作 , 可能会导致阻塞
begin();
ti = threads.add();
if (!isOpen())
return null;
int n;
do {
// 开始锁住文件
n = nd.lock(fd, true, position, size, shared);
} while ((n == FileDispatcher.INTERRUPTED) && isOpen());
if (isOpen()) {
// 如果返回结果为RET_EX_LOCK的话
if (n == FileDispatcher.RET_EX_LOCK) {
assert shared;
FileLockImpl fli2 = new FileLockImpl(this, position, size,
false);
flt.replace(fli, fli2);
fli = fli2;
}
completed = true;
}
} finally {
// 释放锁
if (!completed)
flt.remove(fli);
threads.remove(ti);
try {
end(completed);
} catch (ClosedByInterruptException e) {
throw new FileLockInterruptionException();
}
}
return fli;
}
首先会判断文件是否已打开,然后创建FileLock和FileLockTable 对象,其中FileLockTable是用于存放 FileLock的table。
- 调用
begin()
设置中断触发
protected final void begin() {
if (interruptor == null) {
interruptor = new Interruptible() {
public void interrupt(Thread target) {
synchronized (closeLock) {
if (!open)
return;
open = false;
interrupted = target;
try {
AbstractInterruptibleChannel.this.implCloseChannel();
} catch (IOException x) { }
}
}};
}
blockedOn(interruptor);
Thread me = Thread.currentThread();
if (me.isInterrupted())
interruptor.interrupt(me);
}
- 调用
FileDispatcher.lock()
开始锁住文件
int lock(FileDescriptor fd, boolean blocking, long pos, long size,
boolean shared) throws IOException
{
BlockGuard.getThreadPolicy().onWriteToDisk();
return lock0(fd, blocking, pos, size, shared);
}
lock0()
的实现是在 FileDispatcherImpl.c 中,源码如下:
JNIEXPORT jint JNICALL
FileDispatcherImpl_lock0(JNIEnv *env, jobject this, jobject fdo,
jboolean block, jlong pos, jlong size,
jboolean shared)
{
// 通过fdval函数找到fd
jint fd = fdval(env, fdo);
jint lockResult = 0;
int cmd = 0;
// 创建flock对象
struct flock64 fl;
fl.l_whence = SEEK_SET;
// 从position位置开始
if (size == (jlong)java_lang_Long_MAX_VALUE) {
fl.l_len = (off64_t)0;
} else {
fl.l_len = (off64_t)size;
}
fl.l_start = (off64_t)pos;
// 如果是共享锁 , 则只读
if (shared == JNI_TRUE) {
fl.l_type = F_RDLCK;
} else {
// 否则可读写
fl.l_type = F_WRLCK;
}
// 设置锁参数
// F_SETLK : 给当前文件上锁(非阻塞)。
// F_SETLKW : 给当前文件上锁(阻塞,若当前文件正在被锁住,该函数一直阻塞)。
if (block == JNI_TRUE) {
cmd = F_SETLKW64;
} else {
cmd = F_SETLK64;
}
// 调用fcntl锁住文件
lockResult = fcntl(fd, cmd, &fl);
if (lockResult < 0) {
if ((cmd == F_SETLK64) && (errno == EAGAIN || errno == EACCES))
// 如果出现错误 , 返回错误码
return sun_nio_ch_FileDispatcherImpl_NO_LOCK;
if (errno == EINTR)
return sun_nio_ch_FileDispatcherImpl_INTERRUPTED;
JNU_ThrowIOExceptionWithLastError(env, "Lock failed");
}
return 0;
}
所以,其实文件锁的核心就是调用Linux的
fnctl
来从内核对文件进行加锁。
关于Linux 文件锁,大明哥推荐这两篇博客,小伙伴可以了解下:
- linux文件锁flock:https://www.cnblogs.com/kex1n/p/7100107.html
- Linux文件锁定:https://blog.csdn.net/zwz1984/article/details/44809017
Java 面试宝典是大明哥全力打造的 Java 精品面试题,它是一份靠谱、强大、详细、经典的 Java 后端面试宝典。它不仅仅只是一道道面试题,而是一套完整的 Java 知识体系,一套你 Java 知识点的扫盲贴。
它的内容包括:
- 大厂真题:Java 面试宝典里面的题目都是最近几年的高频的大厂面试真题。
- 原创内容:Java 面试宝典内容全部都是大明哥原创,内容全面且通俗易懂,回答部分可以直接作为面试回答内容。
- 持续更新:一次购买,永久有效。大明哥会持续更新 3+ 年,累计更新 1000+,宝典会不断迭代更新,保证最新、最全面。
- 覆盖全面:本宝典累计更新 1000+,从 Java 入门到 Java 架构的高频面试题,实现 360° 全覆盖。
- 不止面试:内容包含面试题解析、内容详解、知识扩展,它不仅仅只是一份面试题,更是一套完整的 Java 知识体系。
- 宝典详情:https://www.yuque.com/chenssy/sike-java/xvlo920axlp7sf4k
- 宝典总览:https://www.yuque.com/chenssy/sike-java/yogsehzntzgp4ly1
- 宝典进展:https://www.yuque.com/chenssy/sike-java/en9ned7loo47z5aw
目前 Java 面试宝典累计更新 400+ 道,总字数 42w+。大明哥还在持续更新中,下图是大明哥在 2024-12 月份的更新情况:
想了解详情的小伙伴,扫描下面二维码加大明哥微信【daming091】咨询
同时,大明哥也整理一套目前市面最常见的热点面试题。微信搜[大明哥聊 Java]或扫描下方二维码关注大明哥的原创公众号[大明哥聊 Java] ,回复【面试题】 即可免费领取。