前言
多线程技术可以更好地利用系统资源,减少用户的响应时间,提高系统的性能和效率,但同时也增加了系统的复杂性和运维难度,特别是在高并发、大压力、高可靠性的项目中。线程资源的同步、抢占、互斥都需要慎重考虑,以避免产生性能损耗和线程死锁。
1. 不推荐覆写start方法
多线程比较简单的实现方式是继承Thread类,然后覆写run方法,在客户端程序中通过调用对象的start方法即可启动一个线程,这是多线程程序的标准写法。不知道大家能够还能回想起自己写的第一个多线程的demo呢?估计一般是这样写的:
class MultiThread extends Thread{
@Override
public synchronized void start() {
// 调用线程体
run();
}
@Override
public void run() {
// MultiThread do someThing
}
}
覆写run方法,这好办,写上自己的业务逻辑即可,但为什么要覆写start方法呢?最常见的理由是:要在客户端调用start方法启动线程,不覆写start方法怎么启动run方法呢?于是乎就覆写了start方法,在方法内调用run方法。客户端代码是一个标准程序,代码如下:
public static void main(String[] args) {
// 多线程对象
MultiThread m = new MultiThread();
//启动多线程
m.start();
}
相信大家都能看出,这是一个错误的多线程应用,main方法根本就没有启动一个子线程,整个应用程序中只有一个主线程在运行,并不会创建任何其它的线程。对此,有很简单的解决办法。只要删除MultiThread类的start方法即可。
然后呢?就结束了吗?是的,很多时候确实到此结束了。那为什么不必而且不能覆写start方法,仅仅就是因为" 多线程应用就是这样写的 " 这个原因吗?
要说明这个问题,就需要看一下Thread类的源码了。Thread类的start方法的代码如下:
public synchronized void start() {
/**
* 该方法不会被虚拟机创建的main线程或“系统”组线程调用。
* 将来添加到此方法中的任何新功能可能也必须添加到VM中。
*
* 0对应于状态“NEW”。
*/
// 判断线程状态,必须是为启动状态
if (threadStatus != 0)
throw new IllegalThreadStateException();
/* 通知组中此线程即将启动,以便将其添加到组的线程列表中,并且可以减少组的未启动计数。 */
// 加入线程组中
group.add(this);
boolean started = false;
try {
// 本地方法
// 分配栈内存,启动线程,运行run方法
start0();
started = true;
} finally {
try {
if (!started) {
group.threadStartFailed(this);
}
} catch (Throwable ignore) {
/* 什么也不做。如果start0抛出一个Throwable,那么它将被传递到调用堆栈 */
}
}
}
这里的关键是本地方法start0,它实现了启动线程、申请栈内存、运行run方法、修改线程状态等职责,线程管理和栈内存管理都是由JVM负责的,如果覆盖了start方法,也就是撤销了线程管理和栈内存管理的能力,这样如何启动一个线程呢?事实上,不需要关注线程和栈内存的管理,只需要编码者实现多线程的逻辑即可(即run方法体),这也是JVM比较聪明的地方,简化多线程应用。
那可能有人要问了:如果确实有必要覆写start方法,那该如何处理呢?这确实是一个罕见的要求,不过覆写也容易,只要在start方法中加上super.start()即可,代码如下:
class MultiThread extends Thread {
@Override
public synchronized void start() {
/* 线程启动前的业务处理 */
super.start();
/* 线程启动后的业务处理 */
}
@Override
public void run() {
// MultiThread do someThing
}
}
注意看start方法,调用了父类的start方法,没有主动调用run方法,这是由JVM自行调用的,不用我们显示实现,而且是一定不能实现。此方式虽然解决了" 覆写start方法 "的问题,但是基本上无用武之地,到目前为止还没有发现一定要覆写start方法的多线程应用,所有要求覆写start的场景,都可以使用其他的方式实现,例如类变量、事件机制、监听等方式。
注意:继承自Thread类的多线程类不必覆写start方法。
2. 不使用stop方法停止线程
线程启动完毕后,在运行时可能需要中止,Java提供的终止方法只有一个stop,但是不建议使用这个方法,因为它有以下三个问题:
(1)stop方法是过时的:从Java编码规则来说,已经过时的方法不建议采用。
(2)stop方法会导致代码逻辑不完整:stop方法是一种" 恶意 " 的中断,一旦执行stop方法,即终止当前正在运行的线程,不管线程逻辑是否完整,这是非常危险的。看如下的代码:
public static void main(String[] args) throws Exception {
Thread thread = new Thread() {
@Override
public void run() {
try {
// 子线程休眠1秒
Thread.sleep(1000);
} catch (InterruptedException e) {
// 异常处理
}
System.out.println("此处是业务逻辑,永远不会执行");
}
};
// 启动线程
thread.start();
// 主线程休眠0.1秒
Thread.sleep(100);
// 子线程停止
thread.stop();
}
这段代码的逻辑是这样的:子线程是一个匿名内部类,它的run方法在执行时会休眠一秒,然后执行后续的逻辑,而主线程则是休眠0.1秒后终止子线程的运行,也就说JVM在执行thread.stop()时,子线程还在执行sleep(1000),此时stop方法会清除栈内信息,结束该线程,这也就导致了run方法的逻辑不完整,输出语句println代表的是一段逻辑,可能非常重要,比如子线程的主逻辑、资源回收、情景初始化等,但是因为stop线程了,这些都不再执行,于是就产生了业务逻辑不完整的情况。
这是极度危险的,因为我们不知道子线程会在什么时候被终止,stop连基本的逻辑完整性都无法保证。而且此种操作也是非常隐蔽的,子线程执行到何处会被关闭很难定位,这为以后的维护带来了很多麻烦。
(3)stop方法会破坏原子逻辑
多线程为了解决共享资源抢占的问题,使用了锁概念,避免资源不同步,但是正因此原因,stop方法却会带来更大的麻烦,它会丢弃所有的锁,导致原子逻辑受损。例如有这样一段程序:
class MultiThread implements Runnable {
int a = 0;
@Override
public void run() {
// 同步代码块,保证原子操作
synchronized ("") {
// 自增
a++;
try {
// 线程休眠0.1秒
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
// 自减
a--;
String tn = Thread.currentThread().getName();
System.out.println(tn + ":a = " + a);
}
}
}
MultiThread实现了Runnable接口,具备多线程能力,其中run方法中加上了synchronized代码块,表示内部是原子逻辑,它会先自增然后自减,按照synchronized同步代码块的规则来处理,此时无论启动多少线程,打印出来的结果应该是a=0,但是如果有一个正在执行的线程被stop,就会破坏这种原子逻辑,代码如下:
public static void main(String[] args) {
MultiThread t = new MultiThread();
Thread t1 = new Thread(t);
// 启动t1线程
t1.start();
for (int i = 0; i < 5; i++) {
new Thread(t).start();
}
// 停止t1线程
t1.stop();
}
首先要说明的是所有线程共享了一个MultiThread的实例变量t,其次由于在run方法中加入了同步代码块,所以只能有一个线程进入到synchronized块中。这段代码的执行顺序如下:
(1)线程t1启动,并执行run方法,由于没有其它线程同步代码块的锁,所以t1线程执行a++后执行到sleep方法即开始休眠,此时a=1。
(2)JVM又启动了5个线程,也同时运行run方法,由于synchronized关键字的阻塞作用,这5个线程不能执行自增和自减操作,等待t1线程锁释放。
(3)主线程执行了t1.stop方法,终止了t1线程,注意,由于a变量是所有线程共享的,所以其它5个线程获得的a变量也是1。
(4)其它5个线程依次获得CPU执行机会,打印出a值。
分析了这么多,相信大家也明白了输出结果,结果如下:
Thread-5:a = 1
Thread-4:a = 1
Thread-3:a = 1
Thread-2:a = 1
Thread-1:a = 1
原本期望synchronized同步代码块中的逻辑都是原子逻辑,不受外界线程的干扰,但是结果却出现原子逻辑被破坏的情况,这也是stop方法被废弃的一个重要原因:破坏了原子逻辑。
既然终止一个线程不能使用stop方法,那怎样才能终止一个正在运行的线程呢?答案也简单,使用自定义的标志位决定线程的执行情况,代码如下:
class SafeStopThread extends Thread {
// 此变量必须加上volatile
private volatile boolean stop = false;
@Override
public void run() {
// 判断线程体是否运行
while (stop) {
// doSomething
}
}
public void terminate() {
stop = true;
}
}
这是很简单的办法,在线程体中判断是否需要停止运行,即可保证线程体的逻辑完整性,而且也不会破坏原子逻辑。可能大家对JavaAPI比较熟悉,于是提出疑问:Thread不是还提供了interrupt中断线程的方法吗?这个方法不是过时方法,那可以使用吗?它可以终止一个线程吗?
interrupt,名字看上去很像是终止一个线程的方法,但它不能终止一个正在执行着的线程,它只是修改中断标志而已,例如下面一段代码:
public static void main(String[] args) {
Thread thread = new Thread() {
@Override
public void run() {
// 线程一直运行
while (true) {
System.out.println("Running......");
}
}
};
// 启动线程
thread.start();
// 中断线程
thread.interrupt();
}
执行这段代码,你会发现一直有Running在输出,永远不会停止,似乎执行了interrupt没有任何变化,那是因为interrupt方法不能终止一个线程状态,它只会改变中断标志位(如果在thread.interrupt()前后输出thread.isInterrupted()则会发现分别输出了false和true),如果需要终止该线程,还需要自己进行判断,例如我们可以使用interrupt编写出更简洁、安全的终止线程代码:
class SafeStopThread extends Thread {
@Override
public void run() {
// 判断线程体是否运行
while (!isInterrupted()) {
// do SomeThing
}
}
}
总之,如果期望终止一个正在运行的线程,则不能使用已过时的stop方法。需要自行编码实现,如此即可保证原子逻辑不被破坏,代码逻辑不会出现异常。当然,如果我们使用的是线程池(比如ThreadPoolExecutor类),那么可以通过shutdown方法逐步关闭池中的线程,它采用的是比较温和、安全的关闭线程方法,完全不会产生类似stop方法的弊端。
3. 线程优先级只使用三个等级
线程的优先级(Priority)决定了线程获得CPU运行的机会,优先级越高获得的运行机会越大,优先级越低获得的机会越小。Java的线程有10个级别(准确的说是11个级别,级别为0的线程是JVM的,应用程序不能设置该级别),那是不是说级别是10的线程肯定比级别是9的线程先运行呢?我们来看如下一个多线程类:
class TestThread implements Runnable {
public void start(int _priority) {
Thread t = new Thread(this);
// 设置优先级别
t.setPriority(_priority);
t.start();
}
@Override
public void run() {
// 消耗CPU的计算
for (int i = 0; i < 100000; i++) {
Math.hypot(924526789, Math.cos(i));
}
// 输出线程优先级
System.out.println("Priority:" + Thread.currentThread().getPriority());
}
}
该多线程实现了Runnable接口,实现了run方法,注意在run方法中有一个比较占用CPU的计算,该计算毫无意义,只是为了保证一个线程尽可能多地消耗CPU资源,目的是为了观察CPU繁忙时不同优先级线程执行的顺序。需要说明的是,如果此处使用了Thread.sleep()方法,则不能体现线程优先级的本质了,因为CPU并不繁忙,线程调度不会遵循优先级顺序来进行调度。
客户端代码如下:
public static void main(String[] args) {
// 启动20个不同优先级的线程
for (int i = 0; i < 20; i++) {
new TestThread().start(i % 10 + 1);
}
}
这里创建了20个线程,每个线程在运行时都耗尽了CPU的资源,因为优先级不同,线程调度应该是先处理优先级高的,然后处理优先级低的,也就是先执行2个优先级为10的线程,然后执行2个优先级为9的线程,2个优先级为8的线程…但是结果却并不是这样的。
Priority:5
Priority:7
Priority:10
Priority:6
Priority:9
Priority:6
Priority:5
Priority:7
Priority:10
Priority:3
Priority:4
Priority:8
Priority:8
Priority:9
Priority:4
Priority:1
Priority:3
Priority:1
Priority:2
Priority:2
println方法虽然有输出损耗,可能会影响到输出结果,但是不管运行多少次,你都会发现两个不争的事实:
(1)并不是严格按照线程优先级来执行的
比如线程优先级为5的线程比优先级为7的线程先执行,优先级为1的线程比优先级为2的线程先执行,很少出现优先级为2的线程比优先级为10的线程先执行(注意,这里是" 很少 ",是说确实有可能出现,只是几率低,因为优先级只是表示线程获得CPU运行的机会,并不代表强制的排序号)。
(2)优先级差别越大,运行机会差别越明显
比如优先级为10的线程通常会比优先级为2的线程先执行,但是优先级为6的线程和优先级为5的线程差别就不太明显了,执行多次,你会发现有不同的顺序。
这两个现象是线程优先级的一个重要表现,之所以会出现这种情况,是因为线程运行是需要获得CPU资源的,那谁能决定哪个线程先获得哪个线程后获得呢?这是依照操作系统设置的线程优先级来分配的,也就是说,每个线程要运行,需要操作系统分配优先级和CPU资源,对于JAVA来说,JVM调用操作系统的接口设置优先级,比如windows操作系统是通过调用SetThreadPriority函数来设置的,问题来了:不同的操作系统线程优先级都相同吗?
事实上,不同的操作系统线程优先级是不同的,Windows有7个优先级,Linux有140个优先级,Freebsd则有255个(此处指的优先级个数,不同操作系统有不同的分类,如中断级线程,操作系统级等,各个操作系统具体用户可用的线程数量也不相同)。Java是跨平台的系统,需要把这10个优先级映射成不同的操作系统的优先级,于是界定了Java的优先级只是代表抢占CPU的机会大小,优先级越高,抢占CPU的机会越大,被优先执行的可能性越高,优先级相差不大,则抢占CPU的机会差别也不大,这就是导致了优先级为9的线程可能比优先级为10的线程先运行。
Java的缔造者们也觉察到了线程优先问题,于是Thread类中设置了三个优先级,此意就是告诉开发者,建议使用优先级常量,而不是1到10的随机数字。常量代码如下:
public class Thread implements Runnable {
/**
* 线程可以拥有的最小优先级。
*/
public final static int MIN_PRIORITY = 1;
/**
* 分配给线程的默认优先级。
*/
public final static int NORM_PRIORITY = 5;
/**
* 线程可以拥有的最大优先级。
*/
public final static int MAX_PRIORITY = 10;
}
在编码时直接使用这些优先级常量,可以说在大部分情况下MAX_PRIORITY的线程回比MIN_PRIORITY的线程优先运行,但是不能认为是必然会先运行,不能把这个优先级做为核心业务的必然条件,Java无法保证优先级高肯定会先执行,只能保证高优先级有更多的执行机会。因此,建议在开发时只使用此三类优先级,没有必要使用其他7个数字,这样也可以保证在不同的操作系统上优先级的表现基本相同。
大家也许会问,如果优先级相同呢?这很好办,也是由操作系统决定的。基本上是按照FIFO原则(先入先出,First Input First Output),但也是不能完全保证。