13.2.1 Java语言中的线程安全

13.2.1 Java语言中的线程安全

我们已经有了线程安全的一个可操作的定义,那接下来就讨论一下:在Java语言中,线程安全具体是如何体现的?有哪些操作是线程安全的?我们这里讨论的线程安全,将以多个线程之间存在共享数据访问为前提。因为如果根本不存在多线程,又或者一段代码根本不会与其他线程共享数据,那么从线程安全的角度上看,程序是串行执行还是多线程执行对它来说是没有什么区别的。

为了更深入地理解线程安全,在这里我们可以不把线程安全当作一个非真即假的二元排他选项来看待,而是按照线程安全的“安全程度”由强至弱来排序,我们[^1]可以将Java语言中各种操作共享的数据分为以下五类:不可变、绝对线程安全、相对线程安全、线程兼容和线程对立。

1.不可变

在Java语言里面(特指JDK 5以后,即Java内存模型被修正之后的Java语言),不可变 (Immutable)的对象一定是线程安全的,无论是对象的方法实现还是方法的调用者,都不需要再进行任何线程安全保障措施。在第10章里我们讲解“final关键字带来的可见性”时曾经提到过这一点:只要一个不可变的对象被正确地构建出来(即没有发生this引用逃逸的情况),那其外部的可见状态永远都不会改变,永远都不会看到它在多个线程之中处于不一致的状态。“不可变”带来的安全性是最直接、 最纯粹的。

Java语言中,如果多线程共享的数据是一个基本数据类型,那么只要在定义时使用final关键字修饰它就可以保证它是不可变的。如果共享数据是一个对象,由于Java语言目前暂时还没有提供值类型的支持,那就需要对象自行保证其行为不会对其状态产生任何影响才行。如果读者没想明白这句话所指的意思,不妨类比java.lang.String类的对象实例,它是一个典型的不可变对象,用户调用它的substring()、replace()和concat()这些方法都不会影响它原来的值,只会返回一个新构造的字符串对象。

保证对象行为不影响自己状态的途径有很多种,最简单的一种就是把对象里面带有状态的变量都声明为final,这样在构造函数结束之后,它就是不可变的,例如代码清单13-1中所示的java.lang.Integer 构造函数,它通过将内部状态变量value定义为final来保障状态不变。

代码清单13-1 JDK中Integer类的构造函数
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/**
* The value of the <code>Integer</code>.
* @serial
*/
private final int value;
/**
* Constructs a newly allocated <code>Integer</code> object that
* represents the specified <code>int</code> value.
*
* @param value the value to be represented by the
* <code>Integer</code> object.
*/
public Integer(int value) {
this.value = value;
}

在Java类库API中符合不可变要求的类型,除了上面提到的String之外,常用的还有枚举类型及java.lang.Number的部分子类,如Long和Double等数值包装类型、BigInteger和BigDecimal等大数据类型。 但同为Number子类型的原子类AtomicInteger和AtomicLong则是可变的,读者不妨看看这两个原子类的源码,想一想为什么它们要设计成可变的。

2.绝对线程安全

绝对的线程安全能够完全满足Brian Goetz给出的线程安全的定义,这个定义其实是很严格的,一个类要达到“不管运行时环境如何,调用者都不需要任何额外的同步措施”可能需要付出非常高昂的, 甚至不切实际的代价。在Java API中标注自己是线程安全的类,大多数都不是绝对的线程安全。我们可以通过Java API中一个不是“绝对线程安全”的“线程安全类型”来看看这个语境里的“绝对”究竟是什么意思。

如果说java.util.Vector是一个线程安全的容器,相信所有的Java程序员对此都不会有异议,因为它的add()、get()和size()等方法都是被synchronized修饰的,尽管这样效率不高,但保证了具备原子性、 可见性和有序性。不过,即使它所有的方法都被修饰成synchronized,也不意味着调用它的时候就永远都不再需要同步手段了,请看看代码清单13-2中的测试代码。

代码清单13-2 对Vector线程安全的测试
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
private static Vector<Integer> vector = new Vector<Integer>();
public static void main(String[] args) {
while (true) {
for (int i = 0;i < 10;i++) {
vector.add(i);
}
Thread removeThread = new Thread(new Runnable() {@Override
public void run() {for (int i = 0;i < vector.size();i++) {vector.remove(i);}}});
Thread printThread = new Thread(new Runnable() {@Override
public void run() {for (int i = 0;i < vector.size();i++) {System.out.println((vector.get(i)));}}});
removeThread.start();
printThread.start();
//不要同时产生过多的线程,否则会导致操作系统假死
while (Thread.activeCount() > 20);
}
}

运行结果如下:

1
2
3
4
5
Exception in thread "Thread-132" java.lang.ArrayIndexOutOfBoundsException:
Array index out of range: 17
at java.util.Vector.remove(Vector.java:777)
at org.fenixsoft.mulithread.VectorTest$1.run(VectorTest.java:21)
at java.lang.Thread.run(Thread.java:662)

很明显,尽管这里使用到的Vector的get()、remove()和size()方法都是同步的,但是在多线程的环境中,如果不在方法调用端做额外的同步措施,使用这段代码仍然是不安全的。因为如果另一个线程恰好在错误的时间里删除了一个元素,导致序号i已经不再可用,再用i访问数组就会抛出一个ArrayIndexOutOfBoundsException异常。如果要保证这段代码能正确执行下去,我们不得不把removeThread和printThread的定义改成代码清单13-3所示的这样。

代码清单13-3 必须加入同步保证Vector访问的线程安全性
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
Thread removeThread = new Thread(new Runnable() {
@Override
public void run() {
synchronized (vector) {
for (int i = 0; i < vector.size(); i++) {
vector.remove(i);
}
}
}
});

Thread printThread = new Thread(new Runnable() {
@Override
public void run() {
synchronized (vector) {
for (int i = 0; i < vector.size(); i++) {
System.out.println((vector.get(i)));
}
}
}
});

假如Vector一定要做到绝对的线程安全,那就必须在它内部维护一组一致性的快照访问才行,每次对其中元素进行改动都要产生新的快照,这样要付出的时间和空间成本都是非常大的。

3.相对线程安全

相对线程安全就是我们通常意义上所讲的线程安全,它需要保证对这个对象单次的操作是线程安全的,我们在调用的时候不需要进行额外的保障措施,但是对于一些特定顺序的连续调用,就可能需要在调用端使用额外的同步手段来保证调用的正确性。代码清单13-2和代码清单13-3就是相对线程安全的案例。

在Java语言中,大部分声称线程安全的类都属于这种类型,例如Vector、HashTable、Collections的synchronizedCollection()方法包装的集合等。

4.线程兼容

线程兼容是指对象本身并不是线程安全的,但是可以通过在调用端正确地使用同步手段来保证对象在并发环境中可以安全地使用。我们平常说一个类不是线程安全的,通常就是指这种情况。Java类库API中大部分的类都是线程兼容的,如与前面的Vector和HashTable相对应的集合类ArrayList和HashMap等。

5.线程对立

线程对立是指不管调用端是否采取了同步措施,都无法在多线程环境中并发使用代码。由于Java 语言天生就支持多线程的特性,线程对立这种排斥多线程的代码是很少出现的,而且通常都是有害的,应当尽量避免。

一个线程对立的例子是Thread类的suspend()和resume()方法。如果有两个线程同时持有一个线程对象,一个尝试去中断线程,一个尝试去恢复线程,在并发进行的情况下,无论调用时是否进行了同步,目标线程都存在死锁风险——假如suspend()中断的线程就是即将要执行resume()的那个线程,那就肯定要产生死锁了。也正是这个原因,suspend()和resume()方法都已经被声明废弃了。常见的线程对立的操作还有System.setIn()、Sytem.setOut()和System.runFinalizersOnExit()等。

[^1]: 这种划分方法也是Brian Goetz发表在IBM developWorkers上的一篇论文中提出的,这里写“我们”纯 粹是笔者下笔行文中的语言用法,并非由笔者首创。