01 可见性、原子性和有序性问题(出现诡异问题的根源)
1.1 缓存导致的可见性问题?
思考方向:CPU 和 内存之间还存在 CPU 缓存,假如 CPU 的两个核上分别有两个线程同时读入内存中的共享变量值,分别进行计算,再刷入内存时会无法得到正确结果
1.2 线程切换带来的原子性问题?
思考方向:CPU中的单个核,在一个时间片内只有一个线程在执行,一些我们直觉上是原子操作的高级语言代码相对于CPU来说可能都不是原子操作,都有可能在执行时发生任务切换(一般指线程切换)。CPU 能保证的原子操作是 CPU 指令级别的:
- 指令 1:首先,需要把变量 count 从内存加载到 CPU 的寄存器;
- 指令 2:之后,在寄存器中执行 +1 操作;
- 指令 3:最后,将结果写入内存(缓存机制导致可能写入的是 CPU 缓存而不是内存)。
1.3 编译优化带来的有序性问题
思考方向:编译器在编译时会进行指令重排
public class Singleton {private volatile static Singleton instance;private Singleton() { }public static Singleton getInstance(){if (instance == null) {synchronized(Singleton.class) {if (instance == null)instance = new Singleton();}}return instance;}}
例如:上面的一段代码中的 new 操作,我们以为的 new 操作应该是:
- 分配一块内存 M;
- 在内存 M 上初始化 Singleton 对象;
- 然后 M 的地址赋值给 instance 变量。
但是实际上优化后的执行路径却是这样的:
- 配一块内存 M;
- 将 M 的地址赋值给 instance 变量;
- 最后在内存 M 上初始化 Singleton 对象。
如果没有volatile关键字那么可能会出现上面的指令重排,这样当线程1进入临界区时,已经将内存地址赋值给了 instance 变量,这样线程2执行临界区外的 if 语句时,就会直接返回 instance 变量了,但是此时对象并没初始化完成,线程2在使用时就可能触发空指针异常。
02 Java内存模型-volatile和happen-before规则(解决可见性和有序性)
通过 volatile 和七项 happen-before 规则+一个特性解决可见性和有序性。
2.1 volatile 关键字
volatile 可以禁止指令重排,例如 1.3 中若在 instance 变量再加一个 volatile 修饰,则可以保证 new 操作是按照我们预期的顺序执行的;
volatile 修饰的变量,还相当于告诉编译器,对这个变量的读写,不能使用CPU缓存,必须从内存中读取或者写入。不过单独看这一项会有一定的困惑,还需结合 happen-before 规则,例如下面的代码在 jdk 1.5 时 x 可能是42也可能是0,但在1.5以上就是42,原因就是通过 happen-before 规则约束了编译器的优化:
class VolatileExample {int x = 0;volatile boolean v = false;public void writer() {x = 42;v = true;}public void reader() {if (v == true) {// 这里x会是多少呢?}}}
2.2 七项 happen-before 原则+一个特性
- 传递性:这条规则是指如果A Happens-Before B,且B Happens-Before C,那么A Happens-Before C。
- 程序次序规则:在一个线程内,按照程序代码顺序,书写在前面的操作 Happens-Before 于书写在后面的操作。准确地说,应该是控制流顺序而不是程序代码顺序,因为要考虑分支、循环等结构。
- volatile 变量规则:对一个 volatile 变量的写操作 Happens-Before 于后续对这个 volatile 变量的读操作,这
里的”后续”是指时间上的先后顺序。 - 管程锁定规则:这条规则是指对一个锁的解锁 Happens-Before 于后续对这个锁的加锁。这里必须强调的是同一个锁,而”后续”是指时间上的先后顺序。
- 线程 start() 规则:主线程A启动子线程B后,子线程B能够看到主线程在启动子线程B前的操作。
- 线程 join() 规则:主线程A等待子线程B完成(主线程A通过调用子线程B的 join() 方法实现),当子线程B完成后(主线程A中join()方法返回),主线程能够看到子线程的操作。当然所谓的“看到”,指的是对共享变量的操作。
- 线程中断规则:对线程 interrupt() 方法的调用 Happens-Before 于被中断线程的代码检测到中断事件的发生,可以通过Thread.interrupted()方法检测到是否有中断发生。
对象终结规则:一个对象的初始化完成(构造函数执行结束) Happens-Before 于它的 finalize() 方法的开始。
2.3 final 关键字
在1.5以后Java内存模型对final类型变量的重排进行了约束。现在只要我们提供正确构造函数没有“逸出”,就不会出问题了。“逸出”有点抽象,我们还是举个例子吧,在下面例子中,在构造函数里面将this赋值给了全局变量global.obj,这就是“逸出”,线程通过 global.obj 读取x是有可能读到0的。因此我们一定要避免“逸出”
final int x;// 错误的构造函数public FinalFieldExample() {this.x = 3;this.y = 4;// 此处就是讲this逸出,global.obj = this;}
03-04 互斥锁(解决原子性问题)
3.1 synchronied 关键字
修饰静态方法:锁定的是当前类的Class对象;
- 修饰非静态方法:锁定的是当前实例对象this;
- 修饰代码块 :指定加锁对象,对给定对象/类加锁;
3.2 锁和受保护资源的关系
锁和受保护资源的一个合理的关系是:受保护资源和锁之间的关联关系是N:1的关系。同时如果资源之间存在一定的关联关系,此时需要注意不要出现只对其中一个资源加锁的情况:class Account {private int balance;// 转账synchronized void transfer(Account target, int amt){if (this.balance > amt) {this.balance -= amt;target.balance += amt;}}}

05:引发死锁的原因和解决办法
当多个受保护资源存在一定的关联关系时,最简单的办法就是通过同一把锁把所有资源都锁住,但是这种粗粒度锁可能会导致原本并行的操作串行化效率很低。因此,一般会使用细粒度锁,每个受保护资源一个锁可以提高并行度,但细粒度锁也可能导致死锁(一组互相竞争资源的线程因互相等待,导致“永久”阻塞的现象)。例如如下代码:class Account {private int balance;// 转账void transfer(Account target, int amt){// 锁定转出账户synchronized(this){ ①// 锁定转入账户synchronized(target){ ②if (this.balance > amt) {this.balance -= amt;target.balance += amt;}}}}}
5.1 预防死锁
有个叫Coffman的牛人早就总结过了,只有以下这四个条件都发生时才会出现死锁:
- 互斥:共享资源X和Y只能被一个线程占用;
- 占有且等待:线程T1已经取得共享资源X,在等待共享资源Y的时候,不释放共享资源X;
- 不可抢占:其他线程不能强行抢占线程T1占有的资源;
- 循环等待:线程T1等待线程T2占有的资源,线程T2等待线程T1占有的资源,就是循环等待。
因此,只要我们破坏其中一个,就可以成功避免死锁的发生:
- 对于“占用且等待”这个条件,我们可以一次性申请所有的资源,这样就不存在等待了。
- 对于“不可抢占”这个条件,占用部分资源的线程进一步申请其他资源时,如果申请不到,可以主动释放它占有的资源,这样不可抢占这个条件就破坏掉了。(synchronied 关键字做不到,但是 Lock 可以)
对于“循环等待”这个条件,可以靠按序申请资源来预防。所谓按序申请,是指资源是有线性顺序的,申请的时候可以先申请资源序号小的,再申请资源序号大的,这样线性化后自然就不存在循环了。
06:synchronized 等待-通知机制
synchronized配合 Object 的实例方法wait()、notify()、notifyAll()这三个方法就能轻松实现。这三个方法操作的等待队列是互斥锁的等待队列,所以如果 synchronized 锁定的是 this,那么对应的一定是 this.wait()、this.notify()、this.notifyAll();如果 synchronized 锁定的是 target,那么对应的一定是 target.wait()、target.notify()、target.notifyAll() 。而且 wait()、notify()、notifyAll() 能够被调用的前提是已经获取了相应的互斥锁,所以我们会发现 wait()、notify()、notifyAll() 都是在 synchronized{} 内部被调用的。如果在 synchronized{} 外部调用,或者锁定的 this,而用 target.wait() 调用的话,JVM会抛出一个运行时异常:java.lang.IllegalMonitorStateException;

notify() 和 notifyAll() 方法被调用后,会通知等待队列(互斥锁的等待队列)中的线程,告诉它条件曾经满足过。被通知的线程要想重新执行,仍然需要获取到互斥锁。为了解决“条件曾经满足过”这个问题,可以采用以下范式:while(条件不满足) {<br /> wait();<br />}
notify() 是会随机地通知等待队列中的一个线程,而 notifyAll() 会通知等待队列中的所有线程。一般建议使用 notifyAll(),notify() 可能导致某些线程永远不会被通知到。
07:安全性、活跃性以及性能(宏观角度重新审视并发编程相关概念和理论)
7.1 安全性问题 - 数据竞争和竞态条件
当多个线程同时访问同一数据,并且至少有一个线程会写这个数据的时候,如果我们不采取防护措施,那么就会导致并发Bug,对此还有一个专业的术语,叫做数据竞争(Data Race)。有些情况可以通过简单的加锁解决,但是下面的代码 set 和 get 都是线程安全的方法,组合在一起使用却出现了并发问题。
public class Test { private long count = 0; synchronized long get(){ return count; } synchronized void set(long v){ count = v; } void add10K() { int idx = 0; while(idx++ < 10000) { set(get()+1) // 出现了竞态条件 } } }这种问题,有个官方的称呼竞态条件(Race Condition),指的是程序的执行结果依赖线程执行的顺序。 例如上面的例子,如果两个线程完全同时执行,那么结果是1;如果两个线程是前后执行,那么结果就是2。在并发环境里,线程的执行顺序是不确定的,如果程序存在竞态条件问题,那就意味着程序执行的结果是不确定的,而执行结果不确定这可是个大Bug。
也可以按照下面这样来理解竞态条件。在并发场景中,程序的执行依赖于某个状态变量,也就是类似于下面这样:
if (状态变量 满足 执行条件) {执行操作
}7.2 活跃性问题 - 死锁、活锁和饥饿
发生“死锁”后线程会互相等待,而且会一直等待下去,技术上表现形式是线程永久地“阻塞”了 ;
- 有时线程虽然没有发生阻塞,但仍然会存在执行不下去的情况,这就是所谓的“活锁”;
所谓“饥饿”指的是线程因无法访问所需资源而无法执行下去的情况 ;
7.3 性能问题
所以我们要尽量减少串行,那串行对性能的影响是怎么样的呢?假设串行百分比是5%,我们用多核多线程相比单核单线程能提速多少呢?有个阿姆达尔(Amdahl)定律,代表了处理器并行运算之后效率提升的能力,它正好可以解决这个问题,具体公式如下:
08 | 管程:并发编程的万能钥匙
synchronized 关键字详解
- 再谈 synchronized 锁升级
偏向锁通过对比Mark Word解决加锁问题,避免执行CAS操作。
轻量级锁是通过用CAS操作和自旋来解决加锁问题,避免线程阻塞和唤醒而影响性能。
重量级锁是将除了拥有锁的线程以外的线程都阻塞
8.1 什么是管程
Java 中的 synchronized 采用的是管程技术(Monitor)解决并发问题 wait()、notify()、notifyAll() 这三个方法都是管程的组成部分。所谓管程,指的是管理共享变量以及对共享变量的操作过程,让他们支持并发。翻译为Java领域的语言,就是管理类的成员变量和成员方法,让这个类是线程安全的。 synchronized 关键字修饰的代码块,在编译期会自动生成相关加锁和解锁的代码,但是仅支持一个条件变量;而Java SDK并发包实现的管程支持多个条件变量,不过并发包里的锁,需要开发人员自己进行加锁和解锁操作。
8.2 MESA 模型
在管程模型里,共享变量和对共享变量的操作是被封装起来的,图中最外层的框就代表封装的意思。框的上面只有一个入口,并且在入口旁边还有一个入口等待队列。当多个线程同时试图进入管程内部时,只允许一个线程进入,其他线程则在入口等待队列中等待。
管程里还引入了条件变量的概念,而且每个条件变量都对应有一个等待队列,如下图,条件变量A和条件变量B分别都有自己的等待队列。
public class BlockedQueue<T>{
final Lock lock = new ReentrantLock();
// 条件变量:队列不满
final Condition notFull = lock.newCondition();
// 条件变量:队列不空
final Condition notEmpty = lock.newCondition();
// 入队
void enq(T x) {
lock.lock();
try {
while (队列已满){
// 等待队列不满
notFull.await();
}
// 省略入队操作...
//入队后,通知可出队
notEmpty.signal();
}finally {
lock.unlock();
}
}
// 出队
void deq(){
lock.lock();
try {
while (队列已空){
// 等待队列不空
notEmpty.await();
}
// 省略出队操作...
//出队后,通知可入队
notFull.signal();
}finally {
lock.unlock();
}
}
}
8.3 wait()的正确姿势
但是有一点,需要再次提醒,对于MESA管程来说,有一个编程范式,就是需要在一个while循环里面调用wait()。这个是MESA管程特有的。while(条件不满足) {
wait();}
Hasen模型、Hoare模型和MESA模型的一个核心区别就是当条件满足后,如何通知相关线程。管程要求同一时刻只允许一个线程执行,那当线程T2的操作使线程T1等待的条件满足时,T1和T2究竟谁可以执行呢?
- 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线程的生命周期
Java语言中线程共有六种状态,分别是:
NEW(初始化状态)
- RUNNABLE(可运行/运行状态)
- BLOCKED(阻塞状态)
- WAITING(无时限等待)
- TIMED_WAITING(有时限等待)
- TERMINATED(终止状态)
9.1 RUNNABLE 与 BLOCKED 的状态转换
只有线程等待 synchronized 隐式锁会触发。synchronized 修饰的方法、代码块同一时刻只允许一个线程执行,其他线程只能等待,这种情况下,等待的线程就会从 RUNNABLE 转换到 BLOCKED 状态。而当等待的线程获得 synchronized 隐式锁时,就又会从 BLOCKED 转换到 RUNNABLE 状态。
JVM 层面并不关心操作系统调度相关的状态,因为在 JVM 看来,等待 CPU 使用权(操作系统层面此时处于可执行状态)与等待 I/O(操作系统层面此时处于休眠状态)没有区别,都是在等待某个资源,所以都归入了 RUNNABLE 状态。而我们平时所谓的 Java 在调用阻塞式 API 时,线程会阻塞,指的是操作系统线程的状态,并不是 Java 线程的状态。
9.2 RUNNABLE 与 WAITING 的状态转换
总体来说,有三种场景会触发这种转换。
- 第一种场景,获得 synchronized 隐式锁的线程,调用无参数的 Object.wait() 方法。
- 第二种场景,调用无参数的 Thread.join() 方法。其中的 join() 是一种线程同步方法,例如有一个线程对象 thread A,当调用 A.join() 的时候,执行这条语句的线程会等待 thread A 执行完,而等待中的这个线程,其状态会从RUNNABLE 转换到 WAITING。当线程 thread A 执行完,原来等待它的线程又会从 WAITING 状态转换到 RUNNABLE。
第三种场景,调用 LockSupport.park() 方法。其实Java并发包中的锁,都是基于LockSupport 对象实现的。调用 LockSupport.park() 方法,当前线程会阻塞,线程的状态会从 RUNNABLE 转换到WAITING。调用 LockSupport.unpark(Thread thread) 可唤醒目标线程,目标线程的状态又会从 WAITING 状态转换到 RUNNABLE。
9.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 状态的区别,仅仅是触发条件多了超时参数。
9.4 从 NEW 到 RUNNABLE 状态
Java刚创建出来的Thread对象就是 NEW 状态,而创建Thread对象主要有两种方法:
继承Thread对象,重写run()方法。示例代码如下:
// 自定义线程对象 class MyThread extends Thread { public void run() { // 线程需要执行的代码 ...... } } // 创建线程对象 MyThread myThread = new MyThread();实现 Runnable 接口,重写run()方法,并将该实现类作为创建Thread对象的参数。示例代码如下:
// 实现Runnable接口 class Runner implements Runnable { @Override public void run() { // 线程需要执行的代码 ...... } } // 创建线程对象 Thread thread = new Thread(new Runner());NEW状态的线程,不会被操作系统调度,因此不会执行。Java线程要执行,就必须转换到RUNNABLE状态。只要调用线程对象的start()方法就可以从NEW状态转换到RUNNABLE状态了,示例代码如下:
MyThread myThread = new MyThread();// 从NEW状态转换到RUNNABLE状态myThread.start();9.5 从 RUNNABLE 到 TERMINATED 状态
线程执行完 run() 方法或执行时异常抛出,会自动转换到 TERMINATED 状态;
- 调用 stop() 方法,已经标记为@Deprecated,不建议使用;
- 调用 interrupt() 方法;
9.5.1 stop()和interrupt()方法的主要区别是什么呢?
stop() 方法会直接杀死线程,不给线程喘息的机会,如果线程持有 ReentrantLock 锁,被 stop() 的线程并不会自动调用 ReentrantLock 的 unlock() 去释放锁,那其他线程就再也没机会获得 ReentrantLock 锁。
