1000 个线程同时写一个文件怎么设计
问题
面试官追问:如果有 1000 个线程需要同时写入同一个文件,你如何设计?这不是一个典型的八股题,但恰恰是考察并发 + IO 综合理解的高频追问。
核心思路:把"并发写"转为"有序写"
直接让 1000 个线程各自 FileOutputStream.write() 同一个文件,会带来两个问题:
- 文件锁竞争 — 操作系统在文件层有锁,大量线程抢锁会导致上下文切换暴涨
- 磁盘 IO 混乱 — 随机写入导致寻道时间(HDD)或写入放大(SSD),总吞吐反而下降
所以核心思路是把"1000 个线程同时写"变为"单线程或少量线程有序写"。下面从简单到深入,给出三层方案。
方案一:生产者-消费者模式
1000 个工作线程把数据写入一个线程安全的阻塞队列,一个单独的 IO 线程从队列中消费并写入文件。
public class ConcurrentFileWriter {
private final BlockingQueue<String> queue = new ArrayBlockingQueue<>(10000);
private final volatile boolean running = true;
public void startWriter(Path file) {
Thread writer = new Thread(() -> {
try (BufferedWriter bw = Files.newBufferedWriter(file,
StandardOpenOption.CREATE, StandardOpenOption.APPEND)) {
while (running) {
String line = queue.poll(1, TimeUnit.SECONDS);
if (line != null) {
bw.write(line);
bw.newLine();
bw.flush(); // 攒批 flush 可优化
}
}
} catch (IOException | InterruptedException e) {
Thread.currentThread().interrupt();
}
}, "file-writer");
writer.start();
}
public void write(String data) throws InterruptedException {
queue.put(data); // 阻塞直到入队成功
}
public void shutdown() {
running = false;
}
}优点:实现简单,解耦生产与消费。 缺点:单线程写文件,吞吐上限受限于磁盘带宽和队列大小。
方案二:每个线程写独立文件,最后合并
每个线程写自己的临时文件,所有线程完成后,主线程按顺序合并。
public class MergeFileWriter {
private static final int THREAD_COUNT = 1000;
private final Path[] tempFiles = new Path[THREAD_COUNT];
public void write(int threadId, String data) throws IOException {
Path temp = tempFiles[threadId];
if (temp == null) {
temp = Files.createTempFile("shard-" + threadId, ".log");
tempFiles[threadId] = temp;
}
Files.writeString(temp, data + System.lineSeparator(),
StandardOpenOption.APPEND);
}
public void merge(Path output) throws IOException {
try (var out = new FileOutputStream(output.toFile())) {
for (Path temp : tempFiles) {
Files.copy(temp, out);
Files.delete(temp);
}
}
}
}优点:零锁竞争,每个线程只写自己的文件,完全并行。 缺点:中间文件占用磁盘空间,合并阶段是串行瓶颈,大文件合并时 IO 压力大。
方案三:RandomAccessFile + 偏移预分配
预先分配好大文件空间,每个线程只负责写入自己固定的偏移区间,互不重叠。
public class OffsetAllocator {
private final long chunkSize;
private final AtomicLong nextOffset = new AtomicLong(0);
public OffsetAllocator(long chunkSize) {
this.chunkSize = chunkSize;
}
public long allocate() {
return nextOffset.getAndAdd(chunkSize);
}
}
// 使用
RandomAccessFile raf = new RandomAccessFile("output.log", "rw");
long offset = allocator.allocate();
raf.seek(offset);
raf.write(data.getBytes(StandardCharsets.UTF_8));优点:无锁写入,极端场景下吞吐最高。 缺点:需要预先知道或估算文件大小;写入顺序和原始顺序未必一致,需要额外元数据。
分析:面试官到底想考什么?
这道题表面在问"用什么锁",实际考的是对 IO 瓶颈的理解深度。
文件 IO 的瓶颈不在锁,在磁盘带宽。即使 1000 个线程并发写,磁盘的物理 IOPS 和写入带宽是硬上限。在 HDD 上,1000 个线程的随机写入会加剧寻道时间;在 SSD 上,并发写入会触发写入放大(Write Amplification),总吞吐反而比单线程低。
正确的优化方向:
- 攒批(Batching):攒够 4KB 或 64KB 再写,减少系统调用次数
- 顺序写入(Sequential Write):SSD 顺序写入可达 5–7 GB/s,随机写入只有 1/10
- 缓冲区对齐(Buffer Alignment):对齐到文件系统的块大小(通常 4KB)
P7/P8 深度:进阶方案
1. mmap(内存映射文件)
将文件映射到虚拟内存,多个线程在不同偏移位写入,底层由操作系统处理脏页回写。
FileChannel channel = FileChannel.open(path,
StandardOpenOption.READ, StandardOpenOption.WRITE,
StandardOpenOption.CREATE);
MappedByteBuffer buffer = channel.map(
FileChannel.MapMode.READ_WRITE, 0, fileSize);
// 每个线程写入自己的偏移
buffer.position(offset);
buffer.put(data);mv 减少了一次用户态到内核态的数据拷贝,性能远优于 RandomAccessFile。但需要预先分配文件大小,且不适合动态增长的文件。
2. 日志场景的专用方案:Disruptor
Log4j2 的异步 Appender 底层用 LMAX Disruptor,基于无锁环形缓冲区(RingBuffer),支持每秒百万级日志写入。核心设计:
- 预分配 RingBuffer 槽位,避免 GC
- CAS 替代锁,Cache-Friendly 数组结构
- 消费者生产者并行,通过序列号(Sequence)协调
3. 带版本号的 WAL 写入
如果写入需要保证顺序(如 Write-Ahead Log),每个线程分配单调递增的序列号,写入时按序列号排序,回放时恢复正确顺序:
record LogEntry(long sequence, byte[] data) {}
// 写入线程
long seq = sequenceGenerator.incrementAndGet();
queue.put(new LogEntry(seq, data));
// 消费线程按 sequence 排序后写入
// 使用 PriorityBlockingQueue 或 TreeMap 缓存乱序到达的条目总结
回答这道题的关键不是背一个固定方案,而是展示你对IO 模型、调度策略、硬件特性的综合理解:
- 先给出生产者-消费者方案作为 baseline
- 指出瓶颈在磁盘带宽而非锁
- 提到攒批写入、顺序 IO、缓冲区对齐等优化方向
- 如果面试官继续追问,抛出 mmap 和 Disruptor 方案,展示深度
最终使用的方案取决于业务场景:日志收集用 Disruptor,配置更新用 mmap,临时数据聚合用生产者-消费者。没有银弹,但理解底层的 IO 原理后,任何场景都能找到合适的方案。
参考:Linux man page mmap(2) · LMAX Disruptor 文档 · Log4j2 异步日志设计 · Brian Goetz《Java 并发编程实战》