多线程和并发——读《编写高质量代码:改善Java程序的151个建议》(九)

读书,收获,分享
建议后面的五角星仅代表笔者个人需要注意的程度。
Talk is cheap.Show me the code

建议118:不推荐覆写start方法★★★☆☆

错误的多线程应用示例:

class MultiThread extends Thread{
    @Override
    public synchronized void start() {
        //调用线程体        
        run();
    }
    @Override
    public void run() {
        //MultiThread do someThing
    }
}

public static void main(String[] args) {
        //多线程对象
        MultiThread m = new MultiThread();
        //启动多线程
        m.start();
}
    /**
     * main方法根本就没有启动一个子线程,整个应用程序中只有一个主线程在运行,并不会创建任何其它的线程。
     * 对此,有很简单的解决办法。只要删除MultiThread类的start方法即可。
     * */

要说明这个问题,就需要看一下Thread类的源代码了。Thread类的start方法的代码(JDK7版本的)如下:

public synchronized void start() {
        // 判断线程状态,必须是未启动状态
        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) {
              
            }
        }
    }   
    // 本地方法
     private native void start0();

这里的关键是本地方法start0,它实现了启动线程、申请栈内存、运行run方法、修改线程状态等职责,线程管理和栈内存管理都是由JVM负责的,如果覆盖了start方法,也就是撤消了线程管理和栈内存管理的能力

如果确实有必要覆写start方法,那该如何处理呢?只要在start方法中加上super.start即可:

class MultiThread extends Thread {
    @Override
    public synchronized void start() {
        /* 线程启动前的业务处理 */
        super.start();
        /* 线程启动后的业务处理 */
    }

    @Override
    public void run() {
        // MultiThread do someThing
    }

}

注意:继承自Thread类的多线程类不必覆写start方法。

建议119:启动线程前stop方法是不可靠的☆☆☆☆☆

stop方法,是一个已弃用的方法且设计实现有一定的缺陷,要避免使用。详细说明

建议120:不使用stop方法停止线程★★☆☆☆

因为它有以下三个问题:

  1. stop方法是过时的

    从Java编码规则来说,已经过时的方法不建议采用

  2. stop方法会导致代码逻辑不完整

    stop方法是一种“恶意”的中断,一旦执行stop方法,即终止当前正在运行的线程,不管线程逻辑是否完整,这是非常危险的。看如下的代码:

        public static void main(String[] args) {
            //子线程
            Thread thread = new Thread() {
                @Override
                public void run() {
                    try {
                        // 子线程休眠1秒
                        Thread.sleep(1000);
                    } catch (InterruptedException e) {
                        // 异常处理
                    }
                    System.out.println("此处代码不会执行");
                }
            };
            // 启动线程
            thread.start();
            // 主线程休眠0.1秒
            try {
                Thread.sleep(100);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            // 子线程停止
            thread.stop();
        }
    

    这段代码的的逻辑:

    1. 子线程是一个匿名内部类,它的run方法在执行时会休眠1秒钟,然后再执行后续的逻辑。
    2. 主线程则是休眠0.1秒后终止子线程的运行。
    3. 也就是说,JVM在执行thread.stop()时,子线程还在执行sleep(1000)
    4. 此时stop方法会清除栈内信息,结束该线程。
    5. 因为stop线程了,println这些就都不再执行,这也就导致了run方法的逻辑不完整。
    6. 而且此种操作也是非常隐蔽的,子线程执行到何处会被关闭很难定位,这为以后的维护带来了很多麻烦。
  3. stop方法会破坏原子逻辑

    多线程为了解决共享资源抢占的问题,使用了锁概念,避免资源不同步,但是正因此原因,stop方法却会带来更大的麻烦:它会丢弃所有的锁,导致原子逻辑受损。例如有这样一段程序:

       
        /**
         * 其中run方法中加上了synchronized代码块,表示内部是原子逻辑,它会先自增然后再自减少
         */
     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);
                }
            }
        }
    
     
        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();
        }
    

    此段代码的执行顺序如下:

    1. 线程t1启动,并执行run方法,由于没有其他线程持同步代码块的锁,所以t1线程执行自加后执行到sleep方法即开始休眠,此时a=1
    2. JVM又启动了5个线程,也同时运行run方法,由于synchronized关键字的阻塞作用,这5个线程不能执行自增和自减操作,等待t1线程锁释放。
    3. 主线程执行了t1.stop方法,终止了t1线程,注意,由于a变量是所有线程共享的,所以其他5个线程获得的a变量也是1。
    4. 其他5个线程依次获得CPU执行机会,打印出a值。

    原本期望synchronized同步代码块中的逻辑都是原子逻辑,不受外界线程的干扰,但是结果却出现原子逻辑被破坏的情况,这也是stop方法被废弃的一个重要原因:破坏了原子逻辑

那怎样才能终止一个正在运行的线程呢?使用自定义的标志位决定线程的执行情况,代码如下:

class SafeStopThread extends Thread {
    // 此变量必须加上volatile
    private volatile boolean stop = false;

    @Override
    public void run() {
        // 判断线程体是否运行
        while (stop) {
            // doSomething
        }
    }

    public void terminate() {
        stop = true;
    }
}

volatile

  1. 作为指令关键字,确保本条指令不会因编译器的优化而省略,且要求每次直接读值。
  2. 被设计用来修饰被不同线程访问和修改的变量。
  3. 如果不加入volatile,基本上会导致这样的结果:要么无法编写多线程程序,要么编译器失去大量优化的机会。

Thread还提供了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在输出,永远不会停止。

如果需要终止该线程,还需要自行进行判断,如下才是正确使用方式:

class SafeStopThread extends Thread {
    @Override
    public void run() {
        //判断线程体是否运行
        while (!isInterrupted()) {
            // do SomeThing
        }
    }
}

如果我们使用的是线程池(比如ThreadPoolExecutor类),那么可以通过shutdown方法逐步关闭池中的线程,它采用的是比较温和、安全的关闭线程方法,完全不会产生类似stop方法的弊端。

建议121:线程优先级只使用三个等级★★☆☆☆

线程的优先级(Priority)决定了线程获得CPU运行的机会,优先级越高获得的运行机会越大,优先级越低获得的机会越小。Java的线程有10个级别(准确地说是11个级别,级别为0的线程是JVM的,应用程序不能设置该级别)。

但是在测试过程中会有如下两个现象(不管多少次):

  1. 并不是严格遵照线程优先级别来执行的

    比如:线程优先级为9的线程可能比优先级为10的线程先执行,优先级为1的线程可能比优先级为2的线程先执行,但很少会出现优先级为2的线程比优先级为10的线程先执行(这里用了一个词“很少”,是说确实有可能出现,只是几率非常低,因为优先级只是表示线程获得CPU运行的机会,并不代表强制的排序号)。

  2. 优先级差别越大,运行机会差别越明显

    比如:优先级为10的线程通常会比优先级为2的线程先执行,但是优先级为6的线程和优先级为5的线程差别就不太明显了,执行多次,你会发现有不同的顺序。

为什么呢?因为线程运行是需要获得CPU资源的。

那谁能决定哪个线程先获得哪个线程后获得呢?这是依照操作系统设定的线程优先级来分配的,也就是说,每个线程要运行,需要操作系统分配优先级和CPU资源。

对于Java来说,JVM调用操作系统的接口设置优先级,比如Windows操作系统是通过调用SetThreadPriority函数来设置的。

不同的操作系统线程优先级都相同吗?是不相同的。Windows有7个优先级,Linux有140个优先级.........

Java是跨平台的系统,需要把这个10个优先级映射成不同操作系统的优先级,于是界定了Java的优先级只是代表抢占CPU的机会大小,优先级越高,抢占CPU的机会越大,被优先执行的可能性越高,优先级相差不大,则抢占`CPU的机会差别也不大,这就是导致了优先级为9的线程可能比优先级为10的线程先运行。

于是在Thread类中设置了三个优先级常量:

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;

}

如果优先级相同呢?也是由操作系统决定的,基本上是按照FIFO原则(先入先出,First Input First Output),但也是不能完全保证。

注意:线程优先级推荐使用MIN_PRIORITYNORM_PRIORITYMAX_PRIORITY三个级别,不建议使用其他7个数字。

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

Java 1.5版本以后在Thread类中增加了setUncaughtExceptionHandler方法,实现了线程异常的捕捉和处理。

异常处理器应用的例子,代码如下:

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();
        }

    }
    
        public static void main(String[] args) {
            TcpServer tcpServer =new TcpServer();
        }
}

代码的逻辑:

  1. TcpServer类创建时即启动一个线程,提供TCP服务,具体逻辑在run方法中实现。
  2. 同时,设置了该线程出现运行期异常(也就是UncaughtException)时,由TcpServerExceptionHandler异常处理器来处理。
  3. TcpServerExceptionHandler异常处理器做什么事呢?两件事:
    1. 记录异常信息,以便查找问题。
    2. 重新启动一个新线程,提供不间断的服务。

运行结果如下:

系统正常运行:0
系统正常运行:1
系统正常运行:2
线程Thread-0 出现异常,自行重启,请分析原因。
java.lang.RuntimeException
    at com.jyswm.demo1.t151.TcpServer.run(TcpServer.java:22)
    at java.lang.Thread.run(Thread.java:745)
系统正常运行:0
系统正常运行:1
系统正常运行:2
线程Thread-1 出现异常,自行重启,请分析原因。
java.lang.RuntimeException
    at com.jyswm.demo1.t151.TcpServer.run(TcpServer.java:22)
    at java.lang.Thread.run(Thread.java:745)
系统正常运行:0
系统正常运行:1
系统正常运行:2
线程Thread-2 出现异常,自行重启,请分析原因。
java.lang.RuntimeException
    at com.jyswm.demo1.t151.TcpServer.run(TcpServer.java:22)
    at java.lang.Thread.run(Thread.java:745)
系统正常运行:0
系统正常运行:1
系统正常运行:2
线程Thread-3 出现异常,自行重启,请分析原因。
java.lang.RuntimeException
    at com.jyswm.demo1.t151.TcpServer.run(TcpServer.java:22)
    at java.lang.Thread.run(Thread.java:745)

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

  1. 共享资源锁定

    如果线程异常产生的原因是资源被锁定,自动重启应用只会增加系统的负担,无法提供不间断服务。

    例如一个即时通信服务器(XMPP Server)出现信息不能写入的情况时,即使再怎么重启服务,也是无法解决问题的。在此情况下最好的办法是停止所有的线程,释放资源。

  2. 脏数据引起系统逻辑混乱

    异常的产生中断了正在执行的业务逻辑,特别是如果正在执行一个原子操作

    (像即时通信服务器的用户验证和签到这两个事件应该在一个操作中处理,不允许出现验证成功但签到不成功的情况),但如果此时抛出了运行期异常就有可能会破坏正常的业务逻辑,例如出现用户认证通过了,但签到不成功的情况,在这种情景下重启应用服务器,虽然可以提供服务,但对部分用户则产生了逻辑异常。

  3. 内存溢出

    线程异常了,但由该线程创建的对象并不会马上回收,如果再重新启动新线程,再创建一批新对象,特别是加入了场景接管,就非常危险了。

    例如即时通信服务,重新启动一个新线程必须保证原在线用户的透明性,即用户不会察觉服务重启,在此种情况下,就需要在线程初始化时加载大量对象以保证用户的状态信息,但是如果线程反复重启,很可能会引起OutOfMemory内存泄露问题。

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

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


从示意图可以看出:

  1. 线程在初始化时从主内存中加载所需的变量值到工作内存中。
  2. 在线程运行时,如果是读取,则直接从工作内存中读取。
  3. 若是写入则先写到工作内存中,之后再刷新到主存中。

这是JVM的一个简单的内存模型,但是这样的结构在多线程的情况下有可能会出现问题。比如:A线程修改变量的值,也刷新到了主存中,但B、C线程在此时间内读取的还是本线程的工作内存,也就是说它们读取的不是最“新鲜”的值,此时就出现了不同线程持有的公共资源不同步的情况。

对于上述这种情况有很多解决办法,比如使用synchronized同步代码块,或者使用Lock锁来解决该问题。

但使用volatile可以更简单地解决此类问题,比如在一个变量前加上volatile关键字,可以确保每个线程对本地变量的访问和修改都是直接与主内存交互的,而不是与本线程的工作内存交互的,保证每个线程都能获得最“新鲜”的变量值。示意图如下:


那么问题来了,volatile变量是否能够保证数据的同步性呢?两个线程同时修改一个volatile是否会产生脏数据呢?

volatile关键字并不能保证线程安全,它只能保证当线程需要该变量的值时能够获得最新的值,而不能保证多个线程修改的安全性。

注意:volatile不能保证数据是同步的,只能保证线程能够获得最新值。

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

多线程应用有三种实现方式:

  1. 实现Runnable接口,缺点:run方法没有返回值,不能抛出异常。
  2. 继承Thread类,缺点:run方法没有返回值,不能抛出异常。(Thread也是实现了Runnable接口)
  3. 实现Callable接口,具有返回值,并可抛出异常。

Callable的接口定义如下:

public interface Callable {
    /**
     * 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 {
    // 本金
    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 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:优先选择线程池★★☆☆☆

一个线程有五个状态:新建状态(New)可运行状态(Runnable,也叫做运行状态)阻塞状态(Blocked)等待状态(Waiting)结束状态(Terminated),线程的状态只能由新建转变为了运行态后才可能被阻塞或等待,最后终结,不可能产生本末倒置的情况,比如想把一个结束状态的线程转变为新建状态,则会出现异常(IllegalThreadStateException)。

一个线程的运行时间分为三部分:T1为线程启动时间T2为线程体的运行时间T3为线程销毁时间

如果一个线程不能被重复使用,每次创建一个线程都需要经过启动运行销毁这三个过程,那么这势必会增大系统的响应时间。有没有更好的办法降低线程的运行时间呢?

T2是无法避免的,只有通过优化代码来实现降低运行时间。T1T2都可以通过线程池(ThreadPool)来缩减时间,比如在容器(或系统)启动时,创建足够多的线程,当容器(或系统)需要时直接从线程池中获得线程,运算出结果,再把线程返回到线程池中—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

按照之前阐述的“一个线程不可能从结束状态转变为可运行状态”,那为什么此处的2个线程可以反复使用呢?此处就是我们要搞清楚的重点。

线程池的实现涉及以下三个名词:

  1. 工作线程(Worker)

    线程池中的线程,只有两个状态:可运行状态和等待状态,在没有任务时它们处于等待状态,运行时可以循环地执行任务。

  2. 任务接口(Task)

    这是每个任务必须实现的接口,以供工作线程调度器调度,它主要规定了任务的入口、任务执行完的场景处理、任务的执行状态等。

    这里有两种类型的任务:具有返回值(或异常)的Callable接口任务和无返回值并兼容旧版本的Runnable接口任务。

  3. 任务队列(Wok Queue)

    也叫做工作队列,用于存放等待处理的任务,一般是BlockingQueue的实现类,用来实现任务的排队处理。

Executors.newFixedThreadPool(2)表示创建一个具有2个线程的线程池,源代码如下:

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

}
//这里使用了LinkedBlockingQueue作为任务队列管理器,
//所有等待处理的任务都会放在该队列中,需要注意的是,此队列是一个阻塞式的单端队列。

线程池建立好了,那就需要线程在其中运行了,线程池中的线程是在submit第一次提交任务时建立的,代码如下:

public Future submit(Runnable task) {
        //检查任务是否为null
        if (task == null) throw new NullPointerException();
        //把Runnable任务包装成具有返回值的任务对象,不过此时并没有执行,只是包装
        RunnableFuture 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方法从任务队列中获取任务,之后再继续执行,但问题是任务队列是一个BlockingQueue,是阻塞式的,也就是说如果该队列元素为0,则保持等待状态,直到有任务进入为止,我们来看LinkedBlockingQueuetake方法,代码如下:

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;
    }

线程池的创建过程:

  1. 创建一个阻塞队列以容纳任务。
  2. 在第一次执行任务时创建足够多的线程(不超过许可线程数),并处理任务。
  3. 之后每个工作线程自行从任务队列中获得任务,直到任务队列中的任务数量为0为止。
  4. 此时,线程将处于等待状态,一旦有任务再加入到队列中,即唤醒工作线程进行处理,实现线程的可复用性。

注意:使用线程池减少的是线程的创建和销毁时间。

建议126:适时选择不同的线程池来实现★★☆☆☆

Java的线程池实现从最根本上来说只有两个:ThreadPoolExecutor类和ScheduledThreadPoolExecutor类,这两个类还是父子关系,但是Java为了简化并行计算,还提供了一个Executors的静态类,它可以直接生成多种不同的线程池执行器,比如单线程执行器、带缓冲功能的执行器等,但归根结底还是使ThreadPoolExecutor类或ScheduledThreadPoolExecutor类的封装类。

为了理解这些执行器,我们首先来ThreadPoolExecutor类,其中它复杂的构造函数可以很好解释该线程池的作用,代码如下:

public class ThreadPoolExecutor extends AbstractExecutorService {
    // 这是ThreadPoolExecutor 最完整的构造函数,其他的构造函数都是引用该构造函数实现的
    public ThreadPoolExecutor(int corePoolSize, int maximumPoolSize,
            long keepAliveTime, TimeUnit unit,
            BlockingQueue workQueue, ThreadFactory threadFactory,
            RejectedExecutionHandler handler) {
        // 检验输入条件
        if (corePoolSize < 0 || maximumPoolSize <= 0
                || maximumPoolSize < corePoolSize || keepAliveTime < 0)
            throw new IllegalArgumentException();
        // 检验运行环境
        if (workQueue == null || threadFactory == null || handler == null)
            throw new NullPointerException();
        this.corePoolSize = corePoolSize;
        this.maximumPoolSize = maximumPoolSize;
        this.workQueue = workQueue;
        this.keepAliveTime = unit.toNanos(keepAliveTime);
        this.threadFactory = threadFactory;
        this.handler = handler;
    }
}

参数的含义:

  • corePoolSize:最小线程数。

    线程池启动后,在池中保持线程的最小数量。需要说明的是线程数量是逐步到达corePoolSize值的,例如corePoolSize被设置为10,而任务数量只有5,则线程池中最多会启动5个线程,而不是一次性地启动10个线程。

  • maximumPoolSize:最大线程数量。

    这是池中能够容纳的最大线程数量,如果超出,则使用RejectedExecutionHandler拒绝策略处理。

  • keepAliveTime:线程最大生命期。

    这里的生命期有两个约束条件:

    1. 该参数针对的是超过corePoolSize数量的线程;

    2. 该参数针对的是处于非运行状态的线程。

      eg:如果corePoolSize为10,maximumPoolSize为20,此时线程池中有15个线程在运行,一段时间后,其中有3个线程处于等待状态的时间超过了keepAliveTime指定的时间,则结束这3个线程,此时线程池中则还有12个线程正在运行。

  • unit:时间单位。

    这是keepAliveTime的时间单位,可以是纳秒、毫秒、秒、分钟等选项。

  • workQueue:任务队列。

    当线程池中的线程都处于运行状态,而此时任务数量继续增加,则需要有一个容器来容纳这些任务,这就是任务队列。

  • threadFactory:线程工厂。

    定义如何启动一个线程,可以设置线程名称,并且可以确认是否是后台线程等。

  • handler:拒绝任务处理器。

    由于超出线程数量和队列容量而对继续增加的任务进行处理的程序。

线程池的管理过程:

  1. 创建线程池
  2. 根据任务的数量逐步将线程增大到corePoolSize数量
  3. 如果此时仍有任务增加,则放置到workQueue中,直到workQueue爆满为止
  4. 然后继续增加池中的线程数量(增强处理能力),最终达到maximumPoolSize
  5. 那如果此时还有任务要增加进来,这就需要handler来处理了,或者丢弃新任务,或者拒绝新任务,或者挤占已有任务等。

在任务队列和线程池都饱和的情况下,一旦有线程处于等待(任务处理完毕,没有新任务增加)状态的时间超过keepAliveTime,则该线程终止,也就是说池中的线程数量会逐渐降低,直至为corePoolSize数量为止。

Executors提供的几个创建线程池的便捷方法:

  • newSingleThreadExecutor:单线程池。

    就是一个池中只有一个线程在运行,该线程永不超时。而且由于是一个线程,当有多个任务需要处理时,会将它们放置到一个无界阻塞队列中逐个处理,实现代码如下:

    public static ExecutorService newSingleThreadExecutor() {
            return new FinalizableDelegatedExecutorService
                (new ThreadPoolExecutor(1, 1,
                                        0L, TimeUnit.MILLISECONDS,
                                        new LinkedBlockingQueue()));
        }
    

    使用示例:

    public static void main(String[] args) throws ExecutionException,
                InterruptedException {
            // 创建单线程执行器
            ExecutorService es = Executors.newSingleThreadExecutor();
            // 执行一个任务
            Future future = es.submit(new Callable() {
                @Override
                public String call() throws Exception {
                    return "";
                }
            });
            // 获得任务执行后的返回值
            System.out.println("返回值:" + future.get());
            // 关闭执行器
            es.shutdown();
        }
    
  • newCachedThreadPool:缓冲功能的线程池。

    建立了一个线程池,而且线程数量是没有限制的(当然,不能超过Integer的最大值),新增一个任务即有一个线程处理,或者复用之前空闲的线程,或者新启动一个线程,但是一旦一个线程在60秒内一直是出于等待状态时(也就是1分钟没工作可做),则会被终止,其源代码如下:

    public static ExecutorService newCachedThreadPool() {
            return new ThreadPoolExecutor(0, Integer.MAX_VALUE,
                                          60L, TimeUnit.SECONDS,
            //任务队列使用了同步阻塞队列
            //这意味着向队列中加入一个元素,即可唤醒一个线程(新创建的线程或复用池中空闲线程)来处理,        //这种队列已经没有队列深度的概念了
                                          new SynchronousQueue());
        }
    
  • newFixedThreadPool:固定线程数量的线程池。

    在初始化时已经决定了线程的最大数量,若任务添加的能力超出了线程处理能力,则建立阻塞队列容纳多余的任务,源代码如下:

    public static ExecutorService newFixedThreadPool(int nThreads) {
            return new ThreadPoolExecutor(nThreads, nThreads,
                                          0L, TimeUnit.MILLISECONDS,
                                          new LinkedBlockingQueue());
        //如果任务增长速度非常快,超过了LinkedBlockingQueue的最大容量(Integer的最大值),那此时会如  何处理呢?会按照ThreadPoolExecutor默认的拒绝策略(默认是DiscardPolicy,直接丢弃)来处理。
        }
    

以上三种线程池执行器都是ThreadPoolExecutor的简化版,目的是帮助开发人员屏蔽过多的线程细节,简化多线程开发。当需要运行异步任务时,可以直接通过Executors获得一个线程池,然后运行任务,不需要关注ThreadPoolExecutor的一系列参数是什么含义。当然,有时候这三个线程池不能满足要求,此时则可以直接操作ThreadPoolExecutor来实现复杂的多线程运算。可以这样来比喻:newSingleThreadExecutornewCachedThreadPoolnewFixedThreadPool是线程池的简化版,而ThreadPoolExecutor则是旗舰版—简化版更容易操作,需要了解的知识相对少些,方便实用,而且旗舰版功能齐全,适用面广,但难于驾驭。

建议127:Locksynchronized是不一样的★★☆☆☆

以一个任务提交给多个线程运行为例,来看显式锁(Lock类)和内部锁(synchronized关键字)有什么不同

示例如下:

//定义一个任务
public class Task {

    public void doSomething() {
        try {
            // 每个线程等待2秒钟,注意此时线程的状态转变为Warning状态
            Thread.sleep(2000);
        } catch (Exception e) {
            // 异常处理
        }
        StringBuffer sb = new StringBuffer();
        // 线程名称
        sb.append("线程名称:" + Thread.currentThread().getName());
        // 运行时间戳
        sb.append(",执行时间: " + Calendar.getInstance().get(Calendar.SECOND) + "s");
        System.out.println(sb);
    }

}
//显式锁实现
public class TaskWithLock extends Task implements Runnable {

    // 声明显示锁
    private final Lock lock = new ReentrantLock();

    @Override
    public void run() {
        //显式锁的锁定和释放必须在一个try……finally块中,
        //这是为了确保即使出现运行期异常也能正常释放锁,保证其他线程能够顺利执行
        try {
            // 开始锁定
            lock.lock();
            doSomething();
        } finally {
            // 释放锁
            lock.unlock();
        }
    }
}
//内部锁实现
public class TaskWithSync extends Task implements Runnable {

    @Override
    public void run() {
        //内部锁
        synchronized("A"){
            doSomething();
        }
    }
}

建立一个模拟场景,保证同时有三个线程在运行,代码如下:

public class Client2 {

    public static void main(String[] args) throws Exception {
        // 运行显示任务
        runTasks(TaskWithLock.class);
        // 运行内部锁任务
        runTasks(TaskWithSync.class);
    }

    public static void runTasks(Class clz) throws Exception {
        ExecutorService es = Executors.newCachedThreadPool();
        System.out.println("***开始执行 " + clz.getSimpleName() + " 任务***");
        // 启动3个线程
        for (int i = 0; i < 3; i++) {
            es.submit(clz.newInstance());
        }
        // 等待足够长的时间,然后关闭执行器
        TimeUnit.SECONDS.sleep(10);
        System.out.println("---" + clz.getSimpleName() + "  任务执行完毕---\n");
        // 关闭执行器
        es.shutdown();
    }
}

运行结果如下:

***开始执行 TaskWithLock 任务***
线程名称:pool-1-thread-3,执行时间: 3s
线程名称:pool-1-thread-1,执行时间: 3s
线程名称:pool-1-thread-2,执行时间: 3s
---TaskWithLock  任务执行完毕---

***开始执行 TaskWithSync 任务***
线程名称:pool-2-thread-1,执行时间: 13s
线程名称:pool-2-thread-3,执行时间: 15s
线程名称:pool-2-thread-2,执行时间: 17s
---TaskWithSync  任务执行完毕---

注意看运行的时间戳,显式锁是同时运行的,很显然在pool-1-thread-1线程执行到sleep时,其他两个线程也会运行到这里,一起等待,然后一起输出,这还具有线程互斥的概念吗?

而内部锁的输出则是我们的预期结果:pool-2-thread-1线程在运行时其他线程处于等待状态pool-2-thread-1执行完毕后,JVM从等待线程池中随机获得一个线程pool-2-thread-3执行,最后再执行pool-2-thread-2,这正是我们希望的。

现在问题来了:Lock锁为什么不出现互斥情况呢?

这是因为对于同步资源来说(示例中是代码块),显式锁是对象级别的锁,而内部锁是类级别的锁,也就是说Lock锁是跟随对象的,synchronized锁是跟随类的,更简单地说把Lock定义为多线程类的私有属性是起不到资源互斥作用的,除非是把Lock定义为所有线程的共享变量。

Lock锁资源的正确使用示例:

public static void main(String[] args) {
        // 多个线程共享锁
        final Lock lock = new ReentrantLock();
        // 启动三个线程
        for (int i = 0; i < 3; i++) {
            new Thread(new Runnable() {
                @Override
                public void run() {
                    try {
                        lock.lock();
                        // 休眠2秒钟
                        Thread.sleep(2000);
                        System.out.println(Thread.currentThread().getName());
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    } finally {
                        lock.unlock();
                    }
                }
            }).start();
        }
    }
//运行结果:
//Thread-2 | 1622202475797
//Thread-0 | 1622202477812
//Thread-1 | 1622202479816

显式锁和内部锁还有什么不同呢?

  1. Lock支持更细粒度的锁控制

  2. Lock是无阻塞锁,synchronized是阻塞锁

    当线程A持有锁时,线程B也期望获得锁,此时,如果程序中使用的是显式锁,则B线程为等待状态(在通常的描述中,也认为此线程被阻塞了),若使用的是内部锁则为阻塞状态。

  3. Lock可实现公平锁,synchronized只能是非公平锁

    什么叫非公平锁呢?

    当一个线程A持有锁,而线程B、C处于阻塞(或等待)状态时,若线程A释放锁,JVM将从线程B、C中随机选择一个线程持有锁并使其获得执行权,这叫做非公平锁(因为它抛弃了先来后到的顺序);若JVM选择了等待时间最长的一个线程持有锁,则为公平锁(保证每个线程的等待时间均衡)。

    需要注意的是,即使是公平锁,JVM也无法准确做到“公平”,在程序中不能以此作为精确计算。

    显式锁默认是非公平锁,但可以在构造函数中加入参数true来声明出公平锁,而synchronized实现的是非公平锁,它不能实现公平锁。

  4. Lock是代码级的,synchronizedJVM级的

    Lock是通过编码实现的,synchronized是在运行期由JVM解释的,相对来说synchronized的优化可能性更高,毕竟是在最核心部分支持的,Lock的优化则需要用户自行考虑。

在开发中选择哪种锁要根据实际情况考虑:灵活、强大则选择Lock,快捷、安全则选择synchronized

注意:两种不同的锁机制,根据不同的情况来选择。

建议128:预防线程死锁★★☆☆☆

要达到线程死锁需要四个条件:

  1. 互斥条件:一个资源每次只能被一个线程使用。
  2. 资源独占条件:一个线程因请求资源而阻塞时,对已获得的资源保持不放。
  3. 不剥夺条件:线程已获得的资源在未使用完之前,不能强行剥夺。
  4. 循环等待条件:若干线程之间形成一种头尾相接的循环等待资源关系。

只有满足了这些条件才可能产生线程死锁,这也是说要解决线程死锁问题,就必须从这四个条件入手。

一般情况下可以按照以下两种方式来解决:

  1. 避免或减少资源共享

    一个资源被多个线程共享,若采用了同步机制,则产生的死锁可能性很大,特别是在项目比较庞大的情况下,很难杜绝死锁,对此最好的解决办法就是减少资源共享。

  2. 使用自旋锁

    如下代码中使用tryLock实现了自旋锁(Spin Lock):

    public void b2() {
            try {
                // 立刻获得锁,或者2秒等待锁资源
                if (lock.tryLock(2, TimeUnit.SECONDS)) {
                    System.out.println("进入B.b2()");
                }
            } catch (InterruptedException e) {
                e.printStackTrace();
            } finally {
                lock.unlock();
            }
        }
    

    它跟互斥锁一样,如果一个执行单元要想访问被自旋锁保护的共享资源,则必须先得到锁,在访问完共享资源后,也必须释放锁。如果在获取自旋锁时,没有任何执行单元保持该锁,那么将立即得到锁;如果在获取自旋锁时锁已经有保持者,那么获取锁操作将“自旋”在那里,直到该自旋锁的保持者释放了锁为止

在Java多线程并发编程中,死锁很难避免,也不容易预防,对付它的最好办法是测试:提高测试覆盖率,建立有效的边界测试,加强资源监控

建议129:适当设置阻塞队列长度★★☆☆☆

阻塞队列非阻塞队列一个重要区别:阻塞队列的容量是固定的,非阻塞队列则是变长的。阻塞队列可以在声明时指定队列的容量,若指定了容量,则元素的数量不可超过该容量,若不指定,队列的容量为Integer的最大值。

阻塞队列和非阻塞队列有此区别的原因是阻塞队列是为了容纳(或排序)多线程任务而存在的,其服务的对象是多线程应用,而非阻塞队列容纳的则是普通的数据元素。

ArrayBlockingQueue类最常用的add方法:

public class ArrayBlockingQueue extends AbstractQueue
        implements BlockingQueue, java.io.Serializable {

    //容纳元素的数组
    private final Object[] items;
    //元素数量计数器
    private int count;

    public boolean add(E e) {
        //调用offer方法尝试写入
        if (offer(e))
            return true;
        else
            //写入失败,队列已满
            throw new IllegalStateException("Queue full");
    }

    public boolean offer(E e) {
        final ReentrantLock lock = this.lock;
        //申请锁,只允许同时有一个线程操作
        lock.lock();
        try {
            //元素计数器的计数与数组长度相同,表示队列已满
            if (count == items.length)
                return false;
            else {
                //队列未满,插入元素
                enqueue(e);
                return true;
            }
        } finally {
            //释放锁
            lock.unlock();
        }
    }

}

上面在加入元素时,如果判断出当前队列已满,则返回false,表示插入失败,之后再包装成队列满异常。此处需要注意offer方法,如果我们直接调用offer方法插入元素,在超出容量的情况下,它除了返回false外,不会提供任何其他信息,如果我们的代码不做插入判断,那就会造成数据的“默默”丢失,这就是它与非阻塞队列的不同之处。

阻塞队列的这种机制对异步计算是非常有帮助的,例如我们定义深度为100的阻塞队列容纳100个任务,多个线程从该队列中获取任务并处理,当所有的线程都在繁忙,并且队列中任务数量已经为100时,也预示着系统运算压力非常巨大,而且处理结果的时间也会比较长,于是在第101个任务期望加入时,队列拒绝加入,而且返回异常,由系统自行处理,避免了异步运算的不可知性。

但是如果应用期望无论等待多长时间都要运行该任务,不希望返回异常,那该怎么处理呢?

BlockingQueue接口定义了put方法,它的作用也是把元素加入到队列中,但它与addoffer方法不同,它会等待队列空出元素,再让自己加入进去,通俗地讲,put方法提供的是一种“无赖”式的插入,无论等待多长时间都要把该元素插入到队列中,它的实现代码如下:

    public void put(E e) throws InterruptedException {
      //容纳元素的数组
        final Object[] items = this.items;
        final ReentrantLock lock = this.lock;
        //可中断锁
        lock.lockInterruptibly();
               try {
            try {
                //队列满,等待其他线程移除元素
                while (count == items.length)
                    notFull.await();
            } catch (InterruptedException ie) {
                //被中断了,唤醒其他线程
                notFull.signal();
                throw ie;
            }
            //插入元素
            insert(e);
        } finally {
            lock.unlock();
        }
    }

put方法的目的就是确保元素肯定会加入到队列中,问题是此种等待是一个循环,会不停地消耗系统资源,当等待加入的元素数量较多时势必会对系统性能产生影响,那该如何解决呢?JDK已经想到了这个问题,它提供了带有超时时间的offer方法,其实现方法与put比较类似,只是使用ConditionawaitNanos方法来判断当前线程已经等待了多少纳秒,超时则返回false。与插入元素相对应,取出元素也有不同的实现,例如removepolltake等方法,对于此类方法的理解要建立在阻塞队列的长度固定的基础上,然后根据是否阻塞、阻塞是否超时等实际情况选用不同的插入和提取方法。

注意:阻塞队列的长度是固定的。

建议130:使用CountDownLatch协调子线程★★☆☆☆

思考这样一个案例:百米赛跑,多个参加赛跑的人员在听到发令枪响后,开始跑步,到达终点后结束计时,然后统计平均成绩。这里有两点需要考虑:一是发令枪响,这是所有跑步者(线程)接收到的出发信号,此处涉及裁判(主线程)如何通知跑步者(子线程)的问题;二是如何获知所有的跑步者完成了赛跑,也就是主线程如何知道子线程已经全部完成,这有很多种实现方式,此处我们使用CountDownLatch工具类来实现,代码如下:

public class Runner implements Callable {
    //开始信号
    private CountDownLatch begin;
    //结束信号
    private CountDownLatch end;

    public Runner(CountDownLatch begin, CountDownLatch end) {
        this.begin = begin;
        this.end = end;
    }

    @Override
    public Integer call() throws Exception {
        //跑步的成绩
        int score = new Random().nextInt(25);
        //等待发令枪响起
        begin.await();
        //跑步中....
        TimeUnit.MILLISECONDS.sleep(score);
        //跑步者已跑完全程
        end.countDown();
        return score;
    }

}


public class Playground {

    public static void main(String[] args) throws InterruptedException, ExecutionException {
        //参赛人数
        int num = 10;
        //发令枪只响一次
        CountDownLatch begin = new CountDownLatch(1);
        //参与跑步有多个
        CountDownLatch end = new CountDownLatch(num);
        //每一个跑步者一个跑道
        ExecutorService es = Executors.newFixedThreadPool(num);
        //记录比赛成绩
        List> futures = new ArrayList<>();
        //跑步者就位,所有线程处于等待状态
        for (int i = 0; i < num; i++) {
            futures.add(es.submit(new Runner(begin, end)));
        }
        //发令枪响,跑步者开始跑步
        begin.countDown();
        //等待所有跑步者跑完全程
        end.await();
        int count = 0;
        //统计总分
        for (Future future : futures) {
            count += future.get();
        }
        System.out.println("平均分数为:" + count / num);
    }
}

CountDownLatch类是一个倒数的同步计数器,在程序中启动了两个计数器:一个是开始计数器begin,表示的是发令枪;另外是结束计数器end,一共有10个,表示的是每个线程的执行情况,也就是跑步者是否跑完比赛。程序执行逻辑如下:

  1. 10个线程都开始运行,执行到begin.await后线程阻塞,等待begin的计数变为0。
  2. 主线程调用begincountDown方法,使begin的计数器为0。
  3. 10个线程继续运行。
  4. 主线程继续运行下一个语句,end的计数器不为0,主线程等待。
  5. 每个线程运行结束时把end的计数器减1,标志着本线程运行完毕。
  6. 10个线程全部结束,end计数器为0。
  7. 主线程继续执行,打印出成绩平均值。

CountDownLatch的作用是控制一个计数器,每个线程在运行完毕后会执行countDown,表示自己运行结束,这对于多个子任务的计算特别有效。

比如:一个异步任务需要拆分成10个子任务执行,主任务必须要知道子任务是否完成,所有子任务完成后才能进行合并计算,从而保证了一个主任务的逻辑正确性。这和我们的实际工作非常类似,比如领导安排了一个大任务给我,我一个人不可能完成,于是我把该任务分解给10个人做,在10个人全部完成后,我把这10个结果组合起来返回给领导—这就是CountDownLatch的作用。

建议131:CyclicBarrier让多线程齐步走★★☆☆☆

思考这样一个案例:两个工人从两端挖掘隧道,各自独立奋战,中间不沟通,如果两人在汇合点处碰头了,则表明隧道已经挖通。这描绘的也是在多线程编程中,两个线程独立运行,在没有线程间通信的情况下,如何解决两个线程汇集在同一原点的问题。Java提供了CyclicBarrier(关卡,也有翻译为栅栏)工具类来实现,代码如下:

public class Worker implements Runnable {

    //关卡
    private CyclicBarrier cb;

    public Worker(CyclicBarrier cb) {
        this.cb = cb;
    }

    @Override
    public void run() {
        try {
            Thread.sleep(new Random().nextInt(1000));
            System.out.println(Thread.currentThread().getName() + "-到达汇合点");
        } catch (Exception e) {
            //异常处理
        }
    }
}

public class ConstructionSite {

    public static void main(String[] args) throws Exception {
        //设置汇集数量,以及汇集完成后的任务
        CyclicBarrier cb = new CyclicBarrier(2, new Runnable() {
            @Override
            public void run() {
                System.out.println("隧道已打通了。");
            }
        });
        //工人1挖隧道
        new Thread(new Worker(cb), "工人1").start();
        //工人2挖隧道
        new Thread(new Worker(cb), "工人2").start();
    }
}

代码逻辑如下:

  1. 2个线程同时开始运行,实现不同的任务,执行时间不同。
  2. “工人1”线程首先到达汇合点(也就是cb.await语句),转变为等待状态。
  3. “工人2”线程到达汇合点,满足预先的关卡条件(2个线程到达关卡),继续执行。此时还会额外的执行两个动作:执行关卡任务(也就是run方法)和唤醒“工人1”线程。
  4. “工人1”线程继续执行。

CyclicBarrier关卡可以让所有线程全部处于等待状态(阻塞),然后在满足条件的情况下继续执行。

这就好比是一条起跑线,不管是如何到达起跑线的,只要到达这条起跑线就必须等待其他人员,待人员到齐后再各奔东西,CyclicBarrier关注的是汇合点的信息,而不在乎之前或之后做何处理。

CyclicBarrier可以用在系统的性能测试中,例如我们编写了一个核心算法,但不能确定其可靠性和效率如何,我们就可以让N个线程汇集到测试原点上,然后“一声令下”,所有的线程都引用该算法,即可观察出算法是否有缺陷。

你可能感兴趣的:(多线程和并发——读《编写高质量代码:改善Java程序的151个建议》(九))