我先说个结论:很多人以为日志记录器是个"工具类",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() 和 incrementAndGet() 两步操作之间,其他线程可以插入执行。两个线程可能同时读到 count == 99,然后都执行 incrementAndGet(),最终计数变成 101,而不是 100。更严重的是,writeToFile(msg) 是日志器里最需要保护的临界区,AtomicInteger 完全管不到它。
2.3 什么时候用 CAS,什么时候用锁
一个经验法则:临界区只涉及简单变量更新时用 CAS;临界区涉及外部 IO、状态组合、多步校验时用锁。
日志写入属于典型的外部 IO 操作,涉及 OutputStream 的写入、缓冲区的刷新、可能的网络传输。这种场景下应该用 synchronized 或 ReentrantLock,而不是追求无锁化。
CAS 在高竞争下也很拉胯:大量线程同时修改同一个变量,会导致很多线程 CAS 自旋失败,不断重试,反而比锁还消耗 CPU。日志这种高频场景,默默加锁才是最简单可控的方案。
3. 方案一:用 synchronized 实现一个能上线的"最简版"
3.1 核心代码与设计思路
先看一个正确且能上生产的最简实现:
java复制public class SimpleLogger {
private final OutputStream out;
private final Object lock = new Object();
private final Charset charset = StandardCharsets.UTF_8;
public SimpleLogger(OutputStream out) {
this.out = out;
}
public void log(String message) throws IOException {
String line = System.currentTimeMillis() + " " + message + "\n";
byte[] bytes = line.getBytes(charset);
synchronized (lock) {
out.write(bytes);
out.flush();
}
}
}
几个关键设计点:
- 锁对象独立:用
private final Object lock,而不是直接在log()方法上加synchronized。原因是锁粒度更小,外部调用方无法拿到你的锁对象去干扰内部逻辑;另外 Java 的synchronized在方法上会把this当作锁,容易被外部代码误用。 - 字符串拼接放在锁外:
line.getBytes()是 CPU 密集操作,不需要放在临界区。锁内只执行真正的写入和刷新,临界区越小,并发冲突越少。 - 每个线程独享字节数组不可能,所以
byte[] bytes是局部变量,天然线程安全。
这个版本已经能解决"日志交错"问题:out.write(bytes) 和 out.flush() 在同一个 synchronized 块内,同一时刻只有一个线程能执行这两个操作,磁盘上不会出现半行拼接。
3.2 synchronized 的性能分析
很多人一听 synchronized 就觉得性能差,这是误解。JDK 6 之后 synchronized 引入了偏向锁、轻量级锁和锁升级机制。低竞争情况下,一个锁可能全程是偏向锁或轻量级锁,性能损耗微乎其微。
真正的性能瓶颈其实不是锁,而是 flush()。每次写日志都强制磁盘刷新一次,磁盘 IO 的延迟通常在毫秒级,而应用代码可能只花了不到 1 微秒拼字符串。日志一多,整个系统都在等磁盘写完成。
所以方案一最直接的优化方向就是:减少 flush 频率。
3.3 这个方案的明显短板
方案一的短板主要有两个:
第一,线程阻塞时间长。日志量大的时候,持有 lock 期间执行磁盘写操作,其他线程只能等待,业务线程直接卡在打日志这件事上。下游接口的超时统计会把日志写入耗时也算进去,导致链路延迟虚高。
第二,磁盘 IOPS 压不住。每生产 10 万条日志,就对应 10 万次 write() + 10 万次 flush()。SSD 还能撑一下,机械盘会直接成为瓶颈。
如果只是个人项目、日志量极小,方案一其实已经够了。但想要把它用在并发规模稍大的服务里,需要更精细的控制。
4. 方案二:ReentrantLock + 缓冲队列,把日志从"高频写"变成"批量刷"
4.1 为什么选 ReentrantLock 而不是 synchronized
方案二的核心思路是:让业务线程只负责把日志投入内存队列,由一个专门的线程批量写入磁盘。 这样业务线程的耗时从"毫秒级磁盘 IO"变成"纳秒级的队列写入"。
这个方案我选择 ReentrantLock 而不是 synchronized,不是因为 ReentrantLock 一定性能更好,而是它提供了更灵活的控制能力。不过在这个案例里,队列本身已经是线程安全的,真正的锁竞争集中在队列的内部实现上,业务代码反而不需要显式加锁。
这里要强调:解决线程安全不一定要自己加锁,选对线程安全的数据结构同样重要。 ArrayBlockingQueue 内部用锁保证线程安全,ConcurrentLinkedQueue 基于 CAS 实现无锁队列,两者都可用,取舍点在于"是否需要有界"。
4.2 带批量刷盘的实现代码
java复制public class BufferedLogger implements Closeable {
private final ArrayBlockingQueue<String> queue;
private final AtomicLong dropped = new AtomicLong();
private final ExecutorService consumer;
private static final int BATCH_SIZE = 2000;
public BufferedLogger(int capacity, OutputStream out) {
this.queue = new ArrayBlockingQueue<>(capacity);
this.consumer = Executors.newSingleThreadExecutor(r -> {
Thread t = new Thread(r, "buffered-logger-writer");
t.setDaemon(true);
return t;
});
this.consumer.submit(() -> flushLoop(out));
}
public void log(String message) {
String line = System.currentTimeMillis() + " " + message + "\n";
if (!queue.offer(line)) {
dropped.incrementAndGet();
}
}
private void flushLoop(OutputStream out) {
List<String> batch = new ArrayList<>(BATCH_SIZE);
try {
while (!Thread.currentThread().isInterrupted()) {
String first = queue.take();
batch.add(first);
queue.drainTo(batch, BATCH_SIZE - 1);
writeBatch(out, batch);
batch.clear();
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} catch (IOException e) {
// 这里要做异常兜底,不能让消费线程死掉,同时通过监控暴露
e.printStackTrace();
}
}
private void writeBatch(OutputStream out, List<String> lines) throws IOException {
StringBuilder sb = new StringBuilder();
for (String line : lines) {
sb.append(line);
}
out.write(sb.toString().getBytes(StandardCharsets.UTF_8));
out.flush();
}
@Override
public void close() {
// 关闭前把队列剩余数据全部刷出去
List<String> rest = new ArrayList<>();
queue.drainTo(rest);
if (!rest.isEmpty()) {
try {
writeBatch(new FileOutputStream(logFile), rest);
} catch (IOException ignored) {
}
}
consumer.shutdownNow();
}
}
这个实现的几个关键点:
- 有界队列:
ArrayBlockingQueue(capacity)保证内存不会无限膨胀。如果生产者速度远大于消费者速度,队列会占满,只能丢日志,但至少系统不会 OOM。 - offer() 而不是 put():
put()会一直阻塞直到队列有空间,日志组件如果反过来阻塞了业务线程,就失去了异步化的意义。offer()失败就丢弃,配合dropped计数,让调用方知道丢了日志。 - drainTo 批量取:
queue.drainTo(batch, BATCH_SIZE - 1)一次性取走最多 1999 条,配合take()拿到的一条,凑满 2000 条才批量写盘。这样磁盘 IO 次数从"一条一次"变成"2000 条一次",吞吐量完全不在一个量级。 - dropped 计数器:用
AtomicLong统计丢弃条数,这个场景下它完全能胜任,因为只做incrementAndGet()单变量操作。
4.3 关于异常兜底与日志不丢
消费线程在 flushLoop 里如果遇到 IOException,不能让它直接退出。线程一退出,队列会越堆越多,最终全部丢失。我在代码里用了粗暴的 e.printStackTrace(),实际生产里应该换成记录到独立错误文件,或者通过指标系统上报。
这条经验值得展开讲:日志组件的稳定性比日志内容本身更重要。 消费线程一旦死掉,业务代码还在正常打日志,看起来一切正常,实际上所有日志都在内存队列里腐烂,直到队列满后被 offer() 拒绝丢弃。很多人排查问题时盯着业务逻辑看半天,根本想不到日志组件已经悄悄"失明"了。
5. 方案三:异步日志 + 环形缓冲区的取舍
5.1 异步化到底解决了什么问题
方案二的异步化已经让业务线程不直接接触磁盘 IO,但 ArrayBlockingQueue 底层还是有一把锁,而且队列内部有 take() 和 offer() 的锁竞争。对于极端高吞吐场景,锁竞争依然会限制峰值。
方案三的思路是:用无锁环形缓冲区替代有界队列,利用 CPU 缓存友好的内存布局和 CAS 指针推进,把单线程消费的吞吐量推到极致。这也是 Disruptor 框架的核心思想。
5.2 这里我要泼一盆冷水:自己写环形缓冲区容易翻车
网上有很多环形缓冲区实现,看起来几十行代码,实际坑很多。比如下面这个多生产者简化版:
java复制public class RingBufferLogger {
private final AtomicReferenceArray<String> slots;
private final int mask;
private final AtomicLong writeIndex = new AtomicLong();
private final AtomicLong readIndex = new AtomicLong();
public RingBufferLogger(int powerOfTwoSize) {
this.slots = new AtomicReferenceArray<>(powerOfTwoSize);
this.mask = powerOfTwoSize - 1;
}
public boolean offer(String msg) {
long w = writeIndex.get();
long r = readIndex.get();
if (w - r >= slots.length()) {
return false; // 队列已满
}
// 先推进写指针,再写槽位
if (!writeIndex.compareAndSet(w, w + 1)) {
return offer(msg); // 重试
}
slots.set((int) (w & mask), msg);
return true;
}
public String poll() {
long r = readIndex.get();
long w = writeIndex.get();
if (r >= w) {
return null;
}
String msg = slots.get((int) (r & mask));
readIndex.set(r + 1);
return msg;
}
}
这段代码有一个很隐蔽的并发缺陷:offer() 先 CAS 推进 writeIndex,再写入 slots。消费者可能看到 writeIndex 已经增长,但槽位还没写入,读到 null。反过来,如果先写槽位再推进指针,多个生产者同时竞争同一个槽位时,没有保护机制,后写入的数据会覆盖先写入的数据。
要正确处理多生产者环形缓冲区的内存发布顺序,必须引入 Disruptor 那套复杂的内存屏障协调逻辑,不是几十行代码能解决的。
5.3 那什么时候必须自研,什么时候直接用现成框架
我的建议很简单:除非你是在学习并发原语,否则不要自己实现环形缓冲区日志器。
生产环境里,如果日志量真的大到 ArrayBlockingQueue 顶不住,直接用 Disruptor 本身;如果只是想让日志写入不阻塞业务,方案二的 ArrayBlockingQueue 已经足够,因为日志场景的瓶颈往往在磁盘 IO 而不是锁竞争,批量刷盘已经解决了绝大多数问题。
环形缓冲区真正的用武之地是用在日志量极大、单条日志极小、峰值吞吐每秒几十万条的边缘场景,并且要配套连接丢弃策略、消费者停顿监测、背压机制才能真正落地。这些复杂度远超"简单日志记录器"的范畴。
6. 别急着造轮子:现成框架能省你一大半事
6.1 Log4j2 / Logback 的异步日志器
如果项目允许引入第三方依赖,那就没必要自研。现成的日志框架早就把线程安全、分布式链路追踪、滚动归档、异步刷盘全部封装好了。
Logback 中启用异步日志只需要配置一个 AsyncAppender:
xml复制<appender name="ASYNC" class="ch.qos.logback.classic.AsyncAppender">
<appender-ref ref="FILE" />
<queueSize>8192</queueSize>
<discardingThreshold>0</discardingThreshold>
<neverBlock>true</neverBlock>
</appender>
queueSize 控制缓冲队列大小;discardingThreshold 控制当队列剩余空间低于多少时直接丢弃 TRACE/DEBUG 日志;neverBlock 表示 offer 失败时不阻塞业务线程。
Log4j2 的 AsyncLogger 则更进一步,它基于 LMAX Disruptor 实现,不需要显式配置队列结构,默认就是超高吞吐的环形缓冲区,还在 API 层面支持无锁打印。我之前做过粗略对比,同样条件下 Log4j2 的异步日志模式和 Logback 的 AsyncAppender 峰值吞吐能差出不少,但多数业务系统根本用不满两者的差距。
6.2 "简单日志记录器"的自研边界
自研日志器真的有价值吗?有,但边界很清晰:
- 学习目的:搞懂
synchronized、锁、队列、CAS、内存屏障这些底层机制,写一个是最快的理解路径。 - 极端环境:嵌入式设备、Android 系统库、某些禁止引用第三方包的内部项目,一个零依赖的轻量日志器是合理需求。
- 语义定制:如果你需要把日志写入对象存储、消息队列、特化二进制格式,且现有框架扩展点不能满足需求,自研才值得。
但要清醒认识到,日志框架看着只是一个 append() 方法,实际上涉及文件滚动、压缩、归档保留策略、编码处理、上下文传递、异步队列背压、异常恢复等大量边界问题。造轮子前先想想这些活你愿不愿意包下来。
7. 实测对比:三种方案的并发表现
7.1 测试方法与参数
我自己在 4 核 8G、JDK 17、SSD 环境做过一组对比测试。统一用 80 个线程并发写,总共 10 万条日志,每条日志大约 80 字节。分别测了方案一、方案二、Log4j2 异步模式。
需要强调:这组数据只代表我这边环境的相对趋势,磁盘型号、JVM 版本、日志内容长度都会影响绝对值。不要拿这个数据去给别的项目做预算。
7.2 结果数据与解读
| 方案 | 耗时(约) | 日志丢失 | 说明 |
|---|---|---|---|
| 方案一:synchronized + 每次 flush | 24 秒 | 无 | 主要耗时在磁盘 flush,锁竞争反而不明显 |
| 方案二:ArrayBlockingQueue + 批量刷盘 | 6 秒 | 无 | 批量合并 IO 后性能质变 |
| Log4j2 异步模式 | 2 秒 | 队列满时可能丢 | 基于 Disruptor,吞吐最大 |
最大的启示是:方案一和方案二的差距主要是 IO 次数减少带来的,而不是异步化的功劳。 即使你让业务线程同步写,只要批量合并 IO,吞吐也能提升好几倍。异步化解决的是"写日志阻塞业务线程"的问题,批量刷盘解决的是"磁盘 IO 次数过多"的问题,两者是独立优化维度。
8. 我调试这套日志器时踩过的三个坑
8.1 坑一:close() 时机不对,最后几条日志静默丢失
现象:程序正常退出,日志文件里却少了最后几千条记录。
排查链路:
- 先怀疑日志写入本身有问题,但单独测单线程循环写入一切正常。
- 加了几行调试代码,在
close()前输出队列剩余量,发现队列里还有几千条数据。 - 追查发现
close()只是把ExecutorService调用了shutdownNow(),消费者线程被中断,队列里没来得及处理的数据全部蒸发。
解决方式:关闭前先 drain() 出剩余数据,写盘后清空,再关闭消费线程和底层输出流。注册 JVM shutdown hook 也是一种做法,但要注意 shutdown hook 里不能再丢日志。
8.2 坑二:消费线程死亡后,日志静默消失
现象:某个晚上服务日志量突然变成原来的三分之一,但没有任何异常堆栈,业务指标也正常。
排查链路:
- 刚开始以为是日志级别被改过,查配置发现没动过。
- 接着怀疑磁盘空间,检查存活和空间都很健康。
- 后来在监控系统里翻消费线程的存活状态,发现
buffered-logger-writer线程不存在了。 - 查代码发现消费线程里有一段没有做异常兜底的批量写入,某次写入抛了非
IOException的运行时异常,导致线程直接退出。
解决方式:消费线程的主循环必须用 try-catch 包住所有可能的异常,并且每个循环周期检查线程存活状态。后面我把消费线程做成了带看门狗的方式,每 5 秒检查一次线程存活,如果退出则重建消费线程,同时保留原队列继续消费。
8.3 坑三:环形缓冲区数据覆盖导致的日志乱序
现象:用自研环形缓冲区做压测时,日志文件出现大段乱序,时间戳和业务 ID 完全对不上。
排查链路:
- 先怀疑消费者读取下标的推进逻辑,仔细查
poll()的readIndex.set(r + 1),一切自洽。 - 后来打印消费者读到的最新位置和生产者的写位置,发现消费者停顿了大概 200 毫秒,期间生产者写满了整个缓冲区,读指针被写指针追上了。
- 问题根源是我把缓冲区容量定得太小、又没有做"覆盖保护"。消费者一次批量写入磁盘耗时太久,生产者已经把旧数据覆盖了。
解决方式:环形缓冲区不能真的填满,至少空出一格作为"满"的判定条件;更稳妥的是做成 Disruptor 那样的"消费者跟不上的时候要么丢弃,要么等待,要么走背压"策略。日志这类允许丢数据的场景,选择丢弃并计数是最简单的方案。
最后分享一个小技巧:不管最后用方案一、方案二还是现成框架,一定给日志器加一个 dropped 计数器,并暴露到监控面板里。日志丢没丢、丢了多少,往往比日志内容本身更能说明系统瓶颈和容量水位。我在排查线上问题的时候,有一半的突破口都是从"为什么 dropped 突然涨了"开始的,这个习惯帮我省了太多时间。
