线程同步机制
从广义上说,Java平台提供的线程同步机制包括锁、volatile关键字、final关键字和一些相关的API,如Object.wait( )/.notify( )等
锁
定义:锁具有排他性,即一个锁一次只能被一个线程持有。因此,这种锁被称为排他锁或者互斥锁。还有另外一种锁--读写锁,它可以被看作排他锁的一种相对改进。
作用:锁能够保护共享数据以实现线程安全,其作用包括保障原子性,保障可见性和保障有序性。
临界区:锁的持有线程在其获得锁之后和释放锁之前的这段时间内所执行的代码被称为临界区。
在java平台中,锁的获得隐含着刷新处理器缓存这个动作,这使得读线程在执行临界区代码前(获得锁之后)可以将写线程对共享变量所做的更新同步到该线程执行处理器的高速缓存中;而锁的释放隐含着冲刷处理器缓存这个动作,这使得写线程对共享变量所做的更新能够被“推送”到该线程执行处理器的高速缓存中,从而对读线程可同步。
锁对可见性、原子性和有序性的保障是有条件的,需要保证以下两点得以满足:
1. 这些线程在访问同一组共享数据的时候必须使用同一个锁。
2. 这些线程中的任意一个线程,即使其仅仅是读取这组共享数据而没有对其进行更新的话,也需要在读取时持有相应的锁。
调度的根因在于资源的排他性,及资源有限。
所有的调度策略中都应考虑公平策略和非公平策略。
内部锁:
内部锁属于非公平锁,而显式锁即支持公平锁又支持非公平锁。
java平台中的任何一个对象都有唯一一个与之关联的锁。这种锁被称为监视器或者内部锁。内部锁是一种排他锁。
java虚拟机对锁的实现,通过synchronized关键字实现。
作为锁句柄的变量通常采用final修饰,这是因为锁句柄变量的值一旦改变,会导致执行同一个同步块的多个线程实际上使用不同的锁,从而导致竞态。所以,通常我们会使用private修饰作为句柄的变量。
内部锁的调度:
- JVM会给每个内部锁分配一个入口集(Entry Set),用于记录等待获得相应内部锁的线程。
- 入口集中的线程被称为内部锁的等待线程
- 调度:当锁被持有的线程释放的时候,该锁的入口集中的任意一个线程将会被唤醒,从而得到再次(上一次申请失败,才会出现在入口集中)申请锁的机会;被唤醒的线程等待占用处理器运行时可能还有其他新的活跃线程与该线程抢占这个被释放的锁;即这是一种非公平的内部锁调度策略。
显式锁:
- 显式锁提供了一些内部锁不具备的特性,但并不是内部锁的替代品。
- java中通过java.util.concurrent.locks.Lock接口的实现类实现
- 公平锁保障锁调度的公平性往往是以增加了线程的暂停和唤醒的可能性,即增加了上下文切换为代价的。因此,公平锁适合于锁被持有的时间相对长或者线程申请锁的平均间隔时间相对长的情形。
读写锁:
- 允许多个线程可以同时读取共享变量,但是一次只允许一个线程对共享变量进行更新
- 适用场景:
- 只读操作比写操作要频繁的多
- 读线程持有锁的时间比较长
锁的适用场景
- check-then-act操作
- read-modify-write操作
- 多个线程对多个共享数据进行更新
线程同步机制的底层助手:内存屏障
内存屏障:
- 在指令序列中就像一堵墙一样使其两侧的指令(内存操作)无法穿越(即不可以重排序CPU指令和内存操作,由于需要实现不可以重排序内存操作,所以会导致刷新处理器缓存和冲刷处理器缓存的操作)。
锁与内存屏障
- 获取锁之后,需要刷新处理器缓存(确保该锁的当前持有线程可以读取到前一个持有线程所做的更新)
- 释放锁之后,需要冲刷处理器缓存(确保该锁的持有线程对这些数据的更新对该锁的后续持有线程可见)
内存屏障分类:
- 按照可见性保障分类:
- 加载屏障(LoadBarrier):刷新处理器缓存,获得锁之前插入
- 存储屏障(StoreBarrier):冲刷处理器缓存,释放锁之后插入
- 按照有序性保障:
- 获取屏障(AcquireBarrier):在一个读操作之后插入该屏障
- 释放屏障(ReleaseBarrier):在一个写操作之前插入该屏障
内存屏障在锁中的使用:
- 获取锁
- 加载屏障 (可见性)
- 获取屏障 (有序性)
- 临界区
- 释放屏障(有序性)
- 存储屏障(可见性)
- 释放锁
- 其中:3和5用来禁止指令重排序
以下主要来自, 并增加了一下补充。
轻量级同步机制:volatile 关键字
- 用于修饰共享可变变量,所以该变量前不应有final关键字。
- volatile被称为轻量级锁:volatile 关键字会触发CPU指令中插入内存屏障保证可见性和有序性。
- 仅仅能保证volatile变量写操作的原子性,但是没有锁的排他性
- 不会引起线程的阻塞和等待,所以它不会导致上下文切换,
- 作用volatile关键字可以保证对long/double型变量的写操作具有原子性
volatile变量写操作与内存屏障:
- 普通变量的读写操作
- 释放屏障(保证之前的读写操作在写操作已经提交,即准备进行写操作时,所赋值的value在多个线程中是一致的,不然如果引起重排序可能导致赋值的value还没有提交,导致语义错误) 主要保证写原子性。
- 写操作
- 存储屏障
volatile变量读操作与内存屏障:
- 加载屏障
- 读操作
- 获取屏障(保证之后的读写操作在读操作之后提交,即后面的操作使用时,此值在多个线程中value是一致的,不然如果引起重排序可能导致获取的共享值还没有提交,导致语义错误)主要保证读可见性。
- 普通变量的读写操作
volatile变量的开销:
- 不会导致上下文切换,开销比锁小
- 读取变量的成本比临界区中读取变量要低,但是其读取成本可能比读取普通变量要高;因为每次读取都从高速缓存或者主内存中读取,无法被暂存在寄存器中,从而无法发挥访问的高效性
应用场景:
- 使用volatile变量作为状态标志
- 保障可见性
替代锁:
- 利用该变量对写操作的原子性
- volatile适合多个线程共享一个状态变量,锁适合多个线程共享一组状态变量或一个原子操作中有多个相互依赖的步骤
- 我们可以将多个线程共享的一组状态变量合并成一个对象,用于一个volatile变量来引用该对象,从而替代锁。多个线程共享一组可变状态变量的时候,可以把这一组可变变量封装成一个不可变的对象,引入时加上关键volatile,那么对这些状态变量的更新操作就可以通过创建一个新的对象并将该对象引用赋值给相应的引用型变量来实现。在这个过程中,volatile保障了原子性和可见性,从而避免了锁的使用。
- 使用volatile实现简易版读写锁,这种简易版本读写锁仅涉及一个共享变量并且允许一个线程读取共享变量时其他线程可以更新该变量。因此,这种读写锁允许读线程可以读取到共享变量的非最新值。典型例子是计数器。
单例模式:
- 基于双检锁的volatile的单例模式(使用volatile修饰实例变量,防止实例赋值时new Instance() 操作被重排序(new Instance()操作可以被分解成三个步骤,1. 初始化对象内存空间,2. 初始化对象成员变量默认值,3.对象引用写入共享变量))
- 基于静态内部类的单例模式(利用类静态变量初始化特点,详情见对象初始化安全描述)
- 基于枚举类型的单例模式(目前最安全的方式,可以防止反射、序列化的破坏)
CAS与原子变量
- 定义:一种处理器指令的称呼
- 作用:保障像自增这种比较简单的操作的原子性我们有更好的选择CAS。CAS 能够将read-modify-write和check-and-act之类的操作转换为原子操作。(保障简单的原子性)
- CAS只是保障了共享变量更新这个操作的原子性,它并不保障可见性。因此需要采用volatile修饰共享变量。
基于CAS算法的代码模板:
do {
oldValue = V.get();// 读取共享变量当前值
newValue = calculate(oldValue);// 计算共享变量的新值
} while (/* 调用CAS来更新共享变量的值 */!compareAndSwap(V, oldValue, newValue));
原子变量类:
基于CAS实现的能够保障对共享变量进行read-modify-write更新操作的原子性和可见性的一组工具类。
关于check-then-act操作的原子性,可以使用AtomicBoolean的compareAndSwap方法来保障其原子性。样例代码
public enum AlarmMgr implements Runnable {
// 保存该类的唯一实例
INSTANCE;
private final AtomicBoolean initializating = new AtomicBoolean(false);
boolean initInProgress;
AlarmMgr() {
// 什么也不做
}
public void init() {
// 使用AtomicBoolean的CAS操作确保工作者线程只会被创建(并启动)一次
if (initializating.compareAndSet(false, true)) {
Debug.info("initializating...");
// 创建并启动工作者线程
new Thread(this).start();
}
}
当涉及单个共享变量的原子性操作时,可以尽量用原子性工具类替换锁,增强程序的性能。
CAS的问题 —— ABA 问题
CAS 上下文中的ABA问题。
例如,对于共享变量V,当前线程看到它的值为A的那一刻,其他线程已经将其更新为B,接着在当前线程执行CAS的时候该变量的值又被其他线程更新为A,那么此时我们是否认为变量V的值没有被其他线程更新过呢?这就是ABA问题,即共享变量的值经历了A-B-A的更新。
规避ABA问题也不难,那就是为共享变量的更新引入一个修订号(时间戳)
对象的初始化安全:重访final与static
- Java中类的初始化实际上也采取延迟加载的技术,即一个类被JVM加载后,该类的静态变量的值都仍然是默认值(引用的任意型静态变量的默认值为null,boolean变量的默认值为false),直到某个线性初次访问了该类的任意一个静态变量或静态方法才使这个类的静态变量被初始化——类的静态初始化块(static{})被执行。如下代码所示:
public class ClassLazyInitDemo {
public static void main(String[] args) {
System.out.println(Collaborator.class.hashCode());// ???
Collaborator.staticMethod();
//System.out.println(Collaborator.number);// ???
//System.out.println(Collaborator.flag);
}
static class Collaborator {
static int number = 1;
static boolean flag = true;
static {
System.out.println("Collaborator initializing...");
}
static void staticMethod(){
System.out.println("Collaborator invoke static method...");
}
}
}
执行结果如下:
1704856573
Collaborator initializing...
Collaborator invoke static method...
- 如上所示,static关键字在多线程环境下有其特殊的涵义,它能够保证一个线程即使在未使用其他同步机制的情况下也总是可以读取到一个类的静态变量的初始值(而不是默认值),即static可以使得在未使用其他同步机制的情况下,一个线程触发类初始化后结果,对其他的线程是可见的。如下代码所示:
public class ClassLazyInitDemo2 {
public static void main(String[] args) {
Runnable r1 = new Runnable(){
@Override
public void run() {
System.out.println(Thread.currentThread().getName()+":"+Collaborator.class.hashCode());
Collaborator.staticMethod();
System.out.println(Thread.currentThread().getName()+":"+Collaborator.flag);
}
};
Runnable r2 = new Runnable(){
@Override
public void run() {
System.out.println(Thread.currentThread().getName()+":"+Collaborator.number);
}
};
Thread t1 = new Thread(r1);
Thread t2 = new Thread(r2);
t1.start();
t2.start();
}
static class Collaborator {
static int number = 1;
static boolean flag = true;
static {
System.out.println(Thread.currentThread().getName()+":"+"Collaborator initializing...");
}
static void staticMethod(){
System.out.println(Thread.currentThread().getName()+":"+"Collaborator invoke static method...");
}
}
}
执行结果:
Thread-0:1540000978
Thread-1:Collaborator initializing...
Thread-1:1
Thread-0:Collaborator invoke static method...
Thread-0:true
Thread-0:1
- 当然,如果这个这个静态变量在相应类初始化完毕后被其他线程更新过,那么一个线程要读取该相对新值仍然需要借助锁、volatile等同步机制。
- static关键字仅仅保障读线程能够读取到相应字段的初始值,而不是相对新值。
final 关键字作用:
在new一个对象时,操作可以被分解成三个步骤,1. 初始化对象内存空间,2. 初始化对象成员变量初始值,3.对象引用写入共享变量,但是由于重排序的作用,可能的排序是1-3-2,所以,一个线程读取到一个对象引用时,该对象可能尚未初始化完毕,即这些线程可能读取到该对象字段的默认值而不是初始值。解决方案,使用final关键字。
在多线程环境下final关键字有其特殊的作用:
当一个对象发布到其他线程的时候,该对象的所有final字段都是初始化完毕的,即其他线程读取这些字段的时候所读取到的值都是相应字段的初始值(而不是默认值)。而非final字段没有这种保障,即其他线程读取这些字段的时候所读取到的值都是相应字段的默认值(而不是初始值)。当一个对象的引用对其他线程可见的时候,final关键字只能保障有序性,即保障一个对象对外可见的时候该对象的final字段必然是初始化完毕的。
对象安全发布与逸出:
- 对象发布是指对象能够被其作用域之外的线程访问。
- 对象逸出是指当一个对象的发布出现我们不期望的结果或者对象发布本身不是我们所希望的时候,就成为对象逸出。
- 最容易导致对象逸出的一种发布,具体包括以下几种形式:
- 在构造器中将this赋值给一个共享变量
- 在构造器中将this作为方法参数传递给其他方法
- 在构造器中启动基于匿名类的线程
由于构造器未执行结束意味着相应对象的初始化未完成,因此在构造器中将this关键字代表的当前对象发布到其他线程(这里可以是构造器重启动的匿名类线程)会导致这些线程看到的可能是一个未初始化完毕的对象,从而可能导致程序运行结果错误。