20分钟了解Java并发编程
Java并发
介绍
上一篇讲完了Java容器的概念与其底层数据结构原理,这一篇主要介绍Java并发编程的概念。这篇文件的内容基于Java多线程与并发进行总结归纳。
理论基础
1. 并发编程三要素
由于CPU、内存、I/O 设备的速度是有极大差异的,为了合理利用 CPU 的高性能,平衡这三者的速度差异,计算机体系结构、操作系统、编译程序都做出了贡献,主要体现为:
- CPU增加了缓存,以均衡与内存的速度差异;// 导致
可见性
问题 - 操作系统增加了进程、线程,以分时复用CPU,进而均衡CPU与I/O设备的速度差异;// 导致
原子性
问题 - 编译程序优化指令执行次序,使得缓存能够得到更加合理地利用。// 导致
有序性
问题
为什么CPU增加了缓存会导致可见性问题?
由于CPU比内存的处理数据的速度更快,当CPU需要从内存读写数据时需要等待很长的CPU时钟周期才能完成一次内存访问,中间的延迟导致了CPU资源浪费,所以增加了缓存,用于暂时保存最近使用的数据,以加速数据访问速度。
缓存的层次结构设计是层次化的,但速度逐层减慢的,也就是L1最快、L3最慢。当缓存层次越多,数据同步延迟越大,更新的传播路径越长。也就是由于各线程在读取或修改共享变量时未及时看到其他线程的更新,导致线程之间的数据不一致性问题,从而造成程序逻辑错误。也就是可见性问题的发生。
为什么CPU的分时复用导致了原子性问题?
操作系统通过引入进程和线程来实现 CPU的分时复用 ,以提高系统的资源利用率和响应能力等,但是这种并发执行引发了原子性问题,也就是某个操作要么完全执行,要么完全不执行,期间不能被中断。例如操作系统的时间片轮转意味着一个线程可能在执行一个关键操作(如更新变量)时被中断,操作被中断则意味着原子性问题的发生,可能会导致数据不一致或错误。也就是原子性问题的发生。
为什么编译程序优化指令执行次序会导致有序性问题?
编译程序为了减少CPU的等待时间和指令执行的延迟而优化指令执行次序,但这也导致了有序性的破坏,例如指令重排序的操,将不依赖于某个计算结果的指令移到其前面执行。再例如内存重排序,编译器可能会对内存访问操作进行重排序,以减少内存访问延迟。这可能会导致多个线程或进程看到的内存操作顺序不一致,从而影响程序的正确性。
三大特性 | 说明 | 引起问题的原因顺序 |
---|---|---|
可见性 | 一个线程对共享变量的修改,另外一个线程能够立刻看到 | CPU与内存的速度差异 -> 缓存的出现 -> 缓存的结构越复杂 -> 数据传播速度越慢 -> 无法立刻看见另一个线程的修改 |
原子性 | 在并发环境中,某个操作要么完全执行,要么完全不执行,期间不能被中断 | 为提高资源利用率,线程与进程的出现 -> 为提高CPU的使用率,CPU的分时复用的出现 -> 导致操作中断等原子性问题 |
有序性 | 程序执行的顺序按照代码的先后顺序执行 | 为提高CPU执行率,编译器的指令重排 -> 导致有序性问题产生 |
接下来看看Java是如何保证三要素的实现。
2. Java如何保证三要素的实现
Java通过JMM规范和约束编译器和处理器的重排序,并基于JMM提供语法层面的三个关键字volatile、synchronized 和 final,让程序员能根据不同场景进行不同程度的控制并发达到性能的优化。
Java内存模型(Java Memory Model)
这个章节总结自深入理解Java内存模型,作者程晓明。
顺序一致性模型是一个理想中的多线程编程中的内存模型的概念,它为并发程序定义了内存操作的执行顺序。在顺序一致性模型下,所有线程的内存读写操作都按照它们在程序中的顺序来执行,所有线程看到的内存访问顺序是一致的。这是最强的内存一致性保证,但代价是性能开销较大。许多内存模型都根据顺序一致性模型进行设计,例如x86处理器的内存模型(强内存模型-接近顺序一致性)、SPARC的PSO和RMO模型等等。
顺序一致性模型主要是通过两个特性来实现的,基于该模型设计的内存模型都会大致围绕这两个特性展开。
- 同一个操作执行的顺序表 : 一个线程中的所有操作必须按照程序的顺序来执行,所有线程都只能看到一个单一的操作执行顺序,每个操作都必须原子执行且立刻对所有线程可见。
- 单一全局内存开关装置 : 这个内存通过一个左右摆动的开关可以连接到任意一个线程,同时,每一个线程必须按程序的顺序来执行内存读/写操作,在任意时间点最多只能有一个线程可以连接到内存,多线程并发时,通过开关将操作串行化。
JMM也是一个基于顺序一致性模型设计的一个内存模型,其设计理念是对程序员而言,易于理解易于编程,通过实现弱内存模型来减少束缚,增加程序员优化的可能性,从而提高程序性能。它的作用是规范和定义多线程环境下,线程之间如何共享和交互内存中的数据,以确保程序在并发情况下能够正确运行。所以Java线程之间的通信由JMM控制,JMM决定一个线程对共享变量的写入何时对另一个线程可见。
工作原理一 : 编译器、处理器和硬件平台为了优化程序性能而进行对指令的重排序,JMM通过定义规则和内存屏障来限制和规范编译器和处理器的重排序行为。
JMM在约束和规范重排序的行为时,数据依赖性和as-if-serial语义起到关键性作用。
- 数据依赖性 : 如果两个操作访问同一个变量,且这两个操作中有一个为写操作,此时这两个操作之间就存在数据依赖性。数据依赖性适用于多种场景,如单线程/单处理器/多线程(同步机制)等。
- as-if-serial语义 : 不管怎么重排序,(单线程)程序的执行结果不能被改变。编译器,runtime 和处理器都必须遵守这个语义。这个语义为单线程程序的行为提供了正确性保障。
工作原理二 : 对于不同情况的不同处理。
- 单线程程序 : 不会出现内存可见性问题。
- 正确同步的多线程程序 : 具有顺序一致性。也就是 : 一个线程中的所有操作按照程序的顺序来执行、所有线程都只能看到一个单一的操作执行顺序、每个操作都必须原子执行且立刻对所有线程可见。
- 未同步/未正确同步的多线程程序 : 只保证最小安全性。线程执行时读取到的值,要么是之前某个线程写入的值,要么是默认值(0,null,false)。
总的来说,JMM是用于约束和规范编译器和处理器重排序的行为,接下来我们继续看看,Java从语言层面为开发者提供的多线程并发工具 : volatile、synchronized 和 final 。这些关键字通过遵循 JMM 的规则来保证多线程环境中的正确性。
关键字 volatile、synchronized 和 final
synchronized详解
这一节主要总结自文章synchronized详解,其中的关于JVM部分可继续阅读与面试官聊20分钟JVM。
在能选择的情况下,既不要用Lock也不要用synchronized关键字,用java.util.concurrent包中的各种各样的类,如果不用该包下的类,在满足业务的情况下,可以使用synchronized关键,因为代码量少,避免出错。
volatile详解
注意使用volitale前的三个条件:
- 在使用volatile的场景中,写操作不能依赖该变量的当前值来进行计算 当我们对一个变量执行“读取-修改-写入”这种复合操作时,如果该操作依赖于当前的值,volatile 不能保证操作是线程安全的。 例如,递增操作 counter++,这个操作分为三个步骤:读取、修改、写入。
1
2
3
4
5
volatile int count = 0;
public void increment() {
count++; // 非原子操作,多个线程可能导致结果错误
}
- 在多线程环境中,如果某个变量的值变化会影响其他变量的值或状态(即存在不变性约束),volatile 不能保证多个变量之间的状态一致性。
1
2
3
4
5
6
7
volatile boolean ready = false;
int data = 0;
public void update() {
data = 100; // data 和 ready 之间有约束关系
ready = true;
}
- 只有当变量的状态与程序中其他部分没有关联或依赖时,才能安全地使用volatile。
1
2
3
4
5
6
7
8
9
10
11
12
13
// 适合使用volatile样例
volatile boolean shutdown = false;
public void shutdown() {
shutdown = true; // shutdown 变量独立,且只需要保证可见性
}
public void run() {
while (!shutdown) {
// 继续执行任务
}
}
final详解
使用final的条件和局限性 : 当声明一个 final 成员时,必须在构造函数退出前设置它的值。
线程安全与其实现办法、并发总结
词典
时钟周期
CPU时钟周期(Clock Cycle)是指CPU内部时钟信号完成一个完整的振荡周期的时间单位。
分时复用
CPU的分时复用(Time-sharing)是一种操作系统调度策略,旨在让多个任务(进程或线程)在同一个CPU上并发执行,从而提高系统的资源利用率和响应能力。它的核心思想是将CPU的时间划分为一个个小的时间片(time slice),并在这些时间片之间轮流分配给不同的任务执行。
临界区
即访问共享资源的代码段。
同步块
同步块是一种精确控制锁的方式。它只锁定某段关键代码,而不是整个方法,从而减少锁的粒度,提升程序的并发性和性能。
CAS原子指令
CAS(Compare And Swap/Set)原子指令 是一种硬件级别的原子操作,通常用于实现无锁并发编程。