一、线程的状态

Java 给线程引入了六种状态:

  1. NEW:创建了 Thread 对象,但是还没调用 start()
  2. TERMINATED:操作系统内部的线程已经销毁了,但 Thread 对象还在,线程的入口方法执行完毕。
  3. RUNNABLE:可工作的,又可以分成正在工作中和即将开始工作。

接下来的三个状态表示排队等着其他事情:

  1. WAITING:进入阻塞状态,然后死等。
  2. TIMED_WAITING:带有超时时间的阻塞等待。
  3. BLOCKED:特指由于锁引起的阻塞。

二、线程安全问题

先来看一个线程不安全的案例:

线程不安全代码示例

运行之后:

运行结果

两个线程分别对 count 变量都进行 50000 次的 count++,预计结果应该是 100000 才对,可是结果却是 71783,而且再多运行几次会发现这个数值是不确定的、随机性的,由此便引入线程安全问题。

1. 线程安全概念

给出一个线程安全的确切定义是比较复杂的,但我们可以这样认为:

某个逻辑,单个线程执行没有问题,但是多个线程执行出现问题,这便是线程安全问题。

上述的逻辑,如果单线程执行,那毫无疑问,最终的结果一定是 100000。

2. 线程安全问题的原因

  1. 根本原因:操作系统对于线程的调度是随机的。
  2. 两个线程针对同一个变量进行修改操作。
  3. 修改操作不是原子性的。
  4. 内存可见性。
  5. 指令重排序。

分析上述代码,count++ 这行代码对应了三个 CPU 的指令:

  1. load:把内存中的数据加载到寄存器中。
  2. add:把寄存器中的数据 +1。
  3. save:把寄存器中的数据写回到内存中。

但是由于调度顺序是不确定的,因此会有无数种情况,这里随机列举四种情况:

线程调度示例图

3. 如何解决线程安全问题

我们主要从上述提到的“修改操作不是原子的”这个原因下手。

没有保证原子性,会给多线程带来什么问题?

如果一个线程正在对一个变量操作,中途其他线程插入进来了,若这个操作被打断了,那么结果就可能是错误的。由此,便引入了“锁”,从而把修改操作变成原子性的。

4. synchronized 关键字

Java 中引入 synchronized 关键字,其语法格式为:

synchronized(object) {
    // 一系列代码;
}

synchronized 会起到互斥(两件事情不可能同时发生)效果,若某个线程执行到某个对象的 synchronized 中时,其他线程如果也执行到同一个对象 synchronized,就会阻塞等待。

  • 进入 synchronized 修饰的代码块,相当于 “加锁”
  • 退出 synchronized 修饰的代码块,相当于 “解锁”

注意:

  1. 此处“加锁”不是禁止线程调度,而是防止其他线程插队。
  2. synchronized() 括号里的对象,从语法角度上来讲,可以是任意的对象,只要是一个 Object 子类的实例就行;但是从语义角度上来讲,两个线程要填写相同的对象,才会产生锁竞争,才会有阻塞/防止插队这样的效果。

下面来看这个示例流程(lock 表示“加锁”,unlock 表示“解锁”):

synchronized 示例流程

其实可以用生活中的上厕所来粗略理解,假设一个人进了厕所,锁上门(一个线程进行“加锁”),其他人就要在外面等待(其他线程进入阻塞等待状态),至少得等厕所里的人出来后(“解锁”),才能允许第二个人进去(第二个线程加“锁”成功,可以解除阻塞状态往下执行逻辑了)。

下面来看加“锁”后的代码:

加锁后的代码

运行后:

加锁后运行结果

这样就达到我们预期结果了。

如果把整个 for 循环都放到 synchronized 关键字里,是否会有问题呢?

for 循环加锁

运行后,依旧没问题:

for 循环加锁运行结果

其实把整个 for 循环放到 synchronized 里面,相当于就只加了一次“锁”,而只把 count++ 放到 synchronized 里面,就相当于循环的加了 50000 次“锁”。

我们说“锁”内部的逻辑少(只是一次 count++),那么“锁”的粒度就小。“锁”内部的逻辑多(50000 次的 for 循环),那么“锁”的粒度就大,注意,这并不是代码行数的问题,而是与逻辑相关。

5. synchronized 其他使用示例

5.1. 修饰普通方法

修饰普通方法

5.2. 修饰静态方法

修饰静态方法

无论哪种写法,synchronized() 针对啥对象“加锁”不重要,重要的是,两个线程是否针对同一个对象“加锁”。

6. 可重入锁

先来看一下下面的代码案例:

可重入锁代码示例

可重入锁代码示例

thread1 线程针对 count1 进行第一次“加锁”,调用 add() 方法时,又针对 count1 进行第二次“加锁”,按照之前的逻辑,这次应该会阻塞,此时就无法继续执行完 add() 方法,也就无法执行到第一次“加锁”的“}”从而“解锁”了,那逻辑就卡住了,不就成“死锁”了吗?

实际代码一执行,是可以正常运行的:

可重入锁运行结果

其实,“死锁”的逻辑是存在的,但是 synchronized 针对这种情况做了特殊处理,这就是锁的“可重入机制”。

JVM 会记录当前持有这把锁的线程,同时维护一个锁计数器,同一个线程再次获取已经持有的锁时,不会阻塞等待,仅仅将计数器加一,每退出一层 synchronized 同步块,计数器就减一,只有计数器归零的时候,锁才真正释放,其他线程才可以获取。正是这套机制,避免了同一个线程自己阻塞自己的问题。

分析一下上述代码:

  1. 外层 synchronized(count1):拿到锁 → 计数器 = 1
  2. 进入 add,synchronized(this) 重入 → 计数器 = 2
  3. 退出 add 内同步块 → 计数器 = 1
  4. 退出外层同步块 → 计数器 = 0,锁真正释放

7. 死锁

7.1 概念

锁的可重入只能避免同一个线程重复获取同一把锁造成的自我阻塞,并不能消除死锁。当多个线程各自占有一把锁,又同时去请求对方手里的锁时,就会发生真正的死锁。

那什么是死锁呢?

多个线程互相持有对方所需要的锁,彼此无限等待对方释放锁,所有线程都被卡住,程序无法继续执行。

7.2 死锁的四个必要条件(必须同时满足)
  1. 互斥条件:锁资源同一时刻只能被一个线程占用。
  2. 不可剥夺条件:锁只能由持有线程主动释放,其他线程不能强行抢占。
  3. 请求并保持:线程持有一把锁,在不释放已有锁的情况下,再去申请其他锁。
  4. 循环等待条件:线程之间形成环状等待关系,A 等 B、B 等 A。

只要破坏其中任意一条必要条件,就可以规避死锁。

7.3 死锁的代码案例

死锁代码示例

运行后:

死锁运行结果

程序不会终止,后续打印语句永远不会执行,程序陷入停滞,发生死锁。

案例流程分析:

  1. thread1 获取 locker1,休眠 1 秒,不释放 locker1,接着申请 locker2。
  2. thread2 获取 locker2,休眠 1 秒,不释放 locker2,接着申请 locker1。
  3. thread1 在等待 thread2 释放 locker2;thread2 在等待 thread1 释放 locker1。
  4. 双方都不释放已占有的锁,互相无限等待,程序卡死,产生死锁。

这个案例同时满足死锁的四个必要条件:互斥条件、不可剥夺条件、请求并保持、循环等待。

注意区分:“可重入锁”解决的是同一个线程重复申请同一把锁,无法避免这种多线程之间的死锁。

补充小提示:代码里 sleep 不会释放锁!sleep 只是让线程休眠,锁依旧握在线程手上。

7.4 如何规避死锁

死锁的发生需要四个必要条件同时满足,只要破坏其中任意一个条件,就可以避免死锁。实际开发中重点关注打破“请求并保持”与打破“循环等待”。

1. 打破“请求并保持”

产生问题:线程已经持有一部分锁,不进行释放,又去申请其他新的锁。

解决办法:

  1. 一次性申请所有需要的锁,全部锁获取成功之后,再执行业务逻辑;不要拿到一把锁之后,中途再去申请另一把锁。
  2. 如果申请不到新锁,就主动释放当前已经持有的锁,之后再重新尝试获取锁。

2. 打破“循环等待”

产生问题:多个线程之间形成环形等待关系,互相等待对方手中的锁。

解决办法:

给所有锁规定统一的获取顺序,所有线程严格按照相同的先后顺序申请锁,切断循环等待环路。

举例:规定永远先获取 locker1,再获取 locker2。thread1、thread2 都遵守该顺序,就不会出现一个持有 locker1 等 locker2,另一个持有 locker2 等 locker1 的情况。

8. volatile 关键字

8.1 内存可见性

synchronized 关键字是解决线程安全问题的一种方案,但是还有一种场景需要通过 volatile 来解决。

这就要谈到内存可见性了。

Java 线程工作时,会把变量从主内存拷贝到自己的工作内存中进行操作。修改之后不会立刻刷新回主内存。当一个线程修改了共享变量的值,另一个线程看不到最新修改后的值,读到的还是旧数据,这就是内存可见性问题。

现象举例:一个线程修改标记变量,另一个线程一直在循环读取该变量,永远感知不到变量已经被修改,循环不会停止。

8.2 volatile 作用

1. 保证内存可见性

被 volatile 修饰的共享变量,一旦某个线程对它完成修改,会立刻刷新到主内存;其他线程读取时,直接从主内存读取最新值,不再使用自己缓存里的旧副本。

2. 禁止指令重排序

JVM、CPU 会对指令做优化重排序,volatile 可以阻止部分不合理的指令重排,保证代码执行顺序符合我们编写的逻辑。

重要:volatile 不能保证原子性

volatile 只解决“可见性、禁止重排序”,不能保证操作是原子的。例如 count++,读取-修改-写入多步操作,即便加了 volatile,多线程下依旧会出现线程安全问题,这种场景仍然需要 synchronized。

8.3 volatile 和 synchronized 的简单对比
  • volatile:轻量级,只保证可见性、禁止指令重排序,不保证原子性;适合标记位、状态开关这类场景。
  • synchronized:重量级锁,同时保证可见性、原子性、禁止指令重排序,会阻塞线程。
8.4 代码案例

来看下面这个代码案例:

volatile 代码示例

运行之后:

volatile 运行结果

现象:

  • thread1 一直在执行 while(flag == 0) 循环;
  • thread2 通过键盘输入,把 flag 修改为非 0 值,thread2 打印结束。
  • 但是 thread1 不会退出循环,不会打印“thread1 结束”,程序卡死。

原因(内存可见性问题):

  1. flag 是共享变量。thread1 把 flag 拷贝到自己的工作内存,一直读取缓存里旧值 0。
  2. thread2 修改 flag,修改写回主内存。
  3. 但是 thread1 不知道主内存的 flag 已经变化,不去刷新自己的工作内存,一直读到旧数据 0,循环永远跑下去。

解决办法:

给共享变量 flag 加上 volatile 修饰:

volatile 修饰 flag

加上 volatile 之后:

  • thread2 修改 flag,会立刻刷新到主内存;
  • thread1 不会再使用本地缓存副本,每次都去主内存读取最新的 flag;
  • 输入非 0 后,while 条件不成立,thread1 正常退出循环,打印结束。

volatile 解决后运行结果

9. wait() 和 notify()

由于线程是抢占式执行,线程之间执行的先后顺序是难以预知的。但是在实际开发中,有时我们希望可以人为协调多个线程的执行先后顺序。

完成线程之间的协调工作,主要依靠三个方法:wait()、notify()、notifyAll()。

注意:这三个方法是属于 Object 对象的方法,不是 Thread 的方法;并且必须写在 synchronized 同步代码块内部调用。

9.1 wait() 方法

调用 wait() 会做三件事:

  1. 让当前线程进入等待状态,暂时停止往下执行;
  2. 释放当前持有的锁(非常关键,别的线程才可以拿到这把锁执行代码);
  3. 当线程被唤醒之后,不会立刻继续执行,而是要重新去竞争获取锁,拿到锁之后,才会从 wait 处继续往下运行。

结束等待(被唤醒)的几种条件:

  1. 其他线程调用该对象的 notify() 方法,唤醒等待在这个对象上的线程;
  2. 如果调用带超时时间的 wait(long timeout),等待时间超时,会自动唤醒;
  3. 线程被 interrupt() 中断,会抛出 InterruptedException 异常,结束等待。

简单说明:

注意:wait() 一定要在 synchronized 保护的代码块中调用,在锁对象上调用 wait()。如果没有持有锁就直接调用 wait,程序会直接抛出 IllegalMonitorStateException 异常。

9.2 notify() 方法

notify() 用来唤醒调用 wait() 进入等待的线程。

作用:唤醒一个正在该对象上等待的线程。

注意:

  1. 如果有多个线程在该对象上等待,会随机唤醒其中某一个线程。
  2. 调用 notify() 之后,不会立刻释放锁;要等到当前 synchronized 代码块执行完毕,退出同步块之后,锁才会释放,被唤醒的线程才可以去竞争获取锁继续执行。
  3. 如果当前没有任何线程在等待,调用 notify() 不会报错,只是什么事情都不做。
9.3 notifyAll() 方法

作用:唤醒所有在该对象上处于等待状态的线程。

唤醒之后,所有被唤醒的线程会进入锁竞争,大家互相争抢这一把锁,同一时刻也只能有一个线程拿到锁执行,其余没抢到的继续阻塞。

注意:notify() vs notifyAll() 小结

  • notify():随机唤醒一个等待线程。
  • notifyAll():唤醒全部等待线程。
9.4 代码案例

wait notify 代码示例

运行后:

wait notify 运行结果

9.5 wait() 与 sleep() 的区别(经典面试题)

1. 锁处理不同

  • wait():在等待的时候,会释放持有的锁,其他线程可以获取锁执行;
  • sleep():休眠的时候不会释放锁,如果已经拿到锁,休眠期间锁仍然被自己占有,别的线程拿不到锁。

2. 所属类型不同

  • wait():Object 的成员方法;
  • sleep():Thread 类的静态方法。

3. 使用位置要求

  • wait():必须放在 synchronized 同步代码块/同步方法中调用;
  • sleep():可以在任意地方使用,不需要加锁。

4. 唤醒方式

  • wait():可以被 notify() / notifyAll() 主动唤醒,也可以设置超时自动唤醒,也可以被 interrupt 中断;
  • sleep():时间到自动结束休眠,也可以被 interrupt() 中断抛出异常,不能被 notify 唤醒。

实际上,sleep 只是单纯让线程歇一会;wait 是用来做线程之间条件协调通信的。

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐