在不改变程序执行结果的前提下,尽可能提高并行度
并发编程的挑战
3 个挑战:上下文切换带来的性能损耗、死锁问题、资源限制带来的挑战
上下文切换
什么是上下文:上下文就是线程运行的一个状态。上下文切换就是处理器在切换线程的时候不断的写入读取线程的状态。
- 第一节主要论证了一件事情:就是多线程并不一定就比单线程性能更好。原因是多线程会带来更大的上下文切换的开销。
- 怎么减少上下文切换:
- 无锁并发编程:将 task 根据某种规则直接分配给对应的线程
- CAS 算法:通过 CAS 算法来避免加锁。但我一直认为 CAS 算法本身就是一种乐观锁
- 使用最少线程:上下文切换之所以会成为问题,是因为没有 task 的线程引起的上下文切换是无效的。那我们就应该尽量避免穿件多余的线程
- 协程:在单线程里面实现多任务的调度。问题:协程之于线程,类似于线程之于进程。那为什么协程不会引起上下文切换的问题?
死锁
- 使用 jstack 发现死锁
- 锁的释放要放在 finally 语句块中,或者干脆使用 synchronized
- 避免死锁:
- 避免一个线程同时获取多个锁
- 避免一个线程在锁内占用多个资源,尽量保证每个锁只占用一个资源
- 尝试使用定时锁,尝试使用
lock.tryLock(timeout)
替代内部锁机制 - 对于数据库锁,加锁和解锁必须在一个数据库连接里,否则会出现解锁失败的情况
资源限制的挑战
大意:资源的限制使得并行程序不能完全发挥实力,甚至退化为性能更差的串行执行。需要根据资源瓶颈对症下药。
Java 并发机制的底层实现
3 个主要机制的底层实现:volatile、synchronized 和原子操作
volatile
- volatile 修饰的共享变量会在写操作时,在汇编代码前面多出一个 lock 前缀
- 将 cpu 缓存行的数据写回系统内存
- 使其他 cpu 缓存的该数据失效
- volatile 的优化,了解一下就好。主要操作是填充缓冲行
synchronized 的原理与应用
synchronized 可以用于给普通方法(锁实例),静态方法(锁 Class 对象)和方法块(锁括号里的对象)上锁。基于 Monitor 对象的操作实现锁,书中有些地方也称之为监视器锁。
- Java 对象头:Java 对象头里面存有锁相关的信息。主要关注一个指针和锁标识位
- 锁的升级:synchronized 的锁最开始都是非常重量级的锁。为了改善性能,引入了偏向锁和轻量级锁。所以锁就有了四种状态,从低到高分别是:无锁,偏向锁,轻量级锁,重量级锁
偏向锁
偏向锁的出现源于一个研究结果:大多数情况下,锁不仅不存在竞争,且总是由同一个线程多次获得
- 通过替换 Java 对象头里面的线程 ID 来获得偏向锁
- 除非出现竞争,否则不会释放偏向锁。偏向锁释放后,可能恢复到无锁状态,也可能偏向另一个线程,或者膨胀为轻量级锁。评判标准书中没有提及
- 具体的获得和释放流程可以参看书中的图片
轻量级锁
- 加锁
- 先在栈空间中创建用于存储锁记录的空间
- 将 Java 对象头的 Mark Word 复制到锁记录中
- 尝试将 Java 对象头的 Mark Word 指向锁记录的指针
- 第3步失败,则自旋重试
- 解锁
- 将锁记录中的 Mark Word 复制回 Java 对象头
- 第1步失败,锁膨胀
- 具体的获得和释放流程可以参看书中的图片
重量级锁
书中没有提到重量级锁的加锁和解锁。个人理解是可以参看轻量级锁的。唯一不通是重量级锁尝试获取锁失败后不会自旋重试,而是阻塞等待唤醒。
优缺点对比
锁 | 优点 | 缺点 | 适用场景 |
---|---|---|---|
偏向锁 | 加锁解锁不需要额外的消耗,和执行非同步方法相比仅存在纳秒极的差距 | 如果线程间存在锁竞争,会带来额外的锁撤销的消耗 | 适用于只有一个线程访问同步块场景 |
轻量级锁 | 竞争的线程不会阻塞,提高了程序的相应速度 | 如果始终得不到锁竞争的线程,使用自旋会消耗 cpu | 追求响应时间,同步块执行速度非常快 |
重量级锁 | 线程竞争不使用自旋,不会消耗 cpu | 线程阻塞,相应时间缓慢 | 追求吞吐量,同步块执行时间较长 |
个人认为这一块看一看,了解一下就好了。尤其适用场景,底层用什么锁完全是 JVM 决定的,我们程序员没法干涉
原子操作的实现原理
这一章降了两个层次上面原子操作的实现原理:处理器和 Java 语言
处理器实现原子操作
- 通过总线锁保证原子性。处理器向总线发出 LOCK # 信号,阻塞其他处理器的请求,从而独占共享内存
- 通过缓存锁定来保证原子性。简而言之:处理器具有缓存一致性机制会阻止同时修改由两个以上处理器缓存的内存区域数据
Java 如何实现原子操作
通过锁和循环 CAS 的方式来实现原子操作
- CAS 实现原子操作的示例代码
- CAS 原子操作的问题
- ABA 问题。如果数据被修改后的值和记录的旧值一样,CAS 发现不了。解决方案:加上版本号。我的问题:数据被修改过,但和旧值一样,。处理不处理有什么关系吗?直接用不就好了?
- CAS 自旋引起的性能问题。这个在轻量级锁里面就有提及过
- 只能保证一个共享变量的原子操作。解决方案:多个变量组合成为一个
- 使用锁机制实现原子操作。比较好理解,可以类比数据库事务
Java 内存模型
这一章简单介绍了 Java 的内存模型,重排序和先行发生原则。以及 volatile, lock, final 的重排序和先行发生的规则
Java 内存模型的基础
- 线程间通信的方式:共享内存和消息传递。Java 是采用了共享内存的方式。可能在上层的代码能看到一些“消息传递”,但事实上的底层实现仍然是共享内存。
- Java 内存的抽象结构,主要分为三个部分:主内存(线程共享),本地内存(线程独有),线程(这个我理解其实就是 CPU 了)。具体可以参考书上的图理解
- 为了提高性能,指令会被重排序,主要分为三种重排序:编译器、指令并行重排、内存系统重排
- 第4小节主要介绍了不同处理器的重排序规则和内存屏障的类型。个人认为这一块太晦涩了,不是很感冒
- 先行发生原则的简介:
- 首先,要明确一点:现行发生原则对程序员来说它是保证了可见性,并不保证他一定就是顺序执行的
- 程序顺序规则:一个线程中的每个操作,先行发生于该线程中的任意后续操作
- 监视器锁规则:对一个锁的解锁,先行发生于随后对这个锁的加锁。其实这就说明了监视器所是不可重入的
- volatile 变量规则:对一个 volatile 域的写,先行发生于任意后续对这个 volatile 域的读
- 传递性:A 先行发生于 B,B 先行发生于 C,则 A 先行发生于 C
重排序
数据依赖性
- 什么是数据依赖性:访问同一个变量的两个操作中,存在一个写操作,那就称这两个操作存在数据依赖性
- 为什么要提到数据依赖性?数据依赖性与重排序有什么关系?只要存在数据写,那么就不能进行重排序
as-if-serial
直译就是看上去像线性执行。在单线程的情况下重排序会让程序运行看上去像是线性执行的,但是实际上还是有指令重排来改善性能。实现这一点的主要依据就是数据依赖性
程序顺序规则
一言以蔽之,程序员可以根据先行发生原则去判断多线程下的可见性。同时 JMM 还是允许底层的实现使用重排序提高性能。
在不改变程序执行结果的前提下,尽可能提高并行度。
重排序对多线程的影响
重排序是会对多线程产生影响的,具体可以参考书上的示例代码进行分析。
顺序一致性
数据竞争
- 什么是数据竞争:在一个线程中写一个变量,在另一个线程中读这个变量,而且这两个操作没有通过同步排序
- 对于正确进行了同步的程序,是没有数据竞争的
顺序一致性模型
这是一个理论模型,是处理器和编程语言在设计内存模型的一个参考
顺序一致性模型的两大特性:
- 一个线程中的所有操作必须按照程序的顺序来执行
- (不管程序是否同步)所有线程都智能看到一个单一的操作执行顺序。在顺序一致性内存模型中,每个操作都必须院子执行且立刻对所有线程可见
这两个特性就把并发中的三个问题:原子性,有序性,可见性全都给带上了,不愧是理论上的模型
可以参看书上的示例来理解同步状态和非同步状态下面的顺序一致性模型的执行效果
同步程序的顺序一致性效果
参看书上示例理解
未同步程序的执行特性
一句话:特性就是无序,结果将变得不可预测
volatile 的内存语义
volatile 的特性
- 可见性:一个线程对 volatile 变量的写将立即对另一个线程可见
- 原子性:对 volaile 变量的单个读/写操作具有原子性
- 有序性:volatile 事实上是个内存屏障,可以阻止重排序。具有一定的有序性
volatile 的先行发生原则
前面有提到过,volatile 变量的写先于 volatile 变量的读
volatile 的内存语义
- 当写一个 volatile 变量时,JMM 会把该线程对应的本地内存中的共享变量值刷新到主内存
- 当读一个 volatile 变量时,JMM 会把该线程对应的本地内存置为无效。线程接下来将从内存中读取共享变量
volatile 内存语义的实现
这一节主要讲述 volatile 底层是怎么利用内存屏障来实现内存语义的。 其中有一个值得注意的地方:就是 volatile 会组织重排序
是否能重排序 | 第二个操作 | ||
---|---|---|---|
第一个操作 | 普通读/写 | volatile 读 | volatile 写 |
普通读/写 | NO | ||
volatile 读 | NO | NO | NO |
volatile 写 | NO | NO |
总结: any -> volatile write, volatile read -> any, volatile write -> volatile read
- 第二个操作是 volatile 写时,不能重排序
- 第一个操作是 volatile 读时,不能重排序
- 第一个操作是 volatile 写,第二操作是 volatile 读时,不能重排序
锁的内存语义
需要注意的是,这一节的锁混了 JVM 的监视器锁和 JDK 的锁
锁的现行发生原则
锁的释放现行发生于锁的获取
锁的释放和获取的内存语义
锁的内存语义和 volatile 内存语义相同(也难怪作者说 volatile 是一种比锁更轻量级的通信机制了)
- 线程 A 释放一个锁,实质上是线程 A 向接下来将要获取这个锁的某个线程发出了(线程 A 对共享变量所做修改的)消息
- 线程 B 获取一个锁,实质上是线程 B 接收了之前某个线程发出的(在释放这个锁之前对共享变量所做修改的)消息
- 线程 A 释放锁,随后线程 B 获取这个锁,这个过程实质上是线程 A 通过主内存向线程 B 发送消息
锁内存语义的实现
这一节通过 ReentrantLock 的源码,进行了锁内存语义分析的尝试。 总结,锁释放-获取的内存语义实现的两种方式:
- 利用 volatile 变量的写-读锁具有的内存语义
- 利用 CAS 所附带的 volatile 读和 volatile 写的内存语义
final 域的内存语义
final 域的重排序规则
- 在构造函数内对一个 final 域的写入,与随后把这个被构造对象的引用赋值给一个引用变量,这两个操作之间不能重排序
- 初次读一个包含 final 域的对象的引用,与随后初次读这个 final 域,这两个操作之间不能重排序
写 final 域的重排序规则
规则其实就是上面的第一条,那这个规则的意义是什么呢? 该规则可以确保:在对象引用为任意线程可见之前,对象的 final 域已经被正确初始化。
普通域呢?static 域呢? 普通域不具有这个保证,static 域不需要对象初始化就可以使用
疑惑:既然普通域不具有该保证,那 JVM 是怎么保证对象被调用时就是“准备就绪”的?
读 final 域的重排序规则
规则是上面的第二条 其实读取对象引用,与读取对象的 final 域被视为有数据的间接依赖,所以不会被重排序
final 域为引用类型
当 final 域为引用类型时,被引用对象的成员域赋值与该对象的引用赋值给 final 域,不能重排序
final 引用不能从构造函数内“溢出”
在构造方法返回前,被构造对象的引用不应该为其他线程所见。不然就可能导致其他线程看到没有被正确初始化的 final 域的值
先行发生原则
先行发生原则对程序员做出了高并发下的可见性保证
JMM 的设计
JMM 主要思考两个问题:
- 对程序员提供易于理解,易于编程的保证
- 对编译器和处理器提出尽量宽松的约束
所以 JMM 对程序员提出了先行发生原则来提供可见性保证,对编译器和处理器只提出了一条硬性要求:禁止会改变结果的重排序
先行发生的定义
- 如果一个操作先行发生于另一个操作,那么第一个操作的执行结果将对第二个操作可见,而且第一个操作的执行顺序排在第二个操作之前
- 两个操作之间存在先行发生关系,并不意味着 Java 平台的具体实现必须要按照先行发生关系指定的顺序来执行。如果重排序之后的执行结果,与按先行发生关系来执行的结果一直,那么这种重排序并不非法
先行发生规则
- 程序顺序规则:一个线程中的每个操作,先行发生于该线程中的任意后续操作
- 监视器锁规则:对一锁的解锁,先行发生于随后对这个锁的枷锁
- volatile 变量规则:对一个 volatile 域的写,先行发生于任意后续对这个 volatile 域的读
- 传递性:如果 A 先行发生 ,且 B 先行发生 C,那么 A 先行发生 C
- start() 规则:如果线程 A 执行操作 ThreadB.start()(启动线程 B),那么 A 线程的 ThreadB.start() 操作先行发生于线程 B 中的任意操作
- join() 规则:如果线程 A 执行操作 ThreadB.join() 并成功返回,那么线程 B 中的任意操作先行发生于线程 A 从 ThreadB.join() 操作成功返回