Java 中的不可变类

不可变类:当创建了这个类的实例后,就不允许修改它的属性值。在 JDK 的基本类库中,所有基本类型的包装类,如 IntegerLong 类,都是不可变类。String 也是不可变类。

用户在创建自己的不可变类时,可以考虑采用以下设计模式:

  • 把属性定义为 private final 类型。
  • 不对外公开用于修改属性的 setXXX() 方法
  • 只对外公开用于读取属性的 getXXX() 方法
  • 在构造方法中初始化所有属性。
  • 覆盖 Object 类的 equals()hashCode() 方法,在 equals() 方法中根据对象的属性值来比较两个对象是否相等,并且保证用 equals() 方法判断为相等的两个对象的 hashCode() 方法的返回值也相等,这可以保证这些对象能正确地放到 HashMapHashSet 集合中。
  • 如果需要的话,提供实例缓存和静态工厂方法,允许用户根据特定参数获得与之匹配的实例。
String 类是不可变的

  • String 类由 final 修饰,说明其不可继承。
  • 字符数组 char value[] 是由 private final 修饰的,保证引用地址不可变。(不保证数组内容不可变)
  • String 类中,没有公开修改内部成员字段的方法。
  • 读取 value 数组时,采用了保护性拷贝 Arrays.copyof()

实际上,可以通过反射修改 value 属性。

不可变类的实例在整个生命周期中永远保持初始化的状态,它没有任何状态变化,简化了与其他对象之间的关系。不可变类具有以下优点:

  • 不可变类能使程序更加安全,不容易出错;
  • 不可变类是线程安全的,当多个线程访问不可变类的同一个实例时,无须进行线程的同步。

把不可变类实例属性的 final 修饰符去除,增加 public 类型的 set 方法就变成了可变类。使用可变类更容易使程序代码出错。因为随意改变一个可变类对象的状态,有可能会导致与之关联的其他对象的状态被错误地改变。

由此可见,应该优先考虑把类设计为不可变类,假使必须使用可变类,也应该把可变类的尽可能多的属性设计为不可变的,即用 final 修饰符来修饰,并且不对外公开用于改变这些属性的方法。

在创建不可变类时,假如它的属性所属的类是可变类,在必要的情况下,必须提供保护性复制,否则,这个不可变类的实例的属性仍然有可能被错误地修改。即使可变类属性已经用 final 修饰,也必须提供保护性复制。

保护性复制是一种防御性编程技术。

例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public final class Schedule {
private final Date start;
private final Date end;
public Schedule(Date start,Date end){
//不允许放假日期在开学日期的前面
if(start.compareTo(end)>0)
throw new IllegalArgumentException(start +" after " +end);
this.start=new Date(start.getTime()); //采用保护性拷贝
this.end=new Date(end.getTime()); //采用保护性拷贝
}
public Date getStart(){return (Date)start.clone();} //采用保护性拷贝
public Date getEnd(){return (Date)end.clone();} //采用保护性拷贝
}
// 嵌入Schedule的将不是别人给的可变类Date的引用,返回出去的Date也不会是实际存的引用,这样别人就不能改动可变类Date造成状态错误。

假如某个类中被 final 修饰的属性所属的类是不可变类,就无须提供保护性复制,因为该属性所引用的实例的值永远不会被改变,这进一步体现了不可变类的优点。

保护性拷贝:通过创建副本对象来避免共享的手段。

具有实例缓存的不可变类

推荐阅读:站内文章享元模式:共享实例 中 Java 的 IntegerString 带缓存的不可变类的实现。

不可变类的实例的状态不会变化,这样的实例可以安全地被其他与之关联的对象共享,还可以安全地被多个线程共享。为了节省内存空间,优化程序的性能,应该尽可能地重用不可变类的实例,避免重复创建具有相同属性值的不可变类的实例。

缓存并没有固定的实现方式,完善的缓存实现不仅要考虑何时把实例加入缓存,还要考虑何时把不再使用的实例从缓存中及时清除,以保证有效合理地利用内存空间。一种简单的实现是直接用 Java 集合来作为实例缓存。

另外要注意的是,没有必要为所有的不可变类提供实例缓存。随意创建大量实例缓存,反而会浪费内存空间,降低程序的运行性能。通常,只有满足以下条件的不可变类才需要实例缓存:

  • 不可变类的实例的数量有限。
  • 在程序运行过程中,需要频繁访问不可变类的一些特定实例。这些实例拥有与程序本身同样长的生命周期。

不变模式 Immutable

不变模式:一个对象的状态在对象创建之后不再改变。涉及的 Java 类即不变类(Immutable Class),如果是对象则为不变对象(Immutable Object)。

不变模式分为两类:

  • 普通不变模式:对象中包含的引用对象是可以改变的。如果不特别说明,通常我们所说的不变模式,指的就是普通的不变模式。
  • 深度不变模式:对象包含的引用对象也不可变

它们之间的关系类似于 站内文章深拷贝和浅拷贝 之间的关系。

不变模式使用到不可变类,而不可变类不存在线程问题,因此不变模式常用于多线程场景。所以,不变模式也常被归类为多线程设计模式。

不变集合

不变集合是一种特殊的不变类。

Google Guava 针对集合类 CollectionListSetMap 等提供了对应的不变集合类 ImmutableCollectionImmutableListImmutableSetImmutableMap……

Google Guava 提供的不变集合类属于普通不变模式,集合中的对象不会增删,但是对象的成员变量是可以改变的。

JDK 中也提供了类似的集合,只不过它提供的是一个集合的「不可修改的视图」,而不是不变集合。当原始集合被修改后,Collections.unmodifiableXXX 里面的元素会跟着发生变化。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public static void main(String[] args) {
List<String> originalList = new ArrayList<>();
originalList.add("a");
originalList.add("b");
originalList.add("c");

List<String> jdkUnmodifiableList = Collections.unmodifiableList(originalList);
List<String> guavaImmutableList = ImmutableList.copyOf(originalList);

//jdkUnmodifiableList.add("d"); // 抛出UnsupportedOperationException
// guavaImmutableList.add("d"); // 抛出UnsupportedOperationException
originalList.add("d");

print(originalList); // a b c d
print(jdkUnmodifiableList); // a b c d (发生了改变)
print(guavaImmutableList); // a b c
}

JDK 类库中提供的 unmodifiableXXX 方法存在以下不足:

  • 笨拙:代码量多;
  • 不安全:只有在不会引用到原来的集合情况下,才能保证集合唯一且永恒不变;
  • 效率很低:返回的不可修改的集合数据结构仍然具有可变集合的所有开销。

本文参考