ThreadLocal专题二
可以。这个问题在面试里通常要回答 “为什么 SimpleDateFormat 线程不安全 + 如何用 ThreadLocal 解决 + 最好给出生产可用写法”。我直接按这个顺序讲。
1. 为什么 SimpleDateFormat 线程不安全
SimpleDateFormat 在格式化 / 解析日期时,会复用内部状态,比如 Calendar、解析位置等对象。
如果多个线程共享同一个 SimpleDateFormat 实例,就可能出现并发修改这些内部状态的问题,导致:
- 格式化结果错乱
- 解析结果异常
- 甚至抛
NumberFormatException
例如下面这种写法就是有问题的:
public 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);
}
}
如果多个线程同时调用 format() / parse(),都在操作同一个 SDF,就会有线程安全问题。
2. 用 ThreadLocal 的核心思路
不要让多个线程共享同一个 SimpleDateFormat,而是:
给每个线程都分配一个独立的
SimpleDateFormat实例。
这正是 ThreadLocal 的典型用法。
也就是:
- 线程 A 用自己的
SimpleDateFormat - 线程 B 用自己的
SimpleDateFormat - 互不影响
3. 标准实现:用 ThreadLocal 包装 SimpleDateFormat
3.1 工具类写法
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.Date;
public class DateFormatUtil {
private static final ThreadLocal<SimpleDateFormat> SDF_THREAD_LOCAL =
ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"));
/**
* 日期转字符串
*/
public static String format(Date date) {
return SDF_THREAD_LOCAL.get().format(date);
}
/**
* 字符串转日期
*/
public static Date parse(String dateStr) throws ParseException {
return SDF_THREAD_LOCAL.get().parse(dateStr);
}
/**
* 使用完后清理,尤其在线程池环境下
*/
public static void remove() {
SDF_THREAD_LOCAL.remove();
}
}
4. 怎么使用
public class Test {
public static void main(String[] args) throws Exception {
Date now = new Date();
String str = DateFormatUtil.format(now);
System.out.println("格式化结果:" + str);
Date date = DateFormatUtil.parse("2026-07-08 10:30:00");
System.out.println("解析结果:" + date);
}
}
5. 为什么这样就线程安全了
因为底层不再是所有线程共用一个 SimpleDateFormat,而是变成:
线程1 -> ThreadLocalMap -> (ThreadLocal -> SimpleDateFormat实例1)
线程2 -> ThreadLocalMap -> (ThreadLocal -> SimpleDateFormat实例2)
线程3 -> ThreadLocalMap -> (ThreadLocal -> SimpleDateFormat实例3)
所以每个线程操作的是自己的 SimpleDateFormat 对象,不存在多个线程同时修改同一个对象内部状态的问题,自然就线程安全了。
6. 如果在线程池里,为什么还要 remove()
这是面试很容易继续追问的点。
虽然用了 ThreadLocal,但是如果在线程池环境中:
- 线程不会销毁
- 线程会被复用
ThreadLocal中的值可能一直挂在线程上
所以最好在使用完成后清理:
public class Demo {
public static void main(String[] args) {
try {
String str = DateFormatUtil.format(new Date());
System.out.println(str);
} finally {
DateFormatUtil.remove();
}
}
}
这样做的好处:
- 避免线程池线程长期持有
SimpleDateFormat - 避免
ThreadLocal相关的内存泄漏问题 - 形成规范:set/get 后最终 remove
7. 更完整一点:封装成 try-finally 的使用方式
比如:
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.Date;
public class DateFormatUtil {
private static final ThreadLocal<SimpleDateFormat> HOLDER =
ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"));
public static String format(Date date) {
return HOLDER.get().format(date);
}
public static Date parse(String text) throws ParseException {
return HOLDER.get().parse(text);
}
public static void clear() {
HOLDER.remove();
}
public static void main(String[] args) {
try {
String s = DateFormatUtil.format(new Date());
System.out.println(s);
Date d = DateFormatUtil.parse("2026-07-08 12:00:00");
System.out.println(d);
} catch (Exception e) {
e.printStackTrace();
} finally {
DateFormatUtil.clear();
}
}
}
8. 面试里怎么回答最合适
你可以直接这么说:
SimpleDateFormat是线程不安全的,因为它在格式化和解析过程中会复用内部的Calendar等可变状态,如果多个线程共享同一个SimpleDateFormat实例,就会发生并发修改问题。
解决方式之一是使用ThreadLocal<SimpleDateFormat>,为每个线程保存一个独立的SimpleDateFormat实例。这样每个线程访问的是自己线程内的对象副本,不会互相干扰。
典型写法是用ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"))初始化,然后在format()和parse()时通过threadLocal.get()获取当前线程的SimpleDateFormat。
如果是在线程池环境下,还要注意在使用完成后调用remove(),避免线程复用导致的内存泄漏或脏数据问题。
9. 但是生产里我更建议你补一句:Java 8 以后更推荐 DateTimeFormatter
这是加分点。
因为 SimpleDateFormat 本身就比较老了,Java 8 以后更推荐使用 DateTimeFormatter,它是线程安全的,不需要再配合 ThreadLocal。
例如:
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
public class NewDateUtil {
private static final DateTimeFormatter FORMATTER =
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
public static String format(LocalDateTime time) {
return time.format(FORMATTER);
}
public static LocalDateTime parse(String text) {
return LocalDateTime.parse(text, FORMATTER);
}
}
所以如果面试官问:
生产里你会怎么做?
你可以答:
- 老项目 / 必须用
Date、SimpleDateFormat时:可以用ThreadLocal<SimpleDateFormat>解决线程安全问题 - 新项目 / Java 8+:优先用
DateTimeFormatter
10. 最后给你一个“面试可直接背”的完整答案
SimpleDateFormat是线程不安全的,因为它内部维护了可变状态,例如Calendar,在format和parse时会修改这些状态。如果多个线程共享同一个SimpleDateFormat实例,就可能导致日期格式化或解析结果错乱。
一个常见解决方案是使用ThreadLocal<SimpleDateFormat>,为每个线程提供一个独立的SimpleDateFormat实例。这样每个线程只操作自己的实例,不会发生并发冲突。
代码上通常会写成:private static final ThreadLocal<SimpleDateFormat> TL = ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"));
在format或parse时通过TL.get()获取当前线程的格式化对象。
另外,如果是在使用线程池的场景下,使用完成后要调用remove(),避免线程复用带来的内存泄漏问题。
不过在 Java 8 及以后,生产中更推荐使用线程安全的DateTimeFormatter,这样就不需要ThreadLocal了。
如果你愿意,我可以继续把这个问题再往面试深一点扩展成:
“SimpleDateFormat 为什么线程不安全?请从源码里的 Calendar / NumberFormat 共享状态角度解释”。
这个回答会更像高级面试答案。
可以,这个是 Spring / MyBatis 多数据源 里非常经典的设计。
你可以把它理解成:
在进入业务方法之前,先把“当前应该用哪个数据源”的标识放到
ThreadLocal;后面执行 SQL 时,动态数据源组件再从ThreadLocal里取出这个标识,决定到底走主库、从库,还是某个租户库。
我按 “为什么要这样做 → 整体流程 → 代码实现 → 执行链路 → 注意事项” 给你讲。
1. 先说场景:为什么要动态切换数据源
比如系统里有多个数据源:
master:主库,负责写slave:从库,负责读- 或者:
tenantA_dbtenantB_dbtenantC_db
这时你可能希望:
- 查询走从库
- 新增/修改/删除走主库
- 或者根据租户 ID 走不同数据库
比如:
orderService.createOrder() -> master
userService.queryUser() -> slave
tenantService.xxx() -> tenant_01 / tenant_02
但是业务代码里你不想每次都手动写:
if (xxx) {
用A数据源
} else {
用B数据源
}
所以通常会做成:
- AOP 在方法执行前,先决定数据源
- 把“当前数据源标识”存到
ThreadLocal - 后续 JDBC / MyBatis 拿连接时,再根据这个标识去真正选择 DataSource
2. 核心思想:数据源标识放到 ThreadLocal
2.1 为什么放 ThreadLocal
因为一次请求通常是由一个线程处理的。
而同一个请求里,从 Controller -> Service -> Mapper 整个调用链一般都在同一个线程中。
所以只要把当前数据源标识放到这个线程的 ThreadLocal 里:
ThreadLocal<String> -> "master"
那么这个线程后续不管调到哪一层,只要还在同一个线程里,都能拿到这个值。
这就很适合保存“本次调用上下文”。
3. 整体执行流程图
先看全局流程:
Controller / Service 方法调用
│
▼
AOP 拦截方法
│
▼
根据注解/规则决定数据源
│
▼
DataSourceContextHolder.set("slave")
│
▼
执行业务代码
│
▼
MyBatis / JDBC 获取连接
│
▼
AbstractRoutingDataSource.determineCurrentLookupKey()
│
▼
DataSourceContextHolder.get()
│
▼
返回 "slave"
│
▼
选择 slave 数据源执行 SQL
│
▼
方法结束
│
▼
finally 中 remove()
4. 先定义一个 ThreadLocal 上下文类
一般都会先写一个 DataSourceContextHolder。
public class DataSourceContextHolder {
private static final ThreadLocal<String> CONTEXT = new ThreadLocal<>();
public static void setDataSource(String dataSourceKey) {
CONTEXT.set(dataSourceKey);
}
public static String getDataSource() {
return CONTEXT.get();
}
public static void clear() {
CONTEXT.remove();
}
}
作用很简单:
setDataSource("master"):当前线程设置数据源getDataSource():获取当前线程的数据源clear():请求结束清理,防止线程池复用污染
5. 定义一个动态数据源类
Spring 里通常会继承 AbstractRoutingDataSource。
import org.springframework.jdbc.datasource.lookup.AbstractRoutingDataSource;
public class DynamicDataSource extends AbstractRoutingDataSource {
@Override
protected Object determineCurrentLookupKey() {
return DataSourceContextHolder.getDataSource();
}
}
这个方法非常关键:
determineCurrentLookupKey()
Spring 在真正获取数据库连接时,会调用这个方法,问你一句:
“当前应该用哪个数据源?”
而你这里直接返回:
DataSourceContextHolder.getDataSource()
也就是说:
从当前线程的 ThreadLocal 里拿数据源标识。
6. Spring 配置多个数据源
比如有两个数据源:master 和 slave
@Bean
public DataSource masterDataSource() {
return DataSourceBuilder.create()
.url("jdbc:mysql://master-host:3306/test")
.username("root")
.password("123456")
.build();
}
@Bean
public DataSource slaveDataSource() {
return DataSourceBuilder.create()
.url("jdbc:mysql://slave-host:3306/test")
.username("root")
.password("123456")
.build();
}
然后把它们交给动态数据源:
@Bean
public DataSource dataSource() {
DynamicDataSource dynamicDataSource = new DynamicDataSource();
Map<Object, Object> targetDataSources = new HashMap<>();
targetDataSources.put("master", masterDataSource());
targetDataSources.put("slave", slaveDataSource());
dynamicDataSource.setTargetDataSources(targetDataSources);
dynamicDataSource.setDefaultTargetDataSource(masterDataSource());
return dynamicDataSource;
}
这就相当于建立了一张映射表:
"master" -> masterDataSource
"slave" -> slaveDataSource
后面只要 determineCurrentLookupKey() 返回 "slave",就会走从库。
7. AOP 怎么把数据源标识放入 ThreadLocal
通常会配合一个注解,比如:
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface DS {
String value();
}
使用时:
@Service
public class UserService {
@DS("slave")
public User queryUser(Long id) {
return userMapper.selectById(id);
}
@DS("master")
public void saveUser(User user) {
userMapper.insert(user);
}
}
8. AOP 切面实现
@Aspect
@Component
public class DynamicDataSourceAspect {
@Around("@annotation(ds)")
public Object around(ProceedingJoinPoint joinPoint, DS ds) throws Throwable {
try {
// 1. 方法执行前,把数据源标识放入 ThreadLocal
DataSourceContextHolder.setDataSource(ds.value());
// 2. 执行业务方法
return joinPoint.proceed();
} finally {
// 3. 方法结束清理
DataSourceContextHolder.clear();
}
}
}
这里逻辑就是:
进入方法前
如果方法上有:
@DS("slave")
那么 AOP 就会执行:
DataSourceContextHolder.setDataSource("slave");
此时当前线程上下文中就记录了:
当前线程 -> "slave"
执行 mapper 查询时
MyBatis 需要拿数据库连接,Spring 的动态数据源就会调用:
determineCurrentLookupKey()
拿到 "slave",于是选择从库连接。
方法结束
在 finally 里:
DataSourceContextHolder.clear();
清理当前线程上下文,防止线程池线程复用污染。
9. 把整个调用链串起来看一遍
假设有下面的代码:
@DS("slave")
public User getUser(Long id) {
return userMapper.selectById(id);
}
执行过程是这样的:
第一步:调用 getUser(1L)
进入 AOP:
DataSourceContextHolder.setDataSource("slave");
此时当前线程 ThreadLocal 中:
ThreadLocal = "slave"
第二步:执行 userMapper.selectById(1L)
MyBatis 需要获取数据库连接,最终会走到动态数据源:
protected Object determineCurrentLookupKey() {
return DataSourceContextHolder.getDataSource();
}
得到:
"slave"
于是 Spring 就从:
targetDataSources.get("slave")
拿到从库 DataSource。
第三步:SQL 在从库执行
例如:
select * from user where id = 1
这条 SQL 最终是在 slaveDataSource 对应的库上执行。
第四步:方法执行结束
AOP finally 清理:
DataSourceContextHolder.clear();
当前线程的 ThreadLocal 被清空。
10. 为什么这个设计一定要用 ThreadLocal
因为 determineCurrentLookupKey() 执行的时候,已经不在你业务方法里了。
它是在 Spring 底层获取数据库连接时触发的。
也就是说:
- AOP 设置数据源是在 业务方法入口
- 真正选数据源是在 后面执行 SQL 获取连接的时候
这两个动作中间隔了很多层:
Controller
-> Service
-> Mapper代理
-> SqlSession
-> Executor
-> JDBC Connection
你不可能把 "master" / "slave" 这个参数一层层手动往下传。
所以最自然的办法就是:
把“当前线程本次调用的数据源标识”存到 ThreadLocal,整个调用链随时可取。
11. 一个更真实的生产规则:读写分离
比如你定义:
- 查询方法默认走
slave - 增删改方法走
master
你可以写两个注解:
@DS("master")
public @interface Master {
}
@DS("slave")
public @interface Slave {
}
然后:
@Service
public class OrderService {
@Slave
public Order queryOrder(Long id) {
return orderMapper.selectById(id);
}
@Master
@Transactional
public void createOrder(Order order) {
orderMapper.insert(order);
}
}
这样就很清晰了。
12. 再讲一个面试里经常追问的问题:为什么要在 finally 里 clear()
因为线程池线程会复用。
假设线程 T1 上一次执行了:
DataSourceContextHolder.setDataSource("slave");
但没有清理。
下一次线程池又把 T1 分配给另一个本该走 master 的任务,结果它可能还残留着旧值 "slave",这就会导致:
- 写请求跑到从库
- 租户 A 的请求跑到租户 B 的库
- 数据错乱
所以必须:
try {
DataSourceContextHolder.setDataSource("slave");
return joinPoint.proceed();
} finally {
DataSourceContextHolder.clear();
}
这和前面讲 ThreadLocal 的使用规范完全一致。
13. 一个非常重要的坑:事务和动态数据源的顺序问题
这个点面试很爱问,而且生产里也确实容易出 bug。
问题是什么?
如果你用了:
@Transactional@DS("slave")
那么必须保证在事务开启之前,就已经把数据源切换好。
因为事务一旦开启,Spring 可能已经拿到了数据库连接。
如果连接已经从 master 拿出来了,你后面再切换 ThreadLocal 为 slave,也没用了——连接已经定了。
正确理解
必须是这样的顺序:
先执行数据源切面,设置 ThreadLocal = slave
↓
再开启事务 / 获取连接
↓
事务从 slave 获取连接
所以在项目里,动态数据源切面的优先级通常要高于事务切面。
例如:
@Aspect
@Order(-1) // 比事务切面更早执行
@Component
public class DynamicDataSourceAspect {
...
}
否则可能出现:
- 明明标了
@DS("slave") - 实际还是走了
master
14. 如果是多租户按租户切库,也是同样思路
比如有:
- tenant_1_db
- tenant_2_db
- tenant_3_db
用户请求进来时先拿到租户 ID:
String tenantId = request.getHeader("tenant-id");
然后在拦截器 / AOP 中:
DataSourceContextHolder.setDataSource("tenant_" + tenantId);
后面查询时动态数据源就会自动切到:
- tenant_1
- tenant_2
- tenant_3
所以本质上不只是“主从切换”,也可以用于:
- 多租户分库
- 分环境路由
- 不同业务库路由
15. 面试里你可以这样总结
你可以直接按这套说:
在 Spring 动态数据源切换里,通常会把“当前线程应该使用哪个数据源”的标识放到
ThreadLocal中。
原因是一次请求从 Controller 到 Service 再到 Mapper,通常都在同一个线程里执行,而真正选择数据源是在底层获取数据库连接时完成的,中间隔了很多层调用,不适合通过方法参数逐层传递,所以用ThreadLocal保存本次线程上下文最合适。
实现上一般会先定义一个DataSourceContextHolder,内部通过ThreadLocal<String>保存数据源 key,比如master、slave或某个租户库名。然后自定义一个继承AbstractRoutingDataSource的动态数据源,在determineCurrentLookupKey()方法中从ThreadLocal取出数据源标识,并返回给 Spring,Spring 再根据这个 key 找到真正的 DataSource。
在业务层通常配合 AOP 和自定义注解,例如@DS("slave")。AOP 在方法执行前把slave放入ThreadLocal,执行结束后在finally中清理。这样后续 MyBatis 执行 SQL 时,就会自动走到从库。
需要注意两点:第一,必须在finally中remove(),避免线程池复用造成数据源污染;第二,动态数据源切面的执行顺序要早于事务切面,否则事务先拿到连接,后续再切换数据源就失效了。
如果你愿意,我可以继续往下讲一个非常高频的面试追问:
“如果一个方法上标了 @Transactional,方法内部又调用另一个 @DS("slave") 方法,会不会真的切到从库?”
这个问题会直接涉及:
- 事务传播
- 连接绑定
- 为什么“事务里切数据源可能失效”
- 主从切换在事务中的真实行为
这个在面试里命中率很高,我可以直接给你画执行链路。
浙公网安备 33010602011771号