07共享模型之不可变
前面几章解决并发问题的思路大多是“加锁保护可变状态”——synchronized、volatile、CAS、原子类,本质上都是在多个线程争抢同一份可变数据时做协调。本章换一个角度:如果对象的状态根本不能被修改,并发修改自然就不存在了,它天生就是线程安全的。
本章主要包括三部分:不可变类的使用、不可变类设计、无状态类设计。
日期转换的问题
问题提出
下面的代码在运行时,由于 SimpleDateFormat 不是线程安全的,多个线程共用同一个实例解析日期会出问题:
SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd");
for (int i = 0; i < 10; i++) {
new Thread(() -> {
try {
log.debug("{}", sdf.parse("1951-04-21"));
} catch (Exception e) {
log.error("{}", e);
}
}).start();
}有很大几率出现 java.lang.NumberFormatException,或者出现不正确的日期解析结果。例如:
19:10:40.859 [Thread-2] c.TestDateParse - {}
java.lang.NumberFormatException: For input string: ""
at java.lang.NumberFormatException.forInputString(NumberFormatException.java:65)
at java.lang.Long.parseLong(Long.java:601)
at java.lang.Long.parseLong(Long.java:631)
at java.text.DigitList.getLong(DigitList.java:195)
at java.text.DecimalFormat.parse(DecimalFormat.java:2084)
at java.text.SimpleDateFormat.subParse(SimpleDateFormat.java:2162)
at java.text.SimpleDateFormat.parse(SimpleDateFormat.java:1514)
at java.text.DateFormat.parse(DateFormat.java:364)
at cn.itcast.n7.TestDateParse.lambda$test1$0(TestDateParse.java:18)
at java.lang.Thread.run(Thread.java:748)
19:10:40.859 [Thread-1] c.TestDateParse - {}
java.lang.NumberFormatException: empty String
at sun.misc.FloatingDecimal.readJavaFormatString(FloatingDecimal.java:1842)
at sun.misc.FloatingDecimal.parseDouble(FloatingDecimal.java:110)
at java.lang.Double.parseDouble(Double.java:538)
at java.text.DigitList.getDouble(DigitList.java:169)
at java.text.DecimalFormat.parse(DecimalFormat.java:2089)
at java.text.SimpleDateFormat.subParse(SimpleDateFormat.java:2162)
at java.text.SimpleDateFormat.parse(SimpleDateFormat.java:1514)
at java.text.DateFormat.parse(DateFormat.java:364)
...一部分线程直接抛异常,还有一部分线程解析出了荒唐的结果:
19:10:40.857 [Thread-8] c.TestDateParse - Sat Apr 21 00:00:00 CST 1951
19:10:40.857 [Thread-9] c.TestDateParse - Sat Apr 21 00:00:00 CST 1951
19:10:40.857 [Thread-6] c.TestDateParse - Sat Apr 21 00:00:00 CST 1951
19:10:40.857 [Thread-4] c.TestDateParse - Sat Apr 21 00:00:00 CST 1951
19:10:40.857 [Thread-5] c.TestDateParse - Mon Apr 21 00:00:00 CST 178960645
19:10:40.857 [Thread-0] c.TestDateParse - Sat Apr 21 00:00:00 CST 1951
19:10:40.857 [Thread-7] c.TestDateParse - Sat Apr 21 00:00:00 CST 1951
19:10:40.857 [Thread-3] c.TestDateParse - Sat Apr 21 00:00:00 CST 1951原因在于 SimpleDateFormat 内部维护着解析用的日历对象(Calendar)等可变成员,parse() 过程会不断读写这些共享状态。一个线程解析到一半,另一个线程也进来改,于是数字被拼接错、年份被算成了 178960645。
思路一:加同步锁
最容易想到的办法是把 parse 操作保护起来:
SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd");
for (int i = 0; i < 50; i++) {
new Thread(() -> {
synchronized (sdf) {
try {
log.debug("{}", sdf.parse("1951-04-21"));
} catch (Exception e) {
log.error("{}", e);
}
}
}).start();
}这样虽能解决问题,但所有线程在锁上排队,带来的是性能上的损失,并不算很好的方案。
思路二:使用不可变类
如果一个对象不能够修改其内部状态(属性),那么它就是线程安全的,因为根本不存在并发修改。这样的对象在 Java 中有很多,例如 Java 8 之后提供的新的日期格式化类 DateTimeFormatter:
DateTimeFormatter dtf = DateTimeFormatter.ofPattern("yyyy-MM-dd");
for (int i = 0; i < 10; i++) {
new Thread(() -> {
LocalDate date = dtf.parse("2018-10-01", LocalDate::from);
log.debug("{}", date);
}).start();
}DateTimeFormatter 可以放心地被所有线程共享,无需加锁。它的文档上明确写着:
This class is immutable and thread-safe.
不可变对象,实际是另一种避免竞争的方式——与其费尽心思地协调并发修改,不如让对象压根不可修改。
不可变设计
另一个大家更为熟悉的 String 类也是不可变的,以它为例说明不可变设计的要素。JDK 中 String 的声明与核心字段如下:
public final class String
implements java.io.Serializable, Comparable<String>, CharSequence {
/** The value is used for character storage. */
private final char value[];
/** Cache the hash code for the string */
private int hash; // Default to 0
// ...
}final 的使用
可以发现该类、类中关键属性都是 final 的:
- 属性用
final修饰,保证了该属性是只读的、不能修改——value这个引用一旦在构造器中被赋值,就不能再指向别的字符数组; - 类用
final修饰,保证了该类中的方法不能被覆盖,防止子类无意间(或故意)破坏不可变性。
保护性拷贝
有同学会说:使用字符串时也有一些跟“修改”相关的方法啊,比如 substring。下面就看一看这些方法是如何实现的,以 substring 为例:
public String substring(int beginIndex) {
if (beginIndex < 0) {
throw new StringIndexOutOfBoundsException(beginIndex);
}
int subLen = value.length - beginIndex;
if (subLen < 0) {
throw new StringIndexOutOfBoundsException(subLen);
}
return (beginIndex == 0) ? this : new String(value, beginIndex, subLen);
}发现其内部是调用 String 的构造方法创建了一个新字符串,而不是在原来的字符数组上截取。再进入这个构造看看,它是否对 final char[] value 做出了修改:
public String(char value[], int offset, int count) {
if (offset < 0) {
throw new StringIndexOutOfBoundsException(offset);
}
if (count <= 0) {
if (count < 0) {
throw new StringIndexOutOfBoundsException(count);
}
if (offset <= value.length) {
this.value = "".value;
return;
}
}
if (offset > value.length - count) {
throw new StringIndexOutOfBoundsException(offset + count);
}
this.value = Arrays.copyOfRange(value, offset, offset + count);
}结果发现也没有:构造新字符串对象时,会生成新的 char[] value,对内容进行复制。这种通过创建副本对象来避免共享可变内部状态的手段,称之为保护性拷贝(defensive copy)。
需要注意的细节是:final 只锁住引用不指向新数组,并不能阻止 value[0] = 'x' 这样的数组元素修改。因此不可变设计不能只靠 final,还要保证内部的可变组成部分不被外界直接接触到——不提供修改入口,必要时在构造和返回时做保护性拷贝。
自定义不可变类的要点
归纳一下,自己设计一个不可变类时通常要做到:
- 类用
final修饰,不让子类继承、覆盖行为; - 所有属性用
private final修饰,不提供 setter 等修改方法; - 构造器中如果接收到外部传入的可变对象(数组、集合、Date 等),使用保护性拷贝,避免外部持有引用后偷偷修改;
- getter 返回可变成员时同样返回副本或不可变视图,而不是原始引用;
- 对“修改”类需求(拼接、截取、加减日期),返回一个新建的对象,原对象保持不变(
String、LocalDate、BigInteger都是这个套路)。
无状态
在 Web 阶段学习 Servlet 时,为了保证线程安全都会有这样一条建议:不要为 Servlet 设置成员变量。Servlet 实例默认是单例的,会被多个请求线程并发访问,一旦有可写的成员变量,就会出现数据串用的问题。
这种没有任何成员变量的类是线程安全的——因为成员变量保存的数据也可以称为“状态信息”,所以没有成员变量就称之为无状态(Stateless)。无状态对象没有可被修改的共享状态,每个线程调用方法时用到的数据都只是各自栈上的局部变量,天然支持并发访问。
享元思想初步
不可变对象不怕共享,于是可以进一步大胆地复用它们:把常用的对象预先创建好缓存起来,需要时直接拿来用,不必每次都 new,从而节省内存和创建开销——这就是享元(Flyweight)思想的雏形。
JDK 中包装类的缓存就是最典型的例子。Integer.valueOf 会缓存 -128~127 之间的对象:
Integer i1 = Integer.valueOf(127);
Integer i2 = Integer.valueOf(127);
System.out.println(i1 == i2); // true,使用的是缓存中的同一个对象
Integer i3 = Integer.valueOf(128);
Integer i4 = Integer.valueOf(128);
System.out.println(i3 == i4); // false,超出缓存范围,每次新建对象类似地,Boolean.TRUE / Boolean.FALSE、String 的字符串常量池,都是在安全共享不可变对象。享元模式能够成立的前提正是被共享的对象不可变——如果大家手里的同一个对象能被某个人改来改去,复用反而会酿成并发事故。
本章小结
- 不可变类使用:日期转换放弃有状态、非线程安全的
SimpleDateFormat,改用不可变的DateTimeFormatter; - 不可变类设计:
final修饰类和属性,不提供修改入口,借助保护性拷贝隔离可变内部状态,“修改”时创建并返回新对象; - 无状态:不保存成员变量的类没有共享状态,天然线程安全(如不要给 Servlet 加成员变量);
- 原理方面:
final的语义保障;模式方面:不可变对象可以被安全复用,引出享元思想。