《Java并发编程实战》读书笔记四第四部分 高级主题 第十三章 显式锁 第十四章 构建自定义的同步工具 第十五章 原子变
第十三章 显式锁
Lock
与ReentrantLock
与内置加锁机制不同的是,Lock
提供了一种无条件的、可轮询的、定时的,以及可中断的锁获取操作,所有加锁和解锁的方法都是显式的。
public interface Lock {
void lock();
void lockInterruptibly() throws InterruptedException;
boolean tryLock();
boolean tryLock(long time, TimeUnit unit) throws InterruptedException;
void unlock();
Condition newCondition();
}
在大多数情况下内置锁都能很好地工作,但在功能上存在一些局限性,例如,无法中断一个正在等待获取锁的线程,或者无法获取一个不会无限等待下去的锁。内置锁必须在获取该锁的代码块中释放,这就简化了编码工作,并且与异常处理操作实现了很好的交互,但却无法实现非阻塞结构的加锁规则。这些都是使用synchronized的原因,但在某些情况下,一种更灵活的加锁机制通常能提供更好的活跃性或性能。
使用Lock接口的标准使用形式:
Lock lock = new ReentrantLock();
lock.lock();
try {
// 更新对象状态
// 捕获异常,并在必要时恢复不变性条件
} finally {
lock.unlock();
}
轮询锁与定时锁
可定时的与可轮询的锁获取模式是由tryLock
方法实现的,与无条件的锁获取模式相比,它具有更完善的错误恢复机制。在内置锁中,死锁是一个严重的问题,恢复程序的唯一方法是重新启动程序。而防止死锁的唯一方法就是在构造程序时避免出现不一致的锁顺序。可定时与可轮询的锁提供了另一种选择:避免死锁的发生。
可中断的锁获取操作
正如定时的锁获取操作,能在带有时间限制的操作中使用独占锁,可中断的锁获取操作同样能在可取消的操作中使用加锁。
可中断的锁获取操作的标准结构比普通的锁获取操作略微复杂一些,因为需要两个try块。
public class InterruptibleLocking {
private Lock lock = new ReentrantLock();
public boolean sendOnSharedLine(String message) throws InterruptedException {
lock.lockInterruptibly();
try {
return cancellableSendOnSharedLine(message);
} finally {
lock.unlock();
}
}
private boolean cancellableSendOnSharedLine(String message) throws InterruptedException {
/* send something */
return true;
}
}
非块结构的加锁
在内置锁中,锁的获取和释放等操作都是基于代码块的——释放锁的操作总是与获取锁的操作处于同一代码快,而不考虑控制权如何退出该代码块。自动地锁释放操作简化了对程序的分析,避免了可能的编码错误,但有时候需要更灵活的加锁规则。如锁分段技术可能需要使用不同的锁来降低锁的粒度。在[CPJ 2.5.1.4]中介绍了使用这项技术的一个示例,并称之为连锁式加锁(Hand-Over-Hand Locking)或锁耦合(Lock Coupling)。
性能考虑因素
在Java 5.0中,当从单线程(无竞争)变化到多线程时,内置锁的性能将急剧下降,ReentrantLock
的性能下降则更为平缓,因而它具有更好的可伸缩性。但在Java 6中情况就完全不同了,内置锁的性能不会由于竞争而急剧下降,并且两者的可伸缩性也基本相当。
公平性
在ReentrantLock
的构造函数中,提供了两种公平性选择:创建一个非公平的锁(默认)或者一个公平的锁。在公平的锁上,线程将按照他们发出请求的顺序来获得锁,但在非公平的锁上则允许“插队”。
当执行加锁操作时,公平性将由于在挂起线程和恢复线程时存在的开销而极大降低性能。激烈的竞争中,非公平锁性能更优。
当持有锁的时间相对较长,或者请求锁的平均时间间隔较长,那么应该使用公平锁。
在synchronized
和ReentrantLock
之间进行选择
ReentrantLock
提供更多功能,性能似乎优于内置锁。
内置锁简洁紧凑,为开发人员所熟悉。内置锁还能在线程转储中给出在哪些调用帧中获得了哪些锁,并能够检测和识别发生死锁的线程。
在一些内置锁无法满足需求的情况下,ReentrantLock
可以作为一种高级工具。当需要一些高级功能时才应该使用ReentrantLock
,这些功能包括:可定时的、可轮询的与可中断的锁获取操作,公平队列,以及非块结构的锁。否则,还是应该优先使用synchronized
。
读-写锁
public interface ReadWriteLock {
Lock readLock();
Lock writeLock();
}
读-写锁实现的加锁策略中,允许多个读操作同时进行,但每次只允许一个写操作。
读-写锁是一种性能优化措施,在一些特定的情况下能实现更高的并发性。
在读取锁和写入锁之间的交互可以采用多种实现方式。ReadWriteLock
中的一些可选实现包括:
- 释放优先
- 读线程插队
- 重入性
- 降级。如果一个线程持有写入锁,那么它能否在不释放该锁的情况下获得读取锁?这可能会使得写入锁被降级为读取锁,同时不允许其他写线程修改被保护的资源。
- 升级。读线程所能否优先于其它正在等待的读线程和写线程而升级为一个写入锁?
第十四章 构建自定义的同步工具
类库中包含了许多存在状态依赖性的类,例如FutureTask
、Semaphore
和BlockingQueue
等。在这些类的一些操作中有着基于状态的前提条件。例如,不能从一个空的队列中删除元素,或者获取一个尚未结束的任务的计算结果,在这些操作可以执行之前,必须等到队列进入“非空”状态,或者任务进入“已完成”状态。
创建状态依赖类的最简单方法通常是在类库中现有状态依赖类的基础上进行构造,但如果类库没有提供你需要的功能,那么还可以使用Java语言和类库提供的底层机制来构造自己的同步机制,包括内置的条件队列、显式的Condition
对象以及AbstractQueuedSynchronizer
框架
状态依赖性的管理
对于并发对象上依赖状态的方法,虽然有时候在前提条件不满足的情况下不会失败,但通常有一种更好的选择,即等待前提条件变为真。依赖状态的操作可以一直阻塞直到可以继续执行,这比使他们简单的失败更为方便且更不易出错。
状态依赖管理的几种实现:
- 状态不满足时直接失败,调用者可以不进入休眠状态,而直接重新调用方法,这种方法被称为忙等待或者自旋等待,也可以进入休眠来避免消耗过多的CPU时间。这两种方案也是容忍自旋导致CPU时钟周期浪费与容忍休眠导致低响应性的权衡。
- 通过轮询与休眠来实现简单的阻塞
- 条件队列,它使得一组线程(称之为等待线程集合)能够通过某种方式来等待特定的条件变成真。传统队列的元素是一个个数据,而与之不同的是,条件队列中的元素是一个个正在等待相关条件的线程。 正如每个Java对象都可以作为一个锁,每个对象同样可以作为一个条件队列,而且Object中的wait,notify和notifyAll方法就构成了内部条件队列的API。
使用条件队列
条件谓词
要想正确地使用条件队列,关键是找出对象在哪个条件谓词上等待。条件谓词是使某个操作成为状态依赖操作的前提条件。条件谓词由类中各个状态变量构成的表达式。例如,在有界缓存中,只有当缓存不为空take方法才能执行,否则必须等待,对于take方法来说,它的条件谓词就是缓存不为空,take方法在执行之前,必须首先测试该条件谓词。
在条件等待中存在一种重要的三元关系,包括加锁,wait方法和一个条件谓词。在条件谓词中包含多个状态变量,而状态变量由一个锁来保护,因此在测试条件谓词之前,必须先持有这个锁,锁对象与条件队列对象(即调用wait与notify等方法所在的对象)必须是同一个对象。
每一次wait调用都会隐式的与特定的条件谓词关联起来。当调用某个特定条件谓词的wait时,调用者必须已经持有与条件队列相关的锁,并且这个锁必须保护着构成条件谓词的状态变量。
过早唤醒
wait方法的返回,并不一定意味着线程正在等待的条件谓词已经变成真了。每当线程从wait中唤醒时,都必须再次测试条件谓词,如果条件谓词不为真,那么就继续等待(或者失败),由于线程在条件谓词不为真的情况下,也可以反复的醒来,因此必须在一个循环中调用wait,并在每次迭代中都测试条件谓词。
当使用条件等待时(例如
Object.wait
或Condition.wait
):
- 通常都有一个条件谓词——包括一些对象状态的测试,线程在执行前必须首先通过这些测试。
- 在调用wait之前测试条件谓词,并且从wait中返回时再次进行测试。
- 在一个循环中调用wait。
- 确保使用与条件队列相关的锁来保护构成条件谓词的各个状态变量。
- 当调用wait、notify或notifyAll等方法时,一定要持有与条件队列相关的锁。
- 在检查条件谓词之后以及开始执行相应的操作之前,不要释放锁。
丢失的信号
丢失的信号是指:线程必须等待一个已经为真的条件,但在开始等待之前,没有检查条件谓词,即没有检查条件谓词便进入了等待,如果此时条件为真,那么可能永远没有通知来唤醒等待,从而造成活跃性故障。
通知
每当在等待一个条件时,一定要确保在条件谓词变为真时通过某种方式发出通知。
由于在调用notify或notifyAll时必须持有条件队列对象的锁,而如果这些等待中线程此时不能重新获得锁,那么无法从wait返回,因此发出通知的线程应该尽快地释放锁,从而确保正在等待的线程尽可能快地解除阻塞。
在调用notify时JVM会从这个条件队列上等待的多个线程中选择一个来唤醒,而调用notifyAll则会唤醒所有在这个条件队列上等待的线程。由于多个线程可以基于不同的条件谓词在同一个条件队列上等待,因此如果使用notify而不是notifyAll,那么将是一种危险的操作,因为单一的通知很容易导致类似于信号丢失的问题,可能notify选中唤醒的线程等待的条件谓词不是要通知的条件谓词。
只有同时满足以下两个条件时,才能用单一的notify,而不是notifyAll:
所有等待线程的类型都相同。只有一个条件谓词与条件队列相关,而且每个线程在从wait返回后都将执行相同的操作。
单进单出。在条件变量上的每次通知,最多只能唤醒一个线程来执行。
单次通知:条件谓词中状态变量发生变化就发送通知,不管是否达到条件谓词要求。
条件通知:只有状态变量变化引发满足了条件谓词时,才通知。
子类的安全问题
在使用条件通知或单次通知时,一些约束条件使得子类化过程变得更加复杂。要想支持子类化,那么在设计类时需要保证:如果在实施子类化时违背了条件通知或单次通知的某个需求,那么在子类中可以增加合适的通知机制来代表基类。
对于状态依赖的类,要么将其等待和通知等协议完全向子类公开(并且写入正式文档),要么完全阻止子类参与到等待和通知等过程中。还有一种选择是完全禁止子类化。
封装条件队列
通常我们应该把条件队列封装起来,因为除了使用条件队列的类,就不能在其他地方访问它。不幸的是,这条建议——将条件队列对象封装起来,与线程安全类的最常见设计模式并不一致,在这种设计模式中建议使用对象的内置锁来保护对象自身的状态。
入口协议与出口协议
对于每个依赖状态的操作,以及每个修改其他操作依赖状态的操作,都应该定义一个入口协议和出口协议。入口协议就是该操作的条件谓词,出口协议则包括,检查被改操作修改的所有状态变量,并确认它们是否使某个其他的条件谓词变为真,如果是,则通知相关的条件队列。
显式的Condition对象
如果想编写一个带有多个条件谓词的并发对象,或者想获得除了条件队列可见性之外的更多控制权,就可以使用显示的Lock
和Condition
而不是内置锁和条件队列,这是一种更灵活的选择。
public interface Condition {
void await() throws InterruptedException;
void awaitUninterruptibly();
long awaitNanos(long nanosTimeout) throws InterruptedException;
boolean await(long time, TimeUnit unit) throws InterruptedException;
boolean awaitUntil(Date deadline) throws InterruptedException;
void signal();
void signalAll();
}
一个Condition
和一个Lock
关联在一起,就像一个条件队列和一个内置锁相关联一样。要创建一个Condition
,可以在相关联的Lock
上调用Lock.newCondition
方法,正如Lock
比内置加锁提供了更为丰富的功能,Condition
同样比内置条件队列提供了更丰富的功能:在每个锁上可存在多个等待、条件等待可以是可中断的或不可中断的、基于时限的等待,以及公平的或非公平的队列操作。
与内置的条件队列不同的是,对于每个Lock
,可以有任意数量的Condition
对象。Condition
对象继承了相关的Lock
对象的公平性,对于公平的锁,线程会按照FIFO顺序从Condition.await
中释放。
特别注意的是:在
Condition
对象中,与wait
,notify
和notifyAll
方法对应的分别是await
、signal
和signalAll
。但是,Condition对象是对Object的扩展,因而它也包含了wait
和notify
方法。一定要确保使用正确的版本——await
和signal
。
一个使用示例如下:
package net.jcip.examples;
import java.util.concurrent.locks.*;
import net.jcip.annotations.*;
/**
* ConditionBoundedBuffer
* <p/>
* Bounded buffer using explicit condition variables
*
* @author Brian Goetz and Tim Peierls
*/
@ThreadSafe
public class ConditionBoundedBuffer <T> {
protected final Lock lock = new ReentrantLock();
// CONDITION PREDICATE: notFull (count < items.length)
private final Condition notFull = lock.newCondition();
// CONDITION PREDICATE: notEmpty (count > 0)
private final Condition notEmpty = lock.newCondition();
private static final int BUFFER_SIZE = 100;
@GuardedBy("lock") private final T[] items = (T[]) new Object[BUFFER_SIZE];
@GuardedBy("lock") private int tail, head, count;
// BLOCKS-UNTIL: notFull
public void put(T x) throws InterruptedException {
lock.lock();
try {
while (count == items.length)
notFull.await();
items[tail] = x;
if (++tail == items.length)
tail = 0;
++count;
notEmpty.signal();
} finally {
lock.unlock();
}
}
// BLOCKS-UNTIL: notEmpty
public T take() throws InterruptedException {
lock.lock();
try {
while (count == 0)
notEmpty.await();
T x = items[head];
items[head] = null;
if (++head == items.length)
head = 0;
--count;
notFull.signal();
return x;
} finally {
lock.unlock();
}
}
}
AbstractQueuedSynchronizer
AbstractQueuedSynchronizer
(AQS)是许多同步器类的基类。AQS是一个用于构建锁和同步器的框架,许多同步器都可以通过AQS很容易并且高效的构造出来。不仅ReentrantLock
和Semaphore
是基于AQS构建的,还包括CountdownLatch
、ReentrantReadWriteLock
、SynchronousQueue
和FutureTask
。
AQS负责管理同步器类中的状态,它管理了一个整数状态信息,可以通过getState
, setState
以及compareAndSetState
等protected类型方法进行操作。
// 尝试获取资源,成功则返回true,失败则返回 false。
protected boolean tryAcquire(int arg)
// 尝试释放资源,成功则返回true,失败则返回 false。
protected boolean tryRelease(int arg)
// 尝试获取资源。负数表示失败;0表示成 功,但没有剩余可用资源;正数表示成功,且有剩余资源。
protected int tryAcquireShared(int arg)
// 尝试释放资源,如果释放后允许唤醒后续 等待结点返回true,否则返回false。
protected boolean tryReleaseShared(int arg)
第十五章 原子变量与非阻塞同步机制
近年来,在并发算法领域的大多数研究都侧重于非阻塞算法,这种算法用底层的原子机器指令(例如比较并交换指令)代替锁来确保数据在并发访问中的的一致性。非阻塞算法被广泛地用在操作系统和JVM中实现线程/进程调度机制、垃圾回收机制以及锁和其他并发数据结构。
与基于锁的方案相比,非阻塞算法在设计和实现上都要复杂得多,但它们在可伸缩性和活跃性上却拥有巨大的优势。由于非阻塞算法可以使多个线程在竞争相同的数据时不会发生阻塞,因此它能在粒度更细的层次上进行协调,并且极大地减少调度开销。而且,在非阻塞算法中不存在死锁和其他活跃性问题。
锁的劣势
在挂起和恢复线程等过程中存在着很大的开销,并且通常存在着较长时间的中断。如果在基于锁的类中包含有细粒度的操作(例如同步器类,在其他大多数方法中只包含了少量操作),那么当在锁上存在着激烈的竞争时,调度开销与工作开销的比值会比较高。
与锁相比,volatile变量是一种更轻量级的同步机制,因为在使用这些变量时不会发生上下文切换或线程调度等操作。然而,volatile变量同样存在一些局限:虽然它们提供了相似的可见性保证,但不能用于构建原子的复合操作。因此,当一个变量依赖其他的变量时,或者当变量的新值依赖于旧值时,就不能使用volatile变量。这些都限制了volatile变量的使用,因此它们不能用来实现一些常见的工具,例如计数器或互斥体(mutex)。
锁定还存在其他一些缺点,当一个线程正在等待锁时,它不能做任何其他事情。如果被阻塞线程的优先级较高,而持有锁的线程优先级较低,那么这将是一个严重的问题——也被称为优先级反转(Priority Inversion)。
即使忽略这些风险,锁定方式对于细粒度的操作(例如递增计数器)来说仍然是一种高开销的机制。
硬件对并发的支持
在针对多处理器操作而设计的处理器中提供了一种特殊指令,用于管理对共享数据的并发访问。在早期的处理器中支持原子的测试并设置(Test-and-Set),获取并递增(Fetch-and-Increment)以及交换(Swap)等指令,这些指令足以实现各种互斥体,而这些互斥体又可以实现一些更复杂的并发对象。现在,几乎所有的处理器都包含了某种形式的原子读-改-写指令,例如比较并交换(Compare-and-Swap)或者关联加载/条件存储(Load-Linked/Store-Conditional)。操作系统和JVM使用这些指令来实现锁和并发的数据结构,但在Java 5.0之前,在Java类中还不能直接使用这些指令。
比较并交换
在大多数处理器架构中,采用的方法是实现一个比较并交换(CAS)指令。CAS包含了3个操作数——需要读写的内存位置V、进行比较的值A和拟写入的新值B。当且仅当V的值等于A时,CAS才会通过原子方式用新值B来更新V,否则不会执行任何操作。无论位置V的值是否等于A,都将返回V原有的值。(这种变化形式被称为比较并设置,无论操作是否成功都会返回。)
下面为模拟CAS代码:
/**
* SimulatedCAS
* <p/>
* Simulated CAS operation
*
* @author Brian Goetz and Tim Peierls
*/
@ThreadSafe
public class SimulatedCAS {
@GuardedBy("this") private int value;
public synchronized int get() {
return value;
}
public synchronized int compareAndSwap(int expectedValue,
int newValue) {
int oldValue = value;
if (oldValue == expectedValue)
value = newValue;
return oldValue;
}
public synchronized boolean compareAndSet(int expectedValue,
int newValue) {
return (expectedValue
== compareAndSwap(expectedValue, newValue));
}
}
当多个线程尝试使用CAS同时更新同一个变量时,只有其中一个线程能更新变量的值,而其他线程都将失败。然而,失败的线程并不会被挂起(这与获取锁的情况不同:当获取锁失败时,线程将被挂起),而是被告知在这次竞争中失败,并可以再次尝试。由于一个线程在竞争CAS时失败不会堵塞,因此它可以决定是否重新尝试,或者执行一些恢复操作,也或者不执行任何操作。这种灵活性就大大减少了与锁相关的活跃性风险(尽管在一些不常见的情况下仍然存在活锁风险)。
JVM对CAS的支持
在Java 5.0中引入了底层的支持,在int、long和对象的引用等类型上都公开了CAS操作,并且JVM把它们编译为底层硬件提供的最有效方法。带支持CAS的平台上,运行时把它们编译为相应的(多条)机器指令。在最坏的情况下,如果不支持CAS指令,那么JVM将使用自旋锁。在原子变量类(例如java.util.concurrent.atomic中的AtomicXxx)中使用了这些底层的JVM支持,为数字类型和引用类型提供一种高效的CAS操作,而在java.util.concurrent中的大多数类在实现时则直接或间接地使用了这些原子变量类。
原子变量类
原子变量比锁的粒度更细,量级更轻,并且对于在多处理器系统上实现高性能的并发代码来说是非常关键的。原子变量将发生竞争的范围缩小到单个变量上,这是你获得的粒度最细的情况(假设算法能够基于这种细粒度来实现)。
共有12个原子变量类,可分为4组:标量类(Scalar)、更新器类、数组类以及复合变量类。
最常见的原子变量就是标量类:AtomicInteger
、AtomicLong
、AtomicBoolean
以及AtomicReference
。所有这些类都支持CAS,此外AtomicInteger
和AtomicLong
支持算术运算。(要想模拟其他基本类型的原子变量,可以将short或byte等类型与int类型进行转换,以及使用floatToBits
或doubleToLongBits
来转换浮点数。)
原子的域更新器类表示现有volatile域的一种基于反射的“视图”,从而能够在已有的volatile域上使用CAS。更新器类提供的原子性保证比普通原子类更弱一些,因为无法保证底层的域不被直接修改——compareAndSet
和算数方法只能确保其他使用原子域更新器方法的线程的原子性。
原子数组类(支持Integer、Long和Reference版本)中的元素可以实现原子更新。原子数组类为数组的元素提供了volatile类型的访问语义,这是普通数组所不具备的特性——volatile类型的数组仅在数组引用上具有volatile语义,而在其元素上没有。
复合变量类:AtomicStampedReference
(以及AtomicMarkableReference
)支持在两个变量上执行原子的条件更新。AtomicStampedReference
将更新一个“对象-引用”二元组,通过在引用上加上“版本号”,从而避免ABA问题(ABA问题是指:如果再算法中的节点可以被循环使用,那么在使用“比较并交换”指令时就可能出现的问题)。类似地,AtomicMarkableReference
将更新一个“对象引用-布尔值”二元组,在某些算法中奖通过这种二元组使节点保存在链表中同时又将其标记为“已删除的节点”。
性能比较:锁与原子变量
在高度竞争的情况下,锁的性能将超过原子变量的性能,但在更真实的竞争情况下,原子变量的性能将超过锁的性能,这是因为锁在发生竞争时会挂起线程,从而降低了CPU的使用率和共享内存总线上的同步通信量。
锁与原子变量在不同竞争程度上的性能差异很好的说明了各自的优势和劣势。在中低程度的竞争中,原子变量能提供更高的可伸缩性,而在高强度的竞争下,锁能够更有效的避免竞争。
非阻塞算法
如果在某种算法中,一个线程的失败或挂起不会导致其他线程也失败或挂起,那么这种算法就被称为非阻塞算法。如果在算法的每个步骤中都存在某个线程能够执行下去,那么这种算法也被称为无锁(Lock-Free)算法。如果在算法中仅将CAS用于协调线程之间的操作,并且能正确地实现,那么它既是一种无阻塞算法,又是一种无锁算法。
非阻塞算法的特性:某项工作的完成具有不确定性,必须重新执行。
CAS的基本使用模式:在更新某个值时存在不确定性,以及在更新失败时重新尝试。
构建非阻塞算法的技巧在于:将执行原子修改的范围缩小到单个变量。
第十六章 Java内存模型
什么是内存模型,为什么需要它
JMM(Java内存模型)规定了JVM必须遵循一组最小保证,这组保证规定了对变量的写入操作在何时将对于其他线程可见。
平台的内存模型
在共享内存的多处理器体系架构中,每个处理器都拥有自己的缓存,并且定期的为主内存进行协调。在不同的处理器架构中提供了不同级别的缓存一致性(Cache Coherence),其中一部分只提供最小的保证,即允许不同的处理器在任意时刻从同一存储位置上看到不同的值。操作系统、编译器以及运行时(有时甚至包括应用程序)需要弥合这种在硬件能力与线程安全需求之间的差异。
要想确保每个处理器都能在任意时刻知道其他处理器正在进行的工作,将需要非常大的开销。在大多数时间里,这种信息是不必要的,因此处理器会适当放宽存储一致性保证,以换取性能的提升。
重排序
在没有充分同步的程序中,如果调度器采用不恰当的方式来交替执行不同线程的操作,那么将导致不正确的结果。更糟的是,JMM还使得不同线程看到的操作执行顺序是不同的,从而导致在缺乏同步的情况下,要推断操作的执行顺序将变得更加复杂。各种使操作延迟或者看似乱序执行的不同原因,都可以归为重排序。
如果没有同步,那么推断出执行顺序将是非常困难的。而要保确保在程序中正确的使用同步却非常容易(相对的)。同步将限制编译器、运行时和硬件对内存操作重排序的方式,从而在实施重排序时不会破坏JMM提供等可见性保证。
Java内存模型简介
要想保证执行操作B的线程看到操作A的结果(无论A和B是否在同一个线程中执行),那么在A和B之间必须满足Happens-Before关系。
Happens-Before的规则包括:
**程序顺序规则。**一个线程中的每个操作,happens-before于该线程中的任意后续操作。(编译器仍然有可能重排序,但保证编译后执行结果与编译前执行结果相同,但是此处仍然可能存在问题,因为这个定律针对单个线程,无法避免多线程情况因为重排序产生错误。)
**监视器锁规则。**对一个锁的解锁,happens-before于随后对这个锁的加锁。
**volatile变量规则。**对一个volatile域的写,happens-before于任意后续对这个volatile域的读。
**线程启动规则。**在线程上对
Thread.start
的调用必须在该线程中执行任何操作之前执行。**线程结束规则。**线程中的任何操作都必须在其他线程检测到该线程已经结束之前执行,或者从
Thread.join
中返回,或者在调用Thread.isAlive
时返回false。**中断规则。**当一个线程在另一个线程上调用interrupt时,必须在被中断线程检测到interrupt调用之前执行(通过抛出
InterruptedException
,或者调用isInterrupted
和interrupted
)。**终结器规则。**对象的构造函数必须在启动该对象的终结器之前执行完成。
**传递性。**如果操作A在操作B之前执行,并且操作B在操作C之前执行,那么操作A必须在操作C之前执行。
借助同步
由于Happens-Before的排序功能很强大,因此有时候可以“借助(Piggyback)”现有同步机制的可见性属性。这需要将Happens-Before的程序顺序规则与其他某个顺序规则(通常是监视器锁规则或者volatile变量规则)结合起来,从而对某个未被锁保护的变量的访问操作进行排序。这项技术由于对语句的顺序非常敏感,因此很容易出错。它是一项高级技术,并且只有当需要最大限度的提升某些类(例如ReentrantLock)的性能时,才应该使用这项技术。
发布
造成不正确发布的真正原因,就是在“发布一个共享对象”与“另一个线程访问对象”之间,缺少一种Happens-Before排序。
不安全的发布
在初始化一个新的对象时需要写入多个变量,即对象中的各个域。同样,在发布一个引用时也需要写入一个变量,即新对象的引用。如果无法确保发布共享引用的操作在另一个线程加载该共享引用之前执行,那么对新对象引用的写入操作将与对象中各个域的写入操作重排序(从使用该对象的线程的角度来看)。在这种情况下,另一个线程可能看到对象引用的最新值,但同时也将看到对象的某些或全部状态中包含的是无效值,即一个被部分构造对象。
安全的发布
第三章介绍的安全发布常用模式可以确保被发布对象对于其他线程是可见的,因为他们保证发布对象的操作将在使用对象的线程开始使用该对象的引用之前执行。
事实上,Happens-Before比安全发布提供了更强可见性与顺序保证(不仅能看到安全发布的对象,还能确保看到对象发布前的所以操作)。
安全初始化模式
静态初始化器是由JVM在类的初始化阶段执行,即在类被加载后并且被线程使用之前,由于JVM将在初始化期间获得一个锁,并且每个线程都至少获取一次这个锁以确保这个类已经加载,因此在静态初始化期间,内存写入操作将自动对所有线程可见。因此无论是在被构造期间还是被引用时,静态初始化的对象都不需要显示的同步。然而,这个规则仅适用于在构造时的状态,如果对象是可变的,那么在读线程和写线程之间仍然需要通过同步来确保随后的修改操作是可见的,以及避免数据破坏。
双重检查锁存在问题:由于最外层判空没有加锁,可能看到引用为非空,但是对象的状态值却是无效或错误的。
初始化过程中的安全性
初始化安全性将确保,对于被正确构造的对象,所有线程都能看到由构造函数为对象各个final域设置的正确值,而不管采用何种方式来发布对象。而且,对于可以通过被正确构造对象中某个final域到达的任意变量(例如某个final数组中的元素,或者由以个final域引用的HashMap
的内容)将同样对其他线程是可见的。
对于含有final域的对象,初始化安全性可以防止对对象的初始引用被重排序到构造函数之前。对于通过final域可到达的初始变量的写入操作,将不会与构造过程后的操作一起被重排序。
不可变对象的初始化安全性:
/**
* SafeStates
* <p/>
* Initialization safety for immutable objects
*
* @author Brian Goetz and Tim Peierls
*/
@ThreadSafe
public class SafeStates {
private final Map<String, String> states;
public SafeStates() {
states = new HashMap<String, String>();
states.put("alaska", "AK");
states.put("alabama", "AL");
/*...*/
states.put("wyoming", "WY");
}
public String getAbbreviation(String s) {
return states.get(s);
}
}
转载自:https://juejin.cn/post/7041559192749998111