编写高质量代码 - 多线程和并发(1)

前言

多线程技术可以更好地利用系统资源,减少用户的响应时间,提高系统的性能和效率,但同时也增加了系统的复杂性和运维难度,特别是在高并发、大压力、高可靠性的项目中。线程资源的同步、抢占、互斥都需要慎重考虑,以避免产生性能损耗和线程死锁。

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),但也是不能完全保证。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值