目录
背景:
最近遇到一个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")
}
}
此外,针对已经被污染的文件,可以检验包内容,如果校验失败,则删除包。
一点启发:
-
追求极致,知易行难。追问题追仔细,追问题追本质。
-
Bob大叔说:工程师们忽视了一个自然规律:无论是从短期还是长期来看,胡乱编写代码的工作速度其实比循规蹈矩更慢。研发团队最好的选择是清晰地认识并避开工程师们过度自信的特点,开始认真地对待自己的代码架构,对其质量负责。个人来看,在设计之初,这些代码根本没有考虑过线程安全问题,要想跑得快,先要跑得稳,一定要有设计。