我先说个结论:很多人以为日志记录器是个"工具类",write 一行字符串而已,加不加锁无所谓。实际上多线程环境下它是最容易翻车的基础组件之一。很多线上问题表面上看是业务逻辑 Bug,最后排查下来只是日志把现场搅浑了。
我维护的一个内部网关服务,压测时单看接口曲线完全正常,CPU 和磁盘 IO 都在合理范围,但日志文件里时不时出现两条日志缠在一起的"乱码行",甚至偶尔整条日志直接消失。折腾了两天才定位到日志写入这一步。这篇文章就把这个"简单"的线程安全日志记录器拆开揉碎,从并发问题根源、AtomicInteger 的使用边界、三种由简到繁的实现方案,到实测数据和排查经验,一次说清楚。
1. 先从一次线上事故说起:日志是被谁写乱的
1.1 事故现场的诡异现象
当时的现象很有代表性:日志里出现这样的内容:
code复制2024-11-20 14:33:22 INFO Request finished, cost=231m2024-11-20 14:33:22 ERROR Service timeout
s
两条日志被截断后拼在了一行,明显是"半行日志 + 半行日志"的缝合怪。更麻烦的是,单条日志偶尔会丢失,但不是每次都丢,丢的比例很低,大概千分之一。这种偶发性问题在测试环境几乎无法复现,因为本地日志量小、并发低,压测脚本也不容易抓到那个时间窗口。
1.2 根因:write() 并不保护"半行"数据
先说结论:FileOutputStream 和底层的磁盘写入接口在多线程下并不是"一个完整逻辑行"的原子操作。
很多人的第一版日志器长这样:
java复制public class NaiveLogger {
private final PrintWriter writer;
public void log(String message) {
String line = "[" + LocalTime.now() + "] " + message + "\n";
writer.write(line);
writer.flush();
}
}
这段代码的问题是:如果两个线程同时进入 log(),线程 A 写完了"[10:00:00] Request",线程 B 插进来写了"[10:00:01] Error",线程 A 再写" finished"。最终落到文件里就是 [10:00:00] Request[10:00:01] Error finished。
有些人会辩解:PrintWriter 内部不是有锁吗?确实,PrintWriter 和 PrintStream 内部有 synchronized 锁,但这个锁粒度是整个流的写入操作,它只能保证"每次调用 write() 方法时不会被其他线程同时写同一个流",不能保证"从拼接字符串到写完换行符"这个业务逻辑是原子的。你拼字符串、调 write()、调 flush() 是多个独立步骤,锁只覆盖了其中一部分。
1.3 为什么测试环境往往复现不出来
这个问题在低并发下几乎不会出现,原因有几点:
- 并发线程数少,两个线程同时到达
write()的碰撞窗口小。 - 本地环境用
System.out输出到控制台,控制台流本身有缓冲和行级刷新机制,掩盖了部分问题。 - JVM 把日志写入优化成批量或缓冲模式,在特定磁盘调度下撞不上那个撤换点。
等到线上几十个线程同时打日志,自然就暴露了。所以,"线程安全"不是给未来并发预留的奢侈品,而是日志组件的基本功。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 把"线程安全"掰开揉碎:AtomicInteger 到底安不安全
2.1 原子性、可见性、有序性,三者缺一不可
回到一个被反复问到的热词:atomicinteger线程安全吗。
先说结论:AtomicInteger 对"单个变量的单次操作"是线程安全的,但它只解决了原子性问题的一部分。线程安全有三个维度,AtomicInteger 只覆盖了第一个:
- 原子性:一个操作要么全部执行完,要么不执行,不能被其他线程打断。CAS 和锁都解决这个问题。
- 可见性:一个线程修改了变量值,另一个线程能不能立刻看到最新值。
volatile是解决这个问题的常见手段,AtomicInteger内部也用到了volatile。 - 有序性:编译器和 CPU 可能重排指令,在无同步机制时,重排后的执行顺序可能和你写代码时的顺序不一致。
拿生活类比,原子性相当于"你转给别人的钱要么完成转账、要么不转,不存在转了一半的状态";可见性相当于"你更新了群公告,别人必须能立即看到";有序性相当于"发布公告前必须先整理好内容,不能公告先发出去,内容后补"。
2.2 AtomicInteger 能保证什么,不能保证什么
AtomicInteger 适合做单变量的原子操作,例如:
java复制counter.incrementAndGet(); // 原子加1
oldValue = counter.getAndSet(5); // 原子赋值
boolean ok = counter.compareAndSet(10, 20); // CAS 比较再赋值
这些操作在并发下不会出现"两个线程同时加 1,结果只加了一次"的问题。
但它解决不了复合操作。比如下面这段代码:
java复制public void safeLog(String msg) {
if (atomicCount.get() < 100) {
atomicCount.incrementAndGet();
writeToFile(msg);
}
}
get() 和 `incrementAnd
