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开发笔记(序)章节目录

posted @ 2019-05-11 10:39  pinlantu  阅读(359)  评论(0编辑  收藏  举报