初涉并发编程 2——线程安全性

线程安全性

此章节讲解线程安全性以及 Java 提供的一些加锁机制。

多线程程序编写的核心

线程和锁之于并发编程,就如工字钢和铆钉之于房屋建筑。要想建筑坚固,钢材和铆钉的正确使用是必要的。要编写线程安全的代码,其核心就在于要对状态访问操作进行管理,特别是对共享的(shared)和可变的(Mutable)状态的访问。这里的状态指的是存储在状态变量(如静态/实例域)中的数据。其也可能包括其他依赖对象的域,比如 Map 的状态不仅保存在 HashMap 对象本身,也抱存在无数个 Map.Entry 对象中。对象的状态包含任何外部可见的数据。

“共享”意味着对象能够被多个线程同时访问,“可变”指对象的值在生命周期内会发生变化。线程安全要求各线程以同步机制来协调对可变状态的访问。

Java 提供的主要同步机制是 synchronized 关键字,但是 volatile,显式锁(Explicit Lock)和原子变量等也是同步机制的一部分。(这句话可能已经过时了)

线程安全类

当访问一个可变状态又没有使用合适的同步,就容易出现错误,有三种方法修复它——

  1. 不共享该状态
  2. 更改其为不可变状态
  3. 使用同步机制(大多数情况下……只有这个选择了吧)

这些方法或许会导致代码的重大更改,所以应当从一开始就设计一个线程安全的类。同时,良好的封装也让线程安全更容易实现——访问某个变量的代码越少,越容易确保对变量的访问实现正确同步。然后,性能优化只有在必须优化,且优化必定有效果时才进行。“提前优化是万恶之源”。

(我认为)线程安全类可以这样定义——其实际行为和其应当的行为完全一致,而无论单线程环境或是多线程环境,无论不同线程对其的调用顺序如何,无论有无采取同步机制。也就是说,不需要外界的程序(类)进行任何额外的同步或协同操作,这个类总是正确工作的(其实这时候在外界看来,线程安全类提供的所有操作都是原子的)。

无状态的对象一定是线程安全的,因为它和其它对象没有共享状态,各玩各的,不会相互影响。

加锁机制

(注意,这里说的是机制,它并不局限于语言!)

线程安全性要求,多个线程之间的操作无论采用何种执行时序或交替方式,都要保证不变性不被破坏。(“不变”是指在某个线程执行该操作的这整个周期中不变)

考虑下面的代码,它描述了一个非线程安全的 Servlet,其功能为返回一个整型变量的因数分解(它是一个数组),同时缓存上一次的值,如果为同一个值,则直接返回缓存中的值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@NotThreadSafe // 这是作者定义的注解,只起描述作用
public class UnsafeCachingFactorizer implements Servlet {
private final AtomicReference<BigInteger> lastNumber = new AtomicReference<>(); // 先无论这是什么玩意
private final AtomicReference<BigInteger[]> lastFactors = new AtomicReference<>();

public void service(ServletRequest reg, ServletResponse resp) {
BigInteger i = extractFromRequest(reg);
if (i.equals(lastNumber.get())) // 缓存命中
encodeIntoResponse(resp, lastFactors.get()); // 这些原子类的所有操作都是原子的
else {
BigInteger[] factors = factor(i);
lastNumber.set(i);
lastFactors.set(factors);
encodeIntoResponse(resp, factors);
}
}
}

这里的不变性的条件之一是,lastNumber 缓存的值的因数之积应当等于 lastFactors 中的值(这个条件在一个线程执行这个操作的整个周期中都不能被改变),这里显然是线程不安全的。通过加锁机制,可以简单地为操作提供原子性,从而保证线程安全性。

内置锁

Java 提供了一种内置的锁来支持原子性——同步代码块(Synchronized Block)。它可以作为关键字来修饰一个方法(对于静态方法,它使用 Class 对象作为对象,对于实例方法,使用 this 来作为对象),也可以以这种形式来规定一个代码块,这里的 lock 可以是任何类型的对象(书中说是 Class 对象,我觉得是翻译错误,它没理由接受一个java.lang.Class对象)——

1
2
3
synchronized (lock) {
. . .
}

这里对象的作用是提供一个实现同步的锁,他称为内置锁(Intrinsic Lock)或监视器锁(Monitor Lock,这个似乎是用的更多的术语)。该锁为其保护起来的代码块提供原子性——每次只能有一个线程执行代码块。当有一个线程进入该代码块,它将获得该代码块的锁,即使该线程被切换出去,其它线程也无法进入代码块,直到该线程执行完代码块并释放锁。因此,这里的一组语句是作为一个不可分割的单元执行的。

上面的 Servlet 中的 service 方法如果使用 synchronized 关键字修饰的话,就能正确地缓存了——每一时刻仅有一个线程能执行该方法,但是它的并发性是非常糟糕的(不过这不是线程安全问题,是性能问题),因为当多个请求到来的时候,它只能串行处理,即使有多处理器也无济于事。

内置锁是可重入的,也就是说,一个线程如果持有了一个对象的锁,它还能再次获取这个对象的锁并保证不会发生死锁。重入的一个实现是给锁关联一个计数器和其所有者线程,如果计数器值为 0 则说明其未被持有,当线程请求一个未被持有的锁时,JVM 记录锁的持有者,将计数器值设为 1, 如果该线程再次请求,则计数器递增,每次退出代码块时,计数器递减,计数器为 0 时锁被释放。

可重入锁的粒度是“线程”而不是“调用”。

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
// 因为对象锁是可重入的,所以这样使用不会产生死锁。
// 这里把 doSomething 方法设为实例方法而非静态方法,是因为如果是静态方法,则相当于是先获取 Son 的 Class 对象锁(这个似乎叫类锁?),再获取 Super 的 Class 对象锁,它只能证明一个线程能同时持有多个锁,而不能证明内置锁是可重入的。
class Super {
public synchronized void doSomething() {
System.out.println("do something");
}
}
class Son extends Super{
@Override
public synchronized void doSomething() {
System.out.println("do some preparation");
super.doSomething();
}
}
public class Main {
public static void main(String[] args) {
new Son().doSomething();
}
}
// 上面的例子不明显,换一个
public class Main {
public static void main(String[] args) {
synchronized (this) {
synchronized (this) {
synchronized (this) {
synchronized (this) {
synchronized (this) {
System.out.println("hello, world!");
}
}
}
}
}
}
}

用锁保护状态

锁要求其保护的代码被串行访问,因此可以通过锁构造一些协议来实现对共享状态的独占访问(就如上面的 Servlet 中的缓存)。

访问共享对象的复合操作必须是原子操作以避免产生竞态条件。如果用同步来协调对某个变量的访问,则在访问该变量的所有位置都应当使用同步。当使用锁来协调对某个变量的访问时,在访问变量的所有地方都应当使用同一个锁。这种情况下,称状态变量被这个锁保护如果对象的不变性涉及多个状态变量时,不变性条件中的每一个变量都必须被同一个锁保护

一种约定是,将所有可变状态封装到对象的内部并通过内置锁对所有访问可变状态的代码路径进行同步以避免并发访问。这种加锁协议是非强制的。

为什么不给每一个方法都设置synchronized关键字?因为这会导致许多多余的同步,而且并不能保证外界对其的复合操作是原子的,比如线程安全的Vector类,这样使用的话,仍然会出现竞态条件,还会导致活跃性和性能问题。

1
2
if (!vector.contains(element))
vector.add(element);

活跃性和性能

上面的 Servlet 是不良并发(Poor Concurrency)的。下面的新的实现进行了更改。它重新引入了两个计数器,分别计数调用次数和缓存命中次数。它引入两个同步代码块,第一个同步代码块检查是否命中,第二个同步代码块更新缓存。

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
@ThreadSafe
public class CachingFactorizer implements Servlet {
@GuardedBy("this") private BigInteger lastNumber; //这里不需要使用原子引用了
@GuardedBy("this") private BigInteger[] lastFactors;
@GuardedBy("this") private long counts;
@GuardedBy("this") private long hits;
public synchronized long getHits(){
return counts;
}
public synchronized double getCacheHitRatio() {
return (double) hits / (double) counts;
}
public void service(ServletRequest reg, ServletResponse resp) {
BigInteger i = extractFromRequest(reg);
BigInteger[] factors = null;
synchronized (this) {
counts++; // 对于两个计数器的操作其实也可以分解在更细的代码块中,但是这样会带来不必要的锁的切换的开销。
if (i.equals(lastNumber)) { //缓存命中
hits++;
factors = lastFactors.clone(); // 我怀疑这个 clone 的必要性!
}
}
if (factors == null) { // 未命中
factors = factor(i); // 因数分解操作可能是耗时的,并且它不需要状态变量,因此可以不包裹在同步代码块中,以尽量提高并发性。
synchronized (this) { // 缓存的因数分解和值必须同时被更新,这是毫无疑问的。
lastNumber = i;
lastFactors = factors.clone(); // 同样,这里的 clone 我也持怀疑态度。
}
}
encodeIntoResponse(resp, factors); //这里应当返回 factors,如果返回 lastFactors,则也需要包裹在同步代码块中。
}

我认为这里使用 clone 是没有必要的。毕竟,若不使用 clone,在不在同步代码块时若 lastFactors 被赋了新值,只不过是它指向了一个新的地址,原来的地址仍旧是被 factors 引用着的,所以用不用 clone 无关紧要。这里没有任何代码调用过 lastFactors 的 setter 吧?

重新构造后的该类取得了在简单性(对整个方法进行同步)与并发性(对尽可能短的代码路径进行同步)中的平衡。不要盲目地为了性能而放弃简单性,因为这会导致维护困难,破坏线程安全性。

执行时间较长的计算或可能无法快速完成的操作(如 I/O),一定不要持有锁

换个图床……


本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 协议 ,转载请注明出处!