《Java并发编程实战》并发编程基础
前言:并发编程的线程安全性、对象的共享、对象的组合和基础构建模块
一、线程安全性
线程安全的代码核心在于要对状态访问操作进行管理,特别是共享(Shared)和可变的(Mutable)状态的访问。共享意味着可由多个线程同时访问,可变意味着变量的值在其生命周期内可以发生变化,我们的讨论将会侧重于如何防止在数据上发生不受控的并发访问。
1.线程安全性
当多个线程访问某个类时,这个类始终都能表现出正确的行为,那么就称这个类是线程安全的。
在线程安全类中封装了必要的同步机制,因此客户端无须进一步采取同步措施。
无状态对象一定是线程安全的,既不包括任何域,也不包括任何对其他类中域的引用。
2.原子性
“++count”这类递增操作不是原子性的,包含三个步骤,读取-修改-写入,三个独立的操作,不会作为一个不可分割的操作来执行。
竞态条件
当某个计算结果的正确性取决于多个线程交替执行时序时就会发生竞态条件,也就是说竞态条件下正确的结果要取决于运气。最常见的竞态条件类型就是“先检查后执行”,即通过一个可能失效的观测结果来决定下一步的动作。
复合操作
要避免竞态条件问题就必须在某个线程修改该变量时,通过某种方式防止其他线程使用这个变量,确保其他线程只能在修改操作完成之前或之后读取和修改。可以将整个“读取-修改-写入”操作统称为复合操作,除了加锁之外,还可以使用java.util.concurrent.atomic包中的一些原子变量类来实现。
3.加锁机制
内置锁
Java提供了一种内置的锁机制来支持原子性,也即同步代码块。同步代码块包括两部分:一个作为锁的对象引用,一个作为由这个锁保护的代码块。
每个Java对象都可以用做一个实现同步的锁,这些锁被称为内置锁(Intrinsic Lock)或监视器锁(Monitor Lock),Java中的内置锁相当于一种互斥锁,最多只有一个线程能持有这种锁。
重入
内置锁是可重入的,也就是说某个线程试图获取一个已经由它自己持有的锁时可以成功的获取到,重入意味着获取锁的操作的粒度是线程而不是调用。
重入的实现方式是为每个锁关联一个获取计数值和一个所有者线程,当线程请求一个未被持有的锁时,JVM将记下锁的持有者,并且将获取计数值置为1,同一个线程继续获取这个锁时计数器递增,当线程退出同步代码块时计数器会相应递减,直到计数器为0时锁释放。
重入进一步加深了锁行为的封装性,简化了面向对象并发代码的开发,解决了不可重入锁容易出现死锁的问题。
二、对象的共享
上述提到的同步代码块和同步方法确保了原子性,也就是防止了某个线程正在使用对象而另一个线程正在同时修改的情况,除此之外,我们还希望当一个线程修改了对象状态后,其他线程能够看到发生的状态变化。
1.可见性
为了确保多个线程对内存写入操作的可见性,必须使用同步机制。在没有同步的情况下,编译器、处理器以及运行时等都可能对操作的执行顺序进行一些意想不到的调整。
在缺乏足够同步的多线程程序中,要想对内存操作的执行顺序进行判断几乎无法得到正确的结论。
失效数据
在缺乏同步的程序中可能产生读到失效数据的情况,除非在每次访问变量是都使用同步,否则很有可能获得该变量的一个失效值。
非原子的64位操作
Java内存模型要求,变量的读取操作和写入操作都必须是原子操作,但对于非volatile类型的long和double变量,JVM允许将64位的读操作或写操作分解为两个32位的操作。(多线程操作非volatile的long时会发生独到某个值的高32位和另一个值的低32位)。不考虑失效数据的问题,多线程中使用共享且可变的long和double是不安全的,除非使用volatile声明或者用锁保护。
加锁与可见性
内置锁可以用于确保某个线程以一种可预测的方式来查看另一个线程的执行结果,加锁的含义不仅仅局限于护持行为,还包括内存可见性,为了确保所有线程都能看到共享变量的最新值,所有执行读操作或者写操作的线程都必须在同一个锁上同步。
Volatile变量
volatile是一种稍弱的同步机制,用来确保将变量的更新操作通知到其他线程。线程在读取volatile类型的变量时总会返回最新写入的值,编译器不会将该变量上的操作与其他内存操作一起重排序,该类型变量也不会被缓存在寄存器或者对其他处理器不可见的地方。
在访问volatile变量时不会执行加锁操作,即不会使执行线程阻塞,因此比sychronized关键字更轻量。
volatile变量通常用做某个操作完成、发生中断或者状态的标志。加锁机制可以确保可见性又可以确保原子性,而volatile变量只能确保可见性,当且仅当满足以下所有条件时才应该使用volatile变量:
- 对变量的写入操作不依赖变量的当前值
- 该变量不会与其他状态变量一只纳入不变性条件中
- 在访问变量时不需要加锁
2.发布与逸出
“发布”指的是是对象能够在当前作用域之外的代码中使用,当某个不应该发布的对象被发布,这种情况就成为“逸出”。当发布一个对象时,在该对象的非私有域中引用的所有对象同样会被发布。
构造过程中使用this可能会导致引用逸出,当从对象的构造函数中发布对象时,只是发布了一个尚未构造完成的对象。
3.线程封闭
当某个对象封闭在一个线程中时,虽然说是可变数据,但仅在单线程内访问,为了防止一些不必要的同步措施,线程封闭(Thread Confinement)技术将自动实现线程安全性,把对象封闭在线程中,即使被封闭的对象本身不是线程安全的。
- Ad-hoc线程封闭
维护线程封闭性的职责完全由程序实现来承担,因为没有任何一种语言特性能将对象封闭到目标线程上,所以Ad-hoc线程封闭是脆弱的, - 栈封闭
栈封闭是线程封闭的一种特例,只能通过局部变量才能访问对象,局部变量的固有属性之一就是封闭在执行线程中。 - ThreadLocal类
维持线程封闭性的一种更规范的方法是使用ThreadLocal,能使线程中的某个值与保存值的对象关联起来,它为每个使用该变量的线程都存有一份独立的副本,提供了get/set方法,操作的值都是当前执行线程的。ThreadLocal常用于防止对可变的单实例变量或全局变量进行共享。
4.不变性
满足同步的另一种方法是使用不可变对象,如果某个对象在创建后其状态就不能被修改,那么这个对象就称为不可变对象,线程安全性是不可变对象的固有属性之一,不可变对象一定是线程安全的。不可变不等于将对象所有域都声明为final类型。
5.安全发布
以上讨论的都是如果确保对象不被发布,但在某些情况下,我们希望在多线程间共享对象,此时必须确保对象安全地进行共享。
要安全的发布一个对象,对象的引用以及对象的状态必须同时对其他线程可见,一个正确构造的对象可以通过以下方式来安全的发布:
- 在静态初始化函数中初始化一个对象引用。
- 将对象的引用保存到volatile类型的域或者AtomicReferance对象中。
- 将对象的引用保存到某个正确构造对象的final类型域中。
- 将对象的引用保存到一个由锁保护的域中。
如果对象从技术上来看是可变的,但其状态在发布后不会再改变,那么把这种对象称为“事实不可变对象”,在没有额外的同步情况下,任何线程都可以安全的使用被安全发布的事实不可变对象。
线程封闭的对象只能由一个线程拥有,对象被封闭在该线程中,并且只能由这个线程修改。线程安全的对象在其内部实现同步,因此多个线程可以通过对象的公有借口来进行访问。被保护的对象只能通过持有特定的锁来访问,保护对象包括封装在其他线程安全对象中的对象,以及已发布的并且由某个特定锁保护的对象。
三、对象的组合
掌握了一些线程安全与同步的基础知识,我们总会希望一些现有的线程安全组件组合为更大规模的状态的组件和程序。
1.设计线程安全的类
需要确保它的不变性条件不会在并发访问的情况下被破坏,需要对其状态进行推断,(对象的状态->对象的域->构成对象状态的所有变量)
要防止多个线程在并发访问同一个对象时产生的相互干扰,这些对象应该要么是线程的对象,要是事实不可变的对象,或者由锁来保护的对象。
2.实例封闭
封闭简化了线程安全类的实现过程,提供了一种实例封闭机制,将数据封装在对象内部,可以将数据的访问限制在对象的方法上,从而更容易确保线程在访问数据是总能持有正确的锁。
实例封闭是构建线程安全类的一个最简单的方式,它还使得在锁策略的选择上有了更多的灵活性。
封闭机制更易于构造线程安全的类,因为当封闭类的状态时,在分析类的线程安全性时就无须检查整个程序。
3.线程安全性委托
某些情况下,多个线程安全类组合而成的类是线程安全的,而在某些情况下,还需要再增加一个额外的线程安全层。
如果一个类是由多个独立且线程安全的状态变量组成,并且在所有的操作中都不包含无效状态转换,那么可以将线程安全性委托给底层的状态变量。
如果一个状态变量是线程安全的,并且没有任何不变性条件来约束他的值,在变量的操作上也不存在任何不允许的状态转换,那么就可以安全的发布这个变量。
4.现有线程安全类中添加功能
某些情况现有的线程安全类能支持我们所需要的大部分操作,如果需要继续重用的话就需要在不破坏线程安全性的情况下添加一个新的操作。
- 客户端加锁机制
对于某个对象X的客户端代码,使用X本身用于保护其状态的锁来保护这段客户代码,要使用客户端加锁,必须知道对象X使用的是哪一种锁。 - 组合
现有类通过将原有代码的操作委托给原有代码的实例来实现原有的功能,同时再添加一个原子的方法,通过自身的内置锁增加了一层额外的加锁,并不关心底层的原有代码是否是线程安全的,他依靠自身提供的一致加锁机制来实现线程安全性。但这额外的同步可能导致轻微的性能损失。
四、基础构建模块
上一部分介绍了构造线程安全类时采用的一些技术。同时,Java平台类库包含了丰富的并发基础构建模块,本部分将介绍其中一些最有用的并发构建模块。
1.同步容器类
包括Vector和Hashtable,它们实现线程安全的方式是:将它们的状态封装起来,并对每个公有方法都进行同步,使得每次只有一个线程能访问容器的状态。
同步容器类都是线程安全的,但在某些情况下可能需要额外的客户端加锁来保护复合操作。例如Vector中的getLast()和deleteLast(),两个线程同时操作获取和删除,可能出现获取被删除元素的情况。如下:
交替调用getLast和deleteLast时将抛出ArrayIndexOutOfBoundsException
这些同步容器支持同步策略,即支持客户端加锁,因此为了让两个操作成为原子操作,就需要对该对象进行加锁处理。
迭代器与ConcurrentModificationException
使用迭代器无法避免在迭代期间对容器加锁,在设计同步容器类的迭代器时并没有考虑到并发修改的问题,当容器在迭代过程中被修改时,就会表现出“及时失败(fail-fast)”就会抛出一个ConcurrentModificationException异常。
这种“及时失败”的迭代器并不是一种完备的处理机制,只能作为并发问题的预警指示器,实现方式是将计数器的变化与容器关联起来,迭代期间计数器被修改hasNext或者next将抛出异常,这种设计也是为了降低并发修改操作的检测代码对程序性能带来的影响。
隐藏迭代器
加锁可以防止迭代器抛出ConcurrentModificationException,但是必须要在所有的共享容器进行迭代的地方进行加锁,但实际可能会出现迭代器隐藏起来的情况。
如果出现状态与保护它的同步代码之间相隔较远,那么开发人员就容易忘记在访问状态时使用正确的同步。和封装对象的状态有助于维持不变性条件一样,封装对象的同步机制同样有助于确保试试同步策略。
2.并发容器
- ConcurrentHashMap
Java7基于分段锁,数据结构是数组+链表,Java8基于CAS,数据结构是数组+链表+红黑树,具体可以看ConcurrentHashMap演进 - CopyOnWriteArrayList
用于替代同步List,在某些情况提供更好的并发性能,在迭代期间不需要对容器进行加锁或复制。写入时复制容器的线程安全性在于:只要正确的发布一个事实不可变的对象,那么在访问该对象时就不再需要进一步的同步。在每次修改时都会创建并重新发布一个新的容器副本,从而实现可变性。在迭代操作远多于修改操作时我们才应该使用“写入时复制”,节省开销。
3.阻塞队列和生产者-消费者模式
阻塞队列支持生产者-消费者这种设计模式,简化开发过程,消除生产者类和消费者类之间的代码依赖性,解耦生产数据和使用数据的过程。
在构建高可靠的应用程序时,有界队列是一种强大的资源管理工具,它们能抑制并防止产生过多的工作项,使应用程序在负荷过载的情况下变得更加健壮。
4.阻塞方法与中断方法
线程阻塞时,它通常被挂起,并处于某种阻塞状态(BLOCKED、WAITING或TIMED_WAITING)。被阻塞的线程必须等待某个不受它控制的时间发生后才能继续执行,例如等到I/O操作完成、等待锁可用、等待外部计算的结束。
每个线程都有一个布尔类型的属性,表示线程的中断状态。中断是一种协作机制,一个线程不能强制其他线程停止正在执行的操作而去执行其他操作,一个线程只能要求另一个线程执行到某个可以暂停并且愿意暂停的地方来停止操作。
要处理对中断的响应有两种基本选择:
- 传递InterruptedException
不捕获该异常,或者捕获该异常,然后在执行某种简单的清理工作后再次抛出这个异常 - 恢复中断
有时候不能抛出InterruptedException,例如代码是Runnable的一部分时,这样如果必须捕获异常,就要调用当前线程的interrupt方法恢复中断。
5.同步工具类
同步工具类可以是任何一个对象,只要它根据自身的状态来协调线程的控制流。所有的同步工具类都包含一些特定的结构化属性:它们封装了一些状态,这些状态用来决定执行同步工具类的线程是继续执行还是等待,还提供一些方法对状态进行操作,以及另一些方法用于高效地等待同步工具类进入预期状态。
闭锁
闭锁是一种同步工具类,可以延迟线程的进度直到到达终止状态。将闭锁比作一扇门,在它到达结束状态之前,这扇门一直是关闭的,没有任何线程能通过,当到达结束状态时,这扇门会打开并允许所有的线程通过。闭锁到达结束状态后,将不会再改变状态,闭锁可以用来确保某些活动直到其他活动都完成后才继续执行。
FutureTask
FutureTask也可以用做闭锁,它表示的计算是通过Callable来实现的,相当于一种可生成结果的Runnable,并可以处于这三种状态:等待运行(Waiting to run),正在运行(Running)和运行完成(Completed)。
运行完成表示所有可能结束的方式,包括正常、取消或异常,FutureTask进入结束状态后将永远停止在这个状态上。Future.get在任务已经完成时会立即返回结果,否则get将阻塞直到任务进入完成状态。
FutureTask在Executor框架中表示异步任务,还可以用来表示一些时间较长的计算,这些计算可以在使用计算结果之前启动。
信号量
计数信号量(Counting Semaphore)用来控制同时访问某个特定资源的操作数量,或者同时执行某个指定操作的数量。计数信号量还可以用来实现某种资源池,或者对容器施加边界。
栅栏
栅栏(Barrier)类似于闭锁,与闭锁的区别在于:所有线程必须同时到达栅栏位置才能继续执行,闭锁用于等待事件,而栅栏用于等待其他线程,必须所有线程齐了才进一步操作。
第一部分小结
- 可变状态是至关重要的。
所有的并发问题都可以归结为如何协调对并发状态的访问,可变状态越少,就越容易确保线程安全性。 - 尽量将域声明为final类型,除非需要它们是可变的。
- 不可变对象一定是线程安全的。
不可变对象能极大的降低并发编程的复杂性,更简单并且安全,可以任意共享并且无需加锁或保护复制等机制。 - 封装有助于管理复杂性。
将数据封装在对象中,更易于维持不变性条件,将同步机制封装在对象中,更易于遵循同步策略。 - 用锁来保护每个可变变量。
- 保护同一个不变性条件的所有变量需要使用同一个锁。
- 在执行复合操作期间要持有锁。
- 在设计过程中考虑线程安全,或者在文档中明确指出它不是线程安全的。
Ref
Briangoetz. Java并发编程实战[M]. 机械工业出版社, 2012.