0%

并发编程2-线程安全性

线程安全性

在构建文件的并发程序时,必须正确地使用线程和锁,但这些终归只是一些机制。要编写线程安全的代码,其核心在于要对状态访问操作进行管理,特别是对共享的和可变的状态的访问。一个对象是否是线程安全的,取决于它是否被多个线程同时访问。当多个线程访问某个状态变量并且其中有一个线程执行写入操作时,必须采用同步机制来协同这些线程对变量的访问。Java中的主要同步机制是关键字synchronized,它提供了一种独占的加锁方式,但「同步」这个术语还包括volatile类型的变量,显式锁以及原子变量。
如果当多个线程访问同一个可变的状态变量时没有使用合适的同步,那么程序就会出现错误。有三种方式可以修复这个问题:

  • 不在线程之间共享该状态变量
  • 将状态变量改为不可变的变量
  • 在访问状态变量时使用同步

什么是线程安全性

当多个线程访问某个类时,不管运行时环境采用何种调度方式或者这些线程将如何交替执行,并且在主调代码中不需要任何额外的同步或协同,这个类都能表现出正确的行为,那么就称这个类是线程安全的。在线程安全类中封装了必要的同步机制,因此客户端无须进一步采取同步措施。
无状态对象一定是线程安全的。(没有共享数据)

原子性

像count++这种是属于非原子操作,它包含了三个独立的操作:读取count的值,将值+1,然后将计算结果写入count。所以不同线程在自增的时候,这个值在第一步拿到的可能是过期的数据。
在并发编程中,这种由于不恰当的执行时序出现不正确的结果是一种非常重要的情况,它有一个正式的名字:竞态条件。

竞态条件

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

复合操作

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

我们将先检查后执行读取-修改-写入等操作统称为「复合操作」。我们要以原子方式执行确保线程安全性,Java通过加锁机制来确保原子性。
原子操作是指,对于访问同一个状态的所有操作(包括该操作本身)来说,这个操作是一个以原子方式执行的操作(不可分割的操作)。

加锁机制

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

内置锁

Java提供了一种内置的锁机制来支持原子性:同步代码快。以关键字synchronized来修饰的方法就是一种横跨整个方法体的同步代码块,其中该同步代码块的所就是方法调用的所在对象。静态的synchronized方法以Class对象作为锁。

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

每个Java对象都可以用做一个实现同步的锁,这些锁被称为内置锁或监视器锁。线程在进入同步代码之前会自动获得锁,并且在退出同步代码块时自动释放锁,而无论是通过正常的控制路径退出,还是通过代码块中抛出异常退出。获得内置锁的唯一途径就是进入由这个锁保护的同步代码块或方法。
Java的内置锁是互斥锁,意思是最多只有一个线程能持有这种锁,当线程A尝试获取一个由线程B持有的锁时,线程A必须等待或阻塞,直到线程B释放这个锁。如果B永远不释放这个锁,那么A也将永远地等下去。
由于每次只能又一个线程执行内置锁保护的代码块,因此,由这个锁保护的同步代码块会以原子方式执行,多个线程在执行该代码块时也不会相互干扰。并发环境中的原子性与事务应用程序的原子性有着相同的含义–一组语句作为一个不可分割的单元被执行。任何一个执行同步代码块的线程,都不可能看到有其他线程正在执行由同一个锁保护的同步代码块。

重入

当某线程请求一个由其他线程持有的锁时,发出请求的线程就会阻塞。然而,由于内置锁是可重入的,因此如果某个线程试图获得一个已经由它自己持有的锁,那么这个请求就会成功。synchronized同步块对同一条线程来说是可重入的,不会出现把自己锁死的问题。

synchronized关键字在经过编译之后,会在同步块的前后分别形成monitorenter和monitorexit这两个字节码指令。
根据虚拟机规范的要求,在执行monitorenter指令时,首先要尝试获取对象的锁,如果这个对象没有被锁定,或者当前线程已经拥有了那个对象的锁,把锁的计数器加1,相应的,在执行monitorexit指令时会将锁计数器减1,当计数器为0时,锁就被释放。如果获取对象锁失败,那当前线程就要阻塞等待,直到对象锁被另外一个线程释放为止。

用锁来保护状态

由于锁能使其保护的代码路径以串行形式来访问,因此可以通过锁来构造一些协议以实现对共享状态的独占访问。对于可能被多个线程同时访问的可变状态变量,在访问它时都需要持有同一个锁,在这种情况下,我们称状态变量是由这个锁保护的。并非所有数据都需要锁的保护,只有被多个线程同时访问的可变数据才需要通过锁来保护。虽然synchronized方法可以确保单个操作的原子性,但如果把多个操作合并为一个复合操作,还是需要额外的加锁机制。

活跃性与性能

一般来讲,对整个方法进行同步,每次只有一个线程可以执行,可能会导致性能糟糕。因此我们通常在只需要同步的地方用同步代码块,只对代码块中的共享状态变量进行加锁保护。我们要找到简单性(对整个方法进行同步)与并发性(对尽可能短的代码进行同步)之间的平衡。

通常,在简单性与性能之间存在着相互制约因素。当实现某个同步策略时,已定不要盲目地为了性能而牺牲简单性(这可能会破坏安全性)。

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