2. Java并发机制的底层实现原理

1. volatile的应用

volatile是轻量级synchronized, 保证了共享变量的可见性, 可见性的意思当一个线程修改一个共享变量时, 其他线程能读取到这个修改的值, volatile变量的使用比synchronized的成本更低, volatile关键字不会引起线程上下文切换和调度

1.1 volatile的定义与实现原理
术语 英文单词 术语描述
内存屏障 memory barries 一组CPU指令,用于实现对内存操作的顺序限制
CPU缓存 cache 缓存了内存地址,以及对应的数据
缓冲行 cache line CPU缓存可以分配的最小存储单位,CPU修改一个缓存会影响整个缓存行
原子操作 atomic operations 不可中断的一个或一系列操作
缓存行填充 cache line fill CPU从内存中读取的数据是可缓存的,CPU将数据缓存到L1,L2,L3的缓存行中
缓存命中 cache hit CPU直接从缓存中读取数据
写命中 write hit CPU将操作数再次写回缓存行
写缺失 write misses the cache CPU写入的数据,不在CPU缓存中
volatile如何保证内存可见

为了提高处理速度, CPU不直接与内存通信, 而是将内存的数据读到CPU缓存中再进行操作, 但操作完成之后, 不知道何时写入到内存

修改了volatile关键字修饰共享变量之后

  1. JVM会下发一个Lock指令到CPU, 将CPU缓存写回到内存
    • LOCK信号一般不锁总线, 而是缓存锁, 总线锁开销比较大
    • 锁定内存区域的缓存, 并将其写回到内存, 使用了缓存一致性机制来确保修改的原子性
    • 缓存一致性会阻止同时修改两个以上CPU缓存的内存区域数据
  2. 一个CPU的缓存写回到内存之后, 会导致其他处理器对于volatile共享变量的缓存无效
    • 使用MESI控制协议维护内部缓存和其他CPU缓存一致性
    • MESI: 每个缓存行使用4种状态进行标记
      • M: 被修改(Modified), 与内存不一致, 缓存就需要在之后的某个时间点, 将数据写回到内存
      • E: 独享(Exclusive), 缓存行被一个CPU缓存, 并且数据和内存一致, 之后如果被其他CPU访问, 那么就会变成共享的
      • S: 共享(Shared), 缓存行被多个CPU共享, 当有一个CPU修改了缓存行, 缓存行就会被作废, 变成无效状态
      • I: 无效(Invalid), 缓存无效, 可能是其他CPU修改了缓存行
1.2 volatile的使用优化

追加字节优化性能, JDK1.8使用@Contended注解填充缓存行
- CPU使用缓存行有固定长度的, 例如64位, 256位
- 如果缓存对象没有达到最小单位, 会将多个缓存对象缓存到一个缓存行
- 如果缓存对象不会被频繁修改, 那么没有必要直接字节

2. synchronized的实现原理与应用

JDK1.6对synchronized关键字进行了各种优化, 为了减少获取和释放锁带来的性能消耗, 引入了偏向锁和轻量级锁, 以及锁的存储结构和升级过程

Java中的每一个对象都可以作为锁

  • 普通同步方法, 锁是当前实例对象(this)
  • 静态同步方法, 锁是当前类的Class对象
  • 同步方法块, 锁是synchronized括号里设置的对象
2.1 java对象头

synchronized用的对象锁是放在Java对象头里面的

  • 如果对象是数组类型, 那么虚拟机使用3字宽(Word)存储对象头
  • 如果对象是非数组类型, 那么使用2字宽存储对象头
  • 32位虚拟机中, 1字宽等于4字节(Byte), 即32bit
  • 64位虚拟机中, 1字宽等于8字节(Byte), 即64bit
长度 内容 说明
32/64bit Mark Word 存储对象的hashcode或者锁信息
32/64bit Class Metadata Address 存储对象类型的指针
32/64bit Array length 如果当前对象是数组, 那么存储数组长度
Mark Word与锁之间的对应关系

Java1.6为了减少获得锁和释放锁带来的消耗, 引入偏向锁和轻量级锁, 锁一共有4种状态, 级别从低到高为无锁状态, 偏向锁状态, 轻量级锁状态和重量级锁状态

锁可以升级但不能降级, 也就是偏向锁升级为轻量级锁之后, 不能降级为偏向锁

2.2 锁升级与对比
2.2.1 偏向锁

大多数情况下, 锁不存在多线程竞争, 并且总是由同一个线程多次获取, 为了让线程获得锁获得锁的代价更小, 引入了偏向锁

偏向锁是最乐观的一种情况: 只有一个线程请求同一把锁

(1).偏向锁的获取

当一个线程访问同步块并获取锁的时候, 会在对象头和栈帧中的锁记录里存储锁偏向的线程ID, 之后线程访问同步代码块的时候, 不需要进行使用CAS自旋锁, 只要验证一下对象头的Mark Word里是否存放指向当前线程的偏向锁

  • 如果存储了指向当前线程的偏向锁, 表示线程已经获取锁, 可以直接访问同步代码块

  • 如果没有存储指向当前线程的偏向锁, 则判断Mark Word中偏向锁标识是否是1, 表示当前是偏向锁

    • 如果是1的话, 尝试使用CAS设置Mark Word偏向锁线程ID, 指向当前线程
    • 如果不是1的话, 则尝试使用CAS竞争锁

(2).偏向锁的撤销

偏向锁要等到竞争出现才会释放锁, 当其他线程尝试竞争偏向锁时, 持有偏向锁的线程才会在全局安全点(锁安全点,没有正在执行的字节码)释放锁

  • 先暂停拥有偏向锁的线程
  • 检查持有偏向锁的线程是否活着
    • 不处于活动状态的话, 则将对象头设置为无锁状态
    • 线程还活着的话, 遍历偏向对象的锁记录, 栈中锁记录和对象头的Mark Word可能执行三种操作
      • 偏向于其他线程
      • 恢复到无锁状态
      • 线程仍然在执行同步代码块, 会升级为轻量级锁
  • 最后唤醒暂停的线程
轻量级锁

通过自旋CAS来竞争锁, 竞争的线程不会阻塞, 如果一直竞争不到锁, 会消耗CPU

(1).加锁

线程在执行同步代码块之前, JVM会在当前线程的栈帧中创建用于存储锁记录的空间, 并将对象头的Mark Word复制到锁记录中, 官方称Displaced Mark Word, 然后线程使用CAS将对象头的Mark Word替换为指向锁记录的指针

  • 如果成功, 将Mark Word的锁标记位设置为00,表示锁对象处于轻量级锁状态, 线程获取到锁
  • 如果失败, 表示其他线程竞争锁, 当前线程尝试使用CAS自旋获取锁, 自旋达到一定次数还没有获取到锁, 会升级为重量级锁

(2).解锁
使用CAS操作将Displaced Mark Word替换回对象头

  • 如何设置成功, 表示没有发生竞争
  • 如果设置失败, 表示当前所锁存在竞争, 就会膨胀为重量级锁
重量级锁

使用操作系统的互斥锁(Mutex Lock)实现, 当进入重量级锁之后, 就不会再降级为轻量级锁, 当其他线程占用锁之后, 当前线程会进入堵塞状态

每个对象都拥有自己的监视器, 线程必须要先获取到对象监视器才能进入同步块或者同步方法, 没有获取到监视器的线程将会被堵塞在同步代码的入口处, 进入blocked状态

对象监视器依赖操作系统底层的mutex lock(互斥锁)实现

3. 原子操作的实现原理

原子操作是不可被中断的一个或者一系列操作

3.1 术语定义
术语名称 解释
缓存行 Cache line 缓存的最小操作单位
比较并交换 Compare and Swap CAS操作需要输入两个值, 一个旧值和一个新值, 操作之前比较旧值有没有发生变化, 如果没有发生变化, 才会交换成新值
CPU流水线 CPU pipeline 在CPU中, 由5到6个不同功能的电路单元组成一条指令处理流水线, 然后将一条X86指令分成5到6步后再由这些单元分别执行, 这样就能实现在一个CPU时钟周期完成一条指令, 提高CPU运算速度
内存顺序冲突 Memory order violation 内存顺序冲突一般是由假共享引起的, 假共享是指多个CPU同时修改同一个缓存行的不同部分, 导致其中一个CPU操作无效, 当出现内存顺序冲突时, CPU必须清空流水线
3.2 处理器如何实现原子操作

如果过个处理器同时对共享变量进行改写操作, 就会导致共享变量的值和期望的值不一致, 因为多个处理器同时从各自的缓存中读取变量, 分别操作, 然后分别写入系统内存中

处理器提供了总线锁定和缓存锁定两种机制来保证复杂的内存操作的原子性

3.2.1 总线锁

CPU提供一个LOCK#信号, 当一个处理器在总线上输出此信号时, 其他处理器的请求将会被阻塞, 该处理器可以独占共享内存

总线锁把CPU和内存的通信锁定住了, 锁定期间, 其他处理器不能操作内存的数据, 所以总线锁开销比较大

3.2.2 缓存锁

只需要保证某个内存地址的操作是原子性即可, 频繁使用的内存会缓存在CPU高速缓存中, 那么原子操作就可以直接在CPU内部缓存中进行, 使用缓存一致性来保证操作的原子性, 一般使用MESI控制协议, 保证缓存的一致性

有两种情况CPU不使用缓存锁定, 而使用总线锁定:

  1. 操作的数据不能被缓存在CPU内部或者操作的数据跨多个缓存行
  2. 有些CPU不支持缓存锁定
3.3 java如何实现原子操作

java通过锁和CAS方式来实现原子操作

3.3.1 CAS(Compare and Swap)

对一个内存地址进行操作, CAS操作需要输入两个值, 一个旧值和一个新值, 操作之前比较旧值有没有发生变化, 如果没有发生变化, 才会交换成新值

Java中的Atomic原子类, AQS的底层实现都使用了CAS实现

CAS实现原子操作的三大问题:

  • ABA问题
    • CAS需要在操作值的时候, 检查值有没有发生变化, 如果没有发生变化才更新, 但如果一个值原来是A, 变成了B, 又变成了A, 那么使用CAS进行检查时会发现它的值没有发生变化
    • 解决思路是使用版本号, 在对象前面追加版本号, 每次版本号加1, JDK中使用AtomicStampedReference类解决ABA问题
    public class AtomicStampedReference {
        
        /**
         * 旧值和旧版本号都相同, 才会把对象更新为新值和新版本号
         *
         * @param expectedReference 旧值
         * @param newReference 新值
         * @param expectedStamp 旧版本号
         * @param newStamp 新版本号
         * @return {@code true} 如果成功返回true
         */
        public boolean compareAndSet(V   expectedReference,
                                 V   newReference,
                                 int expectedStamp,
                                 int newStamp) {
            Pair current = pair;
            return
                expectedReference == current.reference &&
                expectedStamp == current.stamp &&
                ((newReference == current.reference &&
                  newStamp == current.stamp) ||
                 casPair(current, Pair.of(newReference, newStamp)));
        }
    
    }
    
  • 循环时间长开销大, 自旋CAS如果长时间不成功, 那么会给CPU带来非常大的开销
  • 只能保证一个共享变量的原子操作
    • JDK提供了AtomicReference类保证引用对象之间的原子性, 可以把多个变量放在一个对象里进行CAS操作
3.3.2 锁

锁机制保证了只有获得锁的线程才能操作锁定的内存区域, JVM内存实现了多种锁机制, 有偏向锁, 轻量级锁和互斥锁(重量解锁)

除了偏向锁, JVM实现锁的方式都使用了CAS,

  • 当有一个线程想进入同步块的时候使用CAS的方式获取锁
  • 当线程想退出同步块的时候使用CAS方式释放锁

你可能感兴趣的:(2. Java并发机制的底层实现原理)