您的位置:首页 > 编程语言 > Java开发

[Java Concurrency in Practice]第二章 线程安全性

2015-08-09 18:09 483 查看

线程安全性

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

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

“共享“意味着变量可以由多个线程同时访问,而“可变“则意味着变量的值在其生命周期内可以发生变化。

一个对象是否需要是线程安全的,取决于它是否被多个线程访问。这指的是程序中访问对象的方式,而不是对象要实现的功能。要使得对象是线程安全的,需要采用同步机制来协同对对象可变状态的访问。

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

如果当多个线程访问同一个可变的状态变量时没有使用合适的同步,那么程序就会出现错误。有三种方式可以修复这个问题:

● 不在线程之间共享该状态变量。

● 将状态变量修改为不可变的变量。

● 在访问状态变量时使用同步。

当设计线程安全的类时,良好的面向对象技术、不可修改性,以及明晰的不可变性规范都能起到一定的帮助作用。

首先使代码正确运行,然后再提高代码的速度。即便如此,最好也只是当性能测试结果和应用需求告诉你必须提高性能,以及测量结果表明这种优化在实际环境中确实能带来性能提升时,才进行优化。

2.1 什么是线程安全性

“安全“的含义是什么?在线程安全性的定义中,最核心的概念就是正确性。正确性的含义是,某个类的行为与其规范完全一致。在良好的规范中通常会定义各种不变性条件来约束对象的状态,以及定义各种后验条件来描述对象操作的结果。由于我们通常不会为类编写详细的规范,我们无法知道这些类是否是正确的。但我们可以在确信“类的代码能工作“后使用它们。在对“正确性“给出了一个较为清晰的定义后,就可以定义线程安全性:当多个线程访问某个类时,这个类始终都能表现出正确的行为,那么就称这个类时线程安全的。可以将线程安全类认为是一个在并发环境中和单线程环境中都不会被破坏的类。

当多个线程访问某个类时,不管运行时环境采用何种调度方式或者这些线程将如何交替执行,并且在主调代码中不需要任何额外的同步或协同,这个类都能表现出正确的行为,那么就称这个类是线程安全的。

如果正确地实现了某个对象,那么在任何操作中(包括调用对象的共有方法或者对其共有域进行读/写操作)都不会违背不变性条件或后验条件。在线程安全的对象实例上执行的任何串行或并行都不会使对象处于无效状态。

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

无状态的:它既不包括任何域,也不包括任何对其他类中域的引用。计算过程中的临时状态仅存在于线程栈上的局部变量中,并且只能由正在执行的线程访问。

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

2.2 原子性

例如:虽然递增操作++count是一种紧凑的语法,使其看上去只是一个操作,但这个操作并非原子的,因而它并不会作为一个不可分割的操作去执行。实际上,它包含了三个独立的操作,读取count的值,将值加1,然后将计算结果写入count。这是一个“读取-修改-写入“的操作序列,并且其结果状态依赖于之前的状态。

2.2.1 竞态条件

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

这种观察结果的失效就是大多数竞态条件的本质——基于一种可能失效的观察结果来做出判断或者执行某个计算,这种类型的竞态条件称为“先检查后执行”:首先观察某个条件是否为真(例如文件X不存在),然后根据这个观察结果采用相应的动作(创建文件X),但事实上,在你观察到这个结果以及开始创建文件之间,观察结果可能变得无效(另一个线程在这期间创建了文件X),从而导致了各种问题(未预期的异常,数据被覆盖、文件诶破坏等)。

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

@NotThreadSafe
public class LazyInitRace
{
private ExpensiveObject instance = null;

public ExpensiveObject getInstance()
{
if(instance == null)
instance = new ExpensiveObject();
return instance;
}
}


竞态条件并不总会产生错误,还需要某个不恰当的执行时序。然而,竞态条件也可能导致严重的错误。假定LazyInitRace被用于初始化应用程序范围内的注册表,如果在多次调用中返回不同的实例,那么要么会丢失部分注册信息,要么多个行为对同一组注册对象表现出不一致的视图。

2.2.3 复合操作

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

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

为了确保线程安全性,“先检查后执行”(例如延迟初始化)和“读取—修改—写入”(例如递增运算)等操作必须是原子的。我们将“先检查后执行”以及“读取—修改—写入”等操作统称为复合操作:包含了一组必须以原子方式执行的操作以确保线程安全性。

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

在实际情况中,应尽可能地使用现有的线程安全对象(例如AcomicLong)来管理类的状态。与非线程安全的对象相比,判断线程安全对象的可能状态及其状态转换情况要更为容易,从而也更容易维护和验证线程安全性。

2.3 加锁机制

在线程安全性的定义中要求,多个线程之间的操作无论采用何种执行时序或交替方式,都要保证不变性条件不被破坏。

当在不变性条件中涉及多个变量时,各个变量之间并不是彼此独立的,而是某个变量的值会对其他变量的值产生约束。因此,当更新某一个变量时,需要在同一个原子操作中对其他变量同时进行更新。

因为不能保证会同时获取两个值:在线程A获取这两个值的过程中,线程B可能修改了它们,这样线程A也会发现不变性条件被破坏了。

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

2.3.1 内置锁

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

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


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

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

2.3.2 重入

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

2.4 用锁来保护状态

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

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

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

当获取与对象关联的锁时,并不能阻止其他线程访问该对象,某个线程在获得对象的锁后,只能阻止其他线程获得同一个锁。之所以每个对象都有一个内置锁,只是为了免去显式地创建锁对象。你需要自行构造加锁协议或者同步策略来实现对共享状态的安全访问,并且在程序中自始至终地使用它们。

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

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

并非所有的数据都需要锁的保护,只有被多个线程同时访问的可变数据才需要通过锁来保护。

当某个变量由锁来保护时,意味着在每次访问这个变量时都需需要首先获得锁,这样就需要确保在同一时刻只有一个线程可以访问这个变量。当类的不变性条件涉及多个状态变量时,那么还有另外一个需求:在不可变条件中的每个变量都必须由同一个锁来保护。因此可以在单个原子操作中访问或更新这些变量,从而确保不变性条件不被破坏。

对于每个包含多个变量的不变性条件,其中涉及的所有变量都需要由同一个锁来保护。

虽然sychronized方法可以确保单个操作的原子性,但如果要把多个操作合并为一个符合操作,还是需要额外的加锁机制。(请参见4.4节了解如何在线程安全对象上增加原子操作的方法),将每个方法都作为同步方法还可能导致活跃性问题或性能问题。

2.5 活跃性与性

不良并发应用程序:可同时调用哪个的数量,不仅受到可用处理资源的限制,还受到应用程序本身结构的限制。

通过缩小同步代码块的作用范围,要确保同步代码块不要过小,并且不要将本应时原子的操作拆分到多个同步代码块中。应该尽量将不影响共享状态且执行时间较长的操作从同步代码块中分离出去,从而在这些操作的执行过程中,其他线程可以访问共享状态。

要判断同步代码块的合理大小,需要在各种设计需求之间进行权衡,包括安全性(这个需求必须得到满足)、简单性和性能。

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

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

当执行时间较长的计算或者可能无法快速完成的操作时(例如,网络I/O或控制台I/O),一定不要持有锁。
内容来自用户分享和网络整理,不保证内容的准确性,如有侵权内容,可联系管理员处理 点击这里给我发消息
标签:  线程安全 并发 java