第2章 线程安全性
第2章 线程安全性
陶邦仁 发表于3年前
第2章 线程安全性
  • 发表于 3年前
  • 阅读 219
  • 收藏 3
  • 点赞 0
  • 评论 0

在构建稳健的并发程序时,必须正确地使用线程和锁。但这些终归只是一些机制。要编写线程安全的代码,其核心在于要对状态访问操作进行管理,特别是对共享的(Shared)和可变的(Mutable)状态的访问。

从非正式的意义上来说,对象的状态是指存储在状态变量(例如:实例或静态变量)中的数据。对象的状态可能包括其他依赖对象的域。

“共享”意味着变量可以由多个线程同时访问,而“可变”则意味着变量的值在其生命周期内可以发生变化。一个对象是否需要是线程安全的,取决于它是否被多个线程访问。这指的是在程序中访问对象的方式,而不是对象要实现的功能。要使得对象是线程安全的,需要采用同步机制来协同对对象可变状态的访问。如果无法实现协同,那么可能导致数据破坏以及其他不该出现的结果。

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

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

当设计线程安全的类时,良好的面向对象技术,不可修改性,以及明晰的不变性规范都能起到一定的帮助作用。在有些情况中,可能需要牺牲一些良好的设计原则,以换取性能或者对遗留代码的向后兼容。有时候,面向对象中的抽象和封装会降低程序的性能(尽管很少有开发人员相信),但在编写并发应用程序时,一种正确的编程方法就是:首先使代码正确运行,然后再提高代码的速度。

##2.1 什么是线程安全性## 在线程安全性的定义中,最核心的概念就是正确性。如果对线程安全性的定义是模糊的,那么就是因为缺乏对正确性的清晰定义。当多个线程访问某个类时,不管运行时环境采用何种调度方式或者这些线程将如何交替执行,并且在主调代码中不需要任何额外的同步或协同,这个类都能表现出正确的行为,那么就称这个类是线程安全的。

>注意:无状态对象一定是线程安全的。

##2.2 原子性## 在并发编程中,这种由于不恰当的执行时序而出现不正确的结果是一种非常重要的情况,它有一个正式的名字:竞态条件。 ###2.2.1 竞态条件### 当某个计算的正确性取决于多个线程的交替执行时序时,那么就会发生竞态条件。换句话说,就是正确的结果取决于运气。最常见的竞态条件类型就是“先检查后执行”操作,即通过一个可能失效的观测结果来决定下一步的动作。

竞态条件,因为要获得正确的结果,必须取决于事件的发生时序。对于观测结果的失效就是大多数竞态条件的本质—基于一种可能失效的观测结果来做出判断或者执行某个计算。这种类型的竞态条件称为“先检查后执行”:首先观测到某个条件为真,然后根据这个观测结果采用相应的动作,但事实上,在你观测到这个结果时,该观测结果可能变得无效,从而导致各种问题。 ###2.2.2 示例:延迟初始化中的竞态条件### 使用“先检查后执行”的一种常见情况就是延迟初始化。延迟初始化的目的就是将对象的初始化操作推迟到实际被使用时才进行,同时要确保只被初始化一次。但如果出现竞态条件问题,可能会被多个线程多次进行初始化。

>注意:竞态条件容易与数据竞争相混淆。数据竞争是指,如果在访问共享的非final类型的域时没有采用同步来进行协同,那么就会出现数据竞争。当一个线程写入一个变量而另一个线程接下来读取这个变量,或者读取一个之前由另一个线程写入的变量时,并且在这两个线程间没有使用同步,那么就可能会出现数据竞争。在Java的内存模型中,如果在代码中存在数据竞争,那么这段代码就没有确定的语义。并非所有的竞态条件都是数据竞争,同样并非所有的数据竞争都是静态条件,但二者都可能使并发程序失败。

###2.2.3 复合操作### 要避免竞态条件,就必须在某个线程修改该变量时,通过某种方式防止其他线程使用这个变量,从而确保其他线程只能在修改操作完成之前或之后读取和修改状态,而不是在修改状态的过程中。

>假定有两个操作A和B,如果从执行A的线程来看,当另一个线程执行B时,要么将B全部执行完,要么完全不执行B,那么A和B对彼此来说是原子的。原子操作是指,对于访问同一个状态的所有操作(包括该操作本身)来说,这个操作是一个以原子方式执行的操作。

为了确保线程安全性,“先检查后执行(例如延迟初始化)”和“读取-修改-写入(例如递增运算)”等操作必须是原子的。我们将“先检查后执行”以及“读取-修改-写入”等操作统称为复合操作:包含了一组必须以原子方式执行的操作以确保线程安全性。在java.util.concurrent.atomic包中包含了一些原子变量类,用于实现在数值和对象引用上的原子状态转换。 ##2.3 加锁机制## 在线程安全性的定义中要求,多个线程之间的操作无论采用何种执行时序或交替方式,都要保证不变性条件不被破坏。当在不变性条件中涉及多个变量时,各个变量之间并不是彼此独立的,而是某个变量的值会对其他变量的值产生约束。因此,当更新某一个变量时,需要在同一个原子操作中对其他变量同时进行更新。

>注意:要保持状态的一致性,就需要在单个原子操作中更细所有相关的状态变量。

###2.3.1 内置锁### Java提供了一种内置的锁机制来支持原子性:同步代码块(Synchronized Block)。其包括两部分:(1)一个作为锁的对象引用;(2)一个作为由这个锁保护的代码块。以关键字synchronized来修饰的方法就是一种横跨整个方法体的同步代码块,其中该同步代码块的锁就是方法调用所在的对象。静态的synchronized方法以Class对象作为锁。

每个Java对象都可以用做一个实现同步的锁,这些锁被称为内置锁(Intrinsic Lock)或监视器锁(Monitor Lock)。线程在进入同步代码块之前会自动获得锁,并且在退出同步代码块时自动释放锁,而无论是通过正常的控制路径退出,还是通过从代码块中抛出异常退出。获得内置锁的唯一途径就是进入由这个锁保护的同步代码块或方法。

Java的内置锁相当于一个互斥体(或互斥锁),这意味着最多只有一个线程能持有这种锁。当线程A尝试获取一个线程B持有的锁时,线程A必须等待或阻塞,直到线程B释放这个锁。如果线程B永远不释放锁,那么线程A也将永远地等下去。

由于每次只能有一个线程执行内置锁保护的代码块,因此,由这个锁保护的同步代码块会以原子方式执行,多个线程在执行该代码块时也不会相互干扰。并发环境中的原子性与事务应用程序中的原子性有着相同的含义—一组语句作为一个不可分割的单元被执行。任何一个执行同步代码块的线程,都不可能看到有其他线程正在执行由同一个锁保护的同步代码块。

###2.3.2 重入### 当某个线程请求一个由其他线程持有的锁时,发出请求的线程就会阻塞。然而,由于内置锁是可重入的,因此如果某个线程试图获得一个已经由它自己持有的锁,那么这个请求就会成功。“重入”意味着获取锁的操作的粒度是“线程”,而不是“调用”。重入的一种实现方法是,为每个锁关联一个获取计数值和一个所有者线程。当计数值为0时,这个锁被认为是没有被任何线程持有。当线程请求一个未被持有的锁时,JVM将记下锁的持有者,并且将获取计数值置为1。如果同一个线程再次获取这个锁,计数值将递增,而当线程退出同步代码块时,计数器将递减。当计数值为0时,这个锁将被释放。

程序清单:如果内置锁不是可重入的,那么这段代码将发生死锁

public class Widget {
    public synchronized void doSomething() {
        ...
    }
}

public class LoggingWidget extends Widget {
    public synchronized void doSomething() {
        System.out.println(toString() + " : call doSomething");
        super.doSomething();
    }
}

重入进一步提升了加锁行为的封装性,因此简化了面向对象并发代码的开发。以上代码,子类改写了父类synchronized方法,然后调用父类中的方法,此时如果没有可重入的锁,那么这段代码将产生死锁。由于Widget和LoggingWidget中doSomething()方法都是synchronized方法,因此每个doSomething方法在执行前都会获取Widget上的锁。然而,如果内置锁是不可重入的,那么在调用super.doSomething时将无法获得Widget上的锁,因为这个锁已经被持有,从而线程将永远停顿下去,等待一个永远也无法获得的锁。重入则避免了这种死锁情况的发生。 ##2.4 用锁来保护状态## 由于锁能使其保护的代码路径以串行形式来访问,因此可以通过锁来构造一些协议以实现对共享状态的独占访问。只要始终遵循这些协议,就能确保状态的一致性。

访问共享状态的复合操作,例如命中计数器的递增操作(读取—修改—写入)或延迟初始化(先检查后执行),都必须是原子操作以避免产生竞态条件。如果在复合操作的执行过程中持有一个锁,那么会使复合操作成为原子操作。然而,仅仅将复合操作封装到一个同步代码块中是不够的。如果用同步来协调对某个变量的访问,那么在访问这个变量的所有位置上都需要使用同步。而且,当使用锁来协调多某个变量的访问时,在访问变量的所有位置上都要使用同一个锁。

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

对象的内置锁与其状态之间没有内在的关联。虽然大多数类都将内置锁用做一种有效的加锁机制,但对象的域并不一定要通过内置锁来保护。当获取与对象关联的锁时,并不能阻止其他线程访问该对象,某个线程在获得对象的锁之后,只能阻止其他线程获得同一个锁。之所以每个对象都有一个内置锁,只是为了免去显式地创建锁对象。

>每个共享的和可变的变量都应该只有一个锁来保护,从而使维护人员直到是哪一个锁。

一种常见的加锁约定是,将所有的可变状态都封装在对象内部,并通过对象的内置锁对所有访问可变状态的代码路径进行同步,使得在该对象上不会发生并发访问。在许多线程安全类中都使用了这种模式,例如Vector和其他的同步集合类。在这种情况下,对象状态中的所有变量都由对象的内置锁保护起来。然而,这种模式并没有任何特殊之处,编译器或运行时都不会强制实施这种模式。如果在添加新的方法或代码路径时忘记了使用同步,那么这种加锁协议会很容易被破坏。

>注意:(1)对象的串行访问与对象的序列化操作毫不相干。串行访问意味着多个线程依次以独占的方式访问对象,而不是并发地访问。

当某个变量由锁来保护时,意味着在每次访问这个变量时都需要首先获得锁,这样就确保在同一时刻只有一个线程可以访问这个变量。当类的不变性条件涉及到多个状态变量时,那么还有另外一个需求:在不变性条件中的每个变量都必须由同一个锁来保护。

##2.5 活跃性与性能## 虽然synchronized方法可以确保单个操作的原子性,但如果要把多个操作合并为一个复合操作,还是需要额外的加锁机制。此外,将每个方法都作为同步方法可能导致活跃性问题或性能问题。

幸运的是,通过缩小同步代码块的作用范围,我们很容易做到提高代码的并发性,同时又维护线程安全性。要确保同步代码不要过小,并且不要将本应是原子的操作拆分到多个同步代码中。应该尽量将不影响共享状态且执行时间较长的操作从同步代码块中分离出去,从而在这些操作的执行过程中,其他线程可以访问共享状态。

对在单个变量上实现原子操作来说,原子变量是很有用的,但由于已经使用了同步代码块来构造原子操作,而使用两种不同的同步机制不仅会带来混乱,也不会在性能或安全性上带来任何好处,因此在这里不使用原子变量。

要判断同步代码块的合理大小,需要在各种设计需求之间进行权衡,包括安全性,简单性和性能。通常,在简单性与性能之间存在着相互制约因素。当实现某个同步策略时,一定不要盲目地为了性能而牺牲简单性(这可能会破坏安全性)。

>注意:无论是执行计算密集的操作,还是在执行某个可能阻塞的操作,如果持有锁的时间过长,那么都会带来活跃性或性能问题。当执行时间较长的计算或者可能无法快速完成的操作时(例如:网络I/O或控制台I/O),一定不要持有锁。

共有 人打赏支持
粉丝 1457
博文 388
码字总数 1483822
×
陶邦仁
如果觉得我的文章对您有用,请随意打赏。您的支持将鼓励我继续创作!
* 金额(元)
¥1 ¥5 ¥10 ¥20 其他金额
打赏人
留言
* 支付类型
微信扫码支付
打赏金额:
已支付成功
打赏金额: