一、多线程概述
1、多线程简介
多线程是指一个进程中包含的多个执行流(可执行的计算单元),即在一个进程中可以同时运行多个不同的线程,来执行不同的任务。
注意,一个 CPU 同一时间只能执行一个线程。
-
好处:
- 使用多线程的好处是可以
提高 CPU 的利用率
。在多线程程序中,当其中一个线程必须等待的时候,CPU 可以运行其它的线程而不是等待,这样就大大提高了程序的效率。也就是说允许单个程序创建多个并行执行的线程来完成各自的任务。
- 使用多线程的好处是可以
-
坏处:
- 线程也是程序,所以线程需要占用内存。线程越多,占用内存也越多;
- 多线程需要协调和管理,所以需要 CPU 时间跟踪线程;
- 线程之间对共享资源的访问会相互影响,必须解决竞用共享资源的问题。
2、并发和并行
- 并发(concurrency):多个任务在同一个 CPU 核上,按细分的时间片轮流(交替)执行,由于每个时间片的时间非常短,看起来那些任务就像是同时执行。
- 并行(parallelism):单位时间内,多个处理器或多核处理器同时处理多个任务,是真正意义上的“同时进行”。
现在的系统动不动就要求百万级甚至千万级的并发量,而多线程并发编程正是开发高并发系统的基础,利用好多线程机制可以大大提高系统整体的并发能力以及性能。面对复杂业务模型,并行程序会比串行程序更适应业务需求,而并发编程更能吻合这种业务拆分 。
3、进程 VS 线程
现代计算机的 CPU 有多个核心,有时甚至有多个处理器。为了利用所有计算能力,操作系统定义了一个底层结构,叫做线程,而一个进程(例如 Chrome 浏览器)能够生成多个线程,通过线程来执行系统指令。这样如果一个进程是要使用很多 CPU,那么计算负载就会由多个核心分担,最终使得绝大多数应用能更快地完成任务。
1. 定义
- 进程是静态的概念:程序进入内存,并获得了系统所分配的对应资源。因此进程是系统资源分配的基本单元;线程是动态的概念:进程创建的同时也产生了一个主线程用来执行任务,因此线程是可执行的计算单元,是 CPU 调度的基本单位。
- 一个程序启动后至少有一个进程,一个进程至少有一个线程;线程不能够独立执行,必须依存在进程中。
- 进程与线程均能够完成多任务。比如一台电脑上能够同时运行多个 QQ(多进程);一个 QQ 中使用多个聊天窗口(多线程)。
- 从内核的角度看,进程的目的就是担当分配系统资源(CPU 时间、内存等)的基本单位;线程则是进程的一个执行流,是 CPU 调度和分派的基本单位,它是比进程更小的能独立运行的基本单位。
2. 拥有资源
- 每个进程都有自己独立的一块内存空间和一组系统资源。每一个进程的内部数据和状态都是完全独立的。
- 一个标准的线程则由线程 ID、当前指令指针和寄存器组合(即堆栈)组成。线程自己不拥有系统资源,只拥有少量在运行中必不可少的资源,但它可以通过共享进程的内存单元实现数据交换、实时通信和必要的同步操作。
3. 资源消耗
总的来说,进程与线程最大的区别在于上下文切换过程中,线程不用切换虚拟内存,因为同一个进程内的线程都是共享虚拟内存空间的
,线程就单这一点不用切换,就比进程上下文切换的性能开销(空间和时间)减少了很多。据统计,一个进程的开销大约是一个线程开销的 30 倍左右,当然,在具体的系统上,这个数据可能会有较大的区别。
由于虚拟内存与物理内存的映射关系需要查询页表,页表的查询是很慢的过程,因此会把常用的地址映射关系缓存在 TLB 里的,这样便可以提高页表的查询速度,如果发生了进程切换,那 TLB 缓存的地址映射关系就会失效,缓存失效就意味着命中率降低,于是虚拟地址转为物理地址这一过程就会很慢。
优劣势:
维度 | 多进程 | 多线程 | 优劣 |
---|---|---|---|
数据共享、同步 | 数据是分开的;共享复杂;同步简单 | 多线程共享进程数据:共享简单;同步复杂 | 各有优势 |
内存、CPU | 占用内存多,切换复杂,CPU 利用率低 | 占用内存少,切换简单,CPU 利用率高 | 线程占优 |
创建销毁、切换 | 创建销毁、切换复杂,速度慢 | 创建销毁、切换简单,速度快 | 线程占优 |
编程调试 | 编程简单,调试简单 | 编程复杂,调试复杂 | 进程占优 |
可靠性 | 进程间不会相互影响 | 一个线程挂掉将导致整个进程挂掉 | 进程占优 |
分布式 | 适应于多核、多机分布 ;如果一台机器不够,扩展到多台机器比较简单 | 适应于多核分布 | 进程占优 |
4. 应用场景
-
需要频繁创建销毁的,优先用线程。
- 这种原则最常见的应用就是 web 服务器了,来一个连接建立一个线程,断了就销毁线程,要是用进程,创建和销毁的代价是很难承受的。
-
需要进行大量计算的,优先使用线程。
- 所谓大量计算,当然就是要耗费很多 CPU,切换频繁了,这种情况下线程是最合适的。
- 这种原则最常见的是图像处理、算法处理。
-
强相关的处理用线程,弱相关的处理用进程。
- 什么叫强相关、弱相关?理论上很难定义,给个简单的例子就明白了。
- 一般的 server 需要完成如下任务:消息收发、消息处理。“消息收发”和“消息处理”就是弱相关的任务,而“消息处理”里面可能又分为“消息解码”、“业务处理”,这两个任务相对来说相关性就要强多了。因此“消息收发”和“消息处理”可以分进程设计,“消息解码”、“业务处理”可以分线程设计。
- 当然这种划分方式不是一成不变的,也可以根据实际情况进行调整。
-
可能要扩展到多机分布的用进程,多核分布的用线程。
-
都满足需求的情况下,用你最熟悉、最拿手的方式。
-
至于“数据共享、同步”、“编程、调试”、“可靠性”这几个维度中的所谓“复杂、简单”应该怎么取舍,其实没有明确的选择方法。
有一个选择原则:如果多进程和多线程都能够满足要求,那么选择你最熟悉、最拿手的那个。
需要提醒的是:虽然有这么多的选择原则,但实际应用中基本上都是“进程+线程”的结合方式,千万不要真的陷入一种非此即彼的误区。
4、CPU 上下文切换
1. 什么是上下文切换
在多线程编程中,一般线程的个数都大于 CPU 核心的个数,而一个 CPU 核心在任意时刻只能被一个线程使用,为了让这些线程都能得到有效执行,CPU 采取的策略是为每个线程分配时间片并轮转的形式。
时间片是 CPU 分配给各个线程的时间,因为时间非常短,所以 CPU 不断通过切换线程,让我们觉得多个线程是同时执行的,时间片一般是几十毫秒。
概括来说就是:当前任务在执行完 CPU 时间片并切换到另一个任务之前,会先保存自己的状态,以便下次再切换回这个任务保证原来的状态不受影响,让任务看起来还是连续运行。任务从保存(旧任务状态)到加载(新任务状态)的过程就是一次上下文切换
。
2. 频繁切换上下文的问题
上下文切换通常是计算密集型的,每次切换时,需要保存当前的状态起来,以便能够进行先前状态的恢复,而这个切换时非常损耗性能。
也就是说,它需要相当可观的处理器时间,在每秒几十上百次的切换中,每次切换都需要纳秒量级的时间。所以,上下文切换对系统来说意味着消耗大量的 CPU 时间。事实上,这可能是操作系统中时间消耗最大的操作。
Linux 相比与其他操作系统(包括其他类 Unix 系统)有很多的优点,其中有一项就是,其上下文切换和模式切换的时间消耗非常少。
3. 减少上下文切换的方式有哪些
通常减少上下文切换的方式有:
- 无锁并发编程:可以参照 concurrentHashMap 锁分段的思想,不同的线程处理不同段的数据,这样在多线程竞争的条件下,可以减少上下文切换的时间。
- CAS 算法:利用 Atomic 下的 CAS 算法来更新数据,即使用了乐观锁,可以有效的减少一部分不必要的锁竞争带来的上下文切换。
- 使用最少线程:避免创建不需要的线程,比如任务很少,但是创建了很多的线程,这样会造成大量的线程都处于等待状态。
- 协程:在单线程里实现多任务的调度,并在单线程里维持多个任务间的切换。
5、单核CPU多线程执行有没有意义
虽然单线程全部占有 CPU,但不代表全部利用。而多线程能更好的利用资源,前提是组织好程序(比如需要执行多个不同的任务),否则并发执行的效率不一定比串行执行高,因为多线程在执行的时候会有抢占 CPU 资源、上下文切换
的过程。
如果你的程序仅仅是做一种简单的计算,其间不涉及任何可能是使线程挂起的操作,如 I/O 读写,等待某种事件等
,那么从表面上看,两个线程与单个线程相比,增加了切换的开销,应该比单线程慢才对。
但还得考虑操作系统的调度策略。通常,在支持线程的操作系统中,线程才是系统调度的单位,对同样一个进程来讲,多一个线程就可以多分到 CPU 时间,特别是从一个增加到两个的时候
。
举例来说,假如在你的程序启动前,系统中已经有 50 个线程在运行,那么当你的程序启动后,假如他只有一个线程,那么平均来讲,它将获得 1/51 的 CPU 时间,而如果他有两个线程,那么就会获得 2/52 的 CPU 时间(当然,这是一种非常理想的情况,它没有考虑系统中原有其他线程的繁忙或者空闲程度,也没有考虑线程切换)。
但是如果你的程序里面已经有 1000 个线程,那么你把它加到 1500,效果就不会有从 1 个线程加到 2 个线程来的明显。而且很可能造成系统的整体性能下降,因为线程之间的切换也需要时间。
6、设置多少个线程合适
线程池设定多少核心线程?
如何合理设置线程数,与线程占用 CPU 的时间强相关。
简单思路:首先测算一个线程中,占用 CPU 的时间是多少,不占用 CPU 的时间是多少,算出两者的比值,那么分子和分母的和即为合理的线程数,此时 CPU 利用率就能达到 100%。
示例:比如占用 CPU 时间和不占用 CPU 时间的比值是 1:1,则应设置 2 个线程;再比如占用 CPU 时间和不占用 CPU 时间的比值是 1:3,则应设置 4 个线程。
7、用户线程和内核线程
1. 在操作系统中,用户级线程和内核级线程是什么
在操作系统的设计中,为了防止用户操作敏感指令而对 OS 带来安全隐患,OS 被分成了用户空间(user space)和内核空间(kernel space)。
2. 用户级线程
通过用户空间的库类实现的线程,就是用户级线程(user-level threads,ULT)。这种线程不依赖于操作系统核心,进程利用线程库提供创建、同步、调度和管理线程的函数来控制用户线程。
在图里,我们可以清楚的看到,线程表(Thread table,管理线程的数据结构)是处于进程内部的,完全处于用户空间层面,内核空间对此一无所知!当然,用户线程也可以没有线程表!
3. 内核级线程
相应的,由 OS 内核空间直接掌控的线程,称为内核级线程(kernel-level threads,KLT)。其依赖于操作系统核心,由内核的内部需求进行创建和撤销。
同样的,在图中,我们看到内核线程的线程表(Thread table)位于内核中,包括了线程控制块(TCB),一旦线程阻塞,内核会从当前或者其他进程(process)中重新选择一个线程保证程序的执行。
对于用户级线程来说,其线程的切换发生在用户空间,这样的线程切换至少比陷入内核要快一个数量级。但是该种线程有个严重的缺点:如果一个线程开始运行,那么该进程中其他线程就不能运行,除非第一个线程自动放弃 CPU。因为在一个单独的进程内部,没有时钟中断,所以不能用轮转调度(轮流)的方式调度线程。
4. 这两种线程在多核 CPU 的计算机上是否都能并行
-
同一进程中的用户级线程,在不考虑调起多个内核级线程的基础上,是没有办法利用多核 CPU 的,其实质是并发而非并行。
-
内核级线程是可以利用多核 CPU 的,即可以并行,但该线程在内核中创建和撤销线程的开销比较大,需要考虑上下文切换的开销。
二、多线程实现
1、线程常用方法
方法名 | 说明 |
---|---|
void run() | 在线程开启后,此方法将被调用执行 |
void start() | 使此线程开始执行,JVM 会调用 run 方法 |
void setName(String name) | 将此线程的名称更改为等于参数 name |
String getName() | 返回此线程的名称 |
Thread currentThread() | 返回对当前正在执行的线程对象的引用 |
static void sleep(long millis) | 使当前正在执行的线程停留(暂停执行)指定的毫秒数 |
2、多线程实现方式
方式一:继承 Thread 类
实现步骤:
- 自定义一个类,继承 Thread 类;
- 在自定义类中重写 run() 方法;
- 创建自定义类的对象;
- 启动线程,即执行 start() 方法。
解释:
-
为什么要重写 run() 方法?
- 因为 run() 是用来封装被线程执行的代码。
-
run() 方法和 start() 方法的区别?
- run():封装线程执行的代码,直接调用时相当于普通方法的调用。
- start():启动线程,即由 JVM 调用此线程的 run() 方法。
代码示例:
// 自定义的多线程类
class MyThread extends Thread {
@Override
public void run(){
for(int i=0; i<100; i++) {
System.out.println(i);
try{
Thread.sleep(1); // 单位是毫秒
}catch(InterruptedException e){
System.out.println(e);
}
}
}
}
// 测试类
public class Test{
public static void main(String[] args){
MyThread t1 = new MyThread();
MyThread t2 = new MyThread();
// 启动两个线程
t1.start();
t2.start();
}
}
方式二:实现 Runnable 接口
Thread 构造方法:
方法名 | 说明 |
---|---|
Thread(Runnable target) | 分配一个新的 Thread 对象 |
Thread(Runnable target, String name) | 分配一个新的 Thread 对象 |
实现步骤:
- 自定义一个类实现 Runnable 接口;
- 在自定义类中重写 run() 方法;
- 创建自定义类的对象;
- 创建 Thread 类的对象,把 MyRunnable 对象作为构造方法的参数;
- 启动线程,即调用 Thread 类对象的 start() 方法。
示例代码:
package com.demo;
public class Test{
public static void main(String[] args) {
MyThread myThread = new MyThread();
// 自定义类作为Thread类的参数
Thread t1 = new Thread(myThread, "线程1");
Thread t2 = new Thread(myThread, "线程2");
t1.start();
t2.start();
}
}
// 自定义类实现 Runnable 接口
class MyThread implements Runnable {
@Override
public void run(){
for(int i=0; i<100; i++){
try {
Thread.sleep(1); // 单位:毫秒
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName()+": "+i);
}
}
}
方式三:实现 Callable 接口
方法介绍:
方法名 | 说明 |
---|---|
V call() | 计算结果,如果无法计算结果,则抛出一个异常 |
FutureTask(Callable<V> callable) | 创建一个 FutureTask,一旦运行就执行给定的 Callable |
V get() | 如有必要,等待计算完成,然后获取其结果 |
实现步骤:
- 定义一个自定义类实现 Callable 接口;
- 在 MyCallable 类中重写 call() 方法;
- 创建自定义类的对象;
- 创建 Future 的实现类 FutureTask 对象,把自定义类对象作为构造方法的参数;
- 创建 Thread 类的对象,把 FutureTask 对象作为构造方法的参数;
- 启动线程;
- 再调用 get 方法,就可以获取线程结束之后的结果。
示例代码:
public class MyCallable implements Callable<String> {
@Override
public String call() throws Exception {
for (int i = 0; i < 100; i++) {
System.out.println("序号" + i);
}
// 返回值表示线程运行完毕之后的结果
return "答应";
}
}
public class Demo {
public static void main(String[] args) throws ExecutionException, InterruptedException {
// 线程开启之后需要执行里面的call方法
MyCallable mc = new MyCallable();
// 可以获取线程执行完毕之后的结果
FutureTask<String> ft = new FutureTask<>(mc);
// 创建线程对象
Thread t1 = new Thread(ft);
String s = ft.get();
// 开启线程
t1.start();
System.out.println(s);
}
}
3、三种实现方式的对比
继承 Thread 类:
- 优势:编程比较简单,可以直接使用 Thread 类中的方法。
- 劣势:
- 可扩展性较差,不能再继承其他的类。
- 多个线程间无法共享线程类的实例变量(需要创建不同的 Thread 对象,自然不共享)。
实现 Runnable、Callable 接口:
- 优势:扩展性强,实现该接口的同时还可以继承其他的类。
- 通过实现 Runnable 接口的线程类,是互相共享资源的(因为 Thread 对象才是真正的线程对象)。
- Callable 接口如同 Runnable 接口的升级版,其提供的 call() 方法作为线程的执行体,同时允许有返回值。
- 劣势:编程相对复杂,不能直接使用 Thread 类中的方法。
4、线程优先级
线程的两种调度方式:
- 分时调度模型:所有线程轮流使用 CPU 的使用权,平均分配每个线程占用 CPU 的时间片。
- 抢占式调度模型:优先让优先级高的线程使用 CPU,如果线程的优先级相同,那么会随机选择一个,优先级高的线程获取的 CPU 时间片相对多一些。
Java 线程:
-
使用的是抢占式调度模型。
-
随机性:假如计算机只有一个 CPU,那么 CPU 在某一个时刻只能执行一条指令,线程只有得到 CPU 时间片(也就是使用权),才可以执行指令。所以说多线程程序的执行是有随机性的,因为谁抢到 CPU 的使用权是不一定的。
优先级相关方法:
方法名 | 说明 |
---|---|
final int getPriority() | 返回此线程的优先级 |
final void setPriority(int newPriority) | 更改此线程的优先级(线程默认优先级是 5;线程优先级的范围是 1-10) |
代码示例:
package com.demo;
public class Test{
public static void main(String[] args) {
MyThread myThread = new MyThread();
// 自定义类作为Thread类的参数
Thread t1 = new Thread(myThread, "线程1");
Thread t2 = new Thread(myThread, "线程2");
t1.setPriority(1);
System.out.println(t1.getPriority()); // 1
t2.setPriority(10);
System.out.println(t2.getPriority()); // 10
t1.start();
t2.start();
}
}
// 自定义类实现 Runnable 接口
class MyThread implements Runnable {
@Override
public void run(){
for(int i=0; i<5; i++){
try {
Thread.sleep(1); // 单位:毫秒
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName()+": "+i);
}
}
}
5、守护线程
Java程序入口就是由JVM启动main线程,main线程又可以启动其他线程。当所有线程都运行结束时,JVM退出,进程结束。
如果有一个线程没有退出,JVM进程就不会退出。所以,必须保证所有线程都能及时结束。
但是有一种线程的目的就是无限循环,例如,一个定时触发任务的线程:
class TimerThread extends Thread {
@Override
public void run() {
while (true) {
System.out.println(LocalTime.now());
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
break;
}
}
}
}
如果这个线程不结束,JVM进程就无法结束。问题是,由谁负责结束这个线程?
然而这类线程经常没有负责人来负责结束它们。但是,当其他线程结束时,JVM进程又必须要结束,怎么办?
答案是使用守护线程(Daemon Thread)。
守护线程是指为其他线程服务的线程。在JVM中,所有非守护线程都执行完毕后,无论有没有守护线程,虚拟机都会自动退出。
因此,JVM退出时,不必关心守护线程是否已结束。
如何创建守护线程呢?方法和普通线程一样,只是在调用start()方法前,调用setDaemon(true)把该线程标记为守护线程:
Thread t = new MyThread();
t.setDaemon(true);
t.start();
在守护线程中,编写代码要注意:守护线程不能持有任何需要关闭的资源,例如打开文件等,因为虚拟机退出时,守护线程没有任何机会来关闭文件,这会导致数据丢失。
守护线程API:
方法名 | 说明 |
---|---|
void setDaemon(boolean on) | 将此线程标记为守护线程,当运行的线程都是守护线程时,JVM 将退出 |
守护线程示例:
public class MyThread1 extends Thread {
@Override
public void run() {
for (int i = 0; i < 10; i++) {
System.out.println(getName() + "---" + i);
}
}
}
public class MyThread2 extends Thread {
@Override
public void run() {
for (int i = 0; i < 100; i++) {
System.out.println(getName() + "---" + i);
}
}
}
public class Demo {
public static void main(String[] args) {
MyThread1 t1 = new MyThread1();
MyThread2 t2 = new MyThread2();
t1.setName("女神");
t2.setName("备胎");
// 把第二个线程设置为守护线程
// 当普通线程执行完后,守护线程即使未执行完 也会跟着结束
t2.setDaemon(true);
t1.start();
t2.start();
}
}
6、如何停止一个正在运行的线程
- 可以在线程中使用 for 循环来判断线程是否终止状态,如果是,则后面的代码不再运行。
class MyThread extends Thread {
@Override
public void run() {
super.run();
for (int i=0;;i++) {
if (this.interrupted()) {
System.out.println("线程已终止,for循环不再执行");
break; // 也可使用 return
}
System.out.println("i="+(i+1));
}
}
}
public class Test {
public static void main(String[] args) {
MyThread thread = new MyThread();
thread.start();
try {
Thread.sleep(2000);
thread.interrupt();
} catch (Exception e) {
e.printStackTrace();
}
}
}
运行结果:
...
i=143977
i=143978
i=143979
线程已终止,for循环不再执行
- 使用 stop() 强制终止线程(该方法是不安全的,且是已被废弃的方法)。
class MyThread extends Thread {
int i = 0;
@Override
public void run() {
super.run();
while (true) {
try {
System.out.println("i="+i+1);
i++;
Thread.sleep(200);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
public class Test {
public static void main(String[] args) throws InterruptedException {
MyThread thread = new MyThread();
thread.start();
Thread.sleep(2000);
thread.stop();
}
}
运行结果:
...
i=61
i=71
i=81
i=91
三、线程状态
1、线程状态简介
当线程被创建并启动以后,它既不是一启动就进入了执行状态,也不是一直处于执行状态。而是在不同的时期有不同的状态。
那么 Java中 的线程存在哪几种状态呢?Java 中的线程状态被定义在了 java.lang.Thread.State 枚举类中,State 枚举类的源码如下:
public class Thread {
public enum State {
/* 新建 */
NEW ,
/* 可运行状态 */
RUNNABLE ,
/* 阻塞状态 */
BLOCKED ,
/* 无限等待状态 */
WAITING ,
/* 计时等待 */
TIMED_WAITING ,
/* 终止 */
TERMINATED;
}
// 获取当前线程的状态
public State getState() {
return jdk.internal.misc.VM.toThreadState(threadStatus);
}
}
通过源码我们可以看到 Java 中的线程存在 6 种状态,每种线程状态的含义如下:
线程状态 | 具体含义 |
---|---|
NEW | 一个尚未启动的线程的状态。也称之为初始状态、开始状态。线程刚被创建,但是并未启动。还没调用 start 方法。MyThread t = new MyThread() 只有线程象,没有线程特征。 |
RUNNABLE | 当我们调用线程对象的 start 方法,那么此时线程对象进入了 RUNNABLE 状态。那么此时才是真正的在 JVM 进程中创建了一个线程,线程一经启动并不是立即得到执行,线程的运行与否要听令与 CPU 的调度,那么我们把这个中间状态称之为可执行状态(RUNNABLE)也就是说它具备执行的资格,但是并没有真正的执行起来而是在等待CPU的度。 |
BLOCKED | 当一个线程试图获取一个对象锁,而该对象锁被其他的线程持有,则该线程进入 Blocked 状态;当该线程持有锁时,该线程将变成 Runnable 状态。 |
WAITING | 一个正在等待的线程的状态。也称之为等待状态。造成线程等待的原因有两种,分别是调用 Object.wait()、join()方法。处于等待状态的线程,正在等待其他线程去执行一个特定的操作。例如:因为 wait() 而等待的线程正在等待另一个线程去调用 notify() 或 notifyAll();一个因为 join() 而等待的线程正在等待另一个线程结束。 |
TIMED_WAITING | 一个在限定时间内等待的线程的状态。也称之为限时等待状态。造成线程限时等待状态的原因有三种,分别是:Thread.sleep(long),Object.wait(long)、join(long) |
TERMINATED | 一个完全运行完成的线程的状态。也称之为终止状态、结束状态 |
各个状态的转换如下图所示:
2、线程阻塞的原因
在某一时刻,某一个线程在运行一段代码时,另一个线程也需要运行,但是在运行过程中的该线程执行完成之前,另一个线程是无法获取到 CPU 执行权的(调用 sleep() 方法是进入到睡眠暂停状态,但是 CPU 执行权并没有交出去,而调用 wait() 方法则是将 CPU 执行权交给另一个线程),这个时候就会造成线程阻塞。
出现线程阻塞的原因:
-
睡眠状态
当一个线程执行代码的时候调用了 sleep() 方法后,线程处于睡眠状态,需要设置一个睡眠时间,此时有其他线程需要执行时就会造成线程阻塞,而且 sleep() 方法被调用之后,线程不会释放锁对象。也就是说,锁还在该线程手里,CPU 执行权也还在该线程手里,等睡眠时间一过,该线程就会进入就绪状态。 -
等待状态
当一个线程正在运行时,调用了 wait() 方法,此时该线程需要交出 CPU执行权,也就是将锁释放出去,交给另一个线程,该线程进入等待状态,但与睡眠状态不一样的是,进入等待状态的线程不需要设置睡眠时间,但是需要执行 notify() 方法或者 notifyAll() 方法来对其唤醒,自己是不会主动醒来的,等被唤醒之后,该线程也会进入就绪状态,但是进入该状态的该线程手里是没有执行权的,也就是没有锁,而睡眠状态的线程一旦苏醒,进入就绪状态时自己还拿着锁。 -
礼让状态
当一个线程正在运行时,调用了 yield() 方法之后,该线程会将执行权礼让给同等级的线程或者比它高一级的线程优先执行,此时该线程有可能只执行了一部分而此时把执行权礼让给了其他线程,这个时候也会进入阻塞状态,但是该线程会随时可能又被分配到执行权。 -
自闭状态
当一个线程正在运行时,调用了一个 join() 方法,此时该线程会进入阻塞状态,另一个线程会运行,直到运行结束后,原线程才会进入就绪状态。 -
suspend() 和 resume()
这两个方法是配套使用的,suspend() 是让线程进入阻塞状态,它的“解药”就是 resume(),没有 resume(),suspend() 自己是不会恢复的,由于这种比较容易出现死锁现象,所以 JDK 1.5 之后就已经被废除了,这两种方法就是相爱相杀的一对。
3、中断线程
如果线程需要执行一个长时间任务,就可能需要能中断线程。中断线程就是其他线程给该线程发一个信号,该线程收到信号后结束执行run()方法,使得自身线程能立刻结束运行。
我们举个栗子:假设从网络下载一个100M的文件,如果网速很慢,用户等得不耐烦,就可能在下载过程中点“取消”,这时,程序就需要中断下载线程的执行。
中断一个线程非常简单,只需要在其他线程中对目标线程调用interrupt()方法,目标线程需要反复检测自身状态是否是interrupted状态,如果是,就立刻结束运行。
我们还是看示例代码:
public class Main {
public static void main(String[] args) throws InterruptedException {
Thread t = new MyThread();
t.start();
Thread.sleep(1); // 暂停1毫秒
t.interrupt(); // 中断t线程
t.join(); // 等待t线程结束
System.out.println("end");
}
}
class MyThread extends Thread {
public void run() {
int n = 0;
while (! isInterrupted()) {
n ++;
System.out.println(n + " hello!");
}
}
}
仔细看上述代码,main线程通过调用t.interrupt()方法中断t线程,但是要注意,interrupt()方法仅仅向t线程发出了“中断请求”,至于t线程是否能立刻响应,要看具体代码。而t线程的while循环会检测isInterrupted(),所以上述代码能正确响应interrupt()请求,使得自身立刻结束运行run()方法。
如果线程处于等待状态,例如,t.join()会让main线程进入等待状态,此时,如果对main线程调用interrupt(),join()方法会立刻抛出InterruptedException,因此,目标线程只要捕获到join()方法抛出的InterruptedException,就说明有其他线程对其调用了interrupt()方法,通常情况下该线程应该立刻结束运行。
我们来看下面的示例代码:
public class Main {
public static void main(String[] args) throws InterruptedException {
Thread t = new MyThread();
t.start();
Thread.sleep(1000);
t.interrupt(); // 中断t线程
t.join(); // 等待t线程结束
System.out.println("end");
}
}
class MyThread extends Thread {
public void run() {
Thread hello = new HelloThread();
hello.start(); // 启动hello线程
try {
hello.join(); // 等待hello线程结束
} catch (InterruptedException e) {
System.out.println("interrupted!");
}
hello.interrupt();
}
}
class HelloThread extends Thread {
public void run() {
int n = 0;
while (!isInterrupted()) {
n++;
System.out.println(n + " hello!");
try {
Thread.sleep(100);
} catch (InterruptedException e) {
break;
}
}
}
}
main线程通过调用t.interrupt()从而通知t线程中断,而此时t线程正位于hello.join()的等待中,此方法会立刻结束等待并抛出InterruptedException。由于我们在t线程中捕获了InterruptedException,因此,就可以准备结束该线程。在t线程结束前,对hello线程也进行了interrupt()调用通知其中断。如果去掉这一行代码,可以发现hello线程仍然会继续运行,且JVM不会退出。
另一个常用的中断线程的方法是设置标志位。我们通常会用一个running标志位来标识线程是否应该继续运行,在外部线程中,通过把HelloThread.running置为false,就可以让线程结束:
public class Main {
public static void main(String[] args) throws InterruptedException {
HelloThread t = new HelloThread();
t.start();
Thread.sleep(1);
t.running = false; // 标志位置为false
}
}
class HelloThread extends Thread {
public volatile boolean running = true;
public void run() {
int n = 0;
while (running) {
n ++;
System.out.println(n + " hello!");
}
System.out.println("end!");
}
}
注意到HelloThread的标志位boolean running是一个线程间共享的变量。线程间共享变量需要使用volatile关键字标记,确保每个线程都能读取到更新后的变量值。
为什么要对线程间共享的变量用关键字volatile声明?这涉及到Java的内存模型。在Java虚拟机中,变量的值保存在主内存中,但是,当线程访问变量时,它会先获取一个副本,并保存在自己的工作内存中。如果线程修改了变量的值,虚拟机会在某个时刻把修改后的值回写到主内存,但是,这个时间是不确定的!
这会导致如果一个线程更新了某个变量,另一个线程读取的值可能还是更新前的。例如,主内存的变量a = true,线程1执行a = false时,它在此刻仅仅是把变量a的副本变成了false,主内存的变量a还是true,在JVM把修改后的a回写到主内存之前,其他线程读取到的a的值仍然是true,这就造成了多线程之间共享的变量不一致。
因此,volatile关键字的目的是告诉虚拟机:
- 每次访问变量时,总是获取主内存的最新值;
- 每次修改变量后,立刻回写到主内存。
volatile关键字解决的是可见性问题:当一个线程修改了某个共享变量的值,其他线程能够立刻看到修改后的值。
如果我们去掉volatile关键字,运行上述程序,发现效果和带volatile差不多,这是因为在x86的架构下,JVM回写主内存的速度非常快,但是,换成ARM的架构,就会有显著的延迟。
总结:
对目标线程调用interrupt()方法可以请求中断一个线程,目标线程通过检测isInterrupted()标志获取自身是否已中断。如果目标线程处于等待状态,该线程会捕获到InterruptedException;
目标线程检测到isInterrupted()为true或者捕获了InterruptedException都应该立刻结束自身线程;
通过标志位判断需要正确使用volatile关键字;
volatile关键字解决了共享变量在线程间的可见性问题。
四、线程安全
1、线程安全(线程同步)
定义:
-
线程同步:指进程之间所具有的一种制约关系,一个线程的执行依赖另一个线程的消息,当它没有得到另一个线程的消息时则等待,直到消息到达时才被唤醒。
-
线程互斥:指对于共享的进程系统资源,每个线程访问时的排他性。线程互斥可以被看成是一种特殊的线程同步。
Java 中的线程安全就是线程同步的意思。
当多个线程同时运行时,线程的调度由操作系统决定,程序本身无法决定。因此,任何一个线程都有可能在任何指令处被操作系统暂停,然后在某个时间段后继续执行。
这个时候,有个单线程模型下不存在的问题就来了:如果多个线程同时读写共享变量,会出现数据不一致的问题。
我们来看一个例子:
public class Main {
public static void main(String[] args) throws Exception {
var add = new AddThread();
var dec = new DecThread();
add.start();
dec.start();
add.join();
dec.join();
System.out.println(Counter.count);
}
}
class Counter {
public static int count = 0;
}
class AddThread extends Thread {
public void run() {
for (int i=0; i<10000; i++) { Counter.count += 1; }
}
}
class DecThread extends Thread {
public void run() {
for (int i=0; i<10000; i++) { Counter.count -= 1; }
}
}
上面的代码很简单,两个线程同时对一个int变量进行操作,一个加10000次,一个减10000次,最后结果应该是0,但是,每次运行,结果实际上都是不一样的。
这是因为对变量进行读取和写入时,结果要正确,必须保证是原子操作。原子操作是指不能被中断的一个或一系列操作。
例如,对于语句:
n = n + 1;
看上去是一行语句,实际上对应了3条指令:
ILOAD
IADD
ISTORE
我们假设n的值是100,如果两个线程同时执行n = n + 1,得到的结果很可能不是102,而是101,原因在于:
如果线程1在执行ILOAD后被操作系统中断,此刻如果线程2被调度执行,它执行ILOAD后获取的值仍然是100,最终结果被两个线程的ISTORE写入后变成了101,而不是期待的102。
这说明多线程模型下,要保证逻辑正确,对共享变量进行读写时,必须保证一组指令以原子方式执行:即某一个线程执行时,其他线程必须等待:
通过加锁和解锁的操作,就能保证3条指令总是在一个线程执行期间,不会有其他线程会进入此指令区间。即使在执行期线程被操作系统中断执行,其他线程也会因为无法获得锁导致无法进入此指令区间。只有执行线程将锁释放后,其他线程才有机会获得锁并执行。这种加锁和解锁之间的代码块我们称之为临界区(Critical Section),任何时候临界区最多只有一个线程能执行。
可见,保证一段代码的原子性就是通过加锁和解锁实现的。
线程安全问题出现的条件:
- 多线程环境。
- 有共享数据。
- 有多条语句操作共享数据。
如何解决线程安全问题:把多条语句操作共享数据的代码给锁起来,让任意时刻只能有一个线程执行即可。
2、同步控制:synchronized
synchronized 关键字用来修饰成员方法时,代表这个方法对于同一个对象来说,同一时间只允许一个线程执行,别的线程如果也调用这个实例的这个方法,就需要等待已经在执行这个方法的线程执行完毕,才能进入方法执行。
synchronized 还能 修饰代码块、静态方法。
同步的好处和弊端:
- 好处:解决了多线程的数据安全问题。
- 弊端:当线程很多时,由于每个线程都会去判断同步上的锁,这是很耗费资源的,无形中会降低程序的运行效率。
1. 同步代码块
synchronized(任意对象) { // 相当于给代码加锁,任意对象就可以看成是一把锁
多条语句操作共享数据的代码
}
synchronized保证了代码块在任意时刻最多只有一个线程能执行。
代码示例:
public class SellTicket implements Runnable {
private int tickets = 100;
private Object obj = new Object();
@Override
public void run() {
while (true) {
synchronized (obj) { // 对可能有安全问题的代码加锁,多个线程必须使用同一把锁
// t1进来后,就会把这段代码给锁起来
if (tickets > 0) {
try {
Thread.sleep(100);
// t1休息100毫秒
} catch (InterruptedException e) {
e.printStackTrace();
}
// 窗口1正在出售第100张票
System.out.println(Thread.currentThread().getName() + "正在出售第" + tickets + "张票");
tickets--; // tickets = 99;
}
}
// t1出来了,这段代码的锁就被释放了
}
}
}
public class SellTicketDemo {
public static void main(String[] args) {
SellTicket st = new SellTicket();
Thread t1 = new Thread(st, "窗口1");
Thread t2 = new Thread(st, "窗口2");
Thread t3 = new Thread(st, "窗口3");
t1.start();
t2.start();
t3.start();
}
}
2. 同步方法
同步方法,就是把 synchronized 关键字加到方法上:
修饰符 synchronized 返回值类型 方法名(方法参数) {
方法体;
}
- 同步方法的锁对象:this
同步静态方法,就是把 synchronized 关键字加到静态方法上:
修饰符 static synchronized 返回值类型 方法名(方法参数) {
方法体;
}
- 同步静态方法的锁对象:类名.class
用synchronized修饰方法可以把整个方法变为同步代码块,synchronized方法加锁对象是this。一个类没有特殊说明,默认不是thread-safe,通过合理的设计和数据封装可以让一个类变为“线程安全”。
代码示例:
public class MyRunnable implements Runnable {
private static int ticketCount = 100;
@Override
public void run() {
while(true){
if("窗口一".equals(Thread.currentThread().getName())){
//同步方法
boolean result = synchronizedMthod();
if(result){
break;
}
}
if("窗口二".equals(Thread.currentThread().getName())){
//同步代码块
synchronized (MyRunnable.class){
if(ticketCount == 0){
break;
}else{
try {
Thread.sleep(10);
} catch (InterruptedException e) {
e.printStackTrace();
}
ticketCount--;
System.out.println(Thread.currentThread().getName() + "在卖票,还剩下" + ticketCount + "张票");
}
}
}
}
}
// 同步静态方法
private static synchronized boolean synchronizedMthod() {
if(ticketCount == 0){
return true;
}else{
try {
Thread.sleep(10);
} catch (InterruptedException e) {
e.printStackTrace();
}
ticketCount--;
System.out.println(Thread.currentThread().getName() + "在卖票,还剩下" + ticketCount + "张票");
return false;
}
}
}
// 测试类
public class Demo {
public static void main(String[] args) {
MyRunnable mr = new MyRunnable();
Thread t1 = new Thread(mr);
Thread t2 = new Thread(mr);
t1.setName("窗口一");
t2.setName("窗口二");
t1.start();
t2.start();
}
}
我们来概括一下如何使用synchronized:
- 找出修改共享变量的线程代码块;
- 选择一个共享实例作为锁;
- 使用synchronized(lockObject) { ... }。
在使用synchronized的时候,不必担心抛出异常。因为无论是否有异常,都会在synchronized结束处正确释放锁:
public void add(int m) {
synchronized (obj) {
if (m < 0) {
throw new RuntimeException();
}
this.value += m;
} // 无论有无异常,都会在此释放锁
}
3、Lock 锁
虽然我们可以理解同步代码块和同步方法的锁对象问题,但是我们并没有直接看到在哪里加上了锁,在哪里释放了锁。
于是,为了更清晰的表达如何加锁和释放锁,从Java 5开始,引入了一个高级的处理并发的java.util.concurrent包,它提供了大量更高级的并发功能,能大大简化多线程程序的编写。
Lock 是接口,不能直接实例化,下面采用它的实现类 ReentrantLock 来实例化。
ReentrantLock 构造方法:
方法名 | 说明 |
---|---|
ReentrantLock() | 创建一个 ReentrantLock 的实例 |
加锁解锁方法:
方法名 | 说明 |
---|---|
void lock() | 获得锁 |
void unlock() | 释放锁 |
我们知道Java语言直接提供了synchronized关键字用于加锁,但这种锁一是很重,二是获取时必须一直等待,没有额外的尝试机制。
java.util.concurrent.locks包提供的ReentrantLock用于替代synchronized加锁,我们来看一下传统的synchronized代码:
public class Counter {
private int count;
public void add(int n) {
synchronized(this) {
count += n;
}
}
}
如果用ReentrantLock替代,可以把代码改造为:
public class Counter {
private final Lock lock = new ReentrantLock();
private int count;
public void add(int n) {
lock.lock();
try {
count += n;
} finally {
lock.unlock();
}
}
}
因为synchronized是Java语言层面提供的语法,所以我们不需要考虑异常,而ReentrantLock是Java代码实现的锁,我们就必须先获取锁,然后在finally中正确释放锁。
顾名思义,ReentrantLock是可重入锁,它和synchronized一样,一个线程可以多次获取同一个锁。
和synchronized不同的是,ReentrantLock可以尝试获取锁:
if (lock.tryLock(1, TimeUnit.SECONDS)) {
try {
...
} finally {
lock.unlock();
}
}
上述代码在尝试获取锁的时候,最多等待1秒。如果1秒后仍未获取到锁,tryLock()返回false,程序就可以做一些额外处理,而不是无限等待下去。
可以使用tryLock()尝试获取锁,先获取到锁,再进入try {...}代码块,最后使用finally保证释放锁。
所以,使用ReentrantLock比直接使用synchronized更安全,线程在tryLock()失败的时候不会导致死锁。
ReentrantLock使用示例:
public class Ticket implements Runnable {
// 票的数量
private int ticket = 100;
private Object obj = new Object();
private ReentrantLock lock = new ReentrantLock();
@Override
public void run() {
while (true) {
// synchronized (obj){ // 多个线程必须使用同一把锁
try {
lock.lock();
if (ticket <= 0) {
//卖完了
break;
} else {
Thread.sleep(100);
ticket--;
System.out.println(Thread.currentThread().getName() + "在卖票,还剩下" + ticket + "张票");
}
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
lock.unlock();
}
// }
}
}
}
// 测试类
public class Demo {
public static void main(String[] args) {
Ticket ticket = new Ticket();
Thread t1 = new Thread(ticket);
Thread t2 = new Thread(ticket);
Thread t3 = new Thread(ticket);
t1.setName("窗口一");
t2.setName("窗口二");
t3.setName("窗口三");
t1.start();
t2.start();
t3.start();
}
}
4、Condition
使用ReentrantLock比直接使用synchronized更安全,可以替代synchronized进行线程同步。
但是,synchronized可以配合wait和notify实现线程在条件不满足时等待,条件满足时唤醒,用ReentrantLock我们怎么编写wait和notify的功能呢?
答案是使用Condition对象来实现wait和notify的功能。
我们仍然以TaskQueue为例,把前面用synchronized实现的功能通过ReentrantLock和Condition来实现:
class TaskQueue {
private final Lock lock = new ReentrantLock();
private final Condition condition = lock.newCondition();
private Queue<String> queue = new LinkedList<>();
public void addTask(String s) {
lock.lock();
try {
queue.add(s);
condition.signalAll();
} finally {
lock.unlock();
}
}
public String getTask() {
lock.lock();
try {
while (queue.isEmpty()) {
condition.await();
}
return queue.remove();
} finally {
lock.unlock();
}
}
}
可见,使用Condition时,引用的Condition对象必须从Lock实例的newCondition()返回,这样才能获得一个绑定了Lock实例的Condition实例。
Condition提供的await()、signal()、signalAll()原理和synchronized锁对象的wait()、notify()、notifyAll()是一致的,并且其行为也是一样的:
-
await()会释放当前锁,进入等待状态;
-
signal()会唤醒某个等待线程;
-
signalAll()会唤醒所有等待线程;
-
唤醒线程从await()返回后需要重新获得锁。
此外,和tryLock()类似,await()可以在等待指定时间后,如果还没有被其他线程通过signal()或signalAll()唤醒,可以自己醒来:
if (condition.await(1, TimeUnit.SECOND)) {
// 被其他线程唤醒
} else {
// 指定时间内没有被其他线程唤醒
}
注意:Condition对象必须从Lock对象获取。
5、Concurrent集合
BlockingQueue的意思就是说,当一个线程调用这个TaskQueue的getTask()方法时,该方法内部可能会让线程变成等待状态,直到队列条件满足不为空,线程被唤醒后,getTask()方法才会返回。
我们已经通过ReentrantLock和Condition实现了一个BlockingQueue。
因为BlockingQueue非常有用,所以我们不必自己编写,可以直接使用Java标准库的java.util.concurrent包提供的线程安全的集合:ArrayBlockingQueue。
除了BlockingQueue外,针对List、Map、Set、Deque等,java.util.concurrent包也提供了对应的并发集合类。我们归纳一下:
interface | non-thread-safe | thread-safe |
---|---|---|
List | ArrayList | CopyOnWriteArrayList |
Map | HashMap | ConcurrentHashMap |
Set | HashSet / TreeSet | CopyOnWriteArraySet |
Queue | ArrayDeque / LinkedList | ArrayBlockingQueue / LinkedBlockingQueue |
Deque | ArrayDeque / LinkedList | LinkedBlockingDeque |
使用这些并发集合与使用非线程安全的集合类完全相同。我们以ConcurrentHashMap为例:
Map<String, String> map = new ConcurrentHashMap<>();
// 在不同的线程读写:
map.put("A", "1");
map.put("B", "2");
map.get("A", "1");
因为所有的同步和加锁的逻辑都在集合内部实现,对外部调用者来说,只需要正常按接口引用,其他代码和原来的非线程安全代码完全一样。即当我们需要多线程访问时,把:
Map<String, String> map = new HashMap<>();
改为:
Map<String, String> map = new ConcurrentHashMap<>();
就可以了。
java.util.Collections工具类还提供了一个旧的线程安全集合转换器,可以这么用:
Map unsafeMap = new HashMap();
Map threadSafeMap = Collections.synchronizedMap(unsafeMap);
但是它实际上是用一个包装类包装了非线程安全的Map,然后对所有读写方法都用synchronized加锁,这样获得的线程安全集合的性能比java.util.concurrent集合要低很多,所以不推荐使用。
注意:
- 多线程同时读写并发集合是安全的;
- 尽量使用Java标准库提供的并发集合,避免自己编写同步代码;
6、ReadWriteLock
前面讲到的ReentrantLock保证了只有一个线程可以执行临界区代码:
public class Counter {
private final Lock lock = new ReentrantLock();
private int[] counts = new int[10];
public void inc(int index) {
lock.lock();
try {
counts[index] += 1;
} finally {
lock.unlock();
}
}
public int[] get() {
lock.lock();
try {
return Arrays.copyOf(counts, counts.length);
} finally {
lock.unlock();
}
}
}
但是有些时候,这种保护有点过头。因为我们发现,任何时刻,只允许一个线程修改,也就是调用inc()方法是必须获取锁,但是,get()方法只读取数据,不修改数据,它实际上允许多个线程同时调用。
实际上我们想要的是:允许多个线程同时读,但只要有一个线程在写,其他线程就必须等待:
读 | 写 | |
---|---|---|
读 | 允许 | 不允许 |
写 | 不允许 | 不允许 |
使用ReadWriteLock可以解决这个问题,它保证:
- 只允许一个线程写入(其他线程既不能写入也不能读取);
- 没有写入时,多个线程允许同时读(提高性能)。
用ReadWriteLock实现这个功能十分容易。我们需要创建一个ReadWriteLock实例,然后分别获取读锁和写锁:
public class Counter {
private final ReadWriteLock rwlock = new ReentrantReadWriteLock();
private final Lock rlock = rwlock.readLock();
private final Lock wlock = rwlock.writeLock();
private int[] counts = new int[10];
public void inc(int index) {
wlock.lock(); // 加写锁
try {
counts[index] += 1;
} finally {
wlock.unlock(); // 释放写锁
}
}
public int[] get() {
rlock.lock(); // 加读锁
try {
return Arrays.copyOf(counts, counts.length);
} finally {
rlock.unlock(); // 释放读锁
}
}
}
把读写操作分别用读锁和写锁来加锁,在读取时,多个线程可以同时获得读锁,这样就大大提高了并发读的执行效率。
使用ReadWriteLock时,适用条件是同一个数据,有大量线程读取,但仅有少数线程修改。
例如,一个论坛的帖子,回复可以看做写入操作,它是不频繁的,但是,浏览可以看做读取操作,是非常频繁的,这种情况就可以使用ReadWriteLock。
注意:
-
ReadWriteLock只允许一个线程写入;
-
ReadWriteLock允许多个线程在没有写入时同时读取;
-
ReadWriteLock适合读多写少的场景;
7、StampedLock
前面介绍的ReadWriteLock可以解决多线程同时读,但只有一个线程能写的问题。
如果我们深入分析ReadWriteLock,会发现它有个潜在的问题:如果有线程正在读,写线程需要等待读线程释放锁后才能获取写锁,即读的过程中不允许写,这是一种悲观的读锁。
要进一步提升并发执行效率,Java 8引入了新的读写锁:StampedLock。
StampedLock和ReadWriteLock相比,改进之处在于:读的过程中也允许获取写锁后写入!这样一来,我们读的数据就可能不一致,所以,需要一点额外的代码来判断读的过程中是否有写入,这种读锁是一种乐观锁。
乐观锁的意思就是乐观地估计读的过程中大概率不会有写入,因此被称为乐观锁。反过来,悲观锁则是读的过程中拒绝有写入,也就是写入必须等待。显然乐观锁的并发效率更高,但一旦有小概率的写入导致读取的数据不一致,需要能检测出来,再读一遍就行。
我们来看例子:
public class Point {
private final StampedLock stampedLock = new StampedLock();
private double x;
private double y;
public void move(double deltaX, double deltaY) {
long stamp = stampedLock.writeLock(); // 获取写锁
try {
x += deltaX;
y += deltaY;
} finally {
stampedLock.unlockWrite(stamp); // 释放写锁
}
}
public double distanceFromOrigin() {
long stamp = stampedLock.tryOptimisticRead(); // 获得一个乐观读锁
// 注意下面两行代码不是原子操作
// 假设x,y = (100,200)
double currentX = x;
// 此处已读取到x=100,但x,y可能被写线程修改为(300,400)
double currentY = y;
// 此处已读取到y,如果没有写入,读取是正确的(100,200)
// 如果有写入,读取是错误的(100,400)
if (!stampedLock.validate(stamp)) { // 检查乐观读锁后是否有其他写锁发生
stamp = stampedLock.readLock(); // 获取一个悲观读锁
try {
currentX = x;
currentY = y;
} finally {
stampedLock.unlockRead(stamp); // 释放悲观读锁
}
}
return Math.sqrt(currentX * currentX + currentY * currentY);
}
}
和ReadWriteLock相比,写入的加锁是完全一样的,不同的是读取。注意到首先我们通过tryOptimisticRead()获取一个乐观读锁,并返回版本号。接着进行读取,读取完成后,我们通过validate()去验证版本号,如果在读取过程中没有写入,版本号不变,验证成功,我们就可以放心地继续后续操作。如果在读取过程中有写入,版本号会发生变化,验证将失败。在失败的时候,我们再通过获取悲观读锁再次读取。由于写入的概率不高,程序在绝大部分情况下可以通过乐观读锁获取数据,极少数情况下使用悲观读锁获取数据。
可见,StampedLock把读锁细分为乐观读和悲观读,能进一步提升并发效率。但这也是有代价的:一是代码更加复杂,二是StampedLock是不可重入锁,不能在一个线程中反复获取同一个锁。
StampedLock还提供了更复杂的将悲观读锁升级为写锁的功能,它主要使用在if-then-update的场景:即先读,如果读的数据满足条件,就返回,如果读的数据不满足条件,再尝试写。
注意:StampedLock是不可重入锁。
8、死锁
概述:线程死锁是指由于两个或者多个线程互相持有对方所需要的资源,导致这些线程处于等待状态,无法前往执行。
什么情况下会产生死锁:
- 资源有限
- 同步嵌套
示例代码:
public class Demo {
public static void main(String[] args) {
Object objA = new Object();
Object objB = new Object();
new Thread(()->{
while(true){
synchronized (objA){
// 线程一
synchronized (objB){
System.out.println("小康同学正在走路");
}
}
}
}).start();
new Thread(()->{
while(true){
synchronized (objB){
// 线程二
synchronized (objA){
System.out.println("小薇同学正在走路");
}
}
}
}).start();
}
}
9、乐观锁和悲观锁
定义:
-
乐观锁:一段执行逻辑加上乐观锁,不同线程同时执行时,线程可以同时进入执行阶段,在最后更新数据的时候要检查这些数据是否被其他线程修改了,没有修改则进行更新,否则放弃本次操作。
-
悲观锁:一段执行逻辑加上悲观锁,不同线程同时执行时,只能有一个线程执行,其他的线程在入口处等待,直到锁被释放。Java 中的 synchronized 和 Reentrantlock 等独占锁就是悲观锁思想的实现。
使用场景:
-
乐观锁适用于书写比较少的情况下,即冲突很少发生的时候,这样可以省去锁的开销,加大在整个系统的吞吐量。
-
如果是多写的情况,会产生冲突,导致上层应用会不断地进行充实,这样反倒降低了性能,因此在多写的情况下用悲观锁就比较合适。
10、不需要线程同步的操作
我们再来看一个错误使用synchronized的例子:
public class Main {
public static void main(String[] args) throws Exception {
var add = new AddThread();
var dec = new DecThread();
add.start();
dec.start();
add.join();
dec.join();
System.out.println(Counter.count);
}
}
class Counter {
public static final Object lock1 = new Object();
public static final Object lock2 = new Object();
public static int count = 0;
}
class AddThread extends Thread {
public void run() {
for (int i=0; i<10000; i++) {
synchronized(Counter.lock1) {
Counter.count += 1;
}
}
}
}
class DecThread extends Thread {
public void run() {
for (int i=0; i<10000; i++) {
synchronized(Counter.lock2) {
Counter.count -= 1;
}
}
}
}
结果并不是0,这是因为两个线程各自的synchronized锁住的不是同一个对象!这使得两个线程各自都可以同时获得锁:因为JVM只保证同一个锁在任意时刻只能被一个线程获取,但两个不同的锁在同一时刻可以被两个线程分别获取。
因此,使用synchronized的时候,获取到的是哪个锁非常重要。锁对象如果不对,代码逻辑就不对。
我们再看一个例子:
public class Main {
public static void main(String[] args) throws Exception {
var ts = new Thread[] { new AddStudentThread(), new DecStudentThread(), new AddTeacherThread(), new DecTeacherThread() };
for (var t : ts) {
t.start();
}
for (var t : ts) {
t.join();
}
System.out.println(Counter.studentCount);
System.out.println(Counter.teacherCount);
}
}
class Counter {
public static final Object lock = new Object();
public static int studentCount = 0;
public static int teacherCount = 0;
}
class AddStudentThread extends Thread {
public void run() {
for (int i=0; i<10000; i++) {
synchronized(Counter.lock) {
Counter.studentCount += 1;
}
}
}
}
class DecStudentThread extends Thread {
public void run() {
for (int i=0; i<10000; i++) {
synchronized(Counter.lock) {
Counter.studentCount -= 1;
}
}
}
}
class AddTeacherThread extends Thread {
public void run() {
for (int i=0; i<10000; i++) {
synchronized(Counter.lock) {
Counter.teacherCount += 1;
}
}
}
}
class DecTeacherThread extends Thread {
public void run() {
for (int i=0; i<10000; i++) {
synchronized(Counter.lock) {
Counter.teacherCount -= 1;
}
}
}
}
上述代码的4个线程对两个共享变量分别进行读写操作,但是使用的锁都是Counter.lock这一个对象,这就造成了原本可以并发执行的Counter.studentCount += 1和Counter.teacherCount += 1,现在无法并发执行了,执行效率大大降低。实际上,需要同步的线程可以分成两组:AddStudentThread和DecStudentThread,AddTeacherThread和DecTeacherThread,组之间不存在竞争,因此,应该使用两个不同的锁,即:
AddStudentThread和DecStudentThread使用lockStudent锁:
synchronized(Counter.lockStudent) {
...
}
AddTeacherThread和DecTeacherThread使用lockTeacher锁:
synchronized(Counter.lockTeacher) {
...
}
这样才能最大化地提高执行效率。
JVM规范定义了几种原子操作:
- 基本类型(long和double除外)赋值,例如:int n = m;
- 引用类型赋值,例如:List<String> list = anotherList。
long和double是64位数据,JVM没有明确规定64位赋值操作是不是一个原子操作,不过在x64平台的JVM是把long和double的赋值作为原子操作实现的。
单条原子操作的语句不需要同步。例如:
public void set(int m) {
synchronized(lock) {
this.value = m;
}
}
就不需要同步。
对引用也是类似。例如:
public void set(String s) {
this.value = s;
}
上述赋值语句并不需要同步。
但是,如果是多行赋值语句,就必须保证是同步操作,例如:
class Pair {
int first;
int last;
public void set(int first, int last) {
synchronized(this) {
this.first = first;
this.last = last;
}
}
}
有些时候,通过一些巧妙的转换,可以把非原子操作变为原子操作。例如,上述代码如果改造成:
class Pair {
int[] pair;
public void set(int first, int last) {
int[] ps = new int[] { first, last };
this.pair = ps;
}
}
就不再需要同步,因为this.pair = ps是引用赋值的原子操作。
而语句:
int[] ps = new int[] { first, last };
这里的ps是方法内部定义的局部变量,每个线程都会有各自的局部变量,互不影响,并且互不可见,并不需要同步。
总结:
- 多线程同时读写共享变量时,会造成逻辑错误,因此需要通过
synchronized
同步; - 同步的本质就是给指定对象加锁,加锁后才能继续执行后续代码;
- 注意加锁对象必须是同一个实例;
- 对JVM定义的单个原子操作不需要同步。
五、生产者和消费者模式
1、概述
生产者消费者模式是一个十分经典的多线程协作的模式,弄懂生产者消费者问题能够让我们对多线程编程的理解更加深刻。
所谓生产者消费者问题,实际上主要是包含了两类线程:
- 一类是生产者线程用于生产数据。
- 一类是消费者线程用于消费数据。
为了解耦生产者和消费者的关系,通常会采用共享的数据区域:
- 生产者生产数据之后直接放置在共享数据区中,并不需要关心消费者的行为。
- 消费者只需要从共享数据区中去获取数据,并不需要关心生产者的行为。
2、Object类的等待和唤醒方法
方法名 | 说明 |
---|---|
void wait() | 导致当前线程等待,直到另一个线程调用该对象的 notify() 方法或 notifyAll() 方法 |
void notify() | 唤醒正在等待对象监视器的单个线程 |
void notifyAll() | 唤醒正在等待对象监视器的所有线程 |
wait 方法注意:
- 在synchronized内部可以调用wait()使线程进入等待状态,且必须在已获得的锁对象上调用wait()方法;
- wait 方法必须在进入相应对象的 synchronized 块中才能调用。
- 执行 wait 方法之后,自动失去对象的 monitor,也就是说别的线程可以进入这个对象的 synchronized 代码块。
- 被唤醒的线程,就相当于执行过了 wait 方法,开始向下执行。
- 如果 wait 不是 synchronized 块中的最后一行,那么第一件事就是"排队"获取之前失去的 monitor 。
- synchronized 是非公平的,也就是说,不是谁先等待谁就能先获得。
notify/notifyAll 方法注意:
- 必须在已获得的锁对象上调用notify()或notifyAll()方法;
- notify/notifyAll 方法必须在进入相应对象的 synchronized 块中才能调用。
- 在synchronized内部可以调用notify()或notifyAll()唤醒其他等待线程;
- 已唤醒的线程还需要重新获得锁后才能继续执行。
如果 notify/notifyAll 在 wait 之前,会怎么样?
- 如果执行 notify 的时候,线程还没有进入 wait 状态,那么 notify 是没有效果的。先 notify,后进入 wait,就是所谓的 lost notification 问题,可能造成线程无法进行。
- 如果让唤醒的线程 sleep 的比 worker 短(sleep 时间 +1 变 -1,或者干脆不 sleep),也就是先进行 notify,那么就可能会造成这个问题。
- 为什么说可能呢?因为 synchronized 还是阻碍了 notify 的执行,但是 notify 有机会在 wait 前执行了。
3、wait和notify使用
在Java程序中,synchronized解决了多线程竞争的问题。例如,对于一个任务管理器,多个线程同时往队列中添加任务,可以用synchronized加锁:
class TaskQueue {
Queue<String> queue = new LinkedList<>();
public synchronized void addTask(String s) {
this.queue.add(s);
}
}
但是synchronized并没有解决多线程协调的问题。
仍然以上面的TaskQueue为例,我们再编写一个getTask()方法取出队列的第一个任务:
class TaskQueue {
Queue<String> queue = new LinkedList<>();
public synchronized void addTask(String s) {
this.queue.add(s);
}
public synchronized String getTask() {
while (queue.isEmpty()) {
}
return queue.remove();
}
}
上述代码看上去没有问题:getTask()内部先判断队列是否为空,如果为空,就循环等待,直到另一个线程往队列中放入了一个任务,while()循环退出,就可以返回队列的元素了。
但实际上while()循环永远不会退出。因为线程在执行while()循环时,已经在getTask()入口获取了this锁,其他线程根本无法调用addTask(),因为addTask()执行条件也是获取this锁。
因此,执行上述代码,线程会在getTask()中因为死循环而100%占用CPU资源。
如果深入思考一下,我们想要的执行效果是:
- 线程1可以调用addTask()不断往队列中添加任务;
- 线程2可以调用getTask()从队列中获取任务。如果队列为空,则getTask()应该等待,直到队列中至少有一个任务时再返回。
因此,多线程协调运行的原则就是:当条件不满足时,线程进入等待状态;当条件满足时,线程被唤醒,继续执行任务。
对于上述TaskQueue,我们先改造getTask()方法,在条件不满足时,线程进入等待状态:
public synchronized String getTask() {
while (queue.isEmpty()) {
this.wait();
}
return queue.remove();
}
当一个线程执行到getTask()方法内部的while循环时,它必定已经获取到了this锁,此时,线程执行while条件判断,如果条件成立(队列为空),线程将执行this.wait(),进入等待状态。
这里的关键是:wait()方法必须在当前获取的锁对象上调用,这里获取的是this锁,因此调用this.wait()。
调用wait()方法后,线程进入等待状态,wait()方法不会返回,直到将来某个时刻,线程从等待状态被其他线程唤醒后,wait()方法才会返回,然后,继续执行下一条语句。
有些仔细的童鞋会指出:即使线程在getTask()内部等待,其他线程如果拿不到this锁,照样无法执行addTask(),肿么办?
这个问题的关键就在于wait()方法的执行机制非常复杂。首先,它不是一个普通的Java方法,而是定义在Object类的一个native方法,也就是由JVM的C代码实现的。其次,必须在synchronized块中才能调用wait()方法,因为wait()方法调用时,会释放线程获得的锁,wait()方法返回后,线程又会重新试图获得锁。
因此,只能在锁对象上调用wait()方法。因为在getTask()中,我们获得了this锁,因此,只能在this对象上调用wait()方法:
public synchronized String getTask() {
while (queue.isEmpty()) {
// 释放this锁:
this.wait();
// 重新获取this锁
}
return queue.remove();
}
当一个线程在this.wait()等待时,它就会释放this锁,从而使得其他线程能够在addTask()方法获得this锁。
现在我们面临第二个问题:如何让等待的线程被重新唤醒,然后从wait()方法返回?答案是在相同的锁对象上调用notify()方法。我们修改addTask()如下:
public synchronized void addTask(String s) {
this.queue.add(s);
this.notify(); // 唤醒在this锁等待的线程
}
注意到在往队列中添加了任务后,线程立刻对this锁对象调用notify()方法,这个方法会唤醒一个正在this锁等待的线程(就是在getTask()中位于this.wait()的线程),从而使得等待线程从this.wait()方法返回。
我们来看一个完整的例子:
import java.util.*;
public class Main {
public static void main(String[] args) throws InterruptedException {
var q = new TaskQueue();
var ts = new ArrayList<Thread>();
for (int i=0; i<5; i++) {
var t = new Thread() {
public void run() {
// 执行task:
while (true) {
try {
String s = q.getTask();
System.out.println("execute task: " + s);
} catch (InterruptedException e) {
return;
}
}
}
};
t.start();
ts.add(t);
}
var add = new Thread(() -> {
for (int i=0; i<10; i++) {
// 放入task:
String s = "t-" + Math.random();
System.out.println("add task: " + s);
q.addTask(s);
try { Thread.sleep(100); } catch(InterruptedException e) {}
}
});
add.start();
add.join();
Thread.sleep(100);
for (var t : ts) {
t.interrupt();
}
}
}
class TaskQueue {
Queue<String> queue = new LinkedList<>();
public synchronized void addTask(String s) {
this.queue.add(s);
this.notifyAll();
}
public synchronized String getTask() throws InterruptedException {
while (queue.isEmpty()) {
this.wait();
}
return queue.remove();
}
}
这个例子中,我们重点关注addTask()方法,内部调用了this.notifyAll()而不是this.notify(),使用notifyAll()将唤醒所有当前正在this锁等待的线程,而notify()只会唤醒其中一个(具体哪个依赖操作系统,有一定的随机性)。这是因为可能有多个线程正在getTask()方法内部的wait()中等待,使用notifyAll()将一次性全部唤醒。通常来说,notifyAll()更安全。有些时候,如果我们的代码逻辑考虑不周,用notify()会导致只唤醒了一个线程,而其他线程可能永远等待下去醒不过来了。
但是,注意到wait()方法返回时需要重新获得this锁。假设当前有3个线程被唤醒,唤醒后,首先要等待执行addTask()的线程结束此方法后,才能释放this锁,随后,这3个线程中只能有一个获取到this锁,剩下两个将继续等待。
再注意到我们在while()循环中调用wait(),而不是if语句:
public synchronized String getTask() throws InterruptedException {
if (queue.isEmpty()) {
this.wait();
}
return queue.remove();
}
这种写法实际上是错误的,因为线程被唤醒时,需要再次获取this锁。多个线程被唤醒后,只有一个线程能获取this锁,此刻,该线程执行queue.remove()可以获取到队列的元素,然而,剩下的线程如果获取this锁后执行queue.remove(),此刻队列可能已经没有任何元素了,所以,要始终在while循环中wait(),并且每次被唤醒后拿到this锁就必须再次判断:
while (queue.isEmpty()) {
this.wait();
}
所以,正确编写多线程代码是非常困难的,需要仔细考虑的条件非常多,任何一个地方考虑不周,都会导致多线程运行时不正常。
4、生产者和消费者代码实现
案例需求:
-
桌子类(Desk):定义表示包子数量的变量,定义锁对象变量,定义标记桌子上有无包子的变量。
-
生产者类(Cooker):实现 Runnable 接口,重写 run() 方法,设置线程任务。
-
判断是否有包子,决定当前线程是否执行。
-
如果有包子,就进入等待状态,如果没有包子,继续执行,生产包子。
-
生产包子之后,更新桌子上包子状态,唤醒消费者消费包子。
-
-
消费者类(Foodie):实现 Runnable 接口,重写run() 方法,设置线程任务。
-
判断是否有包子,决定当前线程是否执行。
-
如果没有包子,就进入等待状态,如果有包子,就消费包子。
-
消费包子后,更新桌子上包子状态,唤醒生产者生产包子。
-
-
测试类(Demo):里面有 main 方法,main 方法中的代码步骤如下:
-
创建生产者线程和消费者线程对象。
-
分别开启两个线程。
-
// 桌子类(锁对象)
public class Desk {
// 定义一个标记
// true 就表示桌子上有汉堡包的,此时允许吃货执行
// false 就表示桌子上没有汉堡包的,此时允许厨师执行
public static boolean flag = false;
// 汉堡包的总数量
public static int count = 10;
// 锁对象
public static final Object lock = new Object();
}
// 消费者
public class Foodie extends Thread {
@Override
public void run() {
// 1. 判断桌子上是否有汉堡包。
// 2. 如果没有就等待。
// 3. 如果有就开吃
// 4. 吃完之后. 桌子上的汉堡包就没有了
// 叫醒等待的生产者继续生产
// 汉堡包的总数量减一
//套路:
//1. while(true)死循环
//2. synchronized 锁,锁对象要唯一
//3. 判断,共享数据是否结束. 结束
//4. 判断,共享数据是否结束. 没有结束
while(true){
synchronized (Desk.lock){
if(Desk.count == 0){
break;
}else{
if(Desk.flag){
//有
System.out.println("吃货在吃汉堡包");
Desk.flag = false;
Desk.lock.notifyAll();
Desk.count--;
}else{
//没有就等待
//使用什么对象当做锁,那么就必须用这个对象去调用等待和唤醒的方法.
try {
Desk.lock.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
}
}
}
// 生产者
public class Cooker extends Thread {
// 生产者步骤:
// 1. 判断桌子上是否有汉堡包
// 如果有就等待. 如果没有才生产。
// 2. 把汉堡包放在桌子上。
// 3. 叫醒等待的消费者开吃。
@Override
public void run() {
while(true){
synchronized (Desk.lock){
if(Desk.count == 0){
break;
}else{
if(!Desk.flag){
//生产
System.out.println("厨师正在生产汉堡包");
Desk.flag = true;
Desk.lock.notifyAll();
}else{
try {
Desk.lock.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
}
}
}
// 测试类
public class Demo {
public static void main(String[] args) {
/*消费者步骤:
1. 判断桌子上是否有汉堡包。
2. 如果没有就等待。
3. 如果有就开吃
4. 吃完之后. 桌子上的汉堡包就没有了
叫醒等待的生产者继续生产
汉堡包的总数量减一*/
/*生产者步骤:
1. 判断桌子上是否有汉堡包
如果有就等待. 如果没有才生产。
2. 把汉堡包放在桌子上。
3. 叫醒等待的消费者开吃。*/
Foodie f = new Foodie();
Cooker c = new Cooker();
f.start();
c.start();
}
}
5、代码优化
- 将 Desk 类中的变量,采用面向对象的方式封装起来。
- 生产者和消费者类中构造方法接收 Desk 类对象,之后在 run 方法中进行使用。
- 创建生产者和消费者线程对象,构造方法中传入 Desk 类对象。
- 开启两个线程。
// 桌子类
public class Desk {
// 定义一个标记
// true 就表示桌子上有汉堡包的,此时允许吃货执行
// false 就表示桌子上没有汉堡包的,此时允许厨师执行
// public static boolean flag = false;
private boolean flag;
// 汉堡包的总数量
// public static int count = 10;
// 以后我们在使用这种必须有默认值的变量
// private int count = 10;
private int count;
// 锁对象
// public static final Object lock = new Object();
private final Object lock = new Object();
public Desk() {
this(false,10); // 在空参内部调用带参,对成员变量进行赋值,之后就可以直接使用成员变量了
}
public Desk(boolean flag, int count) {
this.flag = flag;
this.count = count;
}
public boolean isFlag() {
return flag;
}
public void setFlag(boolean flag) {
this.flag = flag;
}
public int getCount() {
return count;
}
public void setCount(int count) {
this.count = count;
}
public Object getLock() {
return lock;
}
@Override
public String toString() {
return "Desk{" +
"flag=" + flag +
", count=" + count +
", lock=" + lock +
'}';
}
}
// 生产者类
public class Cooker extends Thread {
private Desk desk;
public Cooker(Desk desk) {
this.desk = desk;
}
// 生产者步骤:
// 1. 判断桌子上是否有汉堡包
// 如果有就等待. 如果没有才生产。
// 2. 把汉堡包放在桌子上。
// 3. 叫醒等待的消费者开吃。
@Override
public void run() {
while(true){
synchronized (desk.getLock()){
if(desk.getCount() == 0){
break;
}else{
// System.out.println("验证一下是否执行了");
if(!desk.isFlag()){
// 生产
System.out.println("厨师正在生产汉堡包");
desk.setFlag(true);
desk.getLock().notifyAll();
}else{
try {
desk.getLock().wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
}
}
}
// 消费者类
public class Foodie extends Thread {
private Desk desk;
public Foodie(Desk desk) {
this.desk = desk;
}
@Override
public void run() {
// 1. 判断桌子上是否有汉堡包。
// 2. 如果没有就等待。
// 3. 如果有就开吃
// 4. 吃完之后. 桌子上的汉堡包就没有了
// 叫醒等待的生产者继续生产
// 汉堡包的总数量减一
// 套路:
// 1. while(true)死循环
// 2. synchronized 锁,锁对象要唯一
// 3. 判断,共享数据是否结束. 结束
// 4. 判断,共享数据是否结束. 没有结束
while(true){
synchronized (desk.getLock()){
if(desk.getCount() == 0){
break;
}else{
// System.out.println("验证一下是否执行了");
if(desk.isFlag()){
// 有
System.out.println("吃货在吃汉堡包");
desk.setFlag(false);
desk.getLock().notifyAll();
desk.setCount(desk.getCount() - 1);
}else{
// 没有就等待
// 使用什么对象当做锁,那么就必须用这个对象去调用等待和唤醒的方法.
try {
desk.getLock().wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
}
}
}
public class Demo {
public static void main(String[] args) {
/*消费者步骤:
1. 判断桌子上是否有汉堡包。
2. 如果没有就等待。
3. 如果有就开吃
4. 吃完之后. 桌子上的汉堡包就没有了
叫醒等待的生产者继续生产
汉堡包的总数量减一*/
/*生产者步骤:
1. 判断桌子上是否有汉堡包
如果有就等待. 如果没有才生产。
2. 把汉堡包放在桌子上。
3. 叫醒等待的消费者开吃。*/
Desk desk = new Desk();
Foodie f = new Foodie(desk);
Cooker c = new Cooker(desk);
f.start();
c.start();
}
}
六、阻塞队列
阻塞队列的继承结构:
常见 BlockingQueue:
-
ArrayBlockingQueue:底层是数组,有界。
-
LinkedBlockingQueue:底层是链表,无界。但不是真正的无界,为 int 的最大值。
BlockingQueue 的核心方法:
-
put(anObject): 将参数放入队列,如果放不进去会阻塞。
-
take(): 取出第一个数据,取不到会阻塞。
代码示例:
public class Demo {
public static void main(String[] args) throws Exception {
// 创建阻塞队列的对象,容量为 1
ArrayBlockingQueue<String> arrayBlockingQueue = new ArrayBlockingQueue<>(1);
// 存储元素
arrayBlockingQueue.put("汉堡包");
// 取元素
System.out.println(arrayBlockingQueue.take());
System.out.println(arrayBlockingQueue.take()); // 取不到会阻塞
System.out.println("程序结束了");
}
}
阻塞队列实现等待唤醒机制:
-
生产者类(Cooker):实现 Runnable 接口,重写 run() 方法,设置线程任务。
-
构造方法中接收一个阻塞队列对象。
-
在run方法中循环向阻塞队列中添加包子。
-
打印添加结果。
-
-
消费者类(Foodie):实现 Runnable 接口,重写 run() 方法,设置线程任务。
-
构造方法中接收一个阻塞队列对象。
-
在run方法中循环获取阻塞队列中的包子。
-
打印获取结果。
-
-
测试类(Demo):里面有 main 方法,main 方法中的代码步骤如下:
-
创建阻塞队列对象。
-
创建生产者线程和消费者线程对象,构造方法中传入阻塞队列对象。
-
分别开启两个线程。
-
public class Cooker extends Thread {
private ArrayBlockingQueue<String> bd;
public Cooker(ArrayBlockingQueue<String> bd) {
this.bd = bd;
}
// 生产者步骤:
// 1,判断桌子上是否有汉堡包:如果有就等待,如果没有才生产
// 2,把汉堡包放在桌子上。
// 3,叫醒等待的消费者开吃。
@Override
public void run() {
while (true) {
try {
bd.put("汉堡包");
System.out.println("厨师放入一个汉堡包");
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
public class Foodie extends Thread {
private ArrayBlockingQueue<String> bd;
public Foodie(ArrayBlockingQueue<String> bd) {
this.bd = bd;
}
@Override
public void run() {
// 1. 判断桌子上是否有汉堡包。
// 2. 如果没有就等待。
// 3. 如果有就开吃,汉堡包的总数量减一
// 4. 吃完之后,桌子上的汉堡包就没有了
// 5. 叫醒等待的生产者继续生产
while (true) {
try {
String take = bd.take();
System.out.println("吃货将" + take + "拿出来吃了");
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
public class Demo {
public static void main(String[] args) {
ArrayBlockingQueue<String> bd = new ArrayBlockingQueue<>(1);
Foodie f = new Foodie(bd);
Cooker c = new Cooker(bd);
f.start();
c.start();
}
}
七、ThreadLocal
多线程是Java实现多任务的基础,Thread对象代表一个线程,我们可以在代码中调用Thread.currentThread()获取当前线程。例如,打印日志时,可以同时打印出当前线程的名字:
public class Main {
public static void main(String[] args) throws Exception {
log("start main...");
new Thread(() -> {
log("run task...");
}).start();
new Thread(() -> {
log("print...");
}).start();
log("end main.");
}
static void log(String s) {
System.out.println(Thread.currentThread().getName() + ": " + s);
}
}
对于多任务,Java标准库提供的线程池可以方便地执行这些任务,同时复用线程。Web应用程序就是典型的多任务应用,每个用户请求页面时,我们都会创建一个任务,类似:
public void process(User user) {
checkPermission();
doWork();
saveStatus();
sendResponse();
}
然后,通过线程池去执行这些任务。
观察process()方法,它内部需要调用若干其他方法,同时,我们遇到一个问题:如何在一个线程内传递状态?
process()方法需要传递的状态就是User实例。有的童鞋会想,简单地传入User就可以了:
public void process(User user) {
checkPermission(user);
doWork(user);
saveStatus(user);
sendResponse(user);
}
但是往往一个方法又会调用其他很多方法,这样会导致User传递到所有地方:
void doWork(User user) {
queryStatus(user);
checkStatus();
setNewStatus(user);
log();
}
这种在一个线程中,横跨若干方法调用,需要传递的对象,我们通常称之为上下文(Context),它是一种状态,可以是用户身份、任务信息等。
给每个方法增加一个context参数非常麻烦,而且有些时候,如果调用链有无法修改源码的第三方库,User对象就传不进去了。
Java标准库提供了一个特殊的ThreadLocal,它可以在一个线程中传递同一个对象。
ThreadLocal 原理:
ThreadLocal
实例通常总是以静态字段初始化如下:
// ThreadLocal一般都是 static 的
// 使用 ThreadLocal 后,这些数据就不会在应用程序之间传递,而是为每个线程保留自己的一份数据
private static final ThreadLocal<List<Phase>> PHASES = new ThreadLocal<>();
private static final ThreadLocal<Long> PHASE_START_TIME = new ThreadLocal<>();
ThreadLocalMap 里处理 hash 冲突的机制不是像 HashMap 一样使用 List,它采用的是另一种经典的处理方式,沿着冲突的索引向后查找空闲的位置。
典型使用方式如下:
void processUser(user) {
try {
threadLocalUser.set(user);
step1();
step2();
} finally {
threadLocalUser.remove();
}
}
通过设置一个User实例关联到ThreadLocal中,在移除之前,所有方法都可以随时获取到该User实例:
void step1() {
User u = threadLocalUser.get();
log();
printUser();
}
void log() {
User u = threadLocalUser.get();
println(u.name);
}
void step2() {
User u = threadLocalUser.get();
checkUser(u.id);
}
注意到普通的方法调用一定是同一个线程执行的,所以,step1()、step2()以及log()方法内,threadLocalUser.get()获取的User对象是同一个实例。
实际上,可以把ThreadLocal看成一个全局Map<Thread, Object>:每个线程获取ThreadLocal变量时,总是使用Thread自身作为key:
Object threadLocalValue = threadLocalMap.get(Thread.currentThread());
因此,ThreadLocal相当于给每个线程都开辟了一个独立的存储空间,各个线程的ThreadLocal关联的实例互不干扰。
最后,特别注意ThreadLocal一定要在finally中清除:
try {
threadLocalUser.set(user);
...
} finally {
threadLocalUser.remove();
}
这是因为当前线程执行完相关代码后,很可能会被重新放入线程池中,如果ThreadLocal没有被清除,该线程执行其他代码时,会把上一次的状态带进去。
为了保证能释放ThreadLocal关联的实例,我们可以通过AutoCloseable接口配合try (resource) {...}结构,让编译器自动为我们关闭。例如,一个保存了当前用户名的ThreadLocal可以封装为一个UserContext对象:
public class UserContext implements AutoCloseable {
static final ThreadLocal<String> ctx = new ThreadLocal<>();
public UserContext(String user) {
ctx.set(user);
}
public static String currentUser() {
return ctx.get();
}
@Override
public void close() {
ctx.remove();
}
}
使用的时候,我们借助try (resource) {...}结构,可以这么写:
try (var ctx = new UserContext("Bob")) {
// 可任意调用UserContext.currentUser():
String currentUser = UserContext.currentUser();
} // 在此自动调用UserContext.close()方法释放ThreadLocal关联对象
这样就在UserContext中完全封装了ThreadLocal,外部代码在try (resource) {...}内部可以随时调用UserContext.currentUser()获取当前线程绑定的用户名。
注意:
- ThreadLocal表示线程的“局部变量”,它确保每个线程的ThreadLocal变量都是各自独立的;
- ThreadLocal适合在一个线程的处理流程中保持上下文(避免了同一参数在所有方法中传递);
- 使用ThreadLocal要用try ... finally结构,并在finally中清除;
八、volatile关键字
volatile 关键字强制每次都从主存获取变量数据,主要针对指令重排有所影响。
1、指令重排
指令重排:程序真的是按照“顺序”执行的吗?并不是。
-
单个线程中的两条语句,未必是按顺序执行,这就是单线程的重排序,但必须保证最终一致性。
-
为什么会乱序?主要是为了提高效率(在等待费时的指令执行的时候,优先执行后面的指令)。
代码示例:
public class DataHolder {
int a, b, c, d, f, g;
// long e;
// TODO 有 volatile 修饰就会影响之前的指令重排
volatile long e;
public void operateData() {
// TODO 按照这个顺序执行,g 的值是肯定小于等于 e 的。但是实际执行在执行的时候,可能会为了优化的目的重排
a += 1;
b += 1;
c += 1;
d += 1;
e += 1;
f += 1;
g += 1;
}
int counter;
public void check() {
// TODO 看似不可能的条件,实际可能被触发到
if (g > e) {
System.out.println("got it " + (counter++));
}
}
}
2、JMM
JMM(Java Memory Model)翻译为 Java 内存模型,我们可以简单的认为是一套 happens-before 标准,规定了内存同步和缓存失效等节点,限制了指令重排。
JMM 是 Java 的内涵之一。Java 字节码(Java Byte Code)使得 Java 在指令层面有了统一的标准。JMM 更让 Java 在执行优化层面也有了统一的标准。让各大厂商可以根据操作系统和硬件,在执行优化上放飞自我。
代码示例:
package com.geekbang.learnvolatile;
public class AccessMemoryVolatile {
public volatile long counterV = 0;
public long counter = 0;
public static void main(String[] args) {
int loopCount = Integer.MAX_VALUE / 30;
// 只是为了演示 volatile 每次访问都要直达内存,不能使用缓存,所以耗费的时间略多
AccessMemoryVolatile accessMemoryVolatile = new AccessMemoryVolatile();
Thread volatileAdder = new Thread(() -> {
long start = System.currentTimeMillis();
for (int i = 0; i < loopCount; i++) {
accessMemoryVolatile.counterV++;
}
System.out.println("volatile adder takes " + (System.currentTimeMillis() - start));
});
volatileAdder.start();
Thread justAdder = new Thread(() -> {
long start = System.currentTimeMillis();
for (int i = 0; i < loopCount; i++) {
accessMemoryVolatile.counter++;
}
System.out.println("simple adder takes " + (System.currentTimeMillis() - start));
});
justAdder.start();
}
}
九、线程池
1、基本原理
概述:
提到池,大家应该能想到的就是水池。水池就是一个容器,在该容器中存储了很多的水。那么什么是线程池呢?线程池也是可以看做成一个池子,在该池子中存储很多个线程。
线程池存在的意义:
-
系统创建一个线程的成本是比较高的,因为它涉及到与操作系统交互。当程序中需要创建大量生存期很短暂的线程时,频繁的创建和销毁线程对系统的资源消耗有可能大于业务处理本身对系统资源的消耗,这样就有点"舍本逐末"了。
-
针对这一种情况,为了提高性能,我们就可以采用线程池。线程池在启动的时,会创建大量空闲线程,当我们向线程池提交任务的时,线程池就会启动一个线程来执行该任务。等待任务执行完毕以后,线程并不会死亡,而是再次返回到线程池中成为空闲状态,等待下一次任务的执行。
线程池工作原理:
为了形象描述线程池执行,加深理解,打个比喻:
- 核心线程比作公司正式员工;
- 非核心线程比作外包员工;
- 阻塞队列比作需求池;
- 提交任务比作提需求。
- 当产品提个需求,正式员工(核心线程)先接需求(执行任务)。
- 如果正式员工都有需求在做,即核心线程数已满,产品就把需求先放需求池(阻塞队列)。
- 如果需求池(阻塞队列)也满了,但是这时候产品继续提需求,怎么办呢?那就请外包(非核心线程)来做。
- 如果所有员工(最大线程数也满了)都有需求在做了,那就执行饱和策略。
- 如果外包员工把需求做完了,他经过一段(keepAliveTime)空闲时间,就离开公司了。
线程池的饱和策略事件,主要有四种类型:
- AbortPolicy(抛出一个异常,默认的)
- DiscardPolicy(新提交的任务直接被抛弃)
- DiscardOldestPolicy(丢弃队列里最老的任务,将当前这个任务继续提交给线程池)
- CallerRunsPolicy(交给线程池调用所在的线程进行处理,即将某些任务回退到调用者)
2、Executors 实现线程池
JDK 对线程池也进行了相关的实现,在真实企业开发中我们也很少去自定义线程池,而是使用 JDK 中自带的线程池。
我们可以使用 Executors 中所提供的静态方法来创建线程池。
- static ExecutorService newCachedThreadPool():创建一个默认的线程池。
- static ExecutorService newFixedThreadPool(int nThreads):创建一个指定最多线程数量的线程池。
示例代码:默认线程池。
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class Test {
public static void main(String[] args) throws InterruptedException {
// 创建一个默认的线程池对象,池子中默认是空的,默认最多可以容纳int类型的最大值
ExecutorService executorService = Executors.newCachedThreadPool();
// Executors --- 可以帮助我们创建线程池对象
// ExecutorService --- 可以帮助我们控制线程池
executorService.submit(()->{
System.out.println(Thread.currentThread().getName() + "在执行了");
});
//Thread.sleep(2000);
executorService.submit(()->{
System.out.println(Thread.currentThread().getName() + "在执行了");
});
executorService.shutdown();
}
}
示例代码:创建指定上限的线程池。
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.ThreadPoolExecutor;
public class Test {
public static void main(String[] args) {
// 参数不是初始值而是最大值
ExecutorService executorService = Executors.newFixedThreadPool(10);
ThreadPoolExecutor pool = (ThreadPoolExecutor) executorService;
System.out.println(pool.getPoolSize()); // 0
executorService.submit(()->{
System.out.println(Thread.currentThread().getName() + "在执行了");
});
executorService.submit(()->{
System.out.println(Thread.currentThread().getName() + "在执行了");
});
System.out.println(pool.getPoolSize()); // 2
// executorService.shutdown();
}
}
3、ThreadPoolExecutor实现线程池
4、非默认的任务拒绝策略
RejectedExecutionHandler 是 JDK 提供的一个任务拒绝策略接口,它下面存在 4 个子类:
- ThreadPoolExecutor.AbortPolicy:丢弃任务并抛出 RejectedExecutionException 异常(是默认的策略)。
- ThreadPoolExecutor.DiscardPolicy:丢弃任务,但是不抛出异常 这是不推荐的做法。
- ThreadPoolExecutor.DiscardOldestPolicy:抛弃队列中等待最久的任务 然后把当前任务加入队列中。
- ThreadPoolExecutor.CallerRunsPolicy:调用任务的 run() 方法绕过线程池直接执行。
注:明确线程池最多可执行的任务数 = 队列容量 + 最大线程数
1. ThreadPoolExecutor.AbortPolicy
public static void main(String[] args) {
/**
* 核心线程数量为1,最大线程池数量为3, 任务容器的容量为1 ,空闲线程的最大存在时间为20s
*/
ThreadPoolExecutor threadPoolExecutor = new ThreadPoolExecutor(1, 3, 20, TimeUnit.SECONDS,
new ArrayBlockingQueue<>(1), Executors.defaultThreadFactory(), new ThreadPoolExecutor.AbortPolicy()) ;
// 提交5个任务,而该线程池最多可以处理4个任务,当我们使用AbortPolicy这个任务处理策略的时候,就会抛出异常
for(int x = 0; x < 5; x++) {
threadPoolExecutor.submit(() -> {
System.out.println(Thread.currentThread().getName() + "---->> 执行了任务");
});
}
}
控制台输出结果:
pool-1-thread-1---->> 执行了任务
pool-1-thread-3---->> 执行了任务
pool-1-thread-2---->> 执行了任务
pool-1-thread-3---->> 执行了任务
控制台报错,仅仅执行了 4 个任务,有 1 个任务被丢弃了。
### ThreadPoolExecutor.DiscardPolicy
~~~java
public static void main(String[] args) {
/**
* 核心线程数量为1,最大线程池数量为3,任务容器的容量为1,空闲线程的最大存在时间为 20s
*/
ThreadPoolExecutor threadPoolExecutor = new ThreadPoolExecutor(1, 3, 20, TimeUnit.SECONDS,
new ArrayBlockingQueue<>(1), Executors.defaultThreadFactory(), new ThreadPoolExecutor.DiscardPolicy());
// 提交5个任务,而该线程池最多可以处理4个任务,当我们使用DiscardPolicy这个任务处理策略的时候,控制台不会报错
for(int x = 0; x < 5; x++) {
threadPoolExecutor.submit(() -> {
System.out.println(Thread.currentThread().getName() + "---->> 执行了任务");
});
}
}
控制台输出结果:
pool-1-thread-1---->> 执行了任务
pool-1-thread-1---->> 执行了任务
pool-1-thread-3---->> 执行了任务
pool-1-thread-2---->> 执行了任务
控制台没有报错,仅仅执行了 4 个任务,有 1 个任务被丢弃了。
2. ThreadPoolExecutor.DiscardOldestPolicy
public static void main(String[] args) {
/**
* 核心线程数量为1,最大线程池数量为3,任务容器的容量为1,空闲线程的最大存在时间为20s
*/
ThreadPoolExecutor threadPoolExecutor;
threadPoolExecutor = new ThreadPoolExecutor(1, 3, 20, TimeUnit.SECONDS,
new ArrayBlockingQueue<>(1), Executors.defaultThreadFactory(), new ThreadPoolExecutor.DiscardOldestPolicy());
// 提交5个任务
for(int x = 0; x < 5; x++) {
// 定义一个变量,来指定当前执行的任务,这个变量需要被final修饰
final int y = x ;
threadPoolExecutor.submit(() -> {
System.out.println(Thread.currentThread().getName() + "---->> 执行了任务" + y);
});
}
}
控制台输出结果:
pool-1-thread-2---->> 执行了任务2
pool-1-thread-1---->> 执行了任务0
pool-1-thread-3---->> 执行了任务3
pool-1-thread-1---->> 执行了任务4
由于任务 1 在线程池中等待时间最长,因此任务 1 被丢弃。
3. ThreadPoolExecutor.CallerRunsPolicy
public static void main(String[] args) {
/**
* 核心线程数量为1,最大线程池数量为3,任务容器的容量为1,空闲线程的最大存在时间为20s
*/
ThreadPoolExecutor threadPoolExecutor;
threadPoolExecutor = new ThreadPoolExecutor(1, 3, 20, TimeUnit.SECONDS,
new ArrayBlockingQueue<>(1), Executors.defaultThreadFactory(), new ThreadPoolExecutor.CallerRunsPolicy());
// 提交5个任务
for(int x = 0; x < 5; x++) {
threadPoolExecutor.submit(() -> {
System.out.println(Thread.currentThread().getName() + "---->> 执行了任务");
});
}
}
控制台输出结果:
pool-1-thread-1---->> 执行了任务
pool-1-thread-3---->> 执行了任务
pool-1-thread-2---->> 执行了任务
pool-1-thread-1---->> 执行了任务
main---->> 执行了任务
通过控制台的输出,我们可以看到次策略没有通过线程池中的线程执行任务,而是直接调用任务的 run() 方法绕过线程池直接执行。
5、Future
可以用Future在将来某个时刻获取结果。对线程池提交一个Callable任务,可以获得一个Future对象。
在执行多个任务的时候,使用Java标准库提供的线程池是非常方便的。我们提交的任务只需要实现Runnable接口,就可以让线程池去执行:
class Task implements Runnable {
public String result;
public void run() {
this.result = longTimeCalculation();
}
}
Runnable接口有个问题,它的方法没有返回值。如果任务需要一个返回结果,那么只能保存到变量,还要提供额外的方法读取,非常不便。
所以,Java标准库还提供了一个Callable接口,和Runnable接口比,它多了一个返回值:
class Task implements Callable<String> {
public String call() throws Exception {
return longTimeCalculation();
}
}
并且Callable接口是一个泛型接口,可以返回指定类型的结果。
现在的问题是,如何获得异步执行的结果?
如果仔细看ExecutorService.submit()方法,可以看到,它返回了一个Future类型,一个Future类型的实例代表一个未来能获取结果的对象:
ExecutorService executor = Executors.newFixedThreadPool(4);
// 定义任务:
Callable<String> task = new Task();
// 提交任务并获得Future:
Future<String> future = executor.submit(task);
// 从Future获取异步执行返回的结果:
String result = future.get(); // 可能阻塞
当我们提交一个Callable任务后,我们会同时获得一个Future对象,然后,我们在主线程某个时刻调用Future对象的get()方法,就可以获得异步执行的结果。在调用get()时,如果异步任务已经完成,我们就直接获得结果。如果异步任务还没有完成,那么get()会阻塞,直到任务完成后才返回结果。
一个Future<V>接口表示一个未来可能会返回的结果,它定义的方法有:
- get():获取结果(可能会等待)
- get(long timeout, TimeUnit unit):获取结果,但只等待指定的时间;
- cancel(boolean mayInterruptIfRunning):取消当前任务;
- isDone():判断任务是否已完成。
6、CompletableFuture
使用Future获得异步执行结果时,要么调用阻塞方法get(),要么轮询看isDone()是否为true,这两种方法都不是很好,因为主线程也会被迫等待。
从Java 8开始引入了CompletableFuture,它针对Future做了改进,可以传入回调对象,当异步任务完成或者发生异常时,自动调用回调对象的回调方法。
我们以获取股票价格为例,看看如何使用CompletableFuture:
// CompletableFuture
import java.util.concurrent.CompletableFuture;
public class Main {
public static void main(String[] args) throws Exception {
// 创建异步执行任务:
CompletableFuture<Double> cf = CompletableFuture.supplyAsync(Main::fetchPrice);
// 如果执行成功:
cf.thenAccept((result) -> {
System.out.println("price: " + result);
});
// 如果执行异常:
cf.exceptionally((e) -> {
e.printStackTrace();
return null;
});
// 主线程不要立刻结束,否则CompletableFuture默认使用的线程池会立刻关闭:
Thread.sleep(200);
}
static Double fetchPrice() {
try {
Thread.sleep(100);
} catch (InterruptedException e) {
}
if (Math.random() < 0.3) {
throw new RuntimeException("fetch price failed!");
}
return 5 + Math.random() * 20;
}
}
创建一个CompletableFuture是通过CompletableFuture.supplyAsync()实现的,它需要一个实现了Supplier接口的对象:
public interface Supplier<T> {
T get();
}
这里我们用lambda语法简化了一下,直接传入Main::fetchPrice,因为Main.fetchPrice()静态方法的签名符合Supplier接口的定义(除了方法名外)。
紧接着,CompletableFuture已经被提交给默认的线程池执行了,我们需要定义的是CompletableFuture完成时和异常时需要回调的实例。完成时,CompletableFuture会调用Consumer对象:
public interface Consumer<T> {
void accept(T t);
}
异常时,CompletableFuture会调用Function对象:
public interface Function<T, R> {
R apply(T t);
}
这里我们都用lambda语法简化了代码。
可见CompletableFuture的优点是:
- 异步任务结束时,会自动回调某个对象的方法;
- 异步任务出错时,会自动回调某个对象的方法;
- 主线程设置好回调后,不再关心异步任务的执行。
如果只是实现了异步回调机制,我们还看不出CompletableFuture相比Future的优势。CompletableFuture更强大的功能是,多个CompletableFuture可以串行执行,例如,定义两个CompletableFuture,第一个CompletableFuture根据证券名称查询证券代码,第二个CompletableFuture根据证券代码查询证券价格,这两个CompletableFuture实现串行操作如下:
// CompletableFuture
import java.util.concurrent.CompletableFuture;
public class Main {
public static void main(String[] args) throws Exception {
// 第一个任务:
CompletableFuture<String> cfQuery = CompletableFuture.supplyAsync(() -> {
return queryCode("中国石油");
});
// cfQuery成功后继续执行下一个任务:
CompletableFuture<Double> cfFetch = cfQuery.thenApplyAsync((code) -> {
return fetchPrice(code);
});
// cfFetch成功后打印结果:
cfFetch.thenAccept((result) -> {
System.out.println("price: " + result);
});
// 主线程不要立刻结束,否则CompletableFuture默认使用的线程池会立刻关闭:
Thread.sleep(2000);
}
static String queryCode(String name) {
try {
Thread.sleep(100);
} catch (InterruptedException e) {
}
return "601857";
}
static Double fetchPrice(String code) {
try {
Thread.sleep(100);
} catch (InterruptedException e) {
}
return 5 + Math.random() * 20;
}
}
除了串行执行外,多个CompletableFuture还可以并行执行。例如,我们考虑这样的场景:
同时从新浪和网易查询证券代码,只要任意一个返回结果,就进行下一步查询价格,查询价格也同时从新浪和网易查询,只要任意一个返回结果,就完成操作:
// CompletableFuture
import java.util.concurrent.CompletableFuture;
public class Main {
public static void main(String[] args) throws Exception {
// 两个CompletableFuture执行异步查询:
CompletableFuture<String> cfQueryFromSina = CompletableFuture.supplyAsync(() -> {
return queryCode("中国石油", "https://finance.sina.com.cn/code/");
});
CompletableFuture<String> cfQueryFrom163 = CompletableFuture.supplyAsync(() -> {
return queryCode("中国石油", "https://money.163.com/code/");
});
// 用anyOf合并为一个新的CompletableFuture:
CompletableFuture<Object> cfQuery = CompletableFuture.anyOf(cfQueryFromSina, cfQueryFrom163);
// 两个CompletableFuture执行异步查询:
CompletableFuture<Double> cfFetchFromSina = cfQuery.thenApplyAsync((code) -> {
return fetchPrice((String) code, "https://finance.sina.com.cn/price/");
});
CompletableFuture<Double> cfFetchFrom163 = cfQuery.thenApplyAsync((code) -> {
return fetchPrice((String) code, "https://money.163.com/price/");
});
// 用anyOf合并为一个新的CompletableFuture:
CompletableFuture<Object> cfFetch = CompletableFuture.anyOf(cfFetchFromSina, cfFetchFrom163);
// 最终结果:
cfFetch.thenAccept((result) -> {
System.out.println("price: " + result);
});
// 主线程不要立刻结束,否则CompletableFuture默认使用的线程池会立刻关闭:
Thread.sleep(200);
}
static String queryCode(String name, String url) {
System.out.println("query code from " + url + "...");
try {
Thread.sleep((long) (Math.random() * 100));
} catch (InterruptedException e) {
}
return "601857";
}
static Double fetchPrice(String code, String url) {
System.out.println("query price from " + url + "...");
try {
Thread.sleep((long) (Math.random() * 100));
} catch (InterruptedException e) {
}
return 5 + Math.random() * 20;
}
}
上述逻辑实现的异步查询规则实际上是:
除了anyOf()可以实现“任意个CompletableFuture只要一个成功”,allOf()可以实现“所有CompletableFuture都必须成功”,这些组合操作可以实现非常复杂的异步流程控制。
最后我们注意CompletableFuture的命名规则:
- xxx():表示该方法将继续在已有的线程中执行;
- xxxAsync():表示将异步在线程池中执行。
CompletableFuture可以指定异步处理流程:
- thenAccept()处理正常结果;
- exceptional()处理异常结果;
- thenApplyAsync()用于串行化另一个CompletableFuture;
- anyOf()和allOf()用于并行化多个CompletableFuture;
7、ForkJoin
Java 7开始引入了一种新的Fork/Join线程池,它可以执行一种特殊的任务:把一个大任务拆成多个小任务并行执行。
我们举个例子:如果要计算一个超大数组的和,最简单的做法是用一个循环在一个线程内完成:
还有一种方法,可以把数组拆成两部分,分别计算,最后加起来就是最终结果,这样可以用两个线程并行执行:
如果拆成两部分还是很大,我们还可以继续拆,用4个线程并行执行:
这就是Fork/Join任务的原理:判断一个任务是否足够小,如果是,直接计算,否则,就分拆成几个小任务分别计算。这个过程可以反复“裂变”成一系列小任务。
Fork/Join是一种基于“分治”的算法:通过分解任务,并行执行,最后合并结果得到最终结果。
ForkJoinPool线程池可以把一个大任务分拆成小任务并行执行,任务类必须继承自RecursiveTask或RecursiveAction。
我们来看如何使用Fork/Join对大数据进行并行求和:
import java.util.Random;
import java.util.concurrent.*;
public class Main {
public static void main(String[] args) throws Exception {
// 创建2000个随机数组成的数组:
long[] array = new long[2000];
long expectedSum = 0;
for (int i = 0; i < array.length; i++) {
array[i] = random();
expectedSum += array[i];
}
System.out.println("Expected sum: " + expectedSum);
// fork/join:
ForkJoinTask<Long> task = new SumTask(array, 0, array.length);
long startTime = System.currentTimeMillis();
Long result = ForkJoinPool.commonPool().invoke(task);
long endTime = System.currentTimeMillis();
System.out.println("Fork/join sum: " + result + " in " + (endTime - startTime) + " ms.");
}
static Random random = new Random(0);
static long random() {
return random.nextInt(10000);
}
}
class SumTask extends RecursiveTask<Long> {
static final int THRESHOLD = 500;
long[] array;
int start;
int end;
SumTask(long[] array, int start, int end) {
this.array = array;
this.start = start;
this.end = end;
}
@Override
protected Long compute() {
if (end - start <= THRESHOLD) {
// 如果任务足够小,直接计算:
long sum = 0;
for (int i = start; i < end; i++) {
sum += this.array[i];
// 故意放慢计算速度:
try {
Thread.sleep(1);
} catch (InterruptedException e) {
}
}
return sum;
}
// 任务太大,一分为二:
int middle = (end + start) / 2;
System.out.println(String.format("split %d~%d ==> %d~%d, %d~%d", start, end, start, middle, middle, end));
SumTask subtask1 = new SumTask(this.array, start, middle);
SumTask subtask2 = new SumTask(this.array, middle, end);
invokeAll(subtask1, subtask2);
Long subresult1 = subtask1.join();
Long subresult2 = subtask2.join();
Long result = subresult1 + subresult2;
System.out.println("result = " + subresult1 + " + " + subresult2 + " ==> " + result);
return result;
}
}
观察上述代码的执行过程,一个大的计算任务0~2000首先分裂为两个小任务0~1000和1000~2000,这两个小任务仍然太大,继续分裂为更小的0~500,500~1000,1000~1500,1500~2000,最后,计算结果被依次合并,得到最终结果。
因此,核心代码SumTask继承自RecursiveTask,在compute()方法中,关键是如何“分裂”出子任务并且提交子任务:
class SumTask extends RecursiveTask<Long> {
protected Long compute() {
// “分裂”子任务:
SumTask subtask1 = new SumTask(...);
SumTask subtask2 = new SumTask(...);
// invokeAll会并行运行两个子任务:
invokeAll(subtask1, subtask2);
// 获得子任务的结果:
Long subresult1 = subtask1.join();
Long subresult2 = subtask2.join();
// 汇总结果:
return subresult1 + subresult2;
}
}
Fork/Join线程池在Java标准库中就有应用。Java标准库提供的java.util.Arrays.parallelSort(array)可以进行并行排序,它的原理就是内部通过Fork/Join对大数组分拆进行并行排序,在多核CPU上就可以大大提高排序的速度。
十、原子性
所谓的原子性,是指在一次操作或者多次操作中,要么所有的操作全部都得到了执行并且不会受到任何因素的干扰而中断,要么所有的操作都不执行,多个操作是一个不可以分割的整体。
1、案例
1. 问题
问题现象:当 A 线程修改了共享数据时,B 线程没有及时获取到最新的值,如果还在使用原先的值,就会出现问题。原因如下:
- 堆内存是唯一的,而每一个线程都有自己的线程栈。
- 每一个线程在使用堆里面变量的时候,都会先拷贝一份到变量的副本中。
- 在线程中,每一次使用是从变量的副本中获取的。
Volatile 关键字:强制线程每次在使用的时候,都看一下共享区域最新的值。
public class Test {
public static void main(String[] args) {
MyAtomThread atom = new MyAtomThread();
for (int i = 0; i < 100; i++) {
new Thread(atom).start();
}
}
}
class MyAtomThread implements Runnable {
private volatile int count = 0; // 送冰淇淋的数量
@Override
public void run() {
for (int i = 0; i < 100; i++) {
// 1. 从共享数据中读取数据到本线程栈中
// 2. 修改本线程栈中变量副本的值
// 3. 把本线程栈中变量副本的值赋给共享数据
count++;
System.out.println("已经送了" + count + "个冰淇淋");
}
}
}
执行结果:
……
已经送了5个冰淇淋
已经送了6个冰淇淋
已经送了9个冰淇淋
已经送了8个冰淇淋
已经送了7个冰淇淋
已经送了11个冰淇淋
已经送了13个冰淇淋
已经送了12个冰淇淋
……
问题:volatile 关键字不能保证原子性。因为 count++ 不是一个原子性操作, 他在执行的过程中,有可能被其他线程打断。
2. 优化
我们可以给 count++ 操作添加锁,那么 count++ 操作就是临界区中的代码,临界区中的代码一次只能被一个线程去执行,所以 count++ 就变成了原子操作。
public class Test {
public static void main(String[] args) {
MyAtomThread atom = new MyAtomThread();
for (int i = 0; i < 100; i++) {
new Thread(atom).start();
}
}
}
class MyAtomThread implements Runnable {
private volatile int count = 0; // 送冰淇淋的数量
private Object lock = new Object();
@Override
public void run() {
for (int i = 0; i < 100; i++) {
// 1. 从共享数据中读取数据到本线程栈中
// 2. 修改本线程栈中变量副本的值
// 3. 会把本线程栈中变量副本的值赋值给共享数据
synchronized(lock){
count++;
System.out.println(Thread.currentThread().getName()+"已经送了" + count + "个冰淇淋");
}
}
}
}
执行结果:
Thread-0已经送了1个冰淇淋
Thread-3已经送了2个冰淇淋
Thread-4已经送了3个冰淇淋
Thread-4已经送了4个冰淇淋
Thread-4已经送了5个冰淇淋
Thread-4已经送了6个冰淇淋
Thread-4已经送了7个冰淇淋
……
2、atomic 实现原子性
概述:JAVA 从 JDK1.5 开始提供了 java.util.concurrent.atomic 包(简称 Atomic 包)。这个包中的原子操作类提供了一种用法简单、性能高效、线程安全地更新一个变量的方式。
因为变量的类型有很多种,所以在 Atomic 包里一共提供了 13 个类,属于 4 种类型的原子更新方式,分别是原子更新基本类型、原子更新数组、原子更新引用和原子更新属性(字段)。
下面只介绍使用原子的方式更新基本类型,使用原子的方式更新基本类型 Atomic 包提供了以下 3 个类:
- AtomicBoolean:原子更新布尔类型
- AtomicInteger:原子更新整型
- AtomicLong:原子更新长整型
以上 3 个类提供的方法几乎一模一样,所以我们以 AtomicInteger 为例进行讲解,AtomicInteger 的常用方法如下:
- public AtomicInteger():初始化一个默认值为 0 的原子型 Integer。
- public AtomicInteger(int initialValue):初始化一个指定值的原子型 Integer。
- int get():获取值。
- int getAndIncrement():以原子方式将当前值加 1。注意,这里返回的是自增前的值。
- int incrementAndGet():以原子方式将当前值加 1。注意,这里返回的是自增后的值。
- int addAndGet(int data):以原子方式将输入的数值与实例中的值(AtomicInteger 里的 value)相加,并返回结果。
- int getAndSet(int value):以原子方式设置为 newValue 的值,并返回旧值。
- int compareAndSet(int expect, int update):用CAS方式设置
代码示例:
import java.util.concurrent.atomic.AtomicInteger;
class MyAtomIntergerDemo {
// public AtomicInteger():初始化一个默认值为0的原子型Integer
// public AtomicInteger(int initialValue):初始化一个指定值的原子型Integer
// int get(): 获取当前值
// int getAndIncrement(): 以原子方式将当前值加1。注意,这里返回的是自增前的值
// int incrementAndGet(): 以原子方式将当前值加1。注意,这里返回的是自增后的值
// int addAndGet(int data): 以原子方式将参数与对象中的值相加,并返回结果
// int getAndSet(int value): 以原子方式设置为value的值,并返回旧值
public static void main(String[] args) {
AtomicInteger ac1 = new AtomicInteger(10);
System.out.println(ac1.get()); // 10
AtomicInteger ac2 = new AtomicInteger(10);
int andIncrement = ac2.getAndIncrement();
System.out.println(andIncrement); // 10
System.out.println(ac2.get()); // 11
AtomicInteger ac3 = new AtomicInteger(10);
int i1 = ac3.incrementAndGet();
System.out.println(i1); // 11
System.out.println(ac3.get()); // 11
AtomicInteger ac4 = new AtomicInteger(10);
int i2 = ac4.addAndGet(20);
System.out.println(i2); // 30
System.out.println(ac4.get()); // 30
AtomicInteger ac5 = new AtomicInteger(100);
int andSet = ac5.getAndSet(20);
System.out.println(andSet); // 100
System.out.println(ac5.get()); // 20
}
}
Atomic类是通过无锁(lock-free)的方式实现的线程安全(thread-safe)访问。它的主要原理是利用了CAS:Compare and Set。
如果我们自己通过CAS编写incrementAndGet(),它大概长这样:
public int incrementAndGet(AtomicInteger var) {
int prev, next;
do {
prev = var.get();
next = prev + 1;
} while ( ! var.compareAndSet(prev, next));
return next;
}
CAS是指,在这个操作中,如果AtomicInteger的当前值是prev,那么就更新为next,返回true。如果AtomicInteger的当前值不是prev,就什么也不干,返回false。通过CAS操作并配合do ... while循环,即使其他线程修改了AtomicInteger的值,最终的结果也是正确的。
我们利用AtomicLong可以编写一个多线程安全的全局唯一ID生成器:
class IdGenerator {
AtomicLong var = new AtomicLong(0);
public long getNextId() {
return var.incrementAndGet();
}
}
通常情况下,我们并不需要直接用do ... while循环调用compareAndSet实现复杂的并发操作,而是用incrementAndGet()这样的封装好的方法,因此,使用起来非常简单。
在高度竞争的情况下,还可以使用Java 8提供的LongAdder和LongAccumulator。
总结:
- 使用java.util.concurrent.atomic提供的原子操作可以简化多线程编程:
- 原子操作实现了无锁的线程安全;
-
适用于计数器,累加器等;
十一、虚拟线程
虚拟线程是Java 19引入的一种轻量级线程,它在很多其他语言中被称为协程、纤程、绿色线程、用户态线程等。
在理解虚拟线程前,我们先回顾一下线程的特点:
- 线程是由操作系统创建并调度的资源;
- 线程切换会耗费大量CPU时间;
- 一个系统能同时调度的线程数量是有限的,通常在几百至几千级别。
因此,我们说线程是一种重量级资源。在服务器端,对用户请求,通常都实现为一个线程处理一个请求。由于用户的请求数往往远超操作系统能同时调度的线程数量,所以通常使用线程池来尽量减少频繁创建和销毁线程的成本。
对于需要处理大量IO请求的任务来说,使用线程是低效的,因为一旦读写IO,线程就必须进入等待状态,直到IO数据返回。常见的IO操作包括:
- 读写文件;
- 读写网络,例如HTTP请求;
- 读写数据库,本质上是通过JDBC实现网络调用。
我们举个例子,一个处理HTTP请求的线程,它在读写网络、文件的时候就会进入等待状态:
真正由CPU执行的代码消耗的时间非常少,线程的大部分时间都在等待IO。我们把这类任务称为IO密集型任务。
为了能高效执行IO密集型任务,Java从19开始引入了虚拟线程。虚拟线程的接口和普通线程是一样的,但是执行方式不一样。虚拟线程不是由操作系统调度,而是由普通线程调度,即成百上千个虚拟线程可以由一个普通线程调度。任何时刻,只能执行一个虚拟线程,但是,一旦该虚拟线程执行一个IO操作进入等待时,它会被立刻“挂起”,然后执行下一个虚拟线程。什么时候IO数据返回了,这个挂起的虚拟线程才会被再次调度。因此,若干个虚拟线程可以在一个普通线程中交替运行:
如果我们单独看一个虚拟线程的代码,在一个方法中:
void register() {
config = readConfigFile("./config.json"); // #1
if (config.useFullName) {
name = req.firstName + " " + req.lastName;
}
insertInto(db, name); // #2
if (config.cache) {
redis.set(key, name); // #3
}
}
涉及到IO读写的#1、#2、#3处,执行到这些地方的时候(进入相关的JNI方法内部时)会自动挂起,并切换到其他虚拟线程执行。等到数据返回后,当前虚拟线程会再次调度并执行,因此,代码看起来是同步执行,但实际上是异步执行的。
虚拟线程的接口和普通线程一样,唯一区别在于创建虚拟线程只能通过特定方法。
方法一:直接创建虚拟线程并运行:
// 传入Runnable实例并立刻运行:
Thread vt = Thread.startVirtualThread(() -> {
System.out.println("Start virtual thread...");
Thread.sleep(10);
System.out.println("End virtual thread.");
});
方法二:创建虚拟线程但不自动运行,而是手动调用start()开始运行:
// 创建VirtualThread:
Thread.ofVirtual().unstarted(() -> {
System.out.println("Start virtual thread...");
Thread.sleep(1000);
System.out.println("End virtual thread.");
});
// 运行:
vt.start();
方法三:通过虚拟线程的ThreadFactory创建虚拟线程,然后手动调用start()开始运行:
// 创建ThreadFactory:
ThreadFactory tf = Thread.ofVirtual().factory();
// 创建VirtualThread:
Thread vt = tf.newThread(() -> {
System.out.println("Start virtual thread...");
Thread.sleep(1000);
System.out.println("End virtual thread.");
});
// 运行:
vt.start();
直接调用start()实际上是由ForkJoinPool的线程来调度的。我们也可以自己创建调度线程,然后运行虚拟线程:
// 创建调度器:
ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
// 创建大量虚拟线程并调度:
ThreadFactory tf = Thread.ofVirtual().factory();
for (int i=0; i<100000; i++) {
Thread vt = tf.newThread(() -> { ... });
executor.submit(vt);
// 也可以直接传入Runnable或Callable:
executor.submit(() -> {
System.out.println("Start virtual thread...");
Thread.sleep(1000);
System.out.println("End virtual thread.");
return true;
});
}
由于虚拟线程属于非常轻量级的资源,因此,用时创建,用完就扔,不要池化虚拟线程。
最后注意,虚拟线程在Java 19中是预览功能,默认关闭,需要添加参数--enable-preview启用:
java --source 19 --enable-preview Main.java
使用限制:
注意到只有以虚拟线程方式运行的代码,才会在执行IO操作时自动被挂起并切换到其他虚拟线程。普通线程的IO操作仍然会等待,例如,我们在main()方法中读写文件,是不会有调度和自动挂起的。
可以自动引发调度切换的操作包括:
- 文件IO;
- 网络IO;
- 使用Concurrent库引发等待;
- Thread.sleep()操作。
这是因为JDK为了实现虚拟线程,已经对底层相关操作进行了修改,这样应用层的Java代码无需修改即可使用虚拟线程。无法自动切换的语言需要用户手动调用await来实现异步操作:
async function doWork() {
await readFile();
await sendNetworkData();
}
在虚拟线程中,如果绕过JDK的IO接口,直接通过JNI读写文件或网络是无法实现调度的。此外,在synchronized块内部也无法调度。
总结:
- Java 19引入的虚拟线程是为了解决IO密集型任务的吞吐量,它可以高效通过少数线程去调度大量虚拟线程;
- 虚拟线程在执行到IO操作或Blocking操作时,会自动切换到其他虚拟线程执行,从而避免当前线程等待,能最大化线程的执行效率;
- 虚拟线程使用普通线程相同的接口,最大的好处是无需修改任何代码,就可以将现有的IO操作异步化获得更大的吞吐能力。
- 计算密集型任务不应使用虚拟线程,只能通过增加CPU核心解决,或者利用分布式计算资源;
十二、 并发工具类总结
1、ConcurrentHashMap
ConcurrentHashMap 出现的原因 :
- 在集合类中 HashMap 是比较常用的集合对象,但是 HashMap 是线程不安全的(多线程环境下可能会存在问题)。
- 为了保证数据的安全性我们可以使用 Hashtable,但是 Hashtable 的效率低下。
基于以上两个原因我们可以使用 JDK1.5 以后所提供的 ConcurrentHashMap。
体系结构 :
- HashMap 是线程不安全的,多线程环境下会有数据安全问题。
- Hashtable 是线程安全的,但是会将整张表锁起来,效率低下。
- ConcurrentHashMap 也是线程安全的,效率较高(在 JDK7 和 JDK8 中,底层原理不一样)。
代码示例:
import java.util.concurrent.ConcurrentHashMap;
public class Test {
public static void main(String[] args) throws InterruptedException {
ConcurrentHashMap<String, String> hm = new ConcurrentHashMap<>(100);
Thread t1 = new Thread(() -> {
for (int i = 0; i < 25; i++) {
hm.put(i + "", i + "");
}
});
Thread t2 = new Thread(() -> {
for (int i = 25; i < 51; i++) {
hm.put(i + "", i + "");
}
});
t1.start();
t2.start();
System.out.println("----------------------------");
// 休眠,把数据全部添加完毕
Thread.sleep(1000);
for (int i = 0; i < 51; i++) {
System.out.println(hm.get(i + "")); // 0 1 2 3 .... 50
}
}
}
2、CountDownLatch
使用场景:让某一条线程等待其他线程执行完毕之后再执行。
方法 | 解释 |
---|---|
public CountDownLatch(int count) | 参数传递线程数,表示等待线程数量 |
public void await() | 让线程等待 |
public void countDown() | 当前线程执行完毕 |
代码示例:
import java.util.concurrent.CountDownLatch;
// 主线程
public class MyCountDownLatchDemo {
public static void main(String[] args) {
// 1. 创建CountDownLatch的对象,需要传递给4个线程
// 在底层就定义了一个计数器,此时计数器的值就是3
CountDownLatch countDownLatch = new CountDownLatch(3);
// 2. 创建四个线程对象并开启他们
MotherThread motherThread = new MotherThread(countDownLatch);
motherThread.start();
ChileThread1 t1 = new ChileThread1(countDownLatch);
t1.setName("小明");
ChileThread2 t2 = new ChileThread2(countDownLatch);
t2.setName("小红");
ChileThread3 t3 = new ChileThread3(countDownLatch);
t3.setName("小刚");
t1.start();
t2.start();
t3.start();
}
}
// 子线程:孩子吃饺子
class ChileThread1 extends Thread {
private CountDownLatch countDownLatch;
public ChileThread1(CountDownLatch countDownLatch) {
this.countDownLatch = countDownLatch;
}
@Override
public void run() {
// 1. 吃饺子
for (int i = 1; i <= 10; i++) {
System.out.println(getName() + "在吃第" + i + "个饺子");
}
// 2. 吃完说一声
// 每一次 countDown 方法的时候,就让计数器 -1
countDownLatch.countDown();
}
}
// 子线程:孩子吃饺子
class ChileThread2 extends Thread {
private CountDownLatch countDownLatch;
public ChileThread2(CountDownLatch countDownLatch) {
this.countDownLatch = countDownLatch;
}
@Override
public void run() {
// 1. 吃饺子
for (int i = 1; i <= 15; i++) {
System.out.println(getName() + "在吃第" + i + "个饺子");
}
// 2. 吃完说一声
// 每一次 countDown 方法的时候,就让计数器 -1
countDownLatch.countDown();
}
}
// 子线程:孩子吃饺子
class ChileThread3 extends Thread {
private CountDownLatch countDownLatch;
public ChileThread3(CountDownLatch countDownLatch) {
this.countDownLatch = countDownLatch;
}
@Override
public void run() {
// 1. 吃饺子
for (int i = 1; i <= 20; i++) {
System.out.println(getName() + "在吃第" + i + "个饺子");
}
// 2. 吃完说一声
// 每一次 countDown 方法的时候,就让计数器 -1
countDownLatch.countDown();
}
}
// 子线程:妈妈收拾碗筷
class MotherThread extends Thread {
private CountDownLatch countDownLatch;
public MotherThread(CountDownLatch countDownLatch) {
this.countDownLatch = countDownLatch;
}
@Override
public void run() {
// 1. 等待
try {
// 当计数器变成0的时候,会自动唤醒这里等待的线程。
countDownLatch.await();
} catch (InterruptedException e) {
e.printStackTrace();
}
// 2. 收拾碗筷
System.out.println("妈妈在收拾碗筷");
}
}
总结:
- CountDownLatch(int count):参数表示等待线程的数量,并定义了一个计数器。
- await():让线程等待,当计数器为 0 时,会唤醒等待的线程。
- countDown(): 线程执行完毕时调用,会将计数器 -1。
3、Semaphore
各种锁的实现,本质上锁的目的是保护一种受限资源,保证同一时刻只有一个线程能访问(ReentrantLock),或者只有一个线程能写入(ReadWriteLock)。
还有一种受限资源,它需要保证同一时刻最多有N个线程能访问,比如同一时刻最多创建100个数据库连接,最多允许10个用户下载等。
这种限制数量的锁,如果用Lock数组来实现,就太麻烦了。
这种情况就可以使用Semaphore,Semaphore本质上就是一个信号计数器,用于限制同一时间的最大访问数量。
使用场景:可以控制访问特定资源的线程数量。
实现步骤:
- 需要有人管理这个通道。
- 当有车进来了,发通行许可证。
- 当车出去了,收回通行许可证。
- 如果通行许可证发完了,那么其他车辆只能等着。
import java.util.concurrent.Semaphore;
public class Test {
public static void main(String[] args) {
MyRunnable mr = new MyRunnable();
for (int i = 0; i < 100; i++) {
new Thread(mr).start();
}
}
}
class MyRunnable implements Runnable {
// 1.获得管理员对象,
private Semaphore semaphore = new Semaphore(2);
@Override
public void run() {
// 2.获得通行证
try {
semaphore.acquire();
// 3.开始行驶
System.out.println("获得了通行证开始行驶");
Thread.sleep(2000);
System.out.println("归还通行证");
// 4.归还通行证
semaphore.release();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
最多允许3个线程同时访问:
public class AccessLimitControl {
// 任意时刻仅允许最多3个线程获取许可:
final Semaphore semaphore = new Semaphore(3);
public String access() throws Exception {
// 如果超过了许可数量,其他线程将在此等待:
semaphore.acquire();
try {
// TODO:
return UUID.randomUUID().toString();
} finally {
semaphore.release();
}
}
}
使用Semaphore先调用acquire()获取,然后通过try ... finally保证在finally中释放。
调用acquire()可能会进入等待,直到满足条件为止。也可以使用tryAcquire()指定等待时间:
if (semaphore.tryAcquire(3, TimeUnit.SECONDS)) {
// 指定等待时间3秒内获取到许可:
try {
// TODO:
} finally {
semaphore.release();
}
}
如果要对某一受限资源进行限流访问,可以使用Semaphore
,保证同一时间最多N个线程访问受限资源。