《Java 并发编程实战》读书笔记二:第2章 线程安全性

要编写线程安全的代码,其核心在于要对状态访问操作进行管理,特别是对共享的(Shared)和可变的(Mutable)状态的访问。

从非正式的意义上来说,对象的状态是指存储在状态变量(例如实例或静态域)中的数据。对象的状态可能包括其他依赖对象的域。例如,某个 HashMap 的状态不仅存储在 HashMap 对象本身,还存储在许多 Map.Entry 对象中。在对象的状态中包含了任何可能影响其外部可见行为的数据。

一个对象是否需要是线程安全的,取决于它是否被多个线程访问。这指的是在程序中访问对象的方式,而不是对象要实现的功能。要使得对象是线程安全的,需要采用同步机制来协同对对象可变状态的访问。如果无法实现协同,那么可能会导致数据破坏以及其他不该出现的结果。

当多个线程访问某个状态变量并且其中有一个线程执行写入操作时,必须采用同步机制来协同这些线程对变量的访问。Java 中主要同步机制是关键字 synchronized,它提供了一种独占的加锁方式,但“同步”这个术语还包括 volatile 类型的变量,显示锁(Explict Lock)以及原子变量。

如果有多个线程访问一个可变的状态变量时没有使用合适的同步,那么程序就会出现错误,有三种方式可以修复这个问题:
1) 不在线程之间共享该状态变量
2) 将状态变量修改为不可变的变量
3) 在访问该状态变量时使用同步

2.1 什么是线程安全性

当多个线程访问某个类时,这个类始终都能表现出正确的行为,那么就称这个类是线程安全的。

由于单线程程序也可以看成是一个多线程程序,如果某个类在单线程环境中都不是正确的,那么它肯定也不会是线程安全的。如果正确地实现了某个对象,那么在任何操作中都不会违背不变性条件或后验条件,在线程安全类的对象实例上执行的任何串行或并行操作都不会使对象处于无效状态。

在线程安全类中封装了必要的同步机制,因此客户端无须进一步采取同步措施。

示例:一个无状态的 Servlet

下面给出了一个简单的因数分解 Servlet。这个 Servlet 从请求中提取出数值,执行因数分解,然后将结果封装到该 Servlet 的响应中。

@ThreadSafe
public class StatelessFactorizer implements Servlet {
    public void service(ServletRequest request, ServletResponse resp) {
        BigInteger i = extractFromRequest(req);
        BigInteger[] factors = factor(i);
        encodeIntoResponse(resp, factors);
    }
}

与大多数 Servlet 相同, StatelessFactorier 是无状态的:它既不好喊任何域,也不包含对其他类中域的引用。计算过程中的临时状态仅存在于线程栈上的局部变量中,并且只能由正在执行的线程访问。由于线程访问无状态对象的行为并不会影响其他线程中操作的正确性,因此无状态对象是线程安全的。

无状态对象一定是线程安全的。

2.2 原子性

当我们在无状态对象中增加一个状态时,会出现什么情况?假设我们希望增加一个“命中计数器”(Hit Counter)来统计所处理的请求数量。一种直观的方法是在 Servlet 中增加一个 long 类型的域,并且每处理一个请求就将这个值加1,如下面程序所示。

图2
不幸的是,UnsafeCountingFactorier 并非线程安全的。虽然++count是一种紧凑的语法,但它包含了三个独立的操作:读取 count 的值,将值加1,然后将计算结果写入 count。这是一个“读取 - 修改 - 写入”的操作序列,并且其结果状态依赖于之前的状态。

在并发编程中,这种由于不恰当的执行时序而出现不正确的结果是一种非常重要的情况,它有一个正式的名字:竞态条件(Race Conditition)。

扫描二维码关注公众号,回复: 1690126 查看本文章

2.2.1 竞态条件

当某个计算的正确性取决于多个线程的交替执行时序时,那么就会发生竞态条件。最常见的竞态条件类型就是“先检查后执行(Check-Then-Act)”操作,即通过一个可能失效的观测结果来决定下一步的动作。

2.2.2 示例:延迟初始化中的竞态条件

使用“先检查后执行”的一种常见情况就是延迟初始化。延迟初始化的目的是将对象的初始化操作推迟到实际被使用时才进行,同时要确保只被初始化一次。在程序清单 2-3 中的 LazyInitRace 说明了这种延迟初始化情况。

图3

在 LazyInitRace 中包含了一个竞态条件,它可能会破坏这个类的正确性。假定线程 A 和线程 B 同时执行 getInstance。A看到instance为空,因而创建一个新的 ExpensiveObject 实例。B同样需要判断 instance 是否为空。此时的instance 是否为空,要取决于不可预测的时序,包括线程的调度方式,以及 A 需要花多长时间来初始化 ExpensiveObject 并设置 instance。如果当 B 检查时,instance 为空,那么在两次调用 getInstance 时可能会得到不同的结果,即使 getInstance 通常被认为是返回相同的实例。

2.2.3 复合操作

LazyInitRace 和 UnsafeCountingFactorizer 都包含一组需要以原子方式执行(或者说不可分割)的操作。要避免竞态条件问题,就必须在某个线程修改该变量时,通过某种方式防止其他线程使用这个变量。从而确保其他线程只能在修改操作完成之前或之后读取和修改状态,而不是在修改状态的过程中。

如果 UnsafeSequence 中的递增操作是原子操作,那么其中的竞态条件就不会发生,并且递增操作在每次执行时都会把计数器增加1.为了确保线程安全性,“先检查后只需”(例如延迟初始化)和“读取 - 修改 - 写入”(例如递增运算)等操作必须是原子的。我们将“先检查后执行”以及“读取 - 修改 - 写入”等操作称为复合操作:包含了一组必须以原子方式执行的操作以确保线程安全性。在 2.3 节中,我们将介绍加锁机制,这是 Java 中用于确保原子性的内置机制。就目前而言,我们先采用另外一种方式来修复这个问题,即使用一个现有的线程安全类,如程序清单 2-4 中的 CountingFactorizer 所示。

图4]![这里写图片描述

在 java.util.concurrent.atomic 包中包含了一些原子变量类,用于实现在数值和对象引用上的原子状态转换。通过 AtomicLong 来代替 long 类型的计数器,能够保证所有对计数器状态的访问操作都是原子的。由于 Servlet 的状态就是计数器的状态,并且计数器是线程安全的,因此这里的 Servlet 也是线程安全的。

当在无状态的类中添加一个状态时,如果该状态完全由线程安全的对象来管理,那么这个类仍然是线程安全的。

2.3 加锁机制

假设我们希望提升 Servlet 的性能:将最近的计算结果缓存起来,当两个连续的请求对相同的数值进行因数分解时,可以直接使用上一次的计算结果,而无须重新计算。(这并非一种有效的缓存策略,5.6 节将给出一种更好的策略)要实现该缓存策略,需要保存两个状态:最近执行因数分解的数值,以及分解结果。

图5]![这里写图片描述

然后这种方法并不正确。尽管这些原子引用本身都是线程安全的,但在 UnsafeCachingFactorizer 中存在着竞态条件,这可能导致产生错误的结果。

UnsafeCachingFactorizer 的不变性条件之一是:在 lastFactors 中缓存的因数之积应该等于在 lastNumber 中缓存的数值。只有确保了这个不变性条件不被破坏,上面的 Servlet 才是正确的。

然而,在某些执行时序中, UnsafeCachingFactorizer 可能会破坏这个不变性条件。在使用原子引用的情况下,尽管对 set 方法的每次调用都是原子的,但仍然无法同时更新 lastNumber 和 lastFactors。如果只修改了其中一个变量,那么在这两次修改操作之间,其他线程将发现不变性条件被破坏了。同时,我们也不能保证会同时获取两个值:在线程 A 获取这两个值的过程中,线程 B 可能修改了它们,这样线程 A 也会发现不变性条件被破坏了。

要保持状态的一致性,就需要再耽搁原子操作中更新所有相关的状态变量。

2.3.1 内置锁

Java 提供了一种内置的锁机制来支持原子性:同步代码块(Synchronized Block)。(第 3 章将介绍加锁机制以及其他同步机制的另一个重要方面:可见性)同步代码块包括两个部分:一个作为锁的对象引用,一个作为由这个锁保护的代码块。以关键字 synchronized 来修饰的方式就是一种横跨整个方法体的同步代码块,其中该同步代码块的锁就是方法调用所在的对象。静态的 synchronized 方法以 Class 对象作为锁。

synchronized (lock) {
    // 访问或修改由锁保护的共享状态
}

图6

图7

2.3.2 重入

图8

2.4 用锁来保护状态

由于锁能使其保护的代码路径以串行形式来访问,因此可以通过锁来构造一些协议实现对共享状态的独占访问。只要始终遵循这些协议,就能确保状态的一致性。

一种常见的错误是认为,只有在写入共享变量时才需要使用同步,然而事实并非如此(3.1 节将进一步解释其中的原因)。

对于可能被多个线程同时访问的可变状态变量,在访问它时都需要持有同一个锁,在这种情况下,
我们称状态变量是由这个锁保护的。

一种常见的加锁约定是,将所有的可变状态都封装在对象内部,并通过对象的内置锁对所有访问可变状态的代码路径进行同步,使得在该对象上不会发生并发访问。在许多线程安全类中都使用了这种模式,例如 Vector 和其他的同步集合类。在这种情况下,对象状态中的所有变量都是由对象的内置锁保护起来。

虽然 synchronized 方法可以确保单个操作的原子性,但如果要把多个操作合并为一个复合操作,
还是需要额外的加锁机制(请参见 4.4 节了解如歌在线程安全对象中添加原子操作的方法)。
例如 Vector 的如下操作:

if(!vector.contains(element)) {
    vector.add(element);
}

虽然 contains 和 add 等方法都是原子方法,但在上面这个 “ 如果不存在则添加(put-if-absent)” 的操作中
仍然存在竞态条件。

2.5 活跃性与性能

要判断同步代码块的合理大小,需要在各种需求之间进行权衡,包括安全性(这个需求必须得到满足)、简单性和性能。有时候,在简单性与性能之间会发生冲突,但通常能在两者之间找到某种合理的平衡。例如下面的 CachedFactorizer。

图9

当使用锁时,你应该清楚代码块中实现的功能,以及在执行该代码块时是否需要很长的时间。无论是执行计算密集的操作,还是在执行某个可能阻塞的操作,如果持有锁的时过长,那么都会带来活跃性或性能问题。

当执行时间较长的计算或者可能无法快速完成的操作时(例如,网络 I/O 或控制台 I/O),一点不要持有锁。

猜你喜欢

转载自blog.csdn.net/xxc1605629895/article/details/80758725