Java 并发编程(2): Java 中的同步原语

1 volatile

volatile 实现了轻量级的线程间通信机制.

1.1 volatile 的特性

  • 对volatile 变量的单个读/写, 等价于使用同一个锁对这些单个读/写操作做了同步.
    • 同时, 它不会引起线程上下文的切换和调度, 从而比使用synchronized 的成本低的多.
  • 可见性 && 原子性.
    • 锁的happens-before 规则保证了释放锁和获取锁的两个线程间的内存可见性.
      • 对一个volatile 变量的读, 总是能看到(任意线程) 对这个volatile 变量最后的写入.
    • 同时, 锁的语义保证了临界区代码的执行具有原子性.

1.2 volatile 读/写的内存语义

  • 从内存语义看, volatile 读写等同于锁的释放和获取:
    • volatile 写等同于锁的释放; volatile 读等同于锁的获取.
  • volatile 写: JMM 会把该线程对应的本地内存中的共享变量值刷新到主内存中.
  • volatile 读: JMM 把该线程对应的本地内存置为无效, 然后从主内存中读取共享变量.
  • A 线程写一个volatile 变量, B 线程随后读取volatile 变量. 实质上是线程A 通过主内存向线程B 发送消息.
1.3 volatile 内存语义的实现
  • 原则:
    • 当第二个操作是volatile 写时, 无论第一个操作是什么, 都不能进行重排序.
    • 当第一个操作是volatile 读时, 无论第二个操作是什么, 都不能进行重排序.
    • 当第一个操作是volatile 写, 第二个操作是volatile 读时, 不能进行重排序.
  • JMM 采取保守策略, 插入内存屏障来实现volatile 语义.
    • volatile 写后面都会插入StoreLoad 屏障, 来避免volatile 写后面可能的volatile 读/写的重排序.
      • 所以volatile 写比volatile 读的开销大的多.

3.1.4 volatile 汇编指令的实现

  • volatile 变量进行写操作时, 会在指令前加上Lock, 并遵守以下两条原则:
    • 原则1: 将当前CPU 缓存行的数据回写到系统内存.
    • 原则2: 使其它CPU 里缓存了该内存地址的数据无效.
  • 实现原则1: 锁总线/缓存.
    • 总线锁定: 在总线上放入Lock# 信号以独占内存. 开销过大.
    • 缓存锁定: 锁定本地内存区域的缓存并回写到内存, 并使用缓存一致性机制来保证修改的原子性.
    • 缓存一致性会阻止同时修改两个以上CPU 缓存的内存区域数据.
  • 实现原则2: MESI(修改, 独占, 共享, 无效).
    • 每个CPU 通过嗅探在总线上传播的数据来检查自己缓存的值是否过期,若发现已过期, 将其置为无效.

3.1.4 增强volatile 内存语义的原因

  • 旧的内存模型中, 允许volatile 变量与普通变量之间的重排序.
    • 从而使得volatile 的读写不具有锁的释放获取锁具有的内存语义.
  • 为了提供比锁更轻量级的线程间通信机制, 严格限制了volatile 变量与普通变量的重排序.
    • 从而保证了volatile 等同于锁的内存语义.

3.2 synchronized 锁

锁让临界区互斥执行, 同时可以让释放锁的线程向获取同一锁的线程发送消息.

3.2.1 锁的释放和获取的内存语义

  • happens-before 中的监视器的锁规则, 保证了线程间的可见性.
  • 完全等同于volatile 的内存语义.
    • 当线程释放锁时, JMM 会把该线程对应的本地内存中的共享变量刷新到主内存中.
    • 当线程获取锁时, JMM 会把该线程对应的本地内存置为无效, 从而使得监视器保护的临界区代码必须从主内存中读取共享变量.

3.2.2 锁的实现原理

  • 基础: 每个对象(实例, Class对象)都可以作为锁.
  • 实现: 基于进入和退出Monitor 对象.
    • 任何一个对象都有一个monitor 与之关联. 当一个Monitor 被持有后, 处于锁定状态.
    • 编译后, 在同步代码块的开始位置, 插入monitorenter 指令, 在同步块的结束和异常处, 插入monitorexit 指令.
    • 线程执行到monitorenter 指令时, 会尝试获取对象对应的monitor 的所有权(即锁).
  • 锁存储在Java 对象头里.

3.2.3 JDK 1.6 中的锁

  • 为了减少锁的性能消耗. 引入了新的锁类型.
    • 级别从低到高: 无锁状态 -> 偏向锁状态 -> 轻量级锁状态 -> 重量级锁状态.
    • 状态会随着竞争情况逐渐升级, 但不能降级.
  • 偏向锁
    • 基础: 多数情况下, 锁总是由同一线程多次获得, 而不存在多线程竞争.
    • 减少锁获取的代价: 在对象头和栈帧中的锁记录中存储偏向锁的线程ID.
      • 之后进入和退出同步块时, 只需测试存储的偏向锁, 如果匹配, 直接获取锁. 否则使用CAS 竞争锁.
    • 直到竞争出现才释放锁的机制.
      • 需要等待全局安全点才能撤销偏向锁.
    • 如果应用程序里所有的锁通常情况下处于竞争状态, 则通过JVM 参数关闭默认打开的偏向锁, 从而默认进入轻量级锁状态.
  • 轻量级锁
    • 线程在执行同步块之前, JVM 会先在当前线程的栈帧中创建用于存储锁记录的空间.
    • 然后, 线程尝试使用CAS 将对象头中的Mark Word 替换为指向锁记录的指针, 如果成功,当前线程获取锁, 否则尝试使用自旋来获取锁.
    • 锁处于该状态下时, 其它试图获取锁的线程会被阻塞住, 知道持有锁的线程释放后会唤醒这些线程进行锁竞争.
优点 缺点 使用场景
偏向锁 加解锁无消耗 若存在线程间的锁竞争,会带来额外的锁撤销消耗 只有一个线程访问同步块
轻量级锁 竞争的线程不会阻塞 得不到锁竞争的线程,会使用自旋来消耗CPU 追求响应速度, 同步块的执行速度快
重量级锁 线程竞争不会自旋 线程阻塞, 响应时间慢 追求吞吐量,同步块执行速度慢

3.3 final

3.3.1 final 与的内存语义

  • 对于final 域, 编译器和CPU 要遵守两个重排序规则:
    • 构造函数内对一个final 域的写入, 与随后把该被构造对象的引用赋值给一个引用变量, 这两个操作不能重排序.
    • 初次读一个包含final 域的对象的引用, 与随后初次读这个final 域. 这两个操作不能重排序.

3.3.2 写final 域的重排序规则

  • 禁止把final 域的写重排序到构造函数之外.
    • 在final 域的写之后, 构造函数return 之前, 插入StoreStore 屏障.
  • 确保: 在对象引用为任意线程可见之前, 对象的final 域已经被证券的初始化过了.
    • 普通变量不具备这个保障.

3.3.3 读final 域的重排序规则

  • 在读final 域操作的前面,插入LoadLoad 屏障.
  • 确保: 在读一个对象的final 域之前, 一定会先读包含这个final 与的对象的引用(null 的判定).

3.3.4 final 域为引用类型

  • 约束: 在构造函数内对一个final 引用的对象的成员域的写入, 与随后在构造函数外把这个被构造对象的引用赋值给一个引用变量, 这两个操作之间不能重排序.
  • 写final 引用域的线程, 和读final 引用域的线程之间, 需要使用同步原语(lock/volatile)来确保可见性.

3.3.5 final 引用的'溢出'

  • 如果在构造函数内, 将this 赋值给全局引用, 其它线程可以通过该全局引用, 访问到未被初始化过的final 域.

3.3.6 X86 CPU 中的final 实现

  • 由于不会对写-写作重排序, 省去了写final 域所需的StoreStore 屏障.
  • 不会对存在间接依赖关系的操作重排序, 读final 域的LoadLoad 屏障也被省去.
  • X86 CPU 不需要对final 域的读写插入任何的内存屏障.

3.3.6 增强final 语义的原因

  • 旧的实现, 可能会读取到未初始化过的final 域.
  • 新实现确保: 只要对象是正确构造的(未'溢出'), 则不需要使用同步, 任意线程都可以看到final 域在构造函数中初始化过的值.

3.4 原子操作

在Java 中通过锁和循环CAS 的方式实现原子操作.

3.2.1 CPU 如何实现原子操作

  • 基于缓存加锁或者总线加锁的方式来实现多CPU 间的原子操作.
    • CPU 会自动保证基本的内存操作的原子性.
      • CPU 读写系统内存中的一个字节是原子的, 其间其它CPU 不能访问该字节的内存地址.
  • 使用总线锁
    • 冲突: 多CPU 可能同时从各种的缓存中读取同一共享变量,然后进行写操作, 最后写入系统内存中.
    • 使用CPU 提供的LOCK # 信号.来独占共享内存.
  • 使用缓存锁
    • 总线锁的开销过大, 其间其它CPU 不能操作任何的内存地址的数据.
    • 频繁使用的内存会缓存在CPU 的L1,L2,L3 高速缓存中, 原子操作可以直接在CPU 内部缓存中进行.
    • 缓存锁定: 如果内存区域被缓存在CPU 的缓存行中, 当它执行锁操作并回写到内存时, CPU 修改内部的内存地址, 并使用缓存一致性机制来保证操作的原子性.
      • 缓存一致性: 阻止同时修改由两个以上CPU 缓存的内存区域数据. 当其它CPU 回写已被锁定的缓存行数据时, 会使缓存行无效.

3.2.2 CAS: compareAndSwap()

  • 语义: 如果当前状态值等于预期值, 则以原子方式将同步状态设置为给定的更新值.
    • 此操作具有volatile 读和写的内存语义.
  • 实现concurrent 包:
    • 首先, 声明共享变量为volatile.
    • 然后, 使用CAS 的原子条件更新来实现线程间的同步.
    • 同时, 以CAS/volatile 的内存语义实现线程间的通信.

3.2.3 CAS 的三大问题

  • ABA 问题: 检查值时, 针对A->B->A的变化,可能会误判为没有变化.
    • 使用版本号解决: 1A->2B->3A. Atomic 包中的AtomicStampedReference.
  • 循环时间长开销大.
  • 只能保证一个共享变量的原子操作.
    • 多个共享变量操作时, 只能用锁.
    • 可以将多个共享变量合并为一个共享变量.

3.5 双重检查锁定与延迟初始化

3.5.1 双重检查锁的由来

  • 场景: 使用延迟初始化来推迟一些高开销的对象初始化操作.
  • 线程安全: 使用synchronized 来对getInstance() 进行加锁.
    • 当该方法会被多个线程频繁调用时, 会导致程序的执行性能严重下降.
  • Double_checked Locking.
    if ( instance == null){    // 第一次检查
        synchronized (DoubleCheckedLocing.clss){ // 加锁
            if( instance == null) // 第二次检查
                instance = new Instance(); // 对象的创建.
        }
        return instance;
    }

3.5.2 问题的根源

  • 在第一次检查时, 代码读取到instance 不为null 时, instance 引用的对象可能还未完成初始化.
  • 对象创建的三个步骤:
    1. memory = allocate(); // 分配对象的内存空间.
    • ctorInstance(memory); // 初始化对象.
    • instance = memory; // 设置instance 指向刚分配的内存地址.
  • 其中, 步骤2 和步骤3 可能会被重排序.(由于该重排序并不会改变单线程中程序执行的结果).

3.5.3 基于volatile 的解决方案

  • 将instance 声明为volatile 型.
    • volatile 的内存语义会禁止步骤2和3之间的重排序.

3.5.4 基于类初始化的解决方案

  • 在执行类的初始化期间, JVM 会去获取一个锁. 该锁可以同步多个线程对同一个类的初始化.
  • 实质: 允许步骤2和3的重排序, 但禁止非构造线程'看到'该重排序.
  • 优势是简洁. 但基于volatile 的方案, 除了静态字段, 还可以实现延迟初始化实例字段.

3.5.5 实践

  • 字段延迟初始化降低了初始化类或创建实例的开销, 但增加了访问被延迟初始化的字段的开销.
  • 多数情况下, 正常初始化要优于延迟初始化.
  • 如需要对实例字段使用线程安全的延迟初始化, 使用volatile 方案.
  • 如需要对静态字段使用线程安全的延迟初始化, 使用类初始化的方案.

你可能感兴趣的:(Java 并发编程(2): Java 中的同步原语)