Java开发笔记(九十六)线程的基本用法
每启动一个程序,操作系统的内存中通常会驻留该程序的一个进程,进程包含了程序的完整代码逻辑。一旦程序退出,进程也就随之结束;反之,一旦强行结束进程,程序也会跟着退出。普通的程序代码是从上往下执行的,遇到分支语句则进入满足条件的分支,遇到循环语句总有跳出循环的时候,遇到方法调用则调用完毕仍然返回原处,之后继续执行控制语句或者方法调用下面的代码。总之一件事情接着一件事情处理,前一件事情处理完了才能处理后一件事情,这种运行方式被称作“串行处理”。串行处理的代码结构清晰,但同一时刻只能执行某段代码,也就是说,只要一个CPU就足够应付了。但现在不管电脑还是手机,中央处理器都是多核CPU,一个设备上集成了四个或更多的CPU,而串行处理的程序自始至终都只用一个CPU,显然无法发挥多核CPU的性能优势。既然串行存在效率问题,就需要另一种允许同时执行多项任务的处理方式,该方式被称作“并行处理”。所谓并行处理,指的是程序在同一时刻进行不止一个事务的处理,比如看网络视频时一边下载一边播放,这样就能提高程序的运行效率。
并行处理的思想体现到程序调度上面,又有多进程与多线程两种方式,多进程仿佛孙悟空拔毫毛变出许多小孙悟空,每只小孙悟空都四肢齐全、有鼻子有眼睛,完全是孙悟空的克隆版本,而且可以单独上阵打斗。至于线程则为进程中的一条控制流,它是操作系统能够调度的最小执行单元,线程犹如人的手,吃饭穿衣都靠它。多线程仿佛哪吒变出三头六臂,每只手臂都拿着一把兵器,战斗力顿时倍增。不过变出来的手臂依附于哪吒本人,要是哪吒挂了,再多的手臂也只能拜拜,当然只要进程还在运行,多些线程绝对有助于加快程序的办事速度。况且一个线程占用的系统资源远小于一个进程,想想看,三个孙悟空有六只手臂同时占据了三个人的空间,而三头六臂的哪吒也有六只手臂但只占据一个人的空间,很明显多线程的性价比要优于多进程。
一个进程默认自带一个线程,这个默认线程被称作主线程,要想在主线程之外另外开辟新线程,就用到了Java的Thread线程类。Thread类封装了线程的生命周期及其调度操作,程序员只需由Thread类派生出新的线程类,并重写run方法添加具体的业务逻辑即可。下面便是一个计数器线程的代码例子,功能很简单,仅仅循环打印0-999的计数日志:
// 定义一个计数器线程 private static class CountThread extends Thread { @Override public void run() { for (int i=0; i<1000; i++) { // 一千次计数,并打印每次计数的日志 // getName方法获取当前线程的名称,getId方法获取当前线程的编号 PrintUtils.print(getName(), "当前计数值为"+i); } } }
上面代码在打印日志时调用了自己写的print方法,该方法主要打印当前时间、当前线程名称、具体事件描述等信息,为节约代码篇幅,往后的线程内部日志都通过print方法来打印,以下是该方法的实现代码:
//定义了线程专用的日志打印工具 public class PrintUtils { // 打印线程的运行日志,包括当前时间、当前线程名称、具体事件描述等信息 public static void print(String threadName, String event) { SimpleDateFormat sdf = new SimpleDateFormat("HH:mm:ss.SSS"); String dateTime = sdf.format(new Date()); String desc = String.format("%s %s %s", dateTime, threadName, event); System.out.println(desc); } }
定义好了计数器线程,轮到外部启动它倒也容易,先创建一个计数器线程的对象,再调用该对象的start方法,接着计数器线程便会自动执行run方法的内部代码。外部启动计数器线程的调用代码示例如下:
CountThread thread = new CountThread(); // 创建一个计数器线程 thread.start(); // 开始线程运行
运行上述的调用代码,观察到如下的线程运行日志,可见一个名叫Thread-0的分线程正常跑起来了:
17:36:01.049 Thread-0 当前计数值为0 17:36:01.051 Thread-0 当前计数值为1 17:36:01.051 Thread-0 当前计数值为2 17:36:01.051 Thread-0 当前计数值为3 ………………………这里省略余下的日志……………………
除了start方法,Thread类还提供了其它一些有用的方法。倘若程序先后启动两个线程,那么通常来说,先启动的线程比后启动的线程要跑得快些。可是有时候业务上又需要后启动的线程跑得更快,此时可调用指定线程的join方法,该方法字面上的意思是“加入”,实际作用却是“插队”。凡是调用了join方法的线程,它们的内部代码相较其它线程会优先处理,由于不同线程之间是并行展开着的,因此优先的意思并非一定会插到前面,而是尽量安排先执行,最终的执行顺序还得由操作系统来决定。下面是演示线程插队功能的代码例子:
// 测试线程的插队操作 private static void testJoin() { // 创建第一个计数器线程 CountThread thread1 = new CountThread(); thread1.start(); // 第一个线程开始运行 // 创建第二个计数器线程 CountThread thread2 = new CountThread(); thread2.start(); // 第二个线程开始运行 try { thread2.join(); // 第二个线程说:“我很着急,我要插队” } catch (InterruptedException e1) { // 插队行为可能会被中断,需要捕获中断异常 e1.printStackTrace(); } }
只有两个分线程的话,尚能通过join方法区分插队的线程与普通线程;要是分线程多于两个,好几个线程都调用join方法,都提出本线程想插队,操作系统又该如何伺候这些猴急的线程们?就算是插队,也得有个插队顺序吧,不是谁嗓门大谁就能排到前面的,所以还需定义一个规矩来区分插队动作的轻重缓急。于是Thread类又提供了优先级设置方法setPriority,调用该方法即可指定每个线程的优先级大小,数值越大的表示它拥有越高的优先级,就越应该安排到前面执行。如此一来,通过优先级数值的大小,能够有效辨别各个线程的排队顺序,再也不必烦恼要到哪里插队了。给多个线程分别设置优先级的代码示例如下:
// 测试线程的优先级顺序 private static void testPriority() { // 创建第一个计数器线程 CountThread thread1 = new CountThread(); thread1.setPriority(1); // 第一个线程的优先级为1 thread1.start(); // 第一个线程开始运行 // 创建第二个计数器线程 CountThread thread2 = new CountThread(); thread2.setPriority(9); // 第二个线程的优先级为9,值越大优先级越高 thread2.start(); // 第二个线程开始运行 }
正常情况下,分线程的内部代码执行完毕后,该线程会自动退出运行。但有时需要提前结束线程,或者先暂停线程,等到时机成熟再恢复线程,Thread类也确实提供了相关的处理方法,例如stop方法用于停止线程运行,suspend方法用于暂停线程运行,resume方法用于恢复线程运行。然而Java同时注明了这三个方法都已经过时,为啥?缘由在于它们仨是不安全的,当一个线程正在欢快运行的时候,突然外部咔嚓一下,不由分说把它干翻,这本身就是很危险的举动,因为谁也无法预料此时线程在做什么、线程意外终止会产生什么后果等等。比如某个线程正在写文件,现在不管三七二十一干掉该线程,结果很可能造成文件损坏。故而由外部强行干预线程的运行实在不是一个好点子,理想的做法是:外部给分线程发个纸条,表示你被炒鱿鱼了,咱通情达理也没立刻赶你走,你收拾收拾差不多了再走也不迟。
这样的话,很自然想到在线程内部增加一个标志位,分线程每隔一阵子便检查该标志,一旦发现标志位发生改变,就自动择机退出运行。据此可以重新编写包含标志位的计数器线程,并在run方法中不时地检查该标志,新线程的定义代码示例如下:
// 定义一个主动检查运行标志的线程 private static class ActiveCheckThread extends Thread { private boolean canRun = true; // 能否运行的标志 // 设置当前线程能否继续运行的标志 public void setCanRun(boolean canRun) { this.canRun = canRun; } @Override public void run() { for (int i=0; i<1000; i++) { PrintUtils.print(getName(), "当前计数值为"+i); if (!canRun) { // 如果不允许运行,就打印停止运行的日志,并跳出线程的循环处理 PrintUtils.print(getName(), "主动停止运行"); break; } } } }
上述的线程代码提供了setCanRun方法给外部调用,通过该方法即可设置当前线程能否继续运行的标志。外部在启动ActiveCheckThread线程之后,再调用setCanRun方法,就实现了给分线程递纸条的功能。下面是ActiveCheckThread线程的调用代码例子:
// 线程自己主动检查是否要停止运行 private static void testActiveCheck() { // 创建一个会自行检查运行标志的线程 ActiveCheckThread thread = new ActiveCheckThread(); thread.start(); // 开始线程运行 try { Thread.sleep(50); // 睡眠50毫秒 } catch (InterruptedException e) { // 睡眠可能会被打断,需要捕获中断异常 e.printStackTrace(); } thread.setCanRun(false); // 告知该线程不要再跑了,请择机退出 }
运行上面的测试代码,观察到以下的线程日志,可见分线程按照标志位提前停止运行了。
………………………这里省略前面的日志…………………… 16:38:18.457 Thread-0 当前计数值为14 16:38:18.457 Thread-0 当前计数值为15 16:38:18.457 Thread-0 当前计数值为16 16:38:18.458 Thread-0 主动停止运行
设置标志位的办法固然可行,但不是很好用,原因有二:其一,分线程要很积极主动的去检查标志位,可是人算不如天算,标志位的检查代码毕竟不能塞得到处都是,那么在遗忘的角落就没法响应外部的信号了;其二,设置标志位是个新增的方法,那么每个线程类的标志设置方法都不尽相同,外部又怎知甲乙丙丁各自提供了哪些设置方法呢?好在Thread类另外提供了线程中断机制,分线程倒也不必新增能否运行的标志,原来的代码结构可以保持不变。在中断机制里,凡是属于正常的业务逻辑,外部概不横加干涉,只有在耗时较久的场合,例如睡眠、等待之类的情况,才可能会收到中断信号,也就是中断异常InterruptedException。于是分线程只管捕捉中断异常,若无异常则照常运行;若有异常则进入中断分支,对相关事宜妥善处理一下,即可退出线程运行。
据此改造先前的计数器线程,在每次计数之后增加调用sleep方法,且睡眠期间允许接收中断信号,另外补充异常处理的try/catch语句,并在异常分支进行善后工作。改造后的计数器线程PassiveInterruptThread代码示例如下:
// 定义一个被动接受中断信号的线程 private static class PassiveInterruptThread extends Thread { @Override public void run() { try { for (int i=0; i<1000; i++) { PrintUtils.print(getName(), "当前计数值为"+i); Thread.sleep(10); // 睡眠10毫秒,睡眠期间允许接收中断信号 } } catch (InterruptedException e) { // 收到了异常中断的信号,打印中断日志并退出线程运行 PrintUtils.print(getName(), "被中断运行了"); } } }
接下来外部启动计数线程之后,调用interrupt方法往分线程发送中断信号,注意这个interrupt方法为Thread类的自有方法,每个线程都适用。下面是PassiveInterruptThread线程的调用代码例子:
// 线程被动接收外部的中断信号 private static void testPassiveInterrupt() { // 创建一个会接收外部中断信号的线程 PassiveInterruptThread thread = new PassiveInterruptThread(); thread.start(); // 开始线程运行 try { Thread.sleep(50); // 睡眠50毫秒 } catch (InterruptedException e) { e.printStackTrace(); } thread.interrupt(); // 不管你正在干什么,先停下来再说 }
运行上面的线程调用代码,观察到如下的线程日志,可见分线程的确收到了外部的中断信号:
………………………这里省略前面的日志…………………… 17:04:33.284 Thread-0 当前计数值为3 17:04:33.294 Thread-0 当前计数值为4 17:04:33.304 Thread-0 当前计数值为5 17:04:33.305 Thread-0 被中断运行了
更多Java技术文章参见《Java开发笔记(序)章节目录》