首页
关于这个博客
Search
1
Java 实现Google 账号单点登录(OAuth 2.0)全流程解析
2,346 阅读
2
大模型“无脑复读”?3分钟搞懂原因+解决办法
967 阅读
3
Spring AI 无法获取大模型深度思考内容?解决方案来了
782 阅读
4
服务器遭遇 XMRig 挖矿程序入侵排查与清理全记录
419 阅读
5
EasyExcel 实战:导出带图片的 Excel 完整方案
419 阅读
Java 核心
框架与中间件
数据库技术
开发工具与效率
问题排查与踩坑记录
程序员成长与思考
前端
AI大模型&智能体
登录
Search
标签搜索
Spring AI
java虚拟机
JVM
Spring AI Alibaba
Java
保姆级教程
SpringBoot
Spring
MCP
WebFlux
龙虾
Agent Skills
大模型
OpenClaw
人工介入
Nginx
Ubuntu
Apache POI
自定义starter
Mybatis
Luca Ju
累计撰写
55
篇文章
累计收到
4
条评论
首页
栏目
Java 核心
框架与中间件
数据库技术
开发工具与效率
问题排查与踩坑记录
程序员成长与思考
前端
AI大模型&智能体
页面
关于这个博客
搜索到
22
篇与
的结果
2026-05-26
做共享目录实时同步,踩过这些坑
项目背景:我们有一个知识库平台,之前都是用户在平台上上传文档,然后执行解析向量数据库,用于后续执行RAG现在客户希望可以文档上传到共享目录,系统就可以自动把这些文件同步到知识库。问题:为什么不用轮询?最直白的方案是定时扫目录,对比前后两次的文件列表差异。这个方案写起来简单,但有几个硬伤:延迟高:扫描周期设 5 分钟,用户上传后平均要等 2.5 分钟才能感知到IO 浪费:目录下文件多的时候,每次全量扫一遍磁盘开销不小无法感知"修改":如果只是文件内容变了但大小没变,很难准确判断所以决定用 java.nio.file.WatchService 做事件驱动监听。WatchService 的基本用法WatchService 本质上是对操作系统底层文件事件通知的封装。Linux 下用的是 inotify,Windows 下是 ReadDirectoryChangesW。注册一个目录的监听很简单:WatchService watchService = FileSystems.getDefault().newWatchService(); Path dir = Paths.get("/share"); WatchKey key = dir.register(watchService, ENTRY_CREATE, ENTRY_MODIFY, ENTRY_DELETE);然后在一个独立线程里 take() 阻塞等待事件:WatchKey key = watchService.take(); for (WatchEvent<?> event : key.pollEvents()) { // 处理事件 } key.reset(); // 必须 reset,否则后续事件收不到坑一:WatchService 不会递归监听子目录这是最容易被忽略的一点。dir.register(...) 只监听这一层目录,子目录下的文件变更完全收不到。我们的共享目录结构是 /share/知识库名/文件,层级不固定,用户还可能随时新建子目录。所以必须自己实现递归注册。方案是在启动时把整个目录树 walk 一遍,每个子目录都注册上:Files.walkFileTree(root, EnumSet.of(FileVisitOption.FOLLOW_LINKS), Integer.MAX_VALUE, new SimpleFileVisitor<>() { @Override public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs) throws IOException { register(dir); // 每个目录都注册监听 return FileVisitResult.CONTINUE; } });同时监听 ENTRY_CREATE 事件,当发现新建的是目录时,立刻给它补上注册:if (kind == ENTRY_CREATE && Files.isDirectory(child)) { registerAll(child); }坑二:目录创建时,里面的文件可能"漏事件"这是个很隐蔽的竞态条件。假设用户通过 cp -r 拷贝一整个目录进来,操作系统产生的事件顺序可能是:ENTRY_CREATE —— 新目录 /share/A/BENTRY_CREATE —— 目录下的文件 file1.txtENTRY_CREATE —— 目录下的文件 file2.txt但如果我们的处理逻辑在第 1 步给 /share/A/B 注册监听时稍微慢了一点,第 2、3 步的事件可能已经被操作系统发出,而我们还没注册上,于是漏掉了。解决办法是在注册新目录后,主动扫一遍目录里已有的文件,补发创建事件:if (Files.isDirectory(child)) { registerAll(child); emitCreateForExistingFiles(child); // 兜底补发 }private void emitCreateForExistingFiles(Path dir) { try (Stream<Path> pathStream = Files.walk(dir)) { pathStream.filter(Files::isRegularFile).forEach(eventHandler::onCreate); } }坑三:事件防抖文件写入往往不是原子操作。比如用户拷贝一个大文件,或者用户频繁修改文件,操作系统可能产生一连串的 MODIFY 事件。如果每个事件都触发一次上传,既浪费资源又容易出错。我们在事件处理器里加了基于时间窗口的去抖:String debounceKey = eventType + "|" + fullPath; long now = System.currentTimeMillis(); Long last = debounceMap.get(debounceKey); if (last != null && now - last < windowMillis) { return; // 同一个文件同类型事件,5 秒内只处理一次 } debounceMap.put(debounceKey, now);这里用 ConcurrentHashMap 保证线程安全,key 的设计是"事件类型 + 文件路径",这样创建和修改事件互不干扰。坑四:应用重启期间的事件丢失WatchService 是内存态的,应用一重启,期间发生的所有文件变更都感知不到了。这对于生产环境是不可接受的。我们的补偿方案是快照比对:每次补偿扫描时,把当前目录下所有文件的路径、大小、最后修改时间文件hash记录下来,存到 db下次扫描时加载上次的快照,和当前状态做 diff新出现的文件 → 补发 CREATE大小或修改时间变化的 → 补发 MODIFY快照里有但现在没了的 → 补发 DELETE快照存 MySQL 而不是本地文件,是为了在集群部署时也能共享状态(虽然当前是单实例,但预留了扩展空间)。补偿扫描有两个触发时机:启动时立即执行一次:覆盖重启期间的漏事件定时执行:默认 5 分钟一次,作为兜底@Scheduled(fixedDelayString = "${doc.share-sync.reconcile-delay-ms:300000}") public void scheduledReconcile() { reconcile("scheduled"); }整体架构整个同步模块拆成了四个组件,职责很清晰:组件职责ShareDirWatcher基于 WatchService 的实时监听,递归注册子目录ShareFileEventHandler事件去抖、路径解析(从文件路径反查知识库ID)、文件处理ShareDirReconcileJob停机补偿扫描,快照比对补发事件ShareFileSnapshotStore快照的持久化存储(MySQL)配置也做了开关控制,方便分阶段上线:doc: share-sync: enabled: true dry-run: true # 第一阶段只打日志,不真写 root-dir: /share settle-seconds: 5 # 防抖窗口 reconcile-delay-ms: 300000一些没写的细节OVERFLOW 事件:WatchService 可能因事件队列满而丢失事件,此时会收到一个 OVERFLOW 事件。我们的处理是直接跳过,依赖定时补偿扫描来修复。删除事件的路径:ENTRY_DELETE 事件发生时,文件已经没了,ev.context() 只能拿到文件名,不能判断它是不是目录。所以删除事件统一走 onDelete,由下游根据数据库记录判断之前是什么。重复上传风险: 监听程序和定时补偿程序同时发现新创建了文件,会出现重复上传的问题。处理方案是添加数据库唯一索引,直接从数据库兜底总结WatchService 来做文件监听是我目前实现的方案,递归注册、新目录兜底扫描、事件去抖、停机补偿,这几块缺一不可。第一阶段先跑 dry-run,把日志打全、路径解析逻辑验证清楚,第二阶段再放开真实写入,这样上线心里比较有底。
2026年05月26日
11 阅读
0 评论
1 点赞
2026-05-15
一次 OOM 线上排查实录
大家好,今天分享一次真实的线上 OOM 排查过程,踩坑 Druid 连接池的经典内存泄漏问题,以及完整的解决思路。一、问题现场:线上内存飙高,OOM 报警某天,线上老项目突然收到服务器内存使用率持续飙高的报警,紧接着应用直接抛出 OOM 错误,服务崩溃。紧急拉取了堆 Dump 文件,用 JProfiler 打开后,直接看到了内存占用的元凶:大量 com.alibaba.druid.proxy.jdbc 相关对象堆积堆中最大的单个对象是一个 char[],大小超过 500MB,存储的正是项目中执行的 SQL 字符串结合项目业务场景,初步判断是数据库操作相关的内存泄漏,定位方向直接锁定了代码中的 SQL 操作和 Druid 连接池配置。二、根因定位:双重问题叠加导致的灾难顺着堆 Dump 里的 SQL 文本,我直接定位到了业务代码,发现这次 OOM 是两个问题叠加导致的。1. 业务代码:SQL 拼接逻辑导致大对象堆积这是一个老项目,当年的开发同学已经离职了,代码里存在这样的逻辑:单条 INSERT 语句中,通过循环拼接 SQL 字符串,一次性插入大量数据当数据量较大时,拼接后的 SQL 字符串会变得非常大,生成的 char[] 对象直接占用几百 MB 内存这些大字符串被线程栈引用,短时间内无法被 GC 回收,直接推高了内存水位2. 框架层面:Druid 1.1.22 版本的经典 SQL 缓存泄漏堆 Dump 中大量的 Druid 对象,指向了一个更致命的问题:Druid 连接池的 SQL 统计缓存。项目使用的 Druid 版本是 1.1.22,这个版本存在一个广为人知的问题:SQL 统计功能会无限制缓存所有执行过的 SQL 字符串,无法自动清理项目中拼接的大量不同 SQL,会被 Druid 全部缓存到 sqlStatMap 中,这些对象会一直持有 SQL 字符串的引用,导致它们无法被 GC 回收随着服务运行时间增长,缓存的 SQL 越来越多,内存只会涨不会跌,最终撑满堆内存,触发 OOM三、解决方案:两步走彻底根治问题针对这两个问题,我们采用了业务+框架双管齐下的修复方案,从根源解决内存泄漏。第一步:优化 Druid 配置,掐断缓存泄漏直接修改项目的 Druid 配置,关闭无限制的 SQL 统计,同时限制缓存大小,避免内存无限增长。方案 A:彻底关闭 SQL 统计(推荐,零泄漏风险)spring: datasource: druid: filter: stat: enabled: false # 关闭导致内存泄漏的SQL统计 web-stat-filter: enabled: false # 关闭Web统计,减少额外内存占用方案 B:保留监控,限制缓存大小(折中方案)如果业务必须保留 SQL 监控,可以通过配置限制缓存的 SQL 数量,避免无限增长:spring: datasource: druid: filter: stat: enabled: true max-stat-count: 200 # 限制最多缓存200条SQL,超出自动淘汰第二步:重构业务代码,替换 SQL 拼接为批量插入修改原有的 SQL 拼接逻辑,改为标准的批量插入方式,既避免了超大 SQL 字符串的生成,也提升了数据库写入性能。改造前(问题代码)// 循环拼接SQL,生成超大字符串 StringBuilder sql = new StringBuilder("INSERT INTO t_invoice (col1, col2) VALUES "); for (Invoice invoice : list) { sql.append("(?, ?),"); } jdbcTemplate.update(sql.toString(), params);改造后(批量插入)// 使用JdbcTemplate批量插入,避免生成超大SQL字符串 String sql = "INSERT INTO t_invoice (col1, col2) VALUES (?, ?)"; jdbcTemplate.batchUpdate(sql, new BatchPreparedStatementSetter() { @Override public void setValues(PreparedStatement ps, int i) throws SQLException { ps.setString(1, list.get(i).getCol1()); ps.setString(2, list.get(i).getCol2()); } @Override public int getBatchSize() { return list.size(); } });四、效果验证与后续优化改造完成后,我们重新上线服务并进行了压测验证:内存曲线恢复平稳,不再出现持续飙高的情况堆 Dump 中 Druid 相关对象和大 char[] 基本消失数据库写入性能也有明显提升,单批次插入耗时降低了 40%额外优化建议对于老项目,建议升级 Druid 到最新稳定版(如 1.2.20+),修复了大量已知的内存泄漏问题批量插入时,建议设置合理的批次大小(如每批 100-500 条),避免单次操作过大导致数据库压力上线前务必进行压测,通过 JProfiler 或 Arthas 观察内存变化,提前发现潜在问题五、踩坑总结这次 OOM 排查给了我两个深刻的教训:老项目的依赖版本一定要关注:Druid 1.1.22 这个版本的 SQL 缓存泄漏问题非常普遍,很多线上 OOM 都源于此,升级或关闭统计是最直接的解决方式。业务代码的 SQL 拼接是隐形杀手:不仅容易导致 SQL 注入,还会生成超大对象,配合框架的缓存机制,很容易引发内存泄漏。批量插入是更安全、更高效的替代方案。希望这次分享能帮到遇到同样问题的朋友,如果你也遇到了 Druid 相关的内存问题,欢迎在评论区交流讨论~
2026年05月15日
12 阅读
0 评论
2 点赞
2026-02-06
Java + EasyExcel 实现单个接口导出多个Excel
在日常开发中,我们经常会遇到 Excel 导出的需求,大多是单个接口导出单个 Excel 文件。但偶尔也会有特殊场景——需要一个接口同时导出两个(或多个)独立的 Excel 文件,比如同时导出“用户列表”和“订单列表”,方便用户一次性获取完整数据。今天就基于 Spring Boot + EasyExcel(目前主流稳定版本),分享一种简单、通用、可直接落地的实现方案,全程附完整代码,新手也能快速上手~一、核心问题与解决方案首先要明确一个关键前提:HTTP 协议单次响应只能返回一个字节流,无法直接返回两个独立的 Excel 文件(相当于一次请求只能下载一个文件)。那怎么实现“一个接口导出多个 Excel”?答案很简单——将多个 Excel 文件打包成 ZIP 压缩包,接口返回 ZIP 流,用户下载后解压,就能得到多个 Excel 文件。这是最通用、最合规的解决方案,既不违背 HTTP 协议,也能满足用户一次性获取多份文件的需求。二、前置准备:引入依赖首先在项目中引入 EasyExcel 核心依赖、ZIP 压缩依赖(用于打包多文件),以及 Spring Web 依赖(接口开发必备)。以下是 Maven 配置,Gradle 可对应转换,版本可根据自己项目需求微调(建议保持和示例一致,避免兼容问题)。<!-- EasyExcel 核心依赖 --> <dependency> <groupId>com.alibaba</groupId> <artifactId>easyexcel</artifactId> <version>4.0.3</version> </dependency> <!-- ZIP 压缩依赖(处理多文件打包) --> <dependency> <groupId>org.apache.commons</groupId> <artifactId>commons-compress</artifactId> <version>1.27.1</version> </dependency> <!-- Spring Web 依赖(已有则忽略,接口开发必备) --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency>三、步骤1:定义Excel对应的实体类假设我们需要导出两个 Excel 文件:用户列表 和 订单列表,分别创建对应的实体类,通过 EasyExcel 的 @ExcelProperty 注解指定 Excel 表头名称(注解参数就是最终 Excel 中显示的表头)。实体类用 Lombok 的 @Data 注解简化 getter/setter 代码,无需手动编写,节省开发时间。3.1 用户实体类(UserData.java)import com.alibaba.excel.annotation.ExcelProperty; import lombok.Data; /** * 用户列表 Excel 对应的实体类 */ @Data public class UserData { // Excel 表头:用户ID @ExcelProperty("用户ID") private Long userId; // Excel 表头:用户名称 @ExcelProperty("用户名称") private String userName; // Excel 表头:手机号 @ExcelProperty("手机号") private String phone; }3.2 订单实体类(OrderData.java)import com.alibaba.excel.annotation.ExcelProperty; import lombok.Data; /** * 订单列表 Excel 对应的实体类 */ @Data public class OrderData { // Excel 表头:订单ID @ExcelProperty("订单ID") private String orderId; // Excel 表头:用户ID(关联用户表) @ExcelProperty("用户ID") private Long userId; // Excel 表头:订单金额 @ExcelProperty("订单金额") private Double amount; // Excel 表头:创建时间 @ExcelProperty("创建时间") private String createTime; }四、步骤2:封装通用工具类(核心)为了避免接口层代码冗余,我们封装一个工具类 ExcelZipExportUtil,专门处理“将多个 Excel 写入 ZIP 流”和“初始化 HTTP 响应头”的逻辑。这个工具类是通用的,后续不管导出多少个 Excel,都能直接复用,无需重复编码。import com.alibaba.excel.EasyExcel; import org.apache.commons.compress.archivers.zip.ZipArchiveEntry; import org.apache.commons.compress.archivers.zip.ZipArchiveOutputStream; import javax.servlet.ServletOutputStream; import javax.servlet.http.HttpServletResponse; import java.io.ByteArrayOutputStream; import java.net.URLEncoder; import java.util.List; /** * EasyExcel 多文件导出 ZIP 工具类(通用可复用) */ public class ExcelZipExportUtil { /** * 将单个 Excel 文件写入 ZIP 输出流 * @param zipOut ZIP 输出流 * @param excelFileName 单个 Excel 的文件名(如:用户列表.xlsx) * @param data Excel 中的数据列表 * @param clazz Excel 对应的实体类(用于解析表头) */ public static <T> void writeExcelToZip(ZipArchiveOutputStream zipOut, String excelFileName, List<T> data, Class<T> clazz) throws Exception { // 1. 临时存储 Excel 内容(内存级,不写入磁盘,性能更高) ByteArrayOutputStream bos = new ByteArrayOutputStream(); // 2. 使用 EasyExcel 写入数据(sheet1 是工作表名称,可自定义) EasyExcel.write(bos, clazz) // 不要自动关闭,交给 Servlet 自己处理 .autoCloseStream(false) // 基于 column 长度,自动适配。最大 255 宽度 .registerWriteHandler(new LongestMatchColumnWidthStyleStrategy()) // 避免 Long 类型丢失精度 .registerConverter(new LongStringConverter()) // 工作表名称 .sheet("sheet1") .doWrite(data); // 3. 将 Excel 作为 ZIP 的一个条目写入 zipOut.putArchiveEntry(new ZipArchiveEntry(excelFileName)); zipOut.write(bos.toByteArray()); zipOut.closeArchiveEntry(); // 关闭当前 ZIP 条目(必须,否则后续条目无法写入) // 4. 关闭临时流 bos.close(); } /** * 初始化 HTTP 响应头(设置 ZIP 下载、解决中文文件名乱码) * @param response 响应对象 * @param zipFileName 最终下载的 ZIP 压缩包名称(如:用户订单数据.zip) */ public static void initZipResponse(HttpServletResponse response, String zipFileName) throws Exception { // 设置响应类型为 ZIP response.setContentType("application/zip"); // 设置下载头,URLEncoder.encode 解决中文文件名乱码(兼容所有浏览器) response.setHeader("Content-Disposition", "attachment;filename=" + URLEncoder.encode(zipFileName, "UTF-8")); // 禁止缓存(避免浏览器缓存旧文件) response.setHeader("Pragma", "no-cache"); response.setHeader("Cache-Control", "no-cache"); } }五、步骤3:接口层实现(最终落地)创建 Controller 接口,模拟构造两个 Excel 的测试数据(实际项目中,这里可以替换成从数据库查询数据),然后调用上面封装的工具类,将两个 Excel 打包成 ZIP 流,通过 HttpServletResponse 返回,实现“一次请求下载两个 Excel”。import org.apache.commons.compress.archivers.zip.ZipArchiveOutputStream; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; import javax.servlet.http.HttpServletResponse; import java.util.ArrayList; import java.util.List; /** * Excel 导出接口控制器 */ @RestController @RequestMapping("/export") public class ExcelExportController { /** * 单个接口导出两个 Excel 文件(打包成 ZIP 下载) * 访问地址:http://localhost:8080/export/twoExcel */ @GetMapping("/twoExcel") public void exportTwoExcel(HttpServletResponse response) { try { // 1. 初始化响应头,设置 ZIP 压缩包名称(用户下载时显示的文件名) ExcelZipExportUtil.initZipResponse(response, "用户订单数据.zip"); // 2. 获取 HTTP 响应输出流,关联 ZIP 输出流 ServletOutputStream servletOut = response.getOutputStream(); ZipArchiveOutputStream zipOut = new ZipArchiveOutputStream(servletOut); // 3. 构造第一个 Excel 的数据(用户列表,实际项目中替换为数据库查询) List<UserData> userList = new ArrayList<>(); // 3.1 完善数据逻辑省略 // 4. 构造第二个 Excel 的数据(订单列表,实际项目中替换为数据库查询) List<OrderData> orderList = new ArrayList<>(); // 4.1 完善数据逻辑省略 // 5. 关键操作:将两个 Excel 分别写入 ZIP 流 ExcelZipExportUtil.writeExcelToZip(zipOut, "用户列表.xlsx", userList, UserData.class); ExcelZipExportUtil.writeExcelToZip(zipOut, "订单列表.xlsx", orderList, OrderData.class); // 6. 关闭流(顺序不能错!否则 ZIP 包会损坏,无法解压) zipOut.finish(); // 完成 ZIP 写入 zipOut.close(); servletOut.flush(); servletOut.close(); } catch (Exception e) { e.printStackTrace(); // 实际项目中建议自定义异常处理,给前端返回明确的错误提示 response.setStatus(500); } } }六、关键注意事项(避坑重点)这部分一定要仔细看!很多新手实现后,出现 ZIP 包损坏、中文乱码、数据缺失等问题,大多是因为忽略了这些细节。流的关闭顺序:必须先执行 zipOut.closeArchiveEntry()(关闭当前 ZIP 条目),再执行 zipOut.finish(),最后关闭 ZIP 流和响应流。顺序颠倒会导致 ZIP 包损坏,无法解压。中文文件名乱码:通过 URLEncoder.encode(zipFileName, "UTF-8") 对 ZIP 文件名和 Excel 文件名编码,兼容 Chrome、Firefox、Edge 等所有主流浏览器,避免中文乱码。Excel 写入方式:示例中使用 ByteArrayOutputStream 临时存储 Excel 内容,属于内存级写入,不占用磁盘空间,性能更高。不建议直接写入磁盘文件再打包,会增加磁盘 IO 开销。异常处理:实际项目中,建议替换 try-catch 中的 printStackTrace(),使用全局异常处理器,给前端返回明确的错误信息(如“导出失败,请重试”),提升用户体验。七、扩展:导出更多Excel文件如果需要导出 3 个、4 个甚至更多 Excel 文件,无需修改工具类,只需在接口中继续调用 ExcelZipExportUtil.writeExcelToZip() 方法即可。示例(新增“商品列表”Excel):// 新增商品列表数据(假设已有 GoodsData 实体类) List<GoodsData> goodsList = new ArrayList<>(); // ... 构造商品数据 // 新增一个 Excel 写入 ZIP 流 ExcelZipExportUtil.writeExcelToZip(zipOut, "商品列表.xlsx", goodsList, GoodsData.class);八、测试效果验证代码写完后,启动 Spring Boot 项目,通过以下步骤测试,确保导出正常:访问接口地址:http://localhost:8080/export/twoExcel(端口号根据自己项目配置调整);浏览器会自动弹出下载提示,下载的文件名为“用户订单数据.zip”;解压 ZIP 压缩包,会得到两个 Excel 文件:用户列表.xlsx 和 订单列表.xlsx;打开 Excel 文件,检查表头和数据是否正常(和接口中构造的测试数据一致)。九、总结代码通用可复用,总结下来就是 3 步:引入 EasyExcel 和 ZIP 依赖,做好前置准备;定义 Excel 对应的实体类,封装通用工具类(处理 ZIP 打包和响应头);在接口中构造数据,调用工具类将多个 Excel 写入 ZIP 流,返回给前端。该方案适用于所有 Spring Boot 项目,支持任意数量的 Excel 导出,避开了新手常踩的坑(流顺序、中文乱码),直接复制代码就能落地使用。如果你的项目中有类似需求,不妨试试这个方案~
2026年02月06日
15 阅读
0 评论
3 点赞
2026-01-14
为什么不建议使用Executors创建线程池?
在Java开发中,线程池是优化并发性能的核心工具,但线程池的创建方式却藏着不少坑。《阿里巴巴Java开发手册》明确规定:线程池不允许使用Executors创建,必须通过ThreadPoolExecutor手动创建。很多新手可能会疑惑:Executors提供的方法简洁又方便,为什么会被禁止?今天就从底层实现出发,彻底讲清楚这个问题,同时补充线程池的核心知识,帮你避开面试和开发中的高频陷阱。一、先认识下「背锅侠」:Executors类Executors是JUC(java.util.concurrent)包下的工具类,专门用于快速创建线程池,提供了4个核心方法:newFixedThreadPool:固定线程数的线程池newSingleThreadExecutor:单线程线程池newCachedThreadPool:可缓存的线程池newScheduledThreadPool:支持定时/周期性任务的线程池这些方法看似「开箱即用」,但底层参数配置存在致命缺陷,我们逐个拆解。1. 隐患1:newFixedThreadPool & newSingleThreadExecutor——内存溢出风险先看newFixedThreadPool的底层实现代码:public static ExecutorService newFixedThreadPool(int nThreads, ThreadFactory threadFactory) { return new ThreadPoolExecutor(nThreads, nThreads, 0L, TimeUnit.MILLISECONDS, new LinkedBlockingQueue<Runnable>(), threadFactory); }核心问题出在等待队列LinkedBlockingQueue上。我们点进LinkedBlockingQueue的无参构造:public LinkedBlockingQueue() { this(Integer.MAX_VALUE); // 队列长度默认是Integer.MAX_VALUE }关键坑点:Integer.MAX_VALUE是2147483647,相当于「无界队列」。当任务提交速度远大于线程处理速度时,任务会不断堆积在队列中,导致JVM内存持续飙升,最终触发OOM(内存溢出)。newSingleThreadExecutor的问题和它完全一致,底层也是用了无界的LinkedBlockingQueue,且核心线程数固定为1,任务堆积的风险更高:public static ExecutorService newSingleThreadExecutor() { return new FinalizableDelegatedExecutorService (new ThreadPoolExecutor(1, 1, 0L, TimeUnit.MILLISECONDS, new LinkedBlockingQueue<Runnable>())); }2. 隐患2:newCachedThreadPool & newScheduledThreadPool——资源耗尽风险再看newCachedThreadPool的实现:public static ExecutorService newCachedThreadPool() { return new ThreadPoolExecutor(0, Integer.MAX_VALUE, 60L, TimeUnit.SECONDS, new SynchronousQueue<Runnable>()); }核心问题有两个:核心线程数为0:没有常驻线程,每次有任务都需要创建新线程(除非有空闲线程可复用)最大线程数为Integer.MAX_VALUE:理论上可以创建无限多线程关键坑点:当短时间内提交大量任务时,线程池会疯狂创建新线程,而每个线程都会占用一定的内存和CPU资源,最终导致系统资源耗尽,程序崩溃。newScheduledThreadPool的问题类似,最大线程数同样是Integer.MAX_VALUE,存在相同的资源耗尽风险:public static ScheduledExecutorService newScheduledThreadPool(int corePoolSize) { return new ScheduledThreadPoolExecutor(corePoolSize); } // 父类构造(ScheduledThreadPoolExecutor继承自ThreadPoolExecutor) public ScheduledThreadPoolExecutor(int corePoolSize) { super(corePoolSize, Integer.MAX_VALUE, DEFAULT_KEEPALIVE_MILLIS, MILLISECONDS, new DelayedWorkQueue()); }二、正确姿势:ThreadPoolExecutor手动创建(推荐)Executors的问题本质是「参数固化」,无法根据业务场景灵活配置。而ThreadPoolExecutor允许我们手动指定所有核心参数,从根源上避免上述隐患。1. 核心参数详解(面试高频考点)ThreadPoolExecutor的核心构造方法:public ThreadPoolExecutor(int corePoolSize, int maximumPoolSize, long keepAliveTime, TimeUnit unit, BlockingQueue<Runnable> workQueue, ThreadFactory threadFactory, RejectedExecutionHandler handler) { // ... 省略参数校验逻辑 }7个参数的作用必须记牢,直接关系到线程池的性能和稳定性:corePoolSize(核心线程数):线程池的常驻线程数,即使空闲也不会销毁(除非设置allowCoreThreadTimeOut=true)。maximumPoolSize(最大线程数):线程池允许创建的最大线程数,超出核心线程数的是「非核心线程」。keepAliveTime(空闲线程存活时间):非核心线程空闲后的最大存活时间,超时会被销毁,释放资源。unit(时间单位):keepAliveTime的时间单位,如MILLISECONDS(毫秒)、SECONDS(秒)。workQueue(工作队列):用于存放等待执行的任务,必须使用「有界队列」(如ArrayBlockingQueue),避免任务堆积。threadFactory(线程工厂):用于创建线程,可自定义线程名称(方便问题排查)、设置线程优先级等。handler(拒绝策略):当线程数达最大且队列满时,新任务的处理策略(如丢弃任务、抛出异常、由提交线程执行等)。2. 推荐实践:自定义线程池示例结合业务场景(如处理用户订单任务),手动创建线程池:import java.util.concurrent.*; public class ThreadPoolDemo { // 线程工厂:自定义线程名称,方便排查问题 private static final ThreadFactory THREAD_FACTORY = new ThreadFactory() { private int count = 1; @Override public Thread newThread(Runnable r) { Thread thread = new Thread(r); thread.setName("order-thread-pool-" + count++); return thread; } }; // 拒绝策略:队列满时抛出异常,及时发现问题 private static final RejectedExecutionHandler REJECTED_HANDLER = new ThreadPoolExecutor.AbortPolicy(); // 自定义线程池 public static final ThreadPoolExecutor ORDER_THREAD_POOL = new ThreadPoolExecutor( 5, // 核心线程数:根据CPU核心数或业务量配置 10, // 最大线程数:不超过CPU核心数*2(IO密集型可适当增加) 60, // 空闲线程存活时间:60秒 TimeUnit.SECONDS, new ArrayBlockingQueue<>(100), // 有界队列:容量100,避免任务堆积 THREAD_FACTORY, REJECTED_HANDLER ); public static void main(String[] args) { // 提交任务 for (int i = 0; i < 100; i++) { int finalI = i; ORDER_THREAD_POOL.submit(() -> { System.out.println(Thread.currentThread().getName() + " 处理订单:" + finalI); }); } // 关闭线程池(实际项目中可在应用关闭时调用) ORDER_THREAD_POOL.shutdown(); } }三、加餐:线程池工作流程(面试必问)理解线程池的工作流程,能帮你更合理地配置参数。当任务提交后,线程池会按以下步骤处理:提交任务:通过execute()或submit()方法提交任务。检查核心线程:若当前运行的线程数 < corePoolSize,立即创建核心线程执行任务;否则进入下一步。检查工作队列:若队列未满,将任务放入队列等待执行;否则进入下一步。检查最大线程:若当前运行的线程数 < maximumPoolSize,创建非核心线程执行任务;否则进入下一步。触发拒绝策略:线程数达最大且队列满时,执行拒绝策略处理任务。记忆小技巧:核心线程优先接活 → 活太多就放队列 → 队列满了就加临时线程 → 临时线程也满了就拒绝。四、总结Executors被禁止的核心原因是「参数不可控」,导致线程池存在内存溢出或资源耗尽的风险;而ThreadPoolExecutor通过手动配置核心参数,能根据业务场景精准控制线程池的行为,从根源上规避风险。最后再划几个重点:必须使用有界队列(如ArrayBlockingQueue),避免任务堆积。最大线程数需合理配置(CPU密集型:核心数+1;IO密集型:核心数*2)。自定义线程工厂,方便问题排查。选择合适的拒绝策略,避免静默失败。掌握线程池的正确创建方式,不仅能提升程序的稳定性,也是Java面试中的高频考点。希望这篇文章能帮你彻底搞懂这个问题~
2026年01月14日
28 阅读
0 评论
2 点赞
2025-11-21
EasyExcel 实战:导出带图片的 Excel 完整方案
在实际开发中,可能会遇到导出 Excel 时需要包含图片的场景。EasyExcel 作为阿里开源的高效 Excel 处理工具,虽然原生支持图片导出,但在图片格式适配、单元格样式调整等细节上需要自定义扩展。本文将详细讲解如何基于 EasyExcel 实现带图片的 Excel 导出,包含完整代码示例和关键细节说明。一、最终效果展示导出的 Excel 中,图片将自适应单元格大小,无图片时显示"无图"提示,整体格式整洁规范:二、实现步骤详解1. 引入依赖首先在 pom.xml 中添加 EasyExcel 核心依赖(本文使用 4.0.3 版本,适配大部分场景):<dependency> <groupId>com.alibaba</groupId> <artifactId>easyexcel</artifactId> <version>4.0.3</version> </dependency>2. 定义导出实体类创建 RecordExcelVO 作为 Excel 导出的数据载体,核心关注图片字段的配置:package com.retail.ocr.model.vo; import com.alibaba.excel.annotation.ExcelProperty; import com.alibaba.excel.annotation.write.style.ColumnWidth; import com.retail.ocr.excel.converter.ImageConverter; import lombok.Data; /** * Excel 导出实体(识别记录) * * @author jucunqi * @since 2025/11/20 */ @Data public class RecordExcelVO { @ColumnWidth(20) // 列宽设置 @ExcelProperty("手机号") // Excel 表头名称 private String phoneNumber; @ColumnWidth(25) @ExcelProperty("识别时间") private String createTime; @ColumnWidth(20) @ExcelProperty("识别读数") private String number1; @ColumnWidth(20) @ExcelProperty("识别条码") private String number2; // 图片字段:使用自定义转换器处理 byte[] 类型 @ExcelProperty(value = "识别照片", converter = ImageConverter.class) private byte[] imageBytes; @ColumnWidth(20) @ExcelProperty("标记内容") private String markDesc; @ColumnWidth(20) @ExcelProperty("备注") private String remark; }关键说明:图片字段类型为 byte[]:便于存储图片二进制数据(可从文件、数据库 BLOB 字段、网络图片转换获取)。@ExcelProperty(converter = ImageConverter.class):指定自定义转换器,将 byte[] 转换为 Excel 可识别的图片格式。@ColumnWidth:统一设置列宽,优化 Excel 显示效果。3. 实现图片转换器(核心)自定义 ImageConverter 实现 Converter<byte[]> 接口,负责将图片二进制数据转换为 EasyExcel 支持的 ImageData 格式:package com.retail.ocr.excel.converter; import com.alibaba.excel.converters.Converter; import com.alibaba.excel.enums.CellDataTypeEnum; import com.alibaba.excel.metadata.GlobalConfiguration; import com.alibaba.excel.metadata.data.ImageData; import com.alibaba.excel.metadata.data.ReadCellData; import com.alibaba.excel.metadata.data.WriteCellData; import com.alibaba.excel.metadata.property.ExcelContentProperty; import com.alibaba.excel.util.ListUtils; import lombok.extern.slf4j.Slf4j; import org.springframework.util.CollectionUtils; import java.util.List; @Slf4j public class ImageConverter implements Converter<byte[]> { @Override public Class<?> supportJavaTypeKey() { return List.class; } @Override public CellDataTypeEnum supportExcelTypeKey() { return CellDataTypeEnum.EMPTY; } @Override public byte[] convertToJavaData(ReadCellData<?> cellData, ExcelContentProperty contentProperty, GlobalConfiguration globalConfiguration) throws Exception { return null; } @Override public WriteCellData<?> convertToExcelData(byte[] value, ExcelContentProperty contentProperty, GlobalConfiguration globalConfiguration) throws Exception { // 这里进行对数据实体类的byte[]进行处理 List<ImageData> data = ListUtils.newArrayList(); ImageData imageData; try { imageData = new ImageData(); imageData.setImage(value); data.add(imageData); } catch (Exception e) { log.error("导出临时记录图片异常:", e); } WriteCellData<?> cellData = new WriteCellData<>(); if (!CollectionUtils.isEmpty(data)) { // 图片返回图片列表 cellData.setImageDataList(data); cellData.setType(CellDataTypeEnum.EMPTY); } else { // 没有图片使用汉字表示 cellData.setStringValue("无图"); cellData.setType(CellDataTypeEnum.STRING); } return cellData; } }核心逻辑:校验图片二进制数据有效性,避免空指针异常。有图片时:封装 ImageData 列表,指定 Excel 单元格类型为 IMAGE。无图片时:设置文本"无图",单元格类型为 STRING,提升用户体验。4. 自定义图片写入处理器由于 EasyExcel 原生图片导出不支持自适应单元格、多图片排列等需求,需自定义 CellWriteHandler 处理图片的位置、大小和样式:package com.retail.ocr.excel.handler; import com.alibaba.excel.enums.CellDataTypeEnum; import com.alibaba.excel.metadata.Head; import com.alibaba.excel.metadata.data.ImageData; import com.alibaba.excel.metadata.data.WriteCellData; import com.alibaba.excel.write.handler.CellWriteHandler; import com.alibaba.excel.write.metadata.holder.WriteSheetHolder; import com.alibaba.excel.write.metadata.holder.WriteTableHolder; import org.apache.commons.collections4.CollectionUtils; import org.apache.poi.hssf.usermodel.HSSFWorkbook; import org.apache.poi.ss.usermodel.*; import org.apache.poi.util.Units; import org.apache.poi.xssf.usermodel.XSSFDrawing; import org.apache.poi.xssf.usermodel.XSSFPicture; import org.apache.poi.xssf.usermodel.XSSFShape; import java.util.List; import java.util.concurrent.CopyOnWriteArrayList; import java.util.concurrent.atomic.AtomicReference; public class ImageWriteHandler implements CellWriteHandler { /** * 已经处理的Cell */ private final CopyOnWriteArrayList<String> REPEATS = new CopyOnWriteArrayList<>(); /** * 单元格的图片最大张数(每列的单元格图片张数不确定,单元格宽度需按照张数最多的长度来设置) */ private final AtomicReference<Integer> MAX_IMAGE_SIZE = new AtomicReference<>(0); /** * 标记手动添加的图片,用于排除EasyExcel自动添加的图片 */ private final CopyOnWriteArrayList<Integer> CREATE_PIC_INDEX = new CopyOnWriteArrayList<>(); @Override public void beforeCellCreate(WriteSheetHolder writeSheetHolder, WriteTableHolder writeTableHolder, Row row, Head head, Integer columnIndex, Integer relativeRowIndex, Boolean isHead) { } @Override public void afterCellCreate(WriteSheetHolder writeSheetHolder, WriteTableHolder writeTableHolder, Cell cell, Head head, Integer relativeRowIndex, Boolean isHead) { } @Override public void afterCellDataConverted(WriteSheetHolder writeSheetHolder, WriteTableHolder writeTableHolder, WriteCellData<?> cellData, Cell cell, Head head, Integer relativeRowIndex, Boolean isHead) { // 在 数据转换成功后 不是头就把类型设置成空 if (isHead) { return; } // 将要插入图片的单元格的type设置为空,下面再填充图片 if (CollectionUtils.isNotEmpty(cellData.getImageDataList())) { cellData.setType(CellDataTypeEnum.EMPTY); } } @Override public void afterCellDispose(WriteSheetHolder writeSheetHolder, WriteTableHolder writeTableHolder, List<WriteCellData<?>> cellDataList, Cell cell, Head head, Integer relativeRowIndex, Boolean isHead) { // 在 单元格写入完毕后 ,自己填充图片 if (isHead || CollectionUtils.isEmpty(cellDataList)) { return; } boolean listFlag = false; Sheet sheet = cell.getSheet(); // 此处为ExcelUrlConverterUtil的返回值 List<ImageData> imageDataList = cellDataList.get(0).getImageDataList(); if (CollectionUtils.isNotEmpty(imageDataList)) { listFlag = true; } if (!listFlag && imageDataList == null) { return; } String key = cell.getRowIndex() + "_" + cell.getColumnIndex(); if (REPEATS.contains(key)) { return; } REPEATS.add(key); if (imageDataList.size() > MAX_IMAGE_SIZE.get()) { MAX_IMAGE_SIZE.set(imageDataList.size()); } // 默认要导出的图片大小为60*60px,60px的行高大约是900,60px列宽大概是248*8 sheet.getRow(cell.getRowIndex()).setHeight((short) 900); sheet.setColumnWidth(cell.getColumnIndex(), listFlag ? 240 * 8 * MAX_IMAGE_SIZE.get() : 240 * 8); if (listFlag) { for (int i = 0; i < imageDataList.size(); i++) { ImageData imageData = imageDataList.get(i); if (imageData == null) { continue; } byte[] image = imageData.getImage(); this.insertImage(sheet, cell, image, i); } } else { this.insertImage(sheet, cell, imageDataList.get(0).getImage(), 0); } // 清除EasyExcel自动添加的没有格式的图片 XSSFDrawing drawingPatriarch = (XSSFDrawing) sheet.getDrawingPatriarch(); List<XSSFShape> shapes = drawingPatriarch.getShapes(); for (int i = 0; i < shapes.size(); i++) { XSSFShape shape = shapes.get(i); if (shape instanceof XSSFPicture && !CREATE_PIC_INDEX.contains(i)) { CREATE_PIC_INDEX.add(i); XSSFPicture picture = (XSSFPicture) shape; picture.resize(0); } } } /** * 重新插入一个图片 * * @param sheet Excel页面 * @param cell 表格元素 * @param pictureData 图片数据 * @param i 图片顺序 */ private void insertImage(Sheet sheet, Cell cell, byte[] pictureData, int i) { int picWidth = Units.pixelToEMU(60); int index = sheet.getWorkbook().addPicture(pictureData, HSSFWorkbook.PICTURE_TYPE_PNG); CREATE_PIC_INDEX.add(index); Drawing<?> drawing = sheet.getDrawingPatriarch(); if (drawing == null) { drawing = sheet.createDrawingPatriarch(); } CreationHelper helper = sheet.getWorkbook().getCreationHelper(); ClientAnchor anchor = helper.createClientAnchor(); // 设置图片坐标 anchor.setDx1(picWidth * i); anchor.setDx2(picWidth + picWidth * i); anchor.setDy1(0); anchor.setDy2(0); // 设置图片位置 int columnIndex = cell.getColumnIndex(); anchor.setCol1(columnIndex); anchor.setCol2(columnIndex); int rowIndex = cell.getRowIndex(); anchor.setRow1(rowIndex); anchor.setRow2(rowIndex + 1); // ClientAnchor.AnchorType里有多种类型可选,从网上看的之前是不移动,直接悬浮在单元格上了,现在这个是随着单元格移动 anchor.setAnchorType(ClientAnchor.AnchorType.MOVE_AND_RESIZE); drawing.createPicture(anchor, index); } }核心功能:自适应行列宽:根据图片数量动态调整列宽,固定行高适配 60*60px 图片。多图片横向排列:支持单元格内多图片横向显示,间距 10px,避免重叠。图片随单元格移动:设置锚点类型为 MOVE_AND_RESIZE,图片会随单元格位置变化而同步移动。5. 业务层导出实现最后在 Controller 中编写导出接口,整合上述组件完成 Excel 导出:@GetMapping("/recognizeRecordExport") public void recognizeRecordExport(RecognizeRecordPageReqVo reqVo, HttpServletResponse response) throws IOException { // 获取数据逻辑省略 List<RecordExcelVO> excelVOList = getRecordExcelVOS(reqVo); String filename = "识别数据.xlsx"; // 输出 Excel EasyExcel.write(response.getOutputStream(), RecordExcelVO.class) .autoCloseStream(false) // 不要自动关闭,交给 Servlet 自己处理 .registerWriteHandler(new ImageWriteHandler()) .sheet(filename).doWrite(excelVOList); // 设置 header 和 contentType。写在最后的原因是,避免报错时,响应 contentType 已经被修改了 response.addHeader("Content-Disposition", "attachment;filename=" + URLEncoder.encode(filename)); response.setContentType("application/vnd.ms-excel;charset=UTF-8"); }关键注意事项:中文文件名乱码:使用 URLEncoder.encode 编码文件名,并指定 filename*=UTF-8'' 格式,兼容主流浏览器。响应头设置:contentType 对于 xlsx 格式需设置为 application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,xls 格式需改为 application/vnd.ms-excel。流关闭策略:设置 autoCloseStream(false),避免 EasyExcel 提前关闭响应流导致下载失败。以上就是使用EasyExcel导出带图片文件的所有内容了,希望对大家有所帮助~
2025年11月21日
419 阅读
0 评论
3 点赞
1
2
...
5