ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

Spring Singleton线程安全深度剖析:从单例模式到三级缓存实战

2026/10/6 17:03:03 拓冰建站 浏览量
Spring Singleton线程安全深度剖析:从单例模式到三级缓存实战 Spring的singleton到底线程安全吗这个话题几乎是我面试Java后端时的必问题也是线上事故群里反复出现的“元凶”之一。我见过不少团队代码review时看到Bean里放了个HashMap就放行了结果压测跑到第三轮开始丢数据、串状态。所以这篇文章我就从实战视角把话说透Spring singleton容器做了什么、没做什么它和《设计模式》里的单例模式到底哪不一样有状态Bean上线前该怎么处理以及Spring自己的三级缓存和循环依赖机制里又有什么线程隐患。文章适合三类人看准备面试的候选人、正在排查线上偶发并发问题的一线开发以及刚接触Spring不久、想彻底搞懂“单例”这个概念的新人。我不会扯太多源码背诵主要讲清楚底层逻辑和能落地的方案。1. Spring singleton与GoF单例模式名字相同内核完全不同先说结论Spring的singleton和《设计模式》里的单例模式只是中文翻译撞了车它们本质上就不是一个层面的东西。1.1 一个“类”的单例一个“容器”的单例GoF单例模式是一种创建型设计模式核心目标是“一个类在整个进程中只能有一个实例”。实现方式通常是私有构造器加静态方法类自己负责维护唯一实例public class OrderNoGenerator { private static final OrderNoGenerator INSTANCE new OrderNoGenerator(); private OrderNoGenerator() { } public static OrderNoGenerator getInstance() { return INSTANCE; } }这种写法是“类自身约束自己”从构造器上就杜绝了外部new出第二个实例的可能。为了防止反射破坏、序列化破坏还得加一堆防御代码。Spring的singleton则完全不同。它指的是“在同一个Spring容器中一个Bean定义只创建一个实例”这个实例由IoC容器统一管理并缓存。你的Bean类本身可以长得很普通不需要私有构造器不需要静态实例甚至可以在代码里随意newService public class OrderService { // 这个类里没有任何单例模式的代码 }Spring只是在背后做了这样一件事容器启动时创建一个OrderService对象放进缓存后续任何地方注入OrderService拿到的都是同一个对象。类本身根本感知不到自己是“单例”。打个比方GoF单例是“全楼只有一把钥匙钥匙藏在门口地垫下谁要用谁自己拿”Spring的singleton是“物业统一管理钥匙你来前台领就给你同一把”。前者是住户自己定的规矩后者是物业定的规矩两者不再一个管理维度上。1.2 生命周期与作用域边界不止是“创建一次”另一个容易混淆的点是生命周期。GoF单例的生命周期基本和JVM进程绑定确切地说是和类加载器绑定。一个类加载器里最多一个实例类加载完成后实例就常驻内存你很难主动销毁它。Spring的singleton生命周期则由ApplicationContext控制。容器启动时创建或首次访问时懒加载容器关闭时统一销毁。而且“同一个类在不同容器里可以各自有一个单例”两个Spring容器互不干扰。也就是说Spring单例的“唯一性”只在一个容器边界内成立。创建时机也有明显差异。GoF单例通常是第一次调用getInstance时创建懒加载Spring默认在容器启动阶段就完成非懒加载单例Bean的实例化真正跑业务时对象已经准备好了。如果你用Lazy标注则会在第一次被使用时才创建。还有一个很典型的对比GoF单例的实例获取方式和生命周期的管理全部写在类内部Spring的Bean完全不用关心自己是单例还是原型Scope去掉容器照样用。这带来的工程化收益是——类的代码更干净控制权收归框架。1.3 对比一把一眼看清差异对比维度GoF单例模式Spring singleton维度层级代码设计模式类自约束容器作用域对象复用约定实现方式私有构造器 静态实例 静态方法Bean定义交给容器容器创建并缓存唯一性边界类加载器级别Spring容器级别创建时机首次getInstance懒加载为主容器启动或首次依赖Lazy生命周期管理类自身静态持有基本不可控容器统一管理支持初始化/销毁回调线程安全只保证“实例唯一”不保证内部状态安全只保证“引用一致”不保证内部状态安全注意表格最后一行无论GoF单例还是Spring的singleton都只管“唯一”不管“安全”。这是很多人踩坑的根源——误以为“单例了就安全了”。2. 线程安全的第一课singleton本身不背锅要搞清Spring singleton线程安全问题先得理解线程安全到底指什么。2.1 判等公式共享 可变 危险线程安全的标准表述是多个线程同时访问某个共享状态时不需要额外协调程序行为依然正确。这串定义拆开看有三个关键词多个线程、共享状态、同时访问。只要有一个不满足就不存在所谓线程安全问题。singleton带来的只是“共享”——所有线程拿到的都是同一个对象引用。但如果这个对象内部没有可变的共享数据那它天然就是线程安全的。我用一个公式概括线程不安全 共享状态 可变状态 并发读写singleton只是放大了“共享”这个前提真正决定安全与否的是对象的内部状态设计。2.2 无状态Bean天然安全实际项目里Controller、Service、Mapper这类对象绝大多数都是无状态的。所谓无状态就是类里没有可变的成员字段只有方法参数和局部变量RestController public class UserController { private final UserService userService; public UserController(UserService userService) { this.userService userService; } public Result getUser(Long id) { User user userService.findById(id); return Result.ok(user); } }这个Controller虽然是singleton但它内部只有final的UserService引用不随请求变化。每个线程调用getUser方法时参数id和局部变量user都分配在各自的线程栈里线程之间完全隔离。所以这种Bean一万个线程同时访问也没事。这就是为什么很多人说“Spring单例是安全的”——因为常用Bean基本都是无状态的。这个说法不算全错但它把因果关系搞反了安全是因为没有共享可变状态不是因为singleton提供了保护机制。2.3 有状态Bean的三类“雷”一旦有人在singleton里放了可变状态情况就完全不同。我列举三个经典场景。第一类简单成员变量自增。Service public class SeqGenerator { private int seq 0; public int next() { return seq; } }这里有相当大的误导性。seq确实只有一个但seq在底层是“读旧值、加一、写回”三步操作不是原子的。两个线程同时读到100同时写回101结果你是丢了两次计数。换成long也一样volatile也只保证可见性不保证读改写原子性。第二类成员集合被并发写。Service public class CacheService { private final MapString, OrderInfo orderCache new HashMap(); public void cache(String orderId, OrderInfo info) { orderCache.put(orderId, info); } }HashMap在多线程并发put时可能出现数据覆盖、size不准极端情况下还会在扩容时出环。JDK8以后不至于死循环了但并发丢失更新依旧存在。第三类非线程安全的格式化工具。Service public class DateFormatService { private final SimpleDateFormat sdf new SimpleDateFormat(yyyy-MM-dd HH:mm:ss); public String format(Date date) { return sdf.format(date); } }SimpleDateFormat内部维护了一个可变的Calendarformat和parse并发调用时会互相篡改内部状态线上表现就是偶发NumberFormatException、ArrayIndexOutOfBoundsException或者解析出完全错误的字符串。这个问题在早期的电商、导出、对账系统里属于高发事故。2.4 为什么“局部变量”天然安全理解了三类雷再回头看安全的本质。线程的栈是独立分配的局部变量和参数都活在栈上每个线程调用一次方法就有一份独立的变量副本不存在跨线程共享问题。所以把可变数据尽量放方法内部是最简单也最彻底的线程安全方案。singleton本身不背锅问题出在“你想让一堆线程共用同一个对象又往对象肚子里塞了会变的东西”。3. 有状态单例Bean的三套实战术ThreadLocal、并发容器、不可变设计如果业务确实需要一个有状态的singleton Bean怎么办我常用的处理方式就三套按场景搭配使用。3.1 ThreadLocal把共享状态变成线程私有ThreadLocal的适用场景是那些“逻辑上属于当前请求/当前线程但恰好要穿过多个方法”的数据。典型有链路追踪ID、用户上下文、事务上下文、多租户标识。正确写法大概是这样Component public class TraceContext { private static final ThreadLocalString TRACE_ID new ThreadLocal(); public static void set(String traceId) { TRACE_ID.set(traceId); } public static String get() { return TRACE_ID.get(); } public static void clear() { TRACE_ID.remove(); } }使用的时候入口设置、出口清理必须在finally里removepublic void handleRequest(HttpServletRequest request) { TraceContext.set(request.getHeader(X-Trace-Id)); try { // 后续逻辑全部能拿到当前请求的traceId } finally { TraceContext.clear(); } }这里特别强调清理这一步因为Web容器和业务线程池都是复用线程的。如果不remove下一个请求会读到上一个请求留下的TraceId线上表现就是链路追踪串号、日志排查错乱。这不是理论风险是真实发生过的事故。ThreadLocal还有一个经典认知它解决的是“线程私有”问题不是“跨线程传递”问题。异步任务、子线程不会自动继承父线程的ThreadLocal值。想传递就得显式传参、用InheritableThreadLocal同样要小心线程池或者借助日志框架的MDC。另外ThreadLocal的内存泄漏不是危言耸听。里面是Map结构key是ThreadLocal对象的弱引用value是强引用。只要线程还活着线程池里的线程基本常驻即使ThreadLocal对象已经被回收value依然挂在ThreadLocalMap里。所以“用完remove”不是可选项而是必选项。3.2 并发容器与原子类把不安全的“普通状态”换成并发工具箱如果状态确实需要跨线程共享那就得用并发容器来承载。计数器、序列号AtomicInteger、AtomicLong或者高并发写场景下的LongAdder。KV缓存ConcurrentHashMap替代HashMap。读多写极少的列表CopyOnWriteArrayList替代ArrayList。简单同步集合Collections.synchronizedList适合小规模低频操作。选型不是越高级越好得看访问特征。我整理一张速查表业务场景推荐方案不推荐统计次数、生成序列AtomicLong / LongAdder普通long加volatile高频并发写入的缓存ConcurrentHashMapHashMap、TreeMap读多写极少的列表CopyOnWriteArrayListsynchronizedList简单容器整体替换volatile引用指向不可变对象synchronized遍历大集合多字段聚合更新ReentrantLock 显式锁分散的原子类各自为战这里有个很容易答错的细节点LongAdder和AtomicLong怎么选。AtomicLong在高并发下CAS竞争激烈自旋消耗CPULongAdder把共享计数拆成多个单元各线程分散累加最后sum()汇总适合“写多、读少、允许最终一致偏高”的场景。如果计数器要求每次读取都精确LongAdder强求的话反而可能读到分片中间态需要小心。3.3 把可变状态“挤”到方法内部这一招很多人忽略但却是最优雅的尽量让singleton Bean变成“无状态的服务类”所有业务数据都走方法参数和返回值传递。Service public class DateFormatService { // DateTimeFormatter是线程安全的因为它是不可变对象 private static final DateTimeFormatter FORMATTER DateTimeFormatter.ofPattern(yyyy-MM-dd HH:mm:ss); public String format(LocalDateTime time) { return FORMATTER.format(time); } }DateTimeFormatter为什么安全因为它被设计成了不可变对象内部没有任何可变状态。这就是“不可变设计”的力量。同样思路还有用LocalDate取代Date用String代替StringBuilder作为共享值用一次性DTO而不是复用可变实体。遇到不可变对象后singleton Bean里只剩下final引用和不可变配置整个类就变成纯逻辑服务线程安全自然成立。3.4 什么时候真的需要上锁最后兜底方案是锁。场景特点是多个操作步骤必须作为一个整体原子完成只用并发容器无法表达“跨步骤不变式”。比如一个价格规则Bean需要同时更新两条规则且保证更新期间读取者看到的是同一个旧版本或同一个新版本Service public class PriceRuleManager { private PriceRule currentRule; private final Object lock new Object(); public PriceRule getCurrentRule() { return currentRule; } public void updateRule(PriceRule newRule) { synchronized (lock) { this.currentRule newRule; } } }上锁不是问题问题是锁的粒度。千万不要持锁调远程服务、不要持锁做数据库操作、不要把整个大方法都塞进synchronized里。锁的范围越大吞吐下降越明显还可能因为外部接口超时把整个线程池拖死。锁的选择上单体应用synchronized通常就够了需要尝试获取锁、超时控制、公平性才考虑ReentrantLock。注意try/finally释放锁避免异常导致锁无法释放。4. Spring容器自己的并发微妙之处三级缓存与循环依赖说完Bean内部状态再聊Spring容器自己。很多人不知道Spring在创建单例Bean的过程中也存在“半成品对象被提前看到”的并发窗口。这就要说到热词里很火的“spring三级缓存原理”。4.1 三级缓存分别存什么Spring默认单例Bean的创建流程本质上是一个三级缓存协作过程一级缓存 singletonObjects存放已经完整创建好的单例Bean。二级缓存 earlySingletonObjects存放提前暴露出来的“早期单例”也就是还没完成属性填充或初始化的半成品。三级缓存 singletonFactories存放单例工厂对象一个工厂负责在需要时生成“早期引用”。正常情况下Bean的创建是线性的实例化、属性填充、初始化、最终放入一级缓存。没有循环依赖时二级、三级缓存根本不会发挥作用。一旦出现循环依赖比如A依赖B、B依赖A流程就变成了A先实例化把自己包装成ObjectFactory放进三级缓存接着填充属性时发现需要B于是去创建BB实例化后填充属性时发现需要A此刻A还没创建完成但三级缓存里有A的工厂。B通过工厂拿到A的早期引用放到二级缓存并注入自己完成B的创建B创建完成后A再拿到B的引用继续完成初始化最终由Spring把A放入一级缓存。4.2 三级缓存为什么不用两级为什么需要“工厂”这一层而不是在A实例化后直接把A放进二级缓存核心原因是为了给AOP代理留出介入空间。如果A最终需要被AOP代理那么B注入的应该也是代理对象而不是原始对象。三级缓存存放一个ObjectFactory在getEarlyBeanReference时可以让BeanPostProcessor提前生成代理。如果固定把原始对象放进二级缓存AOP后置处理器就没法在循环依赖场景下统一替换引用最后会出现A自己拿到的和B拿到的不一致。我之前在自研中间件时踩过这个坑自定义BeanPostProcessor里对早期引用处理不当导致依赖方拿到原始对象而最终容器拿到代理对象线上调用直接走到原始逻辑上顺了一遍才发现是代理时机错乱。4.3 半成品对象的并发窗口Spring对单例池的读取用synchronized做了同步控制所以同一个Bean不会被并发创建两次。这一点Spring做得很好不需要开发者重复造轮子。但三级缓存机制里有个微妙的窗口B在创建过程中从三级缓存拿到A的早期引用时A的属性还没填充完全。如果此时有另一个线程也感知到A在创建中并通过某种途径拿到了这个早期引用直接调用A的业务方法理论上就会读到A的半成品状态。Spring用singletonsCurrentlyInCreation集合和创建状态标记“正在创建中”的Bean加上单例锁的串行化把大部分并发访问挡在门外。但这不是绝对的“线程安全保证”更像是一个“为了打破循环依赖而打开的安全阀”。所以业内强烈推荐优先使用构造器注入原因不只是编码风格更在于构造器注入要求依赖对象完整创建后才能注入一旦出现循环依赖会直接抛异常逼着你重构掉循环关系而不是悄悄依赖三级缓存这个特殊机制。字段注入和setter注入虽然“灵活”但把问题藏起来了。4.4 顺带说说AOP代理和提前暴露在循环依赖里如果A需要被代理开发者的直觉是“A要不被代理B注入了A后面A又做了代理那B拿到的不就错了吗”Spring早就想到这一点。A的早期引用通过getEarlyBeanReference生成如果存在AbstractAutoProxyCreator会在早期阶段就生成A的代理对象并让B注入这个代理。后续A真正完成初始化时Spring会检查二级缓存里是否已经存在早期引用如果存在就直接复用保证容器里只有一个最终对象。这意味着B拿到的A引用与最终容器里缓存的是同一个对象。这份一致性是Spring维护单例不变量的一部分。但在并发场景下这个“提前生成的代理”还没有经历完整的初始化阶段如果你在A的初始化方法里有复杂的资源加载逻辑B所在的线程很可能在A完全初始化完成之前就触达了A的方法。大多数业务代码不会出问题但如果你在写基础设施类组件这几个毫秒的窗口期值得留意。4.5 构造器注入为什么更安全顺理成章地我们就能推出一个建议能用构造器注入就不要用字段注入或setter注入。构造器注入的好处是多线程视角下的“对象完整性”——构造器执行完对象就是一个完整可用状态没有中间过渡期。这也解释了为什么Spring官方文档和社区都持续推进构造器注入。这不仅是风格选择更像一种防御性设计从源头减少“拿到半成品对象”的机会。如果你非要用字段注入代码虽然短但对象的创建被Spring分成了“实例化”和“注入”两步中间状态和并发访问的可能性都增加了。5. Spring singleton线程安全问题排查实录最后聊点实战排查经验。遇到Bean并发问题很多人的第一反应是“把Bean改成prototype”但prototype解决不了所有问题——原型Bean如果有多个线程同时new出来并共享同一个外部可变对象风险一点没少。认清这一点很重要。5.1 常见问题速查表现象常见根因推荐修复SimpleDateFormat偶发解析异常共享可变DateFormat换DateTimeFormatterHashMap并发写后size不对成员Map被多线程putConcurrentHashMap计数器跳号、重复非原子AtomicLong / LongAdderThreadLocal取到脏数据线程池复用未清理finally里remove启动报BeanCurrentlyInCreation构造器注入循环依赖重构依赖避免相互构造不同请求数据互相串号成员变量存了请求局部数据改方法参数或scoperequest接口偶发空指针半初始化对象被提前访问构造器注入避免循环依赖5.2 从现象到根因的排查步骤第一步先确认问题是不是“共享类”引起的。默认Scope就是singleton如果一个Bean有成员字段它天然在多个线程间共享。第二步扫类里的字段。非static final的实例字段特别是集合、时间格式化器、自增计数都进入嫌疑名单。有些IDE和静态检查工具能直接标出“可变成员字段在单例类中使用”的风险值得开起来。第三步复现。偶发问题最难排查我刚工作时常靠多跑几轮看运气。后来养成习惯用并发工具把请求压力打到固定入口int threads 16; int requestsPerThread 1000; ExecutorService pool Executors.newFixedThreadPool(threads); CountDownLatch startGate new CountDownLatch(1); CountDownLatch endGate new CountDownLatch(threads * requestsPerThread); for (int i 0; i threads; i) { pool.submit(() - { startGate.await(); for (int j 0; j requestsPerThread; j) { service.findById(...); endGate.countDown(); } }); } startGate.countDown(); endGate.await();高并发下很容易让普通HashMap、SimpleDateFormat、自增变量之类的问题快速浮出水面。第四步定位变量归属。在可疑方法的入口和出口打印Thread.currentThread().getName()和关键字段值看是否存在跨线程覆盖。更省力的做法是用arthas的thread命令观察现场或者通过Actuator的metrics和线程池监控看接口异常率、RT分布。第五步修复后有条件就做一次压测回归。重点观察错误率、数据一致性确认锁粒度没伤吞吐。5.3 一条实操经验别急着“加锁”我偏好的顺序是先看能不能局部化变量再看能不能换并发容器最后才考虑锁。比如之前一个订单号生成器并发下出现了重复单号。一开始有人提议加synchronized但仔细分析后发现这个操作只是“获取下一个序号”直接换AtomicLong就解决了根本不需要锁。还有一个导出服务把SimpleDateFormat放在Service层共用偶发报错换成DateTimeFormatter后一劳永逸。两件事的共同点是问题本质都出在“共享可变状态”而不是“共享”本身。所以排查时不要一上来就锁方法先问自己一句这份数据真的需要被多个线程共享吗如果答案是需要再考虑用并发容器还是锁如果答案是不需要那就把数据从成员变量挪到方法参数里。最后再说点个人体会这个问题我面试时几乎必问。其实作为面试官最想听的未必是源码级细节而是两件事第一能不能分清“容器级单例”和“设计模式单例”的区别第二面对有状态单例Bean时是能说出ThreadLocal、并发容器、不可变设计这些真实工具箱还是只会背一句“Spring单例是线程安全的”。我看到太多候选人可以背三级缓存源码回到自己项目里照样在Service里new SimpleDateFormat。根据我个人经验Spring官方文档早就说得很明白singleton作用域只是保证“每个容器中同一个Bean定义只有一个实例”它不是线程安全的作用域。框架把对象创建、装配、代理、销毁这些麻烦事解决了但业务状态的一致性责任始终在写代码的人手里。想清楚这一点这类问题基本就不会再缠着你了。