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,所以在实际开发中一般不建议使用无界的队列。
