目录
在上一篇博客当中,我介绍了多线程的一些基础知识,以及如何创建线程。我们现在知道了,创建线程时需要用到Thread类。因此,我会接着介绍Thread类的一些属性和其他用法,其中用法包括:线程中断、线程等待、线程休眠和获取线程实例。
Thread 类是 JVM 用来管理线程的一个类,换句话说,每个线程都有一个唯一的 Thread 对象与之关联。
而每个执行流,也需要有一个对象来描述,类似下图所示,而 Thread 类的对象就是用来描述一个线程执行流的,JVM 会将这些 Thread 对象组织起来,用于线程调度,线程管理。
一.Thread类的常见构造方法
每一个类都有自己的构造方法,当然Thread类也不例外。在Thread类中,既提供了不带参数的构造方法,又提供了带参数的构造方法。具体的构造方法如下表所示:
方法 | 对应说明 |
Thread() | 创建线程对象 |
Thread(Runnable target) | 使用 Runnable 对象创建线程对象 |
Thread(String name) | 创建线程对象,并命名 |
Thread(Runnable target, String name) | 使用 Runnable 对象创建线程对象,并命名 |
【了解】Thread(ThreadGroup group, Runnable target) | 线程可以被用来分组管理,分好的组即为线程组,这 个目前我们了解即可 |
接下来,我来演示一下具体的代码该如何写:
Thread t1 = new Thread();
Thread t2 = new Thread(new MyRunnable());
Thread t3 = new Thread("线程名字t1");
Thread t4 = new Thread(new MyRunnable(), "线程名字t2");
以上的四行代码分别对应表中的四种构造方法,我们可以根据具体情况来合理进行使用。
二.Thread类的几个常见属性
接着我们来看Thread类中的常见属性。具体参考下表:
属性 | 获取方法 |
ID | getId() |
名称 | getName() |
状态 | getState() |
优先级 | getPriority() |
是否后台线程 | isDaemon() |
是否存活 | isAlive() |
是否被中断 | isInterrupted() |
Tips:
由于ID是线程的唯一标识,因此不同线程的ID不会重复。
对于名称这个属性,是各种调试工具用到的。
状态表示线程当前所处的一个情况,后面的博客我会进一步说明。
优先级高的线程理论上来说更容易被调度到。
关于后台线程,需要记住一点:JVM会在一个进程的所有非后台线程结束后,才会结束运行。所以说,前台线程会影响到进程执行结束,后台线程不会影响进程结束。
是否存活,即简单的理解,就看 run 方法是否运行结束了。值得我们注意的是,Thread对象的生命周期往往比系统中的线程要更长。线程没了,Thread对象可能还在。
线程的中断问题,下面我会进一步说明。
三.线程中断
线程中断,就是要让一个线程停止运行(销毁)。在Java中,要销毁/终止线程,做法比较唯一的,就是想办法让run方法尽快执行结束。
终止线程的第一种方法,是可以在代码中手动创建出标志位,来作为run的执行结束的条件。
如图所示,很多线程执行时间久,往往是因为这里写了一个循环,循环要持续执行。要想让run方法执行结束,就是让循环尽快退出。
举如下代码为例:
当前这个代码,是使用了一个成员变量isQuit来作为标志位。当我们想要让这个线程停止运行时,就可以手动将isQuit设为true,这样一来,就能够退出循环,从而让线程中断。
由图可见,这里的isQuit是一个全局变量。那让我们来思考一下,如果把isQuit改成main方法中的局部变量,是否可以呢?
答案是不可以。这里是因为,lambda表达式有一个语法规则,叫变量捕获。lambda表达式里面的代码,是可以自动捕获到上层作用域中涉及到的局部变量的。
所谓的变量捕获,其实就是让lambda表达式把当前作用域中的变量在lambda内部复制了一份(此时,外面是否销毁就无所谓了)。
在Java中,变量捕获语法还有一个前提限制,就是必须只能捕获一个final或者是实际上是final的变量(变量虽然没有使用final,但是却没有修改内容,就是“实际上是final”)。
因此,当isQuit变量被修改时,由于它不再是final变量,编译这里就会报错。
然而,第一种方案不够“优雅”,原因在于以下两点:
第一,需要手动创建变量;
第二,当线程内部在sleep的时候,主线程修改变量,新线程内部不能及时响应。
所以,这里就需要使用另外的方法来完成上述操作 --- isInterrupted()
可以看出,这里使用Thread.currentThread()来获取到当前线程的实例(此处得到的就是t,哪个线程调用这个方法,就会返回哪个线程的对象),接着用这个实例来调用isInterrupted()方法。Thread内部有一个标志位,这个标志位就可以用来判定线程是否结束。
接着进行以上操作,这个操作就是把上述Thread对象内部的标志位设置为true了,从而可以跳出循环,提前终止线程。即使线程内部的逻辑出现阻塞(sleep),也是可以使用这个方法唤醒的。
但是当代码运行起来时,却是以下结果:
可以看到,运行结果抛了一个异常,而且t线程并没有结束。这又是为什么呢?
这是因为,sleep在此”捣乱“!在执行sleep的过程中调用interrupt,大概率sleep休眠时间还没到,就被提前唤醒了。提前唤醒时会做两件事:第一,抛出InterruptedException(紧接着就会被catch获取到);第二,清除Thread对象的isInterrupted标志位。
因此,通过interrupt方法,已经把标志位设为true了,但是sleep提前唤醒操作,就把标志位又设回false了(此时循环还是会继续执行了)。要想让t线程彻底结束,只需要在catch中加上break就行了。
其实,sleep清空标志位,是为了给程序猿更多的“可操作性空间”。比如说,前一个代码写的是sleep(1000),结果现在1000还没到,就要终止线程。这就相当于是两个前后矛盾的操作。此时,是希望写更多的代码来对这样的情况进行具体处理的。此时程序猿就可以在catch语句中,加入一些代码来做一些处理:
1)让线程立即结束(加上break)。
2)让线程不结束,继续执行(不加break)。
3)让线程执行一些逻辑之后,再结束(写一些其他代码,再break)。
四.线程等待
所谓线程等待,其实就是让一个线程等待另一个线程执行结束,再继续执行。本质上就是控制线程结束的顺序。
我们常用join方法实现线程等待效果
我们需要注意的是,在主线程中调用t.join(),此时就是主线程等待t线程结束,主线程才能继续往下执行其他代码。
下面我来分析一下t.join()的工作过程:
1)如果t线程正在运行中,此时调用join的线程就会进入阻塞状态,一直阻塞到t线程执行结束为止。
2)如果t线程已经执行结束了,此时调用join的线程就直接返回了,不会涉及到阻塞。
此外,Java提供了多个join方法的版本,具体参考下图:
其中,第二个版本的join方法是我们在日常开发中使用比较多的。它是带有超时时间的等待,实际开发中一般不建议“死等”,最好要带有“超时时间”。
为了方便大家更好地理解线程等待,我在这里举个栗子🌰说明:
高中的时候,有一天我约女神出来玩,约好19:00在学校门口碰头。那么就会出现三种情况:
1)如果我先到了,我发现女神还没来,我就要“阻塞等待”,等到女神来了之后,我俩就可以一起去吃麻辣烫了。
2)女神先到了。当我来到校门口的时候,虽然时间还不到19:00,但是我看到女神已经在了,此时我俩直接出发去吃麻辣烫就可以了,就不需要等待。
3)我来了之后,等了很久,女神还没出现,我仍然继续等(join默认是“死等”,“不死不休”)
一般来说,等待操作都是带有一个“超时时间”。
五.线程休眠
线程休眠,前面其实提到过,就是sleep方法。它也是我们比较熟悉一组方法,有一点要记得,因为线程的调度是不可控的,所以,这个方法只能保证实际休眠时间是大于等于参数设置的休眠时间的。
关于sleep方法,这里提供了两个版本,具体参考下图:
值得我们注意的是,休眠时间的单位是毫秒(ms)。然而,sleep本身就是存在一定精度误差的,不是说sleep(1000),就是精确的正好1000,这中间还有一个调度的开销。
首先,系统会按照1000这个时间来控制让线程休眠。当1000时间到了之后,系统会唤醒这个线程(阻塞 -> 就绪)。但是,不是说这个线程成了就绪状态,就能立即回到CPU上运行的(这中间有一个“调度”开销)。
对于Windows或者Linux这样的系统来说,调度开销很大,可能达到ms级别。
六.获取线程引用
前面我们知道了,获取线程引用(Thread的引用)可以使用Thread.currentThread()。
当然,获取线程的引用还有其他方法:
当我们的代码创建线程的时候,如果使用的是继承Thread类,也可以通过this来获取当前线程的引用。
但如果是实现Runnable接口,或者使用的是lambda表达式的话,还是只能靠Thread.currentThread()来获取线程引用。
七.小结
以上,就是我对Thread类用法的相关介绍。 在下篇博客中,我会介绍线程的几种状态,大家可以期待一下。多线程的学习任重而道远,大家一定不要半途而废!