技术派项目学习笔记(四)· 高标准:FastExcel 并发导出与线程池(源码级深度解析)
本篇是「高标准严要求」版本,基于 StatisticsSettingServiceImpl.java 逐行解析,不遗漏任何细节。适合面试前通读,确保被拷问时能对答如流。
一、先搞懂:为什么要做”并发导出”?
1.1 不用并发时的问题
假设你要导出 100 万条数据到 Excel:
1 2 3 4 5 6
| 不用并发(单线程): ① 查询 100 万条数据(查数据库,很慢) ② 用 FastExcel 写出到文件(也很慢) ③ 两步串行,总共耗时 = 查数据时间 + 写文件时间 实测:约 3.5 秒
|
问题:
- 数据库查询是 IO 密集型(等待数据库返回,CPU 空闲)
- 可以用多线程并发查询,充分利用 CPU 和数据库连接
1.2 用并发之后的效果
1 2 3 4 5 6 7
| 用并发(多线程): ① 把 100 万条数据分成 5 片,每片 20 万条 ② 5 个线程同时查询数据库(并发查) ③ 所有线程查完后,汇总数据 ④ 单线程 FastExcel 写出(POI 非线程安全) 实测:约 1.4 秒(提升 2.5 倍)
|
二、FastExcel 是什么?为什么不用 EasyExcel?
2.1 FastExcel 简介
FastExcel 是 EasyExcel 的现代化重构版(EasyExcel 已经停止维护了)。
| 对比 |
EasyExcel |
FastExcel |
| 维护状态 |
❌ 已停止维护(2023 年起) |
✅ 活跃维护 |
| 性能 |
基于 POI(较慢) |
重写,性能更好 |
| 内存占用 |
流式写,内存占用低 |
更低的内存占用 |
| API 风格 |
偏旧 |
现代化 API |
| 线程安全 |
❌ 非线程安全 |
❌ 非线程安全(底层仍是 POI) |
2.2 FastExcel 的核心优势:流式写
传统 POI 的问题(Apache POI):
1 2 3 4 5 6 7 8 9
| List<User> users = userDao.selectAll(); XSSFWorkbook workbook = new XSSFWorkbook(); XSSFSheet sheet = workbook.createSheet("用户数据"); for (User user : users) { XSSFRow row = sheet.createRow(rowNum++); row.createCell(0).setCellValue(user.getName()); }
|
FastExcel 的流式写:
1 2 3 4
| FastExcel.write(outputStream, User.class) .sheet("用户数据") .doWrite(users);
|
原理:FastExcel 底层用 SXSSFWorkbook(POI 的流式写实现):
- 数据先写到磁盘临时文件
- 内存里只保留最新的 100 行(可配置)
- 超过 100 行的数据刷到磁盘,释放内存
2.3 FastExcel 是非线程安全的!
关键知识点:FastExcel 底层依赖 SXSSFWorkbook,而 SXSSFWorkbook 不是线程安全的。
1 2 3 4 5 6 7 8
| ExecutorService pool = Executors.newFixedThreadPool(8); for (int i = 0; i < 8; i++) { pool.submit(() -> { FastExcel.write(outputStream, User.class).sheet("用户数据").doWrite(shardData); }); }
|
正确做法:
1
| 多线程并发【查询数据库】→ 汇总到一个 List → 单线程 FastExcel 写出
|
三、线程池:管理多线程的”管家”
3.1 为什么用线程池,而不是 new Thread()?
不用线程池的问题:
1 2 3 4 5 6
| public void export() { for (int i = 0; i < 100; i++) { new Thread(() -> queryDataFromDb()).start(); } }
|
问题:
- 创建线程开销大(要分配栈内存、内核资源)
- 线程数不受控制(如果有 1000 个人同时导出,创建 1000 个线程 → 系统崩溃)
- 线程不能复用(执行完就销毁,下次还要重新创建)
用线程池的好处:
- 复用线程(线程执行完任务后不销毁,继续接新任务)
- 限制线程数(最多同时运行 N 个线程,保护系统)
- 队列缓冲(任务多时,先放进队列排队)
3.2 线程池的核心参数(面试必考!)
看 StatisticsSettingServiceImpl.java 第 139-146 行(逐行解析):
1 2 3 4 5 6 7 8 9
| ThreadPoolExecutor executor = new ThreadPoolExecutor( CORE_POOL, MAX_POOL, 60L, TimeUnit.SECONDS, new ArrayBlockingQueue<>(shardCount + 1), r -> new Thread(r, "excel-export-" + r.hashCode()), new ThreadPoolExecutor.CallerRunsPolicy() );
|
这 6 个参数的含义(用银行办业务比喻):
1 2 3 4 5 6
| 银行(线程池)有 5 个窗口(核心线程数 = 5): ① 来的客户 < 5 个 → 开对应数量的窗口办理 ② 来的客户 > 5 个 → 客户去排队(进队列) ③ 队列满了(100 人排满了)→ 银行临时加开窗口(最大线程数 = 16) ④ 窗口全开了,队列也满了 → 新来的客户被拒绝(执行拒绝策略) ⑤ 客户办理完后,临时窗口空闲超过 60 秒 → 关闭临时窗口(回收线程)
|
3.3 核心线程数怎么设置?
经验公式:
1 2
| CPU 密集型(计算多,IO 少): 核心线程数 = CPU 核心数 + 1 IO 密集型(数据库查询、网络请求):核心线程数 = CPU 核心数 × 2
|
项目里的选择:
- 导出数据是 IO 密集型(主要时间在等数据库返回)
- 假设 CPU 是 4 核 → 核心线程数 = 4 × 2 = 8
- 源码第 126 行:
final int CORE_POOL = 8; // 核心线程数(与 CPU 核心数对齐)
3.4 为什么用 ArrayBlockingQueue 而不是 LinkedBlockingQueue?
| 对比 |
ArrayBlockingQueue |
LinkedBlockingQueue |
| 底层 |
数组(有界,必须指定容量) |
链表(无界,默认 Integer.MAX_VALUE) |
| 内存占用 |
固定(提前分配数组) |
不固定(来多少任务就占多少内存) |
| OOM 风险 |
✅ 低(有界,满了就拒绝新任务) |
❌ 高(无界,任务无限堆积 → OOM) |
项目选 ArrayBlockingQueue 的核心原因:
导出任务是重量级任务(大量内存),必须限制并发任务数,防止 OOM。LinkedBlockingQueue 相当于没有背压。
源码第 143 行:
1
| new ArrayBlockingQueue<>(shardCount + 1),
|
为什么容量是 shardCount + 1?
- 分片数是
shardCount(比如 5 个分片)
- 队列容量设为
shardCount + 1(比如 6)
- 这样最多可以提交
shardCount + 1 个任务到队列
- 超过这个数量,就会触发拒绝策略(
CallerRunsPolicy)
3.5 拒绝策略:CallerRunsPolicy 是什么?
线程池有 4 种拒绝策略:
| 拒绝策略 |
行为 |
适用场景 |
AbortPolicy(默认) |
抛异常(RejectedExecutionException) |
重要任务,不能丢 |
CallerRunsPolicy ✅ |
让提交任务的线程自己执行 |
不丢任务,且有背压效果 |
DiscardPolicy |
直接丢弃新任务,不抛异常 |
不重要任务(如日志记录) |
DiscardOldestPolicy |
丢弃队列里最老的任务 |
新任务比老任务重要 |
CallerRunsPolicy 的背压机制(非常重要!):
1 2 3 4 5 6 7 8
| 场景:线程池满了(8 个线程都在忙),队列也满了(100 个任务在排队) → 新任务被拒绝 → CallerRunsPolicy:让【提交任务的线程】自己执行这个任务 效果: - 提交任务的线程(比如 HTTP 处理线程)被占用 → 无法接收新请求 - 自然限速:新请求进不来,系统不会被压垮 - 等线程池有空闲了,HTTP 线程才能继续接收新请求
|
源码第 145 行:
1
| new ThreadPoolExecutor.CallerRunsPolicy()
|
四、CountDownLatch:等待所有线程完成任务`
4.1 不用 CountDownLatch 时的问题
1 2 3 4 5 6 7 8 9 10 11
| ExecutorService pool = Executors.newFixedThreadPool(8); for (int i = 0; i < 5; i++) { int shardIndex = i; pool.submit(() -> { List<RequestCountExcelDO> data = queryDataFromDb(shardIndex); allData.addAll(data); }); }
FastExcel.write(outputStream, RequestCountExcelDO.class).sheet("导出").doWrite(allData);
|
4.2 CountDownLatch 的原理
CountDownLatch(倒计时门栓)= 一个计数器,线程完成任务后就 -1,等计数器变成 0 时,等待的线程才继续执行。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| int shardCount = 5; CountDownLatch latch = new CountDownLatch(shardCount);
for (int i = 0; i < shardCount; i++) { int shardIndex = i; pool.submit(() -> { try { List<RequestCountExcelDO> data = queryDataFromDb(shardIndex); allData.addAll(data); } finally { latch.countDown(); } }); }
latch.await();
FastExcel.write(outputStream, RequestCountExcelDO.class).sheet("导出").doWrite(allData);
|
流程图:
1 2 3 4 5 6 7 8 9
| 主线程:创建 CountDownLatch(5) ↓ 线程1:查询分片 0 的数据 → latch.countDown() → 计数器 5→4 线程2:查询分片 1 的数据 → latch.countDown() → 计数器 4→3 线程3:查询分片 2 的数据 → latch.countDown() → 计数器 3→2 线程4:查询分片 3 的数据 → latch.countDown() → 计数器 2→1 线程5:查询分片 4 的数据 → latch.countDown() → 计数器 1→0 ↓ 计数器 = 0 → 主线程的 latch.await() 返回 → 继续执行
|
4.3 CountDownLatch vs CyclicBarrier
| 对比 |
CountDownLatch |
CyclicBarrier |
| 重置 |
❌ 不能重置(只能用一次) |
✅ 可以重置(可重复使用) |
| 等待方 |
一个或多个线程等待其他线程完成 |
所有线程互相等待,都到了再一起继续 |
| 适用场景 |
等待 N 个任务完成 |
多线程分阶段计算(阶段 1 完了才能开始阶段 2) |
项目里用 CountDownLatch 的原因:
只需要”等待所有分片查询完成”这一次,不需要重复使用。
4.4 源码里的 ArrayList 线程安全问题
看 StatisticsSettingServiceImpl.java 第 150-151 行:
1 2 3
| List<List<RequestCountExcelDO>> shardResults = new ArrayList<>(shardCount); for (int i = 0; i < shardCount; i++) shardResults.add(null);
|
问题:ArrayList 不是线程安全的!虽然有 shardResults.set(shardIndex, excelBatch)(每个线程写不同的 shardIndex 位置),但 ArrayList 的内部数组可能会扩容,导致线程不安全!
正确做法(源码里的写法其实有坑):
1 2 3 4
| List<List<RequestCountExcelDO>> shardResults = new ArrayList<>(shardCount); for (int i = 0; i < shardCount; i++) shardResults.add(null);
|
为什么有坑?
ArrayList 的内部数组在扩容时会复制数据,这时候如果有其他线程在 set(),可能会数组越界或数据覆盖!
- 虽然这里每个线程写不同的
shardIndex,但 ArrayList 不是为并发设计的!
正确做法 1:用 CopyOnWriteArrayList(推荐)
1 2 3 4
| List<List<RequestCountExcelDO>> shardResults = new CopyOnWriteArrayList<>(); for (int i = 0; i < shardCount; i++) shardResults.add(null);
|
正确做法 2:用 synchronized 保护 set() 操作
1 2 3 4
| synchronized (shardResults) { shardResults.set(shardIndex, excelBatch); }
|
正确做法 3:不用 List<List<?>>,改用数组(最快)
1 2 3 4
| @SuppressWarnings("unchecked") List<RequestCountExcelDO>[] shardResults = new List[shardCount];
|
五、项目里的完整实现(高标准逐行解析)
看 StatisticsSettingServiceImpl.java 第 122-214 行(逐行解析):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100
| @Override public void downloadRequestCount2ExcelConcurrent(HttpServletResponse response) { final int BATCH_SIZE = 100_000; final int CORE_POOL = 8; final int MAX_POOL = 16;
long startTime = System.currentTimeMillis();
long total = requestCountService.count(); int shardCount = (int) Math.ceil((double) total / BATCH_SIZE); log.info("[并发导出] 总记录数: {}, 分片数: {}, 每片大小: {}", total, shardCount, BATCH_SIZE);
ThreadPoolExecutor executor = new ThreadPoolExecutor( CORE_POOL, MAX_POOL, 60L, TimeUnit.SECONDS, new ArrayBlockingQueue<>(shardCount + 1), r -> new Thread(r, "excel-export-" + r.hashCode()), new ThreadPoolExecutor.CallerRunsPolicy() );
List<List<RequestCountExcelDO>> shardResults = new ArrayList<>(shardCount); for (int i = 0; i < shardCount; i++) shardResults.add(null);
CountDownLatch latch = new CountDownLatch(shardCount);
for (int i = 0; i < shardCount; i++) { final int shardIndex = i; executor.submit(() -> { try { PageParam pageParam = PageParam.newPageInstance( (long) (shardIndex + 1), (long) BATCH_SIZE ); List<RequestCountDO> batch = requestCountService.listRequestCount(pageParam); List<RequestCountExcelDO> excelBatch = StatisticsConverter.convertToRequestCountExcelDOList(batch); shardResults.set(shardIndex, excelBatch); log.info("[并发导出] 分片 {} 完成,本片记录数: {}", shardIndex, excelBatch.size()); } catch (Exception e) { log.error("[并发导出] 分片 {} 查询失败", shardIndex, e); shardResults.set(shardIndex, new ArrayList<>()); } finally { latch.countDown(); } }); }
try { boolean finished = latch.await(10, TimeUnit.MINUTES); if (!finished) { log.error("[并发导出] 等待超时,部分分片未完成"); } } catch (InterruptedException e) { Thread.currentThread().interrupt(); throw new RuntimeException("导出被中断", e); } finally { executor.shutdown(); }
List<RequestCountExcelDO> allData = new ArrayList<>((int) total); for (List<RequestCountExcelDO> shard : shardResults) { if (shard != null) allData.addAll(shard); }
long fetchCost = System.currentTimeMillis() - startTime; log.info("[并发导出] 多线程并发查询完成,汇总记录数: {}, 耗时: {}ms", allData.size(), fetchCost);
try { FastExcel.write(response.getOutputStream(), RequestCountExcelDO.class) .sheet("request_count全量导出") .doWrite(allData); } catch (IOException e) { throw new RuntimeException("Excel写出失败", e); }
long totalCost = System.currentTimeMillis() - startTime; log.info("[并发导出] 全流程完成,总耗时: {}ms(查询: {}ms,写Excel: {}ms)", totalCost, fetchCost, totalCost - fetchCost); }
|
六、面试高频追问(高标准版)
Q1:为什么数据查询用多线程,但 FastExcel 写出用单线程?
答:
- 数据查询是 IO 密集型(等数据库返回,CPU 空闲),可以用多线程并发查询,提升吞吐量
- FastExcel 写出底层依赖
SXSSFWorkbook,非线程安全(并发写同一个 Sheet 会导致数据错乱),所以必须用单线程写出
扩展:如果要进一步提升写出速度,可以:
- 按分片生成多个 Excel 文件(每个线程写一个文件)
- 最后用
POI 的 Sheet 合并 API 合并成一个文件
(但项目里没这么做,因为 1.4 秒已经够快了)
Q2:PageParam.newPageInstance() 的分页参数是怎么计算的?
答:PageParam.newPageInstance(pageNum, pageSize) 的内部实现是:
1 2
| offset = (pageNum - 1) * pageSize;
|
所以:
- 分片 0:
pageNum = 0 + 1 = 1 → offset = (1 - 1) * 100000 = 0
- 分片 1:
pageNum = 1 + 1 = 2 → offset = (2 - 1) * 100000 = 100000
- 分片 2:
pageNum = 2 + 1 = 3 → offset = (3 - 1) * 100000 = 200000
源码第 162-165 行:
1 2 3 4
| PageParam pageParam = PageParam.newPageInstance( (long) (shardIndex + 1), (long) BATCH_SIZE );
|
Q3:ArrayList 的线程安全问题怎么解决?
答:源码里的写法有坑:shardResults.set(shardIndex, excelBatch) 虽然每个线程写不同的 shardIndex,但 ArrayList 内部数组可能扩容,导致线程不安全!
正确做法:
1 2 3 4
| List<List<RequestCountExcelDO>> shardResults = new CopyOnWriteArrayList<>(); for (int i = 0; i < shardCount; i++) shardResults.add(null);
|
1 2 3 4
| @SuppressWarnings("unchecked") List<RequestCountExcelDO>[] shardResults = new List[shardCount];
|
Q4:ArrayBlockingQueue 的背压效果是什么?
答:ArrayBlockingQueue 是有界队列,容量固定。当队列满了,新任务会被拒绝,触发拒绝策略。
项目里的拒绝策略是 CallerRunsPolicy:
- 队列满时,提交任务的线程自己执行这个任务
- 效果:提交任务的线程(比如 HTTP 处理线程)被占用 → 无法接收新请求 → 自然限速
- 等线程池有空闲了,HTTP 线程才能继续接收新请求
这就是背压(Backpressure):消费者处理不过来时,生产者自动减速,防止系统崩溃!
Q5:CountDownLatch.await() 超时了怎么办?
答:看源码第 182-189 行:
1 2 3 4 5 6
| boolean finished = latch.await(10, TimeUnit.MINUTES); if (!finished) { log.error("[并发导出] 等待超时,部分分片未完成"); }
executor.shutdown();
|
处理方案:
- 记录日志:哪些分片没完成?
- 返回部分数据:已经完成的片区数据可以先用着
- 前端轮询:导出任务提交后,前端轮询导出进度(比如用 WebSocket 推送进度)
- 大文件异步导出:不用 HTTP 同步等待,改用消息队列异步导出,导出完了通知用户下载
七、总结:并发导出的完整流程(高标准版)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43
| ┌─────────────────────────────────────────────────┐ │ 用户点击"导出"按钮 │ └────────────────────────────┬────────────────────┘ │ ▼ ┌─────────────────────────────────────────────────┐ │ ① 查询总量,计算分片数 │ │ total = 100 万,shardSize = 20 万 → shardCount = 5 │ └────────────────────────────┬────────────────────┘ │ ▼ ┌─────────────────────────────────────────────────┐ │ ② 创建线程池(8 核心线程,ArrayBlockingQueue 有界队列, │ │ CallerRunsPolicy 背压策略) │ └────────────────────────────┬────────────────────┘ │ ▼ ┌─────────────────────────────────────────────────┐ │ ③ 提交 5 个分片任务到线程池 │ │ → 线程 1 查询分片 0(offset=0, limit=20万) │ │ → 线程 2 查询分片 1(offset=20万, limit=20万) │ │ → ... │ │ → CountDownLatch 计数器从 5 降到 0 │ └────────────────────────────┬────────────────────┘ │ ▼ ┌─────────────────────────────────────────────────┐ │ ④ latch.await() 返回(所有分片查询完成) │ │ → 汇总所有分片数据到一个 List │ └────────────────────────────┬────────────────────┘ │ ▼ ┌─────────────────────────────────────────────────┐ │ ⑤ 单线程 FastExcel 写出(POI 非线程安全) │ │ FastExcel.write(outputStream, RequestCountExcelDO.class) │ │ .sheet("request_count全量导出") │ │ .doWrite(allData); │ └────────────────────────────┬────────────────────┘ │ ▼ ┌─────────────────────────────────────────────────┐ │ ⑥ 返回 Excel 文件给前端 │ └─────────────────────────────────────────────────┘
|
八、本文贡献的”高标准”内容(对比之前版本)
| 内容 |
之前版本 |
本版本(高标准) |
ArrayList 线程安全问题 |
没提到 |
❌ 指出源码里用 ArrayList 有坑,给出 3 种修复方案 |
PageParam.newPageInstance() 分页计算 |
没详细讲 |
✅ 详细讲解 pageNum = shardIndex + 1 的原因 |
ArrayBlockingQueue 背压效果 |
只提了一句 |
✅ 详细讲解有界队列如何防止 OOM,队列满时的背压效果 |
CallerRunsPolicy 背压机制 |
没详细讲 |
✅ 详细讲解”提交任务的线程自己执行”如何实现背压 |
CountDownLatch.await() 超时处理 |
没提到 |
✅ 给出 4 种处理方案(记录日志、返回部分数据、前端轮询、异步导出) |
| 面试追问 |
4 道题 |
✅ 增加到 5 道题,每道都有详细答案和源码依据 |