Java并发编程 - 底层概念

@Author 何昊东
@Date 2017-02-17 18:38:09

第一章 并发编程的挑战

上下文切换

上下文切换会产生开销
如何减少上下文切换?

  • 无锁并发编程 : 多线程竞争锁时,会引起上下文切换
  • CAS算法 : CompareAndSet, 原子操作更新数据, 因而不需要加锁
  • 使用最少线程
  • 协程 : 在单线程里实现多任务调度, 并在单线程里维持多个任务间的切换

死锁

如何避免死锁?

  • 避免一个线程同时获取多个锁
  • 避免一个线程在锁内同时占用多个资源, 占用不可共享的资源等于加锁
  • 使用定时锁, lock,tryLock(timeout) 来替代内部锁机制
  • 对于数据库锁, 加锁和解锁必须在一个数据库连接里

资源限制

多线程带来的效率提升的上限受到计算机系统本身等软硬件资源的限制
集群的常见使用方法: "数据ID%机器数", 计算得到一个机器编号, 然后由对应编号的机器处理这笔数据

第二章 Java并发机制的底层实现原理

volatile的应用

volatile的定义和底层原理

volatile, 排他锁, 可以实现多线程安全地访问 共享变量
汇编层上, 会产生一个 lock 指令, lock指令的作用如下

  • 将当前处理器缓存行的数据写回到系统内存
  • 这个写回内存的操作会使其他核里缓存了该内存地址的数据无效

缓存一致性协议: 每个处理器通过嗅探在总线上传播的数据来检查自己缓存的值是不是过期了, 当处理器发现自己缓存和对应的内存地址被修改, 就会将当前处理器的缓存行置为无效, 下次读的时候, 会重新从内存中读取.

volatile的两条实现原则

  • Lock前缀指令会引起处理器缓存回写到内存
  • 一个核的缓存回写到内存会导致其它核的缓存无效

synchronized的底层实现和应用

Java中每一个对象都可以作为锁, 具体用法如下:

  • 对于普通同步方法, 锁是当前实例对象
  • 对于静态同步方法, 锁是当前类的Class对象
  • 对于同步方法块, 锁是synchronized括号里配置的对象

Java对象头

锁的信息是存在Java对象头里的, 具体信息见p13

锁的升级与对比

jdk1.6中, 锁一共有四中状态, 按级别从低到高依次是: 无锁状态, 偏向锁状态, 轻量级锁状态, 重量级锁状态. 锁可以升级但是不能降级

  1. 偏向锁
    一个事实: 大多数情况下, 锁不仅不存在多线程竞争, 而且总是由同一线程获得, 执行CAS操作(涉及循环判断)来加锁和解锁带来了大的开销
    偏向锁通过记录线程ID, 以后该线程在进入和退出同步块时不需要进行CAS操作, 只需简单地测试一下ID是否一致. 若是, 则表示已经获得了锁. 若否, 先判断当前锁是不是偏向锁, 若是, 则尝试更改偏向锁记录的ID, 若否, 则用CAS竞争锁
  2. 轻量级锁(自旋锁)
    CAS竞争锁
  3. 重量级锁(互斥锁)
    当锁处于这个状态下, 其他线程试图获取琐时, 会被阻塞住, 直到持有锁的线程释放锁后唤醒它们
优点 缺点 适用场景
偏向锁 加锁和解锁不需要额外的消耗 如果存在较多的锁竞争, 会带来额外的开销 适用于只有一个线程访问同步块的场景
轻量级锁 自旋, 竞争的线程不会阻塞 自旋会消耗CPU 追求响应时间 同步块执行的时间非常块
重量级锁 不自旋, 不会消耗CPU 阻塞, 响应时间缓慢 追求吞吐量 同步块执行的时间较长

原子操作的实现

处理器的底层实现

  • 总线锁 一个核在总线上输出 lock 信号, 独占共享内存
  • 缓存锁 只锁对应缓存

一般情况下处理器都是使用缓存锁定, 除非

  • 当操作的数据不能被缓存在处理器内部, 或者跨越多个缓存行时
  • 老版本的处理器, 不支持缓存锁

Java如何实现原子操作

循环CAS
循环CAS存在的问题:

  • ABA问题 A->B->A 不能检测到值发生了变化, CAS就不更新了. 解决办法是 追加版本号参数
  • 循环开销大
  • 只能保证一个共享变量的原子操作

第三章 Java内存模型

Java内存模型的基础

并发编程的两个关键问题:

  • 线程之间通信
    共享内存 通过线程之间共享程序的公共状态 进行隐式通信 ** Java采用的是这种 **
    消息传递 发消息来进行显式通信
  • 线程之间同步
    共享内存的情况下, 同步需要显示进行
    消息传递, 同步是隐式进行的

JMM(Java Memory Model) Java内存模型

线程之间的共享变量存储在主内存中, 每个线程都有一个私有的本地内存, 本地内存中存储了该线程以读/写共享变量的副本
如果线程A与B要进行通信的话, 必须

  1. 线程A把本地内存A中更新的共享变量刷新到主内存中去
  2. 线程B到主内存中去读取线程A已经更新过的共享变量, 并刷新自己本地内存中的值

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.

重排序

  1. 数据依赖性
    单处理器中执行的指令序列如果前后存在数据依赖性, 则不会被重排序. 但是不同处理器, 不同线程的数据依赖性不被考虑
  2. as-if-serial 语义 不管怎么重排序, 单线程程序的执行结果不能被改变.
  3. 程序顺序规则 虽然存在 happens-before 规则, 但是只要执行的结果一致, 底层可以进行重排.

顺序一致性

顺序一致性只是一个理论模型, JMM才是Java中的内存可见性的具体实现

  1. 一个线程中的所有操作必须按照程序的顺序来执行
  2. (不管程序是否同步)所有程序都只能看到一个单一的操作执行顺序, 每个操作都必须原子执行且立刻对所有线程可见 JMM没有保证这一点

JMM允许 临界区内的代码可以重排序(只要临界区内的代码不影响临界区外的代码), 不保证未同步程序的执行结果满足一致性模型的要求, 即要求程序员自己解决线程安全的问题
JMM不保证对64位的 long(Long) 和 double(Double) 型变量的写操作具有原子性, 原因是32位CPU需要读写两次总线, 如果保证原子性的话开销过大. 但是读操作是原子性的

volatile的内存语义

volatile可以实现对long和double的原子操作
如果是多个volatile操作或类似于volatile++这种复合操作, 这些操作整体上不具有原子性
当写一个volatile变量时, JMM会把改线程对应的本地内存中的共享变量值刷新到主内存
当读一个volatile变量时, JMM会把该线程对应的本地内存置为无效. 线程接下来将从主内存中读取共享变量

重要规则

volatile写之前的操作不会被重排序到volatile写之后
volatile读之后的操作不会被重排序到volatile读之前

ReetrantLock

可重入锁, 分为公平锁和非公平锁

final域的重排序规则

JMM保证, 只要对象是正确构造的(被构造对象没有从构造函数中"溢出"), 那么不需要使用同步(lock 和 volatile 的使用)就可以保证任意线程都能看到这个final域在构造函数中被初始化后的值.

TODO 2和4是不是一样了?有空验证下

  1. 在构造函数内对一个final域的写入, 与随后把这个被构造对象的引用赋值给一个引用变量, 这两个操作之间不能重排序
  2. 初次读一个包含final域的对象的引用, 与随后初次读这个final域, 这两个操作之间不能重排序
  3. JMM禁止把final域的写重排序到构造函数之外, 但是普通变量不具有这个限制
  4. 初次读对象引用与初次读该对象包含的final域, JMM禁止处理器重排序这两个操作

在构造函数内部, 不能让这个被构造对象的引用为其他线程所见, 也即对象引用不能在构造函数中"溢出", 例子详见 p61

再谈happens-before 和 as-if-serial

as-if-serial语义给编写单线程程序的程序员创造了一个幻境: 单线程的程序是按顺序来执行的. happens-before 关系给编程正确同步的多线程程序的程序员一个幻境: 正确同步的多线程程序是按happens-before 指定的顺序来执行的. 实际上, 对程序员提供了强内存模型. 但是对编译器和处理器, 只要是不改变程序运行结果, 可以为了提高效率进行重排序, 提供的是弱内存模型.

一行语句对应多行汇编时, 可能汇编语句被重排序了, 这就是双重检查锁定的问题根源. 例子见p69

发生下列任一一种情况时, 一个类或者接口类型T将被立即初始化

对于每一个类或接口, 都有一个唯一的初始化锁与之对应

  1. T是一个类, 而且一个T类型的实例被创建
  2. T是一个类, 且T中声明的一个静态方法被调用
  3. T中声明的一个静态字段被赋值
  4. T中声明的一个静态字段被使用, 而且这个字段不是一个常量字段
  5. T是一个顶级类(在其它类的外面声明一个类, 待详查), 而且一个断言语句嵌套在T内部被执行.

在大多数时候, 正常的初始化要优于延迟初始化. 如果确实需要对实例字段使用线程安全的延迟初始化, 使用基于volatile的延迟初始化的方案. 如果确实需要对静态字段使用线程安全的延迟初始化, 使用基于类初始化的方案. 例子代码见p72

总结

JMM的内存可见性保证

  • 单线程程序: 单线程程序不会出现内存可见性问题. 编译器, runtime 和 处理器会共同确保单线程程序的执行结果与该程序的在顺序一致性模型中的执行结果相同
  • 正确同步的多线程程序: 正确同步的多线程程序的执行将具有顺序一致性, 通过限制编译器和处理器的重排序来为程序员提供内存可见性的保证
  • 未同步/未正确同步的多线程程序: JMM为它们提供了最小安全性保障: 线程执行时读取到的值, 要么是之前某个线程写入的值, 要么是默认值(0, Null, false), 不会是无中生有冒出来的值, 但是并不一定是正确的

对于64位精度long/double, 至少是写了32位的值, 反正是某个线程写过后的值.

参考资料:
对象监视器锁 轻量级锁 偏向锁 详解

你可能感兴趣的:(Java并发编程 - 底层概念)