文章目录
前言 :
简单回顾上文知识点
上文我们了解了 线程是为解决并发编程引入的机制.
知道了线程相比于进程来说更轻量 ( 创建线程比创建进程开销更小,销毁进程比销毁进程 开销更小, 调度线程比调度进程开销更小) 。
另外我们的进程是包含线程的 , 所以同一个进程中的诺干个线程之间共享同一份内存资源和文件描述符表,
虽然进程之间是共享同一份资源 , 但是线程之间都是可以独立调度执行的,且每个线程都有自己的状态 / 优先级 / 上下文 / 记账信息 .
除了以上这几点 ,还总结果两点 :
1.进程是操作系统分配的基本单位
2.线程是操作系统调度执行的基本单位
通过 Thread 类 创建线程的几种方法
1.继承Thread 重写 run
2.实现 Runnable 重写 run
3.使用匿名内部类, 继承 Thread
4.使用匿名内部类,实现 Runnable
5.使用lambda 表达式
注意 : 这里是离不开 Thread的, 上面的几种方式 只是使用了不同的方式来描述 Thread里的任务是啥 。
同时这里的几种方法创建出来的线程是一样的 .
最后回顾一下 Thread 的 run 和 start 的区别
run : 只是一个普通的方法, 描述线程执行的任务是上面
start : 当我们调用 start 方法时才会去创建线程, 并执行run里面的方法.
下面就来开始本文的学习 :
上文 我们已经通过 Thread类创建了我们的线程, 下面就来了解一下 Thread类 和 方法
1.Thread类 :
概念 :
Thread 类是 JVM 用来管理线程的一个类,换句话说,每个线程都有一个唯一的 Thread 对象与之关联。
Thread 类的对象就是用来描述一个线程执行流的,JVM 会将这些 Thread 对象组织起来,用于线程调度,线程管理
1.1 Thread类常见的构造方法
方法 | 说明 |
---|---|
Thread() | 创建线程对象 |
Thread(Runnable target) | 使用 Runnable 对象创建线程对象 |
Thread(String name) | 创建线程对象,并命名 |
Thread(Runnable target, String name) | 使用 Runnable 对象创建线程对象,并命名 |
【了解】Thread(ThreadGroup group,Runnable target) | 线程可以被用来分组管理,分好的组即为线程组,这个目前我们了解即可 |
这里 Thread(String name) 和 Thread(Runnable target , String name) 与之前相比只是多了一个参数 name , 主要的作用就是为我们的线程起名,方便我们后面进行调试 , 这里线程的默认名字 为 thread-0 之类的…
如 thread - 1, 2 , 3 等, 线程一多就很难分辨,所以命名操作比较重要。
下面就来演示一下 :
图一 : 创建线程, 并将线程命名为 mythread
, 并启动
下面就可以通过上文所讲的 jconsole
来查看我们的线程, 这里就不赘述如何找到jdk的jconsole
。
图二 :
注意 :
常见的构造方法看完来看看我们的Thread的常见属性
1.2 Thread的几个常见属性
属性 | 获得方法 |
---|---|
ID | getId() |
名称 | getName() |
状态 | getState() : 线程状态 |
优先级 | getPriority() |
是否后台线程 | isDaemon() |
是否存活 | isAlive() |
是否被中断 | isInterrupted() |
getName() : 这里获取到的就是我们再构造方法里面取的名字
getState() : 线程状态
注 :java里面的线程状态要比操作系统原生的状态更丰富一点
getPriority() : 这个可以获取线程的优先级 ,也可以设置,但设置了也没有啥用
isDaemon()
: 这里是判断是否为守护线程,这里翻译成守护线程可能会让人尝试误会,所以这里更推荐说成是否为后台线程 :
那么啥是后台线程呢 ?
简单来说 : 到了 中午 12点 ,吃饭,但我们手头上有一件事情,比较重要 紧急,必须做完才能去吃 , 这里就是前台线程,必须等线程任务执行完才能结束线程.
后台线程 : 同样是 12 点 吃饭,手头上同样有一件事情,但是不那么 重要,紧急 ,此时就可以直接放下手中的事情,去吃饭. 就是说后台线程 ,不会因为线程的工作 而导致线程不结束.
前台线程 :会阻止进程结束,前台线程的工作没有做完,进程是完成不了的
后台线程 : 不会阻止进程结束 , 后台线程工作没做完,进程也可以结束的.
注意 : 代码里面手动创建的线程,默认都是前台线程, 包括 main默认也是前台线程。
其他 jvm 自带的线程都是后台的, 这里我们也可以使用 setDaemon
设置成后台线程.
演示 通过 setDaemon
将 t 线程改为 我们的后台线程
isAlive() : 是否存活
这里判断的系统里面的线程是否存活
图一 :
图二 :
另外 : 多个线程再微观上可能是并行的(多个核心), 也可能是并发的(一个核心) ,宏观上感知不到(应用程序) , 所以我们看到的就始终是随机执行,抢占式调度的.
属性看完下面来看一下我们如何中断一个线程
1.3 中断一个线程
注意 : 这里中断的意思 ,不是让线程立刻就停止,而是通知线程 ,你应改要停止了,是否真的停止,取决于线程这里具体的写法 .
举个简单的例子 :
这里我们处于打游戏 的时候, 突然 母亲叫我们去超市带一瓶酱油 此时我们有三种做法
1.放下游戏,立刻就去
2.打完这把,再去,稍后处理
3.假装没听见,就完全不处理
此时 这里是直接去还是拖一会,还是直接装傻充愣 不去都是我们自己的选着 。
在我们线程中 就有两种比较典型的做法
1.通过共享的标记来进行沟通
2.调用 interrupt() 方法来通知
上面自定义变量的方法 会有一个很大的缺点, 它不能及时响应, 尤其是在 sleep 休眠的时间比较久就的时候 .
这里我们就可以使用 Thread 自带的标志位,来进行判断
图一 :
图二 :
图三 :
注意 : 这里是 sleep 清空我们的标志位 .
之前我们说过 线程的执行的随机调度的,所以我们并不能够知道谁前谁后,正因为它的不可预期性,这里我们普变更喜欢能够预期的,知道谁先执行谁后执行 , 这里我们就可以通过 等待线程,来控制两个线程的结束顺序.
1.4 等待一个线程-join()
图一 :
图二 :
好比 :放学接娃, 原本 5点放学, 我们 4.55 到,此时我们就需要等待孩子放学,到了时间我们就可以将孩子接走,如果 我们 5.05到,那就不是我们等孩子了而是孩子等我们, 当我们到了就直接将孩子带走即可.
另外 我们直接使用 join() 会有一个问题,就是死等的问题, 啥是死等呢 ?
就相当于当于一条舔狗,一直舔一个女神,不管多久就舔她一个,这里就可以看成死等 , 没有舔到 , 就一直就 舔。
这里就非常不好,即便是舔狗也是有原则的,也是有底线的,舔不到就不舔了,换一个。
join 为了解决死等的问题 , 就提供了另外一个版本 ,即 join(long millis) 此时就可以指定等待的时间, 这里的参数就是线程能够等待的最长时间 , 当时间到达了 这个等待的时间,就不会在等待了 直接执行下面的操作。
1.5 获取当前线程引用
方法 | 说明 |
---|---|
public static Thread currentThread(); | 返回当前线程对象的引用 |
这个方法在 中断一个线程就出现了,到这里因该是比较熟悉的 ,所以这里就直接一笔带过 。
题外话:
public static Thread currentThread() ;
这里我们一般都称 被 static
修饰的方法 是静态方法但是静态方法这个名字不太好 , 更推荐 称为 类方法 。
类方法 : 调用这个方法,不需要实例,直接通过类名来调用。
如果 : Thread t = new Thread() Thread 是 类 , t 是实例 , 那么 对于 currentThread() 方法, 就可以直接 Thread.currentThread() 调用 (返回值正是这个引用指向的对象), 而不一定非得
t.currentThread() , 这里 直接使用类名调用 就是类方法 。
另外 : 通过 t.currentThread() 也是可以调用的, 但这么做,本质上 还是通过实例找到类, 再通过类名的方法调用. (javase 语法中也提过这个,这样写编译器会报警告).
1.6 休眠当前线程
方法 | 说明 |
---|---|
public static void sleep(long millis) throws InterruptedException | 休眠当前线程 millis毫秒 |
public static void sleep(long millis, int nanos) throws InterruptedException | 可以更高精度的休眠 |
线程休眠 : 本质上就是让这个线程不参与调度了(不去CPU上执行) 。
好比之前进程中的 海王例子 : 不是说海王谈了3个女朋友 A B C , 假如 B 去出差了 , 此时海王就没有机会去和 B约会了, 那么 B 就相当于暂时不参与调度了.
此时 只有 A 和 C 能进行调度 ,当 B回来之后 ,才能重新参与调度 .
2.线程状态
之前在 进程的那篇文章,说过状态 是进程的一种属性但是这种说法并不是太严谨, 为啥之前说呢?
还是哪一个点, 之前的文章是针对进程中只有一个线程的情况,所以才去这么说的, 但大多数情况下一个进程对应着一组PCB(每个PCB对应这一个线程) , 此时操作系统调度线程, 就可以通过线程的状态来判断是否需要调度 就绪就直接调用 ,阻塞就换一个处于就绪的线程
. 所以以后听到状态更应该是线程的属性了, 那么后面谈到状态都应该先考虑线程。
我们 主要讨论 两种状态 1.就绪, 2. 阻塞 , 但是 java 对于线程的状态,进行了细化操作 , 也就分出来了 6中状态, 下面就来看看.
1.NEW :创建了 Thread对象, 但是还没调用 start(操作系统内核还没创建对应的PCB)
2.TERMINATED : 表示内核中的PCB 已经执行完毕了, 但Thread对象还在.
3.RUNNABLE : 可以运行的 (注意 : 这里叫 RUNNABLE
,而不是 RUNNING 正在运行
)
这里有两种情况
a : 正在CPU上执行的
b : 在就绪队列里,随时可以去CPU上执行 .
4.WAITNG
5.TIMED_WAITING
6.BLOCKED
这里 4 , 5 , 6 都是阻塞 , 都是表示 线程 PCB正在阻塞队列中 , 这几种状态 都是不同原因的阻塞.
简单画一个 线程的转化 :
另外 这里也有 复杂的 :个人感觉简单的图足够 了 .
下面就来通过 代码 来触发一下这几个状态 :
1.NEW :创建了 Thread对象, 但是还没调用 start(操作系统内核还没创建对应的PCB)
2.TERMINATED : 表示内核中的PCB 已经执行完毕了, 但Thread对象还在.
补充 :
TERMINATED 这个状态其实并没有啥用 , 因为一旦内核里的线程 PCB 消亡了, 此时 代码中 t 对象 也就没啥用了。
为啥存在呢 ?
其实迫不得已 , java中对象的生命周期,自有其规则,这个生命周期和系统内核里面的线程并非完全一致 , 内核的线程释放的时候,无法保证 Java 代码中 t 对象也立即释放 .
因此 , 势必就会存在 ,内核的 PCB 没了,但是代码中的 t 还存在的情况 , 此时就需要通过特定的状态,来将 t 对象表示成 “无效的状态” 。
另外这里 不能 重新 start , 也就是不能重新创建线程 , 这里一个线程 只能start 一次 .
3.RUNNABLE : 可以运行的 (注意 : 这里叫 RUNNABLE
,而不是 RUNNING 正在运行
)
这里有两种情况
a : 正在CPU上执行的
b : 在就绪队列里,随时可以去CPU上执行 .
如果再 run 里面写了 sleep 之类 的方法 让线程阻塞了 , 就不是 RUNNABLE
4.TIMED_WAITING
5.WAITNG
6.BLOCKED
最后的这两个 后面说 .
到此 Thread 类常见的方法 和 线程的状态就差不多说完了, 下面我们来通过代码来看看 :
单个线程和多个线程之间的执行速度的差别 (CPU 密集的程序中差别是非常明显的) .
小补充 :
其实 程序主要分为 :
CPU 密集 : 包含了大量的加减乘除等算术运算
IO 密集 : 涉及到读写文件, 读写控制台,读写网络
这里就可以假设一个场景 : 有一个运算量非常大的任务 , 这里就分别通过进程 (单个线程) 和 多线程分别执行,观察他们开始执行时间 和 结束程序时间之差。
1.使用单个线程
2.使用两个线程
对比一下 : 使用单个线程 执行的时间为 484ms
, 使用两个线程执行的时间是 270ms
, 可以看到时间缩短的很明显。
但是就有一个问题 :为啥不是正好缩短一半?
答案 : 因为多线程可以更充分的利用到多核心 cpu 的资源,因此使用多线程的时间会快 ,但是 我们并不能知道 t1 和 t2 是分布再两个 CPU上执行还在一格cpu上 先执行 t1 然后 执行 t2 ,绕一圈回来 又执行t1. (这里就是并行和并发)
另外一点我们的线程调度 自身也是又时间消耗的.
所以这的时间就很难缩短到一半 。
所以 : 多线程 在这种CPU密集型的任务中,有非常大的作用,可以充分的利用 CPU 的多核资源,从而加快程序的运行效率
另外 : 并不是使用多线程 就能一定提高效率
还需要考虑 1. 是否是多核 , 2. 当前核心是否空闲(如果CPU 这些核心都满载了,这个时候启动跟多的线程也就没啥用)。
上面说 多线程 在 密集型程序有明显的作用, 其实多线程在 IO密集型的任务中,也是有作用的 .
举例 : 在日常使用的一些程序中,经常会看到 “程序未响应” (如 :启动 某款程序 ) 为啥呢 ?
因为 程序 进行了 一些耗时 的 IO 操作 (比如打开 dota2 启动 加载数据文件 ,就涉及到了 大量的读硬盘操作 ) , 阻塞了界面响应 ,这种情况下使用多线程也就可以有效改善(一个线程赋值IO , 另外一个线程用来响应用户的操作).