返回文章列表
JUC并发编程
JUC不可变线程安全

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,还要保证内部的可变组成部分不被外界直接接触到——不提供修改入口,必要时在构造和返回时做保护性拷贝。

自定义不可变类的要点

归纳一下,自己设计一个不可变类时通常要做到:

  1. 类用 final 修饰,不让子类继承、覆盖行为;
  2. 所有属性用 private final 修饰,不提供 setter 等修改方法;
  3. 构造器中如果接收到外部传入的可变对象(数组、集合、Date 等),使用保护性拷贝,避免外部持有引用后偷偷修改;
  4. getter 返回可变成员时同样返回副本或不可变视图,而不是原始引用;
  5. 对“修改”类需求(拼接、截取、加减日期),返回一个新建的对象,原对象保持不变(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 的语义保障;模式方面:不可变对象可以被安全复用,引出享元思想。