Java对象的可变性与不可变性分析

不可变对象需同时满足:类为final、所有字段private final、无修改内部状态的方法;final仅保证引用不变,不阻止对象状态改变;适合值对象、配置类和HashMap键,但高频率修改场景需权衡GC开销。

不可变对象必须满足的三个条件

一个 Java 对象要被认定为不可变,不是靠加个 final 关键字就完事的。它得同时满足:类本身用 final 修饰(防止被继承)、所有字段都是 private final、并且没有暴露可修改内部状态的方法(比如不提供 setter,也不返回可变对象的引用)。漏掉任意一条,都可能被绕过防护。

  • String 是典型不可变类:它的 value 字段是 private

    final char[]
    ,且所有 public 方法(如 substring())都返回新对象,不修改原数组
  • 如果字段是 private final List,但通过 getter 返回了原始 ArrayList 引用,外部就能调用 add() 修改内容——这不算真正不可变
  • 构造器里若直接赋值传入的可变对象(如 this.data = data;),必须做防御性拷贝:this.data = new ArrayList(data);

为什么 final 修饰引用不等于对象不可变

final 只保证该变量不能再指向别的对象,但不阻止对象自身状态被修改。这是最常被误解的一点。

public class Person {
    private final List hobbies;
    public Person(List h) {
        this.hobbies = h; // ❌ 危险:hobbies 指向外部传入的 ArrayList
    }
    public List getHobbies() {
        return hobbies; // ✅ 外部拿到后可直接 add/remove
    }
}

上面代码中,hobbiesfinal,但 ArrayList 本身是可变的。正确做法是用 Collections.unmodifiableList() 包装,或在构造时深拷贝。

可变对象在并发场景下的典型问题

多线程共享可变对象时,如果没有同步机制,会出现可见性、原子性、重排序三类问题。比如 SimpleDateFormat 就是典型的非线程安全可变类。

  • 多个线程共用同一个 SimpleDateFormat 实例调用 parse(),可能抛出 java.lang.NumberFormatException 或返回错误日期
  • 解决方式不是加 synchronized(性能差),而是改用 DateTimeFormatter(不可变、线程安全),或每次新建 SimpleDateFormat 实例
  • 使用 ThreadLocal 也可行,但要注意内存泄漏风险(尤其在线程池中)

什么时候该设计成不可变对象

不是所有对象都适合不可变。它最适合那些状态一旦创建就不应改变、且会被多处共享的场景。

  • 值对象(value object):如 LocalDateBigInteger、自定义的 MoneyRange
  • 配置类:如果整个应用生命周期内配置只读,用不可变对象能避免意外篡改
  • 作为 HashMap 的 key:不可变保证了 hashCode() 值稳定,不会因字段变化导致 key “消失”
  • 但频繁修改的业务实体(如 User 订单状态流转)强行不可变会导致大量临时对象,GC 压力上升

不可变性的代价是每次“修改”都要新建对象,所以关键看修改频次和对象大小。小而稳的数据结构值得不可变;大而频繁变更的状态,得权衡清楚。