第十四章《多线程》第4节:控制线程

时间:2023-01-02 17:55:07

​从14.3小节所列举的各个例子可以很明显的看出:线程的执行有一定的随机性,如果不加以适当控制,会导致执行结果的不确定性。实际开发过程中,很多情况下都需要让线程按照程序员期望的结果运行,为了保证线程运行结果的确定性,必须用各种方式对线程加以控制。本小节将详细讲解线程的控制技术。

14.4.1等待线程

当有多个线程运行时程序员往往希望主线程能够最后结束执行,这是因为主线程常常负责关闭各种资源。如果主线程早于子线程结束,那么会导致子线程还没有用完资源的情况下资源就被关闭了,从而子线程的执行就会出错。​

如何保证主线程最后结束呢?假设让主线程睡眠足够长时间就能保证主线程最后结束,但子线程要运行多长时间并不好估计,因此主线程的睡眠时间也无法确定。另一种办法是让主线程用循环的方式不断的调用子线程对象的isAlive()方法判断子线程有没有结束执行,当子线程结束执行后主线程才退出循环并关闭资源。这也不是一个好办法,因为主线程的判断会占用CPU,导致整体程序运行效率变得很低。​

为解决这个问题,Thread类中专门定义了一个join()方法,这个方法能够让一个线程通知另一个等待,知道自身运行结束后另一个线程才能继续执行。从表14-1可以看到:join()方法还定义了另外两个版本,这两个版本能够规定等待时间,如果超过这个时间后,即使当前线程没有结束运行,另一个线程也能继续执行,这是为了避免等待时间过长。需要注意:读者在学习join()方法时必须弄清楚“谁等谁”的问题。假设有两个线程t1和t2,如果在t1的代码中出现了“t2.join();”,那么就表示t1等待t2,而如果在main()方法中出现了“t2.join();”则表示要让主线程等待t2线程。下面的【例14_05】展示了join()方法的作用。​

【例14_05等待线程】

Exam14_05.java​

class JoinThread extends Thread{
@Override
public void run() {
for (int i=1;i<=5;i++){
System.out.println("子线程:"+i);
try {
Thread.sleep(500);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
public class Exam14_05 {
public static void main(String[] args) {
JoinThread jt = new JoinThread();
jt.start();
for (int i=1;i<=5;i++){
System.out.println("主线程:"+i);
try {
if(i==2){
jt.join();//打印完数字2后就开始等待jt运行结束
}
Thread.sleep(200);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}

【例14_05】中的JoinThread是一个子线程,它的任务是打印从1到5这5个整数,每次打印完睡眠500毫秒。主线程的任务也是打印从1~5这5个整数,每次打印完睡眠200毫秒。当打印完数字2后,主线程等待子线程执行完毕后才继续执行。【例14_05】的运行结果如图14-6所示。​

第十四章《多线程》第4节:控制线程

图14-6【例14_05】运行结果​

从图14-6可以很清楚的看到:虽然主线程睡眠时间短执行速度快,但由于在打印完数字2之后就等待子线程,因此主线程是在子线程执行完毕之后才结束运行。图14-6的方框内,子线程连续打印数字就是因为主线程在等待的结果。​

14.4.2后台线程

有一种特殊的线程,它的任务是为其他线程提供服务,这种线程运行于后台,因此被称为“后台线程”。Java虚拟机的垃圾回收线程就是一个典型的后台线程。由于后台线程是为其他前台线程提供服务的,因此当前台线程都执行完毕时,后台线程自动结束。使用Java语言可以很容易的开发出一个后台线程,程序员只需要调用线程对象的setDaemon()方法,并为方法传递参数为true即可让一个普通的前台线程变为后台线程。想判断一个线程是否是后台线程也很容易,只要调用线程对象的isDaemon()方法,如果方法的返回值为true则说明这个线程就是后台线程。下面的【例14_06】展示了后台线程的创建方式以及特点。​

【例14_06 后台线程】

Exam14_06.java​

class DaemonThread extends Thread{
@Override
public void run() {
for (int i=1;i<=5;i++){
System.out.println("后台线程:"+i);
try {
Thread.sleep(500);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
public class Exam14_06 {
public static void main(String[] args) {
System.out.println("主线程是不是后台线程:"+Thread.currentThread().isDaemon());
DaemonThread dt = new DaemonThread();
dt.setDaemon(true);//①把dt设置为后台线程
dt.start();
for (int i=1;i<=5;i++){
System.out.println("前台线程:"+i);
try {
Thread.sleep(200);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}

【例14_06】中所定义的DaemonThread就是一个线程类,通过代码可以看到这个类实际上和普通的线程类并没有什么区别,它的对象dt也是用普通方式创建出来的,但main()方法中的语句①调用dt对象的setDaemon()方法并传递参数为true就能把dt设置为一个后台线程。此处需要强调:把线程设置为后台线程必须在启动之前完成,也就是说setDaemon()方法必须在start()方法之前执行,否则会引发IllegalThreadStateException异常。【例14_06】的运行结果如图14-7所示。​

第十四章《多线程》第4节:控制线程

图14-7【例14_06】运行结果​

从图14-7可以很清楚的看到:后台线程其实并没有完全运行完毕,这是因为当前台线程运行结束后,后台线程失去了服务对象,因此Java虚拟机会通知后台线程结束运行,但后台线程从收到指令到真正结束运行也需要一定时间。​

此外还可以看出:主线程默认不是后台线程。实际上,由前台线程创建的线程默认也是前台线程,后台线程创建的线程默认也是后台线程。主线程所创建的dt默认也是前台线程,因此需要调用线程对象的setDaemon()方法手动把她设置为后台线程​

14.4.3线程睡眠

前面几个小节中的例子都调用了sleep()方法,这个方法的作用是时线程睡眠一段时间,睡眠实际上就是让线程暂停一段时间,因此sleep()方法也是控制线程的一种方法。从表14-1可以看到:sleep()方法有两个版本,其中一个版本可以把线程睡眠时间设置为精确到纳秒,但实际开发过程中很少使用这个版本的sleep()方法,这是因为大部分计算机都不能精确的把睡眠时间控制到纳秒级别。​

sleep()方法是一个静态方法,在哪个线程中调用这个方法哪个线程就会进入睡眠状态。之所以把这个方法设计为静态方法,是因为主线程的任务是写在main()方法中的,main()方法是一个静态方法,所以静态的sleep()方法更容易被main()方法调用。当线程开始睡眠时,它就进入了阻塞状态,因此在线程睡眠期间它是无法执行的,但在它睡眠的这段时间内,其他线程是可以执行的。​

在实际开发中,线程的睡眠往往用来控制程序执行的节奏。例如在屏幕上显示一个进度条,就可以通过线程睡眠的方式让进度条的长度每隔一段时间增加一点,达到逐步增加的效果。​

此外,在Thread类中还定义了一个与sleep()方法有点类似的yield()方法。yield一词意为让步、放弃,yield()方法的作用是让当前正在执行的线程暂停,但它不会阻塞该线程,只是将该线程转入就绪状态,让线程调度器重新调度一次。由于线程调度器每次都是从多个就绪状态的线程中选择一个让其执行,因此yield()方法执行后,有可能是别的线程被调度器选中,也有可能是当前线程再次被调度器选中继续执行。需要注意:当一个线程调用了yield()方法暂停后,只有优先级与当前线程相同或优先级更高且处于就绪状态的线程才有机会被调度器选中进行执行,那些优先级比当前线程更低的线程并没有机会被调度器选中。​

sleep()方法和yield()方法都是native方法,但sleep()方法有更好的移植性,因此一般不用yield()方法来控制并发执行的线程。​

14.4.4设置线程优先级

线程对象具有一个priority属性,这个属性表示线程的优先级。必须强调:当多个线程都处于就绪状态时,并不是先把优先级较高的线程执行完毕后才去执行优先级较低的线程,而是调度器在选择线程时,优先级较高的线程有较多的执行机会,优先级较低的线程有较少的执行机会。线程的优先级与创建它的线程的优先级相同。​

Thread类提供了setPriority()方法来设置线程的优先级。setPriority()方法只有一个int型参数,这个参数就表示线程的优先级。线程优先级的范围是1~10,当所设置的优先级超出这个范围时,程序将会抛出IllegalArgumentException异常。为提高代码的可读性,Thread类还定义了三个静态属性来表示优先级,它们分别是:​

  • MAX_PRIORITY:表示最高优先级,它的值是10​
  • MIN_PRIORITY:表示最低优先级,它的值是1​
  • NORM_PRIORITY:表示普通优先级,它的值是5​

需要注意:虽然Java提供了10 个优先级级,但这些优先级级需要操作系统的支持。但不同操作系统上的优先级并不相同,而且也不能很好地和Java 的10个优先级对应,例如Windows 2000 仅提供了7个优先级。因此应该尽量避免直接使用数字为线程指定优先级,而应该使用Thread类所提供的三个静态属性来设置优先级,这样才可以保证程序具有最好的可移植性。​

获取优先级的操作也很简单,只需要调用getPriority()方法即可,这个方法的返回值是一个int型数值,它就是线程的优先级。下面的【例14_07】展示了两个优先级不同的线程运行效果。​

【例14_07 线程的优先级】​

Exam14_07.java​

class PriorityThread extends Thread{
public long count = 0;//计数器
boolean running = true;
public void run()
{
while (running)
{
count++;
}
}
public void stopThread()
{
running = false;
}
}
public class Exam14_07 {
public static void main(String[] args) {
PriorityThread pt1 = new PriorityThread();
PriorityThread pt2 = new PriorityThread();
pt1.setPriority(Thread.MAX_PRIORITY);//设置线程的优先级为最高
pt2.setPriority(Thread.MIN_PRIORITY);//设置线程的优先级为最低
try{
pt1.start();
pt2.start();
Thread.sleep(1000);//主线程睡眠,让子线程运行
//时间到,停止线程
pt1.stopThread();
pt2.stopThread();
} catch (InterruptedException e){
e.printStackTrace();
}
System.out.println("p1的计数器值 " + pt1.count);
System.out.println("p2的计数器值 " + pt2.count);
}
}

【例14_07】中,PriorityThread是一个线程类,它定义了一个int型属性count,它表示计数器。此外还定义了一个boolean属性running,这个属性表示线程是否能运行。PriorityThread的run()方法在running为true的情况下让count完成自增的操作。main()方法中创建了两个PriorityThread线程类对象pt1和pt2,并分别设置它们的优先级为最高和最低。启动线程后,主线程睡眠10000毫秒,主线程恢复运行后立刻停止pt1和pt2的运行,最后根据两个线程的count属性值就能看出这两个线程哪一个获得执行的时间更多。【例14_07】的运行结果如图14-8所示。​

第十四章《多线程》第4节:控制线程

图14-8【例14_07】运行结果​

从图14-8可以看出:虽然两个线程优先级相差很多,但运行时间差距并不大,这是因为调度器在调度线程运行时不仅仅以优先级作为参考条件,还会参考其他因素。并且现在的很多计算机都有多个CPU,因此线程获得CPU的时间会大幅增加,甚至有些时候还会出现低优先级线程运行时间比高优先级线程更多的情况。

本文字版教程还配有更详细的视频讲解,小伙伴们可以点击这里观看。