关键在于精准控制临界区、守住共享状态边界、避开复合操作陷阱;需统一锁对象保护底层容器、失效标记和加载任务引用,用双重检查避免竞态,并引入超时与异步机制防阻塞。

直接用 synchronized 重构旧报表缓存层,关键不是“加锁”,而是**精准控制临界区、守住共享状态边界、避开复合操作陷阱**。企业级报表缓存通常涉及“查缓存→没命中→查库→写缓存”这类多步逻辑,单靠包装容器(如 Collections.synchronizedMap)远远不够——它只保单方法原子性,无法防止竞态条件。
明确哪些状态必须被同一把锁保护
报表缓存的核心共享状态一般包括:
- 底层存储容器(如
HashMap<String, ReportData>) - 缓存失效标记或版本号(如
volatile long lastRefreshTime) - 正在执行的加载任务引用(用于避免重复加载,即“缓存击穿防护”)
这些字段必须在同一个 synchronized 块中读写。不能一部分用 synchronized method,另一部分用 synchronized(this),更不能分散锁不同对象——否则线程仍可交叉修改,失去互斥意义。
拒绝“检查后执行”裸奔,所有复合逻辑必须包裹进同步块
旧代码常见错误写法:
立即学习“Java免费学习笔记(深入)”;
if (!cache.containsKey(key)) { // 非原子:A线程刚判断完,B线程就写入了
ReportData data = loadFromDB(key); // B线程也在执行这行
cache.put(key, data); // A、B都put,后者覆盖前者,且浪费一次查询
}
正确重构方式(显式同步 + 双重检查):
- 用
synchronized(cache)包裹整个“查-判-载-存”流程 - 在同步块内再次检查缓存是否已存在(防虚假唤醒或其它线程抢先写入)
- 若仍为空,再执行耗时加载;完成后统一写入
合理选择锁对象,避免锁粒度失衡
不推荐直接 synchronized(this) 或 synchronized 实例方法——报表服务通常是单例,等于全局串行化,吞吐量骤降。
更优做法:
- 为缓存模块定义私有锁对象:
private final Object cacheLock = new Object(); - 所有缓存读写操作统一使用
synchronized(cacheLock) - 若需更高并发性(如报表按租户隔离),可按 key 分片加锁:
synchronized(lockFor(key)),配合ConcurrentHashMap的分段思想
处理阻塞与超时,防止报表请求无限等待
单纯 synchronized 会令线程排队阻塞,一旦数据库慢查询卡住,所有后续报表请求全被拖住。必须主动防御:
- 加载数据库前,先记录开始时间,同步块内检查是否已超时(如 >3s),超时则跳过加载,返回 stale 数据或空结果
- 对长耗时加载操作,考虑异步刷新机制:同步块内只返回旧数据,另起线程后台更新缓存(配合
volatile标记或AtomicBoolean控制刷新状态) - 避免在同步块中调用外部服务、发 HTTP 请求、写日志文件等 I/O 操作
上线前必须验证的三件事
重构不是改完就完事:
- 压测验证:启动 20+ 线程并发请求同一报表 key,断言最终缓存值唯一、数据库查询次数 ≤1
- 检查线程栈:用
jstack抓取运行中堆栈,确认无长时间持有cacheLock的线程(排除慢 SQL 卡死) - 日志埋点:在同步块入口/出口打日志,统计平均等待时长和临界区执行时长,作为后续优化依据


















