【Java】多线程

基本概念

进程(Process) 是计算机中的程序关于某数据集合上的一次运行活动,是系统进行资源分配和调度的基本单位,是操作系统结构的基础。 在当代面向线程设计的计算机结构中,进程是线程的容器。程序是指令、数据及其组织形式的描述,进程是程序的实体。是计算机中的程序关于某数据集合上的一次运行活动,是系统进行资源分配和调度的基本单位,是操作系统结构的基础。程序是指令、数据及其组织形式的描述,进程是程序的实体。

线程(thread) 是操作系统能够进行运算调度的最小单位。它被包含在进程之中,是进程中的实际运作单位。一条线程指的是进程中一个单一顺序的控制流,一个进程中可以并发多个线程,每条线程并行执行不同的任务。

  • 进程:指在系统中正在运行的一个应用程序;程序一旦运行就是一个进程;进程——资源分配的最小单位
  • 线程:系统分配处理器时间资源的基本单元,或者说进程之内独立执行的一个单元执行流;线程——程序执行的最小单位

举例解释进程与线程:

  • IDEA 这个应用程序启动后就是一个单独的进程
  • IDEA 里的各种语法提示、错误报警等功能都是一个个线程并发运行

并发和并行的区别:

  • 并行:同一个时间点多个线程一起执行
  • 并发:同一个时间段多个线程交替执行

创建多线程

创建多线程有四种方式:

  • 继承Thread
  • 实现Runnable接口
  • 实现Callable接口
  • 使用线程池

方式一:继承 Thread 类

  1. 创建一个继承于Thread类的子类
  2. 重写Thread类的run() --> 将此线程执行的操作声明在run()
  3. 创建Thread类的子类的对象
  4. 通过此对象调用start()
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
// 1. 创建一个继承于Thread类的子类
class MyThread extends Thread {
// 2. 重写Thread类的run()
@Override
public void run() {
for (int i = 0; i < 100; i++) {
if(i % 2 == 0){
System.out.println(Thread.currentThread().getName() + ":" + i);
}
}
}
}

public class ThreadTest {
public static void main(String[] args) {
// 3. 创建Thread类的子类的对象
MyThread t1 = new MyThread();

// 4.通过此对象调用start(): ① 启动当前线程 ② 调用当前线程的run()
t1.start();

// 问题一:我们不能通过直接调用run()的方式启动线程,还会在原线程工作,因为必须调用其代理对象。
// t1.run();
// 问题二:再启动一个线程,遍历100以内的偶数。不可以还让已经start()的线程去执行。会报IllegalThreadStateException
// t1.start();

// 我们需要重新创建一个线程的对象
MyThread t2 = new MyThread();
t2.start();

// 如下操作仍然是在 main 线程中执行的。
for (int i = 0; i < 100; i++) {
if(i % 2 == 0){
System.out.println(Thread.currentThread().getName() + ":" + i + "***********main()************");
}
}
}
}

start() 方法底层是调用 native 修饰的 start0() 方法,其调用的并非JVM中的Java程序,而是本地的C语言程序执行线程任务,因此调用 t1.start() 方法时该线程并不一定立即执行,其执行时机由CPU决定。

方式二:实现 Runnable 接口

  1. 创建一个实现了Runnable接口的类
  2. 实现类去实现Runnable中的抽象方法:run()
  3. 创建实现类的对象
  4. 将此对象作为参数传递到Thread类的构造器中,创建Thread类的对象
  5. 通过Thread类的对象调用start()
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// 1. 创建一个实现了Runnable接口的类
class MyRunnable implements Runnable {
// 2. 实现类去实现Runnable中的抽象方法:run()
@Override
public void run() {
for (int i = 0; i < 100; i++) {
if (i % 2 == 0){
System.out.println(Thread.currentThread().getName() + ":" + i);
}
}
}
}

public class MyRunnableTest {
public static void main(String[] args) {
// 3. 创建实现类的对象
MyRunnable myRunnable = new MyRunnable();
// 4. 将此对象作为参数传递到Thread类的构造器中,创建Thread类的对象
Thread t1 = new Thread(myRunnable);
t1.setName("线程1");
// 5. 通过Thread类的对象调用start(): ① 启动线程 ② 调用当前线程的run()-->调用了Runnable类型的target的run()
t1.start();

// 再启动一个线程,遍历100以内的偶数
Thread t2 = new Thread(myRunnable);
t2.setName("线程2");
t2.start();
}
}

方式三:实现 Callable 接口

Runnable相比,Callable功能更强大:

  • 相比run()方法,call() 方法可以有返回值(借助于 FutureTask 类)
  • 方法可以抛出异常
  • 支持泛型的返回值
  • 需要借助FutureTask类,比如获取返回结果

其中:

  • 第一次调用 FutureTaskget() 方法时,线程会一直阻塞直到任务结束然后获取结果;
  • 第二次调用 FutureTaskget() 方法时,将不会再次运行线程,而是直接返回结果。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
package multiThread;

import java.util.concurrent.Callable;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.FutureTask;

// 1. 创建一个实现Callable的实现类
class NumCallable implements Callable {

// 2. 实现call方法,将此线程需要执行的操作声明在call()中
@Override
public Object call() throws Exception {
int sum = 0;
for (int i = 1; i <= 100; i++){
System.out.println(i);
sum += i;
}

return sum;
}
}

public class CallableTest {
public static void main(String[] args) {
// 3. 创建Callable接口实现类的对象
NumCallable numCallable = new NumCallable();

// 4. 将此Callable接口的实现类的对象传递到FutureTask构造器中
FutureTask futureTask = new FutureTask(numCallable);

// 5. 将FutureTask类对象作为参数传递到Thread类的构造器中,创建对象并调用start()
new Thread(futureTask).start();

// 6. 获取Callable中call方法的返回值
while (!futureTask.isDone()) {
// 等待
}

try {
// get()方法一直阻塞直到任务结束然后获取结果
Object sum = futureTask.get();
} catch (InterruptedException e) {
e.printStackTrace();
} catch (ExecutionException e) {
e.printStackTrace();
}
}
}

方式四:使用线程池

经常创建和销毁、使用量特别大的资源,比如并发情况下线程对性能影响很大。思路: 提前创建好多个线程,放入池中使用时直接获取,使用完放回池中。可以避免频繁创建销毁、实现重复利用。好处:

  • 提高响应速度 (减少了创建新线程的时间)
  • 降低资源消耗 (重复利用线程池中,不需要每次都创建)
  • 便于线程管理
    • corePoolSize:核心池的大小
    • maximumPoolSize:最大线程数
    • keepAliveTime:线程没有任务时最多保持长间后会 终止
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

// 创建并使用多线程的第四种方法:使用线程池
class MyRunnable implements Runnable {
@Override
public void run() {
for (int i = 1; i <= 100; i++) {
System.out.println(Thread.currentThread().getName() + ":" + i);
}
}
}

public class ThreadPool {
public static void main(String[] args) {
// 1.调用Executors的newFixedThreadPool(),返回指定线程数量的ExecutorService
ExecutorService pool = Executors.newFixedThreadPool(10);
// 设置线程池属性
ThreadPoolExecutor service1 = (ThreadPoolExecutor) service;
service1.setCorePoolSize(15);
// 2.将Runnable实现类的对象作为形参传递给ExecutorService的submit()方法中,开启线程并执行相关的run()
pool.execute(new MyRunnable());
// pool.submit(Callable callable);
// 3.结束线程的使用
pool.shutdown();
}
}

Thread 和 Runnable 的关系

Thread类实现了Runnable接口,该方法本质上使用了静态代理的设计模式:Thread类实现了Runnable接口中的run()方法,调用Thread的run()方法时,会调用在构造器中传入的自定义类的run()方法,因此是一种静态代理的设计模式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Thread implements Runnable {
// 构造方法
public Thread(Runnable target) {
init(null, target, "Thread-" + nextThreadNum(), 0);
}

// Thread类的run()方法,其中target是构造方法中传入的自定义类
@Override
public void run() {
if (target != null) {
target.run();
}
}
}

源码

image-20220214174916270

构造方法:

image-20220214175041390

静态代理模式:

image-20220214175118234

在调用 Thread 对象的 start() 方法时,将会调用 native 修饰的 start0() 方法,其调用的并非JVM中的Java程序,而是本地的C语言程序执行线程任务,因此调用 start() 方法时该线程并不一定立即执行,其执行时机由CPU决定。

image-20220214175302113

这个新开辟的线程中将调用Thread的run()方法(本质还是调用了Runnable的run()方法)

如果在当前线程中直接调用 run() 方法,则不会开辟新线程,而是直接在当前线程中执行 run() 方法。

总结

1
2
3
4
5
6
7
8
9
class MyRunnable implements Runnable{} // 相当于被代理类
class Thread implements Runnable{} // 相当于代理类

public static void main(){
MyRunnable t = new MyRunnable(); // 创建被代理类对象
Thread thread = new Thread(t); // 创建代理类,执行start方法时会调用接口的run()方法
// 调用本地方法start0(),新开启一个线程,并在该线程中调用Thread的run()方法(本质调用了Runnable的run()方法)
thread.start();
}

开发中优先选择:实现Runnable接口的方式。原因:

  • 实现的方式没有类的单继承性的局限性
  • 实现的方式更适合来处理多个线程有共享数据的情况。

相同点:两种方式都需要重写run(),将线程要执行的逻辑声明在run()中。

设计 Thread 的目的可能是,用户只需要实现 Runnable 接口里的 run() 方法,只需要关注自己的业务,不需要在意和 JVM 本地方法相关的操作。

线程运行原理

栈与栈帧

Java Virtual Machine Stacks (Java 虚拟机栈)

我们都知道 JVM 中由堆、栈、方法区所组成,其中栈内存是给谁用的呢?其实就是线程,每个线程启动后,虚拟机就会为其分配一块栈内存。 每个栈由多个栈帧(Frame)组成,对应着每次方法调用时所占用的内存。

每个线程只能有一个活动栈帧,对应着当前正在执行的那个方法。

线程上下文切换(Thread Context Switch)

因为以下一些原因导致 cpu 不再执行当前的线程,转而执行另一个线程的代码。

  • 线程的 cpu 时间片用完
  • 垃圾回收
  • 有更高优先级的线程需要运行
  • 线程自己调用了 sleep、yield、wait、join、park、synchronized、lock 等方法

当 Context Switch 发生时,需要由操作系统保存当前线程的状态,并恢复另一个线程的状态,Java 中对应的概念就是程序计数器(Program Counter Register),它的作用是记住下一条 jvm 指令的执行地址,是线程私有的。状态包括程序计数器、虚拟机栈中每个栈帧的信息,如局部变量、操作数栈、返回地址等。Context Switch 频繁发生会影响性能。

image-20220214192614593

一个普通程序启动后会产生哪些线程

  • main
  • Attach Listener
  • Signal Dispatcher
  • Finalizer
  • Reference Handler

Thread 类中常用方法

Thread中的常用方法:

  • start():启动当前线程;调用当前线程的run()
  • run():通常需要重写Thread类中的此方法,将创建的线程要执行的操作声明在此方法中
  • yield():自己主动释放当前cpu的执行权,释放后所有线程重新竞争cpu执行权
  • join():在线程a中调用线程b的join(),此时线程a就进入阻塞状态等待线程b执行完毕。直到线程b完全执行完以后,线程a才结束阻塞状态。
  • sleep(long millitime):让当前线程“睡眠”指定的millitime毫秒。在指定的millitime毫秒时间内,当前线程是阻塞状态。会释放cpu的执行权(释放时间片),但不会释放持有的锁Object.wait() 方法会释放锁)
  • stop():已过时。当执行此方法时,强制结束当前线程。不推荐使用(会破坏同步代码块,导致锁失效与死锁。无法再恢复到停止时刻状态,而sleep()等都可以恢复)。若想停止某个线程推荐使用 interrupt() 方法
  • interrupt():打断某个线程。如果被打断线程正在 sleep/wait/join,会导致被打断的线程抛出 InterruptedException,并清除打断标记;如果打断正在运行的线程,则会设置打断标记;park 的线程被打断,也会设置打断标记
  • isAlive():判断当前线程是否存活
  • currentThread():静态方法,返回执行当前代码的线程
  • getName():获取当前线程的名字
  • setName():设置当前线程的名字

sleep()

  • 调用 sleep 会让当前线程从 Running 状态进入 Timed Waiting 状态(阻塞)
  • 其它线程可以使用 interrupt()方法打断正在睡眠的线程,被打断的线程这时就会抛出 InterruptedException异常【注意:这里打断的是正在休眠的线程,而不是其它状态的线程】
  • 睡眠结束后的线程未必会立刻得到执行(需要分配到cpu时间片)

善用 sleep() 方法解决 CPU 运行 100% 问题:在程序中的无限循环代码块 while(true) 中添加 sleep(xxx) 方法可以有效地防止 CPU 运行 100%。例如,Nacos 的服务端代码中就在服务注册功能中使用到了该技巧。


yield()

yield:屈服

  • 调用 yield() 方法会让当前线程从 Running 进入 Runnable 就绪状态,然后所有线程重新竞争cpu执行权(自己依然可能再次抢到 cpu 执行权)
  • 具体的实现依赖于操作系统的任务调度器,可能自己刚释放cpu就再次抢到控制权

注意yield() 方法和线程优先级都不是决定性因素,最终是哪个线程抢到资源还是看操作系统的线程任务调度器。

yield() 方法使得线程从运行状态变为就绪状态(可以立即抢cpu控制权),而 sleep()方法使得线程从运行状态变为阻塞状态

当线程进入 IO 阻塞状态时,操作系统也会收回其 cpu 执行权。

join()

join() 方法是保护性暂停模式的一种实现。常使用 join() 方法实现线程间的同步协作。例如主线程需要等待其余线程都执行完毕才能继续执行

在线程a中调用线程b的join(),此时线程a就进入阻塞状态等待线程b执行完毕。直到线程b完全执行完以后,线程a才结束阻塞状态。

join() 方法属于比较底层的 API。一旦使用 join(),就必须等待指定线程完全执行完毕才可以继续执行。如果想实现b达到某个条件后a就可以继续执行,则可以使用更高级的 API 实现线程间的同步协作,例如 CountDownLatch

1
2
3
4
5
6
7
8
9
10
11
12
13
private static void test1() throws InterruptedException {
Thread t1 = new Thread(() -> {
log.debug("开始");
Thread.sleep(1000);
log.debug("结束");
r = 10;
// 全部执行完后主线程才会结束阻塞
},"t1");
t1.start();

// 阻塞在这里等待 t1 执行完毕
t1.join();
}

join() 的原理:保护性暂停模式。其原理的简化版本就是使用 wait()/notify() 机制。调用 join() 的线程一直轮询检查线程 alive 状态:

1
2
3
4
5
6
7
// 等价于下面的代码
synchronized (t1) {
// 调用者线程进入 t1 的 waitSet 等待, 直到 t1 运行结束
while (t1.isAlive()) {
t1.wait(0);
}
}

interrupt()

调用 sleep/wait 方法必须要添加 try/catch 块的原因就是其阻塞期间可能被其他线程打断

interrupt() 方法可以打断任意线程,无论是正在运行还是阻塞状态,都可以打断。

  • 阻塞中的线程被打断时会抛出异常,并设置为 true,但是一旦该异常被 catch 捕获,就会清除该标志:自动将打断标志位设置 false(因为抛出异常了就不需要再设置打断标志位了)
  • 正在运行的线程不会抛出异常,而是将打断标志位设置为 true。这样该线程就可以自己决定是否需要中断自己

情景一:打断阻塞中的线程

调用 sleep/wait/join 方法的线程会进入阻塞状态,此时可以在其他线程调用 interrupt() 方法打断该线程。必须使用 try/catch 块捕获可能出现的异常:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread() {
@Override
public void run() {
log.debug("线程任务执行");
try {
Thread.sleep(10000); // wait, join
} catch (InterruptedException e) {
// 必须添加try/catch块捕获可能发生的打断异常
log.debug("被打断");
}
}
};
t1.start();
Thread.sleep(500);
// 打断睡眠中的t1线程
t1.interrupt();
}

情景二:打断正常运行的线程

打断正在运行的线程不会抛出异常,而是将打断标志位设置为 true。这样该线程就可以自己决定是否需要中断自己:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
while (true) {
boolean interrupted = Thread.currentThread().isInterrupted();
// 自己决定被打断后如何执行
if (interrupted) {
log.debug("被打断了, 退出循环");
break;
}
}
}, "t1");
t1.start();
Thread.sleep(1000);
t1.interrupt();
}

sleep/yield/join/wait 对比

方法来源:

  • sleep/join/yieldThread 类中的方法
  • wait/notifyObject 中的方法

是否释放 cpu 时间片和锁:

  • sleep():释放 cpu 时间片、不释放锁、进入阻塞状态
  • yield():释放 cpu 时间片、不释放锁、进入就绪状态
  • join():释放 cpu 时间片、释放锁、进入阻塞状态
  • wait():释放 cpu 时间片、释放锁、进入阻塞状态

线程优先级

线程的优先级:

  • MAX_PRIORITY:10
  • MIN_PRIORITY:1
  • NORM_PRIORITY:5 --> 默认优先级

如何获取和设置当前线程的优先级:

  • getPriority():获取线程的优先级
  • setPriority(int p):设置线程的优先级

说明:高优先级的线程要抢占低优先级线程cpu的执行权。但是只是从概率上讲,高优先级的线程高概率的情况下被执行。并不意味着只有当高优先级的线程执行完以后,低优先级的线程才执行。

如果 cpu 比较忙,那么优先级高的线程会获得更多的时间片,但 cpu 空闲时,优先级几乎没作用

当调用 notify() 唤醒线程时,会优先唤醒优先级高的线程。如果优先级一样就随机唤醒。

线程状态

线程的生命周期

image-20210615154527649

只有 yield() 方法可以主动进入就绪状态,其余都是进入阻塞状态。

线程状态之五种状态

线程的五种状态主要是从操作系统的层面进行划分的:

image-20220214210005243

线程的五种状态:

  • 初始状态:仅仅是在语言层面上创建了线程对象,即Thead thread = new Thead(),还未与操作系统的线程关联
  • 可运行状态:也称就绪状态,指该线程已经被创建,与操作系统相关联,已经拥有所需要的资源(主要是内存),等待 CPU 给它分配时间片就可运行。就绪状态的线程才能够争抢 CPU 时间片,争抢失败的继续在就绪状态等待稍后再次争抢 CPU
  • 运行状态:指线程获取了 CPU 时间片,正在运行。当 CPU 时间片用完,线程会转换至【可运行状态】,等待 CPU 再次分配时间片,会导致我们前面讲到的上下文切换
  • 阻塞状态
    • 如果调用了阻塞 API,如 BIO 读写文件,那么线程实际上不会用到 CPU,不会分配 CPU 时间片,会导致上下文切换,进入【阻塞状态】
    • 等待 BIO 操作完毕,会由操作系统唤醒阻塞的线程,转换至【可运行状态】
    • 与【可运行状态】的区别是,只要操作系统一直不唤醒线程,调度器就一直不会考虑调度它们,CPU 就一直不会分配时间片
    • 调用 Thread.sleep()wait()LockSupport.park()lock() 等方法后线程都会进入阻塞状态。直到该线程“睡眠结束、被唤醒、其他线程释放锁后”才会从阻塞状态结束进入就绪状态,开始争抢 CPU 时间片,如果争抢成功就进入运行状态否则仍然进入就绪状态等待下一次 CPU 时间片的争抢
  • 终止状态:表示线程已经执行完毕,生命周期已经结束,不会再转换为其它状态、、

就绪状态和阻塞状态的区别:

  • 就绪状态的线程随时可以进行争抢 CPU 时间片
  • 阻塞状态的线程必须等待阻塞结束才可以进入就绪状态。进入阻塞状态的原因:
    • IO 阻塞
    • 调用 Thread.sleep()wait()LockSupport.park() 等方法令自身进入等待状态
    • 其他线程先占用了锁,导致自己只能进入阻塞状态等待其释放锁
  • 阻塞状态的线程只有等到“睡眠结束、被唤醒、其他线程释放锁后”才会从阻塞状态结束进入就绪状态

线程状态之六种状态

线程的六种状态是从 Java API 层面来描述的,这样划分是为了更好区分不同 API 调用后的具体线程状态(例如是等待还是阻塞还是超时等待):

image-20220214210344478

  • NEW 跟五种状态里的初始状态是一个意思
  • RUNNABLE 是当调用了 start() 方法之后的状态。表示该线程已经在 JVM 中执行,当然由于执行需要计算资源,它可能是正在运行也可能还在等待系统分配给它 CPU 片段,在就绪队列里面排队(并非被锁阻塞)。注意,Java API 层面的 RUNNABLE 状态涵盖了操作系统层面的**【可运行状态】【运行状态】【IO 导致的阻塞状态】**(由于 BIO 导致的线程阻塞,在 Java 里无法区分,仍然认为是可运行)。注意只包含 IO 导致的阻塞,而不包含 wait/sleep 等造成的阻塞
  • TERMINATED 代表当线程代码运行结束
  • BLOCKEDWAITINGTIMED_WAITING 都是 Java API 层面对【阻塞状态】的细分。以 synchronized 锁情况下为例:
    • BLOCKED:进入 Monitor 的阻塞队列 EntryList(竞争 cpu 失败的线程都会进入 BLOCKED 状态,等待下一次争抢 cpu 时间片)
    • WAITING:进入 Monitor 的等待队列 WaitSet(被唤醒后如果竞争锁失败将进入 BLOCKED 状态)
    • TIMED_WAITING:进入 Monitor 的等待队列 WaitSet,带超时时间,超出时间后自动被唤醒

注意:就绪状态是在 RUNNABLE 状态内的,代表有资格竞争 CPU 时间片,而没有被锁阻塞或主动sleep

重要BLOCKEDWAITING/TIME_WAITING 的区别:

  • BLOCKED 是因为线程竞争锁失败导致的阻塞
  • WAITING/TIME_WAITING 是因为线程调用Thread.sleep()wait()LockSupport.park() 等方法导致的阻塞

一个是因为竞争锁失败,另一个则是主动调用阻塞等待方法。

img

三者与 RUNNABLE 状态的具体转换:

RUNNABLE <–> BLOCKED

竞争 CPU 失败的是 RUNNABLE,而不是 BLOCKED

竞争锁失败的线程都会进入 BLOCKED 状态:

  • t 线程用 synchronized(obj) 获取了对象锁时,如果竞争失败,从RUNNABLE --> BLOCKED
  • 持 obj 锁线程的同步代码块执行完毕,会唤醒该对象上所有 BLOCKED 的线程重新竞争,如果其中 t 线程竞争
    成功,从 BLOCKED --> RUNNABLE,其它失败的线程仍然 BLOCKED

线程的竞争结果决定了 BLOCKED 状态是否能转换为 RUNNABLE 状态

  • 如果竞争成功,就可以转为 RUNNABLE 状态
  • 如果竞争失败,仍然处于 BLOCKED 状态

竞争锁失败的线程都会进入 BLOCKED 状态,阻塞等待直到持有锁的线程释放锁,然后再一起竞争锁,失败仍然 BLOCKED,成功的进入 RUNNABLE

注意:竞争 CPU 失败和竞争锁失败导致的结果是不同的:

  • 竞争 CPU 失败的线程是 RUNNABLE 状态,其并没有被阻塞,随时可能竞争下一次的 CPU
  • 竞争锁失败的线程是 BLOCKED 状态,因为其必须等待锁被释放了才能尝试获取锁,如果获取锁失败了仍然是 BLOCKED,获取锁成功了才是 RUNNABLE

RUNNABLE <–> WAITING

情况一:线程用 synchronized(obj) 获取了对象锁后:

  • 调用 obj.wait() 方法时,t 线程从 RUNNABLE --> WAITING(进入 Monitor 的等待队列 WaitSet,被唤醒如果竞争失败就将进入 BLOCKED 状态)
  • 调用 obj.notify()obj.notifyAll()t.interrupt() 时:
    • 竞争锁成功,t 线程从 WAITING --> RUNNABLE
    • 竞争锁失败,t 线程从 WAITING --> BLOCKED

情况二LockSupport 精确唤醒:

  • 当前线程调用 LockSupport.park() 方法会让当前线程从 RUNNABLE --> WAITING
  • 调用 LockSupport.unpark(目标线程) 或调用了线程的interrupt(),会让目标线程从 WAITING --> RUNNABLE(因为是精确唤醒,所以不可能竞争失败,也就不可能进入 BLOCKED 状态)

情况三join() 方法

  • 当前线程调用 t.join() 方法时,当前线程从 RUNNABLE --> WAITING。注意是当前线程在 t 线程对象的监视器上等待
  • t 线程运行结束,或调用了当前线程的 interrupt()时,当前线程从 WAITING --> RUNNABLE

RUNNABLE <–> TIMED_WAITING

TIMED_WAITING 状态都需要设置超时时间

情况一:t 线程用 synchronized(obj) 获取了对象锁后:

  • 调用 obj.wait(long n) 方法时,t 线程从 RUNNABLE --> TIMED_WAITING
  • t 线程等待时间超过了 n 毫秒,或调用 obj.notify()obj.notifyAll()t.interrupt()时:
    • 竞争锁成功,t 线程从 TIMED_WAITING --> RUNNABLE
    • 竞争锁失败,t 线程从 TIMED_WAITING --> BLOCKED

情况二join()

  1. 当前线程调用 t.join(long n) 方法时,当前线程从 RUNNABLE --> TIMED_WAITING 注意是当前线程在t 线程对象的监视器上等待
  2. 当前线程等待时间超过了 n 毫秒,或t 线程运行结束,或调用了当前线程的 interrupt() 时,当前线程从 TIMED_WAITING --> RUNNABLE

情况三sleep(long n)

  • 当前线程调用 Thread.sleep(long n),当前线程从 RUNNABLE --> TIMED_WAITING
  • 当前线程等待时间超过了 n 毫秒或调用了线程的 interrupt(),当前线程从 TIMED_WAITING --> RUNNABLE

情况四LockSupport

  • 当前线程调用 LockSupport.parkNanos(long nanos)LockSupport.parkUntil(long millis) 时,当前线程从 RUNNABLE --> TIMED_WAITING
  • 调用 LockSupport.unpark(目标线程) 或调用了线程 的 interrupt() ,或是等待超时,会让目标线程从 TIMED_WAITING--> RUNNABLE

解决线程安全问题

方式一:synchronized 同步代码块

1
2
3
synchronized(同步监视器){
// 需要被同步的代码
}

操作同步代码时,只能有一个线程参与,其他线程等待。相当于是一个单线程的过程,效率低(局限性)。说明:

  1. 操作共享数据的代码,即为需要被同步的代码。不能包含代码多了,也不能包含代码少了。
  2. 共享数据:多个线程共同操作的变量。
  3. 同步监视器,俗称:任何一个类的对象,都可以充当锁

要求:多个线程必须要共用同一把锁(同步监视器)

  • 在实现Runnable接口创建多线程的方式中,我们可以考虑使用this充当同步监视器。因为Runnable类对象被多个Thread类对象所共享,所以多个线程共用同一把锁。
  • 在继承Thread类创建多线程的方式中,因为多个线程类对象本身不能共享数据,因此需要设置一个static修饰的对象作为锁,或使用反射方式xxx.class作为锁。

实现Runnable接口例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
class Window1 implements Runnable{
private int ticket = 100;

@Override
public void run() {
while(true){
synchronized (this){ //此时的this:唯一的Window1的对象,也可以用其他任意类对象作为锁
if (ticket > 0) {
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}

System.out.println(Thread.currentThread().getName() + ":卖票,票号为:" + ticket);
ticket--;
} else {
break;
}
}
}
}
}

public class WindowTest1 {
public static void main(String[] args) {
Window1 w = new Window1();

Thread t1 = new Thread(w);
Thread t2 = new Thread(w);
Thread t3 = new Thread(w);

t1.setName("窗口1");
t2.setName("窗口2");
t3.setName("窗口3");

t1.start();
t2.start();
t3.start();
}
}

若使用继承Thread类的方式,需要在类中设置一个static对象作为锁,或使用反射方式Window1.class

方式二:synchronized 同步方法

同步方法仍然涉及到同步监视器,只是不需要显示声明:

  • 非静态的同步方法(适用于实现Runnable接口),同步监视器是:this
  • 静态的同步方法(适用于继承Thread类),同步监视器是:当前类本身

非静态的同步方法(适用于实现Runnable接口):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class Window2 implements Runnable {
private int ticket = 100;

@Override
public void run() {
while (true) {
// 在while循环内加锁
show();
}
}
// 同步监视器:this
private synchronized void show(){
if (ticket > 0) {
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName() + ":卖票,票号为:" + ticket);
ticket--;
}
}
}

静态的同步方法(适用于继承Thread类):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class Window3 extends Thread {
private static int ticket = 100;

@Override
public void run() {
while (true) {
show();
}
}
// 同步监视器:Window3.class
private static synchronized void show(){

if (ticket > 0) {
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}

System.out.println(Thread.currentThread().getName() + ":卖票,票号为:" + ticket);
ticket--;
}
}
}

方式三:Lock 锁

解决线程安全问题的方式三:Lock锁 — JDK5.0新增

synchronizedLock的区别:

  • 原始构成sync是JVM层面的,底层通过monitorentermonitorexit来实现的。Lock是JDK API层面的。(sync一个enter会有两个exit,一个是正常退出,一个是异常退出)
  • 使用方法sync不需要手动释放锁(若发生异常也会自动释放),而Lock需要手动释放unlock()(通常写在finally代码块中防止发生异常无法释放同步监视器)
  • 是否可中断sync不可中断,除非抛出异常或者正常运行完成。Lock是可中断的,通过调用interrupt()方法。
  • 是否为公平锁sync只能是非公平锁,而Lock既能是公平锁,又能是非公平锁。
  • 绑定多个条件sync不能,只能随机唤醒。而Lock可以通过Condition来绑定多个条件,精确唤醒。

在高并发场景下,Lock 的性能远优于 synchronized

优先使用顺序:Lock——> 同步代码块(已经进入了方法体,分配了相应资源)——> 同步方法(在方法体之外)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
class Window implements Runnable{
private int ticket = 100;

//1.实例化ReentrantLock
private ReentrantLock lock = new ReentrantLock();

@Override
public void run() {
while(true){
try{
//2.调用锁定方法lock()
lock.lock();

if(ticket > 0){
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName() + ":售票,票号为:" + ticket);
ticket--;
}else{
break;
}
}finally {
//3.调用解锁方法:unlock()
lock.unlock();
}

}
}
}

死锁的理解:不同的线程分别占用对方需要的同步资源不放弃,都在等待对方放弃自己需要的同步资源,就形成了线程的死锁。出现死锁后,不会出现异常,不会出现提示,只是所有的线程都处于阻塞状态,无法继续。

synchronized 多种使用情况

synchronized 实现同步的基础:Java中的每一个对象都可以作为锁。具体表现为以下3种形式:

  • 对于普通同步方法,锁是当前实例对象
  • 对于静态同步方法,锁是当前类的 Class 类模板
  • 对于同步方法块,锁是 synchonized 括号里配置的对象

情况一:普通同步方法 + 调用同一个对象

一个对象中的各个普通同步方法都是互斥的,一个普通同步方法上的 synchronized的是当前类的实例对象而不仅仅是锁当前方法,同一时间只能有一个线程访问该类实例对象里的某一个普通同步方法,而不能有多个线程同时访问同一个对象的多个普通同步方法(但是可以同时访问他的普通非同步方法)。注意此时描述的是多个线程调用同一个对象里的多个普通同步方法。

举例:手机资源类中有很多不同功能的普通同步资源(普通同步方法),且该手机只有一部(一个实例对象),那么一旦某人(某线程)抢到了该手机(实例对象),那么不管他用的是哪个普通同步资源(哪个普通同步方法),其他人(其他线程)都无法再使用该手机(实例对象)里的任何普通同步资源(普通同步方法),则其他人(其他线程)可以同时听这部手机里的歌(普通非同步资源)

结论:普通同步方法上的 synchronized锁的是当前实例对象,而不仅仅是当前方法,但要注意的是,普通非同步方法并不会被锁,其仍然能正常调用。

情况二:普通同步方法 + 调用不同的对象

对比情况一,如果多个线程调用的是不同对象的普通同步方法,则并不会互斥,因为普通同步方法上的锁只所当前实例对象,不同的实例对象上的锁不同,当然不会互斥。

情况三:普通同步方法 + 普通非同步方法

当调用的是普通方法(没有加synchronized的方法)时,并不受锁的影响。

情况四:静态同步方法 + 调用不同对象

静态同步方法锁的是整个类模板,该类的所有实例对象都不能同时访问静态同步方法。但是同一个对象的静态同步方法和普通同步方法不互斥,两个线程可以同时访问静态同步方法和普通同步方法。因为两个方法锁一个锁当前类模板,一个锁当前对象,所以并不互斥。

情况五:静态同步方法 + 普通同步方法

同一个对象的静态同步方法和普通同步方法不互斥,两个线程可以同时访问静态同步方法和普通同步方法。因为两个方法锁一个锁当前类模板,一个锁当前对象,所以并不互斥。

当一个线程试图访问同步代码块时,它首先必须得到锁,退出或抛出异常时必须释放锁。也就是说如果一个实例对象的非静态同步方法获取锁后,该实例对象的其他非静态同步方法必须等待获取锁的方法释放锁后才能获取锁,可是别的实例对象的非静态同步方法因为跟该实例对象的非静态同步方法用的是不同的锁,所以毋须等待该实例对象,已获取锁的非静态同步方法释放锁就可以获取他们自己的锁。所有的静态同步方法用的也是同一把锁——类对象本身,这两把锁是两个不同的对象,所以静态同步方法与非静态同步方法之间是不会有竞态条件的。

但是一旦一个静态同步方法获取锁后,其他的静态同步方法都必须等待该方法释放锁后才能获取锁,而不管是同一个实例对象的静态同步方法之间,还是不同的实例对象的静态同步方法之间,只要它们是同一个类的实例对象,都会互斥

死锁

两个或者两个以上线程在执行过程中,因为争夺资源而造成一种互相等待的现象。如果没有外力干涉,他们无法在执行下去。

产生死锁的原因:一个线程需要同时获取多把锁,多个这样的线程同时竞争这些锁就可能死锁。

  • 系统资源不足
  • 进程运行推进顺序不合适
  • 资源分配不当

发生死锁的四个条件:

  1. 互斥条件,线程使用的资源至少有一个不能共享的。
  2. 至少有一个线程必须持有一个资源正在等待获取一个当前被别的线程持有的资源。
  3. 资源不能被抢占。
  4. 循环等待。

img

死锁案例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
public class DeadLock {

//创建两个对象
static Object a = new Object();
static Object b = new Object();

public static void main(String[] args) {
new Thread(()->{
synchronized (a) {
System.out.println(Thread.currentThread().getName()+" 持有锁a,试图获取锁b");
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (b) {
System.out.println(Thread.currentThread().getName()+" 获取锁b");
}
}
},"A").start();

new Thread(()->{
synchronized (b) {
System.out.println(Thread.currentThread().getName()+" 持有锁b,试图获取锁a");
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (a) {
System.out.println(Thread.currentThread().getName()+" 获取锁a");
}
}
},"B").start();
}
}

验证是否是死锁

  1. jps:类似于linux中的ps -ef查看进程号
  2. jstack:自带的堆栈跟踪工具
  3. jvisualvm:Java虚拟机图形化检测器,可以查看死锁

通过用idea自带的命令行输入 jps -l,查看其编译代码的进程号后使用 jstack 进程号查看进程堆栈信息。死锁验证截图:

img

避免死锁的方案:可以使用 JUC 下的 Lock 锁,使用 tryLock() 方法尝试获取锁,若获取不到就不再加锁,从而可以避免死锁问题。可以使用该原理解决哲学家进餐问题(每人都需要左右两边都有叉子和勺子才可以进餐,使用tryLock()就可以选择放弃从而让其他人进餐。

线程间的通信

本章介绍线程间进行通信的方法。所谓线程间通信,其实就是通过睡眠与唤醒机制,使得多个线程能相互通知(唤醒),整体按照一定的先后顺序交替执行,从而达到通信的效果。

共有三种方式可以实现睡眠与唤醒机制:

  • 方式1:使用Object中的wait()方法让线程等待,使用Object中的notify()方法唤醒线程
  • 方式2:使用JUC包中Conditionawait()方法让线程等待,使用signal()方法唤醒线程
  • 方式3:使用LockSupport类的park() 阻塞当前线程,使用 unpark(xxx) 唤醒指定被阻塞的线程

其他线程间通信手段需要用到更加高级的 API,例如 CountDownLatch、CyclicBarrier 等

wait()、notify()、notifyAll()

Object 类中包含三个方法:

  • wait():一旦执行此方法,当前线程就进入睡眠状态 WAITING并释放锁,被其他线程唤醒后不会立即执行,而是阻塞等待其他线程释放锁后才会再次竞争锁,竞争成功后再从刚才睡眠的地方继续执行
  • notify():一旦执行此方法,就会随机唤醒被wait的一个线程,如果有多个线程被wait,就唤醒优先级高的线程(如果优先级一样就随机唤醒),并且本线程继续向下执行(不会阻塞也不会释放锁)。可能造成虚假唤醒
  • notifyAll():一旦执行此方法,就会唤醒所有被wait的线程,并且本线程继续向下执行(不会阻塞也不会释放锁)。可能造成虚假唤醒

注意:wait()notify()notifyAll()这三个方法:

  • 都是定义在java.lang.Object类中,所有 Java 类都拥有该方法
  • 必须使用在 synchronized 修饰的同步代码块或同步方法中
  • 三者的调用者必须是同步代码块或同步方法中的同步监视器(即 synchronized(obj) 锁住的 obj),否则会出现IllegalMonitorStateException异常。即必须获得此对象的锁,才能调用这几个方法

之所以这三个方法都是在 Object 类中,是因为所有对象 obj 都有可能成为 synchronized(obj) 锁住的同步监视器,并且其睡眠与唤醒机制是通过 JVM 底层的 Monitor 实现的(其需要和该 obj 对象绑定使用,一个 obj 对应一个 Monitor),因此 obj 对象中必须包含这三个方法,所以 JDK 设计者就将这三个方法设计到 Object 类中,这样所有的类都可以作为锁,都可以调用这三个方法进行睡眠与唤醒。关于该部分的具体原理见文章 【JUC】synchronized 原理


原理:调用 wait() 的线程会进入 Monitor等待集合 WaitSet,进入 WAITING 状态,并且释放锁 。一旦其他线程调用 notify(),就会随机从该集合中唤醒一个线程,使得其进入阻塞队列 EntryList。阻塞队列中的线程将等待调用 notify() 的线程退出同步代码块或也进入 wait() 状态(本质就是释放锁)后进行非公平竞争抢锁,抢占成功就进入 RUNNABLE 状态,否则依旧进入阻塞队列等待其他线程释放锁。

image-20220215203416267


虚假唤醒问题:wait() 方法的特点是“在哪里睡就在哪里醒”。如果wait()方法不使用在 while 循环中,则当其被其他线程意外唤醒后,可能还没满足条件就继续向下执行了,这显然是不符合逻辑的。因此某些业务条件下需要将 wait() 放在 while 循环中,每次被唤醒后都判断下资源是否满足,防止其被别人意外唤醒后,在不满足跳出阻塞条件的情况下继续向下执行

解决虚假唤醒问题的关键:

  • while 循环自旋加条件判断,满足条件再退出循环执行业务代码
  • 使用 notifyAll() 唤醒所有等待线程

解决虚假唤醒问题的方法示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 线程1
synchronized(obj) {
while (条件不成立) {
obj.wait();
// 更新条件值
}
// 退出 while 循环就说明条件满足了,可以真正执行业务了
...
}

// 线程2
synchronized(obj) {
// 唤醒所有线程。因为线程1添加了while循环不断自旋判断,所以无需担心虚假唤醒问题
obj.notifyAll();
}

这时因为线程 1 添加了 while 循环不断自旋判断,所以无需担心虚假唤醒问题。

sleep() 和 wait() 的异同

  • 相同点:一旦执行方法,都会释放 CPU 时间片,都会使得当前线程进入 WAITING 状态
  • 不同点:
    • 两个方法声明的位置不同:Thread类声明sleep()Object类中声明wait()
    • 调用的要求不同:sleep()可以在任何需要的场景下调用,wait()必须在 synchronized 中调用
    • 关于是否释放锁:sleep()不会释放锁wait()会释放锁
    • Thread.sleep(xxx) 在时间结束后才会苏醒,wait(xxx) 可以在时间结束前被其他线程唤醒

await()、signal()(lock.newCondition)

除了可以使用 Object 类中的 wait() 等方法外,还可以使用 Lock 接口提供的 Condition 接口(条件变量)进行线程通信,其好处是可以使用 condition.signal() 唤醒指定的线程(精准唤醒)

1
2
3
4
5
6
7
8
9
10
11
12
Condition condition01 = lock.newCondition();
Condition condition02 = lock.newCondition();

// 唤醒其他所有线程
condition01.signalAll();

// condition01 线程睡眠等待
condition01.await();

// ---------------------------------------
// 唤醒指定线程 condition02
condition02.signal();

使用这种方式就可以避免虚假唤醒问题,因为其可以精准唤醒目标线程。


条件变量实现原理:每个条件变量其实就对应着一个等待队列,其实现类是 ConditionObject。它是 AQS 的一个内部类,一个 lock 锁所在的 AQS 可以创建多个 ConditionObject,每个 ConditionObject 都维护一个等待队列,用于存储睡眠的线程:

image-20220216104717371

await() 流程:开始时 Thread-0 持有锁,调用 condition0.await() 时,进入 ConditionObjectaddConditionWaiter 流程:创建新的 Node 状态为 -2(Node.CONDITION),关联 Thread-0,加入等待队列尾部,进入 WAITING 状态,并且释放锁,阻塞等待被唤醒:

image-20220216103521763

signal() 流程:假设线程 Thread-1 接下来获取到了锁,并调用 codition0.signal(),则会唤醒该 Thread-0。并将该 Node 加入 AQS 双向队列的尾部,将 Thread-0 的 waitStatus 改为 0,Thread-3 的 waitStatus 改为 -1 ,代表其成功被唤醒:

image-20220216104214217


park()/unpark()(LockSupport)

除了上面两种方式外,还可以使用 LockSupport (锁支持)类来创建锁和其他同步类的基本线程阻塞原语LockSuport是 JUC 包下的一个线程阻塞工具类,所有的方法都是静态方法,并且底层使用了Unsafe类的native本地方法保证操作的原子性。

LockSupport中的 park()unpark(thread) 的作用分别是阻塞线程解除阻塞线程。其功能比wait/notifyawait/signal更强。

1
2
3
4
5
// 暂停当前线程
LockSupport.park();

// 恢复某个线程的运行
LockSupport.unpark(暂停线程对象)

原理:其使用了一种名为Permit(许可)的概念来做到阻塞和唤醒线程的功能,每个线程都有一个许可(permit)permit 只有两个值 1 和 0 ,默认是 0。可以把许可看成是一种信号量(Semaphore),但与 Semaphore 不同的是,许可的累加上限是 1

  • permit 默认是0,所以一开始调用 park() 方法,当前线程就会阻塞,直到别的线程将当前线程的 permit 设置为1时,park() 方法会被唤醒,然后会将 permit 再次设置为 0 并返回。
  • 若其他线程事先调用了 unpark(a),则 a 线程的 permit 事先就变为了 1,其后续再调用 park() 时就不会阻塞)

其底层结构:

image-20220216110323928

每一个线程都在底层 C 语言中对应一个 Parker 对象,其内的 _counter 值就是前面介绍的许可 Permit,通过 UNSAFE 类的原子性方法保证原子性修改该值,从而实现阻塞和唤醒功能。


wait()/notify()、await()/signal()、park()/unpark() 区别

  • wait()notify()Object中的方法,必须在 synchronized(obj) 中使用,睡眠的线程保存在 Monitor 的等待集合 WaitSet 中。调用顺序必须是先 wait()notify(),否则线程无法被唤醒。无法精确唤醒,可能存在虚假唤醒问题
  • await()signal()AQS 中 Condition 接口的方法。必须在reentranLock.lock()包裹的同步代码块中使用,睡眠的线程保存在 AQS 中的 ConditionObject 的等待队列中。调用顺序必须是先 await()signal(),否则线程无法被唤醒。可以精确唤醒
  • park()unpark()LockSupport中的方法。不需要在同步代码块内就可以使用调用顺序无所谓(底层使用 Pemit 机制)

总结:前面两种方式必须在同步代码块中才可以使用,并且对调用的顺序有要求。而使用 LockSupport 就没有这两个限制。

应用

应用一:两个线程交替打印 1-100 数字。

思路:使用 wait()notifyAll()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
package multiThread;

/**
* 线程通信的例子:使用两个线程交替打印1-100
*/
class AlternatePrinting implements Runnable {
private int number = 1;

@Override
public void run() {
while (true) {
synchronized (this) {
// 唤醒其他所有线程,哪怕唤醒后,因为当前资源已经被锁,唤醒后的线程也进不来
notifyAll();

if (number < 100){
try {
Thread.sleep(10);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName() + ":" + number);
number++;

try {
// 使得调用wait()方法的线程进入阻塞状态,同时释放锁
wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
} else {
break;
}
}
}
}
}

public class MultiThreadingTest {
public static void main(String[] args) {
AlternatePrinting01 r = new AlternatePrinting01();

Thread t1 = new Thread(r);
Thread t2 = new Thread(r);

t1.setName("线程1");
t2.setName("线程2");

t1.start();
t2.start();
}
}

其他写法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
public class CyclePrint {
public static void main(String[] args) {
Printer printer = new Printer(10, 0);
Thread t1 = new Thread(printer, "Thread-0");
Thread t2 = new Thread(printer, "Thread-1");
t1.start();
t2.start();
}
}

class Printer implements Runnable {
private final Object monitor = new Object();
private volatile int count;
private final int limit;

public Printer(int limit, int initCount) {
this.limit = limit;
this.count = initCount;
}

/**
* 可以改造成其他需求, 例如:交替打印 ababab
* 此时就可以根据 count++ 的值是奇数还是偶数, 决定其打印 a 还是 b
*/
@Override
public void run() {
synchronized (monitor) {
while (true) {
System.out.println(Thread.currentThread().getName() + ": " + count++);
monitor.notifyAll();
// 满足条件就可以直接结束线程
if (count >= limit) {
break;
}
// 如果还不满足就继续阻塞等待
try {
monitor.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
}

应用二:三个线程交替打印:abcabcabcabc。其中,线程 1 输出 a 5 次,线程 2 输出 b 5 次,线程 3 输出 c 5 次。

共有三种方式可以实现该需求:

  • wait() + notifyAll()
  • await() + signal()
  • park() + unpark()

方式一:wait() + notifyAll()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
public class AlternatePrinting01 {

public static void main(String[] args) {
WaitAndNotify waitAndNotify = new WaitAndNotify(1, 5);

new Thread(()->{
waitAndNotify.run("a", 1, 2);
}).start();
new Thread(()->{
waitAndNotify.run("b", 2, 3);
}).start();
new Thread(()->{
waitAndNotify.run("c", 3, 1);
}).start();
}
}

class WaitAndNotify {
private int flag;
private int loopNumber;

public WaitAndNotify(int flag, int loopNumber) {
this.flag = flag;
this.loopNumber = loopNumber;
}

public void run(String str, int flag, int nextFlag) {
for(int i = 0; i < loopNumber; i++) {
synchronized(this) {
while (flag != this.flag) {
try {
this.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
System.out.print(str);
// 设置下一个运行的线程标记
this.flag = nextFlag;
// 唤醒所有线程
this.notifyAll();
}
}
}
}

方式二:await() + signal()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
public class AlternatePrinting02 {

public static void main(String[] args) {
AwaitAndSignal lock = new AwaitAndSignal(5);
Condition a = lock.newCondition();
Condition b = lock.newCondition();
Condition c = lock.newCondition();
new Thread(() -> {
lock.run("a", a, b);
}).start();

new Thread(() -> {
lock.run("b", b, c);
}).start();

new Thread(() -> {
lock.run("c", c, a);
}).start();

try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}

lock.lock();
// 唤醒线程1,开始交替打印
try {
a.signal();
}finally {
lock.unlock();
}
}
}

class AwaitAndSignal extends ReentrantLock {
private int loopNumber;

public AwaitAndSignal(int loopNumber) {
this.loopNumber = loopNumber;
}

public void run(String str, Condition current, Condition nextCondition) {
for(int i = 0; i < loopNumber; i++) {
lock();
try {
current.await();
System.out.print(str);
nextCondition.signal();
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
unlock();
}
}
}
}

方式三:park() + unpark()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
public class AlternatePrinting03 {

public static Thread t1, t2, t3;
public static void main(String[] args) {
ParkAndUnPark obj = new ParkAndUnPark(5);
t1 = new Thread(() -> {
obj.run("a", t2);
});

t2 = new Thread(() -> {
obj.run("b", t3);
});

t3 = new Thread(() -> {
obj.run("c", t1);
});
t1.start();
t2.start();
t3.start();

// 解锁线程1,开始交替打印
LockSupport.unpark(t1);
}
}

class ParkAndUnPark {
private int loopNumber;

public ParkAndUnPark(int loopNumber) {
this.loopNumber = loopNumber;
}

public void run(String str, Thread nextThread) {
for(int i = 0; i < loopNumber; i++) {
LockSupport.park();
System.out.print(str);
LockSupport.unpark(nextThread);
}
}
}

LockSupport

线程等待和唤醒方式

3种让线程等待和唤醒的方法:

  • 方式1:使用Object中的wait()方法让线程等待,使用Object中的notify()方法唤醒线程
  • 方式2:使用JUC包中Conditionawait()方法让线程等待,使用signal()方法唤醒线程
  • 方式3:使用LockSupport类的park()unpark() 阻塞当前线程以及唤醒指定被阻塞的线程

传统的synchronizedLock实现等待唤醒通知的约束:

  • 线程先要获得并持有锁,必须在锁块(synchronizedlock)中
  • 必须要先等待后唤醒,线程才能够被唤醒

前两种方式存在的问题:

  • Object类中的wait()notify()方法必须要在同步块或者方法里面且成对出现使用,否则会抛出java.lang.IllegalMonitorStateException。并且调用顺序必须是先wait()notify(),否则线程无法被唤醒。
  • Condition类中的await()signal()方法必须要在同步块或者方法里面且成对出现使用,否则会抛出java.lang.IllegalMonitorStateException。并且调用顺序必须是先await()signal(),否则线程无法被唤醒。

第三种使用LockSupport的方式则不会出现这些问题。

LockSupport 介绍

LockSupport Java doc

LockSupport(锁支持)是用来创建锁和其他同步类的基本线程阻塞原语LockSupport中的 park()unpark(thread) 的作用分别是阻塞线程解除阻塞线程。其功能比wait/notifyawait/signal更强。

LockSuport是 JUC 包下的一个线程阻塞工具类,所有的方法都是静态方法,可以让线程在任意位置阻塞,阻塞之后也有对应的唤醒方法。归根结底,LockSupport调用的Unsafe中的native代码。

Basic thread blocking primitives for creating locks and other synchronization classes.

This class associates, with each thread that uses it, a permit (in the sense of the Semaphore class). A call to park will return immediately if the permit is available, consuming it in the process; otherwise it may block. A call to unpark makes the permit available, if it was not already available. (Unlike with Semaphores though, permits do not accumulate. There is at most one.)

LockSupport通过park()unpark(thread)方法来实现阻塞和唤醒线程的操作

其使用了一种名为Permit(许可)的概念来做到阻塞和唤醒线程的功能,每个线程都有一个许可(permit)permit只有两个值1和零,默认是零。可以把许可看成是一种信号量(Semaphore),但与Semaphore不同的是,许可的累加上限是1

LockSupport 源码

  • park() / park(Object blocker):阻塞当前线程 / 阻塞传入的具体线程。其底层使用了Unsafe类的native本地方法:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class LockSupport {

//...

public static void park() {
UNSAFE.park(false, 0L);
}

public static void park(Object blocker) {
Thread t = Thread.currentThread();
setBlocker(t, blocker);
UNSAFE.park(false, 0L);
setBlocker(t, null);
}

//...
}

permit默认是0,所以一开始调用park()方法,当前线程就会阻塞,直到别的线程将当前线程的permit设置为1时,park()方法会被唤醒,然后会将permit再次设置为0并返回(若其他线程事先调用了unpark(a),则a线程的permit事先就变为了1,其后续再调用park()时就不会阻塞)

  • unpark(Thread thread) :唤醒处于阻塞状态的指定线程
1
2
3
4
5
6
7
8
9
10
11
12
public class LockSupport {

//...

public static void unpark(Thread thread) {
if (thread != null)
UNSAFE.unpark(thread);
}

//...

}

调用unpark(thread)方法后,就会将thread线程的许可permit设置成1(注意多次调用unpark()方法,不会累加,pemit值还是1)。从而自动唤醒thead线程,即之前阻塞中的LockSupport.park()方法会立即返回。

图解流程

  1. 初始状态(还未调用 park() 时):_counter = 0
  2. 调用 Unsafe.park() 方法
    • 检查 _counter,初始时为 0。这时,获得 _mutex 互斥锁
    • 线程进入 _cond条件变量阻塞
    • 设置 _counter = 0

image-20220216105603513

  1. 调用 Unsafe.unpark(Thread_0) 方法
    • 设置 _counter为 1
    • 唤醒 _cond 条件变量中的 Thread_0
    • Thread_0 恢复运行
    • 设置 _counter为 0

image-20220216105820315

LockSupport 案例解析

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public class LockSupportDemo {

public static void main(String[] args) {
Thread a = new Thread(()->{
System.out.println(Thread.currentThread().getName() + " come in. " + System.currentTimeMillis());
LockSupport.park();
System.out.println(Thread.currentThread().getName() + " 换醒. " + System.currentTimeMillis());
}, "Thread A");
a.start();

Thread b = new Thread(()->{
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
LockSupport.unpark(a);
System.out.println(Thread.currentThread().getName()+" 通知.");
}, "Thread B");
b.start();
}
}

输出结果:

1
2
3
Thread A come in.
Thread B 通知.
Thread A 换醒.

正常 + 无锁块要求。先前错误的先唤醒后等待顺序,LockSupport可无视这顺序。

总结

LockSuport是一个线程阻塞工具类,所有的方法都是静态方法,可以让线程在任意位置阻塞,阻塞之后也有对应的唤醒方法。归根结底,LockSupport调用的Unsafe中的native代码。

LockSupport提供park()unpark()方法实现阻塞线程和解除线程阻塞的过程。LockSupport和每个使用它的线程都有一个许可(permit)关联。permit相当于1,0的开关,默认是0,调用一次unpark()就加1变成1,调用一次park()会消费permit,也就是将1变成0,同时park()立即返回。

如再次调用park()会变成阻塞(因为permit为0了会阻塞在这里,一直到permit变为1),这时调用unpark()会把permit置为1。每个线程都有一个相关的permitpermit最多只有一个,重复调用unpark()也不会积累凭证

形象的理解

线程阻塞需要消耗凭证(permit),这个凭证最多只有1个。当调用park()方法时:

  • 如果有凭证,则会直接消耗掉这个凭证然后正常退出。
  • 如果无凭证,就必须阻塞等待凭证可用。

unpark()则相反,它会增加一个凭证,但凭证最多只能有1个,累加无效。


面试题

  • 为什么LockSupport可以先唤醒线程后阻塞线程:因为unpark(a)让线程a先获得了一个凭证,之后a线程再调用park()方法时,就可以名正言顺的凭证消费,故不会阻塞。
  • 为什么LockSupport唤醒两次后阻塞两次,但最终结果还会阻塞线程:因为凭证的数量最多为1(不能累加),连续调用两次unpark()和调用一次效果一样,只会增加一个凭证;而调用两次park()却需要消费两个凭证,证不够,不能放行。

ThreadLocal

ThreadLocal 是 Java 所提供的的线程本地存储机制,可以利用该机制将数据缓存在某个线程内部ThreadLocalMap 对象内),该线程可以在任意时刻,任意方法栈位置获取缓存的数据。

ThreadLocal 底层是通过 ThreadLocalMap 实现的。每个线程对象 Thread 内部有一个 ThreadLocalMap 对象,其存储的 key:value 为"ThreadLocal 对象:需要缓存的值"。例如:

1
2
3
4
5
6
7
private ThreadLocal<String> tl1 = new ThreadLocal();
private ThreadLocal<String> tl2 = new ThreadLocal();

public void setName(String name) {
tl1.set(name);
tl2.set(name);
}

调用 setName("hello") 时就会在当前线程的 ThreadLocalMap 对象中存储两个 key:value

1
2
3
key(ThreadLocal 类型) : value(String 类型)
tl1 "hello"
tl2 "hello"

潜在风险

如果在线程池中使用 ThreadLocal,则会造成内存泄漏。因为线程池内的核心线程一直存活,其内的 ThreadLocalMap 会一直存储之前已经结束的任务保存的 ThreadLocal 数据,使得这些对象无法被 GC,从而会造成内存泄漏。

解决方案:在使用完 ThreadLocal 内存储的数据后,手动调用 remove() 方法清除其内存储的对象。

应用场景

ThreadLocal 的典型应用场景就是连接管理。一个线程持有一个连接,该链接对象可以在不同的方法之间传递,线程间不共享同一个连接。

应用:Spring Transaction

在 Spring 中,当一个新的事务创建时,就会被绑定到当前线程上。TransactionAspectSupport类中的ThreadLocal<TransactionInfo>在当前线程保存了一个事务的信息 TransactionInfo

image-20210818103221467

这样就能做到在当前线程中获取到正在起作用的事务信息。之后从 ThreadLocal 中获取到 TransactionInfo 即可得知当前事务的信息。

该线程会伴随着这个事务整个生命周期,直到事务提交、回滚或挂起(临时解绑)时该线程才会取消与该事务的绑定。

应用:云商城

在项目中的应用 【Project】云商城-订单服务:在登录拦截其中将会员信息保存到 ThreadLocal 中,从而在 Controller 层和 Service 层能获取到会员信息:

  1. 自定义登录拦截器,若用户未登录则跳转到登录页面;若已登录则将用户信息存入 ThreadLocal 中在 Service 层获取:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
/**
* 登录拦截器,未登录的用户不能进入订单服务
*/
public class LoginInterceptor implements HandlerInterceptor {
public static ThreadLocal<MemberResponseVo> loginUserThreadLocal = new ThreadLocal<>();

@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// 允许路径匹配 /order/order/infoByOrderSn/** 与 /payed/** 的请求直接放行
// 因为这些远程调用请求是和MQ相关的定时任务(定时删单和定时支付),不包含用户信息,可以直接放行
String requestURI = request.getRequestURI();
AntPathMatcher matcher = new AntPathMatcher();
boolean match1 = matcher.match("/order/order/infoByOrderSn/**", requestURI);
boolean match2 = matcher.match("/payed/**", requestURI);
if (match1 || match2) return true;

// Spring Session 包装后的 request 对象使用 getSession() 时将从 Redis 里获取 Session
HttpSession session = request.getSession();
// 从 Redis Session 中查出当前用户的会员信息
MemberResponseVo memberResponseVo = (MemberResponseVo) session.getAttribute(AuthServerConstant.LOGIN_USER);
if (memberResponseVo != null) {
// 保存到 ThreadLocal 中,将被 Service 层读取
loginUserThreadLocal.set(memberResponseVo);
return true;
}else {
session.setAttribute("msg","请先登录");
// 跳转到登录页面
response.sendRedirect("http://auth.yunmall.com/login.html");
return false;
}
}

@Override
public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {

}

@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {

}
}
  1. Service 层获取到登陆拦截器所获取的会员信息:
1
2
3
4
5
6
7
8
9
@Override
public OrderConfirmVo confirmOrder() {
// 从 ThreadLocal 中查出当前会员用户的信息
MemberResponseVo memberResponseVo = LoginInterceptor.loginUserThreadLocal.get();
// 防止 ThreadLocal 内存泄漏,需要手动清除数据,从ThreadLocalMap中移除该ThreadLocal及其数据
LoginInterceptor.loginUserThreadLocal.remove();

// ...
}

多线程中的设计模式

同步模式之保护性暂停

即 Guarded Suspension,用在一个线程等待另一个线程的执行结果。保护性暂停模式是同步阻塞的(生产者消费者模式是异步阻塞的,放入阻塞队列的消息不会被立即消费)

  • 有一个结果需要从一个线程传递到另一个线程,让他们关联同一个 GuardedObject
  • 如果有结果不断从一个线程到另一个线程那么可以使用消息队列(见生产者/消费者)
  • JDK 中,join 的实现、Future 的实现(future.get()),采用的就是此模式
  • 因为要等待另一方的结果,因此归类到同步模式

image-20220303161541285

实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class GuardedObject {
private Object response;
private final Object lock = new Object();
public Object get() {
synchronized (lock) {
// 条件不满足则等待
while (response == null) {
try {
lock.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
return response;
}
}
public void complete(Object response) {
synchronized (lock) {
// 条件满足,通知等待线程
this.response = response;
lock.notifyAll();
}
}
}

应用

一个线程等待另一个线程的执行结果。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public static void main(String[] args) {
GuardedObject guardedObject = new GuardedObject();
new Thread(() -> {
try {
// 子线程执行下载
List<String> response = download();
log.debug("download complete...");
guardedObject.complete(response);
} catch (IOException e) {
e.printStackTrace();
}
}).start();
log.debug("waiting...");
// 主线程阻塞等待
Object response = guardedObject.get();
log.debug("get response: [{}] lines", ((List<String>) response).size());
}

多任务版 GuardedObject

图中 Futures 就好比居民楼一层的信箱(每个信箱有房间编号),左侧的 t0,t2,t4 就好比等待邮件的居民,右侧的 t1,t3,t5 就好比邮递员。

如果需要在多个类之间使用 GuardedObject 对象,作为参数传递不是很方便,因此设计一个用来解耦的中间类,
这样不仅能够解耦【结果等待者】和【结果生产者】,还能够同时支持多个任务的管理。

image-20220303161946370

同步模式之顺序控制

wait/notifyawait()/signal()park()/unpark() 就是顺序控制模式的一种实现。

异步模式之生产者/消费者

  • 与前面的保护性暂停中的 GuardObject 不同,不需要产生结果和消费结果的线程一一对应
  • 消费队列可以用来平衡生产和消费的线程资源
  • 生产者仅负责产生结果数据,不关心数据该如何处理,而消费者专心处理结果数据
  • 消息队列是有容量限制的,满时不会再加入数据,空时不会再消耗数据
  • JDK 中各种阻塞队列,采用的就是这种模式

image-20220303164337399

实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53

class Message {
private int id;
private Object message;
public Message(int id, Object message) {
this.id = id;
this.message = message;
}
public int getId() {
return id;
}
public Object getMessage() {
return message;
}
}

class MessageQueue {
private LinkedList<Message> queue;
private int capacity;
public MessageQueue(int capacity) {
this.capacity = capacity;
queue = new LinkedList<>();
}
public Message take() {
synchronized (queue) {
while (queue.isEmpty()) {
log.debug("没货了, wait");
try {
queue.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
Message message = queue.removeFirst();
queue.notifyAll();
return message;
}
}
public void put(Message message) {
synchronized (queue) {
while (queue.size() == capacity) {
log.debug("库存已达上限, wait");
try {
queue.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
queue.addLast(message);
queue.notifyAll();
}
}
}

应用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
MessageQueue messageQueue = new MessageQueue(2);
// 4 个生产者线程, 下载任务
for (int i = 0; i < 4; i++) {
int id = i;
new Thread(() -> {
try {
log.debug("download...");
List<String> response = Downloader.download();
log.debug("try put message({})", id);
messageQueue.put(new Message(id, response));
} catch (IOException e) {
e.printStackTrace();
}
}, "生产者" + i).start();
}
// 1 个消费者线程, 处理结果
new Thread(() -> {
while (true) {
Message message = messageQueue.take();
List<String> response = (List<String>) message.getMessage();
log.debug("take message({}): [{}] lines", message.getId(), response.size());
}
}, "消费者").start();

异步模式之工作线程

让有限的工作线程(Worker Thread)来轮流异步处理无限多的任务。也可以将其归类为分工模式,它的典型实现
就是线程池,也体现了经典设计模式中的享元模式

让有限的工作线程(Worker Thread)来轮流异步处理无限多的任务。也可以将其归类为分工模式,它的典型实现
就是线程池,也体现了经典设计模式中的享元模式。

终止模式之两阶段终止模式

Two Phase Termination:在一个线程 T1 中如何“优雅”终止线程 T2?这里的【优雅】指的是给 T2 一个料理后事的机会 。

错误思路:

  • 使用线程对象的 stop() 方法停止线程:stop 方法会真正杀死线程,如果这时线程锁住了共享资源,那么当它被杀死后就再也没有机会释放锁,其它线程将永远无法获取锁
  • 使用 System.exit(int) 方法停止线程:目的仅是停止一个线程,但这种做法会让整个程序都停止

两阶段终止模式:分两个阶段终止线程

image-20220303164726942

实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// 停止标记用 volatile 是为了保证该变量在多个线程之间的可见性
// 我们的例子中,即主线程把它修改为 true 对 t1 线程可见
class TPTVolatile {
private Thread thread;
private volatile boolean stop = false;
public void start(){
thread = new Thread(() -> {
while(true) {
Thread current = Thread.currentThread();
if(stop) {
log.debug("料理后事");
break;
}
try {
Thread.sleep(1000);
log.debug("将结果保存");
} catch (InterruptedException e) {
}
// 执行监控操作
}
},"监控线程");
thread.start();
}
// 通过打断的方式停止程序,使其能够料理后事
public void stop() {
stop = true;
thread.interrupt();
}
}

调用:

1
2
3
4
5
6
TPTVolatile t = new TPTVolatile();
t.start();

Thread.sleep(3500);
log.debug("stop");
t.stop();

变量和集合类的线程安全分析

成员变量和静态变量的线程安全分析

  • 如果变量没有在线程间共享,那么变量是安全的
  • 如果变量在线程间共享
    • 如果只有读操作,则线程安全
    • 如果有读写操作,则这段代码处于临界区,需要考虑线程安全

局部变量线程安全分析

  • 基本数据类型的局部变量是安全的
  • 引用类型的局部变量对象未必是安全的
    • 如果局部变量引用的对象没有引用线程共享的对象,那么是线程安全的
    • 如果局部变量引用的对象引用了一个线程共享的对象,那么要考虑线程安全的

保证类成员变量线程安全性的技巧:

  • 使用 final 修饰成员变量或静态成员变量,这样该变量会在初始化时就赋好值,并且在之后就不能被修改了

常见线程安全类

  • String
  • Integer
  • StringBuffer
  • Random
  • Vector
  • Hashtable
  • java.util.concurrent 包下的类

StringInteger类都是不可变的类,因为其类内部状态是不可改变的,因此它们的方法都是线程安全的。

Stringreplacesubstring 等方法被调用时返回的已经是一个新创建的对象了,所以能保证其自身的线程安全性。并且 String 类是 final 修饰的,代表不能有子类继承 String 类,从而保护了其线程安全的特性。

这里说它们是线程安全的是指,多个线程调用它们同一个实例的某个方法时,是线程安全的。也可以理解为它们的每个方法是原子的。但注意它们多个方法的组合不是原子的。

保证类的线程安全的技巧:

  • 使用 private 修饰想要保证线程安全性的方法,这样子类就无法继承该方法,无法破坏其安全性
  • 使用 final 修饰想要保证线程安全性的方法,这样子类就无法重写该方法,无法破坏其安全性

private 修饰的方法虽然可以被子类所继承,但是其没有访问权限,无法破坏其安全性。子类再创建的同名方法就是新方法了

ArrayList 线程不安全

ArrayList 是线程不安全的,并发条件下对 ArrayListadd()remove() 操作会导致异常发生。

解决方案一:使用 Vector 集合(较为古老,不常用)

VectorList 接口的古老实现类,其特点是线程安全的,效率低,不太常用(底层是 synchronized 加锁方式的效率都比较低,因为不论是读还是写都会阻塞,而读操作没必要加锁)

解决方案二:使用 Collections 工具类(不常用)

Collections 是一个操作 SetListMap 等集合的工具类(操作数组的工具类:Arrays)。Collections 中提供了一系列静态的方法对集合元素进行排序、查询和修改等操作,还提供了对集合对象设置不可变、对集合对象实现同步控制等方法。

其提供的线程安全功能是通过 synchronized 方式加锁,其效率不高,也不常用。

1
List<String> list = Collections.synchronizedList(new ArrayList<>());

解决方案三:使用 JUC 包下的 CopyOnWriteArrayList 类(常用)

它相当于线程安全的 ArrayList。和 ArrayList 一样,它是个可变数组;但是和 ArrayList 不同的是,它具有以下特性:

  1. 它最适合于具有以下特征的应用程序:List 大小通常保持很小,只读操作远多于可变操作,需要在遍历期间防止线程间的冲突。
  2. 它是线程安全的
  3. 因为通常需要复制整个基础数组,所以可变操作(add()set()remove() 等等)的开销很大。
  4. 迭代器支持 hasNext()next() 等不可变操作,但不支持可变remove() 等操作。
  5. 使用迭代器进行遍历的速度很快,并且不会与其他线程发生冲突。在构造迭代器时,迭代器依赖于不变的数组快照。

CopyOnWriteArrayList 不使用独占锁,而是使用写时复制技术,将读写分离。读操作不加锁,写操作才加锁。

写时复制思路:当我们往一个容器添加元素的时候,不直接往当前容器添加,而是先将当前容器进行 Copy,复制出一个新的容器,然后新的容器里添加元素,添加完元素之后,再将原容器的引用指向新的容器。这样读和写的是不同的容器,因此实现了读写分离,写时不耽误并发读。

add() 方法源码:

image-20210916205340151

拷贝时使用的是 Arrays 工具类的 copyOf() 方法。

总结Vector 读写时都上锁,因此性能较差,不能同时读和写。但 CopyOnWriteArrayList 可以在读的时候进行写,效率较高。

HashSet 线程不安全

解决方案:使用 CopyOnWriteArraySet

image-20210923215559576

HashMap 线程不安全

解决方案:使用 ConcurrentHashMap