一 并发编程简介
1 什么是并发编程?
所谓并发编程是指在一台处理器上 “同时” 处理多个任务。并发是在同一实体上的多个事件。多个事件在同一时间间隔发生。
并发编程,从程序设计的角度来说,是希望通过某些机制让计算机可以在一个时间段内,执行多个任务。从计算机 CPU 硬件层面来说,是一个或多个物理 CPU 在多个程序之间多路复用,提高对计算机资源的利用率。从调度算法角度来说,当任务数量多于 CPU 的核数时,并发编程能够通过操作系统的任务调度算法,实现多个任务一起执行。
2 并发编程的重要性
对于一个 Java 程序员而言,能否熟练掌握并发编程是判断他优秀与否的重要标准之一。因为并发编程是 Java 语言中最为晦涩的知识点,它涉及操作系统、内存、CPU、编程语言等多方面的基础能力,更为考验一个程序员的内功。
并发编程在开发语言中占据着不可替代的位置。
3 并发编程的特性
并发编程有三大特性:
- 原子性;
- 可见性;
- 有序性。
Tips: 后续的文章内容中,会对这些特性进行详细的详解。了解并掌握并发编程的三大特性,非常重要。
4 为什么学习并发编程?
可以这样来说,在目前开发市场对于程序员的硬性要求中,并发编程占据了重要的位置,不懂并发编程的从业者不是一名合格的软件工程师。
尤其是大数据时代的来临,高并发更成为了家常便饭,工作中,你总是绕不开并发编程的任务,比如说,你想写个程序,一边从文件中读取数据,一边还要做实时计算… 所以,想成为一名资深的 Java 后端工程师,并发编程必须要牢牢把握。
二 操作系统的并发
1 并发编程的定义
定义: 所谓并发编程是指在一台处理器上 “同时” 处理多个任务。并发是在同一实体上的多个事件,多个事件在同一时间间隔发生。
意义:开发者通过使用不同的语言,实现并发编程,充分的利用处理器(CPU)的每一个核,以达到最高的处理性能,提升服务器的资源利用率,提升数据的处理速度。
2 从 CPU 谈并发编程
首先看下图,图中展示了最简单的 CPU 核心通过缓存与主存进行通讯的模型。
在缓存出现后不久,系统变得越来越复杂,缓存与主存之间的速度差异被拉大,由于 CPU 的频率太快了,快到主存跟不上,这样在线程处理器时钟周期内,CPU 常常需要等待主存,这样就会浪费资源。从我们的感官上,计算机可以同时运行多个任务,但是从 CPU 硬件层面上来说,其实是 CPU 执行线程的切换,由于切换频率非常快,致使我们从感官上感觉计算机可以同时运行多个程序。
为了避免长时间的线程等待,我们一方面提升硬件指标(如多级高速缓存的诞生,这里不做讨论),另一方面引入了并发概念,充分的利用处理器(CPU)的每一个核,减少 CPU 资源等待的时间,以达到最高的处理性能。
3 操作系统,进程,线程之间的联系与区别
我们首先来看看,三者之间的关系,从图中可以看到,操作系统是包含多个进程的容器,而每个进程又是容纳多个线程的容器。
什么是进程?
官方定义: 进程(baiProcess)是计算机中的程序关于某数据集合上的一次运行活动,是系统进行资源分配和调度的基本单位,是操作系统结构的基础。
Tips:系统进行资源分配和调度的基本单位其实就是 CPU 时间片的切换,一个 CPU 同一时间只能操作一个任务,只不过 CPU 在不停的切换工作任务,这里的时间片就是我们所说的系统进行资源分配和调度的基本单位。
那么从定义上感觉非常的抽象,但是进程其实就在我们日常的计算机使用过程中。请看下图,进入任务管理器看 Windows 操作系统下的进程:
什么是线程?
官方定义: 线程是操作系统能够进行资源调度的最小单位,它被包含在进程之中,是进程中的实际运作单位,每个线程执行的都是进程代码的某个片段,特定的线程总是在执行特定的任务。
线程与进程的区别?
- 诞生起源:先有进程,后有线程。进程由于资源利用率、公平性和便利性诞生。处理器的速度往往比外设的速度快(键盘、鼠标等),为了提高 CPU 的利用率,诞生了线程,目的就是为了提高程序的执行效率;
- 概念:进程是资源分配的最小单位。 线程是程序执行的最小单位(线程是操作系统能够进行资源调度的最小单位,同个进程中的线程也可以被同时调度到多个 CPU 上运行),线程也被称为轻量级进程;
- 内存共享:默认情况下,进程的内存无法与其他进程共享(进程间通信通过 IPC 进行)。 线程共享由操作系统分配给其父进程的内存块。
4 串行,并行与并发
串行:顺序执行,按步就搬。在 A 任务执行完之前不可以执行 B。
并行:同时执行,多管齐下。指两个或两个以上事件或活动在同一时刻发生。在多道程序环境下,并行性使多个程序同一时刻可在不同 CPU 核心上同时执行。
并发:穿插执行,减少等待。指多个线程轮流穿插着执行,并发的实质是一个物理 CPU 在若干道程序之间多路复用,其目的是提高有限物理资源的运行效率。
三 Java 线程内存模型
1 什么是 Java 的内存模型
定义: Java 内存模型(即 Java Memory Model,简称 JMM)本身是一种抽象的概念,并不真实存在,它描述的是一组规则或规范,通过这组规范定义了程序中各个变量(包括实例字段,静态字段和构成数组对象的元素)的访问方式。
2 Java 线程的私有内存和主内存
首先看下图,图中展示了Java 的内存模型。
工作内存(私有):由于JVM 运行程序的实体是线程,而每个线程创建时 JVM 都会为其创建一个工作内存(栈空间),用于存储线程私有的数据。线程私有的数据只能供自己使用,其他线程不能够访问到当前线程私有的内存空间,保证了不同的线程在处理自己的数据时,不受其他线程的影响。
主内存(共享):Java 内存模型中规定所有变量都存储在主内存,主内存是共享内存区域,所有线程都可以访问。从上图中可以看到,Java 的并发内存模型与操作系统的 CPU 运行方式极其相似,这就是 Java 的并发编程模型。通过创建多条线程,并发的进行操作,充分利用系统资源,达到高效的并发运算。
3 线程拥有私有空间的意义
我们知道,线程的私有空间中存储的数据,仅供当前线程自己使用,其他线程不能够对数据进行访问。线程的私有空间会存放很多程序运行时所必须的数据,如:
程序计数器:记录当前方法执行到了哪里,以便 CPU 切换回来之后能够继续执行上次执行到的位置,而不会进行重复执行或者遗漏。
局部变量:局部变量是方法中的变量,只供当前方法使用。
方法参数:Java 方法会定义自己的入参,入参的真实值也会记录到内存空间供当前线程使用。
由于线程的内存空间会存放很多数据,这里只提以上三中数据以供同学理解线程私有空间的意义。
为了加深理解,我们一起看一个简单的代码示例并进行分析:
public class DemoTest{
public static void main(String[] args) {
sum(10); // 解析点 3
}
public static void sum(int num) {
int i = 5; // 解析点 1
set(); //解析点 2
System.out.println("num+i = "+ (num + i));
}
public static void set() {
int i = 100;
}
}
在给出结果之前,我们来分析下:
解析点 1 :设置 i 的值为 5;
解析点 2: 调用 set() 方法,逻辑如下。
public static void set() {
int i = 100;
}
那最终的结果是多少呢?
解析点 3:我们传入的 sum 的参数值是 10,如果想确定结果,只要确定另外一个加数 i 的值就行了。我们通过分析,在方法 sum(int num) 中的 int i = 5 与方法 set() 中的 int i = 100 是两个不同的方法的局部变量,属于线程私有的。互相不会影响,所以set() 方法中的 int i = 100 不会影响最终的结果:
num+i = 15
4 主内存操作共享变量需要注意的事项
- 确定是否是多线程环境:多线程环境下操作共享变量需要考虑线程的安全性;
- 确定是否有增删改操作:多线程环境下,如果对共享数据有增加,删除或者修改的操作,需要谨慎。为了保证线程的同步性,必须对该共享数据进行加锁操作,保证多线程环境下,所有的线程能够获取到正确的数据。如生产者与消费者模型,售票模型;
- 多线程下的读操作:如果是只读操作,对共享数据不需要进行锁操作,因为数据本身未发生增删改操作,不会影响获取数据的准确性。
5 Java 线程的生命周期
每个事物都有其生命周期,也就是事物从出生开始到最终消亡这中间的整个过程。在其整个生命周期的历程中,会有不同阶段,每个阶段对应着一种状态,比如:人的一生会经历从婴幼儿、青少年、青壮年、中老年到最终死亡,离开这人世间,这是人一生的状态。
同样的,线程作为一种事物,也有生命周期,在其生命周期中也存在着不同的状态,不同的状态之间还会有互相转换。
Java 线程的声明周期会经历 6 种不同的状态变化,后续章节会有详细描述。从线程的创建到线程执行任务的完成,即 Java 线程的生命周期。
四 Java 多线程的创建
1 Thread 类结构介绍
介绍: 位于 java.lang 包下的 Thread 类是非常重要的线程类。学习 Thread 类的使用是学习多线程并发编程的基础。它实现了 Runnable 接口,其包集成结构如下图所示。
2 多线程的三种创建方式
Java 多线程有 3 种创建方式如下:
- 方式一:继承 Thread 类的方式创建线程;
- 方式二:实现 java.lang.Runnable 接口;
- 方式三:实现 Callable 接口。
3 多线程实现之继承 Thread 类
实现步骤:
- 步骤 1:继承 Thread 类 extends Thread;
- 步骤 2:复写 run () 方法,run () 方法是线程具体逻辑的实现方法。
实例:
/**
* 方式一:继承Thread类的方式创建线程
*/
public class ThreadExtendTest extends Thread{ //步骤 1
@Override
public void run() { //步骤 2
//run方法内为具体的逻辑实现
System.out.println("create thread by thread extend");
}
public static void main(String[] args) {
new ThreadExtendTest(). start();
}
}
4 多线程实现之实现 Runnable 接口
Tips:由于 Java 是面向接口编程,且可进行多接口实现,相比 Java 的单继承特性更加灵活,易于扩展,所以相比方式一,更推荐使用方式二进行线程的创建。
实现步骤:
- 步骤 1:实现 Runnable 接口,implements Runnable;
- 步骤 2:复写 run () 方法,run () 方法是线程具体逻辑的实现方法。
实例:
/**
* 方式二:实现java.lang.Runnable接口
*/
public class ThreadRunnableTest implements Runnable{//步骤 1
@Override
public void run() {//步骤 2
//run方法内为具体的逻辑实现
System.out.println("create thread by runnable implements");
}
public static void main(String[] args) {
new Thread(new ThreadRunnableTest()). start();
}
}
5 多线程实现之实现 Callable 接口
Tips:方式一与方式二的创建方式都是复写 run 方法,都是 void 形式的,没有返回值。但是对于方式三来说,实现 Callable 接口,能够有返回值类型。
实现步骤:
- 步骤 1:实现 Callable 接口,implements Callable;
- 步骤 2:复写 call () 方法,call () 方法是线程具体逻辑的实现方法。
实例:
/**
* 方式三:实现Callable接口
*/
public class ThreadCallableTest implements Callable<String> {//步骤 1
@Override
public String call() throws Exception { //步骤 2
//call 方法的返回值类型是 String
//call 方法是线程具体逻辑的实现方法
return "create thread by implements Callable";
}
public static void main(String[] args) throws ExecutionException, InterruptedException{
FutureTask<String> future1 = new FutureTask<String>(new ThreadCallableTest());
Thread thread1 = new Thread(future1);
thread1. start();
System.out.println(future1.get());
}
}
6 匿名内部类创建 Thread
首先确认,这并不是线程创建的第四种方式,先来看如何创建。
实例:
Thread t = new Thread(new Runnable() {
@Override
public void run() {
System.out.println("通过匿名内部类创建Thread");
}
});
我们从代码中可以看出,还是进行了一个 Runnable 接口的使用,所以这并不是新的 Thread 创建方式,只不过是通过方式二实现的一个内部类创建。
7 Thread 类的常用方法介绍
方法 | 作用 |
---|---|
start() | 启动当前的线程,调用当前线程的 run ()。 |
run() | 通常需要重写 Thread 类中的此方法,将创建要执行的操作声明在此方法中。 |
currentThread() | 静态方法,返回代码执行的线程。 |
getName() | 获取当前线程的名字。 |
setName() | 设置当前线程的名字。 |
sleep(long millitime) | 让当前进程睡眠指定的毫秒数,在指定时间内,线程是阻塞状态。 |
isAlive() | 判断进程是否存活。 |
wait() | 线程等待。 |
notify() | 线程唤醒。 |
8 Thread 编程测验实验
实验目的:对 Thread 的创建方式进行练习,巩固本节重点内容,并在练习的过程中,使用常用的 start 方法和 sleep 方法以及 线程的 setName 方法。
实验步骤:
- 使用 Runnable 接口创建两条线程 :t1 和 t2;
- 请设置线程 t1 和 t2 的线程名称分别为 “ThreadOne” 和 “ThreadTwo”;
- 线程 t1 执行完 run () 方法后,线程睡眠 5 秒;
- 线程 t2 执行完 run () 方法后,线程睡眠 1 秒。
请先自行实现,并将结果与所提供的答案进行复核。
public class ThreadTest implements Runnable{
@Override
public void run() {
System.out.println("线程:"+Thread.currentThread()+" 正在执行...");
}
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(new ThreadTest());
t1.setName("ThreadOne");
Thread t2 = new Thread(new ThreadTest());
t2.setName("ThreadTwo");
t1. start();
t1.sleep(5000);
t2. start();
t2.sleep(1000);
System.out.println("线程执行结束。");
}
}
执行结果:
线程:Thread[ThreadOne,5,main] 正在执行...
线程:Thread[ThreadTwo,5,main] 正在执行...
线程执行结束。
五 多线程 join 方法
1 join 方法的作用
方法定义:多线程环境下,如果需要确保某一线程执行完毕后才可继续执行后续的代码,就可以通过使用 join 方法完成这一需求设计。
在项目实践中经常会遇到一个场景,就是需要等待某几件事情完成后主线程才能继续往下执行, 比如多个线程加载资源, 需要等待多个线程全部加载完毕再汇总处理。
Thread 类中有一个 join 方法就可以做这个事情,join 方法是 Thread 类直接提供的。join 是无参且返回值为 void 的方法。
如上图所示,假如有 3 个线程执行逻辑,线程 1 需要执行5秒钟,线程 2 需要执行10 秒钟,线程 3 需要执行 8 秒钟。 如果我们的开发需求是:必须等 3 条线程都完成执行之后再进行后续的代码处理,这时候我们就需要使用到 join 方法。
使用 join 方法后:
- 第 5 秒钟: 线程 1 执行完毕;线程 2 执行了一半; 线程 3 还差 3 秒执行完毕;
- 第 8 秒钟:线程 1 等待了 3 秒; 线程 3 执行完毕; 线程 2 还差 2 秒执行完毕;
- 第10 秒钟: 线程 1 等待了 5 秒; 线程 3 等待了 2 秒;线程 2 执行完毕;
- 从线程 2 执行结束的那一刻:三条线程同时进行后续代码的执行。
这就是 join 方法的作用与解释。
2 join 方法异常处理
join 方法是 Thread 类中的方法,为了了解该方法的异常处理,我们先来简要的看下 join 方法的 JDK 源码:
public final void join() throws InterruptedException {
join(0);
}
从源代码中我们可以看到, join 方法抛出了异常:
throws InterruptedException
所以,我们在使用 join 方法的时候,需要对 join 方法的调用进行 try catch 处理或者从方法级别进行异常的抛出。
try-catch 处理示例:
public class DemoTest implements Runnable{
@Override
public void run() {
System.out.println("线程:"+Thread.currentThread()+" 正在执行...");
}
public static void main(String[] args) {
Thread t1 = new Thread(new DemoTest());
t1. start();
try {
t1.join();
} catch (InterruptedException e) {
// 异常捕捉处理
}
}
}
throws 异常处理示例:
public class DemoTest implements Runnable throws InterruptedException {
@Override
public void run() {...}
public static void main(String[] args) {
Thread t1 = new Thread(new DemoTest());
t1. start();
t1.join();
}
}
3 join 方法如何使用
为了更好的了解 join 方法的使用,我们首先来设计一个使用的场景。
场景设计:
- 线程 1 :执行时间 5 秒钟;
- 线程 2 :执行时间 10 秒钟;
- 线程 3 :执行 8 秒钟。
需求:我们需要等 3 个线程都执行完毕后,再进行后续代码的执行。3 个线程执行完毕后,请打印执行时间。
期望结果: 10 秒执行时间。
实例:
public class DemoTest {
public static void main(String[] args) throws InterruptedException {
Thread threadOne = new Thread(new Runnable() { //线程 1
@Override
public void run() {
try {
Thread.sleep(5000); //线程 1 休眠 5 秒钟
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("线程 1 休眠 5 秒钟,执行完毕。");
}
});
Thread threadTwo = new Thread(new Runnable() { //线程 2
@Override
public void run() {
try {
Thread.sleep(10000); //线程 2 修眠 10 秒钟
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("线程 2 修眠 10 秒钟,执行完毕。");
}
});
Thread threadThree = new Thread(new Runnable() { //线程 3
@Override
public void run() {
try {
Thread.sleep(8000); //线程 3 修眠 8 秒钟
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("线程 3 修眠 8 秒钟,执行完毕。");
}
});
Long startTime = System.currentTimeMillis();
threadOne.start();
threadTwo.start();
threadThree.start();
System.out.println("等待三个线程全部执行完毕再继续向下执行,我要使用 join 方法了。");
threadOne.join(); //线程 1 调用 join 方法
threadTwo.join(); //线程 2 调用 join 方法
threadThree.join(); //线程 3 调用 join 方法
Long endTime = System.currentTimeMillis();
System.out.println("三个线程都执行完毕了,共用时: " + (endTime - startTime) + "毫秒");
}
}
执行结果验证:
等待三个线程全部执行完毕再继续向下执行,我要使用 join 方法了。
线程 1 休眠 5 秒钟,执行完毕。
线程 3 修眠 8 秒钟,执行完毕。
线程 2 修眠 10 秒钟,执行完毕。
三个线程都执行完毕了,共用时: 10002毫秒
从执行的结果来看,与我们对 join 方法的理解和分析完全相符
4 带参数的 join 方法使用
除了无参的 join 方法以外, Thread 类还提供了有参 join 方法如下:
public final synchronized void join(long millis)
throws InterruptedException
该方法的参数 long millis 代表的是毫秒时间。
方法作用描述:等待 millis 毫秒终止线程,假如这段时间内该线程还没执行完,也不会再继续等待。
结合上一个知识点的代码,我们都是调用的无参 join 方法,现在对上一个知识点代码进行如下调整:
threadOne.join(); //线程 1 调用 join 方法
threadTwo.join(3000); //线程 2 调用 join 方法
threadThree.join(); //线程 3 调用 join 方法
从代码中我们看到,线程 2 使用 join 方法 3000 毫秒的等待时间,如果 3000 毫毛后,线程 2 还未执行完毕,那么主线程则放弃等待线程 2,只关心线程 1 和线程 3。
我们来看下执行结果:
等待三个线程全部执行完毕再继续向下执行,我要使用 join 方法了。
线程 1 休眠 5 秒钟,执行完毕。
线程 3 修眠 8 秒钟,执行完毕。
三个线程都执行完毕了,共用时: 8000毫秒
线程 2 修眠 10 秒钟,执行完毕。
从执行结果来看, 总用时 8000 毫秒,因为线程 2 被放弃等待了,所以只考虑线程 1 和线程 3 的执行时间即可。
六 多线程 yield 方法
1 什么是 CPU 执行权
我们知道操作系统是为每个线程分配一个时间片来占有 CPU 的,正常情况下当一个线程把分配给自己的时间片使用完后,线程调度器才会进行下一轮的线程调度,这里所说的 “自己占有的时间片” 即 CPU 分配给线程的执行权。
那进一步进行探究,何为让出 CPU 执行权呢?
当一个线程通过某种可行的方式向操作系统提出让出 CPU 执行权时,就是在告诉线程调度器自己占有的时间片中还没有使用完的部分自己不想使用了,主动放弃剩余的时间片,并在合适的情况下,重新获取新的执行时间片。
2 yield 方法的作用
方法介绍:Thread 类中有一个静态的 yield 方法,当一个线程调用 yield 方法时,实际就是在暗示线程调度器当前线程请求让出自己的 CPU 使用权。
public static native void yield();
Tips:从这个源码中我们能够看到如下两点要点:
- yield 方法是一个静态方法,静态方法的特点是可以由类直接进行调用,而不需要进行对象 new 的创建,调用方式为 Thread.yield ()。
- 该方法除了被 static 修饰,还被 native 修饰,那么进入主题,什么是 native 方法呢?我们继续来看下文的讲解。
抽象地讲,一个 Native Method 就是一个 Java 调用的非 Java 代码的接口。一个 Native Method 是这样一个 Java 的方法:该方法的实现由非 java 语言实现。
简单的来说,native 方法就是我们自己电脑的方法接口,比如 Windows 电脑会提供一个 yield 方法,Linux 系统的电脑也同样会提供一个 yield 方法,本地方法,可以理解为操作调用操作系统的方法接口。
作用:暂停当前正在执行的线程对象(及放弃当前拥有的 cup 资源),并执行其他线程。yield () 做的是让当前运行线程回到可运行状态,以允许具有相同优先级的其他线程获得运行机会。
目的:yield 即 “谦让”,使用 yield () 的目的是让具有相同优先级的线程之间能适当的轮转执行。但是,实际中无法保证 yield () 达到谦让目的,因为放弃 CPU 执行权的线程还有可能被线程调度程序再次选中。
3 yield 方法如何使用
为了更好的了解 yield 方法的使用,我们首先来设计一个使用的场景。
场景设计:
- 创建一个线程,线程名为 threadOne;
- 打印一个数,该数的值为从 1 加到 10000000 的和;
- 不使用 yield 方法正常执行,记录总的执行时间;
- 加入 yield 方法,再次执行程序;
- 再次记录总执行时间。
期望结果: 未加入 yield 方法之前打印的时间 < 加入 yield 方法之后的打印时间。因为 yield 方法在执行过程中会放弃 CPU 执行权并从新获取新的 CPU 执行权。
代码实现 - 正常执行:
public class DemoTest extends Thread {
@Override
public void run() {
Long start = System.currentTimeMillis();
int count = 0;
for (int i = 1; i <= 10000000; i++) {
count = count + i;
}
Long end = System.currentTimeMillis();
System.out.println("总执行时间: "+ (end-start) + " 毫秒, 结果 count = " + count);
}
public static void main(String[] args) throws InterruptedException {
DemoTest threadOne = new DemoTest();
threadOne. start();
}
}
执行结果验证
总执行时间: 6 毫秒.
代码实现 - yield 执行:
public class DemoTest extends Thread {
@Override
public void run() {
Long start = System.currentTimeMillis();
int count = 0;
for (int i = 1; i <= 10000000; i++) {
count = count + i;
this.yield(); // 加入 yield 方法
}
Long end = System.currentTimeMillis();
System.out.println("总执行时间: "+ (end-start) + " 毫秒. ");
}
public static void main(String[] args) throws InterruptedException {
DemoTest threadOne = new DemoTest();
threadOne. start();
}
}
执行结果验证:
总执行时间: 5377 毫秒.
从执行的结果来看,与我们对 yield 方法的理解和分析完全相符。当加入 yield 方法执行时,线程会放弃 CPU 的执行权,并等待再次获取新的执行权,所以执行时间上会更加的长。
4 yield 方法和 sleep 方法的区别
- sleep () 方法给其他线程运行机会时不考虑线程的优先级,因此会给低优先级的线程以运行的机会;
- yield () 方法只会给相同优先级或更高优先级的线程以运行的机会;
- 线程执行 sleep () 方法后转入阻塞 (blocked) 状态,而执行 yield () 方法后转入就绪 (ready) 状态;
- sleep () 方法声明会抛出 InterruptedException, 而 yield () 方法没有声明任何异常;
- sleep () 方法比 yield () 方法具有更好的移植性 (跟操作系统 CPU 调度相关)。
七 线程上下文切换与死锁
1 理解线程的上下文切换
概述:在多线程编程中,线程个数一般都大于 CPU 个数,而每个 CPU 同一时刻只能被一个线程使用,为了让用户感觉多个线程是在同时执行的, CPU 资源的分配采用了时间片轮转的策略,也就是给每个线程分配一个时间片,线程在时间片内占用 CPU 执行任务。
定义:当前线程使用完时间片后,就会处于就绪状态并让出 CPU,让其他线程占用,这就是上下文切换,从当前线程的上下文切换到了其他线程。
问题点解析:那么就有一个问题,让出 CPU 的线程等下次轮到自己占有 CPU 时如何知道自己之前运行到哪里了?所以在切换线程上下文时需要保存当前线程的执行现场, 当再次执行时根据保存的执行现场信息恢复执行现场。
线程上下文切换时机: 当前线程的 CPU 时间片使用完或者是当前线程被其他线程中断时,当前线程就会释放执行权。那么此时执行权就会被切换给其他的线程进行任务的执行,一个线程释放,另外一个线程获取,就是我们所说的上下文切换时机。
2 什么是线程死锁
定义:死锁是指两个或两个以上的线程在执行过程中,因争夺资源而造成的互相等待的现象,在无外力作用的情况下,这些线程会一直相互等待而无法继续运行下去。
如上图所示死锁状态,线程 A 己经持有了资源 1,它同时还想申请资源 2,可是此时线程 B 已经持有了资源 2 ,线程 A 只能等待。
反观线程 B 持有了资源 2 ,它同时还想申请资源 1,但是资源 1已经被线程 A 持有,线程 B 只能等待。所以线程 A 和线程 B 就因为相互等待对方已经持有的资源,而进入了死锁状态。
3 线程死锁的必备要素
- 互斥条件:进程要求对所分配的资源进行排他性控制,即在一段时间内某资源仅为一个进程所占有。此时若有其他进程请求该资源,则请求进程只能等待;
- 不可剥夺条件:进程所获得的资源在未使用完毕之前,不能被其他进程强行夺走,即只能由获得该资源的进程自己来释放(只能是主动释放,如 yield 释放 CPU 执行权);
- 请求与保持条件:进程已经保持了至少一个资源,但又提出了新的资源请求,而该资源已被其他进程占有,此时请求进程被阻塞,但对自己已获得的资源保持不放;
- 循环等待条件:指在发生死锁时,必然存在一个线程请求资源的环形链,即线程集合 {T0,T1,T2,…Tn}中的 T0 正在等待一个 T1 占用的资源,T1 正在等待 T2 占用的资源,以此类推,Tn 正在等待己被 T0 占用的资源。
如下图所示:
4 死锁的实现
为了更好的了解死锁是如何产生的,我们首先来设计一个死锁争夺资源的场景。
场景设计:
- 创建 2 个线程,线程名分别为 threadA 和 threadB;
- 创建两个资源, 使用 new Object () 创建即可,分别命名为 resourceA 和 resourceB;
- threadA 持有 resourceA 并申请资源 resourceB;
- threadB 持有 resourceB 并申请资源 resourceA ;
- 为了确保发生死锁现象,请使用 sleep 方法创造该场景;
- 执行代码,看是否会发生死锁。
期望结果:发生死锁,线程 threadA 和 threadB 互相等待。
实例:
public class DemoTest {
private static Object resourceA = new Object();//创建资源 resourceA
private static Object resourceB = new Object();//创建资源 resourceB
public static void main(String[] args) throws InterruptedException {
//创建线程 threadA
Thread threadA = new Thread(new Runnable() {
@Override
public void run() {
synchronized (resourceA) {
System.out.println(Thread.currentThread().getName() + "获取 resourceA。");
try {
Thread.sleep(1000); // sleep 1000 毫秒,确保此时 resourceB 已经进入run 方法的同步模块
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName() + "开始申请 resourceB。");
synchronized (resourceB) {
System.out.println(Thread.currentThread().getName() + "获取 resourceB。");
}
}
}
});
threadA.setName("threadA");
//创建线程 threadB
Thread threadB = new Thread(new Runnable() {
@Override
public void run() {
synchronized (resourceB) {
System.out.println(Thread.currentThread().getName() + "获取 resourceB。");
try {
Thread.sleep(1000); // sleep 1000 毫秒,确保此时 resourceA 已经进入run 方法的同步模块
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName() + "开始申请 resourceA。");
synchronized (resourceA) {
System.out.println(Thread.currentThread().getName() + "获取 resourceA。");
}
}
}
});
threadB.setName("threadB");
threadA.start();
threadB.start();
}
}
代码讲解:
- 从代码中来看,我们首先创建了两个资源 resourceA 和 resourceB;
- 然后创建了两条线程 threadA 和 threadB。threadA 首先获取了 resourceA ,获取的方式是代码 synchronized (resourceA) ,然后沉睡 1000 毫秒;
- 在 threadA 沉睡过程中, threadB 获取了 resourceB,然后使自己沉睡 1000 毫秒;
- 当两个线程都苏醒时,此时可以确定 threadA 获取了 resourceA,threadB 获取了 resourceB,这就达到了我们做的第一步,线程分别持有自己的资源;
- 那么第二步就是开始申请资源,threadA 申请资源 resourceB,threadB 申请资源 resourceA 无奈 resourceA 和 resourceB 都被各自线程持有,两个线程均无法申请成功,最终达成死锁状态。
执行结果验证:
threadA 获取 resourceA。
threadB 获取 resourceB。
threadA 开始申请 resourceB。
threadB 开始申请 resourceA。
看下验证结果,发现已经出现死锁,threadA 申请 resourceB,threadB 申请 resourceA,但均无法申请成功,死锁得以实验成功。
5 如何避免线程死锁
要想避免死锁,只需要破坏掉至少一个构造死锁的必要条件即可,学过操作系统的读者应该都知道,目前只有请求并持有和环路等待条件是可以被破坏的。
造成死锁的原因其实和申请资源的顺序有很大关系,使用资源申请的有序性原则就可避免死锁。
我们依然以第 4 个知识点进行讲解,那么实验的需求和场景不变,我们仅仅对之前的 threadB 的代码做如下修改,以避免死锁。
代码修改:
Thread threadB = new Thread(new Runnable() { //创建线程 1
@Override
public void run() {
synchronized (resourceA) { //修改点 1
System.out.println(Thread.currentThread().getName() + "获取 resourceB。");//修改点 3
try {
Thread.sleep(1000); // sleep 1000 毫秒,确保此时 resourceA 已经进入run 方法的同步模块
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName() + "开始申请 resourceA。");//修改点 4
synchronized (resourceB) { //修改点 2
System.out.println (Thread.currentThread().getName() + "获取 resourceA。"); //修改点 5
}
}
}
});
请看如上代码示例,有 5 个修改点:
- 修改点 1 :将 resourceB 修改成 resourceA;
- 修改点 2 :将 resourceA 修改成 resourceB;
- 修改点 3 :将 resourceB 修改成 resourceA;
- 修改点 4 :将 resourceA 修改成 resourceB;
- 修改点 5 :将 resourceA 修改成 resourceB。
请读者按指示修改代码,并从新运行验证。
修改后代码讲解:
- 从代码中来看,我们首先创建了两个资源 resourceA 和 resourceB;
- 然后创建了两条线程 threadA 和 threadB。threadA 首先获取了 resourceA ,获取的方式是代码 synchronized (resourceA) ,然后沉睡 1000 毫秒;
- 在 threadA 沉睡过程中, threadB 想要获取 resourceA ,但是 resourceA 目前正被沉睡的 threadA 持有,所以 threadB 等待 threadA 释放 resourceA;
- 1000 毫秒后,threadA 苏醒了,释放了 resourceA ,此时等待的 threadB 获取到了 resourceA,然后 threadB 使自己沉睡 1000 毫秒;
- threadB 沉睡过程中,threadA 申请 resourceB 成功,继续执行成功后,释放 resourceB;
- 1000 毫秒后,threadB 苏醒了,继续执行获取 resourceB ,执行成功。
执行结果验证:
threadA 获取 resourceA。
threadA 开始申请 resourceB。
threadA 获取 resourceB。
threadB 获取 resourceA。
threadB 开始申请 resourceB。
threadB 获取 resourceB。
我们发现 threadA 和 threadB 按照相同的顺序对 resourceA 和 resourceB 依次进行访问,避免了互相交叉持有等待的状态,避免了死锁的发生。
八 守护线程与用户线程
1 守护线程与用户线程的定义及区别
Java 中的线程分为两类,分别为 daemon 线程(守护线程〉和 user 线程(用户线程)。
在 JVM 启动时会调用 main 函数, main 函数所在的线程就是一个用户线程,其实在 JVM 内部同时还启动了好多守护线程,比如垃圾回收线程。
守护线程定义:所谓守护线程,是指在程序运行的时候在后台提供一种通用服务的线程。比如垃圾回收线程就是一个很称职的守护者,并且这种线程并不属于程序中不可或缺的部分。
因此,当所有的非守护线程结束时,程序也就终止了,同时会杀死进程中的所有守护线程。反过来说,只要任何非守护线程还在运行,程序就不会终止。
用户线程定义:某种意义上的主要用户线程,只要有用户线程未执行完毕,JVM 虚拟机不会退出。
区别:在本质上,用户线程和守护线程并没有太大区别,唯一的区别就是当最后一个非守护线程结束时,JVM 会正常退出,而不管当前是否有守护线程,也就是说守护线程是否结束并不影响 JVM 的退出。
言外之意,只要有一个用户线程还没结束, 正常情况下 JVM 就不会退出。
2 守护线程的特点
Java 中的守护线程和 Linux 中的守护进程是有些区别的,Linux 守护进程是系统级别的,当系统退出时,才会终止。
而 Java 中的守护线程是 JVM 级别的,当 JVM 中无任何用户进程时,守护进程销毁,JVM 退出,程序终止。总结来说,Java 守护进程的最主要的特点有:
- 守护线程是运行在程序后台的线程;
- 守护线程创建的线程,依然是守护线程;
- 守护线程不会影响 JVM 的退出,当 JVM 只剩余守护线程时,JVM 进行退出;
- 守护线程在 JVM 退出时,自动销毁。
3 守护线程的创建
创建方式:将线程转换为守护线程可以通过调用 Thread 对象的 setDaemon (true) 方法来实现。
创建细节:
- thread.setDaemon (true) 必须在 thread.start () 之前设置,否则会抛出出一个 llegalThreadStateException 异常。你不能把正在运行的常规线程设置为守护线程;
- 在 Daemon 线程中产生的新线程也是 Daemon 的;
- 守护线程应该永远不去访问固有资源,如文件、数据库,因为它会在任何时候甚至在一个操作的中间发生中断。
线程创建代码示例:
public class DemoTest {
public static void main(String[] args) throws InterruptedException {
Thread threadOne = new Thread(new Runnable() {
@Override
public void run() {
//代码执行逻辑
}
});
threadOne.setDaemon(true); //设置threadOne为守护线程
threadOne. start();
}
}
4 守护线程与 JVM 的退出实验
为了更好的了解守护线程与 JVM 是否退出的关系,我们首先来设计一个守护线程正在运行,但用户线程执行完毕导致的 JVM 退出的场景。
场景设计:
- 创建 1 个线程,线程名为 threadOne;
- run 方法线程 sleep 1000 毫秒后,进行求和计算,求解 1 + 2 + 3 + … + 100 的值;
- 将线程 threadOne 设置为守护线程;
- 执行代码,最终打印的结果;
- 加入 join 方法,强制让用户线程等待守护线程 threadOne;
- 执行代码,最终打印的结果。
期望结果:
- 未加入 join 方法之前,threadOne 不能执行求和逻辑,无打印输出,因为 main 函数线程执行完毕后,JVM 退出,守护线程也就随之死亡,无打印结果;
- 加入 join 方法后,可以打印求和结果,因为 main 函数线程需要等待 threadOne 线程执行完毕后才继续向下执行,main 函数执行完毕,JVM 退出。
ips:main 函数就是一个用户线程,main 方法执行时,只有一个用户线程,如果 main 函数执行完毕,用户线程销毁,JVM 退出,此时不会考虑守护线程是否执行完毕,直接退出。
代码实现 - 不加入 join 方法:
public class DemoTest {
public static void main(String[] args){
Thread threadOne = new Thread(new Runnable() {
@Override
public void run() {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
int sum = 0;
for (int i = 1; i <= 100; i++) {
sum = sum + i;
}
System.out.println("守护线程,最终求和的值为: " + sum);
}
});
threadOne.setDaemon(true); //设置threadOne为守护线程
threadOne. start();
System.out.println("main 函数线程执行完毕, JVM 退出。");
}
}
执行结果验证:
main 函数线程执行完毕, JVM 退出。
从结果上可以看到,JVM 退出了,守护线程还没来得及执行,也就随着 JVM 的退出而消亡了。
代码实现 - 加入 join 方法:
public class DemoTest {
public static void main(String[] args){
Thread threadOne = new Thread(new Runnable() {
@Override
public void run() {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
int sum = 0;
for (int i = 1; i <= 100; i++) {
sum = sum + i;
}
System.out.println("守护线程,最终求和的值为: " + sum);
}
});
threadOne.setDaemon(true); //设置threadOne为守护线程
threadOne. start();
try {
threadOne.join(); // 加入join 方法
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("main 函数线程执行完毕, JVM 退出。");
}
}
执行结果验证:
守护线程,最终求和的值为: 5050
main 函数线程执行完毕, JVM 退出。
从结果来看,守护线程不决定 JVM 的退出,除非强制使用 join 方法使用户线程等待守护线程的执行结果,但是实际的开发过程中,这样的操作是不允许的,因为守护线程,默认就是不需要被用户线程等待的,是服务于用户线程的。
5 守护线程的作用及使用场景
作用:我们以 GC 垃圾回收线程举例,它就是一个经典的守护线程,当我们的程序中不再有任何运行的 Thread, 程序就不会再产生垃圾,垃圾回收器也就无事可做,所以当垃圾回收线程是 JVM 上仅剩的线程时,垃圾回收线程会自动离开。
它始终在低级别的状态中运行,用于实时监控和管理系统中的可回收资源。
应用场景:
- 为其它线程提供服务支持的情况,可选用守护线程;
- 根据开发需求,程序结束时,这个线程必须正常且立刻关闭,就可以作为守护线程来使用;
- 如果一个正在执行某个操作的线程必须要执行完毕后再释放,否则就会出现不良的后果的话,那么这个线程就不能是守护线程,而是用户线程;
- 正常开发过程中,一般心跳监听,垃圾回收,临时数据清理等通用服务会选择守护线程。
九 ThreadLocal 的使用
1 ThreadLocal 概述
诞生:早在 JDK 1.2 的版本中就提供 java.lang.ThreadLocal,ThreadLocal 为解决多线程程序的并发问题提供了一种新的思路。使用这个工具类可以很简洁地编写出优美的多线程程序。
概述:ThreadLocal 很容易让人望文生义,想当然地认为是一个 “本地线程”。其实,ThreadLocal 并不是一个 Thread,而是 Thread 的局部变量,也许把它命名为 ThreadLocalVariable 更容易让人理解一些。
当使用 ThreadLocal 维护变量时,ThreadLocal 为每个使用该变量的线程提供独立的变量副本,所以每一个线程都可以独立地改变自己的副本,而不会影响其它线程所对应的副本。
总体概括:从线程的角度看,目标变量就象是线程的本地变量,这也是类名中 “Local” 所要表达的意思。
了解完 ThreadLocal 的总体介绍后,对其有了一个总体的了解,那我们接下来继续探究 ThreadLocal 的真实面貌以及使用。
2 ThreadLocal 的作用
作用:ThreadLocal 是 JDK 包提供的,它提供了线程本地变量,也就是如果你创建了一个 ThreadLocal 变量,那么访问这个变量的每个线程都会有这个变量的一个本地副本。当多个线程操作这个变量时,实际操作的是自己本地内存里面的变量,从而避免了线程安全问题。
ThreadLocal 是线程本地存储,在每个线程中都创建了一个 ThreadLocalMap 对象,每个线程可以访问自己内部 ThreadLocalMap 对象内的 value。通过这种方式,避免资源在多线程间共享。
使用场景:如为每个线程分配一个 JDBC 连接 Connection。这样就可以保证每个线程的都在各自的 Connection 上进行数据库的操作,不会出现 A 线程关了 B 线程正在使用的 Connection。还有 Session 管理等问题。
原理理解:
一句话理解ThreadLocal,threadlocl是作为当前线程中属性ThreadLocalMap集合中的某一个Entry的key值Entry =>(threadlocl,value),虽然不同的线程之间threadlocal这个key值是一样,但是不同的线程所拥有的ThreadLocalMap是独一无二的,也就是不同的线程间同一个ThreadLocal(key)对应存储的值(value)不一样,从而到达了线程间变量隔离的目的,但是在同一个线程中这个value变量地址是一样的。
3 ThreadLocal set 方法
方法介绍:set 方法是为了设置 ThreadLocal 变量,设置成功后,该变量只能够被当前线程访问,其他线程不可直接访问操作改变量。
实例:
public class DemoTest{
public static void main(String[] args){
ThreadLocal<String> localVariable = new ThreadLocal<> () ;
localVariable.set("Hello World");
}
}
Tips:set 方法可以设置任何类型的值,无论是 String 类型 ,Integer 类型,Object 等类型,原因在于 set 方法的 JDK 源码实现是基于泛型的实现,此处只是拿 String 类型进行的举例。
实例:
public void set(T value) { // T value , 泛型实现,可以 set 任何对象类型
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t);
if (map != null)
map.set(this, value);
else
createMap(t, value);
}
4 ThreadLocal get 方法
方法介绍:get 方法是为了获取 ThreadLocal 变量的值,get 方法没有任何入参,直接调用即可获取。
实例:
public class DemoTest{
public static void main(String[] args){
ThreadLocal<String> localVariable = new ThreadLocal<> () ;
localVariable.set("Hello World");
System.out.println(localVariable.get());
}
}
结果验证:
Hello World
探究:请看如下程序,并给出输出结果
实例:
public class DemoTest{
public static void main(String[] args){
ThreadLocal<String> localVariable = new ThreadLocal<> () ;
localVariable.set("Hello World");
localVariable.set("World is beautiful");
System.out.println(localVariable.get());
System.out.println(localVariable.get());
}
}
探究解析:从程序中来看,我们进行了两次 set 方法的使用。
第一次 set 的值为 Hello World ;第二次 set 的值为 World is beautiful。接下来我们进行了两次打印输出 get 方法,那么这两次打印输出的结果都会是 World is beautiful。 原因在于第二次 set 的值覆盖了第一次 set 的值,所以只能 get 到 World is beautiful。
结果验证:
World is beautiful
World is beautiful
总结:ThreadLocal 中只能设置一个变量值,因为多次 set 变量的值会覆盖前一次 set 的值,我们之前提出过,ThreadLocal 其实是使用 ThreadLocalMap 进行的 value 存储,那么多次设置会覆盖之前的 value,这是 get 方法无需入参的原因,因为只有一个变量值。
5 ThreadLocal remove 方法
方法介绍:remove 方法是为了清除 ThreadLocal 变量,清除成功后,该 ThreadLocal 中没有变量值。
实例:
public class DemoTest{
public static void main(String[] args){
ThreadLocal<String> localVariable = new ThreadLocal<> () ;
localVariable.set("Hello World");
System.out.println(localVariable.get());
localVariable.remove();
System.out.println(localVariable.get());
}
}
Tips:remove 方法同 get 方法一样,是没有任何入参的,因为 ThreadLocal 中只能存储一个变量值,那么 remove 方法会直接清除这个变量值。
结果验证:
Hello World
null
6 多线程下的 ThreadLocal
对 ThreadLocal 的常用方法我们已经进行了详细的讲解,那么多线程下的 ThreadLocal 才是它存在的真实意义,那么问了更好的学习多线程下的 ThreadLocal,我们来进行场景的创建,通过场景进行代码实验,更好的体会并掌握 ThreadLocal 的使用。
场景设计:
- 创建一个全局的静态 ThreadLocal 变量,存储 String 类型变量;
- 创建两个线程,分别为 threadOne 和 threadTwo;
- threadOne 进行 set 方法设置,设置完成后沉睡 5000 毫秒,苏醒后进行 get 方法打印;
- threadTwo 进行 set 方法设置,设置完成后直接 get 方法打印,打印完成后调用 remove 方法,并打印 remove 方法调用完毕语句;
- 开启线程 threadOne 和 threadTwo ;
- 执行程序,并观察打印结果。
结果预期:在 threadOne 设置成功后进入了 5000 毫秒的休眠状态,此时由于只有 threadTwo 调用了 remove 方法,不会影响 threadOne 的 get 方法打印,这体现了 ThreadLocal 变量的最显著特性,线程私有操作。
实例:
public class DemoTest {
static ThreadLocal<String> local = new ThreadLocal<>();
public static void main(String[] args) {
Thread threadOne = new Thread(new Runnable() {
@Override
public void run() {
local.set("threadOne's local value");
try {
Thread.sleep(5000); //沉睡5000 毫秒,确保 threadTwo 执行 remove 完成
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(local.get());
}
});
Thread threadTwo = new Thread(new Runnable() {
@Override
public void run() {
local.set("threadTwo's local value");
System.out.println(local.get());
local.remove();
System.out.println("local 变量执行 remove 操作完毕。");
}
});
threadTwo.start();
threadOne.start();
}
}
结果验证:
threadTwo's local value
local 变量执行 remove 操作完毕。
threadOne's local value
从以上结果来看,在 threadTwo 执行完 remove 方法后,threadOne 仍然能够成功打印,这更加证明了 ThreadLocal 的专属特性,线程独有数据,其他线程不可侵犯。