Java——多线程编程技巧
多线程编程技巧1、线程异常处理2、线程正确关闭2.1、使用退出标志终止线程2.2、使用interrupt方法中断线程2.3、使用stop方法终止线程3、线程死锁3.1、锁顺序性死锁3.2、动态执行死锁3.3、死锁检测3.4、死锁规避4、并发容器的使用4.1、List的使用4.2、Map的使用4.3、Set的使用4.4、Queue的使用1、线程异常处理在使用线程处理业务逻辑时需要注意异常的处理。Java线程不允许抛出未捕获的异常信息线程执行的异常必须在线程内部捕获。java.lang.Runnable接口的run方法声明中没有提供抛出异常的能力。当线程在执行过程中抛出未捕获的异常时线程执行会被终止异常信息会打印在控制台上而其他线程无法感知到当前线程已经抛出异常。在生产环境中Java程序都是以后台进程的模式运行的异常信息无法打印到日志文件中线程结束后异常信息就丢失了。如下代码是一个线程执行异常的示例。在上面的代码中当i的值为2时run方法会抛出java.lang.ArithmeticException错误信息线程在执行的过程中被终止。在实际编程中我们需要对run方法采用防御性编程主动对所有异常进行捕获。如代码所示run方法捕获了java.lang.Throwable异常。Java中所有的异常都继承自java.lang.Throwable异常所以在编写线程代码时最好直接捕获Throwable异常以便捕获所有异常。Thread类的API提供了异常处理器UncaughtExceptionHandler来进行异常捕获Thread类还提供了setUncaughtExceptionHandler方法来设置线程异常处理函数。当线程抛出未捕获异常时JVM会调用对应的异常处理器来处理异常信息。如下代码是Uncaught-ExceptionHandler接口描述。UncaughtExceptionHandler接口定义了uncaughtException方法来处理线程未捕获的异常。如下代码是自定义的异常处理器的具体实现。main函数会调用ThreadsetUncaughtExceptionHandler方法来设置线程的异常处理器如代码所示。2、线程正确关闭在执行完run方法之后普通的任务线程就正常结束了。但有些任务线程需要在JVM中持续运行例如在程序中使用线程来监听Socket端口在这种情况下需要通过while循环来处理线程任务。当系统发布或者重启的时候如果持续运行的线程不能正常结束会影响业务的正确性。Java提供了3种线程停止的方式使用退出标志主动终止、使用interrupt方法中断线程、使用stop方法强行终止线程。2.1、使用退出标志终止线程使用退出标志来终止线程需要在线程内部定义一个boolean类型的变量用来表示线程的运行状态false表示持续运行true表示退出运行。变量必须用volatile关键字修饰以确保多线程的可见性。在线程循环执行时每次任务执行之前线程都会通过运行标志来判断是否需要继续执行。在需要线程终止执行时程序会将线程运行标志设置为true。代码是使用退出标志终止线程的示例。2.2、使用interrupt方法中断线程interrupt方法的核心思想是两阶段终止模式第一阶段由主线程发起终止命令第二阶段由子线程来响应终止命令这样程序就能通过两阶段提交来完成线程的优雅停止。如下代码是一个线程中断而退出线程的简单示例。在使用interrupt方法来终止线程执行时需要设置中断标志并注意线程阻塞的状态。如果线程处于非阻塞状态interrupt方法会返回线程的中断标志。如果线程处于阻塞状态interrupt方法先唤醒被中断的线程。线程醒来后interrupt方法会先清除线程中断标志然后抛出InterruptedException异常。JVM线程中断处理流程如图所示。对于非阻塞状态的线程程序可以通过isInterrupted方法来判断线程是否发生过中断。对于阻塞状态的线程程序需要通过InterruptedException异常来判断线程是否中断。2.3、使用stop方法终止线程在程序中我们可以直接使用Thread类的stop方法来强行终止线程。Thread类的stop方法是通过抛出ThreadDeatherror错误来终止线程的执行的。stop方法会释放线程所持有的锁可能会产生不可预料的结果所以并不推荐使用stop方法来终止线程。注意在使用interrupt方法来结束线程执行时我们需要考虑线程阻塞与非阻塞状态的逻辑处理因为理解成本比较高所以处理不好很难达到正确的停止目标。stop方法在新版本的JDK中已经被放弃使用。3、线程死锁在多线程编程中需要关注线程死锁的问题。线程死锁是指由于两个或者多个线程互相持有对方所需要的资源导致线程都处于等待状态无法继续执行。例如线程A持有锁L需要等待锁M而线程B持有锁M需要获得锁L这时线程A在等待获取锁M才能执行而线程B需要获取到锁L才能执行。线程A、B互相持有对方所需要的锁但线程A、B都不会主动释放所占有的资源所以线程会产生死锁。线程死锁的发生需要具备以下4个条件互斥条件、请求与保持条件、不可剥夺条件、循环等待条件如表所示。3.1、锁顺序性死锁多个业务方法以不同顺序来获取锁资源会导致线程死锁。代码中有两个方法methodA与methodB。methodA方法先对lockA对象加锁然后对lockB对象加锁。而methodB方法是先对lockB对象加锁然后对lockA对象加锁。在两个方法同时执行时线程A会等待线程B释放lockB对象的锁线程B会等待线程A释放lockA对象的锁从而造成线程锁冲突。3.2、动态执行死锁在Java程序中业务逻辑的动态冲突也会造成死锁。以银行资金转账的场景为例转账就是将一个账户的资金转移到另一个账户。为了确保资金转移的安全程序需要对两个账号进行加锁。代码是动态执行死锁的示例。当启动两个线程线程A从X账号往Y账号转账线程B从Y账号往X账号转账。当两个线程同时进行转账的时候线程A在获取到X账号锁后需要等待Y账号的锁线程B在获取Y账号锁后需要等待X账号的锁这样线程A与线程B会产生死锁等待。3.3、死锁检测通过Arthas的thread命令能够快速检测线程死锁。如图所示在Arthas中输入thread命令可以得到系统中的详细的线程信息。BLOCKED表示目前阻塞的线程数从图中可以看到有2个线程处于线程死锁的状态。执行thread-b命令可以快速查找出当前被阻塞的线程结果如图所示。注意上面命令直接输出了造成死锁的线程ID、具体发生死锁的代码位置以及当前线程一共阻塞的线程数量----but blocks 1 other threads!。3.4、死锁规避数据库系统在设计过程中会充分考虑死锁的检测以及自动恢复的功能而JVM不具备自动将线程从死锁状态中恢复的能力。一旦发生了线程死锁线程就不能再正常工作了只有重启系统才能恢复所以在代码设计与开发的过程中我们需要避免线程死锁的发生。在编写代码的过程中要尽量避免在同一个方法里使用多个锁并且只有必要时才持有锁。在多线程环境中同一个业务方法持有多个锁往往是线程死锁的根源。如果一定要使用多个锁我们需要设计好锁获取与锁释放的顺序以确保不会出现锁获取与释放交叉的情况。在使用ReentrantLock、ReentrantReadWriteLock等来获取锁的时候要尽可能地调用tryLock()方法来获取。4、并发容器的使用Java提供了各种容器方便开发人员进行程序开发容器主要分为4个大类List、Map、Set和Queue。但并不是所有的容器都是线程安全的例如常用的ArrayList、HashMap就不是线程安全的。在并发场景中使用HashMap来存储数据在扩容的时候会出现死循环导致CPU使用率居高不下最终导致系统崩溃。虽然JDK 1.5之前提供的同步容器Vector、Hshtable等也能保证线程安全但是性能很差。而JDK 1.5之后的版本提供了多种并发容器并在性能方面进行了很多优化。本节将详细介绍Java的并发容器及其对应的使用场景。4.1、List的使用List的实现子类有ArrayList、LinkedList、CopyOnWriteArrayList但是ArrayList、Linked-List都不是线程安全的只有CopyOnWriteArrayList是线程安全的。CopyOnWriteArray-List采用了读写分离的并发策略能够同时支持多线程的读取与单线程的修改。每次修改CopyOnWriteArrayList都会创建一个新的数组在新的数组里面完成数据修改并在修改完成后进行数组指针的替换。CopyOnWriteArrayList的高频修改会带来大量的数组对象创建与垃圾回收导致严重影响性能所以CopyOnWriteArrayList适合读多写少的并发场景。4.2、Map的使用Map接口的5个实现类是HashMap、TreeMap、Hashtable、ConcurrentHashMap和Concurrent-SkipListMap其中Hashtable、ConcurrentHashMap和ConcurrentSkipListMap是线程安全的。Map容器线程安全特征如表所示。ConcurrentHashMap和ConcurrentSkipListMap的key与value都不允许为空。如果key或value为空则会抛出NullPointerException异常。ConcurrentSkipListMap的key是有序的如果需要保证key的顺序只能使用ConcurrentSkipListMap。ConcurrentSkipListMap采用跳表的数据结构跳表的插入、删除、查询操作平均的时间复杂度是O(log n)能够存储大容量的数据。在并发控制上ConcurrentSkipListMap采用了CAS的方式来修改数据信息。在数据一致性上ConcurrentSkipListMap采取了数据实时一致性索引最终一致性的方案。所以在大容量高并发场景中ConcurrentSkipListMap的性能会更好。4.3、Set的使用线程安全的Set容器有CopyOnWriteArraySet和ConcurrentSkipListSet。CopyOnWrite-ArraySet是基于CopyOnWriteArrayList来实现的。每次插入数据时CopyOnWriteArrayList会先判断数据是否已经在数组中了。如果数组中不包含该数据CopyOnWriteArraySet会新建一个数组将原来数组中的数据复制到新数组中并把要插入的数据放在新数组的尾部。ConcurrentSkipListSet是基于ConcurrentSkipListMap实现的其中key为具体的数据value始终是boolean类型的true变量。使用场景可以参考CopyOnWriteArrayList和ConcurrentSkipListMap它们的原理都是一样的这里不再赘述。4.4、Queue的使用Java提供了非常丰富的线程安全的Queue可以从3个维度进行区分单端与双端、阻塞与非阻塞、有界与无界。第一个维度是单端与双端单端指的是只能队尾入队、队首出队而双端指的是队首和队尾都可以入队、出队。单端队列使用Queue标识双端队列使用Deque标识。第二个维度是阻塞与非阻塞阻塞指的是当队列满了的时候入队操作会被阻塞当队列为空的时候出队操作会被阻塞。第三个维度是有界与无界有界是指队列有容量限制而无界是指队列没有容量上限。详细信息如表所示。在队列使用时需要格外注意队列是否支持有界。无界队列没有容量限制数据量大了之后很容易导致系统OOM所以在实际开发中一般不建议使用无界的队列。