你的位置:首页 > Java教程

[Java教程]编写高质量代码:改善Java程序的151个建议(第8章:多线程和并发___建议122~125)


建议122:使用线程异常处理器提升系统可靠性

  我们要编写一个Socket应用,监听指定端口,实现数据包的接收和发送逻辑,这在早期系统间进行数据交互是经常使用的,这类接口通常需要考虑两个问题:一个是避免线程阻塞,保证接收的数据尽快处理;二是:接口的稳定性和可靠性问题,数据包很复杂,接口服务的系统也很多,一旦守候线程出现异常就会导致Socket停止,这是非常危险的,那我们有什么办法避免吗?

  Java1.5版本以后在Thread类中增加了setUncaughtExceptionHandler方法,实现了线程异常的捕捉和处理。可能大家会有一个疑问:如果Socket应用出现了不可预测的异常是否可以自动重启呢?其实使用线程异常处理器很容易解决,我们来看一个异常处理器应用实例,代码如下: 

class TcpServer implements Runnable {  // 创建后即运行  public TcpServer() {    Thread t = new Thread(this);    t.setUncaughtExceptionHandler(new TcpServerExceptionHandler());    t.start();  }  @Override  public void run() {    for (int i = 0; i < 3; i++) {      try {        Thread.sleep(1000);        System.out.println("系统正常运行:" + i);      } catch (InterruptedException e) {        e.printStackTrace();      }    }    // 抛出异常    throw new RuntimeException();  }  // 异常处理器  private static class TcpServerExceptionHandler implements      Thread.UncaughtExceptionHandler {    @Override    public void uncaughtException(Thread t, Throwable e) {      // 记录线程异常信息      System.out.println("线程" + t.getName() + " 出现异常,自行重启,请分析原因。");      e.printStackTrace();      // 重启线程,保证业务不中断      new TcpServer();    }  }}

  这段代码的逻辑比较简单,在TcpServer类创建时即启动一个线程,提供TCP服务,例如接收和发送文件,具体逻辑在run方法中实现。同时,设置了该线程出现运行期异常(也就是Uncaught Exception)时,由TcpServerExceptionHandler异常处理器来处理异常。那么TcpServerExceptionHandler做什么事呢?两件事:

  • 记录异常信息,以便查找问题
  • 重新启动一个新线程,提供不间断的服务

  有了这两点,TcpServer就可以稳定的运行了,即使出现异常也能自动重启,客户端代码比较简单,只需要new TcpServer()即可,运行结果如下:

  

  从运行结果上可以看出,当Thread-0出现异常时,系统自动重启了Thread-1线程,继续提供服务,大大提高了系统的性能。

  这段程序只是一个示例程序,若要在实际环境中应用,则需要注意以下三个方面:

  • 共享资源锁定:如果线程产生异常的原因是资源被锁定,自动重启应用知会增加系统的负担,无法提供不间断服务。例如一个即时通信服务(XMPP Server)出现信息不能写入的情况,即使再怎么启动服务,也是无法解决问题的。在此情况下最好的办法是停止所有的线程,释放资源。
  • 脏数据引起系统逻辑混乱:异常的产生中断了正在执行的业务逻辑,特别是如果正在处理一个原子操作(像即时通讯服务器的用户验证和签到这两个事件应该在一个操作中处理,不允许出现验证成功,但签到不成功的情况),但如果此时抛出了运行期异常就有可能会破坏正常的业务逻辑,例如出现用户认证通过了,但签到不成功的情况,在这种情境下重启应用服务器,虽然可以提供服务,但对部分用户却产生了逻辑异常。
  • 内存溢出:线程异常了,但由该线程创建的对象并不会马上回收,如果再重亲启动新线程,再创建一批对象,特别是加入了场景接管,就非常危险了,例如即时通信服务,重新启动一个新线程必须保证原在线用户的透明性,即用户不会察觉服务重启,在此种情况下,就需要在线程初始化时加载大量对象以保证用户的状态信息,但是如果线程反复重启,很可能会引起OutOfMemory内存泄露问题。

建议123:volatile不能保证数据同步

  volatile关键字比较少用,原因无外乎两点,一是在Java1.5之前该关键字在不同的操作系统上有不同的表现,所带来的问题就是移植性较差;而且比较难设计,而且误用较多,这也导致它的"名誉" 受损。

  我们知道,每个线程都运行在栈内存中,每个线程都有自己的工作内存(Working Memory,比如寄存器Register、高速缓冲存储器Cache等),线程的计算一般是通过工作内存进行交互的,其示意图如下图所示:

  

  从示意图上我们可以看到,线程在初始化时从主内存中加载所需的变量值到工作内存中,然后在线程运行时,如果是读取,则直接从工作内存中读取,若是写入则先写到工作内存中,之后刷新到主内存中,这是JVM的一个简答的内存模型,但是这样的结构在多线程的情况下有可能会出现问题,比如:A线程修改变量的值,也刷新到了主内存,但B、C线程在此时间内读取的还是本线程的工作内存,也就是说它们读取的不是最"新鲜"的值,此时就出现了不同线程持有的公共资源不同步的情况。

  对于此类问题有很多解决办法,比如使用synchronized同步代码块,或者使用Lock锁来解决该问题,不过,Java可以使用volatile更简单地解决此类问题,比如在一个变量前加上volatile关键字,可以确保每个线程对本地变量的访问和修改都是直接与内存交互的,而不是与本线程的工作内存交互的,保证每个线程都能获得最"新鲜"的变量值,其示意图如下:

  

  明白了volatile变量的原理,那我们思考一下:volatile变量是否能够保证数据的同步性呢?两个线程同时修改一个volatile是否会产生脏数据呢?我们看看下面代码:

class UnsafeThread implements Runnable {  // 共享资源  private volatile int count = 0;  @Override  public void run() {    // 增加CPU的繁忙程度,不必关心其逻辑含义    for (int i = 0; i < 1000; i++) {      Math.hypot(Math.pow(92456789, i), Math.cos(i));    }    count++;  }  public int getCount() {    return count;  }}

  上面的代码定义了一个多线程类,run方法的主要逻辑是共享资源count的自加运算,而且我们还为count变量加上了volatile关键字,确保是从内存中读取和写入的,如果有多个线程运行,也就是多个线程执行count变量的自加操作,count变量会产生脏数据吗?想想看,我们已经为count加上了volatile关键字呀!模拟多线程的代码如下:  

public static void main(String[] args) throws InterruptedException {    // 理想值,并作为最大循环次数    int value = 1000;    // 循环次数,防止造成无限循环或者死循环    int loops = 0;    // 主线程组,用于估计活动线程数    ThreadGroup tg = Thread.currentThread().getThreadGroup();    while (loops++ < value) {      // 共享资源清零      UnsafeThread ut = new UnsafeThread();      for (int i = 0; i < value; i++) {        new Thread(ut).start();      }      // 先等15毫秒,等待活动线程为1      do {        Thread.sleep(15);      } while (tg.activeCount() != 1);      // 检查实际值与理论值是否一致      if (ut.getCount() != value) {        // 出现线程不安全的情况        System.out.println("循环到:" + loops + " 遍,出现线程不安全的情况");        System.out.println("此时,count= " + ut.getCount());        System.exit(0);      }    }  }

  想让volatite变量"出点丑",还是需要花点功夫的。此段程序的运行逻辑如下:

  • 启动100个线程,修改共享资源count的值
  • 暂停15秒,观察活动线程数是否为1(即只剩下主线程再运行),若不为1,则再等待15秒。
  • 判断共享资源是否是不安全的,即实际值与理想值是否相同,若不相同,则发现目标,此时count的值为脏数据。
  • 如果没有找到,继续循环,直到达到最大循环为止。

运行结果如下:

    循环到:40 遍,出现线程不安全的情况
    此时,count= 999
  这只是一种可能的结果,每次执行都有可能产生不同的结果。这也说明我们的count变量没有实现数据同步,在多个线程修改的情况下,count的实际值与理论值产生了偏差,直接说明了volatile关键字并不能保证线程的安全。
  在解释原因之前,我们先说一下自加操作。count++表示的是先取出count的值然后再加1,也就是count=count+1,所以,在某个紧邻时间片段内会发生如下神奇的事情:

(1)、第一个时间片段

  A线程获得执行机会,因为有关键字volatile修饰,所以它从主内存中获得count的最新值为998,接下来的事情又分为两种类型:

  • 如果是单CPU,此时调度器暂停A线程执行,让出执行机会给B线程,于是B线程也获得了count的最新值998.
  • 如果是多CPU,此时线程A继续执行,而线程B也同时获得了count的最新值998.

(2)、第二个片段

  • 如果是单CPU,B线程执行完+1操作(这是一个原子处理),count的值为999,由于是volatile类型的变量,所以直接写入主内存,然后A线程继续执行,计算的结果也是999,重新写入主内存中。
  • 如果是多CPU,A线程执行完加1动作后修改主内存的变量count为999,线程B执行完毕后也修改主内存中的变量为999

这两个时间片段执行完毕后,原本期望的结果为1000,单运行后的值为999,这表示出现了线程不安全的情况。这也是我们要说明的:volatile关键字并不能保证线程安全,它只能保证当前线程需要该变量的值时能够获得最新的值,而不能保证线程修改的安全性。

顺便说一下,在上面的代码中,UnsafeThread类的消耗CPU计算是必须的,其目的是加重线程的负荷,以便出现单个线程抢占整个CPU资源的情景,否则很难模拟出volatile线程不安全的情况,大家可以自行模拟测试。

建议124:异步运算考虑使用Callable接口

  多线程应用有两种实现方式,一种是实现Runnable接口,另一种是继承Thread类,这两个方法都有缺点:run方法没有返回值,不能抛出异常(这两个缺点归根到底是Runnable接口的缺陷,Thread类也实现了Runnable接口),如果需要知道一个线程的运行结果就需要用户自行设计,线程类本身也不能提供返回值和异常。但是从Java1.5开始引入了一个新的接口Callable,它类似于Runnable接口,实现它就可以实现多线程任务,Callable的接口定义如下:

public interface Callable<V> {  /**   * Computes a result, or throws an exception if unable to do so.   *   * @return computed result   * @throws Exception if unable to compute a result   */  V call() throws Exception;}

  实现Callable接口的类,只是表明它是一个可调用的任务,并不表示它具有多线程运算能力,还是需要执行器来执行的,我们先编写一个任务类,代码如下: 

//税款计算器class TaxCalculator implements Callable<Integer> {  // 本金  private int seedMoney;  // 接收主线程提供的参数  public TaxCalculator(int _seedMoney) {    seedMoney = _seedMoney;  }  @Override  public Integer call() throws Exception {    // 复杂计算,运行一次需要2秒    TimeUnit.MILLISECONDS.sleep(2000);    return seedMoney / 10;  }}

  这里模拟了一个复杂运算:税款计算器,该运算可能要花费10秒钟的时间,此时不能让用户一直等着吧,需要给用户输出点什么,让用户知道系统还在运行,这也是系统友好性的体现:用户输入即有输出,若耗时较长,则显示运算进度。如果我们直接计算,就只有一个main线程,是不可能有友好提示的,如果税金不计算完毕,也不会执行后续动作,所以此时最好的办法就是重启一个线程来运算,让main线程做进度提示,代码如下:  

public static void main(String[] args) throws InterruptedException,      ExecutionException {    // 生成一个单线程的异步执行器    ExecutorService es = Executors.newSingleThreadExecutor();    // 线程执行后的期望值    Future<Integer> future = es.submit(new TaxCalculator(100));    while (!future.isDone()) {      // 还没有运算完成,等待200毫秒      TimeUnit.MICROSECONDS.sleep(200);      // 输出进度符号      System.out.print("*");    }    System.out.println("\n计算完成,税金是:" + future.get() + " 元 ");    es.shutdown();  }

  在这段代码中,Executors是一个静态工具类,提供了异步执行器的创建能力,如单线程异步执行器newSingleThreadExecutor、固定线程数量的执行器newFixedThreadPool等,一般它是异步计算的入口类。future关注的是线程执行后的结果,比如没有运行完毕,执行结果是多少等。此段代码的运行结果如下所示:

      **********************************************......

      计算完成,税金是:10  元

  执行时,"*"会依次递增,表示系统正在运算,为用户提供了运算进度,此类异步计算的好处是:

  • 尽可能多的占用系统资源,提供快速运算
  • 可以监控线程的执行情况,比如是否执行完毕、是否有返回值、是否有异常等。
  • 可以为用户提供更好的支持,比如例子中的运算进度等。

建议125:优先选择线程池

  在Java1.5之前,实现多线程比较麻烦,需要自己启动线程,并关注同步资源,防止出现线程死锁等问题,在1.5版本之后引入了并行计算框架,大大简化了多线程开发。我们知道一个线程有五个状态:新建状态(NEW)、可运行状态(Runnable,也叫作运行状态)、阻塞状态(Blocked)、等待状态(Waiting)、结束状态(Terminated),线程的状态只能由新建转变为了运行状态后才能被阻塞或等待,最后终结,不可能产生本末倒置的情况,比如把一个结束状态的线程转变为新建状态,则会出现异常,例如如下代码会抛出异常:

  public static void main(String[] args) throws InterruptedException {    // 创建一个线程,新建状态    Thread t = new Thread(new Runnable() {      @Override      public void run() {        System.out.println("线程正在运行");      }    });    // 运行状态    t.start();    // 是否是运行状态,若不是则等待10毫秒    while (!t.getState().equals(Thread.State.TERMINATED)) {      TimeUnit.MICROSECONDS.sleep(10);    }    // 直接由结束转变为云心态    t.start();  }

  此段程序运行时会报java.lang.IllegalThreadStateException异常,原因就是不能从结束状态直接转变为运行状态,我们知道一个线程的运行时间分为3部分:T1为线程启动时间,T2为线程的运行时间,T3为线程销毁时间,如果一个线程不能被重复使用,每次创建一个线程都需要经过启动、运行、销毁时间,这势必增大系统的响应时间,有没有更好的办法降低线程的运行时间呢?

  T2是无法避免的,只有通过优化代码来实现降低运行时间。T1和T2都可以通过线程池(Thread Pool)来缩减时间,比如在容器(或系统)启动时,创建足够多的线程,当容器(或系统)需要时直接从线程池中获得线程,运算出结果,再把线程返回到线程池中___ExecutorService就是实现了线程池的执行器,我们来看一个示例代码: 

public static void main(String[] args) throws InterruptedException {    // 2个线程的线程池    ExecutorService es = Executors.newFixedThreadPool(2);    // 多次执行线程体    for (int i = 0; i < 4; i++) {      es.submit(new Runnable() {        @Override        public void run() {          System.out.println(Thread.currentThread().getName());        }      });    }    // 关闭执行器    es.shutdown();  }

  此段代码首先创建了一个包含两个线程的线程池,然后在线程池中多次运行线程体,输出运行时的线程名称,结果如下:

        pool-1-thread-1
        pool-1-thread-2
        pool-1-thread-1
        pool-1-thread-2

   本次代码执行了4遍线程体,按照我们之前阐述的" 一个线程不可能从结束状态转变为可运行状态 ",那为什么此处的2个线程可以反复使用呢?这就是我们要搞清楚的重点。

  线程池涉及以下几个名词:

  • 工作线程(Worker):线程池中的线程,只有两个状态:可运行状态和等待状态,没有任务时它们处于等待状态,运行时它们循环的执行任务。
  • 任务接口(Task):这是每个任务必须实现的接口,以供工作线程调度器调度,它主要规定了任务的入口、任务执行完的场景处理,任务的执行状态等。这里有两种类型的任务:具有返回值(异常)的Callable接口任务和无返回值并兼容旧版本的Runnable接口任务。
  • 任务对列(Work Quene):也叫作工作队列,用于存放等待处理的任务,一般是BlockingQuene的实现类,用来实现任务的排队处理。

  我们首先从线程池的创建说起,Executors.newFixedThreadPool(2)表示创建一个具有两个线程的线程池,源代码如下:

public class Executors {  //生成一个最大为nThreads的线程池执行器 public static ExecutorService newFixedThreadPool(int nThreads) {    return new ThreadPoolExecutor(nThreads, nThreads,                   0L, TimeUnit.MILLISECONDS,                   new LinkedBlockingQueue<Runnable>());  }}

  这里使用了LinkedBlockingQueue作为队列任务管理器,所有等待处理的任务都会放在该对列中,需要注意的是,此队列是一个阻塞式的单端队列。线程池建立好了,那就需要线程在其中运行了,线程池中的线程是在submit第一次提交任务时建立的,代码如下:

  public Future<?> submit(Runnable task) {    //检查任务是否为null    if (task == null) throw new NullPointerException();    //把Runnable任务包装成具有返回值的任务对象,不过此时并没有执行,只是包装    RunnableFuture<Object> ftask = newTaskFor(task, null);    //执行此任务    execute(ftask);    //返回任务预期执行结果    return ftask;  }

  此处的代码关键是execute方法,它实现了三个职责。

  • 创建足够多的工作线程数,数量不超过最大线程数量,并保持线程处于运行或等待状态。
  • 把等待处理的任务放到任务队列中
  • 从任务队列中取出任务来执行

  其中此处的关键是工作线程的创建,它也是通过new Thread方式创建的一个线程,只是它创建的并不是我们的任务线程(虽然我们的任务实现了Runnable接口,但它只是起了一个标志性的作用),而是经过包装的Worker线程,代码如下:  

private final class Worker implements Runnable {// 运行一次任务  private void runTask(Runnable task) {    /* 这里的task才是我们自定义实现Runnable接口的任务 */    task.run();    /* 该方法其它代码略 */  }  // 工作线程也是线程,必须实现run方法  public void run() {    try {      Runnable task = firstTask;      firstTask = null;      while (task != null || (task = getTask()) != null) {        runTask(task);        task = null;      }    } finally {      workerDone(this);    }  }  // 任务队列中获得任务  Runnable getTask() {    /* 其它代码略 */    for (;;) {      return r = workQueue.take();    }  }}

  此处为示意代码,删除了大量的判断条件和锁资源。execute方法是通过Worker类启动的一个工作线程,执行的是我们的第一个任务,然后改线程通过getTask方法从任务队列中获取任务,之后再继续执行,但问题是任务队列是一个BlockingQuene,是阻塞式的,也就是说如果该队列的元素为0,则保持等待状态,直到有任务进入为止,我们来看LinkedBlockingQuene的take方法,代码如下:  

public E take() throws InterruptedException {    E x;    int c = -1;    final AtomicInteger count = this.count;    final ReentrantLock takeLock = this.takeLock;    takeLock.lockInterruptibly();    try {      try {        // 如果队列中的元素为0,则等待        while (count.get() == 0)          notEmpty.await();      } catch (InterruptedException ie) {        notEmpty.signal(); // propagate to a non-interrupted thread        throw ie;      }      // 等待状态结束,弹出头元素      x = extract();      c = count.getAndDecrement();      // 如果队列数量还多于一个,唤醒其它线程      if (c > 1)        notEmpty.signal();    } finally {      takeLock.unlock();    }    if (c == capacity)      signalNotFull();    // 返回头元素    return x;  }

  分析到这里,我们就明白了线程池的创建过程:创建一个阻塞队列以容纳任务,在第一次执行任务时创建做够多的线程(不超过许可线程数),并处理任务,之后每个工作线程自行从任务对列中获得任务,直到任务队列中的任务数量为0为止,此时,线程将处于等待状态,一旦有任务再加入到队列中,即召唤醒工作线程进行处理,实现线程的可复用性。

  使用线程池减少的是线程的创建和销毁时间,这对于多线程应用来说非常有帮助,比如我们常用的Servlet容器,每次请求处理的都是一个线程,如果不采用线程池技术,每次请求都会重新创建一个新的线程,这会导致系统的性能符合加大,响应效率下降,降低了系统的友好性。