目录
进程
1.进程具有独立性
———— 虚拟地址空间
线程
为什么要引入多个线程?
多线程注意点
⁜⁜总结:线程和进程的区别和联系⁜⁜ (经典面试题)
Java如何进行多线程编程?
创建线程
——方法1 继承 Thread 类
——方法2 实现 Runnable 接口
—— 使用Runnable的写法 和 直接继承 Thread 有什么区别?
匿名内部类 方法
继承 Thread,重写run,但是使用匿名内部类
实现Runnable,重写run,使用匿名内部类
基于 lambda 表达式 【推荐 比前面的方法都简单】
—— lambda 表达式 (介绍了回调函数)
Thread 类的其他常见使用方法
—— Thread 的常见构造方法
—— Thread 的几个常见属性
启动线程-start() (含经典面试题,start 和 run的区别)
中断线程
-- 方案一
-- 方案二
线程等待
首先介绍一下
在这之前还要了解一下 —— “物理内存”
在早期的操作系统中,程序运行时分配的内存,就是 “物理内存”。
这个物理内存简单理解,就可以看成是一个宿舍楼,宿舍楼里有很多房间,每个房间占一个字节,且每个房间都有编号,这个编号就是“内存地址”。
那现在分配内存就直接从物理内存上分配,但是这时候就会出现问题
而操作系统需要给进程提供一个稳定的运行环境 ,上述的肯定不行。
所以操作系统就引入了 “ 虚拟地址空间 ” 的概念, 不直接分配物理内存了,而是分配一块虚拟的内存空间。操作系统对于内存又进行了一层抽象,如下图。
正是这样的机制,才带来了进程的独立性 。
进程是 资源分配 的基本单位。
(一个系统中可以有很多的进程,每个进程,都有自己的资源)
在Java这样的生态中,不是很鼓励 多进程编程,更鼓励使用 多线程编程
为了实现 并发编程 ———— 当前的时代是一个 多核CPU的时代。
虽然多进程 实现 并发编程,也是很不错的,但是,多进程编程模式 太重了,效率不高,不管是创建,销毁,还是调度一个进程,消耗时间都比较多,
总的来说,就是多进程 开销比较大,效率比较低。 (进程的开销主要是消耗在了申请资源上,进程是资源分配的基本单位)
为了解决上述问题,我们就引入了 “线程(Thread)”。(也叫 “轻量级进程”)
创建,销毁,还是调度一个线程,都比进程要快。
但是线程是不能独立存在的,他必须依附于线程,进程包含线程 。
进程可以包含一个或多个线程。 也就是说,一个进程,最开始至少要包含一个线程,这个线程负责完成 执行代码的工作,也可以根据需要 创建更多的线程,来实现 "并发编程" 的效果。
每个线程都可以独立执行一些代码。
实际情况,一个进程里有多个线程,而每个线程 ,都是可以独立进行调度的 ,每个线程也都有状态,优先级,上下文,记账信息......
一个进程可能使用一个PCB 或 多个PCB表示,每个PCB 对应到一个线程
上述结构决定了线程的特点
- 每个线程都可以 独立 在CPU上调度执行。
- 同一个进程里的多个线程,共用同一份 内存空间 和 文件资源。
所以创建线程的时候 不需要重新 申请资源 ,直接 复用 之前已经给 进程 分配好的资源。
这样就省去了 资源分配的开销,于是创建效率就高了。
画图捋一遍
得出 线程 是 调度执行 的基本单位。
(一个进程中,可以有很多的线程,每一个 线程 都能 独立调度执行,共享 内存/硬盘 资源)
上述对于 多进程 和 多线程 的描述还是比较抽象的,那举一个生活上的例子 。
假如,就是现在有个院子,院子里有条生产线,现在产品销量比较好,老板想扩大一下规模,想多搞一条生产线,那现在就是有两个方案。
方案一:
在搞一个院子,那就有两条生产线了,但是再找一个院子,成本比较高。
方案二 :
在同一个院子里再搞一条生产线,是独立的,各自都能生产,但是这两个生产线共用一个院子,一组工人,一套物流体系,这样就节约了成本 还 提高了生产力 (资本家呀~)
那方案一就使用了 多进程的方式 ,方案二就使用了 多线程的方式。
- 当到了一定程度时,再进一步增加线程数目时,效率无法进一步提升,反而会因为要调度 的线程数目太多,时调度的开销更大,反而会降低了效率。
- 当线程数目多了,可能就会产生一定的冲突,称为 "线程不安全问题" 。
- 如果一个线程抛出异常,没有被妥善处理(catch),就容易把整个进程都搞崩溃,那其他线程也就都没了。
- 进程 可以包含一个或多个 线程
- 进程和线程都是用来实现 “并发编程” 场景的,但是线程比进程更轻量,更高效
- 在同一个进程里的线程,共用同一份资源(内存 和 硬盘),省去了申请资源的开销
- 进程具有独立性,一个进程挂了,不会影响到其他进程;同一个 进程 里的 线程,是可能会相互影响的 (线程安全问题 + 线程出现异常)
- 进程是资源分配的基本单位,线程是调度执行的基本单位。
在java中使用线程,一般有下面几步:
- 创建线程
- 启动线程
- 中断线程
- 线程等待
线程是操作系统的概念。操作系统提供了一些API(应用程序接口),可以操作线程。
Java就利用 (线程)Thread类 针对上述系统API进行了进一步的抽象和封装(为了跨平台)。 这样程序员只需要掌握这一套API就可以了。
那java是如何创建线程的呢,接下来介绍
Thread类是Java标准库内置的类,在 java.lang 这个包下。
使用Thread类,创建 Thread对象,进一步就可以操作 系统内部的 线程了。
继承 Thread 来创建一个线程类 (重写run),创建 MyThread 类的实例,,调用 start 方法启动线程
前面介绍了每个线程都是一个独立的执行流,每个线程都可以执行一系列的逻辑(代码),
那一个线程跑起来,该从那个代码开始执行呢?从入口方法(run方法)开始执行。
运行一个java程序,就跑起来一个java进程,而一个进程里至少有一个线程——主线程,主线程的入口方法就是 main方法 。
但是其他线程此时还只是一个”定义”,要想执行,还要“调用”,在主线程里调用。 具体该如何调用,如下:
⁜为什么我们上面的是run方法,但是这里调用的却是 start方法呢?
此上我们完成了第一个多线程,main方法对应了主线程,run方法对应了thread线程。
之后我们再稍稍复杂一下代码 ,死循环一下
可以看到这两个循环在 “同时执行 ”,两边的语句在交替打印。
这就非常符合线程的特点 —— 每个线程都是独立执行的逻辑(都是独立的执行流) 。
当程序走到 thread.start() 那里之后,兵分两路,一路继续走主线程,另一路走我们创建的新线程
那这种执行方式就是我们前面所说的 “并发执行”,从而也就达到了 并发编程 的效果。充分的使用了多核CPU资源。
那如果我们把thread.start(), 改成thread.run() 会怎么样呢?
那此时,代码就不会创建出新的线程,只有一个主线程,那主线程只能依次执行,这个走完才能到下一步,那run()方法那里是死循环,那就有了下面的情况
只有hello thread 没有 hello main
ps: 如果你想要打印的慢点,就可以利用 sleep(),(还得抛出个异常)
从上图我们可以看到,两条语句的执行顺序是不一定的,这个过程可以看成是 “随机的”,
也就是说,操作系统,对于多线程的调度执行顺序,是“随机的” (这个随机和数学里的 概率均等的随机不一样,这个“随机”取决于 操作系统 对于线程调度的模块(调度器)的具体实现)
实现 Runnable 接口,重写run(),创建 Thread 类实例, 调用 Thread 的构造方法时将 Runnable 对象作为参数,调用 start 方法
从上面的代码可以看出,和继承Thread 的写法是一样的,作用也是描述线程的入口。
从上图看出 这个 Runnable还是要搭配 Thread 使用
Runnable本身与线程没有直接的联系,这里的Runnable 单纯的表示 “可以运行的任务”,这个任务交给线程还是其他的什么来执行,Runnable并不关心。
把这个任务放到线程里来执行,最终还是要通过thread.start(), 这个操作,调用系统的API(应用程序接口)来完成创建线程的工作。
执行情况和上边一样
三个字概况:解耦合
(ps:耦合,相互影响越大,耦合越高)
(⌒▽⌒) 解释 (⌒▽⌒)
我们要知道,创建一个线程,需要两个关键操作:
—— 但任务本身不一定和线程相关,这个任务只是单纯执行一段代码,不管它是使用单个线程还是多个线程去执行,或者什么其他的方法,都没什么区别,此时就可以把这个任务单独 提取出来,让任务和线程解耦合,那就可以随时把代码改成其他方式来执行这个任务。
2.调出系统API(应用程序接口)创建出线程
具体如何写,如下:
其实没什么区别,就是把方法挪了个位置。和方法一本质一样,就是换了个写法。
一样一样
或者更简便
lambda表达式 是一种更简化的语法表示方式。(“语法糖” )。
相当于是 “匿名内部类” 的替换写法。
lambda 表达式 ,本质上是一个匿名函数(一次性的), 主要用来实现“回调函数”的效果。
(java中不允许函数独立存在,(Java这里叫方法),所以lambda 本质是函数式接口(还是没脱离类))
——“回调函数”是计算机中非常重要的术语,我们来了解一下
- 首先我们要知道 “函数指针”
函数指针 是指向内存空间的,函数为什么跑到内存里了?,这背后是操作系统 加载一个可执行程序 创建进程的过程 。
我们写的代码的都是一个一个文件,都在硬盘里,然后编译,得到个.exe, 还是个文件,当双击这个.exe, 操作系统会加载这个.exe, 把.exe 里的指令和数据加载到内存中,构建成一个进程,此时我们所写的这些函数所对应的二进制指令就进入内存中了,这个时候我们才能拿指针指向它。
- 函数指针有很多用出
- 使用函数指针 实现转移表,降低代码的圈复杂度(就是减少 if else 的分支数目)
- 使用函数指针作为 回调函数。
——接下来就来介绍 回调函数
回调函数与普通的函数有个最明显的特点,回调函数不是你主动调用的,也不是现在就立即调用,而是把调用的机会交给别人来使用,别人会在合适的时机调用这个函数
(ps: 这个“别人” 通常是 操作系统,库,框架,别人写的代码)
接下来我们完善一下这个代码,(和上面差不多)
执行效果也和上面一样
除了这些还会有一些方法,这里就先不介绍了。
方法 | 说明 |
Thread() | 创建线程对象 |
Thread(Runnable target) | 使用 Runnable 对象创建线程对象 |
Thread(String name) | 创建线程对象,并命名 |
Thread(Runnable target, String name) | 使用 Runnable 对象创建线程对象,并命名 |
【了解】Thread(ThreadGroup group, Runnable target) |
线程可以被用来分组管理,分好的组即为线程组,这 个目前我们了解即可 |
当创建线程的时候我们可以指定 name,name不影响线程的执行,只是给线程起个名字,方便再之后调试的时候进行区分
代码如下:
我们利用 jconsole 来看一下效果(一般再 C:\Program Files\Java\jdk1.8.0_192\bin 这个路径下)
上图清晰显示出了名字。
属性 | 获取方法 |
ID | getId() |
名称 | getName() |
状态 | getState() |
优先级 | getPriority() |
是否后台线程 | isDaemon() |
是否存活 | isAlive() |
是否被中断 | isInterrupted() |
线程的身份标识,标识一个进程中唯一的一个线程(这个 id 是Java 给你这个线程分配的,不是系统API提供的线程id,更不是 PCB 中的id。)
——我们先了解一下前台线程 :
一个Java进程中,如果前台进程 没有执行结束,那此时的整个线程是 一定不会结束的,
相比之下,后台程序是否结束,不影响整个进程的结果。
因为一般情况下默认是前台线程,所以我们手动设为后台线程
运行一下,发现直接退出了,
改成后台程序之后,主线程飞快执行完了,此时没有其他前台线程,于是进程结束。 (t线程还没来得及执行呢,就完了)
Thread 对象的生命周期,要比系统内核中的线程更长一些,就会出现,Thread 对象还在,但是内核中的线程已经销毁了这样的情况
那此时就可以使用 isAlive() 来判断内核里的线程是否已经没了(回调方法执行完毕,线程就没了)
接下来用利用代码来看一下
执行结果
ps:小细节,true 和 线程开始 这两个日志不一定谁先打印。 因为线程是并发执行的,并发调度顺序不确定,取决于系统的调度器,(但大概率是先打印 true,因为调用 start 之后,新的线程创建也是有开销的,创建过程中,主线程就执行完了)
线程启动,start() 方法是最关键的方法
start 方法内部,是会调用到系统 API,来再系统内核中创建线程的,
相比之下,run方法,只是单纯的描述了该线程要执行的内容。(会在start 创建好线程之后自动被调用)
interrupt (终止/打断)
就是让一个线程停止运作(销毁)
在 Java 中,要销毁/终止线程,做法是比较唯一的,就是想办法让 run 方法尽快执行结束。
可以在代码中手动创建出标志位,来作为 run 的执行结束的条件。
很多线程,执行时间久,往往是因为这里写了一个循环,循环持续很久导致的,要想让run执行结束,我们就要让 循环 尽快退出。
----代码实现
执行结果
小问题:
当前这个代码,是使用了一个成员变量 isQuit 来作为标志位,如果把isQuit 改为 main 方法中的局部变量,是否可行呢??
上图可看见,当把isQuit 改为 main 方法中的局部变量时,while 那里报错了,
但是当把 isQuit = true 注释掉之后就不报错了,
所以我们就可以得知这里的关键是 不能修改。为什么?
因为lambda 表达式,有个语法规则 —— 变量捕获,lambda 表达式里面的代码,是可以自动捕获到上层作用域中涉及到的局部变量 。
这个变量捕获可以理解为就是让 lambda 表达式,把当前作用域中的变量在lambda内部复制了一份。(所以此时如果外面是否销毁,就无所谓了)
但是,在Java中,变量捕获语法,还有一个前提限制,就是必须只能捕获一个 final(常量) 或者是实际上是 final 的变量。
——final
—— 实际上是 final
所以当下面修改的时候就一定会报错了 。
但当 isQuit 是成员变量时,lambda 访问这个成员时,就不是变量捕获这个语法了,
而是 ” 内部类访问外部类的属性“,没有final之类的限制。
所以不能把isQuit 改为 main 方法中的局部变量。
但上述方案,不够优雅,需要手动创建变量:还有当线程内部在 sleep 的时候,主线程修改变量,新线程内部不能及时响应。所以有了方案二。
Tread 类内部 ,有一个现成的标志位 Thread.currentThread().isInterrupted(),可以用来判断当前的循环是否要结束。
——介绍 Thread.currentThread().isInterrupted()
ps: 注意不能直接使用 t. ,因为此时t 还在构建当中
通过 t.interrupt(),这个操作,就把上述的Thread对象的内部的标志位给设置成 true 了。
ps: 而且使用 t.interrupt() 即使线程内部逻辑出现了堵塞(sleep)也是可以使用这个方法唤醒的. 正常来说,sleep会休眠到时间结束 才醒,但此处的interrupt就可以使 sleep 内部触发一个异常,从而被提前唤醒。
我们运行看看结果
当代码sleep 那里的异常写成这样的时候
我们就会发现,异常是抛出了但是线程没停止
这是因为interrupt 唤醒线程之后,此时sleep 方法抛出异常,同时会自动清楚刚才设置的 标志位
这样就使得 ”设置标志位“ 这个效果好像没有生效一样。
那为啥这么设置呢?
这是因为Java期望,当线程收到“要中断”这样的信号时,线程可以自由决定,接下来该如何处理。
一般线程可以采取三种方式来进行操作:
1.假装没听见,循环继续正常执行(就是上面的情况)
2.加上一个 break ,表示让线程立即结束。
运行效果,线程立刻就结束了。
3. 可以在break,前做一些其他工作,完成之后在结束
这样就让我们程序员有更多的 “可操作性空间” (“可操作性空间” 的前提是 通过 “异常”的方式唤醒的,如果没有sleep,就没有上述的操作空间) (就是没有异常那就正常执行,有异常我们就要再确认一下,看看怎么回事)
ps: IntelliJ IDEA Community Edition 2022.3.3 这个版本的IDEA,生成的try-catch是这样的,
执行之后是可以结束的
有时,我们需要等待一个线程完成它的工作后,才能进行下一步工作 。
本质上就是控制线程结束的顺序。
方法 | 说明 |
public void join() | 等待线程结束 |
public void join(long millis) | 等待线程结束,最多等 millis 毫秒 |
public void join(long millis, int nanos) | 同理,但可以更高精度 |
“join() 方法”就是实现线程等待效果的。(在哪个线程里调用了join,哪个线程就阻塞)
如果在主线程中,调用t.join(), 那此时就是主线程等待t 线程,等待t 线程结束。
利用代码,我们能清晰的看见join的效果。
执行效果
t.join 工作过程
- 如果 t 线程正在运行,那此时调用 join 的线程就会阻塞,一直阻塞到 t 线程执行结束。
- 如果 t 线程已经执行结束了,那此时调用 join 线程,就直接返回了,不会阻塞。
- 如果 t 线程一直不结束,join 默认情况下是 “一直等待的”。
但是一直等待不现实,一般,等待操作都有一个“超时时间”,就有了第二个方法
public void join(long millis)。等待线程结束,最多等 millis 毫秒。
╰(*°▽°*)╯╰(*°▽°*)╯╰(*°▽°*)╯╰(*°▽°*)╯╰(*°▽°*)╯完 ╰(*°▽°*)╯╰(*°▽°*)╯╰(*°▽°*)╯╰(*°▽°*)╯╰(*°▽°*)╯