JAVA并发编程的艺术 JMM内存模型

锁的升级和对比

java1.6为了减少获得锁和释放锁带来的性能消耗,引入了"偏向锁"和"轻量级锁"。

偏向锁

偏向锁为了解决大部分情况下只有一个线程持有锁的情况。

大概逻辑是:每次获得锁时,在锁的对象头信息中存储了当前线程的ID,下次获取锁的时候,不需首先使用CAS竞争锁,只需要去对象头里查看是否是当前线程的ID即可。

ps:其实对象头中的线程ID应该时刻变化的,不知道细节是怎么处理的。暂时就了解到这一步。不再深入学习。

轻量级锁

轻量级锁使用自旋的CAS获取锁。

重量级锁

重量级锁使用阻塞来获取锁。

Java内存模型的抽象结构

在java中,实例域,静态域,数组元素都是存放在堆内存中的。对内存在线程之间共享。

局部变量,方法定义参数,异常处理器参数不会再线程之间共享。

JAVA并发编程的艺术 JMM内存模型

线程之间的共享变量存储在主内存中(Main Memory),每一个线程都有自己的本地内存(Local Memory),本地内存中存储着读/写共享变量的副本。

由上图可以看出,线程之间的通信由两个步骤:

线程A把修改后的本地内存中的共享变量更新到主内存中去 
线程B到主内存中读取线程A之前更新过的共享变量

JAVA并发编程的艺术 JMM内存模型

从整体上看,这就是线程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) 方法来检查和/或修改同步状态来实现的:

tryAcquire(int)

tryRelease(int)

tryAcquireShared(int)

tryReleaseShared(int)

isHeldExclusively()

即使此类基于内部的某个 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 表示锁定状态。它还支持一些条件并公开了一个检测方法:

  1. class Mutex implements Lock, java.io.Serializable {
  2.  
  3.     // Our internal helper class
  4.     private static class Sync extends AbstractQueuedSynchronizer {
  5.       // Report whether in locked state
  6.       protected boolean isHeldExclusively() {
  7.         return getState() == 1;
  8.       }
  9.  
  10.       // Acquire the lock if state is zero
  11.       public boolean tryAcquire(int acquires) {
  12.         assert acquires == 1; // Otherwise unused
  13.         return compareAndSetState(0, 1);
  14.       }
  15.  
  16.       // Release the lock by setting state to zero
  17.       protected boolean tryRelease(int releases) {
  18.         assert releases == 1; // Otherwise unused
  19.         if (getState() == 0) throw new IllegalMonitorStateException();
  20.         setState(0);
  21.         return true;
  22.       }
  23.  
  24.       // Provide a Condition
  25.       Condition newCondition() { return new ConditionObject(); }
  26.  
  27.       // Deserialize properly
  28.       private void readObject(ObjectInputStream s) throws IOException, ClassNotFoundException {
  29.         s.defaultReadObject();
  30.         setState(0); // reset to unlocked state
  31.       }
  32.     }
  33.  
  34.     // The sync object does all the hard work. We just forward to it.
  35.     private final Sync sync = new Sync();
  36.  
  37.     public void lock() { sync.acquire(1); }
  38.     public boolean tryLock() { return sync.tryAcquire(1); }
  39.     public void unlock() { sync.release(1); }
  40.     public Condition newCondition() { return sync.newCondition(); }
  41.     public boolean isLocked() { return sync.isHeldExclusively(); }
  42.     public boolean hasQueuedThreads() { return sync.hasQueuedThreads(); }
  43.     public void lockInterruptibly() throws InterruptedException {
  44.       sync.acquireInterruptibly(1);
  45.     }
  46.     public boolean tryLock(long timeout, TimeUnit unit) throws InterruptedException {
  47.       return sync.tryAcquireNanos(1, unit.toNanos(timeout));
  48.     }
  49.  }

以下是一个锁存器类,它类似于 CountDownLatch,除了只需要触发单个 signal 之外。因为锁存器是非独占的,所以它使用 shared 的获取和释放方法。

  1. class BooleanLatch {
  2.  
  3.    private static class Sync extends AbstractQueuedSynchronizer {
  4.      boolean isSignalled() { return getState() != 0; }
  5.  
  6.      protected int tryAcquireShared(int ignore) {
  7.        return isSignalled()? 1 : -1;
  8.      }
  9.  
  10.      protected boolean tryReleaseShared(int ignore) {
  11.        setState(1);
  12.        return true;
  13.      }
  14.    }
  15.  
  16.    private final Sync sync = new Sync();
  17.    public boolean isSignalled() { return sync.isSignalled(); }
  18.    public void signal() { sync.releaseShared(1); }
  19.    public void await() throws InterruptedException {
  20.      sync.acquireSharedInterruptibly(1);
  21.    }
  22. }

读写锁的视线分析

接下来将分析ReentrantReadWriteLock的实现,主要包括:读写状态的设计、写锁的获取与释放、读锁的获取与释放以及锁降级(以下没有特别说明读写锁均可认为是ReentrantReadWriteLock)。

读写状态的设计

读写锁同样依赖自定义同步器来实现同步功能,而读写状态就是其同步器的同步状态。回想ReentrantLock中自定义同步器的实现,同步状态表示锁被一个线程重复获取的次数,而读写锁的自定义同步器需要在同步状态(一个整型变量)上维护多个读线程和一个写线程的状态,使得该状态的设计成为读写锁实现的关键。

如果在一个整型变量上维护多种状态,就一定需要"按位切割使用"这个变量,读写锁是将变量切分成了两个部分,高16位表示读,低16位表示写,划分方式如图1所示。

图1. 读写锁状态的划分方式

JAVA并发编程的艺术 JMM内存模型

如图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)或者该线程不是已经获取写锁的线程,则当前线程进入等待状态,获取写锁的代码如下:

  1. protected final boolean tryAcquire(int acquires) {
  2.    Thread current = Thread.currentThread();
  3.    int c = getState();
  4.    int w = exclusiveCount(c);
  5.    if (c != 0) {
  6.       // 存在读锁或者当前获取线程不是已经获取写锁的线程
  7.       if (w == 0 || current != getExclusiveOwnerThread())
  8.          return false;
  9.       if (w + exclusiveCount(acquires) > MAX_COUNT)
  10.          throw new Error("Maximum lock count exceeded");
  11.       setState(c + acquires);
  12.       return true;
  13.    }
  14.    if (writerShouldBlock() || !compareAndSetState(c, c + acquires)) {
  15.       return false;
  16.    }
  17.    setExclusiveOwnerThread(current);
  18.    return true;
  19. }

该方法除了重入条件(当前线程为获取了写锁的线程)之外,增加了一个读锁是否存在的判断。如果存在读锁,则写锁不能被获取,原因在于:读写锁要确保写锁的操作对读锁可见,如果允许读锁在已被获取的情况下对写锁的获取,那么正在运行的其他读线程就无法感知到当前写线程的操作。因此只有等待其他读线程都释放了读锁,写锁才能被当前线程所获取,而写锁一旦被获取,则其他读写线程的后续访问均被阻塞。

写锁的释放与ReentrantLock的释放过程基本类似,每次释放均减少写状态,当写状态为0时表示写锁已被释放,从而等待的读写线程能够继续访问读写锁,同时前次写线程的修改对后续读写线程可见。

读锁的获取与释放

读锁是一个支持重进入的共享锁,它能够被多个线程同时获取,在没有其他写线程访问(或者写状态为0)时,读锁总会成功的被获取,而所做的也只是(线程安全的)增加读状态。如果当前线程已经获取了读锁,则增加读状态。如果当前线程在获取读锁时,写锁已被其他线程获取,则进入等待状态。获取读锁的实现从Java 5到Java 6变得复杂许多,主要原因是新增了一些功能,比如:getReadHoldCount()方法,返回当前线程获取读锁的次数。读状态是所有线程获取读锁次数的总和,而每个线程各自获取读锁的次数只能选择保存在ThreadLocal中,由线程自身维护,这使获取读锁的实现变得复杂。因此,这里将获取读锁的代码做了删减,保留必要的部分,代码如下。

  1. protected final int tryAcquireShared(int unused) {
  2.    for (;;) {
  3.       int c = getState();
  4.       int nextc = c + (1 << 16);
  5.       if (nextc < c)
  6.          throw new Error("Maximum lock count exceeded");
  7.       if (exclusiveCount(c) != 0 && owner != Thread.currentThread())
  8.          return -1;
  9.       if (compareAndSetState(c, nextc))
  10.          return 1;
  11.    }
  12. }

在tryAcquireShared(int unused)方法中,如果其他线程已经获取了写锁,则当前线程获取读锁失败,进入等待状态。如果当前线程获取了写锁或者写锁未被获取,则当前线程(线程安全,依靠CAS保证)增加读状态,成功获取读锁。

读锁的每次释放均(线程安全的,可能有多个读线程同时释放读锁)减少读状态,减少的值是(1 << 16)。

final域的内存语义

  • JMM禁止编译器把final域的写重排序到构造函数之外(我的理解:也就是说非final域可能发生这种情况,导致其他线程看到未完全构造完成的对象)。
  • 第一次读取final域的对象的引用,在顺序上必须是第一次读取,不能跟随后的第二次第三次重排序。(我的理解:我怀疑第一次可能涉及初始化这个final域,但是final域明明是在构造函数之前初始化的的,那么就应该是构造函数内部,也可能多次读取final域)

具体的原因和实现书上有,我不具体研究了。

双重检查锁定和延迟初始化

上一篇讲过这个问题。不过这本书给了另外一种解决方案:

基于类初始化的解决方案

JVM在类(Class,而不是对象)的初始化阶段(即在Class被加载后,且被线程使用之前),会执行类的初始化。在执行类的初始化期间,JVM会去获取一个锁。这个锁可以同步多个线程对同一个类的初始化。

基于这个特性,可以实现另一种线程安全的延迟初始化方案(这个方案被称之为Initialization On Demand Holder idiom):

  1. public class InstanceFactory {
  2.     private static class InstanceHolder {
  3.         public static Instance instance = new Instance();
  4.     }
  5.  
  6.     public static Instance getInstance() {
  7.         return InstanceHolder.instance ; //这里将导致InstanceHolder类被初始化
  8.     }
  9. }

我的理解:其实这种方式是在初始化阶段加锁一次实现的,而使用volatile的双重检查锁定一般情况下,也只会加锁一次(因为第二次进入,if会成功,就不进入synchronize块了)。在性能上,他们两个差不多吧。

上一篇:20145240《网络对抗》Web安全基础实践


下一篇:python之if循环