前言
高并发、多线程可谓是 Java 最核心、最难懂,也是圈子里都趋之若鹜的知识点,掌握这一知识就可以在工作和面试中站在“上帝视角”笑傲江湖。笔者将会再次从 JMM 和 JVM 出发,从缓存一致性出发,再到 volatile
、然后讲解 synchronized
的实现原理、Lock
的最佳实践,最后结合自己的实践经验谈谈自己对并发的认识。
基础知识准备
Amdahl 定律和 Gustafson 定律
先来说一下并发编程下两条重要的性能定律:Amdahl(阿姆达尔)定律和 Gustafson(古斯塔夫森)定律。Amdahl 说的是在一个应用程序中并行数量的百分比越高,其整体的性能就会越来越高,Gustafon 说的是在应用程序中如果串行数量一定的情况下,并行的数量不论怎么增长,都会达到一个极限。举例说明就是,A 到 B 的距离是 100KM,前半程速度是 50KM/h,那么在后半程速度越高,那么整体的花费的时间就会越短,这就是 Amdahl 定律,然而不论后半程速度不论快,哪怕是光速,其花费的时间也只能是无限接近 1h,这就是 Gustafon 定律。
线程通信机制
在线程之间的通信机制有两种,共享内存和消息传递。在共享内存的方式中,线程之间通过 read-write 内存中的公共状态隐式实现通信,典型的作法就是通过共享对象的方式进行通信。但是在繁忙的内存操作中,如何实现共享方式,就需要依靠缓存一致性协议,具体实现就是 volatile 关键字。在消息传递的方式中,线程之间需要显式地发送消息来实现通信,这就是为什么需要有 wait 和 notify 方法。
线程和进程
拥有资源和独立调度的基本单位是进程。在操作系统中,线程是独立调度的基本单位,进程是资源拥有的基本单位。调度 CPU 资源的最小单位是线程,线程又分为线用户级线程和内核级线程。
用户级线程(User Level Thread,简称 ULT)
用户级线程是由用户创建的线程,线程阻塞则其进程阻塞,用户线程比内核线程创建速度、切换速度要快。用户线程创建不需要依赖操作系统核心,内核对用户线程是无感知的。
内核级线程(Kernel Level Thread,简称 KLT)
内核级线程是由内核创建的,内核线程线程阻塞不会导致其进程阻塞,只有内核线程才具有操作 CPU 的权限,用户级线程通过内核提供的交互接口进行数据交互,内核级线程的创建比用户级线程开销更大。
Tips:用户级线程和内核级线程是隔离开的,最直接的证据就是,在操作系统中某个用户的线程报错会导致用户进停止、异常,但是一般不会导致整个系统异常、宕机。
串行、并行和并发
- 串行:一次只能取得一个任务并执行这一个任务。
- 并行:多进程或多线程的方式同时执行这些任务,每个进程或者每个线程分到一个任务。
- 并发:同时运行多个线程并将一个大任务拆解成多个子任务执行,单个线程会分到多个子任务。
死锁和活锁
死锁
死锁是两个或者两个以上的进程或者线程在执行过程中因争夺资源而相互等待的现象,简单的说法就是线程相互持有另一个线程所需要的资源,造成了线程间相互阻塞的情况。
活锁
活锁是任务没有被阻塞,但是由于某些条件没有满足,导致线程重复尝试 -> 失败 -> 尝试 -> 失败的恶性循环中。简单点说就是线程间相互的谦让导致任务不能正常的执行。
死锁和活锁的区别在于活锁的情况下被锁的对象状态是不断变化的(线程操作发现不能完全满足条件而造成的对象的加锁和解锁),这就是活锁的体现。死锁则表现在线程间相互等待,被锁的对象状态是一直不变的,线程间处于阻塞的状态。活锁可能自行解锁,死锁则需要依靠外力进行解锁。
锁饥饿
锁饥饿则是一个或者多个线程因为多种原因无法获取资源,导致任务一直无法执行。由于 Java 采用了时间片轮转的方式实现线程调度,因此可以对线程设置优先级。比如高优先级的线程一直抢占低优先级线程的 CPU 时间,就会导致线程一直在被阻塞。
重排序
为了提高和优化程序性能编译器和处理器对指令序列进行重新排序,但是不论怎么改变,都必须要遵守 as-if-serial 语义和 happens-before 原则。
as-if-serial 语义说的是在多线程的情况下,也能保证程序串行(或者说是单线程)的方式运行。这里距一个例子:
// A 步骤给 x 赋值,B 步骤给 x 赋值为 4,C 步骤给 x 赋值为 3
int x = 0; // A
x= 4; // B
x =3; // C
System.out.println("x = " + x); // D 输出结果
如果发生指令重排,那么 C 可能出现在 B 的前面,那么最后出现的结果就是错误的值 4。
**happens-before**
是 JMM 最核心的概念,JSR-133 定义的 happens-before
的规则如下:
- 程序顺序性:线程中的每个操作,都要优先于线程中的任意后续操作。例如方法返回结果必须整个方法运行结束后才能返回。
- 监视器锁规则:线程加锁的动作要先于其解锁的动作。
- volatile 变量规则:volatile 的写操作要优先于 volatile 读操作。
- 传递性:如果 A 优先于 B,B 优先于 C,那么 A 也一定优先于 C 。
- start 原则:线程 start 之后,线程才能执行动作。
逃逸分析
前一篇文章中也讲到了如果需要栈上分配,则需要开启逃逸分析,这样对象才能在栈上创建,这里再次详细说一下开启逃逸分析的特点:
-
栈上分配:对象如果不会逃逸到方法外,则对象可以在栈上进行内存分配,栈销毁会将对象销毁,避免了垃圾回收操作,开启逃逸分析的 JVM 参数配置
-XX:+DoEscapeAnalysis
。 -
同步消除:对象如果不会逃逸出线程,可以将对象的同步操作消除,如果代码里进行了加锁操作,JVM 会自动去除加锁操作。开启同步消除的 JVM 参数配置
-XX:+EliminateLocks
。 -
标量替换:聚合量简单的理解就是一个对象,标量就是基本数据类型和简单的包装类。标量替换是将一个聚合量拆散成标量的过程,如果一个对象不会被外部访问,且对象可以被拆散,那么程序执行时不会创建此对象,而是用对象中的属性标量来替代整个对象。开启标量替换的 JVM 参数配置
-XX:+EliminateAllocations
。
缓存一致性协议
这个知识点大家第一反应不就是 MESI 吗?其实还真不是,MESI 协议只是缓存一致性协议的其中一种实现方式而已,是 Intel 的提出的,也是目前认知最广的一种,还有 ASM 协议等。
大家都知道数据的读写速度如下:
读 CPU > 读缓存 > 读内存 > 读硬盘 > 读网络
由于存在读取速度的差异,所以会在 CPU 和内存之间加上多级缓存,来解决读写速度差的问题,同时也提高了 CPU 和主存的工作效率。但是多级缓存的存在,在多个线程竞争读写主存(物理内存)当中数据的时候,到导致数据不一致的严重后果。假设主存中有一个变量 a =1,假设 CPU1 和 CPU2 已经对应的多级缓存中已经加载了 a 的副本,在 t0 时刻各个位置的 a 的值都为 1,在某一时刻 t1,CPU1 将 a 的值 +1 并写入缓存中,在 t2 时刻 CPU2 将 a 的值 +1 也写入了缓存中,按照时间的顺序,a 的值应该变为 3,但是由于缓存的存在,在 t2 时刻 CPU2 对应的缓存 a 的值并没有更新为最新的值为 2,因此,当缓存同步数据到主存时,最终的结果将会是 2。具体过程如下图所示: