前言
本文主要介绍的是【线程】相关内容,注意也要掌握【线程】相关代码的书写以及运行分析。
一、【进程】回顾
虽然多进程已经实现了并发编程,但是存在重要的问题:假如针对每一个客户端都分别创建进程,那么就会造成频繁创建/销毁进程,使得多进程就比较低效。
进程创建步骤:
① 创建PCB;
② 给进程分配资源(内存/文件),然后赋值到PCB中;
(这对操作系统来说是不太容易的,比较消耗时间)
③ 把PCB插入链表
进程销毁步骤:
① 把PCB从链表上删除;
② 把PCB中持有的资源释放;
③ 销毁PCB
二、线程Thread
-
线程Thread
① 线程是被包含在进程中的。
② 一个进程默认会有一个线程,当然也可以有多个线程; 每个线程都是一个单独的“执行流”,可以单独在CPU上进行调度。
③ 同一个进程中的这些线程 共用同一份系统资源(内存+文件) -
线程又被称为“轻量级进程”
理由:创建线程的开销比创建进程小;且销毁线程的开销比销毁进程小。 -
使用线程:
① 能够更充分利用多核CPU,能够提高效率;
② 在同一个进程中,只是创建第一个线程的时候需要申请资源,后续再创建新的线程都是共用同一份资源(节省了申请资源的开销); 销毁线程的时候,只有销毁到最后一个线程的时候才真正释放资源,前面的线程销毁都不是真正的释放资源。 -
操作系统的内核是通过一组PCB来描述一个进程的,每个PCB对应一个线程;
① 这组PCB上的内存指针和文件描述符表其实都是一样的,是共用的;
② 而状态、上下文、优先级以及记账信息则是每个PCB(每个线程)自己有一份。
所以: 进程是 资源分配 的基本单位; 线程是 调度执行 的基本单位。 -
重要面试题: 谈谈进程和线程之间的区别【高频】(面试必考!背!!)
答:① 进程包含线程;
② 线程比进程更轻量,创建更快、销毁也更快;
③ 同一个进程的多个线程之间共用一份内存和文件资源,而进程和进程之间则是独立的文件和内存资源;线程共用资源就省去了线程分配资源的过程
④ 进程是资源分配的基本单位,线程是调度执行的基本单位 -
多线程的线程数目不是越多越好,因为CPU核心数是有限的。当线程数目达到一定数量的时候,CPU的核心就已经被吃满了,此时再增加线程也无法提高效率了;反而会因为线程太多,线程调度开销太大而影响了效率。
-
现代的CPU一般都有“超线程技术”,如一个核心可以并行跑两个线程
-
如果两个线程同时修改同一个变量,也容易产生“线程不安全”问题。
-
如果某个线程发生了意外(出现异常),并且该异常没有处理好就可能导致这个进程崩溃,此时后续的所有线程都难以运行。
-
线程的一些相关操作在操作系统中都提供了一系列的API。
(因为系统原生的线程的API是C语言的,所以java就把这些API给封装成java风格的了) -
多个线程的执行是“抢占式执行”;线程是独立的执行流。
-
多线程的当前程序确实是跑起来了,但是如果想要查看是不是多个线程同时执行的,就可以借助jdk中的jconsole工具来进行查看
jconsole双击打开–> 本地进程–> 选择刚才运行的java进程(双击)–>
选择“不安全连接”(如果双击打开了jconsole之后啥都没有,则尝试 右键-以管理员方式运行)① 有的机器上可能权限管理比较严格,导致jconsole查看不到进程信息,所以就使用“管理员权限运行”
② 打开查看线程的时候会发现除了自己运行的N个线程之外;还有别的线程,这是JVM自己创建的内置功能,有些是负责垃圾回收的、有些是用来辅助完成调试的、还有些是监控进程是否收到一些特殊信号并进行动作的
③ 这里的状态是java自己搞的状态,和操作系统中的PCB里的状态还不太一样;
此处的堆栈跟踪 :描述了当前线程、调用栈是啥样的;是方法之间相互调用的一个关联关系
三、创建线程
-
创建线程的办法
① 继承Thread类,重写run方法,run方法是新线程的入口
② 实现Runnable接口,重写run
③ 使用匿名内部类,实现创建Thread子类的方式
④ 使用匿名内部类,实现 实现Runnable接口的方式
⑤ 使用Lambda表达式(lambda本质上是一个“匿名函数”)
(其实lambda表达式一般用于一个方法的实现上,该方法可以作为参数传入)
线程的创建不不止这5中,还有两种后续介绍。【面试题:java中有哪些方式来创建线程?】
(注:在实际工作中,在写代码时使用下划线将数字进行分割是一个有效技巧)
-
创建线程的相关事宜
// 创建MyThread实例:创建实例并不会在系统中真的创建一个线程!! MyThread myThread = new MyThread(); // 调用 start方法的时候才是真正创建出一个新的线程!!! // 而此时新的线程就会执行run里面的逻辑,一直到run里的代码执行完新的线程才是运行结束。 myThread.start(); // 调用 start方法 其实是另外启动了一个线程来执行Thread中的run方法! // 新的线程是一个单独的执行流,和现有线程的执行流不相关。 并发(并发+并行)执行 // 如果main方法里面没有Thread子类创建的实例对象调用start方法,则调用的其实是main里面自带的默认线程(也叫 主线程) // main主线程(JVM创建)和MyThread创建出来的新线程是一个“并发执行”的关系。(但是此处的并发其实是指:并发+并行) // 并发执行:两边同时执行,各自安好、互不干扰! // MyThread的线程中run是该线程的入口方法
-
阻塞等待
t1.start(); t2.start(); // 此处才创建线程 // 阻塞等待线程结束 join // 在main中调用 t1.join(); 的效果其实就是 让main线程阻塞,一直到t1执行完run后main才继续执行!!! // 在main中调用 t2.join(); 的效果其实就是 让main线程阻塞,一直到t2执行完run后main才继续执行!!! // 注意:t1和t2是并发执行的(宏观上是同时执行的),而不是先执行完t1再执行t2!! try { // 抛异常 t1.join(); t2.join(); } catch (InterruptedException e) { e.printStackTrace(); }
① 使用两个线程消耗的时间不一定是使用一个线程消耗的时间的50%。因为:其实两个线程的并发执行=并行+并发,在执行过程中,并行(其实会变快)和并发(总时间没有减少,反而会因为线程切换而调度开销变长)执行次数不确定
② 创建线程的实例的过程也是有开销的:串行执行没有额外创建线程,并发执行就需要额外创建线程。
如果计算量大、计算的久,创建线程的开销就不明显;但是如果计算量小、计算的快,创建线程的开销影响较大,多线程提升效率就不明显,反而可能会降低执行效率
THINK
重点掌握
- 线程的创建以及运行
- 面试题:进程和线程的区别
- 面试题:java中有哪些方式来创建线程
四、Thread类及主要方法
- 【复习】进程包含了线程,一个线程对应一个PCB,一个进程对应一组PCB(内存指针和文件描述符表都是相同的一份,但是状态、优先级、上下文、记账信息是每个线程独立的)
(一个进程对应一组PCB:pid其实是不一样的,但是属性tgrp_id是相同的) - 每个线程都有一个唯一的 Thread 对象与之关联。
1. Thread常见构造方法
-
可以在创建线程的时候给线程起名字(这个名字是允许重复的),目的就是为了方便程序员来调试。
如果不手动起名字,默认JVM会按照thread-0、thread-1、…这样子起名字,但是可读性不好。 -
Thread常见的构造方法
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-UuAXDA6S-1679473678226)(E:\Users\yu\Desktop\数据结构\多线程.assets\b05b22aab0a44d92a07aed541b3bee8d.png)]
示例:
Thread t1 = new Thread();
Thread t2 = new Thread(new MyRunnable());
Thread t3 = new Thread("这是我的名字");
Thread t4 = new Thread(new MyRunnable(), "这是我的名字");
2. Thread的几个常见属性
-
Thread的常见属性
① ID 是线程的唯一标识,不同线程不会重复
② 名称是各种调试工具用到
③ 状态表示线程当前所处的一个情况
④ 优先级高的线程理论上来说更容易被调度到
⑤ 关于后台线程,需要记住一点:JVM会在一个进程的所有非后台线程结束后,才会结束运行。
⑥ 是否存活,即简单的理解,为 run 方法是否运行结束 -
常见属性相关介绍
① getId() 是java给Thread对象安排的身份标识,和操作系统内核中的PCB的pid以及和操作系统提供的线程API中的线程id都不是一回事儿。
② 身份标识可以有多个,在不同的环境下使用不同的标识。一个线程:
①在JVM中有一个id
②在操作系统的线程API中有个id
③在内核PCB中还有个id
(但是这些id效果其实是一样的,只是在不同环境中使用)
③ 咱们默认创建的线程是“前台线程”,前台线程会阻止线程退出。
如:如果main运行完了而前台线程还没运行结束,则进程不会退出。
如果是后台线程,后台线程是不阻止进程退出的。
如:如果main等其他的前台线程执行完了,此时即使后台线程没有执行完进程也会退出(主线程main也是前台线程)
(前台线程:一般是重要的线程,也就是数据较为重要,一定要完成才能够结束的那种。如:转账)
④ 可以使用setDaemon(true)来设置为后台线程。—— 在start之前设置!! 一旦程序启动就没办法设置。
⑤ isAlive() 是判断线程是否存活 ,也就是判断内核中的线程是否还存在
Thread对象虽然和内核中的线程是一一对应的关系,但是生命周期并非完全相同。
Thread对象已经创建后内核里的线程还不一定有,调用start方法后内核线程才有;当内核线程执行完了(run运行完了),内核的线程就销毁了,但是Thread对象还在。
3.线程启动
- 调用start才会真正创建线程,不调用start就没有创建线程(注意这里指的是在内核里创建PCB)
- 调用 start 方法, 才真的在操作系统的底层创建出一个线程。
- 注意理解:run和start的区别==【经典面试题】==
答:① 直接调用run并没有创建线程,只是在原来的线程中运行代码,只是相当于调用方法
② 调用start则是创建了线程,在新线程中执行代码(和原来的线程是并发执行的(并发+并行))
4.线程中断
-
线程的中断
run方法执行完则线程结束,那么有没有办法让线程提前结束呢?
——就是通过线程中断的方式来进行的(本质上仍然是让run方法尽快结束,而不是让run执行一半就强制结束) -
方法:① 直接自己定义一个标志位,作为线程是否结束的标志。
②使用标准库里面自带的一个标志位 -
interrupt方法的行为有两种情况:①t线程在运行状态会设置Thread.currentThread().isInterrupted() 为true
(注:public static Thread currentThread();
// 返回当前线程对象的引用,在哪个线程里调用得到的就是哪个线程的引用)
②t线程在阻塞状态(sleep)不会设置标志位,而是触发一个InterruptedException异常,这个异常会把sleep提前唤醒开始运行; 所以要想顺利结束运行就加一个break; -
中断线程,目前常见的有以下两种方式:
1) 通过共享的标记来进行沟通(也就是自定义标记)
2) 调用 interrupt() 方法来通知1)自定义标记:
需要给标志位上加 volatile 关键字(不加也是ok的)
如:public volatile boolean isQuit = false;
2)调用 interrupt() 方法来通知:
① 使用 Thread.interrupted() 或者 Thread.currentThread().isInterrupted() 代替自定义标志位
② 使用 thread 对象的 interrupted() 方法通知线程结束
thread 收到通知的方式有两种:
① 如果线程因为调用 wait/join/sleep 等方法而阻塞挂起,则以 InterruptedException 异常的形式通知,清除中断标志
当出现 InterruptedException 的时候, 要不要结束线程取决于 catch 中代码 的写法.。可以选择忽略这个异常, 也可以跳出循环结束线程。
② 否则,只是内部的一个中断标志被设置,thread 可以通过 Thread.interrupted() 判断当前线程的中断标志被设置,清除中断标志
Thread.currentThread().isInterrupted() 判断指定线程的中断标志被设置,不清除中断标志
这种方式通知收到的更及时,即使线程正在 sleep 也可以马上收到。
5.在java中,中断线程并不是强制的,而是线程自身的代码来进行判定处理的。
线程自身能怎么处理呢?
①立即结束线程; ②不理会; ③稍后理会 【也就是说取决于代码么写】
5.线程阻塞
-
阻塞等待join:
线程之间的调度顺序是不确定的,可以通过一些特殊的操作来对线程的执行顺序作出干预。如join就是一个方法来控制线程的结束顺序。 -
在main中调用t.join效果就是:让main线程阻塞等待,等到t执行完了main才继续执行。
-
【java中的多线程方法,只要这个方法会阻塞都可能抛出InterruptedException异常】
-
【如果是调用join之前t线程就已经结束了,此时main线程还需要等待吗?不需要!】
-
join有带时间版本:等待,但并不是无限等待
实际开发中,很少使用无线等待;大多数都指定了最大等待时间,避免程序因为死等导致“卡死”情况。join相关方法
6.线程休眠
- sleep指定休眠时间,让线程休息一会儿(阻塞一会儿)。
- 操作系统管理这些线程的PCB的时候是有多个链表的。
- 就绪队列的PCB才会参与线程调度,而阻塞队列中的PCB暂时不会参与线程的调度。
- 当就绪队列中的PCB sleep之后进入阻塞队列,当时间达到的时候该PCB又从阻塞队列挪回就绪队列,但是**并不代表可以立即能够上CPU运行,**还得看系统啥时候调度到该CPB。
- 所以:sleep(N); 并不一定是真的只休眠了N ms,一般是要略大于N的,具体时间看调度的时间开销。 因为:从阻塞到就绪之后不一定CPU就直接调度到该PCB!
- 【所以,为了解决上述调度的等待调度时间的不可预知性,就出现了“实时操作系统”,该操作系统的特点就是:任务调度的开销是可预期的,等待调度时间在可控范围内。
(但是做到实时,是需要付出一些代价的) 如:知名实时操作系统:vxworks、wind river】
sleep相关方法:(了解)
五、线程状态
-
Java中有自带的状态,但是觉得不是特别合适;所以又自己搞了一套状态,具体如下:
① NEW:Thread 对象创建出来了,但是内核的PCB还没有创建,也就是说:还没有真正创建线程。
② TERMINATED:内核的PCB销毁了,但是Thread 对象还在。
③ RUNNABLE:就绪状态(正在CPU上运行+在就绪队列中排队)
④ TIMED_WAITING:按照一定的时间进行阻塞。 调用sleep、join这类带时间的都是TIMED_WAITING
⑤ WAITING:特殊的阻塞状态,调用wait等
⑥ BLOCKED:等待锁的时候进入的状态
(其实整体来说,状态就是:就绪+ 阻塞(分成三种具体情况了④⑤⑥)) -
状态转换
- .补充:
① RUNNABLE:(运行+排队)可被服务的状态,是否开始服务,则看调度器的调度
② isAlive() 方法,可以认为是处于不是 NEW 和 TERMINATED 的状态都是活着的。
③ Thread. yield() 大公无私,让出 CPU。
yield 不改变线程的状态, 但是会重新去排队。
CKED:等待锁的时候进入的状态
(其实整体来说,状态就是:就绪+ 阻塞(分成三种具体情况了④⑤⑥))
-
状态转换
[外链图片转存中…(img-8UuMEkRL-1679473678230)]
-
.补充:
① RUNNABLE:(运行+排队)可被服务的状态,是否开始服务,则看调度器的调度
② isAlive() 方法,可以认为是处于不是 NEW 和 TERMINATED 的状态都是活着的。
③ Thread. yield() 大公无私,让出 CPU。
yield 不改变线程的状态, 但是会重新去排队。