1. 项目概述:为什么一个看似简单的单例类,会在多线程环境下突然“失灵”
“Thread Safety in Java Singleton Classes”——这个标题乍看像教科书里的一个章节小节,但如果你在真实项目里写过单例、改过单例、或者被单例的诡异行为半夜叫醒过,你就会知道,它根本不是理论题,而是压在生产环境上的一块实打实的砖。我第一次遇到这个问题是在一个电商秒杀系统的库存服务里,用的是最经典的“饿汉式”单例,本地测试一切正常,一上预发环境,库存扣减就出现负数。排查三天,最后发现是单例内部缓存的库存Map没加锁,两个线程同时读-改-写,覆盖了彼此的更新。那一刻我才真正明白: 单例本身不等于线程安全,它只是把“唯一性”交给了JVM,而“一致性”还得靠你自己一砖一瓦垒起来。
这个标题背后藏着Java开发者绕不开的三重现实:第一,单例是设计模式里最常用、也最容易被滥用的模式;第二,Java多线程是面试必考、线上必踩的深水区;第三,“synchronized”这个词,90%的人能背出语法,但不到30%的人能在复杂场景下准确判断它该加在哪、加几层、加完会不会拖垮性能。所以这篇内容不是讲“怎么写一个单例”,而是讲“怎么写出一个在高并发、长生命周期、混合调用链路下依然坚挺的单例”。它适合三类人:刚学完《Java编程思想》第12章、正准备面试的应届生;写了三年CRUD、第一次接手支付模块、被并发问题搞得焦头烂额的中级开发;还有那些带团队的技术负责人——你可能不亲手写单例,但你得一眼看出下属提交的代码里,那个“双重检查锁定”的volatile到底有没有漏掉。
核心关键词“Thread Safety”和“Singleton”在这里不是并列关系,而是因果关系: 单例是目标,线程安全是前提。 没有线程安全的单例,就像没有地基的房子,建得再漂亮,风一吹就倒。而“synchronized”只是实现线程安全的一种工具,不是银弹,更不是万能膏药。接下来我会带你一层层剥开这个看似简单的问题:从JVM类加载机制如何天然保障饿汉式的“安全假象”,到懒汉式里new操作的非原子性如何成为并发漏洞的温床;从synchronized的字节码级锁升级过程,到为什么在静态方法上加synchronized和在实例方法上加,效果天差地别;最后落到真实业务场景——比如一个日志收集器单例,既要保证初始化时的线程安全,又要保证运行时写入日志的线程安全,还要避免锁竞争导致吞吐量暴跌。所有内容,都基于我过去十年在金融、电商、IoT三个领域踩过的坑、修过的bug、压测过的数据。
2. 单例模式的四种经典实现与线程安全本质拆解
2.1 饿汉式:用空间换时间的“静态安全”,但代价是初始化即加载
饿汉式单例的代码简洁得让人安心:
public class EagerSingleton {
private static final EagerSingleton instance = new EagerSingleton();
private EagerSingleton() {}
public static EagerSingleton getInstance() {
return instance;
}
}
表面看,它“线程安全”得毫无争议——instance是static final,JVM在类加载的“初始化”阶段(clinit)就完成了对象创建,而JVM规范明确要求: 同一个类的clinit方法,在多线程环境下只会被一个线程执行,其他线程必须阻塞等待。 这就是它“天然线程安全”的底层原理。但这种安全是有严格前提的:它只保障了“单例对象被创建出来”这一件事的安全, 绝不保障对象内部状态的安全。 我曾在一个风控系统里见过这样的代码:
public class RiskRuleEngine {
private static final RiskRuleEngine instance = new RiskRuleEngine();
private final Map<String, Rule> ruleCache = new HashMap<>(); // 问题就在这里!
private RiskRuleEngine() {
loadRulesFromDB(); // 从数据库加载规则
}
private void loadRulesFromDB() {
// 假设这里用了JDBC,查出100条规则
List<Rule> rules = jdbcTemplate.query("SELECT * FROM risk_rules", ...);
for (Rule rule : rules) {
ruleCache.put(rule.getId(), rule); // 多线程同时调用?HashMap直接GG
}
}
public Rule getRule(String id) {
return ruleCache.get(id); // 无锁读取,没问题
}
}
这段代码在单线程下完美运行,但一旦部署到Web容器(如Tomcat),多个Servlet线程同时触发类加载(比如第一次访问不同URL路径),
loadRulesFromDB()
就可能被多个线程并发执行,
ruleCache
作为非线程安全的HashMap,会因扩容时的rehash操作导致死循环(JDK 7)或数据丢失(JDK 8+)。
所以饿汉式的“线程安全”是虚假的繁荣,它只解决了“创建”这一个点,却把“初始化内部状态”这个更大的雷,留给了开发者自己去排。
正确做法是:要么把
ruleCache
换成
ConcurrentHashMap
,要么在
loadRulesFromDB()
方法上加
synchronized
,要么——更推荐——把规则加载逻辑从构造函数里剥离,放到一个显式的、带锁的
init()
方法里,由应用启动时统一调用。
提示:饿汉式最大的硬伤是“初始化即加载”,哪怕你的单例在整个应用生命周期里一次都没被用到,它也会在类加载时消耗内存和CPU。在微服务架构下,一个包含几十个饿汉式单例的starter包,会显著拖慢服务启动速度。我们团队在一次全链路压测中发现,某个监控SDK的饿汉式MetricsCollector占用了启动时间的17%,后来改成懒汉式+延迟初始化,启动时间从3.2秒降到1.9秒。
2.2 懒汉式(基础版):最危险的“看起来很美”,new操作的非原子性是根源
懒汉式试图解决饿汉式的资源浪费问题,代码同样简洁:
public class LazySingleton {
private static LazySingleton instance;
private LazySingleton() {}
public static LazySingleton getInstance() {
if (instance == null) { // Step 1: 检查是否为空
instance = new LazySingleton(); // Step 2: 创建新实例
}
return instance;
}
}
这段代码在单线程下毫无问题,但在多线程下,它是典型的“竞态条件(Race Condition)”教科书案例。问题出在
new LazySingleton()
这行代码上。JVM执行它实际分为三步:
- 分配内存空间 (给对象分配一块内存)
- 初始化对象 (调用构造函数,设置字段值)
- 将引用赋值给instance变量 (让instance指向这块内存)
关键在于:
步骤2和步骤3在JVM层面没有happens-before关系,编译器和处理器为了优化性能,可能发生指令重排序!
也就是说,线程A可能执行了步骤1(分配内存)和步骤3(赋值instance),但还没来得及执行步骤2(初始化对象),此时instance已经不为null了。线程B恰好在此刻进入
if (instance == null)
判断,发现instance不为null,直接返回这个“半成品”对象。当线程B调用这个对象的方法时,极大概率触发
NullPointerException
,因为对象的字段还是默认值(0、null、false)。
这就是为什么基础懒汉式在任何严肃的生产环境中都绝对禁止使用。它不是一个“可能出错”的问题,而是一个“必然在高并发下出错”的定时炸弹。我见过最惨的一次事故,是某银行核心交易系统的一个配置管理单例,因为用了这种写法,上线后每小时出现1-2次交易失败,错误日志里全是NPE,但因为概率低、复现难,排查了整整一周才定位到根源。
2.3 双重检查锁定(DCL):最常被误用的“高级技巧”,volatile是它的生命线
为了解决懒汉式的竞态问题,又不想牺牲性能(每次调用都加锁太重),双重检查锁定(Double-Checked Locking)应运而生。它的代码看起来很精巧:
public class DCLSingleton {
private static DCLSingleton instance;
private DCLSingleton() {}
public static DCLSingleton getInstance() {
if (instance == null) { // 第一次检查
synchronized (DCLSingleton.class) {
if (instance == null) { // 第二次检查
instance = new DCLSingleton();
}
}
}
return instance;
}
}
这个模式的精妙之处在于:
只有在instance为null时才进入同步块,而一旦instance被创建,后续所有调用都走无锁的快速路径。
它理论上兼顾了懒加载和高性能。但问题来了:上面这段代码,在JDK 5之前是
完全错误的
!原因正是前面提到的指令重排序。即使加了
synchronized
,它也只能保证同步块内的代码按顺序执行,但无法约束同步块外的
instance = new DCLSingleton()
这行代码的重排序行为。
JDK 5引入了JSR-133内存模型,正式定义了
volatile
关键字的语义:
对volatile变量的写操作,happens-before于后续对该变量的读操作。
这意味着,如果我们将
instance
声明为
volatile
,就能强制JVM禁止对
new DCLSingleton()
的指令重排序,确保“分配内存”、“初始化对象”、“赋值引用”这三步严格按序执行。修正后的正确代码是:
public class DCLSingleton {
private static volatile DCLSingleton instance; // 关键!必须加volatile
private DCLSingleton() {}
public static DCLSingleton getInstance() {
if (instance == null) {
synchronized (DCLSingleton.class) {
if (instance == null) {
instance = new DCLSingleton(); // 现在,这行代码是安全的
}
}
}
return instance;
}
}
注意:
volatile修饰的是instance引用本身,不是它指向的对象。它的作用是建立内存屏障(Memory Barrier),阻止编译器和CPU对instance的读写操作进行重排序。很多开发者以为只要加了synchronized就万事大吉,忽略了volatile这个“画龙点睛”的一笔,结果写的还是个“伪DCL”,在极端压力下依然会崩溃。我们团队有个老规矩:凡是看到DCL代码,第一眼就找volatile,没有就直接打回。
2.4 静态内部类:JVM类加载机制的“神来之笔”,零成本的终极方案
如果说DCL是工程师用智慧和规则对抗硬件的不确定性,那么静态内部类(Static Inner Class)就是直接借用了JVM最可靠的机制——类加载。它的实现堪称优雅:
public class StaticInnerClassSingleton {
private StaticInnerClassSingleton() {}
private static class SingletonHolder {
private static final StaticInnerClassSingleton INSTANCE = new StaticInnerClassSingleton();
}
public static StaticInnerClassSingleton getInstance() {
return SingletonHolder.INSTANCE;
}
}
它的原理非常清晰:
外部类
StaticInnerClassSingleton
被加载时,并不会加载其静态内部类
SingletonHolder
;只有当第一次调用
getInstance()
方法,执行到
return SingletonHolder.INSTANCE
时,JVM才会触发
SingletonHolder
类的加载和初始化。
而JVM对类初始化的保证,和饿汉式一样:
SingletonHolder
的clinit方法是线程安全的,只会被执行一次。因此,
INSTANCE
的创建天然就是线程安全的。
这个方案的优势是碾压级的:
-
绝对线程安全
:依赖JVM最底层、最可靠的机制,无需任何
synchronized或volatile。 -
懒加载
:
INSTANCE只在第一次getInstance()时才创建,完美解决饿汉式资源浪费问题。 -
高性能
:
getInstance()方法没有任何同步开销,是纯粹的字段读取,比DCL的两次判空还快。 - 代码简洁 :没有复杂的锁逻辑,不易出错。
我在多个高并发项目中都把它作为单例的默认实现。唯一需要提醒的是:它只适用于“单例对象创建过程不依赖外部参数”的场景。如果你的单例需要根据配置文件动态决定初始化方式,那静态内部类就无能为力了,这时就得回到DCL或枚举方案。
3. 线程安全的深层维度:单例“创建”之后的“使用”才是主战场
3.1 单例对象内部状态的线程安全:为什么“安全的创建”不等于“安全的使用”
到目前为止,我们讨论的“线程安全”,焦点都集中在“如何保证单例对象被唯一、正确地创建出来”。但这只是万里长征第一步。 一个单例类真正的线程安全挑战,90%都发生在“创建完成之后”的运行时。 想象一个日志记录器单例:
public class Logger {
private static final Logger instance = new Logger();
private final List<String> logBuffer = new ArrayList<>(); // 非线程安全!
private final SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"); // 非线程安全!
private Logger() {}
public static Logger getInstance() {
return instance;
}
public void log(String message) {
String timestamp = sdf.format(new Date()); // 危险!SimpleDateFormat非线程安全
logBuffer.add(timestamp + " - " + message); // 危险!ArrayList非线程安全
}
}
这个类的创建是饿汉式,绝对安全。但它的
log()
方法,在多线程下调用时,会因为
SimpleDateFormat
和
ArrayList
的非线程安全性,导致格式化时间错乱、日志丢失甚至JVM崩溃。
单例的线程安全,必须是一个端到端的保障:从创建、初始化,到每一个公开方法的执行,都要经得起并发考验。
解决方案有几种:
-
无状态化(推荐)
:把
logBuffer和sdf都移除,改为每次调用都创建新的SimpleDateFormat(虽然有开销,但现代JVM GC已足够高效),日志直接写入java.util.logging或Logback等成熟框架。 -
局部变量化
:在
log()方法内声明SimpleDateFormat,让它成为线程私有变量。 -
使用线程安全的替代品
:用
DateTimeFormatter(JDK 8+,不可变、线程安全)替换SimpleDateFormat;用CopyOnWriteArrayList替换ArrayList(适用于读多写少场景)。
实操心得:我在做一次支付网关重构时,发现一个自研的
IdGenerator单例,内部用了一个AtomicLong来生成递增ID,看起来很安全。但后来发现,它还有一个Map<String, Long>用来缓存不同业务线的起始ID,这个Map是HashMap。在压测时,当多个业务线同时首次请求ID,这个Map的并发put操作导致了严重的锁竞争,TPS直接腰斩。最终解决方案是:把Map也换成ConcurrentHashMap,并且给每个业务线的key加了前缀,利用ConcurrentHashMap的分段锁特性,将锁粒度从“全局一把锁”细化到“16个桶”,性能提升了3倍。这说明, 线程安全的优化,永远要从“锁的粒度”入手,而不是盲目加锁。
3.2 synchronized的底层机制与性能真相:从偏向锁到重量级锁的“自动进化”
理解
synchronized
,不能只停留在“加个锁”这个表层。它的性能表现,直接决定了你的单例在高并发下的生死。JVM对
synchronized
做了极其精妙的优化,它不是一个静态的、笨重的锁,而是一个会根据竞争激烈程度“自动进化”的智能体。整个过程分为四个阶段:
-
无锁状态(Unlocked)
:对象刚创建,没有任何线程竞争,
synchronized几乎不产生任何开销。 -
偏向锁(Biased Locking)
:当第一个线程尝试获取锁时,JVM会将对象头的Mark Word标记为“偏向”该线程ID。此后,只要这个线程再次进入同步块,只需检查Mark Word中的线程ID是否匹配,匹配则直接进入,
完全不需要CAS操作!
这是性能最高的状态。偏向锁默认开启(JDK 6+),但可以通过
-XX:-UseBiasedLocking关闭。 - 轻量级锁(Lightweight Locking) :当第二个线程尝试获取已被偏向的锁时,偏向锁会“撤销”(Revoke),升级为轻量级锁。此时,JVM会在当前线程的栈帧中创建一个“锁记录(Lock Record)”,然后用CAS操作将对象头的Mark Word替换为指向该锁记录的指针。如果CAS成功,当前线程获得锁;失败则说明有竞争,进入自旋等待。
-
重量级锁(Heavyweight Locking)
:当轻量级锁的自旋等待超过一定阈值(默认10次,可通过
-XX:PreBlockSpin调整),或者等待线程数过多,JVM会将锁彻底升级为重量级锁。此时,所有未获得锁的线程都会被挂起(OS级别的park),进入阻塞状态。这是性能最低的状态,因为它涉及用户态到内核态的切换。
这个“锁升级”过程是单向的,不可逆。这意味着,
如果你的单例方法在大部分时间都是无竞争的(比如一个配置读取器),那么
synchronized
带来的开销几乎可以忽略不计;但如果你的方法是高频、高竞争的(比如一个计数器),那么它很快就会退化成重量级锁,成为性能瓶颈。
我们曾经在一个实时风控引擎里,把一个
getRiskScore()
方法用
synchronized
保护,结果在QPS 5000时,CPU的sys(系统态)时间飙升到40%,大量线程在内核态排队。后来改用
StampedLock
的乐观读锁,CPU sys时间降到5%,TPS翻倍。
3.3 枚举单例:Joshua Bloch的“终极答案”,但现实世界总有例外
在《Effective Java》第3版中,Joshua Bloch给出了单例的“终极推荐”——枚举(Enum):
public enum EnumSingleton {
INSTANCE;
public void doSomething() {
// 业务逻辑
}
}
枚举单例的线程安全性是JVM语言层面的保证:
枚举类型的实例在类加载时就被创建,并且JVM确保其创建过程的绝对原子性和线程安全。
它还天然防止反序列化攻击(
readResolve()
方法在枚举中无效,因为枚举实例是JVM控制的),代码极度简洁。在绝大多数标准场景下,它确实是最佳选择。
但现实世界的项目,往往比教科书复杂。我遇到过几个“枚举单例无法胜任”的真实案例:
- 需要延迟初始化 :某个AI模型推理服务的单例,其初始化需要加载一个几百MB的模型文件。如果用枚举,服务启动时就会卡住。必须用静态内部类或DCL。
-
需要依赖注入
:在Spring Boot项目中,单例Bean通常需要从容器中注入其他Bean(如
DataSource、RestTemplate)。枚举无法被Spring管理,无法进行依赖注入。 - 需要实现接口或继承类 :枚举类型不能继承其他类(Java不支持多重继承),也不能实现需要被动态代理的接口(如Spring AOP)。一个需要被事务管理的单例DAO,就必须是普通类。
所以, 枚举单例是“理想国”,而静态内部类是“现实世界里的最优解”。 后者在保持了枚举的绝大部分优点(线程安全、懒加载、防反射、防序列化)的同时,保留了普通类的全部灵活性。这也是为什么在我带的团队里,代码规范明确写着:“除非有特殊理由,否则单例一律使用静态内部类实现”。
4. 真实项目中的单例线程安全实战:从代码审查到压测调优
4.1 代码审查清单:5分钟揪出单例里的“线程安全漏洞”
在Code Review中,我有一套针对单例类的快速审查清单,专门用来捕捉那些隐藏在代码深处的线程安全陷阱。这套清单不是凭空而来,而是从我们团队过去三年修复的37个单例相关Bug中提炼出来的:
| 审查项 | 检查要点 | 高危风险示例 | 修复建议 |
|---|---|---|---|
| 1. 创建方式 | 是否使用了基础懒汉式(无锁)? |
if (instance == null) instance = new X();
| 立即否决,强制改为DCL(带volatile)或静态内部类 |
| 2. volatile关键字 |
DCL实现中,
instance
字段是否声明为
volatile
?
|
private static Instance instance;
(缺少volatile)
|
补上
volatile
,并解释其必要性
|
| 3. 内部可变状态 |
单例内部是否有非线程安全的集合(
ArrayList
,
HashMap
,
LinkedList
)?
|
private List<String> cache = new ArrayList<>();
|
替换为
CopyOnWriteArrayList
、
ConcurrentHashMap
,或重构为无状态
|
| 4. 非线程安全工具类 |
是否使用了
SimpleDateFormat
,
Date
,
Random
等非线程安全类?
|
private SimpleDateFormat sdf = new SimpleDateFormat(...);
|
替换为
DateTimeFormatter
、
ThreadLocal<SimpleDateFormat>
或局部变量
|
| 5. 初始化逻辑 | 构造函数或静态块中,是否有耗时或可能失败的IO操作(DB连接、HTTP调用)? |
private MySingleton() { initFromRemoteConfig(); }
|
将初始化逻辑移到显式的
init()
方法,由Spring
@PostConstruct
或应用启动器调用
|
注意:这条清单的核心思想是“防御性审查”。它不假设开发者是专家,而是假设任何一行代码都可能存在隐患。比如,
ThreadLocal<SimpleDateFormat>看似安全,但如果ThreadLocal的remove()没被调用,会导致内存泄漏(尤其在Web容器的线程池中)。所以,审查时不仅要看到ThreadLocal,还要确认remove()是否在finally块中被调用。
4.2 压测场景设计:用JMeter模拟真实世界的“并发地狱”
写完代码,通过单元测试,不代表它在线上就安全。单例的线程安全问题,往往只在特定的并发模式下才会暴露。我们设计了一套标准化的JMeter压测脚本,专门用于“锤炼”单例:
-
场景1:初始化竞争(Initialization Contention)
:模拟100个线程在1秒内同时首次调用
getInstance()。这是检验DCL或静态内部类是否真的能扛住瞬间洪峰的关键。指标:所有线程必须100%成功获取到同一个实例,且无任何异常。 -
场景2:高频读取(High-Frequency Read)
:模拟1000个线程,持续1分钟,每秒调用
getInstance().getConfigValue("timeout")。这是检验getInstance()方法本身是否有锁竞争。指标:平均响应时间应稳定在0.1ms以内,99线不超过0.5ms。 -
场景3:读写混合(Read-Write Mix)
:模拟500个线程读取配置,50个线程每5秒更新一次配置缓存。这是检验单例内部状态(如
ConcurrentHashMap)在混合负载下的稳定性。指标:读取操作不能因写入而阻塞,更新操作不能导致数据不一致。
有一次,我们压测一个订单号生成器单例,它用
AtomicLong
生成递增ID,看起来很安全。但在“读写混合”场景下,TPS从预期的10000骤降到3000。用
jstack
分析线程堆栈,发现大量线程在
Unsafe.park()
上等待。深入排查,发现它内部有一个
ConcurrentHashMap<String, AtomicLong>
用来管理不同业务线的ID序列,而
ConcurrentHashMap
的
computeIfAbsent()
方法在高并发下会触发内部的
transfer()
扩容操作,这个操作是全局加锁的。最终解决方案是:为每个业务线预分配一个独立的
AtomicLong
,避免在运行时动态创建,彻底消除了锁竞争。
4.3 生产环境问题排查:从GC日志到Arthas,定位“幽灵”线程安全问题
线上问题往往比压测更诡异。我经历过一个最离奇的案例:一个单例的
process()
方法,在日志里偶尔会打印出
null
值,但单元测试100%通过,压测也找不到问题。最后,我们动用了三件套:
-
GC日志分析
:开启
-XX:+PrintGCDetails -XX:+PrintGCTimeStamps,发现每次null出现前,都伴随着一次Full GC。这提示问题可能和对象生命周期有关。 -
JVM线程转储(jstack)
:在问题发生时,用
jstack -l <pid>抓取线程快照。我们发现,有多个线程卡在同一个ConcurrentHashMap的putVal()方法里,但它们的堆栈显示,这些线程正在处理完全不同的业务请求。这说明,问题不是锁竞争,而是数据结构本身被破坏了。 -
Arthas动态诊断
:用
watch命令实时观察process()方法的入参和返回值:watch com.xxx.MySingleton process '{params, returnObj}' -n 5。结果发现,传入的参数是正常的,但返回值却是null。再用ognl命令直接调用instance.getState(),发现状态字段是null。最终定位到:单例内部有一个state字段,被一个异步回调线程在process()执行中途修改了,而process()方法本身没有对state做任何同步保护。
这个案例教会我们:
线程安全问题,不一定是“多个线程同时修改”,也可能是“一个线程修改,另一个线程读取了中间状态”。
最终修复,是在
process()
方法的入口和出口,用
synchronized(this)
包裹对
state
的读写,确保其操作的原子性。这个教训也写进了我们的《Java并发编程规范》第一条:“任何被多个线程共享的、可变的(mutable)状态,都必须有明确的同步策略。”
5. 常见问题与独家避坑指南:那些没人告诉你的“八股文之外”的真相
5.1 “synchronized(this)” vs “synchronized(Singleton.class)”:锁对象的选择,决定了你的单例是“金钟罩”还是“纸糊的”
这是面试中最常被问,也是实践中最容易答错的问题。很多人脱口而出:“
synchronized(this)
锁的是实例,
synchronized(Singleton.class)
锁的是类,所以后者更安全。” 这个答案在单例语境下,
是完全错误的。
让我们看一个反例:
public class BadSingleton {
private static BadSingleton instance;
private BadSingleton() {}
public static BadSingleton getInstance() {
if (instance == null) {
synchronized (BadSingleton.class) { // 锁类对象,正确
if (instance == null) {
instance = new BadSingleton();
}
}
}
return instance;
}
// 问题在这里!
public void unsafeMethod() {
synchronized (this) { // 锁的是单例实例本身
// 修改内部状态...
}
}
}
这段代码的问题在于:
unsafeMethod()
里的
synchronized(this)
,锁的是
instance
这个对象。在单例模式下,
this
就是那个唯一的实例,所以这个锁确实能保护该实例的状态。
但它保护不了“单例的唯一性”本身。
如果有人恶意地、绕过
getInstance()
方法,直接
new BadSingleton()
创建了一个新实例(虽然构造函数是private,但反射可以突破),那么这个新实例上的
synchronized(this)
,就和单例实例上的锁,完全无关了!它保护的是一块“孤岛”,而不是整个单例体系。
而
synchronized(Singleton.class)
,锁的是
Singleton.class
这个
Class
对象。这个对象是JVM级别的,是所有该类实例的“元数据”代表,
无论你用什么方式创建了多少个
Singleton
实例,它们共享的都是同一个
Class
对象。
所以,用类对象作为锁,才能真正意义上“锁住整个类”,保障单例的全局唯一性。
实操心得:在我们团队的代码规范里,明确规定: 所有用于保障单例创建安全的
synchronized块,锁对象必须是Singleton.class。 而用于保护单例内部状态的synchronized,则可以根据具体场景选择:如果状态是静态的(如一个静态缓存Map),就用Singleton.class;如果是实例级别的(如一个实例字段),就用this。但绝不能混用,更不能在创建逻辑里用this。
5.2 “final”字段的线程安全魔力:为什么它能让“半成品”对象变得安全
final
关键字在Java并发模型中,拥有远超其字面意义的魔力。JSR-133内存模型规定:
一个正确构造的对象(properly constructed object),其final字段的值,对所有线程都是可见的。
什么是“正确构造”?就是对象的引用在构造函数执行完毕之前,没有被泄露出去(即没有发生“this逃逸”)。
把这个原理应用到单例上,会产生奇妙的效果。看这个例子:
public class FinalFieldSingleton {
private static FinalFieldSingleton instance;
private final int value;
private final String name;
private FinalFieldSingleton(int value, String name) {
this.value = value; // final字段,在构造函数中赋值
this.name = name;
// 假设这里没有this逃逸
}
public static FinalFieldSingleton getInstance() {
FinalFieldSingleton result = instance;
if (result == null) {
synchronized (FinalFieldSingleton.class) {
result = instance;
if (result == null) {
instance = result = new FinalFieldSingleton(42, "Singleton");
}
}
}
return result;
}
}
在这个例子中,即使没有
volatile
,
getInstance()
方法返回的
FinalFieldSingleton
实例,其
value
和
name
字段对所有线程也一定是正确的、初始化完成的值。因为JVM保证:
final字段的写操作,happens-before于构造函数的结束;而构造函数的结束,happens-before于
instance
引用的赋值(在DCL的同步块内)。
因此,其他线程读取到
instance
后,再读取其
final
字段,一定能看到正确的值。
这个特性,是
volatile
之外,另一种保障单例“发布安全”的手段。它特别适用于那些状态简单、字段都是
final
的单例(比如一个只包含配置常量的单例)。但要注意,它只对
final
字段有效,对非final字段无效。所以,它不能替代
volatile
,而是可以和
volatile
一起,构成更坚固的防线。
5.3 Spring Bean的单例与Java原生单例:两套世界观的碰撞与融合
在Spring生态中,我们天天说“Spring Bean默认是单例的”,但这和我们前面讨论的“Java单例模式”,是两套完全不同的概念。前者是 Spring容器管理的单例(Container Singleton) ,后者是 JVM级别的单例(JVM Singleton) 。它们的生命周期、作用域、线程安全模型,都截然不同。
-
Spring Bean单例
:Spring容器在启动时,根据Bean定义,创建一个实例,并将其放入单例池(
singletonObjectsMap)中。所有对该Bean的请求,都返回这个池中的同一个实例。 它的“单例性”是由Spring容器保证的,不是JVM。 如果你绕过Spring,直接new MyService(),就会得到一个全新的、不受Spring管理的实例。 -
Java原生单例
:通过
private constructor和static getInstance()等手段,在JVM层面确保只有一个实例存在。它的“单例性”是语言和JVM机制保证的,与任何框架无关。
这两者在现实中经常交织。最常见的场景是:一个Spring管理的Service Bean,内部依赖了一个Java原生的单例工具类(比如一个
IdGenerator
)。这时,线程安全问题就变成了一个“组合问题”:Spring Bean本身是线程安全的(无状态),但它的依赖
IdGenerator
如果不安全,整个Service就也不安全。
我们的解决方案是:
在Spring项目中,尽量避免混合使用两种单例。
优先使用Spring Bean。如果必须用Java原生单例,那么它必须是“无状态”的(所有字段都是
final
或
static final
),或者其内部状态的线程安全,必须由它自己100%负责。我们团队有个约定:所有被Spring Bean注入的Java单例,都必须通过
@PostConstruct
方法进行初始化,并且在初始化方法上加
synchronized
,确保初始化过程的线程安全。
最后分享一个小技巧:在IDEA中,你可以安装一个叫“Singleton Detector”的插件。它能自动扫描项目中所有实现了单例模式的类,并根据我们上面讨论的规则(是否有volatile、是否有内部非线程安全状态等),给出风险等级评分。这个插件帮我们提前发现了23个潜在的线程安全问题,避免了它们上线后变成线上事故。技术是死的,但用技术的人,可以把它用活。

3343

被折叠的 条评论
为什么被折叠?



