很多开发者会觉得“我的服务是单线程的,不用考虑并发问题”。但现实是,Web 应用天然是多线程的。一个 Spring Bean 或一个工具类的实例,很可能被成百上千个来自不同 HTTP 请求的线程同时调用。
下面这段代码就模拟了一个看似无害,实则暗藏隐患的日期格式化工具。它演示了可变状态如何在并发环境下制造混乱。
我们创建一个名为 MutableDateFormatter 的可变类,它内部持有一个 pattern 字段。接下来启动两个线程,分别设置不同的日期格式并进行输出。由于两个线程共享同一个实例,它们会互相覆盖对方的格式配置,导致输出结果完全无法预测。
import java.text.SimpleDateFormat;
import java.util.Date;
public class MutableDateFormatter {
private String pattern = "yyyy-MM-dd";
public void setPattern(String pattern) {
this.pattern = pattern;
}
public String format(Date date) {
return new SimpleDateFormat(pattern).format(date);
}
public static void main(String[] args) throws InterruptedException {
MutableDateFormatter formatter = new MutableDateFormatter();
Thread t1 = new Thread(() -> {
formatter.setPattern("yyyy-MM-dd");
System.out.println("Thread-1: " + formatter.format(new Date()));
});
Thread t2 = new Thread(() -> {
formatter.setPattern("dd/MM/yyyy");
System.out.println("Thread-2: " + formatter.format(new Date()));
});
t1.start();
t2.start();
t1.join();
t2.join();
}
}
实际运行结果(多次运行,结果不一致):
Thread-2: 26/01/2026
Thread-1: 26/01/2026 ← 本该是 yyyy-MM-dd,却变成了 dd/MM/yyyy!
// 或者
Thread-1: 2026-01-26
Thread-2: 2026-01-26 ← 本该是 dd/MM/yyyy,却变成了 yyyy-MM-dd!
问题根源就在于,两个线程共享同一个 formatter 实例,并互相覆盖了 pattern 字段。这就是 “可变状态 + 多线程 = 不确定性” 的典型教科书案例。
如果采用不可变设计,这个问题将从根源上消失——因为每个线程操作的都将是完全独立的对象,彼此互不干扰。
什么是不可变对象?核心思想就一句:不修改,只生成
不可变对象指的是,一旦创建,其内部状态就永远不能被改变。
任何“看似修改”的操作,实际上都会返回一个全新的对象,而原始对象则保持原封不动。Java 中最经典的例子莫过于 String。当你调用 toUpperCase() 时,它并不会把原字符串变成大写,而是生成一个全新的字符串对象并返回。
String a = "hello";
String b = a.toUpperCase(); // 返回新字符串 "HELLO"
System.out.println(a); // 输出: hello(原对象未变!)
这种“值语义”的设计,使得 String 成为了线程安全、可缓存、可作为哈希键的理想数据载体。
不可变性带来的三大核心优势
天然线程安全
可变对象在多线程环境下极易产生竞态条件,而不可变对象因为状态无法更改,天然具备线程安全性——无需加锁、无需任何同步措施,多个线程同时读取也绝不会看到不一致的状态。
为了验证这一点,我们分别用可变版本和不可变版本的日期格式器,在两个线程中并发设置不同格式并输出结果。
可变对象 MutableDateFormatter 的代码已在前文展示,下面是其不可变版本的实现:
public final class ImmutableDateFormatter {
private final String pattern; // final 字段
public ImmutableDateFormatter(String pattern) {
this.pattern = pattern;
}
public String format(Date date) {
return new SimpleDateFormat(pattern).format(date);
}
// 返回新实例,原对象不变
public ImmutableDateFormatter withPattern(String newPattern) {
return new ImmutableDateFormatter(newPattern);
}
}
接下来是测试主类,用于对比两种设计的并发行为:
public class ThreadSafetyDemo {
public static void main(String[] args) throws InterruptedException {
System.out.println("=== 可变版本(有并发问题) ===");
testMutable();
System.out.println("\n=== 不可变版本(线程安全) ===");
testImmutable();
}
static void testMutable() throws InterruptedException {
MutableDateFormatter formatter = new MutableDateFormatter();
Thread t1 = new Thread(() -> {
formatter.setPattern("yyyy-MM-dd");
System.out.println("Thread-1: " + formatter.format(new Date()));
});
Thread t2 = new Thread(() -> {
formatter.setPattern("dd/MM/yyyy");
System.out.println("Thread-2: " + formatter.format(new Date()));
});
t1.start(); t2.start();
t1.join(); t2.join();
}
static void testImmutable() throws InterruptedException {
// 每个线程创建自己的不可变实例
Thread t1 = new Thread(() -> {
ImmutableDateFormatter f = new ImmutableDateFormatter("yyyy-MM-dd");
System.out.println("Thread-1: " + f.format(new Date()));
});
Thread t2 = new Thread(() -> {
ImmutableDateFormatter f = new ImmutableDateFormatter("dd/MM/yyyy");
System.out.println("Thread-2: " + f.format(new Date()));
});
t1.start();
t2.start();
t1.join();
t2.join();
}
}
注意:在不可变版本的测试中,每个线程自己构造实例,这是典型用法。当然,你也可以从一个共享的原型对象派生出新实例,同样安全:
ImmutableDateFormatter base = new ImmutableDateFormatter("default");
ImmutableDateFormatter f1 = base.withPattern("yyyy-MM-dd");
ImmutableDateFormatter f2 = base.withPattern("dd/MM/yyyy");
运行结果(可多次运行验证)如下:
=== 可变版本(有并发问题) ===
Thread-1: 26/01/2026
Thread-2: 26/01/2026
=== 不可变版本(线程安全) ===
Thread-2: 26/01/2026
Thread-1: 2026-01-26
由此可见:
- 可变版本:多次运行后,两个线程的输出格式总是相同,说明一个线程的设置被另一个覆盖,结果错误且不可预测。
- 不可变版本:每次输出都严格符合各自设定的格式,结果稳定、正确、可预期。
消除副作用
“副作用”是指一个函数调用意外修改了外部状态。这会让程序的行为变得难以预测,尤其在复杂的调用链中,排查问题如同大海捞针。
可变集合是副作用的重灾区。比如,你传一个 ArrayList 给某个方法,它可能在你不知情的情况下向里面添加或删除元素。
而不可变集合则彻底杜绝了这种可能——你甚至无法调用 add() 这类修改方法。
// 可变列表:危险!
List<String> users = new ArrayList<>(Arrays.asList("Alice"));
process(users); // 如果process内部调用了users.add("Bob"),原列表就被污染了
// 不可变列表(以Guava为例):安全!
ImmutableList<String> safeUsers = ImmutableList.of("Alice");
process(safeUsers); // 无法修改,编译通过但运行时会抛出 UnsupportedOperationException
这种“防呆”设计,使得函数的行为变得纯粹:输入决定输出,调用不改变世界。这对于理解、测试和推理代码逻辑至关重要。
完美支持链式调用
链式调用(Fluent API)之所以流畅优雅,正是因为每一步操作都基于上一步的结果,而原始输入始终保持不变。你可以放心地复用中间结果,不必担心后续操作会污染之前的状态。
SafeString result = SafeString.of(" ADMIN ")
.trim()
.toLowerCase()
.replace("admin", "user");
// 原始输入未变,中间结果可复用
如果底层是可变对象,.trim() 会直接修改原字符串的内容,那么后续的 .toLowerCase() 就是在已经被修改的基础上操作。虽然单一线性的链式调用看似没问题,但一旦你想保留原始值或并行处理多个分支逻辑时,错误就会悄然出现。
不可变性确保了每一步操作都是独立、可预测且可自由组合的。
不可变 = 性能差?别被这个误解困住了
很多人第一反应是:“每次都 new 对象,内存和GC(垃圾回收)压力不就大了吗?”
事实上,现代 JVM 对短生命周期小对象的分配和回收做了极致优化(例如TLAB,线程本地分配缓冲区)。而且,不可变对象通常设计得非常轻量(比如只包装一个 String 字段),创建成本极低。
更重要的是,我们需要进行权衡:省下的调试时间、避免的并发Bug修复成本、以及提升的系统稳定性,其价值远高于那一点点额外的内存开销。
工程决策往往不是在追求绝对的速度最快,而是在追求更稳定、更可维护、更可持续。
如何写出真正的不可变类?遵循四条铁律
仅仅“不提供setter方法”是远远不够的。要确保一个类真正不可变,必须严格遵守以下四条原则:
- 将类声明为
final:防止被子类继承后,通过覆盖方法或添加可变字段来破坏不可变性。
- 将所有字段声明为
private final:彻底禁止外部直接访问和内部后续修改。
- 在构造时进行防御性拷贝或严格校验:如果构造参数本身是可变的(如
Date, List),必须创建其副本或进行不可变转换,防止外部调用者通过持有的引用从外部修改对象内部状态。
- getter方法不暴露可变内部状态:如果返回的是集合或数组等,应返回其不可变视图(unmodifiable view)或深拷贝副本。
下面是一个遵循这些原则的简单示例:
public final class SafeString {
private final String value; // private + final
public SafeString(String value) {
this.value = Objects.requireNonNull(value); // 构造时校验非空
}
public String getValue() {
return value; // String 本身是不可变的,所以安全返回原引用即可
}
public SafeString trim() {
return new SafeString(value.trim()); // 任何修改操作都返回新实例
}
}
常见的实现陷阱包括:
- 字段没有用
final 修饰。
- getter 返回可变集合时,虽然返回了副本
new ArrayList<>(internalList),但没有用 Collections.unmodifiableList() 包裹,导致返回的副本仍可被修改(尽管不影响原对象,但违反了“不可变对象不应暴露可变内部”的原则)。
- 构造函数接收了
Date 这类可变对象,却没有通过 new Date(date.getTime()) 进行深拷贝。
不可变性在主流Java框架与库中的实践
不可变性并非理论上的玩具,而是构建高可靠系统的标配设计思想。来看看Java生态中的几个经典实践:
1. Java标准库:时间API的全面革新
Java 8 引入的 java.time 包彻底告别了旧版 Date 和 Calendar 的可变性与线程不安全噩梦。新API中的所有类都是不可变的。
// LocalDateTime 是不可变的
LocalDateTime now = LocalDateTime.now();
LocalDateTime tomorrow = now.plusDays(1); // plusDays() 返回一个新对象
System.out.println(now); // 原对象不变,输出仍是当前时间
这使得所有时间计算在并发环境下都是绝对安全的。
2. Google Guava:强大的不可变集合库
Guava 提供了一整套不可变集合,如 ImmutableList、ImmutableSet、ImmutableMap,它们强制要求集合内容在创建后不可更改。
// 创建不可变列表
List<String> safeList = ImmutableList.of("a", "b", "c");
// 尝试修改会抛出 UnsupportedOperationException
// safeList.add("d"); // 运行时失败,但编译可通过(这是Java集合接口设计的历史遗留问题)
虽然编译器无法完全阻止你调用修改方法(因为实现了 List 接口),但运行时的强力防护已极大地降低了误用风险。
3. Spring Framework:防御性拷贝的实践
Spring 框架中的 HttpHeaders 类本身并非完全不可变,但在关键路径上采用了防御性拷贝的策略。例如,当 HttpHeaders 被传递给 WebClient 用于构建请求时,内部会对其进行拷贝。
HttpHeaders headers = new HttpHeaders();
headers.set("Content-Type", "application/json");
// 当headers被传递给 WebClient.builder() 时,内部会进行拷贝。
// 因此,即使你后续再执行 headers.set("X-Token", "..."),也不会影响到已经发出或正在构建的请求。
这是一种在灵活性与安全性之间取得巧妙平衡的“准不可变”策略。
结语:简单,不等于简陋
不可变性不是“过度设计”,而是用微小的对象创建成本,去换取巨大的工程确定性与可维护性。
它让:
- 并发编程不再令人提心吊胆。
- 函数行为没有秘密,易于理解和测试。
- 链式API的组合如丝般顺滑,逻辑清晰。
真正的简单,是把复杂性和不确定性封装在底层实现里,把确定性和可靠性清晰地呈现给使用者。 在构建现代、健壮的Java应用时,有意识地采用不可变对象设计,无疑是一个值得投入的良好习惯。