前言
上一章节中,我们知道,进程是可以很好的解决并发编程这样的问题,但在一些特定的情况下,进程的表现是不尽人意的。比如:有些场景下,需要频繁的创建和销毁进程,此时使用多进程编程,系统开销就会很大
- 举一个例子:
在早期Web开发中,就是基于多进程的编程模式来开发的。服务器同一时刻会收到很多请求,针对每个请求,它都会创建出一个进程,给这个请求提供一定的服务,然后返回对应的响应,一旦这个请求处理完了,此时这个进程就要销毁掉
- 但是如果请求很多,意味着服务器就需要不停地创建新的进程和销毁旧的进程,这样的操作,开销就会很大
开销比较大最关键的原因:
- 资源的申请和释放:
- 进程是资源分配的基本单位,一个进程在刚刚启动时,首先就是对 内存资源 的一个分配。进程需要将所依赖的代码和数据,从磁盘加载到内存中
- 从系统分配一个内存,并非一件易事
- 一般来说,申请内存时,需要指定一个大小。系统内部就把各种大小的空闲内存,通过一定的数据结构给组织起来,实际申请的时候,就需要去这样的空间中进行查找,找到个大小合适的空闲内存,然后分配
- 问题:如果一个很大的进程,找不到一个合适的空闲内存,此时系统会怎样?
- 答:系统会报错,启动不了这个进程
- 这在Windows上不明显,在Linux上是很明显的
结论:进程在进行频繁创建和销毁时,开销是很大的,所以引进了线程这一概念
内核相关
-
前面提到在操作系统内核中创建出线程,但什么是内核呢?
-
内核,是操作系统中,最核心的功能模块,其作用是:管理硬件,给软件提供稳定的运行环境
举一个例子,张三去银行办理业务,他只能通过工作人员去代办他的需求,他不能进入办事窗口自己办理业务(如果自己能进去,那警察叔叔可就要上门请你喝茶了)
-
上述例子中,工作人员所待的办事窗口,在操作系统中称为 ”内核空间(内核态)“,而张三所待的大堂,在操作系统中称为 ”用户空间(用户态)“
-
我们平时运行的普通程序如:QQ音乐,微信等程序都是运行在 用户态 的,这些程序在有的场景下,需要针对一些系统提供的 软硬件资源 进行操作
如:QQ音乐在播放音乐时,需要对扬声器进行操作;打微信视频电话时,需要对摄像头进行操作
-
这些操作,都 不是应用程序直接操作 的,此时需要调用系统所提供的 api,进一步在内核中完成这样的操作
问题:为什么要划分出内核态和用户态呢?
答:最主要的目的还是为了 “稳定”。如果给应用程序的权限太大,使它可以直接操作你的硬件,如果运行过程中出现了bug,就很可能导致将硬件给干烧了,直接用不了了
所以,操作系统封装了一些 api,这些 api 属于 “合法” 操作,应用程序只能调用这些 ”合法“ 的 api,这样就不至于对系统/硬件设备造成危害
线程
线程,也可以称为 “轻量级进程” ,其优点在于:
保持了独立调度执行,但同时省去了 “分配资源” 和 “释放资源” 带来的额外开销
一、线程概念及简单操作
-
一个线程就是一个 “执行流”,每个线程之间都可以按照顺序执行自己的代码。多个线程之间 “同时” 执行着多份代码
设想如下场景:
一家公司要去银行办理业务,既要进行财务转账,又要进行福利发放,还得缴社保
如果只有张三一个会计就会忙不过来,耗费的时间特别长。为了让业务更快的办理好,张三又找来两位同事李四、王五一起来帮助他,三个人分别负责⼀个事情,分别申请⼀个号码进⾏排队,⾃此就有了三个执⾏流共同完成任务,但本质上他们都是为了办理同一个业务。此时,我们就把这种情况称为多线程,将⼀个⼤任务分解成不同⼩任务,交给不同执⾏流就分别排队执⾏。其中李四、王五都是张三叫来的,所以张三⼀般被称为主线程(Main Thread) -
为啥要有线程
- 首先,“并发编程” 成为 “刚需”
- 单核 CPU 的发展遇到了瓶颈,要想提高算力,就需要多核 CPU。而并发编程能更充分利用多核 CPU 资源
- 有些任务场景需要 “等待IO“,为了让等待 IO 的时间能够去做一些其他的工作,也需要用到并发编程
- 其次,虽然多进程也能实现 并发编程,但是线程比进程更轻量
- 创建线程比创建进程更快
- 销毁线程比销毁进程更快
- 调度线程比调度进程更快
-
进程和线程的区别
- 进程是包含线程的,每个进程至少有一个线程存在,即主线程
- 进程和进程之间不共享内存空间。同一个进程的线程之间共享同一个内存空间
- 进程是系统分配资源的最小单位,线程是系统调度的最小单位
- 有线程之前,进程需要扮演两个角色(资源分配的基本单位和调度执行的基本单位)
- 有线程之后,进程专注于资源分配,线程专注于调度执行
- 一个进程挂了一般不会影响到其他进程。但是一个线程挂了,可能会把同进程的其他线程一起带走(整个进程崩溃)
比如,张三、李四、王五,其中李四挂了,李四所负责的任务就要落到其他两人身上,其他两人不堪重负,也纷纷挂掉了,这个业务就没法执行了!
-
线程多了也不是一件好事
- 当线程数量太多,线程之间就会相互竞争CPU的资源(毕竟CPU核心数是有限的),那么这样非但不会提高效率,反而还会增加调度的开销
- 线程之间可能会起冲突,就可能会导致代码中出现一些逻辑上的错误(线程安全问题,重点,难点)
-
Java 的线程 和 操作系统线程 的关系
- 线程是操作系统中的概念。操作系统内核实现了线程这样的机制,并且对用户层提供了一些API供用户使用(例如 Linux 的 pthread 库)
- Java 标准库中 Thread 类可以视为是对操作系统提供的 API 进一步的抽象和封装
- 由于操作系统都是C/C++实现的,提供的api也是C/C++风格的,JDK就对这些api进行了封装,封装成了Java风格的api(一个Java程序也是可以调用C/C++函数的,这涉及到了Java中的JNI技术)
多线程编程
第一个多线程程序
- 题外话:
- 一般是把 “跑起来” 的程序,称为 “进程”
- 没有运行起来的程序(.exe),称为 “可执行文件”
代码:
class MyThread extends Thread {
@Override
public void run() {
// run 方法就是该线程的入口方法
System.out.println("hello world");
}
}
public class ThreadDemo1 {
public static void main(String[] args) {
// 根据刚才的类,创建出实例(线程实例,才是真正的线程)
Thread thread = new MyThread();
// 调用 Thread 的 start 方法,才会真正调用系统 api,在系统内核中创建出 线程
thread.start();
}
}
- 注意事项:
run()
方法,不需要程序员手动调用,它会在合适的时机(线程创建好之后),由 jvm 自动调用执行。这种风格的函数,称为 “回调函数(callback)”- 回调函数:比如Java中的 PriorityQueue 中的
compareTo()
和compare
就属于 “回调函数”,它会在我们插入元素时,自动调用这些方法 - 个人理解回调函数:就是在A方法中包含了另一个B方法,在调用 A 方法时,A方法会自动调用B方法,而不需要我们手动去调用B方法
- 回调函数:比如Java中的 PriorityQueue 中的
run()
方法,类似于 main 方法,是一个 Java 进程的入口方法- 线程 是要 创建出来才有的
- 调用
start()
方法时,就会自动调用run()
方法
真正体现多线程程序
代码:
class MyThread2 extends Thread {
@Override
public void run() {
while (true) {
System.out.println("hello world");
}
}
}
public class ThreadDemo2 {
public static void main(String[] args) {
Thread thread = new MyThread2();
thread.start();
while (true) {
System.out.println("hello main");
}
}
}
- 两个
while(true)
死循环 - 在之前的学习中,我们知道,当陷入一个死循环时,该循环下面的代码是无法继续执行,但是当我们真正运行时,我们会发现:
- 两句打印语句是 “交替进行” 的,速度非常快!这就是 “并发编程“ 的体现
如图:主线程 main 和其 子线程thread0,此时就是互不干扰,各搞各的
注意! 当有多个线程时,这些线程执行的先后顺序,是不确定的! 因为在操作系统内核中,有一个 “调度器” 模块,这个模块的实现方式,会呈现出一种 “随机调度” 的效果
- 什么叫随机调度?
- 一个线程,什么时候被调度到CPU上执行,时机是不确定的
- 一个线程,什么时候从CPU上 下来,给别人让位,这个时机也是不确定的
- 这种 ”抢占式执行“,也给后面多线程的线程安全问题埋下伏笔
一些问题
上述代码中,俩循环都是死循环,而且没有加任何条件,一旦程序运行起来,这俩循环就会执行得飞快,导致CPU占用率比较高,所以我们可以在循环中加上 sleep()
方法来降低循环速度
C语言中用的是 Windows api 中提供的 Sleep
函数
在Java中,我们使用的是封装后的版本,是Thread类提供的静态方法
注意:1s = 1000ms,这个方法本身也没有非常精确,精度误差就在毫秒级,所以用第一个方法就好
但是,直接用的话,会报异常,如图:这个异常,意味着 sleep(1000)
的过程中,可能会被提前唤醒
代码:
class MyThread2 extends Thread {
@Override
public void run() {
while (true) {
System.out.println("hello world");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
public class ThreadDemo2 {
public static void main(String[] args) throws InterruptedException {
Thread thread = new MyThread2();
thread.start();
while (true) {
System.out.println("hello main");
Thread.sleep(1000);
}
}
}
运行结果:可以看到,两个语句是同时出现,也就是说可以认为两个循环是同时执行,即两个线程是同时执行的,而且打印也是 “随机” 的
但是!即便是 “随机” 的,也是主线程 main 先打印出语句
这是因为:主线程main在调用 start()
方法后,就立即往下执行打印语句了;于此同时,内核就要通过 刚才线程的 api 构建出线程,然后执行 run()
,由于创建线程本身也有开销,所以在第一轮打印时子线程要稍慢一些
- 细节问题:为什么
run()
方法中只有一个try() catch()
改错方案,不能有throws
,但是下面的main()
方法中就可以有两种改错方案? - 答:因为如果
run()
方法加上throws
,就修改了方法签名,此时就无法构成 “重写”,父类的run()
没有throws
这个异常,子类重写的时候,就也不能throws
异常
jconsole工具
在 JDK 中,有一个 jconsole 工具,可以更直观地看到多个线程,地址在:Java-jdk-bin-jconsole.exe
如果打开后,发现进程一栏是空,那么需要以管理员方式打开
当然,使用IDEA调试器,也可以看线程情况(打断点)
创建线程的方式
方法一:继承 Thread 类
该方法就是上面的写法
方法二:实现 Runnable
接口
代码:
class MyThread3 implements Runnable {
@Override
public void run() {
while (true) {
System.out.println("hello runnable");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
public class ThreadDemo3 {
public static void main(String[] args) {
// 两种写法
// 1.
/*Runnable runnable = new MyThread3();
Thread thread = new Thread(runnable);*/
// 2.
Thread thread = new Thread(new MyThread3());
thread.start();
while (true) {
System.out.println("hello main");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
- 说明:
- 如图,这种写法,其实就是把 线程 和 要执行的任务 进行 解耦合,让他们的关联性没那么强
方法三:继承 Thread,但是使用 匿名内部类
内部类:在一个类里面定义的类,没有名字,不能重复使用,用一次就扔了
方法四:实现 Runnable
接口,但是使用 匿名内部类
代码:
public class ThreadDemo4 {
public static void main(String[] args) {
Thread thread = new Thread(new Runnable() {
@Override
public void run() {
while (true) {
System.out.println("runnable");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
});
thread.start();
while (true) {
System.out.println("main");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
方法五:[常用/推荐] 使用 lambda 表达式
- lambda表达式,其实就是 匿名函数
- 在Java中,方法必须要依赖类,所以在使用方法时,必须要给它套一层类
- 所以就引入了 lambda 表达式,在不破坏原有的规则下,对调用方法进行简化
代码:
public class ThreadDemo5 {
public static void main(String[] args) throws InterruptedException {
Thread thread = new Thread(() -> {
while (true) {
System.out.println("lambda");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});
thread.start();
while (true) {
System.out.println("main");
Thread.sleep(1000);
}
}
}
细节问题:
为什么编译器正好知道我们要重写的是 run()
方法?
因为Thread 中的构造方法有好几个版本,在编译器编译的时候,就会一个个往里面匹配,其中匹配到 Runnable
这个版本时,发现这里有个 run()
方法,无参数,正好能和现在的 lambda 对上
二、Thread 类及常见方法
1)Thread 常见构造方法
如:此时在 jconsole 工具中就能看到我们所命名的线程
Thread thread = new Thread(new Runnable() {
@Override
public void run() {
System.out.println("runnable");
}
},"这是一个线程");
注意:第五个构造方法,Java中的线程组和系统内核中的线程组不是一个东西,了解即可
2)Thread 的几个常见属性
getId()
- jvm 自动分配的身份标识,保证唯一性
getState()
- 进程之前介绍有 就绪状态和阻塞状态
- 线程也有状态,Java中对线程的状态又进行了进一步的区分
getPriority()
- 线程的优先级
- 在Java中设置优先级,效果不是很明显(对 内核 调度器的调度过程产生一些影响,但由于系统的随机调度,影响比较小)
isDaemon()
:是否是 ”后台线程“(和手机上的 前台app,后台app概念是不同的)- 前台线程:会阻止进程结束
- 后台线程:不会阻止进程结束
-
如图:当前代码执行时,thread 子线程一直在执行,但是 main 主线程已经结束了,但是编译器依旧在打印子线程语句
-
所以说,该代码创建的线程,默认是前台线程, 会阻止进程结束,只要前台线程没执行完,进程就不会结束,即使主线程已经执行完毕
-
所以我们就可以设置,线程为 ”前台线程“ 或 ”后台线程“
-
如图:
setDaemon
,设置为 true 时,就是将该线程设置为 “后台线程”,此时主线程默认是前台线程,主线程结束后,该子后台线程也只能结束 -
这个设置满足这样的需求:主线程结束后,该进程就需要直接关闭,在这种场景下,就可以将其他子线程设置为 “后台线程”
isAlive()
:表示,内核中的线程是否还存在
3)提前终止一个线程
- 如何让线程 提前终止?
方法一:添加一个 标志符
在之前的代码中,我们循环条件里填的就是 true
,直接设定成死循环,现在我们添加一个 boolean
类型的变量,来操控这个循环的终止
代码:通过添加一个 布尔类型的变量 isQuit
,当我们想要结束该线程时,就在主线程main中将这个 isQuit
修改
public static boolean isQuit = false;
public static void main(String[] args) throws InterruptedException {
Thread thread = new Thread(() -> {
while (!isQuit) {
System.out.println("thread");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
System.out.println("线程执行完毕");
},"测试线程1");
thread.start();
// 运行三秒
Thread.sleep(3000);
System.out.println("提前终止线程");
isQuit = true;
}
问题1:isQuit
变量,为什么放在 main 方法当中就不可行?写作 成员变量就可以了呢?
答:涉及到变量捕获;lambda表达式本质上是 “函数式接口” => 匿名内部类,内部类访问外部类成员这个事情本身就是可以的,就不受到变量捕获的影响了
问题2:为什么 Java 对于变量捕获有 final 限制?
答:每个线程都有其独立的栈帧,各自栈帧的生命周期不一样。这就可能导致主线程执行完,栈帧销毁了,但子线程还在,还想用主线程里面的变量。---- Java 中的做法就非常的简单粗暴,变量捕获本质上就是 “传参” ,换句话说,就是让 lambda 表达式在自己的栈帧中创建一个新的 isQuit
并把外面的 isQuit
值拷贝过来(为了避免 里外 的 isQuit
的值不同步,Java干脆就不让修改了)
相比之下:JS 里的变量捕获就很复杂,JS改变了变量的生命周期:某个局部变量被其他 “匿名函数” 捕获,此时这个变量就脱离原有的函数级别的生命周期了(这背后就涉及到一个非常复杂的 “作用域链” 问题/闭包)
方法二:方法一的优雅版本
方法一,代码不够简洁,还需要我们手动添加一个布尔类型变量
Thread
类中,就内置了这样一个变量,如图
代码:
public class ThreadDemo8 {
public static void main(String[] args) {
Thread thread = new Thread(() -> {
while (!Thread.currentThread().isInterrupted()) {
System.out.println("thread");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});
thread.start();
try {
Thread.sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("我要终止这个线程");
thread.interrupt();
}
}
-
但是注意,运行之后,如图:进程并没有真正结束,而是抛了一个异常后,继续执行
-
观察发现这里抛一个 InterruptedException 异常,说 sleep interrupted,就是 sleep 被提前唤醒了,可见是
sleep()
引起的问题 -
sleep()
被提前唤醒会做两件事:- 抛出异常
- 清除 Thread 实例的 isInterrupted 标志位
-
我们通过
thread.interrupt()
方法已经把标志位设为true
了 -
但是
sleep
提前唤醒,操作之后,又把 标志位改回了false
,所以循环又继续了(注意,不止是 sleep 有这样一个操作,其他方法也会这样)
注意! 在Java中,线程的终止是一种 “软性” 操作,必须要对应的线程配合,才能把该线程给提前终止;相比之下,系统原生的 api 还提供了 “强制终止线程” 的操作,无论线程是否愿意配合,无论该线程执行到哪个代码,都能强行把这个线程给干掉
强行干掉线程,在Java中是没有提供相应 api 的,这种操作弊大于利,如果强行干掉一个线程,很可能线程执行到一半,就会出现一些残留的临时性质的“错误”数据
- 问题:为什么 sleep 会清除标志位呢?
- 答:为了给程序员更多的 “可操作空间”
- 前一个代码,写的是 睡眠1秒
sleep(1000)
,但现在还没到1s呢,就要终止线程了 - 这就相当于两个前后矛盾的操作,在计算机眼里,这一步出现了问题,所以需要我们程序员来对这样的情况进行具体的处理
- 处理方法有:
- 让线程立即结束:加
break
- 让线程不结束,继续执行:不加
break
- 让线程执行一些逻辑之后,再结束:写一些其他代码,再
break
- 让线程立即结束:加
- 前一个代码,写的是 睡眠1秒
线程须知
- 一个线程对象只能调用一次
start()
方法,多次调用会抛异常!所以要想启动更多线程,就是需要创建更多新的线程实例- 本质上
start()
会调用系统的 api,来完成 创建线程 的操作
- 本质上
关于线程,异常处理方案
线程中我们经常需要去处理抛的异常,一般有以下处理方法
- 尝试自动恢复
- 能自动恢复的,就尽量自动恢复,如:出现一个网络连接失败,就可以在
catch
中尝试重连网络
- 能自动恢复的,就尽量自动恢复,如:出现一个网络连接失败,就可以在
- 记录日志(将异常信息记录到 文件 中)
- 有些情况并非是很严重的问题,只需要把这个问题记录下来即可,等之后程序员有空再解决
- 发出报警
- 针对一些比较严重的问题(程序无法继续执行)
- 包括但不限于:给程序员 发邮件、短信
- [少数/非常规用法]:依赖 catch
- 比如文件操作中有的方法,就是要通过 catch 来结束循环之类的
4)等待线程 - join()
让一个线程等待另一个线程的结束
-
注意:多个线程的执行顺序是不确定的!(随机调度,抢占式执行)
-
虽然线程底层的调度是无序的,但可以在应用程序中,通过一些api来影响到线程执行的顺序
-
join()
就是一种方式。比如:- t2线程 等待 t1线程,此时,一定是 t1 先结束,t2后结束
- 因为
join
是可能会使 t2线程 阻塞!
使用 join()
在 main 线程中调用 thread.join()
,就是让 main 线程等待 thread 线程结束。哪个线程调用 join()
方法,那么调用这个方法的线程就进入阻塞等待状态
代码:
public class ThreadDemo9 {
public static void main(String[] args) throws InterruptedException {
Thread thread = new Thread(() -> {
for(int i = 0; i < 5; i++) {
System.out.println("子线程正在工作中...");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});
thread.start();
thread.join();
System.out.println("这是主线程,等待子线程结束后,该日志才能打印");
}
}
打印结果:可以看到,主线程 main
调用了 thread,所以 主线程 main 处于阻塞等待状态(下面简称为 “等待状态” )
直接上结论:thread.join()
方法,只会使主线程(或者调用thread.join()
的线程)进入等待池,并等待 thread
线程执行完毕后才会被唤醒。并不影响同一时刻处在运行状态的其他线程
打个比方,join有连接、汇合的意思,主线程main
调用子线程的join()
方法时,好比用一条绳子将两个线程连在一块了,主线程的速度是比子线程快的,这条绳子就相当于把主线程捆住了,需要等子线程跑完,然后主线程才能继续跑
join()
的种类
如图:可以看到有 不带参数 和 带参数的 join()
方法
-
死等(不带参数的)
- 这种方式容易出问题,如果代码中因为死等,导致程序卡住,无法处理后续的逻辑,这就是一个非常严重的bug
-
带有超时时间的等(等,但是有时间限制)
- 等待一定时间,超过了这个时间,就不等了,继续往下走
-
当然,
interrupt()
方法,就可以把 等待池 里的线程提前唤醒
- 如图:join也会抛一个
InterruptedException
异常,就是因为interrupt()
操作可以将它提前唤醒
5)获取当前线程引用
通过 this 拿到线程实例
代码:
class MyThread5 extends Thread {
@Override
public void run() {
System.out.println(this.getId() + " " + this.getName());
}
}
public class ThreadDemo10 {
public static void main(String[] args) throws InterruptedException {
MyThread5 thread1 = new MyThread5();
MyThread5 thread2 = new MyThread5();
thread1.start();
thread2.start();
Thread.sleep(1000);
System.out.println(thread1.getId() + " " + thread1.getName());
System.out.println(thread2.getId() + " " + thread2.getName());
}
}
运行结果:可见,如果是继承 Thread 的类,就可以直接使用 this
拿到线程实例
但如果是 Runnable 或 lambda 的方式,this
就拿不到了,因为此时 this
已经不再指向 Thread
实例了,就只能用以下方法
Thread.currentThread()
方法,获取当前线程引用
代码:
public static void main(String[] args) {
Thread thread1 = new Thread(() -> {
System.out.println(Thread.currentThread().getName());
});
Thread thread2 = new Thread(() -> {
System.out.println(Thread.currentThread().getName());
});
thread1.start();
thread2.start();
}
运行结果:如果在 lambda 表达式中改为 this
去引用,势必会报错
6)休眠当前线程
已经很熟悉的方法了,就是我们的 sleep()
方法
三、线程的状态
Java中,线程有以下六种状态:
- NEW:Thread 实例创建好了,但是还没有调用
start()
方法,系统中**还没有创建出线程** - TERMINATED:系统内部的**线程已经执行完毕**,但是Thread 对象仍然存在
- RUNNABLE:就绪状态。表示这个线程正在CPU上执行,或者已经准备就绪,随时都可以去CPU上执行
- TIMED_WAITING:指定时间的阻塞,在到达一定时间后,线程会被自动唤醒
- 如使用
sleep()
或 带有超时时间的join()
方法,就会进入这个状态
- 如使用
- WAITING:也就是 死等,必须满足一定条件后,才会唤醒该线程
- BLOCKED:锁竞争引起的阻塞
画个图来表示这六个状态的流程:
- 当程序卡住时,就可以使用 jconsole 之类的工具,观察线程的状态,找出问题所在