Java线程安全策略与多线程并发最佳实践

Java线程安全策略与多线程并发最佳实践

线程安全策略

不可变对象

不可变对象(Immutable Objects)是指对象一旦被创建它的状态(对象的数据,也即对象属性值)就不能改变,任何对它的改变都应该产生一个新的对象。

不可变对象需要满足的条件:

  1. 对象创建以后其状态就不能修改
  2. 对象所有域都是final类型
  3. 对象时正确创建的(在对象创建期间,this引用没有逸出)

除了使用final自行封装不可变对象之外,还可以通过以下两种方式定义不可变对象

  • Collections.unmodifiableXXX():XXX可以是Collection、List、Set、Map。使用api将已有普通集合类对象转变成不可变对象。原理是进行包装,然后定义所有修改函数抛出异常。
  • guava里面的ImmutableXXX。XXX可以是Collection、List、Set、Map。

线程封闭

当访问共享的可变数据时,通常需要同步。一种避免同步的方式就是不共享数据。如果仅在单线程内访问数据,就不需要同步,这种技术称为线程封闭。

常见线程封闭手段:

  • 堆栈封闭:局部变量,没有并发问题
  • threadlocal:特别好的线程封闭方法。通常在filter将用户信息保存threadlocal。

spring中一定要在拦截器afterCompletion中,执行threadlocal的remove函数,线程池中使用同理。

同步容器

stringbuilder:线程不安全(可以在函数中定义,利用堆栈封闭避免了线程不安全,同时节省了加锁的消耗,性能更好)

stringbuffer:线程安全(每个函数都是用synchronized修饰),可以做全局变量。

SimpleDateFormat:JDK中的工具类,线程不安全。使用方法可以参考stringbuilder。

JodaTime:线程安全,功能更丰富。

ArrayList/HashSet/HashMap等Collections:都是线程不安全的

Vector/Stack/HashTable:都是线程安全的

先检查再执行:if(condition(a)){handle(a)},这种形式如果没有加锁的话,就不是原子性,也是线程不安全的

并发容器

线程安全的容器除了上文提到的同步容器一些外,在Java的J.U.C(java.utils.concurrent的缩写)下,同样提供了线程安全的并发容器。

  1. CopyOnWriteArrayList
  2. 对应ArrayList,是线程安全容器。适合读多写少的场景(读不加锁,写加可重入锁。读是读的原数组,写是在新数组)
  3. 缺点:
  4. 消耗内存,可能引发gc
  5. 不能用于实时读
  6. CopyOnWriteArraySet/ConcurrentSkipListSet
  7. 对应HashSet/TreeSet,是线程安全容器。其中CopyOnWriteArraySet底层是CopyOnWriteArrayList
  8. ConcurrentHashMap/ConcurentSkipListMap
  9. 对应HashMap/TreeMap,是线程安全容器。ConcurentSkipListMap支持key有序,而且后者支持更高的并发数,因为它的存取时间和线程数是没有关系的。

注意:并发容器的批量操作都不是线程安全的,例如调用removeAll,containsAll等,需要自行加锁。

CopyOnWriteArrayList、CopyOnWriteArraySet,这种利用cow特性的数据结构,需要copy消耗内存,可能引发gc。

死锁

线程死锁是指由于两个或者多个线程互相持有对方所需要的资源,导致这些线程处于等待状态,无法前往执行。

死锁的必要条件

  1. 互斥条件。进程对于所分配到的资源具有排它性,即一个资源只能被一个进程占用,直到被该进程释放
  2. 请求和保持条件。一个进程因请求被占用资源而发生阻塞时,对已获得的资源保持不放。
  3. 不剥夺条件。任何一个资源在没被该进程释放之前,任何其他进程都无法对他剥夺占用。
  4. 环路等待条件。当发生死锁时,所等待的进程必定会形成一个环路(类似于死循环),造成永久阻塞。

死锁示例代码:

@Slf4j
public class DeadLock implements Runnable {
 public int flag = 1;
 //静态对象是类的所有对象共享的
 private static Object o1 = new Object(), o2 = new Object();
 @Override
 public void run() {
 log.info("flag:{}", flag);
 if (flag == 1) {
 synchronized (o1) {
 try {
 Thread.sleep(500);
 } catch (Exception e) {
 e.printStackTrace();
 }
 synchronized (o2) {
 log.info("1");
 }
 }
 }
 if (flag == 0) {
 synchronized (o2) {
 try {
 Thread.sleep(500);
 } catch (Exception e) {
 e.printStackTrace();
 }
 synchronized (o1) {
 log.info("0");
 }
 }
 }
 }
 public static void main(String[] args) {
 DeadLock td1 = new DeadLock();
 DeadLock td2 = new DeadLock();
 td1.flag = 1;
 td2.flag = 0;
 //td1,td2都处于可执行状态,但JVM线程调度先执行哪个线程是不确定的。
 //td2的run()可能在td1的run()之前运行
 new Thread(td1).start();
 new Thread(td2).start();
 }
}

避免死锁的方法

  1. 注意加锁顺序。当多个线程需要相同的一些锁,但是按照不同的顺序加锁,死锁就很容易发生。如果能确保所有的线程都是按照相同的顺序获得锁,那么死锁就不会发生。
  2. 加锁有时限。在尝试获取锁的时候加一个超时时间,这也就意味着在尝试获取锁的过程中若超过了这个时限该线程则放弃对该锁请求。
  3. 死锁检测。主要是针对那些不可能实现按序加锁并且锁超时也不可行的场景。每当一个线程获得了锁,会在线程和锁相关的数据结构中(map、graph等等)将其记下。除此之外,每当有线程请求锁,也需要记录在这个数据结构中。当一个线程请求锁失败时,这个线程可以遍历锁的关系图看看是否有死锁发生。

死锁排查方法

虽然造成死锁的原因是因为我们设计得不够好,但是可能写代码的时候不知道哪里发生了死锁。

JDK提供了两种方式来给我们检测:

  • JconsoleJDK自带的图形化界面工具,使用JDK给我们的的工具JConsole
  • Jstack是JDK自带的命令行工具,主要用于线程Dump分析。

检测出死锁时的解决方案

一个可行的做法是释放所有锁,回退,并且等待一段随机的时间后重试。这个和简单的加锁超时类似,不一样的是只有死锁已经发生了才回退,而不会是因为加锁的请求超时了。虽然有回退和等待,但是如果有大量的线程竞争同一批锁,它们还是会重复地死锁(编者注:原因同超时类似,不能从根本上减轻竞争)。

一个更好的方案是给这些线程设置优先级,让一个(或几个)线程回退,剩下的线程就像没发生死锁一样继续保持着它们需要的锁。如果赋予这些线程的优先级是固定不变的,同一批线程总是会拥有更高的优先级。为避免这个问题,可以在死锁发生的时候设置随机的优先级。

多线程并发最佳实践

1. 使用本地变量

尽量使用本地变量,而不是创建一个类或实例的变量。

class concurrentTask {
 private static List temp = new ArrayList<>();
 public void execute(Message message) {
 // 使用本地变量保证线程安全
// List temp = new ArrayList<>();
 temp.add(message.getId());
 temp.add(message.getCode());
 // ...省略各种业务逻辑
 temp.clear();
 }
}

2. 使用不可变类

不可变类比如String 、Integer等一旦创建,不再改变,不可变类可以降低代码中需要的同步数量。

3. 最小化锁的作用域范围

"阿姆达尔定律",又称"安达尔定理": S=1/(1-a+a/n)

a:并行计算部分所占比例

n:并行处理结点个数

S:加速比

当1-a等于0时,没有串行只有并行,最大加速比 S=n

当a=0时,只有串行没有并行,最小加速比 S = 1

当n→∞时,极限加速比 s→ 1/(1-a)

例如,若串行代码占整个代码的25%,则并行处理的总体性能不可能超过4。

4. 使用线程池,而不是直接使用new Thread执行

避免new Thread创建线程。通过线程池的管理,可提高线程的复用性(避免新建线程的昂贵的资源消耗),简化线程生命周期的管理。JDK提供了各种ThreadPool线程池和Executor。

5. 宁可使用同步工具类也不要使用线程的wait和notify

同步工具类包括:countdownlaunch/Semaphore/Semaphore。应当优先使用这些同步工具,而不是去思考如何使用线程的wait和notify。此外,使用BlockingQueue实现生产消费的设计比使用wait和notify要好。

6. 使用blockingqueue实现生产消费模式

阻塞队列是生产者-消费者模式的最好的实现方式,不仅包括单个生产者单个消费者,还支持多个生产者多个消费者情况。

7. 使用并发集合而不是加了锁的同步集合

JDK提供了ConcurrentHashMap、CopyOnWriteArrayList、CopyOnWriteArraySet、BlockingQueue中的Deque和BlockingDeque五大并发集合,他们有着较好性能;尽量使用该并发集合,而避免使用synchronizedXXX的锁同步集合。

8. 使用semaphore创建有界的访问

为了建立稳定可靠的系统,对于数据库、文件系统和socket等资源必须要做有界的访问,Semaphone可以限制这些资源开销的选择,Semaphone可以以最低的代价阻塞线程等待,可以通过Semaphone来控制同时访问指定资源的线程数。

9. 宁可使用同步代码块,也不使用同步的方法

主要针对synchronized关键字。使用synchronized关键字同步代码块只会锁定一个对象,而不会将整个方法锁定(当类不是单例的时候)。如果更改共同的变量或类的字段,首先应该选择的是原子型变量,然后使用volatile。如果需要互斥锁,可以考虑使用ReentrantLock。

10. 避免使用静态变量

静态变量在多线程并发环境中会造成较多的问题。当使用静态变量时,优先将其指定为final变量,若用其来保存集合Collection变量,则考虑使用只读集合。详见上文的不可变对象,同步容器和并发容器。

感谢您耐心看完了文章...

关注作者:JAVA高级程序员。

我会不定期在微头条发放:(Java工程化、分布式架构、高并发、高性能、深入浅出、微服务架构、Spring、MyBatis、Netty、源码分析)等技术学习资料,以及Java进阶学习路线图。

我们总是想得太多,做的太少!

相关推荐