ThreadLocal专题二

SimpleDateFormat是线程不安全的,请用threadLoca解决这个问题
 

可以。这个问题在面试里通常要回答 “为什么 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();
        }
    }
}
 

这样做的好处:

  1. 避免线程池线程长期持有 SimpleDateFormat
  2. 避免 ThreadLocal 相关的内存泄漏问题
  3. 形成规范: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);
    }
}
 

所以如果面试官问:

生产里你会怎么做?

你可以答:

  • 老项目 / 必须用 DateSimpleDateFormat:可以用 ThreadLocal<SimpleDateFormat> 解决线程安全问题
  • 新项目 / Java 8+:优先用 DateTimeFormatter

10. 最后给你一个“面试可直接背”的完整答案

SimpleDateFormat 是线程不安全的,因为它内部维护了可变状态,例如 Calendar,在 formatparse 时会修改这些状态。如果多个线程共享同一个 SimpleDateFormat 实例,就可能导致日期格式化或解析结果错乱。
一个常见解决方案是使用 ThreadLocal<SimpleDateFormat>,为每个线程提供一个独立的 SimpleDateFormat 实例。这样每个线程只操作自己的实例,不会发生并发冲突。
代码上通常会写成:
private static final ThreadLocal<SimpleDateFormat> TL = ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"));
formatparse 时通过 TL.get() 获取当前线程的格式化对象。
另外,如果是在使用线程池的场景下,使用完成后要调用 remove(),避免线程复用带来的内存泄漏问题。
不过在 Java 8 及以后,生产中更推荐使用线程安全的 DateTimeFormatter,这样就不需要 ThreadLocal 了。


如果你愿意,我可以继续把这个问题再往面试深一点扩展成:
“SimpleDateFormat 为什么线程不安全?请从源码里的 Calendar / NumberFormat 共享状态角度解释”
这个回答会更像高级面试答案。

 
数据库动态数据源切换 在 AOP 中把数据源标识放入 ThreadLocal 可以解释下吗?
 

可以,这个是 Spring / MyBatis 多数据源 里非常经典的设计。
你可以把它理解成:

在进入业务方法之前,先把“当前应该用哪个数据源”的标识放到 ThreadLocal;后面执行 SQL 时,动态数据源组件再从 ThreadLocal 里取出这个标识,决定到底走主库、从库,还是某个租户库。

我按 “为什么要这样做 → 整体流程 → 代码实现 → 执行链路 → 注意事项” 给你讲。


1. 先说场景:为什么要动态切换数据源

比如系统里有多个数据源:

  • master:主库,负责写
  • slave:从库,负责读
  • 或者:
    • tenantA_db
    • tenantB_db
    • tenantC_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 配置多个数据源

比如有两个数据源:masterslave

 
@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 拿出来了,你后面再切换 ThreadLocalslave,也没用了——连接已经定了。


正确理解

必须是这样的顺序:

 
先执行数据源切面,设置 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,比如 masterslave 或某个租户库名。然后自定义一个继承 AbstractRoutingDataSource 的动态数据源,在 determineCurrentLookupKey() 方法中从 ThreadLocal 取出数据源标识,并返回给 Spring,Spring 再根据这个 key 找到真正的 DataSource。
在业务层通常配合 AOP 和自定义注解,例如 @DS("slave")。AOP 在方法执行前把 slave 放入 ThreadLocal,执行结束后在 finally 中清理。这样后续 MyBatis 执行 SQL 时,就会自动走到从库。
需要注意两点:第一,必须在 finallyremove(),避免线程池复用造成数据源污染;第二,动态数据源切面的执行顺序要早于事务切面,否则事务先拿到连接,后续再切换数据源就失效了。


如果你愿意,我可以继续往下讲一个非常高频的面试追问

“如果一个方法上标了 @Transactional,方法内部又调用另一个 @DS("slave") 方法,会不会真的切到从库?”

这个问题会直接涉及:

  • 事务传播
  • 连接绑定
  • 为什么“事务里切数据源可能失效”
  • 主从切换在事务中的真实行为

这个在面试里命中率很高,我可以直接给你画执行链路。

 
 
 
 
 
 
 

posted on 2026-07-08 11:49  日思日睿  阅读(9)  评论(0)    收藏  举报