线程同步
线程同步机制 是一套用于协调线程间的数据访问(data access)及活动(activity)的机制,该机制用于保障线程安全以及实现这些线 程的共同目标。
如果把线程比作在公路上行驶的车辆, 那么线程同步机制就好比是任何车辆都需要遵循的交通规则。
实现线程同步:
- 锁
- volatile
- final
- static
- API
锁 | volatile | CAS | final | static | |
---|---|---|---|---|---|
原子性保障 | 是 | 变量读写操作本身的原子性 | 是 | ||
可见性保障 | 是 | 是 | 否 | 否 | 初次读取类的静态变量时保障 |
有序性保障 | 是 | 是 | 是 | 初次读取类的静态变量时保障 | |
上下文切换 | 被争用的锁可能导致 | 否 | 否 | 否 | 静态变量所属类初始化可呢导致 |
锁
锁:将多个线程对共享数据的并发访问转换为串行访问,即一个共享数据一次只能被一个线程访问,该线程访问结束后其他线程才能对其进行访问。
锁可以理解为对共享数据进行保护的许可证。对于同一个许可证所保 护的共享数据而言,任何线程访问这些共享数据前必须先持有该许可证。一个线程只有在持有许可证的情况下才能够对这些共享数据进行访问; 并且, 一个许可证一次只能够被一个线程持有;许可证的持有线程在其结束对这些共享数据的访问后必须让出(释放)其持有的许可证,以便其他线程能够对这些共享数据进行访问。
一个线程在访问共享数据前必须申请相应的锁,即锁的获得。锁的持有线程可以对该锁所保护的共享数据进行访问,访问结束后该线程必须释放相应的锁。
锁的持有线程在其获得锁之后和释放锁之前这段时间内所执行的代码被称为 临界区(Critical Section)。
因此,共享数据只允许在临界区内进行访问,临界区一次只能被一个线程执行。
锁具有排他性(Exclusive),即一个锁一次只能被一个线程持有。
因此,这种锁被称为排他锁或者互斥锁(Mutex)。
锁的作用:
- 保障原子性:通过互斥,将多个线程对共享数据的访问由本来的并发改为串行。
- 保障可见性:锁的获得隐含着刷新处理器缓存这个动作,锁的释放隐含着冲刷处理器缓存这个动作。锁的互斥性+可见性,保证临界区内的代码能够读取到共享数据的最新值。也同时保障引用对象的字段的最新值。
- 保障有序性:由于锁对可见性的保障,写线程在临界区中对上述任何一个共享变量所做的更新都对读线程可见。并且, 由于临界区内的操作具有原子性,因此写线程对上述共享变量的更新会同时对读线程可见,即在读线程看来这些变量就像是在同一刻被更新的。即有序性得以保障。
可重入性(reentrancy):一个线程在其持有一个锁的时候能否再次申请该锁。
粒度(granularity):一个锁实例所保护的共享数据的数量大小。
锁的粒度过粗会导致线程在申请锁时进行不必要的等待。
锁的粒度过细会增加锁调度的开销。
锁泄漏(Lock Leak):锁泄漏是指一个线程获得某个锁之后,由于程序的错误、缺陷致使该锁一直无法被释放而导致其他线程一直无法获得该锁的现象。因此, 锁泄漏会导致同步在该锁上的所有线程都无法进展。
内部锁:synchronized
Java 平台中任何一个对象都有唯一一个与之关联的锁,为监视器或内部锁,通过synchronized
关键字实现。
synchronized
(同步)可修饰方法以及代码块。
synchronized (锁句柄) {
// 锁句柄是一个对象的引用 如 this
}
作为锁句柄的变量通常用final
修饰。
因为锁句柄变量值一旦改变,会导致锁变化,从而导致竞态。
同步静态方法相当于以当前类对象(.class)为引导锁的同步块。
内部锁的使用并不会导致锁泄漏。
调度:
Java 虚拟机会为每个内部锁分配一个入口集(Entry Set),用于记录等待获得相应内部锁的线程。
多个线程申请同一个锁的时候,只有一个申请者能够成为该锁的持有线程,而其他申请者的申请操作会失败。这些申请失败的线程会被暂停(BLOCKED)并被存人相应锁的入口集中等待再次申请锁的机会。
入口集中的线程就被称为相应内部锁的等待线程。当这些线程申请的锁被其持有线程释放的时候,该锁的入口集中的一个任意线程会被 Java 虚拟机唤醒,从而 得到再次申请锁的机会(非公平)。
显式锁:Lock 接口
java.util.concurrent.locks.Lock
接口的实例。默认实现类是java.util.concurrent.locks.ReentrantLock
。
public interface Lock {
void lock(); // 获取锁
void lockInterruptibly() throws InterruptedException; // 若当前线程未被中断 则获取锁
boolean tryLock(); // 若调用时锁为空闲 则获取锁
boolean tryLock(long time, TimeUnit unit) throws InterruptedException; // 在给定时间内空闲且当前线程未被中断 则获取锁
void unlock(); // 释放锁
Condition newCondition(); // 获取一个 Condition
}
// ReentrantLock 可重入锁构造器
public ReentrantLock() {
// 默认非公平调度
sync = new NonfairSync();
}
public ReentrantLock(boolean fair) {
sync = fair ? new FairSync() : new NonfairSync();
}
// 显式锁使用
private final Lock lock = ...;
lock.lock();
try {
} finally {
lock.unlock();
}
内部锁与显式锁
内部锁 | 显式锁 |
---|---|
基于代码块 不灵活 | 基于对象 更灵活 |
简单易用 | 功能强大 |
无锁泄漏 | 有锁泄漏 |
公平锁 | 公平/非公平锁 |
读写锁:改进型排他锁
读写锁(Read/Write Lock)是一种改进型的排他锁,允许多个线程可以同时读取共享变量,但只允许一个线程对共享变量进行更新。
读写锁的功能是通过其扮演的两种角色一一读锁(Read Lock)和写锁(Write Lock) 实现的。
读线程在访问共享变量的时候必须持有相应读写锁的读锁。读锁是可以同时被多个线程持有的,即读锁是共享的(Shared)。
写线程在访问共享变量的时候必须持有相应读写锁 的写锁。写锁是排他的(Exclusive),即一个线程持有写锁的时候其他线程无法获得相应锁的写锁或读锁。因此,写锁保障了写线程对共享变量的访问(包括更新)是独占的。
读锁实际上只是在读线程之间是共享的, 任何一个线程持有一个读锁的时候, 其他任何线程都无法获得相应锁的写锁。总的来说,读锁对于读线程来说起到保护其访问的共享变量在其访问期间不被修改的作用, 并使多个读线程可以同时读取这些变量从而提高了并发性; 而写锁保障了写线程能够以独占的方式安全地更新共 享变量。写线程对共享变量的更新对读线程是可见的。
java.tuil.concurrent.locks.ReadWriteLock
接口是对读写锁的抽象,默认实现是ReentrantReadWriteLock
public interface ReadWriteLock {
Lock readLock();
Lock writeLock();
}
// 读写锁使用
private final ReadWriteLock rwLock = new ReentrantReadWriteLock();
private final Lock readLock = rwLock.readLock();
private final Lock writeLock = rwLock.writeLock();
public void reader() {
readLock.lock();
try {
} finally {
readLock.unlock();
}
}
public void writer() {
writeLock.lock();
try {
} finally {
writeLock.unlock();
}
}
同时满足以下条件时适合使用读写锁:
- 只读操作比写(更新)操作要频繁得多
- 读线程持有锁的时间比较长
降级(downgrade):线程持有读写锁的写锁情况下可以获得读锁。
升级(downgrade):线程持有读写锁的读锁情况下可以获得写锁。
锁的适用场景
- check-then-act:一个线程读取共享数据并在此基础上决定下一个操作。
- read-modify-write:一个线程读取共享数据并在此基础上更新该数据。
- 多个线程对多个共享数据更新:如果这些共享数据之间存在关联关系,那么为了保障操作的原子性,可以考虑使用锁。
内存屏障
内存屏障是对一类仅针对内存读、写操作指令(Instruction)的跨处理器架构(比如x86、ARM)的比较底层的抽象。
内存屏障是被插入到两个指令之间进行使用的,其作用是禁止编译器、处理器重排序从而 保障有序性。
它在指令序列中就像是一堵墙一样使其两侧(之前和之后)的指令无法穿越它(禁止重排序)。
但是,为了实现禁止重排序的功能,这些指令也往往具有一个副作用一一刷新处理器缓存、冲刷处理器缓存,从而 保障可见性。
锁与重排序
- 临界区内的操作不允许被重排序到临界区之外
- 临界区内的操作允许重排序
- 临界区外的操作允许重排序
- 锁申请(monitor enter)与锁释放(monitor exit)不能重排序
- 两个锁申请操作不能被重排序
- 两个锁释放操作不能被重排序
- 临界区外的操作允许重排序到临界区内
规则4,5,6共同确保了嵌套锁的支持。
volatile
volatile:易挥发,引申为不稳定,用于修饰可变共享变量。
volatile
关键字表示被修饰的变量的值容易变化,因而不稳定。
volatile
变量的不稳定性意味着对这种变量的读和写操作都必须从高速缓存或者主内存中读取,以读取变量的相对新值。因此,volatile
变量不会被编 译器分配到寄存器进行存储,对volatile
变量的读写操作都是内存访问操作。
volatile
关键字常被称为轻量级锁,其作用与锁的作用有相同的地方: 保证 可见性 和 有序性。
所不同的是,在原子性方面它仅能保障写volatile
变量操作的 原子性,但没有锁的排他性; 其次,volatile
关键字的使用不会引起上下文切换。
volatile
仅保障对其修饰的变量读写操作本身的原子性,不等于对volatile
变量的赋值操作一定具有原子性。
比如对volatile
的赋值操作右边表达式中涉及共享变量,那么这个赋值操作不是原子操作。如果表达式右边的共享变量的值被其他线程更新,有可能导致竞态。
写线程对volatile
的写操作会产生类似于释放锁的效果
读线程对volatile
的读操作会产生类似于获得锁的效果
写操作:
释放屏障禁止了volatile
写操作与该操作之前的任何读写操作与该操作重排序,从而保证了volatile
写操作之前的任何读写操作会先于该写操作被提交。这就保障了读线程对写线程在更新volatile
变量前对共享变量所执行的更新操作的感知顺序与相应的源代码顺序一致,即保障了有序性。
存储屏障冲刷处理器缓存,使得该存储屏障前所有操作的结果对其他处理器来说是同步的。
读操作:
加载屏障通过冲刷处理器缓存,使得读线程所在的处理器将其他处理器对共享变量的更新同步到该处理器的高速缓存中。
获取屏障禁止了volatile
读操作之后的的任何读写操作与该操作重排序,从而保障了volatile
读操作之后的任何操作开始执行前,写线程对相关共享变量的更新已经对当前线程可见。
读线程的加载屏障 + 写线程的存储屏障 = 可见性
读线程的获取屏障 + 写线程的释放屏障 = 有序性
对于引用型volatile
变量,只保证读线程能够读到一个指向对象的相对新的内存地址,而不保证指向的对象的实例/静态变量值是否为相对新值。
应用场景
- 保障long/double变量的写操作的原子性
volatile
变量作为状态标志,使得一个线程能够通知其他线程。- 保障可见性
- 替代锁。多个线程共享一组可变状态变量的时候,利用
volatile
,我们可以把这一组可变状态变量封装成一个对象,那么对这些状态变量的更新操作就可以通过创建一个新的对象并将该对象引用赋值给相应的引用型变量来实现。保障了原子性和可见性,从而避免了锁的使用。 - 实现简易版读写锁。锁用于保障写操作的原子性,
volatile
用于保障共享变量的可见性。
public class Counter {
private volatile long count;
public long value() {
return count;
}
public void increment() {
synchronized (this) {
count++;0.
}
}
}
单例模式
// 单线程 延迟加载
public class Singleton {
private static Singleton instance = null;
private Singleton () {}
public static Singleton getInstance () {
if (instance == null) {
instance = new Singleton();
}
return instance;
}
}
// 简单加锁
public class Singleton {
private static Singleton instance = null;
private Singleton () {}
public static Singleton getInstance () {
synchronized (Singleton.class) {
if (instance == null) {
instance = new Singleton();
}
}
return instance;
}
}
// 双重检查锁定
public class Singleton {
private static volatile Singleton instance;
private Singleton () {}
public static Singleton getInstance () {
if (instance == null) {
synchronized (Singleton.class) {
if (instance == null) {
instance = new Singleton();
}
}
}
return instance;
}
}
// 静态内部类
public class Singleton {
private Singleton () {}
private static class InstanceHolder {
final static Singleton INSTANCE = new Singleton();
}
public static Singleton getInstance () {
return InstanceHolder.INSTANCE;
}
}
// 枚举
public enum Singleton {
INSTANCE;
Singleton () {}
}
CAS
CAS:compare and swap,是对一种处理器指令的称呼。
作用类似于:
boolean compareAndSwap (Variable V, Object A, Object B) {
if (A == V.get()) { // 变量是否已经被其他线程修改
V.set(B);
return true; // 修改成功
}
return false; // 修改失败
}
// CAS 计数器
public void increment () {
long oldValue;
long newValue;
do {
oldValue = count;
newValue = oldValue + 1;
} while (!compareAndSwap(oldValue, newValue));
}
CAS 只保障共享变量更新的原子性,不保证可见性。
原子变量类
基于CAS实现的能够保障对共享变量进行read-modify-write
更新操作的原子性和可见性的一组工具类。
AtomicLong
可以被看作一个增强型的volatile long
private final AtomicLong count = new AtomicLong(0);
public void increment () {
count.incrementAndGet();
}
public long getCount () {
return count.get();
}
对象发布和逸出
对象发布:使对象能够被其作用域之外的线程访问。
安全发布:使对象以线程安全的方式被发布。
对象逸出:对象发布出现不期望的结果。
对象发布:
- 将对象引用到
public
变量中 - 非
private
方法返回对象 - 内部类使用
导致逸出:- 在构造器中
this
赋值给一个共享变量 - 在构造器中
this
作为方法参数传递给其他方法 - 在构造器中启动基于匿名类的线程
- 在构造器中
- 通过方法调用传递给外部方法
安全发布正确创建对象:
- static 修饰引用该对象的变量
- final 修饰引用该对象的变量
- volatile 修饰引用该对象的变量
- AtomicReference 引用该对象
- 对访问该对象的代码加锁
public class SafeObjPublishWhenStartingThread {
private final Map<String, String> objectState;
private SafeObjPublishWhenStartingThread(Map<String, String> objectState) {
this.objectState = objectState;
// 不在构造器中启动工作者线程 避免 this 逸出
}
private void init () {
new Thread() {
public void run () {
}
}.start();
}
public static SafeObjPublishWhenStartingThread newInstance (Map<String, String> objectState) {
SafeObjPublishWhenStartingThread instance = new SafeObjPublishWhenStartingThread(objectState);
instance.init();
return instance;
}
}
static
访问类本身(.class)仅仅使该类被虚拟机加载,并未被初始化。
static
仅仅能够保障线程可以读取到字段的初始值(非默认值),而不是相对新值。
final
当一个对象的引用对其他线程可见时,这些线程看到的final
字段必然是初始化完毕的。
不能保障包含final
字段的对象引用自身对其他线程的可见性。
参考资料:《Java 多线程编程实战指南(核心篇)》 黄文海 著