聊聊线程安全的操作文件

文章讨论了一个多线程环境下操作文件导致的Bug,即线程A解压文件,线程B可能在解压未完成时删除文件夹。提出了四种解决方案:使用FileChannel的文件锁(但不适用于多线程)、使用synchronized关键字(不灵活)、对所有文件操作加锁(仍有并发问题)以及合并相同请求(优化资源使用)。最终选择了合并请求的方案,确保线程安全并减少资源浪费。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

目录

背景:

解决方案:

方案一:

方案二:

方案三:

方案四:

一点启发:


背景:

最近遇到一个Bug,根因是多个线程执行某方法,会同时操作同一文件。

具体来讲:A线程解压ZIP包至某文件夹,此时,B线程有几率删除该文件夹。(A线程在解压但未解压完成,B线程判断文件夹已存在,校验文件夹内容失败,后删除文件夹;A线程继续向该文件夹解压,会导致文件不完整)。BTW:历史上的坑太多啦。

解决方案:

最终采纳了方案四,合并相同请求。

方案一:

凡事不决,可问ChatGPT。它说:FileChannel可解。

思路:Java提供了FileChannel类可以对文件加锁,可以实现对文件的读写操作的互斥,以及对文件的独占访问。FileChannel类的lock()方法可以获取文件锁,unlock()方法可以释放文件锁。

public class FileLockExample {

    public static void main(String[] args) {
        String filePath = "example.txt";

        Thread thread1 = new Thread(() -> {
            writeToFile(filePath, "线程1写入的内容");
        });

        Thread thread2 = new Thread(() -> {
            writeToFile(filePath, "线程2写入的内容");
        });

        thread1.start();
        thread2.start();
    }

    private static void writeToFile(String filePath, String content) {
        try (RandomAccessFile file = new RandomAccessFile(filePath, "rw");
             FileChannel fileChannel = file.getChannel()) {

            System.out.println("尝试获取文件锁: " + content);

            // 获取文件锁
            try (FileLock lock = fileChannel.lock()) {
                System.out.println("获取到文件锁: " + content);

                // 模拟写入延迟
                Thread.sleep(1000);

                // 将文件指针移动到文件末尾
                file.seek(file.length());

                // 写入文件
                file.writeBytes(content + System.lineSeparator());

                System.out.println("写入完成: " + content);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }

        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

存在问题:运行后发现线程二会报错。原因是FileLock是进程级别的锁,不可用于多线程安全控制。ChatGpt不大靠谱呀。

查阅FileLock源码,发现源码中有自相矛盾的地方:
原文:File channels are safe for use by multiple concurrent threads. 
File locks are held on behalf of the entire Java virtual machine. They are not suitable for controlling access to a file by multiple threads within the same virtual machine.
译文:文件通道可供多个并发线程安全使用。文件锁代表整个 Java 虚拟机。它们不适合控制同一虚拟机内的多个线程对文件的访问。

方案二:

再问ChatGpt,它说:Synchronized锁可解。

思路:在Java中,可以通过使用synchronized关键字来实现线程安全的写文件。可以在多线程环境下安全地写入文件,使用一个静态的对象lock作为同步对象,并在写文件的方法中使用synchronized关键字将代码块包裹起来。这样一来,任何时候只有一个线程能够执行这段代码,从而保证了线程安全。

public class ThreadSafeFileWriter {
    private static final String FILENAME = "example.txt";
    private static final Object lock = new Object();

    public static void writeToFile(String text) {
        synchronized (lock) {
            try (BufferedWriter bw = new BufferedWriter(new FileWriter(FILENAME, true))) {
                bw.write(text);
                bw.newLine();
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    }
}

存在问题:要锁的方法内部包含切线程操作,场景不适用,大锁不灵活。

方案三:

自己动手,丰衣足食。对所有操作文件地方加锁。

思路:对所有操作文件地方加锁,建立ConcurrentHashMap一个全局锁,Key为文件路径,Value为可重入锁ReentrantLock。 


class FileWritingLock {
    companion object {
        private const val TAG = "FileWritingLock"
        private val lockMap = ConcurrentHashMap<String, ReentrantLock>()

        @JvmStatic
        private fun lock(filePath: String?) {
            if (filePath.isNullOrEmpty()) {
                return
            }
            lockMap.getOrPut(filePath, { ReentrantLock() }).lock()
        }

        @JvmStatic
        private fun unlock(filePath: String?) {
            if (filePath.isNullOrEmpty()) {
                return
            }
            lockMap[filePath]?.unlock()
        }
        
        @JvmStatic
        fun safeFileOperate(file: File?, function: (File?) -> Boolean): Boolean {
            Log.i(TAG, "SafeFileOperate start>>" + file?.absolutePath)
            return try {
                lock(file?.absolutePath)
                function.invoke(file)
            } catch (e: Exception) {
                Log.e(TAG, e)
                false
            } finally {
                unlock(file?.absolutePath)
                Log.i(TAG, "SafeFileOperate end>>")
            }
        }
    }
}
调用处(删除文件):
FileWritingLock.safeFileOperate(installDir, new Function1<File, Boolean>() {
    @Override
    public Boolean invoke(File file) {
        return IOUtils.delete(file);
    }
});

调用处(解压文件):
FileWritingLock.safeFileOperate(installDir, new Function1<File, Boolean>() {
    @Override
    public Boolean invoke(File file) {
        installZipPackage(pkgInstallListener, needInstallFile, installDir);
        return true;
    }
});

存在问题:虽杜绝了多线程同时操作文件夹的情况,但依然会有Bad Case。比如A线程解压文件,B线程已经执行到删除文件的地方,只等解压文件完毕;当解压完成时,删除了文件。线上问题是:解压和删除同时进行,污染文件;方案三的问题是:A线程解压完毕->B线程删除->B线程再解压,资源存在浪费。

方案四:

合并相同请求。

当十个线程以相同参数同时调用此方法时,结果预期是相同的。我们实际只调用第一次请求,记录了其余九次请求,待第一次请求结果返回后,同时给外面十个回调。 

class SafeOperate {
    companion object {
        private const val TAG = "SafeOperate"
        //建立<应用,回调List列表>的映射关系。
        private val pkgDownloadMap = ConcurrentHashMap<String, PkgDownloadInstallListenerWrapper>()

        @JvmStatic
        fun buildWrapper(identifierWithInfo: String, pkgInstallListener: IPkgDownloadInstallListener): Pair<PkgDownloadInstallListenerWrapper, Boolean> {
            val wrapper = pkgDownloadMap.getOrPut(identifierWithInfo) {
                PkgDownloadInstallListenerWrapper(identifierWithInfo, ArrayList<ListenerWithState>())
            }
            synchronized(wrapper) {
                wrapper.addTarget(ListenerWithState(pkgInstallListener, DownloadStateEnum.INIT))
                return Pair(wrapper, wrapper.getTargetsSize() == 1)
            }
        }
    }

    //PkgDownloadInstallListenerWrapper为回调管理器,目的是维护回调List。
    class PkgDownloadInstallListenerWrapper(private var identifierWithInfo: String, private val mTargets: MutableList<ListenerWithState>) : IPkgDownloadInstallListener {
        @Synchronized
        fun addTarget(target: ListenerWithState) {
            mTargets.add(target)
        }

        @Synchronized
        fun getTargetsSize(): Int {
            return mTargets.size
        }

        @Synchronized
        override fun onInstallPkgSuccess(requestType: Int, installPath: String, pkgVersion: String) {
            for (listener in mTargets) {
                //补偿机制,合并请求时,假设未经过onDownloadSuccess,直接到onInstallSuccess,需要补偿onDownloadSuccess回调。
                if (DownloadStateEnum.INIT == listener.state) {
                    listener.state = DownloadStateEnum.DOWNLOADED
                    listener.target.onDownloadSuccess(requestType, true)
                }
                listener.state = DownloadStateEnum.INSTALL
                listener.target.onInstallPkgSuccess(requestType, installPath, pkgVersion)
            }
            //被保护方法的流程结束点是onInstallPkgSuccess和onFailed回调。
            mTargets.clear()
        }

        @Synchronized
        override fun onDownloadSuccess(requestType: Int, fromLocal: Boolean) {
            for (listener in mTargets) {
                listener.state = DownloadStateEnum.DOWNLOADED
                listener.target.onDownloadSuccess(requestType, fromLocal)
            }
        }

        @Synchronized
        override fun onFailed(requestType: Int, code: String, reason: String) {
            for (listener in mTargets) {
                listener.target.onFailed(requestType, code, reason)
            }
            //被保护方法的流程结束点是onInstallPkgSuccess和onFailed回调。
            mTargets.clear()
        }
    }
}


enum class DownloadStateEnum(var stateType: String) {
    INIT("init"), DOWNLOADED("downloaded"), INSTALL("installed");
}

class ListenerWithState internal constructor(val target: IPkgDownloadInstallListener, var state: DownloadStateEnum)
public void downloadAndInstallPackage(String identifierWithInfo, @NonNull IPkgDownloadInstallListener pkgInstallListener) {
    Pair<SafeOperate.PkgDownloadInstallListenerWrapper, Boolean> wrapper = SafeOperate.buildWrapper(identifierWithInfo, pkgInstallListener);
    if (wrapper.component2()) {
        Log.i(TAG, "RealDownloadAndInstallPackage")
        realDownloadAndInstallPackage(identifierWithInfo, wrapper.component1());
    } else {
        Log.i(TAG, "No need to download")
    }
}

此外,针对已经被污染的文件,可以检验包内容,如果校验失败,则删除包。

一点启发:

  1. 追求极致,知易行难。追问题追仔细,追问题追本质。

  2. Bob大叔说:工程师们忽视了一个自然规律:无论是从短期还是长期来看,胡乱编写代码的工作速度其实比循规蹈矩更慢。研发团队最好的选择是清晰地认识并避开工程师们过度自信的特点,开始认真地对待自己的代码架构,对其质量负责。个人来看,在设计之初,这些代码根本没有考虑过线程安全问题,要想跑得快,先要跑得稳,一定要有设计。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

张云瀚

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值