Java进阶—不可变对象及其应用

对于并发问题常见的解决方法可以分为两大类:无锁和有锁。无锁可分为:局部变量、不可变对象、ThreadLocal、CAS原子类;而有锁的方式又分为synchronized关键字和ReentrantLock可重入锁。

本文主要讨论一下什么是不可变对象,以及实际应用中可以怎么使用不可变对象。

什么是不可变对象

《Effective Java》这本书对于不可变对象有如下定义:
不可变对象(Immutable Object):对象一旦被创建后,对象所有的状态及属性在其生命周期内不会发生任何变化。

举个例子,我们常用的字符串对象就是一个不可变对象,比如:String s=“Hello” ,注意这里的字符串是指“Hello”,而不是指引用“Hello”这个字符串的变量s,即便我们使用“Hello”这个字符串和字符串“World”组合得到一个新的字符串“HelloWorld”,原本的“Hello”字符串也不会发生变化,这就是我们说的不可变对象。参考一下如下的示例:

1
2
3
4
5
6
7
8
@Test
public void test() {
String s = "Hello World";
String s1 = s;

System.out.println("after replace s:" + s.replace("World", "Bejing"));
System.out.println("after replace s1:" + s1);
}

输出:

1
2
after replace s:Hello Bejing
after replace s1:Hello World

应用示例

案例场景

上面以String为例简单介绍了不可变对象,并且也说到不可变对象可以解决部分并发问题,下面通过一个车辆管理系统的案例讲解不可变对象的应用场景。
在这个案例中,需要对车辆的信息进行跟踪,其中车辆的位置信息的代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
public class Location {

private double x;

private double y;

public Location(double x, double y) {
this.x = x;
this.y = y;
}

public double getX() {
return x;
}

public double getY() {
return y;
}

public void setXY(double x, double y) {
this.x = x;
this.y = y;
}

}

上面是一个位置信息类,包含代表坐标的变量X和Y,和用来对车辆位置信息进行更新的方法setXY,接下来我们看下实现车辆信息追踪代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
public class CarLocationTracker {

/**
* 车辆编码对应车辆位置信息map,key是不同车辆的唯一编码
*/
private Map<String, Location> locationMap = new ConcurrentHashMap<>();


/**
* 更新车辆的位置信息
*
* @param carCode 车辆编码
* @param x 位置信息x
* @param y 位置信息y
*/
public void updateLocation(String carCode, double x, double y) {
Location location = locationMap.get(carCode);
location.setXY(x, y);
}


/**
* 获取车里位置
*
* @param carCode 车辆唯一编码
* @return 车辆位置
*/
public Location getLocation(String carCode) {
return locationMap.get(carCode);
}

}

当车辆的位置信息发生变更的时候,我们可以调用updateLocation方法来更新车辆的位置,另外也可以通过调用getLocation方法来获取车辆的信息。

但Location类的setXY方法不是一个线程安全的方法,我们可以参考下图做一下具体分析:

如上图所示,一开始某辆车的位置信息为x=1.0 y=1.0,接着线程1调用updateLocation方法来更新位置信息为x = 2.0,y = 2.0 ,这时线程1只来得及更新了x的值,y的值还没有更新,好巧不巧,线程2也来读取车辆的位置信息,此时它得到的结果是 x =2.0,y = 1.0。 这可是这个车根本不曾到达过的“诗和远方”。

为了确保车辆信息的更新具备线程安全的特性,我们可以将位置信息类改造为不可变类,如果车辆的位置信息发生变化,咱们通过替换整个Location对象来实现,而不是通过setXY方法来实现。

如何实现一个不可变类

所谓的不可变类是指这个类的对象一经创建就不可再改变。

在我们车辆管理系统中来说就是Location类一旦创建就不能变了,不能改变X的值,也不能改变Y的值。
如果Location类中的X的值不能变,Y的值也不能变,那么我们可以使用Java的关键字final来修饰这两个字段,通过Java语言的语法特性来保证这两个字段的不可变。

1
2
3
private final double x;

private final double y;

而且如果X和Y的值不能改变,那么setXY方法的存在也不合理,所以需要将setXY方法也去掉。

继续思考:如果当前类被子类继承还是一个不可变类吗?

接着我们再思考一个问题:假设我有一个子类继承了Location,然后重写了它的getX方法怎么办?如下:

1
2
3
4
5
6
7
8
9
10
11
12
public class SubLocation extends Location {

public SubLocation(double x, double y) {
super(x, y);
}

@Override
public double getX() {
return super.getX() + 1;
}

}

从这个代码可以看出,假设有一个类继承了Location类,然后重写getX方法。比如说本来一个Location对象的X值为1的,但是这个子类确返回了 1 + 1 = 2。这很显然不符合不可变对象的行为,因为它的子类可以改变它的方法行为。 为了杜绝这种情况,我们需要将Location类设计为不可继承的,通过final修饰符修饰即可。

那么最终版本的不可变的Location是这样的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public final class Location {

private final double x;

private final double y;

public Location(double x, double y) {
this.x = x;
this.y = y;
}

public double getX() {
return x;
}

public double getY() {
return y;
}

}

接着,如果车辆位置发生变化的时候,通过替换整个Location来表示,这样就能避免前面的线程安全问题了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
public class CarLocationTracker {

/**
* 车辆编码对应车辆位置信息map,key是不同车辆的唯一编码
*/
private Map<String, Location> locationMap = new ConcurrentHashMap<>();


/**
* 更新车辆的位置信息
* <p>
* 替换整个location对像
*
* @param carCode 车辆编码
* @param newLocation 位置信息
*/
public void updateLocation(String carCode, Location newLocation) {
Location location = locationMap.get(carCode);
locationMap.put(carCode, newLocation);
}


/**
* 获取车辆位置
*
* @param carCode 车辆唯一编码
* @return 车辆位置
*/
public Location getLocation(String carCode) {
return locationMap.get(carCode);
}

}

总结与拓展

通过上面的例子,我们大概了解了使用可变的类会引发什么样问题,以及如何将一个类改造成不可变类,来解决线程安全问题。最后我们总结一下实现不可变类的一些思路:

  1. 使用final关键字修饰所有成员变量,避免其被修改。并且成员变量在创建对象时初始化值,即final修饰的字段在其他线程可见时,必定是初始化完成的。

  2. 使用private修饰所有成员变量,可以防止通过引用直接修改变量值。

  3. 禁止提供修改内部状态的公开接口(比如我们前面例子中的setXY方法)

  4. 禁止不可变类被外部继承,防止子类改变其定义的方法的行为。

  5. 如果类中存在数组或集合,在提供给外部访问之前需要做防御性复制

前面4点比较好理解,我们在前面改造Location为不可变类的过程中都有运用到,第5点则需要另外做一下说明,请看如下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class DefensiveReplicaDemo {

private final List<Integer> data = new ArrayList<>();

public DefensiveReplicaDemo() {
data.add(1);
data.add(2);
data.add(3);
}

public List<Integer> getData() {
return data;
}

public static void main(String[] args) {
DefensiveReplicaDemo defensiveReplicaDemo = new DefensiveReplicaDemo();
List<Integer> data = defensiveReplicaDemo.getData();
data.add(4);
}
}

上面代码中的DefensiveReplicaDemo类中有一个List<Integer>类型的数据,使用了final关键字修饰,其中数据内容为1,2,3(构造函数添加进去的)。并且提供了一个getData()的方法。

注意看main方法中的代码,调用了data.add(4),因为返回的是一个引用,指向的对象和DefensiveReplicaDemo类中的data指向的对象是同一个,这样就会导致DefensiveReplicaDemo类中的data数据内容改变为1,2,3,4。 为了避免这种情况,我们通常会做防御性复制,将getData()方法修改成如下:

1
2
3
public List<Integer> getData() {
return Collections.unmodifiableList(data);
}

使用Collections.unmodifiableList方法包装了一个新的list,这样能保证外部可以拿到data中的数据,但拿到的这个data数据是无法修改的,那么DefensiveReplicaDemo的data集合的值永远会是1,2,3。

除了JDK中提供的一系列方法创建不可变集合,在Google的Guava包中也提供了一系列方法来创建不可变集合,如:

1
ImmutableList.copyOf(list)

这2种方式虽然都能创建不可变list,但是两者是有区别的,JDK自带提供的方式实际上创建出来的不是真正意义上的不可变集合,看unmodifiableList方法的实现就知道了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
/**
* Returns an unmodifiable view of the specified list. This method allows
* modules to provide users with "read-only" access to internal
* lists. Query operations on the returned list "read through" to the
* specified list, and attempts to modify the returned list, whether
* direct or via its iterator, result in an
* <tt>UnsupportedOperationException</tt>.<p>
*
* The returned list will be serializable if the specified list
* is serializable. Similarly, the returned list will implement
* {@link RandomAccess} if the specified list does.
*
* @param <T> the class of the objects in the list
* @param list the list for which an unmodifiable view is to be returned.
* @return an unmodifiable view of the specified list.
*/
public static <T> List<T> unmodifiableList(List<? extends T> list) {
return (list instanceof RandomAccess ?
new UnmodifiableRandomAccessList<>(list) :
new UnmodifiableList<>(list));
}

/**
* @serial include
*/
static class UnmodifiableList<E> extends UnmodifiableCollection<E>
implements List<E> {
private static final long serialVersionUID = -283967356065247728L;

final List<? extends E> list;

UnmodifiableList(List<? extends E> list) {
super(list);
this.list = list;
}

public boolean equals(Object o) {return o == this || list.equals(o);}
public int hashCode() {return list.hashCode();}

public E get(int index) {return list.get(index);}
public E set(int index, E element) {
throw new UnsupportedOperationException();
}
public void add(int index, E element) {
throw new UnsupportedOperationException();
}
public E remove(int index) {
throw new UnsupportedOperationException();
}
public int indexOf(Object o) {return list.indexOf(o);}
public int lastIndexOf(Object o) {return list.lastIndexOf(o);}
public boolean addAll(int index, Collection<? extends E> c) {
throw new UnsupportedOperationException();
}

@Override
public void replaceAll(UnaryOperator<E> operator) {
throw new UnsupportedOperationException();
}
@Override
public void sort(Comparator<? super E> c) {
throw new UnsupportedOperationException();
}

//......
}

可以看出,实际上UnmodifiableList是将入参list的引用复制了一份,同时将所有的修改方法抛出UnsupportedOperationException。因此如果在外部修改了入参list,实际上会影响到UnmodifiableList,而Guava包提供的ImmutableList是真正意义上的不可变集合,它实际上是对入参list进行了深拷贝。看下面这段测试代码的结果便一目了然:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Test
public void test2() {
List<Integer> list = new ArrayList<Integer>();
list.add(1);
System.out.println(list);

List unmodifiableList = Collections.unmodifiableList(list);
ImmutableList immutableList = ImmutableList.copyOf(list);

list.add(2);
System.out.println(unmodifiableList);
System.out.println(immutableList);

}

输出:

1
2
3
[1]
[1, 2]
[1]

不可变对象真的”完全不可改变”吗?

不可变对象虽然具备不可变性,但是不是”完全不可变”的,这里打上引号是因为通过反射的手段是可以改变不可变对象的状态的。

大家看到这里可能有疑惑了,为什么既然能改变,为何还叫不可变对象?这里面大家不要误会不可变的本意,从不可变对象的意义分析能看出来对象的不可变性只是用来辅助帮助大家更简单地去编写代码,减少程序编写过程中出错的概率,这是不可变对象的初衷。如果真要靠通过反射来改变一个对象的状态,此时编写代码的人也应该会意识到此类在设计的时候就不希望其状态被更改,从而引起编写代码的人的注意。下面是通过反射方式改变不可变对象的例子:

1
2
3
4
5
6
7
8
9
10
11
12
@Test
public void test03() throws NoSuchFieldException, IllegalAccessException {
String s = "Hello World";
System.out.println("s = " + s);

Field valueFieldOfString = String.class.getDeclaredField("value");
valueFieldOfString.setAccessible(true);

char[] value = (char[]) valueFieldOfString.get(s);
value[5] = '_';
System.out.println("s = " + s);
}

该测试代码打印结果:

1
2
s = Hello World
s = Hello_World

------ 本文完 ------