找回密码
立即注册
搜索
热搜: Java Python Linux Go
发回帖 发新帖

3005

积分

0

好友

410

主题
发表于 昨天 03:52 | 查看: 1| 回复: 0

很多开发者会觉得“我的服务是单线程的,不用考虑并发问题”。但现实是,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方法”是远远不够的。要确保一个类真正不可变,必须严格遵守以下四条原则:

  1. 将类声明为 final:防止被子类继承后,通过覆盖方法或添加可变字段来破坏不可变性。
  2. 将所有字段声明为 private final:彻底禁止外部直接访问和内部后续修改。
  3. 在构造时进行防御性拷贝或严格校验:如果构造参数本身是可变的(如 Date, List),必须创建其副本或进行不可变转换,防止外部调用者通过持有的引用从外部修改对象内部状态。
  4. 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 包彻底告别了旧版 DateCalendar 的可变性与线程不安全噩梦。新API中的所有类都是不可变的。

// LocalDateTime 是不可变的
LocalDateTime now = LocalDateTime.now();
LocalDateTime tomorrow = now.plusDays(1); // plusDays() 返回一个新对象
System.out.println(now); // 原对象不变,输出仍是当前时间

这使得所有时间计算在并发环境下都是绝对安全的。

2. Google Guava:强大的不可变集合库

Guava 提供了一整套不可变集合,如 ImmutableListImmutableSetImmutableMap,它们强制要求集合内容在创建后不可更改。

// 创建不可变列表
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应用时,有意识地采用不可变对象设计,无疑是一个值得投入的良好习惯。




上一篇:DDR5插4根内存不稳定的核心原因:拓扑、频率与信号完整性分析
下一篇:AXI总线协议详解:为何突发传输不能跨越4K边界
您需要登录后才可以回帖 登录 | 立即注册

手机版|小黑屋|网站地图|云栈社区 ( 苏ICP备2022046150号-2 )

GMT+8, 2026-2-6 07:19 , Processed in 0.356527 second(s), 40 queries , Gzip On.

Powered by Discuz! X3.5

© 2025-2026 云栈社区.

快速回复 返回顶部 返回列表