01 | 可见性、原子性和有序性问题:并发编程Bug的源头
并发问题产生的根源:
源头之一:缓存导致的可见性问题
多核时代,每颗 CPU 都有自己的缓存
源头之二:线程切换带来的原子性问题
Java 并发程序都是基于多线程的,自然也会涉及到任务切换,操作系统做任务切换,可以发生在任何一条 CPU 指令执行完,是的,是 CPU 指令,而不是高级语言里的一条语句。
我们把一个或者多个操作在 CPU 执行的过程中不被中断的特性称为原子性。CPU 能保证的原子操作是 CPU 指令级别的,而不是高级语言的操作符。
源头之三:编译优化带来的有序性问题
02 | Java内存模型:看Java如何解决可见性和有序性问题
导致可见性的原因是缓存,导致有序性的原因是编译优化,那解决可见性、有序性最直接的办法就是按需禁用缓存以及编译优化。Java 内存模型规范了 JVM 如何提供按需禁用缓存和编译优化的方法。具体来说,这些方法包括 volatile、synchronized 和 final 三个关键字,以及六项 Happens-Before 规则
Happens-Before 规则:前面一个操作的结果对后续操作是可见的。
- 程序的顺序性规则:程序前面对某个变量的修改一定是对后续操作可见的。
- volatile 变量规则
指对一个 volatile 变量的写操作, Happens-Before 于后续对这个 volatile 变量的读操作。 - 传递性
这条规则是指如果 A Happens-Before B,且 B Happens-Before C,那么 A Happens-Before C。 - 管程中锁的规则
这条规则是指对一个锁的解锁 Happens-Before 于后续对这个锁的加锁。 - 线程 start() 规则
指主线程 A 启动子线程 B 后,子线程 B 能够看到主线程在启动子线程 B 前的操作。
如果线程 A 调用线程 B 的 start() 方法(即在线程 A 中启动线程 B),那么该 start() 操作 Happens-Before 于线程 B 中的任意操作。 - 线程 join() 规则
指主线程 A 等待子线程 B 完成(主线程 A 通过调用子线程 B 的 join() 方法实现),当子线程 B 完成后(主线程 A 中 join() 方法返回),主线程能够看到子线程的操作。当然所谓的“看到”,指的是对共享变量的操作。
03 | 互斥锁(上):解决原子性问题
受保护资源和锁之间的关联关系是 N:1 的关系
反例:
04 | 互斥锁(下):如何用一把锁保护多个资源?
如果资源之间没有关系,很好处理,每个资源一把锁就可以了。如果资源之间有关联关系,就要选择一个粒度更大的锁,这个锁应该能够覆盖所有相关的资源。除此之外,还要梳理出有哪些访问路径,所有的访问路径都要设置合适的锁。
“原子性”的本质是操作的中间状态对外不可见。
05 | 一不小心就死锁了,怎么办?
死锁的一个比较专业的定义是:一组互相竞争资源的线程因互相等待,导致“永久”阻塞的现象。
只有以下这四个条件都发生时才会出现死锁:
互斥,共享资源 X 和 Y 只能被一个线程占用;
占有且等待,线程 T1 已经取得共享资源 X,在等待共享资源 Y 的时候,不释放共享资源 X;
不可抢占,其他线程不能强行抢占线程 T1 占有的资源;
循环等待,线程 T1 等待线程 T2 占有的资源,线程 T2 等待线程 T1 占有的资源,就是循环等待。
也就是说只要我们破坏其中一个,就可以成功避免死锁的发生。
其中,互斥这个条件我们没有办法破坏,因为我们用锁为的就是互斥。不过其他三个条件都是有办法破坏掉的,到底如何做呢?
对于“占用且等待”这个条件,我们可以一次性申请所有的资源,这样就不存在等待了。对于“不可抢占”这个条件,占用部分资源的线程进一步申请其他资源时,如果申请不到,可以主动释放它占有的资源,这样不可抢占这个条件就破坏掉了。
对于“循环等待”这个条件,可以靠按序申请资源来预防。所谓按序申请,是指资源是有线性顺序的,申请的时候可以先申请资源序号小的,再申请资源序号大的,这样线性化后自然就不存在循环了。
06 | 用“等待-通知”机制优化循环等待
等待 - 通知机制:线程首先获取互斥锁,当线程要求的条件不满足时,释放互斥锁,进入等待状态;当要求的条件满足时,通知等待的线程,重新获取互斥锁。
notify() 只能保证在通知时间点,条件是满足的。而被通知线程的执行时间点和通知的时间点基本上不会重合,所以当线程执行的时候,很可能条件已经不满足了(保不齐有其他线程插队)。被通知的线程要想重新执行,仍然需要获取到互斥锁。
07 | 安全性、活跃性以及性能问题
并发编程问题:安全性问题、活跃性问题和性能问题。
安全性问题:存在共享数据并且该数据会发生变化,通俗地讲就是有多个线程会同时读写同一数据。called 数据竞争.
活跃性问题
死锁,活锁”和“饥饿”
活锁:能会一直没完没了地“谦让”下去,成为没有发生阻塞但依然执行不下去的“活锁”。解决“活锁”的方案很简单,谦让时,尝试等待一个随机的时间就可以了。所谓“饥饿”指的是线程因无法访问所需资源而无法执行下去的情况。“不患寡,而患不均”,如果线程优先级“不均”,在 CPU 繁忙的情况下,优先级低的线程得到执行的机会很小,就可能发生线程“饥饿”;持有锁的线程,如果执行的时间过长,也可能导致“饥饿”问题。解决饥饿通过公平地分配资源。
性能问题
08 | 管程:并发编程的万能钥匙
管程和信号量是等价的,所谓等价指的是用管程能够实现信号量,也能用信号量实现管程。所谓管程,指的是管理共享变量以及对共享变量的操作过程。
- Hasen 模型里面,要求 notify() 放在代码的最后,这样 T2 通知完 T1 后,T2 就结束了,然后 T1 再执行,这样就能保证同一时刻只有一个线程执行。
- Hoare 模型里面,T2 通知完 T1 后,T2 阻塞,T1 马上执行;等 T1 执行完,再唤醒 T2,也能保证同一时刻只有一个线程执行。但是相比 Hasen 模型,T2 多了一次阻塞唤醒操作。
- MESA 管程里面,T2 通知完 T1 后,T2 还是会接着执行,T1 并不立即执行,仅仅是从条件变量的等待队列进到入口等待队列里面。这样做的好处是 notify() 不用放到代码的最后,T2 也没有多余的阻塞唤醒操作。但是也有个副作用,就是当 T1 再次执行的时候,可能曾经满足的条件,现在已经不满足了,所以需要以循环方式检验条件变量。
09 | Java线程(上):Java线程的生命周期
这五态分别是:初始状态、可运行状态、运行状态、休眠状态和终止状态。
初始状态,指的是线程已经被创建,但是还不允许分配 CPU 执行。
可运行状态,指的是线程可以分配 CPU 执行。
当有空闲的 CPU 时,操作系统会将其分配给一个处于可运行状态的线程,被分配到 CPU 的线程的状态就转换成了运行状态。
运行状态的线程如果调用一个阻塞的 API(例如以阻塞方式读文件)或者等待某个事件(例如条件变量),那么线程的状态就会转换到休眠状态,同时释放 CPU 使用权。
线程执行完或者出现异常就会进入终止状态,终止状态的线程不会切换到其他任何状态,进入终止状态也就意味着线程的生命周期结束了。
1. RUNNABLE 与 BLOCKED 的状态转换
只有一种场景会触发这种转换,就是线程等待 synchronized 的隐式锁。
2. RUNNABLE 与 WAITING 的状态转换
第一种场景,获得 synchronized 隐式锁的线程,调用无参数的 Object.wait() 方法。
第二种场景,调用无参数的 Thread.join() 方法。其中的 join() 是一种线程同步方法,例如有一个线程对象 thread A,当调用 A.join() 的时候,执行这条语句的线程会等待 thread A 执行完,而等待中的这个线程,其状态会从 RUNNABLE 转换到 WAITING。
第三种场景,调用 LockSupport.park() 方法。其中的 LockSupport 对象,也许你有点陌生,其实 Java 并发包中的锁,都是基于它实现的。调用 LockSupport.park() 方法,当前线程会阻塞,线程的状态会从 RUNNABLE 转换到 WAITING。调用 LockSupport.unpark(Thread thread) 可唤醒目标线程,目标线程的状态又会从 WAITING 状态转换到 RUNNABLE。
3. RUNNABLE 与 TIMED_WAITING 的状态转换
4.从 NEW 到 RUNNABLE 状态
创建 Thread 对象主要有两种方法。一种是继承 Thread 对象,重写 run() 方法。另一种是实现 Runnable 接口,重写 run() 方法。
从 NEW 状态转换到 RUNNABLE 状态很简单,只要调用线程对象的 start() 方法就可以了。
5. 从 RUNNABLE 到 TERMINATED 状态
线程执行完 run() 方法后,会自动转换到 TERMINATED 状态,当然如果执行 run() 方法的时候异常抛出,也会导致线程终止。
stop() 和 interrupt() 方法的主要区别是什么呢?
stop() 方法会真的杀死线程,不给线程喘息的机会,如果线程持有 ReentrantLock 锁,被 stop() 的线程并不会自动调用 ReentrantLock 的 unlock() 去释放锁,那其他线程就再也没机会获得 ReentrantLock 锁。,interrupt() 方法仅仅是通知线程,线程有机会执行一些后续操作。
10 | Java线程(中):创建多少线程才是合适的?
度量性能的指标:延迟和吞吐量。延迟指的是发出请求到收到响应这个过程的时间;吞吐量指的是在单位时间内能处理请求的数量;
在并发编程领域,提升性能本质上就是提升硬件的利用率,再具体点来说,就是提升 I/O 的利用率和 CPU 的利用率。
最佳线程数 =CPU 核数 * [ 1 +(I/O 耗时 / CPU 耗时)]
11 | Java线程(下):为什么局部变量是线程安全的?
局部变量就是放到了调用栈里
每个线程都有自己独立的调用栈。
方法里的局部变量,因为不会和其他线程共享–线程封闭.
12 | 如何用面向对象思想写好并发程序?
如何才能用面向对象思想写好并发程序呢?结合我自己的工作经验来看,我觉得你可以从封装共享变量、识别共享变量间的约束条件和制定并发访问策略这三个方面下手。
一、封装共享变量
将共享变量作为对象属性封装在内部,对所有公共方法制定并发访问策略。
对于不会发生变化的共享变量,建议你用 final 关键字来修饰。
二、识别共享变量间的约束条件
识别共享变量间的约束条件非常重要。因为这些约束条件,决定了并发访问策略。
共享变量之间的约束条件,反映在代码里,基本上都会有 if 语句,所以,一定要特别注意竞态条件。
举个🌰
三、制定并发访问策略
避免共享:避免共享的技术主要是利于线程本地存储以及为每个任务分配独立的线程。不变模式:这个在 Java 领域应用的很少,但在其他领域却有着广泛的应用,例如 Actor 模式、CSP 模式以及函数式编程的基础都是不变模式。管程及其他同步工具:Java 领域万能的解决方案是管程,但是对于很多特定场景,使用 Java 并发包提供的读写锁、并发容器等同步工具会更好。