锁的升级和对比
java1.6为了减少获得锁和释放锁带来的性能消耗,引入了"偏向锁"和"轻量级锁"。
偏向锁
偏向锁为了解决大部分情况下只有一个线程持有锁的情况。
大概逻辑是:每次获得锁时,在锁的对象头信息中存储了当前线程的ID,下次获取锁的时候,不需首先使用CAS竞争锁,只需要去对象头里查看是否是当前线程的ID即可。
ps:其实对象头中的线程ID应该时刻变化的,不知道细节是怎么处理的。暂时就了解到这一步。不再深入学习。
轻量级锁
轻量级锁使用自旋的CAS获取锁。
重量级锁
重量级锁使用阻塞来获取锁。
Java内存模型的抽象结构
在java中,实例域,静态域,数组元素都是存放在堆内存中的。对内存在线程之间共享。
局部变量,方法定义参数,异常处理器参数不会再线程之间共享。
线程之间的共享变量存储在主内存中(Main Memory),每一个线程都有自己的本地内存(Local Memory),本地内存中存储着读/写共享变量的副本。
由上图可以看出,线程之间的通信由两个步骤:
线程A把修改后的本地内存中的共享变量更新到主内存中去
线程B到主内存中读取线程A之前更新过的共享变量
从整体上看,这就是线程A在向线程B发送消息,而且这个消息必须经过主内存。JMM通过控制主内存与每个线程的本地内存之间的交互,来为Java程序员提供内存可见性保证。
重排序和内存栅栏
重排序包含两种:
- Java编译器对代码进行重排序
- 处理器指令级重排序
一旦我们使用volatile语义或者synchronize同步,或者锁,都会限制重排序。
限制重排序是通过内存栅栏实现的。
内存栅栏不仅能限制重排序,还能通过刷新每个CPU的缓存实现可视性。
有四类内存栅栏指令:
屏障类型 |
指令示例 |
说明 |
LoadLoad Barriers |
Load1; LoadLoad; Load2 |
确保Load1数据的装载,之前于Load2及所有后续装载指令的装载。 |
StoreStore Barriers |
Store1; StoreStore; Store2 |
确保Store1数据对其他处理器可见(刷新到内存),之前于Store2及所有后续存储指令的存储。 |
LoadStore Barriers |
Load1; LoadStore; Store2 |
确保Load1数据装载,之前于Store2及所有后续的存储指令刷新到内存。 |
StoreLoad Barriers |
Store1; StoreLoad; Load2 |
确保Store1数据对其他处理器变得可见(指刷新到内存),之前于Load2及所有后续装载指令的装载。StoreLoad Barriers会使该屏障之前的所有内存访问指令(存储和装载指令)完成之后,才执行该屏障之后的内存访问指令。 |
happens-before
如果一个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须存在happens-before关系。这里提到的两个操作既可以是在一个线程之内,也可以是在不同线程之间。
happens-before规则如下:
- 程序顺序规则:一个线程中的每个操作,happens- before 于该线程中的任意后续操作(不太明白。我的理解:即使在一个同步块内部,也会有重排序,应该说是由依赖关系的两步操作,才可能会遵循这个规则吧?)。
- 监视器锁规则:对一个监视器锁的解锁,happens- before 于随后对这个监视器锁的加锁。
- volatile变量规则:对一个volatile域的写,happens- before 于任意后续对这个volatile域的读。
- 传递性:如果A happens- before B,且B happens- before C,那么A happens- before C。
注:这里只罗列了4个,其实还有,可以参考上一篇文章。
注意,两个操作之间具有happens-before关系,并不意味着前一个操作必须要在后一个操作之前执行(不用紧跟着执行)!happens-before仅仅要求前一个操作(执行的结果)对后一个操作可见,且前一个操作按顺序排在第二个操作之前
as-if-serial
不管怎么重排序(编译器和处理器为了提高并行度),程序的执行结果不能被改变,编译器,runtime和处理器都必须遵守as-if-serial语义。
为了遵守as-if-serial语义,编译器和处理器不会对存在数据依赖关系的操作做重排序,因为这种重排序会改变执行结果。
数据依赖关系如:
a = 1;b=a;
或者:
a = 1;a=2;
或者:
a= b;b=1
顺序一致性内存模型
这是一种理想化的理论参考模型。顺序一致性内存模型进制重排序,每个操作都必须原子执行并立即对所有线程可见。
JMM实现的的顺序一致性:
- 使用同步:同步块会串行执行,同步块内部的代码可能会重排序。
- 不适用同步:只能保证最小安全性:线程读取到的共享值,要么是之前某个线程写入的值(可能过期),要么是初始值。
volatile的内存语义
volatile内衣保证原子性和可见性。
具体实现原理是:
使用内存栅栏,在代码的相应位置插入相应的内存栅栏指令,可以做到:
- 限制重排序:volatile写不能上一步(任何操作)做重排序 + volatile写不能和下一步的volatile读重排序 + volatile读不能和下一步(任何操作)重排序。(ps:这个地方也不太明白)
- 缓存:当写入一个volatile变量时,JMM会把该线程对应的本地内存中的(所有)共享变量刷新到主内存。
- 缓存:当读取一个volatile变量时,JMM会清空该线程对应的(所有)本地内存共享变量,那么读取的时候只能去主内存中重新读取。
锁的内存语义
- 实现互斥访问。
- 缓存:当释放一个锁时,JMM会把该线程对应的本地内存中的(所有)共享变量刷新到主内存。
- 缓存:当获得一个锁时,JMM会清空该线程对应的(所有)本地内存共享变量,那么读取的时候只能去主内存中重新读取。
锁的内存语义的实现:
我的理解:锁是java使用代码实现的,处理器指令层面没有锁的概念。
具体细节我就不再描述了,大概逻辑是:
AbstractQueuedSynchronizer是一个底层的虚拟类,它内部定义了一个volatile的int属性(因为是可重入的,这里是一个计数)。通过对着属性执行CAS操作实现类似于tryLock的操作。
如果是"轻量级锁",那么lock操作应该是自旋tryLock实现的。
如果是"重量级锁",那么lock操作应该是在tryLock失败之后直接挂起,等待被唤醒后重试。(我理解的,挂起和唤醒是操作系统支持的语义)
AbstractQueuedSynchronizer
因为AbstractQueuedSynchronizer比较特殊,在它的基础上实现了ReentrantLock,ReadWriteLock,以及闭锁,信号量等。所以这里把它的API列出来。
AbstractQueuedSynchronizer是一个抽象类,它内部维护了一个volatile类型的int对象,叫state,同时实现了一个不可重写的final方法compareAndSetState,这个方法提供对state进行原子的CAS操作(应该是native代码编写的)。还有两个同样final和原子的方法:getState和setState。
AbstractQueuedSynchronizer内部还维护了一个先进先出的队列。
它的API说明如下:
为实现依赖于先进先出 (FIFO) 等待队列的阻塞锁定和相关同步器(信号量、事件,等等)提供一个框架。
此类的设计目标是成为依靠单个原子 int 值来表示状态的大多数同步器的一个有用基础。
子类必须定义更改此状态的受保护方法,并定义哪种状态对于此对象意味着被获取或被释放。假定这些条件之后,此类中的其他方法就可以实现所有排队(通过Queue)和阻塞(通过volatile和CAS)机制。
子类可以维护其他状态字段,但只是为了获得同步而只追踪使用 getState()、setState(int) 和 compareAndSetState(int, int) 方法来操作以原子方式更新的 int 值。
应该将此类定义为非公共内部帮助器类,可用它们来实现其封闭类的同步属性。类 AbstractQueuedSynchronizer 没有实现任何同步接口。而是定义了诸如 acquireInterruptibly(int) 之类的一些方法,在适当的时候可以通过具体的锁定和相关同步器来调用它们,以实现其公共方法。
使用
为了将此类用作同步器的基础,需要适当地重新定义以下方法,这是通过使用 getState()、setState(int) 和/或 compareAndSetState(int, int) 方法来检查和/或修改同步状态来实现的:
即使此类基于内部的某个 FIFO 队列,它也无法强行实施 FIFO 获取策略。独占同步的核心采用以下形式:
Acquire:
while (!tryAcquire(arg)) {
enqueue thread if it is not already queued;
possibly block current thread;
}
Release:
if (tryRelease(arg))
unblock the first queued thread;
用例
以下是一个非再进入的互斥锁定类,它使用值 0 表示未锁定状态,使用 1 表示锁定状态。它还支持一些条件并公开了一个检测方法:
- class Mutex implements Lock, java.io.Serializable {
- // Our internal helper class
- private static class Sync extends AbstractQueuedSynchronizer {
- // Report whether in locked state
- protected boolean isHeldExclusively() {
- return getState() == 1;
- }
- // Acquire the lock if state is zero
- public boolean tryAcquire(int acquires) {
- assert acquires == 1; // Otherwise unused
- return compareAndSetState(0, 1);
- }
- // Release the lock by setting state to zero
- protected boolean tryRelease(int releases) {
- assert releases == 1; // Otherwise unused
- if (getState() == 0) throw new IllegalMonitorStateException();
- setState(0);
- return true;
- }
- // Provide a Condition
- Condition newCondition() { return new ConditionObject(); }
- // Deserialize properly
- private void readObject(ObjectInputStream s) throws IOException, ClassNotFoundException {
- s.defaultReadObject();
- setState(0); // reset to unlocked state
- }
- }
- // The sync object does all the hard work. We just forward to it.
- private final Sync sync = new Sync();
- public void lock() { sync.acquire(1); }
- public boolean tryLock() { return sync.tryAcquire(1); }
- public void unlock() { sync.release(1); }
- public Condition newCondition() { return sync.newCondition(); }
- public boolean isLocked() { return sync.isHeldExclusively(); }
- public boolean hasQueuedThreads() { return sync.hasQueuedThreads(); }
- public void lockInterruptibly() throws InterruptedException {
- sync.acquireInterruptibly(1);
- }
- public boolean tryLock(long timeout, TimeUnit unit) throws InterruptedException {
- return sync.tryAcquireNanos(1, unit.toNanos(timeout));
- }
- }
以下是一个锁存器类,它类似于 CountDownLatch,除了只需要触发单个 signal 之外。因为锁存器是非独占的,所以它使用 shared 的获取和释放方法。
- class BooleanLatch {
- private static class Sync extends AbstractQueuedSynchronizer {
- boolean isSignalled() { return getState() != 0; }
- protected int tryAcquireShared(int ignore) {
- return isSignalled()? 1 : -1;
- }
- protected boolean tryReleaseShared(int ignore) {
- setState(1);
- return true;
- }
- }
- private final Sync sync = new Sync();
- public boolean isSignalled() { return sync.isSignalled(); }
- public void signal() { sync.releaseShared(1); }
- public void await() throws InterruptedException {
- sync.acquireSharedInterruptibly(1);
- }
- }
读写锁的视线分析
接下来将分析ReentrantReadWriteLock的实现,主要包括:读写状态的设计、写锁的获取与释放、读锁的获取与释放以及锁降级(以下没有特别说明读写锁均可认为是ReentrantReadWriteLock)。
读写状态的设计
读写锁同样依赖自定义同步器来实现同步功能,而读写状态就是其同步器的同步状态。回想ReentrantLock中自定义同步器的实现,同步状态表示锁被一个线程重复获取的次数,而读写锁的自定义同步器需要在同步状态(一个整型变量)上维护多个读线程和一个写线程的状态,使得该状态的设计成为读写锁实现的关键。
如果在一个整型变量上维护多种状态,就一定需要"按位切割使用"这个变量,读写锁是将变量切分成了两个部分,高16位表示读,低16位表示写,划分方式如图1所示。
图1. 读写锁状态的划分方式
如图1所示,当前同步状态表示一个线程已经获取了写锁,且重进入了两次,同时也连续获取了两次读锁。读写锁是如何迅速的确定读和写各自的状态呢?答案是通过位运算。假设当前同步状态值为S,写状态等于 S & 0x0000FFFF(将高16位全部抹去),读状态等于 S >>> 16(无符号补0右移16位)。当写状态增加1时,等于S + 1,当读状态增加1时,等于S + (1 << 16),也就是S + 0x00010000。
根据状态的划分能得出一个推论:S不等于0时,当写状态(S & 0x0000FFFF)等于0时,则读状态(S >>> 16)大于0,即读锁已被获取。
写锁的获取与释放
写锁是一个支持重进入的排它锁。如果当前线程已经获取了写锁,则增加写状态。如果当前线程在获取写锁时,读锁已经被获取(读状态不为0)或者该线程不是已经获取写锁的线程,则当前线程进入等待状态,获取写锁的代码如下:
- protected final boolean tryAcquire(int acquires) {
- Thread current = Thread.currentThread();
- int c = getState();
- int w = exclusiveCount(c);
- if (c != 0) {
- // 存在读锁或者当前获取线程不是已经获取写锁的线程
- if (w == 0 || current != getExclusiveOwnerThread())
- return false;
- if (w + exclusiveCount(acquires) > MAX_COUNT)
- throw new Error("Maximum lock count exceeded");
- setState(c + acquires);
- return true;
- }
- if (writerShouldBlock() || !compareAndSetState(c, c + acquires)) {
- return false;
- }
- setExclusiveOwnerThread(current);
- return true;
- }
该方法除了重入条件(当前线程为获取了写锁的线程)之外,增加了一个读锁是否存在的判断。如果存在读锁,则写锁不能被获取,原因在于:读写锁要确保写锁的操作对读锁可见,如果允许读锁在已被获取的情况下对写锁的获取,那么正在运行的其他读线程就无法感知到当前写线程的操作。因此只有等待其他读线程都释放了读锁,写锁才能被当前线程所获取,而写锁一旦被获取,则其他读写线程的后续访问均被阻塞。
写锁的释放与ReentrantLock的释放过程基本类似,每次释放均减少写状态,当写状态为0时表示写锁已被释放,从而等待的读写线程能够继续访问读写锁,同时前次写线程的修改对后续读写线程可见。
读锁的获取与释放
读锁是一个支持重进入的共享锁,它能够被多个线程同时获取,在没有其他写线程访问(或者写状态为0)时,读锁总会成功的被获取,而所做的也只是(线程安全的)增加读状态。如果当前线程已经获取了读锁,则增加读状态。如果当前线程在获取读锁时,写锁已被其他线程获取,则进入等待状态。获取读锁的实现从Java 5到Java 6变得复杂许多,主要原因是新增了一些功能,比如:getReadHoldCount()方法,返回当前线程获取读锁的次数。读状态是所有线程获取读锁次数的总和,而每个线程各自获取读锁的次数只能选择保存在ThreadLocal中,由线程自身维护,这使获取读锁的实现变得复杂。因此,这里将获取读锁的代码做了删减,保留必要的部分,代码如下。
- protected final int tryAcquireShared(int unused) {
- for (;;) {
- int c = getState();
- int nextc = c + (1 << 16);
- if (nextc < c)
- throw new Error("Maximum lock count exceeded");
- if (exclusiveCount(c) != 0 && owner != Thread.currentThread())
- return -1;
- if (compareAndSetState(c, nextc))
- return 1;
- }
- }
在tryAcquireShared(int unused)方法中,如果其他线程已经获取了写锁,则当前线程获取读锁失败,进入等待状态。如果当前线程获取了写锁或者写锁未被获取,则当前线程(线程安全,依靠CAS保证)增加读状态,成功获取读锁。
读锁的每次释放均(线程安全的,可能有多个读线程同时释放读锁)减少读状态,减少的值是(1 << 16)。
final域的内存语义
- JMM禁止编译器把final域的写重排序到构造函数之外(我的理解:也就是说非final域可能发生这种情况,导致其他线程看到未完全构造完成的对象)。
- 第一次读取final域的对象的引用,在顺序上必须是第一次读取,不能跟随后的第二次第三次重排序。(我的理解:我怀疑第一次可能涉及初始化这个final域,但是final域明明是在构造函数之前初始化的的,那么就应该是构造函数内部,也可能多次读取final域)
具体的原因和实现书上有,我不具体研究了。
双重检查锁定和延迟初始化
上一篇讲过这个问题。不过这本书给了另外一种解决方案:
基于类初始化的解决方案
JVM在类(Class,而不是对象)的初始化阶段(即在Class被加载后,且被线程使用之前),会执行类的初始化。在执行类的初始化期间,JVM会去获取一个锁。这个锁可以同步多个线程对同一个类的初始化。
基于这个特性,可以实现另一种线程安全的延迟初始化方案(这个方案被称之为Initialization On Demand Holder idiom):
- public class InstanceFactory {
- private static class InstanceHolder {
- public static Instance instance = new Instance();
- }
- public static Instance getInstance() {
- return InstanceHolder.instance ; //这里将导致InstanceHolder类被初始化
- }
- }
我的理解:其实这种方式是在初始化阶段加锁一次实现的,而使用volatile的双重检查锁定一般情况下,也只会加锁一次(因为第二次进入,if会成功,就不进入synchronize块了)。在性能上,他们两个差不多吧。