不知道你有没有遇到过这样的情况:系统压测指标死活上不去,CPU 不高,线程堆栈却一片红,关键业务接口 QPS 卡在 2000 上下,再压线程数也是原地踏步。我之前处理一个金融交易系统的日期格式化瓶颈时,就遇到了这个典型案例。业务逻辑本身不重,但每个交易请求都要把撮合时间格式化成标准字符串返回给前端和下游系统,用的正是“经典”的全局共享 SimpleDateFormat 对象,为了线程安全又包了一层 synchronized。压测结果非常稳定,稳定在 2000 QPS,怎么调线程池参数都突破不了。
这篇文章不打算讲玄乎的高深理论,而是从一个真实瓶颈出发,把“栈封闭”这个并发编程里的基础概念彻底讲透:局部变量为什么线程安全?它和“共享可变状态”到底差在哪?为什么加锁能解决问题,却解决不了性能问题?最后我会给出可落地的三种改造方案和实测参考数据,并盘点迁移过程中的坑。如果你也在维护类似的金融、交易、高并发接口,这篇内容应该能帮你少踩很多坑。
1. 先把问题钉死:SimpleDateFormat 为什么在多线程下会翻车
1.1 一个最典型的并发故障现场
先重现一下这个经典故障。假设你现在维护一个交易系统的报价服务,内部写了一个工具类,把所有日期格式化统一收口:
java复制public final class DateUtil {
private static final SimpleDateFormat SDF =
new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
public static String format(Date date) {
return SDF.format(date);
}
}
这段代码在低并发下运行得很正常,一天可能也就几百笔请求,完全不会暴露问题。但一旦进入压测环境,或者到了业务高峰,你会开始发现三类诡异现象:
- 第一种,格式化结果错乱。比如同一次请求里,日志显示返回给下游的交易时间是 2026-04-12 09:30:00,可前端收到的却是 2026-04-11 18:52:14,数据完全对不上。
- 第二种,直接抛异常。StackOverflow 上最常见的是
java.lang.NumberFormatException: For input string: "2026.4.12"或者ArrayIndexOutOfBoundsException,而且这些问题并不是必现,可能跑一万次才出现一两次。 - 第三种,线程卡死或 CPU 飙高。锁竞争激烈时,线程大量阻塞在
synchronized内部,用jstack一抓,几乎全是同一个方法栈。
出现第一种和第二种现象时,很多经验不足的同学第一时间会去检查自己的业务代码,甚至怀疑是前端传参格式变了。但只要你把视线稍微转向那个全局共享的 SDF,再对照一下 SimpleDateFormat 的源码,答案其实就藏在里面。
1.2 根因:可变共享状态加非原子操作链
SimpleDateFormat 之所以线程不安全,根本原因不是它没有加锁,而是它内部维护着一堆可变的状态字段。最核心的是继承自 DateFormat 的 Calendar calendar 字段。
看一下 SimpleDateFormat.format(Date date) 的内部实现逻辑,大致的执行链路是这样的:
- 读取当前对象持有的
calendar字段; - 调用
calendar.setTime(date),把传入的日期写进这个共享的Calendar实例; - 然后从这个
Calendar里逐字段读取年月日时分秒; - 拼接成字符串返回。
问题就出在“共享的 Calendar”上。当线程 A 执行完 setTime,还没开始读取字段时,线程 B 也进入了同一个 format 方法,又把 calendar 的日期改掉了。此时线程 A 再读取,拿到的就可能已经是线程 B 的数据。轻则结果错乱,重则解析过程直接崩溃。
这不是理论上可能,而是并发环境下几乎必然会出现。共享可变对象一旦被多个线程同时访问,又没有同步控制,就是一个数据竞争。数据竞争带来的问题不具备确定性,今天错一个分钟,明天错一个日期,后天抛异常,都属于合理范围。
那为什么加锁之后不会错乱?因为 synchronized 强制让同一时间只有一个线程进入 format 方法,其他线程都在锁上排队。数据竞争被同步机制挡住了,正确性恢复了,代价是并发度直接归零。
1.3 加锁救场,但代价远比你想象的大
有人说,加锁能解决问题就行,性能差点就差点。但你要知道,锁竞争的开销并不是“有点慢”那么简单。
我们当时压测的结果是:核心接口 QPS 只有 2000,而且继续增加压测线程,QPS 几乎不再上升,甚至下降。为什么?因为 synchronized 加在 format 方法上,本质上是把所有日期格式化操作变成了一个串行队列。无论你启动多少线程,最终这些线程都在争一把锁。线程越多,锁竞争越激烈,线程切换和上下文切换的开销越大。
一次 SimpleDateFormat 的 format 调用,内部要做 Calendar.setTime、多个字段计算、字符串拼接、子序列处理等一系列工作,单次操作本身是有一定耗时的。如果单次操作平均耗时 200 微秒,那么单线程每秒最多处理 5000 次。但这是理想状态,加上锁的获取、阻塞、唤醒、上下文切换,实际单次耗时可能膨胀到 500 微秒以上,2000 QPS 就是这么来的。
| 方案 | 正确性 | 并发度 | 单次耗时表现 | 瓶颈 |
|---|---|---|---|---|
| 全局 SimpleDateFormat 不加锁 | 错误 | 看似高并发但数据错乱 | 快但没有意义 | 数据竞争 |
| 全局 SimpleDateFormat 加锁 | 正确 | 串行化 | 锁竞争损耗大 | 锁竞争、上下文切换 |
所以结论很清楚:加锁保住了正确性,却丢掉了高性能。问题的根源不在“锁用错了”,而在“共享可变对象加锁”这个组合,本身就是高并发场景下的次优解。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 栈封闭为什么能成为并发问题的“根治方案”
2.1 运行时模型里的“线程私有空间”
要理解栈封闭,先把 Java 程序运行时的内存布局搞清楚。Java 虚拟机在运行时会划分多个区域,其中和并发关系最密切的是两块:堆和虚拟机栈。
堆是线程共享的,所有对象实例和数组都分配在这里。任何线程只要持有对象的引用,就能访问这个对象。简单理解,堆是一个公开的仓库,所有人都能往里放东西、取东西。
虚拟机栈是线程私有的。每启动一个线程,JVM 就会为该线程创建一个独立的虚拟机栈。每次调用一个方法,就会在栈里压入一个栈帧,栈帧内部有局部变量表、操作数栈、动态链接、方法出口等信息。局部变量表里存放的是基本数据类型值,以及对象的引用。
关键点在这里:一个线程的虚拟机栈,天然归这个线程私有,其他线程既看不到,也无法访问。所以凡是只在当前方法栈帧里存在的局部变量,对其他线程来说就是不可见的。
画一张最朴素的图来理解:全局共享的 SimpleDateFormat 对象,就像一个摆在办公室正中间的公共白板,谁都可以过来写,谁都可以过来改,不改乱了才怪。而局部变量,相当于每个人工位上的一张私便利贴,你在自己的桌子上记要点,别人看不见,也不会来动,自然不存在写冲突。
2.2 栈封闭成立的关键:引用不能逃逸
这里必须纠正一个容易误导新人的说法:不是“所有局部变量都线程安全”,而是“满足栈封闭条件的局部变量才线程安全”。
为什么这么说?因为 Java 的对象实例本身是分配在堆上的,局部变量表里只保存对象的引用。也就是说,你写下面这段代码:
java复制public String convert(Date date) {
DateFormat df = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
return df.format(date);
}
df 这个变量在栈帧里,但它指向的 SimpleDateFormat 对象,实际上依然在堆上。这个对象之所以安全,不是因为它“已经在线程的栈上”,而是因为它当前的引用只存在于当前线程的栈帧中,没有其他线程能拿到这个引用。
这就是栈封闭的核心思想:通过“作用域”控制对象的可达性,让一个对象只能被一个线程访问。只要这个引用不从方法里逃逸出去,那么即使在堆上,也不会有第二个线程碰得到它。
那什么叫“逃逸”?
- 把局部对象赋值给一个实例字段或静态字段;
- 把局部对象塞进一个共享容器,比如全局
Map、List; - 把局部对象作为参数传给其他方法,而那个方法又把它持久化到字段里;
- 把局部对象传到一个新启动的线程里;
- 被匿名内部类或 lambda 表达式捕获,并最终在其他线程执行。
任何一个动作,都等于把“私便利贴”贴到了公共白板上。一旦逃逸,栈封闭就不成立了,局部变量也无法保证线程安全。
举个例子,下面这段代码看起来是在方法内部 new 了对象,实际上已经埋了雷:
java复制private DateFormat leakedDf;
public String convert(Date date) {
DateFormat df = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
leakedDf = df; // 逃逸!字段被其他方法、其他线程可见
return df.format(date);
}
所以真正可靠的设计原则不是“用局部变量就行”,而是“确保对象不逃逸出当前线程的调用栈”。这也是为什么把 SimpleDateFormat 从共享字段改成方法内部创建,能从根本上解决问题的原因。
2.3 现代 JVM 给了这个方案多大的助力
很多人会担心:每次 format 都 new 一个 SimpleDateFormat,会不会产生大量垃圾对象,导致 GC 压力很大?这种担心合理,但现代 JVM 的表现往往比大多数人想象的好。
JIT 编译器在运行时会对代码做逃逸分析。如果一个对象没有逃逸出方法或线程,JIT 就认为这个对象不会被其他线程观察到。基于这个分析结果,JIT 可以做两件非常关键的事情:
- 标量替换:把对象的字段拆成一个个独立的局部变量,不再真正创建对象实例。
- 栈上分配:在虚拟机栈上分配对象空间,方法结束即自动销毁,完全不需要触发垃圾回收。
- 锁消除:如果对象本身是线程私有的,那么即使代码上写了
synchronized块,JIT 也会把锁消除掉,因为没有竞争的可能。
所以“每次 new 一个”加上逃逸分析之后,实际开销可能比你想象的小得多。即便对象真的被分配到了堆上,现代垃圾收集器对短生命周期小对象的回收效率也非常高。相比之下,锁竞争导致的上下文切换开销,往往比对象分配的代价更大。
这就是为什么我们在优化时,优先把 “共享对象加锁” 改成 “局部对象无锁”,而不是继续纠结怎么优化锁的粒度。方向对了,性能自然就上来了。
3. 从 2000 QPS 出发,落地成三段式优化
3.1 先建立可对比的压测基线
任何性能优化,第一步都不是改代码,而是先建立基线。没有基线,你后面再怎么优化,都可能是在自嗨。我当时给这个交易系统做优化时,先写了一个简单压测脚本,模拟核心接口的调用频率和并发数。
如果你没有成熟的压测平台,可以用一个最简单的线程循环去模拟。核心思路是:固定一批线程,每个线程循环调用日期格式化函数,统计单位时间内的完成次数。
java复制@Test
public void stress() throws InterruptedException {
int threadCount = 64;
ExecutorService pool = Executors.newFixedThreadPool(threadCount);
int total = 1_000_000;
CountDownLatch start = new CountDownLatch(1);
CountDownLatch end = new CountDownLatch(threadCount);
AtomicInteger counter = new AtomicInteger();
for (int i = 0; i < threadCount; i++) {
pool.submit(() -> {
try {
start.await();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
while (counter.incrementAndGet() <= total) {
DateUtil.format(new Date());
}
end.countDown();
});
}
long begin = System.nanoTime();
start.countDown();
end.await();
long costMs = (System.nanoTime() - begin) / 1_000_000;
System.out.println("QPS = " + (total * 1000L / costMs));
pool.shutdown();
}
这个测试虽然简陋,但足够说明问题。当时基线数据非常稳定,64 个线程下 QPS 在 2000 左右波动,偶尔还会掉到 1800。如果你用 JMH 来测,数据会更精确,但压测脚本的价值在于快速验证优化方向,不用一开始就套上重工具。
3.2 方案一:局部 new,用栈封闭消灭共享
第一个优化方案,就是不改任何业务结构,只把静态字段共享的 SimpleDateFormat 改成方法内部的局部变量:
java复制public final class DateUtil {
private static final String PATTERN = "yyyy-MM-dd HH:mm:ss";
public static String format(Date date) {
DateFormat df = new SimpleDateFormat(PATTERN);
return df.format(date);
}
}
改动虽然小,但意义重大。现在每个线程每次调用 format,都会在自己的栈帧中创建并持有 df,这个对象不会被其他线程观察到,栈封闭成立。数据竞争天然消失,不再需要任何锁。
这个方案的优点是简单、直观、几乎没有迁移成本,非常适合老系统快速止血。代价是每次方法调用都要创建一次对象。在 JIT 逃逸分析生效的情况下,这个代价会被大幅削减。
这里有个细节值得注意:PATTERN 一定要用 static final 常量,不要在方法里直接写字符串。因为每次 new SimpleDateFormat("yyyy-MM-dd HH:mm:ss") 时,构造函数都要解析这个 pattern 字符串,如果每次传入的都是一个新字符串对象,还多了字符串常量池相关的开销。虽然影响不大,但没必要浪费。
在我实测的机器上,同样 64 线程压测,这个简单改动直接把 QPS 从 2000 拉到了 6 万以上。你没看错,不是翻倍,而是接近 30 倍。原因就是掉进了锁竞争这个无底洞之后,去掉锁带来的收益远超我们的直觉判断。
3.3 方案二:ThreadLocal 做线程内缓存
如果你担心局部 new 的创建开销,不想依赖逃逸分析,那可以上第二套方案:用 ThreadLocal 为每个线程缓存一个 SimpleDateFormat。
java复制public final class DateUtil {
private static final String PATTERN = "yyyy-MM-dd HH:mm:ss";
private static final ThreadLocal<DateFormat> DF_HOLDER =
ThreadLocal.withInitial(() -> new SimpleDateFormat(PATTERN));
public static String format(Date date) {
DateFormat df = DF_HOLDER.get();
return df.format(date);
}
}
ThreadLocal 的核心机制是:每个线程内部维护了一个 ThreadLocalMap,以 ThreadLocal 对象作为 key,以你存入的值作为 value。所以每个线程拿到的 df 都是自己线程独有的,互不干扰。本质上,它做的还是栈封闭,只不过对象存活范围从“单次方法调用”扩大到了“整个线程生命周期”。
这也就意味着,同一线程连续多次调用 format 时,可以复用同一个 SimpleDateFormat 对象,避免了反复创建和释放。从性能上来说,ThreadLocal 方案通常比“每次 new”更快,尤其是在逃逸分析不生效、对象创建成本被放大的解释执行阶段。
但 ThreadLocal 有一个经典风险:内存泄漏。在线程池场景下,线程是长期存活的。如果你往 ThreadLocal 里放了值,使用完后不清理,这个值会一直被线程的 ThreadLocalMap 引用着,无法被垃圾回收。如果这个值是重量级对象,或者每条业务请求都往里放不同的大对象,就有可能导致老年代持续增长,最终触发频繁 Full GC。
所以用 ThreadLocal 时,我一般会配合 try-finally 做清理:
java复制public static String formatWithCleanup(Date date) {
DateFormat df = DF_HOLDER.get();
try {
return df.format(date);
} finally {
DF_HOLDER.remove();
}
}
不过说实话,在日期格式化这个场景里,单次 format 后清理会降低复用价值。如果你的调用链是“请求进来,处理完再出去”,我建议把清理动作放到整个业务请求的入口/出口,而不是工具方法内部。否则你就是在反复 put/remove,反而引入额外开销。
3.4 方案三:直接用 DateTimeFormatter 一劳永逸
如果这个交易系统的代码已经跑在 Java 8 及以上,我强烈建议你别在 SimpleDateFormat 的周边继续打转了,直接用 java.time.format.DateTimeFormatter。
java复制public final class DateUtil {
private static final DateTimeFormatter DTF =
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")
.withZone(ZoneId.of("Asia/Shanghai"));
public static String format(Date date) {
return DTF.format(date.toInstant());
}
}
DateTimeFormatter 被设计成不可变对象,一经创建,所有内部状态都不会改变。不可变对象不需要加锁,因为没有任何写操作,即使多个线程同时读它,也不会出现数据竞争。这是比栈封闭更彻底的解决方案:栈封闭是让一个对象只能被一个线程访问,不可变对象则是让任意多个线程安全地读同一个对象。
从性能角度看,DateTimeFormatter 的格式化路径比 SimpleDateFormat 更干净,没有共享可变字段,没有锁,也没有每次创建对象的开销。实测下来,它通常是最快的方案。
但这里有一个容易被忽视的问题:DateTimeFormatter 的默认解析策略比 SimpleDateFormat 严格。SimpleDateFormat 默认是宽松模式,很多不合规的输入它都能“努力”解析出来;而 DateTimeFormatter 默认走 ISO 规范相关的严格策略,输入格式稍微不符合预期,就会抛出 DateTimeParseException。
另外,DateTimeFormatter.ofPattern 的格式符号和 SimpleDateFormat 大部分兼容,但细节有差异。比如:
YYYY(周基准年)和yyyy(日历年)在跨年场景结果完全不同;SimpleDateFormat里DD表示一年中的第几天,DateTimeFormatter里也是,但大小写含义需要仔细核对;- 解析时对闰年、月份天数的校验严格度不一样。
所以迁移时不能只改类型,还要对每个 pattern 手工验证边界值,尤其是 12 月 31 日附近、闰年 2 月 29 日这些容易出幺蛾子的时间点。
4. 实测结果与迁移中的各种坑
4.1 一组参考性能数据
我在 8 核容器环境下,JDK 1.8.0_292,压测线程数 64,循环格式化 100 万次,得到了这样一组参考数据(不同机器结果会有差异,但相对趋势是一致的):
| 实现方式 | 是否线程安全 | 是否加锁 | 参考 QPS |
|---|---|---|---|
| 全局 SimpleDateFormat 加锁 | 安全 | 是 | 约 2000 |
| 方法内局部创建 SimpleDateFormat | 安全 | 否 | 约 7 万 |
| ThreadLocal 缓存 SimpleDateFormat | 安全 | 否 | 约 16 万 |
| 全局不可变 DateTimeFormatter | 安全 | 否 | 约 18 万 |
从 2000 到 18 万,这个提升幅度听起来夸张,但在“把高代价锁竞争消除”的场景下,其实是合理的。因为 synchronized 模式下,大量线程并不是在“干活”,而是在“抢锁”、“阻塞”、“被唤醒”,这些过程消耗的时间远远超过实际格式化时间。优化之后,每个线程都只干自己那份活,互不干扰,吞吐量自然成倍增长。
也正因如此,我在做性能优化时最怕的不是“代码跑得慢”,而是“代码大部分时间停在等待上”。只要发现了等待,去掉等待,收益往往都是几十倍级别的。
4.2 排查漏网之鱼:从 jstack 到火焰图
优化完核心 DateUtil 之后,你以为就结束了?并没有。我们就踩过这样一个坑:主链路确实快了,但另一个定时任务服务里依然保留了全局共享的 SimpleDateFormat 加锁写法,每天凌晨跑批时,这个任务和日终报表接口互相争抢 CPU,整体性能还是上不去。
排查这种“漏网之鱼”,最有效的手段是 jstack 抓线程栈。
bash复制jstack <pid> > jstack.log
抓完日志后,搜索 SimpleDateFormat.format 或 java.text.DateFormat,看看哪些线程正在这个方法上排队。如果在日志里看到大量线程阻塞在 java.text.SimpleDateFormat.format 的 monitor 上,基本可以断定这个类还在被多线程共享。
如果想看更直观的性能热点,可以用 async-profiler 生成火焰图。火焰图中出现一个宽大的“平顶”,下面挂着大量线程栈,通常就是锁竞争或热方法。
另一个保险方案是全局搜索代码仓库:
- 搜索
new SimpleDateFormat,确认每个实例的持有方式和生命周期; - 搜索
SimpleDateFormat类型的静态字段和实例字段; - 搜索
synchronized块里包含format或parse调用的地方。
不要只排查 DateUtil 这个工具类,因为很多老项目的业务类里直接内嵌了 SimpleDateFormat 字段,甚至有的传给了内部私有方法,散落得到处都是。宁可花半小时把这些点排干净,也不要上线后再被线上告警追着跑。
4.3 迁移过程中的易错清单
结合那次金融交易系统的优化经历,我整理了一份日期格式化迁移检查清单,每一条都是实际上踩过或帮别人 review 时踩过的坑。
第一,注意 parse 和 format 的输入类型差异。SimpleDateFormat.format(Date) 接收 java.util.Date,但 DateTimeFormatter.format(TemporalAccessor) 需要 LocalDateTime、LocalDate、Instant 等类型。如果你手头还留着大量 java.util.Date 字段,迁移时要么先转 Instant,要么把字段统一升级成 LocalDateTime。最怕的是全项目一半 Date 一半 LocalDateTime,工具类里来回转换,反而引入更多代码噪音。
第二,注意时区问题。SimpleDateFormat 默认使用 JVM 系统时区,如果你没显式设置 TimeZone,服务器时区一变,格式化结果就跟着变。DateTimeFormatter.ofPattern(...) 默认也使用系统时区,但如果你处理的是 Instant,它需要一个显式的时区来转换,否则会抛异常。所以我在工具类里都会直接指定时区:
java复制private static final DateTimeFormatter DTF =
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")
.withZone(ZoneId.of("Asia/Shanghai"));
第三,不要把局部对象存到缓存或全局容器。有些同事优化到一半,为了保证“性能”,把原本的局部对象存到了一个公共 ConcurrentHashMap 里,想着一劳永逸。结果对象的生命周期被无限延长,栈封闭瞬间被打破,并发错乱问题又重新出现。栈封闭的关键是作用域,不是“看起来每次 new 太浪费”。如果真要复用,就必须用 ThreadLocal 或不可变对象,二选一。
第四,ThreadLocal 的线程池清理问题。在高并发 Web 应用里,业务线程通常来自线程池。如果你在 ThreadLocal 里放了 SimpleDateFormat,并且这个线程被长期复用,那么这个缓存对象会一直存在。如果每笔请求都往同一个线程的 ThreadLocal 里塞不同的对象(比如塞了用户会话信息、请求上下文),那线程池里的每一个线程都可能持有大量历史对象的引用,最终导致 GC 无法回收。日期格式化对象本身不大,影响可能不明显,但这不是一个好习惯。养成“用完后在 finally 里 remove”的习惯,能避免未来更隐蔽的内存问题。
第五,不要为了优化日期格式化,把业务代码改成“双写”或者“异步格式化”。有个同事曾经提出,既然 format 慢,就先用原始时间戳返回,等请求结束后异步补一个格式化写日志。听上去很聪明,但副作用是下游系统拿到的数据不一致,交易对账时时间字段经常对不上。性能优化不能破坏接口语义,这个底线一定要守住。
4.4 由日期格式化外推到其他共享工具类
如果你理解了栈封闭的原理,你会发现它并不是日期格式化专用技巧,而是并发编程里一个通用原则。一个类非线程安全,通常是因为它有可变的实例字段,并且多个线程都会修改这些字段。下面这些常见类也有类似问题:
java.text.NumberFormat、DecimalFormat:内部有可变 parsing/format 状态,多线程共享会出现解析错乱。java.text.MessageFormat:内部有Format数组等可变状态,不适合无保护跨线程使用。java.util.Random:如果多线程共享一个Random实例,虽然内部使用了 CAS 实现原子性,但在高并发下自旋竞争激烈,性能下降明显。更推荐ThreadLocalRandom。java.util.UUID的randomUUID静态方法:底层也是共享SecureRandom,在极端高并发时可能出现性能抖动,需要视场景处理。
对这些类,我们的优化路径完全可以复用:要么每次使用时在局部变量中创建,要么用 ThreadLocal 隔离,要么换成不可变或线程安全的替代实现。
这也引出一个设计层面的思考:在服务端代码里,静态字段和单例 bean 是“共享”的天然温床。每次把一个可变的非线程安全对象放进静态字段或单例字段之前,都要先问一句:这个对象会不会被多个线程同时修改?如果会,就一定要通过栈封闭、线程封闭或不可变设计来处理,而不是直接把 synchronized 挂上去。
5. 把优化沉淀成团队里的评审习惯
那次优化之后,我又处理过不少类似问题,最终形成了一套很简单的代码评审检查流程,分享出来供你参考。
第一,遇到 private static final 字段,先确认类型是否是不可变类。如果类型是 SimpleDateFormat、DecimalFormat、Random 这类有内部可变状态又不保证线程安全的类,就要立即标红。这不是说要禁止使用,而是强制触发一次讨论:这个字段的生命周期和访问方式是什么?
第二,看到 synchronized 关键字,先追问锁保护的对象是什么。如果锁只保护一个非常小的方法体,还好。如果是直接锁住整个 format 方法,而里面操作的全是共享可变对象,那就要考虑重构了。
第三,看到 ThreadLocal,顺手检查有没有 remove。没有 remove 不代表一定出事,但一旦出问题就是老年代缓慢增长的典型内存泄漏,排查成本极高。
第四,看到 DateTimeFormatter,检查 pattern 容器是否用了 static final。DateTimeFormatter 本身不可变,但如果每次在方法里 ofPattern 临时创建,虽然线程安全,但性能没吃到红利,还不如局部创建 SimpleDateFormat。既然用了新 API,就要把它的优势发挥出来。
这些习惯沉淀下来之后,不光日期格式化,包括配置解析、对象转换、ID 生成这些场景,都能少踩很多坑。我个人的体会是,并发性能问题十有八九不是 “语法不会写”,而是 “共享边界没想清楚”。栈封闭不是什么高深算法,它只是把“谁拥有这个对象”这个问题提前在设计阶段想明白。代码写完之后,对象归属关系越清晰,并发问题就越少。
