可见性与有序性

可见性

  • CPU在读取数据时倾向于先从本地高速缓存中读取,然而多线程环境下某个线程对于共享变量的修改可以导致其他线程对该共享变量的不可见
  • 需要首先了解CPU读取缓存和保持缓存一致性的方式

  • CPU缓存结构

并发编程2-CAS和锁 - 图1

  • 在CPU读取数据的时候,会先去访问Cache,只有当Cache中没有数据时,CPU才会去访问内存。
  • 运行程序经过编译汇编之后会分配对应的内存地址,并没有对应的高速缓存地址,CPU可以直接根据这个内存地址去读取所需要的数据
  • CPU拿到的内存地址格式:[高位组标记][低位索引][偏移量]
  • 缓存一致性协议(EMSI)规定了缓存行数据的状态和 CPU cache 操作如何影响数据可见性

有序性

  • JVM 会在不影响正确性的前提下,可以调整语句的执行顺序。这种特性称之为『指令重排』,多线程下『指令重排』会影响正确性。
  • 指令集并行原理

并发编程2-CAS和锁 - 图2

  • 内存屏障保障可见性和有序性

可见性

  • 写屏障(sfence)保证在该屏障之前的,对共享变量的改动,都同步到主存当中
  • 读屏障(lfence)保证在该屏障之后,对共享变量的读取,加载的是主存中最新数据

有序性

  • 写屏障会确保指令重排序时,不会将写屏障之前的代码排在写屏障之后
  • 读屏障会确保指令重排序时,不会将读屏障之后的代码排在读屏障之前

volatile

  • volatile是轻量级的synchronized,在多处理器开发中保证了共享变量的“可见性” 。可见性指当一个线程修改一个共享变量时,另一个线程能够读到这个修改的值。
  • volatile 的底层实现原理是内存屏障,Memory Barrier(Memory Fence)
    1. 对 volatile 变量的写指令后会加入写屏障
    2. 对 volatile 变量的读指令前会加入读屏障

volatile保证了:

  1. 当前处理器缓存行的数据写回到系统内存中
  2. 这个写回内存的操作会使在其他CPU里的缓存数据失效
  • 写屏障

    1. 写屏障(sfence)保证在该屏障之前的,对共享变量的改动,都同步到主存当中
    2. 写屏障会确保指令重排序时,不会将写屏障之前的代码排在写屏障之后
      1. public void actor2(I_Result r) {
      2. num = 2;
      3. ready = true;
      4. // ready是被volatile修饰的 , 赋值带写屏障
      5. // 写屏障
      6. }
  • 读屏障

    1. 读屏障(lfence)保证在该屏障之后的,对共享变量的读取,加载的是主存中的最新数据
    2. 读屏障会确保指令重排序时,不会将读屏障之后的代码排在读屏障之前
      1. public void actor1(I_Result r) {
      2. // 读屏障
      3. // ready是被volatile修饰的 ,读取值带读屏障
      4. if(ready) {
      5. r.r1 = num + num;
      6. } else {
      7. r.r1 = 1;
      8. }
      9. }
  • volatile可以保障有序性和可见性,但无法保证原子性

原子性与CAS

原子操作的实现

  • 32位IA-32处理器使用基于对缓存加锁总线加锁的方式来实现多处理器之间的原子操作。
  • 首先处理器会自动保证基本的内存操作的原子性。处理器保证从系统内存中读取或者写入一个字节是原子的,意思是当一个处理器读取一个字节时,其他处理器不能访问这个字节的内存地址。
  • 但是复杂的内存操作处理器是不能自动保证其原子性的,比如跨总线宽度、跨多个缓存行和跨页表的访问。

  • Java中使用CAS实现原子操作

  • 结合 CAS 和 volatile 可以实现无锁并发,适用于线程数少、多核 CPU 的场景下。
  1. CAS 是基于乐观锁的思想:最乐观的估计,不怕别的线程来修改共享变量,就算改了也没关系,我吃亏点再重试呗。
  2. synchronized 是基于悲观锁的思想:最悲观的估计,得防着其它线程来修改共享变量,我上了锁你们都别想改,我改完了解开锁,你们才有机会。
  3. CAS 体现的是无锁并发、无阻塞并发,请仔细体会这两句话的意思
    1. 因为没有使用 synchronized,所以线程不会陷入阻塞,这是效率提升的因素之一
    2. 但如果竞争激烈(写操作多),可以想到重试必然频繁发生,反而效率会受影响
  • CAS的原理
  • JVM中的CAS操作正是利用了处理器提供的CMPXCHG指令实现的。自旋CAS实现的基本思路就是循环进行CAS操作直到成功为止

image.png

  • CAS优点
  1. 无锁情况下,即使重试失败,线程始终在高速运行,没有停歇,而 synchronized 会让线程在没有获得锁的时候,发生上下文切换,进入阻塞。
  2. 但无锁情况下,因为线程要保持运行,需要额外 CPU 的支持,CPU 在这里就好比高速跑道,没有额外的跑道,线程想高速运行也无从谈起,虽然不会进入阻塞,但由于没有分到时间片,仍然会进入可运行状态,还是会导致上下文切换。
  3. 线程数少于CPU核心数时,使用CAS更为合适
  • CAS问题
  1. ABA问题。解决:Java 1.5 开始提供了AtomicStampedReference,通过验证当前标志是否等于预期标志解决ABA问题
  2. 循环开销时间大。自旋CAS如果长时间不成功,会给CPU带来非常大的执行开销。
  3. 只能保证一个共享变量的原子操作。当对一个共享变量执行操作时,我们可以使用循环CAS的方式来保证原子操作,但是对多个共享变量操作时,循环CAS就无法保证操作的原子性,这个时候就可以用锁

Java中的锁

synchronized

  • synchronized 俗称对象锁,它采用互斥的方式让同一时刻至多只有一个线程持有对象锁,其他线程如果想获取这个锁就会阻塞住,这样就能保证拥有锁的线程可以安全的执行临界区内的代码,不用担心线程上下文切换
  • synchronized实际上利用对象保证了临界区代码的原子性,临界区内的代码在外界看来是不可分割的,不会被线程切换所打断
  • JVM中基于进入和退出Monitor对象实现方法或代码块的同步,通过monitorenter和monitorexit指令实现对Monitor的持有和释放

  • 对于同步方法,锁的是当前示例对象this

  • 对于静态同步方法,锁的是当前类的Class对象
  • 对于同步方法块,锁的是synchronized括号里的对象

synchronized原理

  • Java对象的结构

image.png

  • 其中对象头的 Mark Word 存储了锁信息

并发编程2-CAS和锁 - 图5
并发编程2-CAS和锁 - 图6

  • 每个 Java 对象都可以关联一个 Monitor,即每个 Java 对象都有一个 Monitor 与之对应。如果使用 synchronized 给对象上锁(重量级),该对象头的 Mark Word中 就被设置为指向 Monitor 对象的指针

并发编程2-CAS和锁 - 图7

锁的升级

  • Java SE 1.6 引入了偏向锁和轻量级锁,锁共有四种状态,级别从低到高为:无锁状态、偏向锁状态、轻量锁状态、重量级锁状态。
  • 锁的状态可以升级,却不能降级。
  • 但偏向锁可以撤销

  • 偏向锁

  • 因为经过HotSpot的作者大量的研究发现,大多数时候是不存在锁竞争的,常常是一个线程多次获得同一个锁,因此如果每次都要竞争锁会增大很多没有必要付出的代价,为了降低获取锁的代价,才引入的偏向锁。
  • 当线程1访问代码块并获取锁对象时,会在Java对象头和栈帧中记录偏向的锁的threadID(当前线程ID),因为偏向锁不会主动释放锁,因此以后线程1再次获取锁的时候,需要比较当前线程的threadID和Java对象头中的threadID是否一致,如果一致(还是线程1获取锁对象),则无需使用CAS来加锁、解锁;如果不一致(其他线程,如线程2要竞争锁对象,而偏向锁不会主动释放因此还是存储的线程1的threadID),那么需要查看Java对象头中记录的线程1是否存活,如果没有存活,那么锁对象被重置为无锁状态,其它线程(线程2)可以竞争将其设置为偏向锁;如果存活,那么立刻查找该线程(线程1)的栈帧信息,如果还是需要继续持有这个锁对象,那么暂停当前线程1,撤销偏向锁,升级为轻量级锁,如果线程1不再使用该锁对象,那么将锁对象状态设为无锁状态,重新偏向新的线程。

  • 轻量级锁

  • 轻量级锁考虑的是竞争锁对象的线程不多,而且线程持有锁的时间也不长的情景。因为阻塞线程需要CPU从用户态转到内核态,代价较大,如果刚刚阻塞不久这个锁就被释放了,那这个代价就有点得不偿失了,因此这个时候就干脆不阻塞这个线程,让它自旋这等待锁释放。
  • 加锁过程

1、每次指向到synchronized代码块时,都会在栈帧中创建锁记录(Lock Record)对象,每个线程都会包括一个锁记录的结构,锁记录内部可以储存对象的Mark Word和对象引用reference
2、让锁记录中的Object reference指向对象,并且尝试用 CAS 将锁记录中第一项 Lock Record 地址替换为Object对象的Mark Word ,即将 Mark Word 的值存入锁记录中
image.png
3、轻量级锁支持重入
image.png

  • 自旋优化
  • 如果在线程1复制对象头的同时(在线程1CAS之前),线程2也准备获取锁,复制了对象头到线程2的锁记录空间中,但是在线程2CAS的时候,发现线程1已经把对象头换了,线程2的CAS失败,那么线程2就尝试使用自旋锁来等待线程1释放锁。
  • 当自旋一定次数,或者出现新的线程竞争锁时,轻量级锁会膨胀为重量级锁。
  • 重量级锁
  • 为对象申请Monitor锁,让Object指向重量级锁地址,Mark Word 后两位变为10(表示重量级锁)。然后自己进入Monitor的EntryList 变成BLOCKED状态

image.png

并发编程2-CAS和锁 - 图11

AQS

  • 概述:全称是 AbstractQueuedSynchronizer,是阻塞式锁和相关的同步器工具的框架
  • 特点:
  1. 用 state 属性来表示资源的状态(分独占模式和共享模式),子类需要定义如何维护这个状态,控制如何获取锁和释放锁
    1. getState - 获取 state 状态
    2. setState - 设置 state 状态
    3. compareAndSetState - cas 机制设置 state 状态
    4. 独占模式是只有一个线程能够访问资源,而共享模式可以允许多个线程访问资源
  2. 提供了基于 FIFO 的等待队列,类似于 Monitor 的 EntryList
  3. 条件变量来实现等待、唤醒机制,支持多个条件变量,类似于 Monitor 的 WaitSet

子类主要实现这样一些方法(默认抛出 UnsupportedOperationException)

  1. tryAcquire
  2. tryRelease
  3. tryAcquireShared
  4. tryReleaseShared
  5. isHeldExclusively
  • 设计原理

AbstractQueuedSynchronizer 中 state 设计:

  • state 使用了 32bit int 来维护同步状态,独占模式 0 表示未加锁状态,大于 0 表示已经加锁状态

    1. private volatile int state;
  • state 使用 volatile 修饰配合 cas 保证其修改时的原子性

  • state 表示线程重入的次数(独占模式)或者剩余许可数(共享模式)
  • state API:
    • protected final int getState():获取 state 状态
    • protected final void setState(int newState):设置 state 状态
    • protected final boolean compareAndSetState(int expect,int update)CAS 安全设置 state

封装线程的 Node 节点中 waitstate 设计:

  • 使用 volatile 修饰配合 CAS 保证其修改时的原子性
  • 表示 Node 节点的状态,有以下几种状态:
    1. // 默认为 0
    2. volatile int waitStatus;
    3. // 由于超时或中断,此节点被取消,不会再改变状态
    4. static final int CANCELLED = 1;
    5. // 此节点后面的节点已(或即将)被阻止(通过park),【当前节点在释放或取消时必须唤醒后面的节点】
    6. static final int SIGNAL = -1;
    7. // 此节点当前在条件队列中
    8. static final int CONDITION = -2;
    9. // 将releaseShared传播到其他节点
    10. static final int PROPAGATE = -3;

阻塞恢复设计:

  • 使用 park & unpark 来实现线程的暂停和恢复,因为命令的先后顺序不影响结果
  • park & unpark 是针对线程的,而不是针对同步器的,因此控制粒度更为精细
  • park 线程可以通过 interrupt 打断

队列设计:

  • 使用了 FIFO 先入先出队列,并不支持优先级队列,同步队列是双向链表,便于出队入队

image.png

image.png

  • 可重入原理

每次加锁时不仅判断state是否为0,也要判断锁的owner是否为当前线程,如果是,则通过state++进行重入
解锁时,要判断state自减后是否为0,为0才能释放锁

  • 不可打断模式

不可打断模式:在此模式下,即使它被打断,仍会驻留在 AQS 队列中,一直要等到获得锁后方能得知自己被打断了
可打断锁:在被打断后当前线程直接抛出异常退出循环,退出AQS队列

  • 是否实现公平锁

公平锁与非公平锁的区别主要在于tryAcquired()方法的实现
公平锁在判断state==0后首先会去判断是否还存在前驱节点,如果存在,不会去竞争锁

  • 支持条件变量Condition

Condition 类 API:

  • void await():当前线程从运行状态进入等待状态,释放锁
  • void signal():唤醒一个等待在 Condition 上的线程,但是必须获得与该 Condition 相关的锁

总体流程是将 await 线程包装成 node 节点放入 ConditionObject 的阻条件塞队列,如果被唤醒就将 node 转移到 AQS 的执行阻塞队列,等待获取锁
image.png

ReentrantLock

ReentrantLock 相对于 synchronized 具备如下特点:

  1. 锁的实现:synchronized 是 JVM 实现的,而 ReentrantLock 是 JDK 实现的
  2. 性能:新版本 Java 对 synchronized 进行了很多优化,synchronized 与 ReentrantLock 大致相同
  3. 使用:ReentrantLock 需要手动解锁,synchronized 执行完代码块自动解锁
  4. 可中断:ReentrantLock 可中断,而 synchronized 不行
  5. 公平锁:公平锁是指多个线程在等待同一个锁时,必须按照申请锁的时间顺序来依次获得锁
    • ReentrantLock 可以设置公平锁,synchronized 中的锁是非公平的
    • 不公平锁的含义是阻塞队列内公平,队列外非公平
  6. 锁超时:尝试获取锁,超时获取不到直接放弃,不进入阻塞队列
    • ReentrantLock 可以设置超时时间,synchronized 会一直等待
  7. 锁绑定多个条件:一个 ReentrantLock 可以同时绑定多个 Condition 对象
  8. 两者都是可重入锁