Java多线程
01 |可见性、原子性和有序性问题:并发编程Bug的源头
原子性:线程切换导致原子性。
可见性:CPU缓存导致可见性。
有序性:编译优化导致有序性。
02 | Java内存模型:看Java如何解决可见性和有序性问题
Java 内存模型规范了 JVM 如何提供按需禁用缓存和编译优化的方法。具体来说,这些方法包括 volatile、synchronized 和 final 三个关键字,以及六项 Happens-Before 规则。
volatile:告诉编译器,对这个变量的读写,不能使用 CPU 缓存,必须从内存中读取或者写入。
Happens-Before 规则
前面一个操作的结果对后续操作是可见的。在 Java 语言里面,Happens-Before 的语义本质上是一种可见性,A Happens-Before B 意味着 A 事件对 B 事件来说是可见的,无论 A 事件和 B 事件是否发生在同一个线程里。
1、程序顺序性原则
按照程序顺序,前面的操作 Happens-Before 于后续的任意操作。(此处指统一代码块中的单线程思维)
2、volatile原则
对一个 volatile 变量的写操作, Happens-Before 于后续对这个 volatile 变量的读操作。(结合规则3(传递性)使用)(写变量“v=true” 读变量 “v=true”)
3、传递性
如果 A Happens-Before B,且 B Happens-Before C,那么 A Happens-Before C。
举例如下:
代码:class VolatileExample {
int x = 0;
volatile boolean v = false;
public void writer() {
x = 42;
v = true;
}
public void reader() {
if (v == true) {
// 这里x会是多少呢?
}
}
}
分析:上面的示例代码,假设线程 A 执行 writer() 方法,按照 volatile 语义,会把变量 “v=true” 写入内存;假设线程 B 执行 reader() 方法,同样按照 volatile 语义,线程 B 会从内存中读取变量 v,如果线程 B 看到 “v == true” 时,那么线程 B 看到的变量 x 是42
PS:利用了volatite和传递性原则
4、管程中锁的规则
对一个锁的解锁 Happens-Before 于后续对这个锁的加锁——(假设 x 的初始值是 10,线程 A 执行完代码块后 x 的值会变成 12(执行完自动释放锁),线程 B 进入代码块时,能够看到线程 A 对 x 的写操作,也就是线程 B 能够看到 x==12)。这个地方对于synchronized(管程中的一种)同样适用,一个锁的解锁 Happens-Before后续这个锁的加锁。
5、线程start()原则
它是指主线程 A 启动子线程 B 后,子线程 B 能够看到主线程在启动子线程 B 前的操作
6、线程join原则
主线程 A 等待子线程 B 完成(主线程 A 通过调用子线程 B 的 join() 方法实现),当子线程 B 完成后(主线程 A 中 join() 方法返回),主线程能够看到子线程的操作。当然所谓的“看到”,指的是对共享变量的操作。
final
在 1.5 以后 Java 内存模型对 final 类型变量的重排进行了约束。现在只要我们提供正确构造函数没有“逸出”,就不会出问题了。
03 | 互斥锁(上):解决原子性问题
我们把一段需要互斥执行的代码称为临界区。
Java 语言提供的锁技术:synchronized(锁和锁要保护的资源是有对应关系的,比如你用你家的锁保护你家的东西,我用我家的锁保护我家的东西)。受保护资源和锁之间的关联关系是 N:1 的关系,但是不能用多把锁来保护一个资源。
当修饰静态方法的时候,锁定的是当前类的 Class 对象,在上面的例子中就是 Class X;当修饰非静态方法的时候,锁定的是当前实例对象 this。
同一线程在调用自己类中其他 synchronized 方法/块或调用父类的 synchronized 方法/块都不会阻碍该线程的执行。就是说同一线程对同一个对象锁是可重入的,而且同一个线程可以获取同一把锁多次,也就是可以多次重入。
04 | 互斥锁(下):如何用一把锁保护多个资源?
细粒度锁:用不同的锁对受保护资源进行精细化管理,是性能优化的一个重要手段。(使用细粒度锁是有代价的,这个代价就是可能会导致死锁)
this 这把锁可以保护自己的资源,却保护不了别人的资源,就像你不能用自家的锁来保护别人家的资产,也不能用自己的票来保护别人的座位一样。
我们提到用同一把锁来保护多个资源——包场:锁能覆盖所有受保护资源
“原子性”的本质:其实不是不可分割,不可分割只是外在表现,其本质是多个资源间有一致性的要求,操作的中间状态对外不可见。(解决原子性问题,是要保证中间状态对外不可见。)
PS:不能用可变对象做锁
05 | 一不小心就死锁了,怎么办?
死锁的一个比较专业的定义是:一组互相竞争资源的线程因互相等待,导致“永久”阻塞的现象。
以下四个条件都发生时才会出现死锁:
1、互斥,共享资源 X 和 Y 只能被一个线程占用;
2、占有且等待,线程 T1 已经取得共享资源 X,在等待共享资源 Y 的时候,不释放共享资源 X;
3、不可抢占,其他线程不能强行抢占线程 T1 占有的资源;
4、循环等待,线程 T1 等待线程 T2 占有的资源,线程 T2 等待线程 T1 占有的资源,就是循环等待。
也就是说只要我们破坏其中一个,就可以成功避免死锁的发生(互斥这个条件我们没有办法破坏,因为我们用锁为的就是互斥)
1. 破坏占用且等待条件
从理论上讲,要破坏这个条件,可以一次性申请所有资源。
2. 破坏不可抢占条件
核心是要能够主动释放它占有的资源
3. 破坏循环等待条件
破坏这个条件,需要对资源进行排序,然后按序申请资源。(这个实现非常简单,我们假设每个账户都有不同的属性 id,这个 id 可以作为排序字段,申请的时候,我们可以按照从小到大的顺序来申请。)
06 | 用“等待-通知”机制优化循环等待
等待 - 通知机制:线程首先获取互斥锁,当线程要求的条件不满足时,释放互斥锁,进入等待状态;当要求的条件满足时,通知等待的线程,重新获取互斥锁。
Java 语言内置的 synchronized 配合 wait()、notify()、notifyAll() 这三个方法就能轻松实现等待 - 通知机制
等待队列和互斥锁是一对一的关系,每个互斥锁都有自己独立的等待队列。
wait() 操作工作原理图
notify() 操作工作原理图
注意:被通知的线程要想重新执行,仍然需要获取到互斥锁(因为曾经获取的锁在调用 wait() 时已经释放了)
等待 - 通知机制中,我们需要考虑以下四个要素。
互斥锁:上一篇文章我们提到 Allocator 需要是单例的,所以我们可以用 this 作为互斥锁。
线程要求的条件:转出账户和转入账户都没有被分配过。
何时等待:线程要求的条件不满足就等待。
何时通知:当有线程释放账户时就通知。
07 | 安全性、活跃性以及性能问题
并发编程主要要注意三个问题:安全性问题、活跃性问题和性能问题
安全性问题
线程安全:程序按照我们期望的执行,不要让我们感到意外
数据竞争:当多个线程同时访问同一数据
竞态(争)条件:指的是程序的执行结果依赖线程执行的顺序
活跃性问题
活跃性问题:指的是某个操作无法执行下去。
“死锁”就是一种典型的活跃性问题,当然除了死锁外,还有两种情况,分别是“活锁”和“饥饿”。
有时线程虽然没有发生阻塞,但仍然会存在执行不下去的情况,这就是所谓的“活锁”
“饥饿”指的是线程因无法访问所需资源而无法执行下去的情况——在 CPU 繁忙的情况下,优先级低的线程得到执行的机会很小,就可能发生线程“饥饿”;持有锁的线程,如果执行的时间过长,也可能导致“饥饿”问题。——解决“饥饿”问题的方案,有三种方案:一是保证资源充足,二是公平地分配资源,三就是避免持有锁的线程长时间执行。这三个方案中,方案一和方案三的适用场景比较有限,因为很多场景下,资源的稀缺性是没办法解决的,持有锁的线程执行的时间也很难缩短。倒是方案二的适用场景相对来说更多一些。那如何公平地分配资源呢?在并发编程里,主要是使用公平锁
性能问题
Java SDK 并发包里之所以有那么多东西,有很大一部分原因就是要提升在某个特定领域的性能。
第一,既然使用锁会带来性能问题,那最好的方案自然就是使用无锁的算法和数据结构了。
第二,减少锁持有的时间。
性能方面的度量指标有很多,我觉得有三个指标非常重要,就是:吞吐量、延迟和并发量
08 | 管程:并发编程的万能钥匙
管程(Monitor)和信号量是等价的,所谓等价指的是用管程能够实现信号量,也能用信号量实现管程。
管程,指的是管理共享变量以及对共享变量的操作过程,让他们支持并发。——翻译为 Java 领域的语言,就是管理类的成员变量和成员方法,让这个类是线程安全的。
MESA 模型
在并发编程领域,有两大核心问题:一个是互斥,即同一时刻只允许一个线程访问共享资源;另一个是同步,即线程之间如何通信、协作。
管程解决互斥问题的思路很简单,就是将共享变量及其对共享变量的操作统一封装起来。
封装变量&封装对变量的操作
借鉴就医流程,看管程如何解决线程间的同步问题
MEAS管程模型
每个条件变量都对应有一个等待队列
两个重要的类:ReentrantLock & Condition
final Lock lock = new ReentrantLock();
// 条件变量:队列不满 final Condition notFull = lock.newCondition();
// 条件变量:队列不空 final Condition notEmpty = lock.newCondition();
09 | Java线程(上):Java线程的生命周期
通用的线程生命周期基本上可以用下图这个“五态模型”来描述。这五态分别是:初始状态、可运行状态、运行状态、休眠状态和终止状态。
通用线程状态转换图——五态模型
Java 语言中线程共有六种状态,分别是:
NEW(初始化状态)
RUNNABLE(可运行 / 运行状态)
BLOCKED(阻塞状态)
WAITING(无时限等待)
TIMED_WAITING(有时限等待)
TERMINATED(终止状态)
Java 线程中的 BLOCKED、WAITING、TIMED_WAITING 是一种状态,即前面我们提到的休眠状态。也就是说只要 Java 线程处于这三种状态之一,那么这个线程就永远没有 CPU 的使用权。
Java 中的线程状态转换图
1. RUNNABLE 与 BLOCKED 的状态转换
只有一种场景会触发这种转换,就是线程等待 synchronized 的隐式锁(等待的线程就会从 RUNNABLE 转换到 BLOCKED 状态。而当等待的线程获得 synchronized 隐式锁时,就又会从 BLOCKED 转换到 RUNNABLE 状态。)
2. RUNNABLE 与 WAITING 的状态转换
Object.wait() 、 Thread.join() 、 LockSupport.park()
3. RUNNABLE 与 TIMED_WAITING 的状态转换
调用带超时参数的 Thread.sleep(long millis) 方法;获得 synchronized 隐式锁的线程,调用带超时参数的 Object.wait(long timeout) 方法;调用带超时参数的 Thread.join(long millis) 方法;调用带超时参数的 LockSupport.parkNanos(Object blocker, long deadline) 方法;调用带超时参数的 LockSupport.parkUntil(long deadline) 方法。
TIMED_WAITING 和 WAITING 状态的区别,仅仅是触发条件多了超时参数。
4. 从 NEW 到 RUNNABLE 状态
从 NEW 状态转换到 RUNNABLE 状态很简单,调用线程对象的 start()
5. 从 RUNNABLE 到 TERMINATED 状态
1、线程执行完 run() 方法后,会自动转换到 TERMINATED 状态,当然如果执行 run() 方法的时候异常抛出,也会导致线程终止。 2、调用 interrupt() 方法
6、NEW状态
新建一个线程对象
010 | Java线程(中):创建多少线程才是合适的?
要解决这个问题,首先要分析以下两个问题:
为什么要使用多线程? 多线程的应用场景有哪些?
计算机主要有哪些硬件呢?主要是两类:一个是 I/O,一个是 CPU。简言之,在并发编程领域,提升性能本质上就是提升硬件的利用率,再具体点来说,就是提升 I/O 的利用率和 CPU 的利用率。(PS: CPUworking跟I/Oworking都是有线程在参与)
我们的程序一般都是 CPU 计算和 I/O 操作交叉执行的,所以有CPU密集型跟IO密集型的区别
CPU 密集型计算
对于 CPU 密集型的计算场景,理论上“线程的数量 =CPU 核数”就是最合适的。不过在工程上,线程的数量一般会设置为“CPU 核数 +1”,这样的话,当线程因为偶尔的内存页失效或其他原因导致阻塞时,这个额外的线程可以顶上,从而保证 CPU 的利用率。
I/O 密集型的计算场景
最佳线程数 =1 +(I/O 耗时 / CPU 耗时) 单核CPU
最佳线程数 =CPU 核数 * [ 1 +(I/O 耗时 / CPU 耗时) 多核CPU
目标就是让CPU 和 I/O 设备的利用率都达到最高位。将硬件的性能发挥到极致。
单线程执行示意图
二线程执行示意图
三线程执行示意图
011 | Java线程(下):为什么局部变量是线程安全的?
局部变量的作用域是方法内部,也就是说当方法执行完,局部变量就没用了,局部变量应该和方法同生共死。(局部变量是和方法同生共死的,一个变量如果想跨越方法的边界,就必须创建在堆里。)
两个线程可以同时用不同的参数调用相同的方法,那调用栈和线程之间是什么关系呢?答案是:每个线程都有自己独立的调用栈。
线程与调用栈的关系图
没有共享,就没有伤害。
附:
当调用方法时,会创建新的栈帧,并压入调用栈;
调用栈结构
局部变量就是放到了调用栈里
保护局部变量的调用栈结构
012 | 如何用面向对象思想写好并发程序?
在 Java 语言里,面向对象思想能够让并发编程变得更简单。
用面向对象思想写好并发程序:
1、封装共享变量 2、识别共享变量间的约束条件 3、制定并发访问策略
1、封装共享变量
面向对象思想里面有一个很重要的特性是封装,封装的通俗解释就是将属性和实现细节封装在对象内部,外界对象只能通过目标对象提供的公共方法来间接访问这些内部属性.将共享变量作为对象属性封装在内部,对所有公共方法制定并发访问策略。
对于不会发生变化的共享变量,建议你用 final 关键字来修饰。
2、识别共享变量间的约束条件
这些约束条件,决定了并发访问策略。识别出所有共享变量之间的约束条件,如果约束条件识别不足,很可能导致制定的并发访问策略南辕北辙。
共享变量之间的约束条件,反映在代码里,基本上都会有 if 语句,所以,一定要特别注意竞态条件。
3、制定并发访问策略
避免共享:避免共享的技术主要是利于线程本地存储以及为每个任务分配独立的线程。
不变模式:这个在 Java 领域应用的很少,但在其他领域却有着广泛的应用,例如 Actor 模式、CSP 模式以及函数式编程的基础都是不变模式。
管程及其他同步工具:Java 领域万能的解决方案是管程,但是对于很多特定场景,使用 Java 并发包提供的读写锁、并发容器等同步工具会更好。
优先使用成熟的工具类:Java SDK 并发包里提供了丰富的工具类,基本上能满足你日常的需要,建议你熟悉它们,用好它们,而不是自己再“发明轮子”,毕竟并发工具类不是随随便便就能发明成功的。
迫不得已时才使用低级的同步原语:低级的同步原语主要指的是 synchronized、Lock、Semaphore 等,这些虽然感觉简单,但实际上并没那么简单,一定要小心使用。
避免过早优化:安全第一,并发程序首先要保证安全,出现性能瓶颈后再优化。在设计期和开发期,很多人经常会情不自禁地预估性能的瓶颈,并对此实施优化,但残酷的现实却是:性能瓶颈不是你想预估就能预估的。