Java进阶知识点:不要只会写synchronized - JDK十大并发编程组件总结
一、背景
提到Java中的并发编程,首先想到的便是使用synchronized代码块,保证代码块在并发环境下有序执行,从而避免冲突。如果涉及多线程间通信,可以再在synchronized代码块中使用wait和notify进行事件的通知。
不过使用synchronized+wait+notify进行多线程协作编程时,思维方式过于底层,常常需要结合具体的并发场景编写大量额外的控制逻辑。
好在java.util.concurrent包下已经为我们准备好了大量适用于各类并发编程场景的组件,利用这些组件我们可以快速完成复杂的多线程协作编程,而且这些组件都是经过高度性能优化的。
二、常用的并发编程组件
2.1 ReentrantLock
synchronized代码块本质上完成的是代码片段的自动上锁和解锁,以确保关键代码片段在多线程中的互斥访问。
所以,说起synchronized的替代品,首先想到的便是Lock,而ReentrantLock(可重入锁)便是Lock中最常用的组件。之所以称之为可重入锁,是因为同一个线程可以对同一个ReentrantLock对象多次嵌套加锁,只要按照加锁流程,依次完成相同次数的解锁就不会有问题。
相比于synchronized代码块,ReentrantLock最大的特点就是灵活。通常我们选用ReentrantLock而不是synchronized代码块,会出于如下三种考虑:
1、需要更加灵活的加锁和解锁时机控制
比如如下模拟的锁耦合场景(释放当前锁之前必须获取另外一个锁),用synchronized是很难完成的,因为你永远无法让两个synchronized代码块交叉重叠。
ReentrantLock lockA = new ReentrantLock(); ReentrantLock lockB = new ReentrantLock(); lockA.lock(); lockB.lock(); lockA.unlock(); lockB.unlock();
2、需要支持非阻塞的锁机制
synchronized代码片段一旦出现多线程资源争用,代码会一直卡住,直到其他线程释放资源(代码执行出了synchronized片段)后,本线程抢得资源的使用权为止。
极端情况下,可能会出现本线程一直无法获取资源使用权的情况,而这种情况下,synchronized关键字没有任何后续补救措施。
而ReentrantLock在这一点上要人性化很多,它不光提供了tryLock()这类非阻塞的尝试获取锁的方法,也提供了tryLock(long timeout, TimeUnit unit)这种带超时机制的尝试获取锁的方法。即便是最坏的死锁情况发生,至少你的程序能够在一定时长的等待后,打破死锁,进而获得报警/恢复/忽略继续执行等后续逻辑处理的可能性。
3、想要获得更高的性能
在JDK6之前,synchronized的性能与ReentrantLock相比还是有较大差距,特别是高并发场景下,synchronized的性能可能会急剧衰减。所以那时会通过使用ReentrantLock替换synchronized代码块进行特定场景下的性能优化。
不过JDK7中已经对synchronized做了优化,性能与ReentrantLock已经很接近了,而JDK8中连ConcurrentHashMap的实现都开始用synchronized替换之前版本的ReentrantLock。Java官方也推荐大家尽量使用synchronized关键字,毕竟用它编写的代码要显得更加优雅,也不会发生忘记解锁的情况。至于synchronized的性能,现在不光不会拖后腿,您使用它还将享受Java对其坚持不懈的优化。
2.2 ReadWriteLock
ReadWriteLock本质是一对相互关联的ReadLock和WriteLock,ReadLock和WriteLock各自的使用方式与2.1中介绍的ReentrantLock一致,不过ReadLock和WriteLock间有一定的制约关系。
不同线程可以同时对同一个ReadWriteLock中的ReadLock加锁(即调用readWriteLock.readLock().lock()),但只要想对ReadWriteLock中的WriteLock加锁(即调用readWriteLock.writeLock().lock()),则必须等待其他线程中已经持有的ReadLock和WriteLock的都解锁后才能成功。
在共享资源读多写少,且多线程同时读共享资源不会有问题的场景下,通常只要做到多线程间读与读可以共存,读与写不能共存,写与写不能共存,即可保证共享资源的并发访问不会有问题。这便是ReadWriteLock的典型适用场景。
2.3 Semaphore
Semaphore,又叫信号量,是专门用于只允许N个任务同时访问某个共享资源的场景。
它使用很简单,典型使用方式如下所示:
Semaphore semaphore = new Semaphore(10); //创建信号量,并为该信号量指定初始许可数量 semaphore.acquire(); //获取单个许可,如果无可用许可前将一直阻塞等待 semaphore.acquire(2); //获取指定数目的许可,如果无可用许可前将会一直阻塞等待 semaphore.tryAcquire(); //尝试获取单个许可,如果无可用许可直接返回false,不会阻塞 semaphore.tryAcquire(2); //尝试获取指定数量的许可,如果无可用许可直接返回false,不会阻塞 semaphore.tryAcquire(2, 2, TimeUnit.SECONDS); //在指定的时间内尝试获取指定数量的许可,如果在指定的时间内获取成功,返回true,否则返回false。 semaphore.release(); //释放单个许可 semaphore.release(2); //释放指定数量的许可 int availablePermits = semaphore.availablePermits(); //查询信号量当前可用许可数量
需要特别注意的是,信号量并没有严格要求释放的许可数量必须与已申请的许可数量一致。也就是说多次调用release方法,会使信号量的许可数增加,达到动态扩展许可的效果。例如:初始permits 为1,调用了两次release(),availablePermits会改变为2,这在应对某些需要动态提升并发任务量的需求时特别有用。
2.4 AtomicInteger
AtomicInteger、AtomicLong、AtomicDouble等都是对基本类型的Atomic封装,为基本类型封装线程安全的访问方式。特别适用于需要多线程同时操作一个数值变量,完成累积计数统计等操作的场景。
它们的底层实现原理都基于如下两点:
1、使用volatile关键字保证多线程间对数值更新的实时可见性。
2、使用CAS操作保证操作的原子性。CAS操作会在对值进行更新前,检查该值是否是之前读取到的旧值,如果是,则说明该值目前还没有其他线程更新,不存在并发冲突,可以安全设置为新值,整个检查+设置新值的操作通常由CPU提供单指令完成,保证原子性。显然,CAS可能会失败(遇到并发冲突时),则自动重新读取当前值,不断循环,直至CAS成功。
JDK1.8中为AtomicInteger增加值源码如下:
public final int getAndAddInt(Object var1, long var2, int var4) { int var5; do { var5 = this.getIntVolatile(var1, var2); //读取旧值 } while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4)); //利用CAS操作将旧值设置为增加后的新值 return var5; }
AtomicInteger典型操作如下:
AtomicInteger atomicInteger = new AtomicInteger(1); atomicInteger.compareAndSet(1, 2); //CAS操作,如果当前值等于期望值,则将当前值设置为新值,并返回true表示设置成功,否则返回false表示设置失败。该操作是原子性的。 atomicInteger.getAndIncrement(); //当前值加1,同时返回加1前的旧值 atomicInteger.incrementAndGet(); //当前值加1,同时返回加1后的新值 atomicInteger.getAndDecrement(); //当前值减1,同时返回减1前的旧值 atomicInteger.decrementAndGet(); //当前值减1,同时返回减1后的新值 atomicInteger.getAndAdd(3); //当前值加3,同时返回加3前的旧值 atomicInteger.addAndGet(3); //当前值加3,同时返回加3后的新值
由于Atomic采用无锁化设计,在高并发场景下通常拥有较好的性能表现。
2.5 CountDownLatch
CountDownLatch可以设置一个初始计数,一个线程可以调用await等待计数归零。其他线程可以调用countDown来减小计数。
计数不可被重置,CountDownLatch被设计为只触发一次。
CountDownLatch的典型操作如下:
CountDownLatch countDownLatch = new CountDownLatch(5); //初始化CountDownLatch并设置初始计数值 countDownLatch.countDown(); //将计数值-1 countDownLatch.await(); //等待直至计数值为0 countDownLatch.await(2, TimeUnit.MINUTES); //等待直至计数值为0,或者超时时间达到
我们常常会将一个任务拆分为可独立运行的N个任务,待N个任务都完成后,再继续执行后续任务。这便是CountDownLatch的典型应用场景。
CountDownLatch典型用法1:某一线程在开始运行前等待n个线程执行完毕。将CountDownLatch的计数器初始化为n new CountDownLatch(n)
,每当一个任务线程执行完毕,就将计数器减1 countdownlatch.countDown()
,当计数器的值变为0时,在CountDownLatch上 await()
的线程就会被唤醒。一个典型应用场景就是启动一个服务时,主线程需要等待多个组件加载完毕,之后再继续执行。
CountDownLatch典型用法2:实现多个线程开始执行任务的最大并行性。注意是并行性,不是并发,强调的是多个线程在某一时刻同时开始执行。类似于赛跑,将多个线程放到起点,等待发令枪响,然后同时开跑。做法是初始化一个共享的CountDownLatch(1),将其计数器初始化为1,多个线程在开始执行任务前首先 coundownlatch.await()
,当主线程调用 countDown()
时,计数器变为0,多个线程同时被唤醒。
2.6 CyclicBarrier
CyclicBarrier 的字面意思是可循环使用(Cyclic)的屏障(Barrier)。它要做的事情是,让一组线程到达一个屏障(也可以叫同步点)时被阻塞,直到最后一个线程到达屏障时,屏障才会打开,届时所有被屏障拦截的线程同时开始继续执行。
CyclicBarrier默认的构造方法是CyclicBarrier(int parties),其参数表示屏障拦截的线程数量。每个线程通过调用CyclicBarrier的await方法告诉CyclicBarrier我已经到达了屏障,然后当前线程被阻塞,直至所有要拦截的线程都调用了CyclicBarrier的await后,大家同时解锁。
CyclicBarrier还提供另外一个构造函数CyclicBarrier(int parties, Runnable barrierAction),用于在屏障打开前,先执行barrierAction,方便处理更复杂的业务场景。
2.7 BlockingQueue
队列是解决线程间通信的利器,几乎绝大部分使用wait、notify这类底层线程间通信的编程风格都可以重构为更简单的队列模型,线程间的协作问题可以通过队列中的消息传递来解决。
BlockingQueue(有界阻塞队列)便是最常用的一种队列。
BlockingQueue的典型操作如下:
BlockingQueue<String> blockingQueue = new ArrayBlockingQueue<>(100); //初始队列并设置队列最大长度为100 blockingQueue.put("message"); //往队尾插入新消息,如果队列已满将一直等待队列有可用空间为止 blockingQueue.offer("message"); //往队尾插入新消息,如果队列已满导致无法插入,则直接返回false表示插入失败;如果队列未满,可以成功插入,则返回true表示插入成功 blockingQueue.offer("message", 2, TimeUnit.MINUTES); //普通offer的增强版,可以指定超时时间,如果无法插入先尝试等待指定超时时间,超时时间达到后还无法插入,直接返回false表示插入失败;超时时间达到前可以插入,则成功插入并返回true String message = blockingQueue.take(); //从队头取出最新消息,如果队列为空,没有最新消息,则一直等待直到有最新消息为止 message = blockingQueue.poll(); //从队头取出最新消息,如果队列为空,没有最新消息,则直接返回null message = blockingQueue.poll(2, TimeUnit.MINUTES); //普通poll的增强版,可以指定超时时间,如果没有最新消息先尝试等待指定超时时间。如果超时时间到达前有最新消息,则立即取出最新消息;如果超时时间达到后仍没有最新消息,则立即返回null
2.8 PriorityBlockingQueue
PriorityBlockingQueue(优先级队列)作为阻塞队列的一种特殊形态,是一个带优先级排序功能的无界阻塞队列。
PriorityBlockingQueue的典型操作与BlockingQueue基本一致。除了实现BlockingQueue的基本功能以外,PriorityBlockingQueue额外保证每次从对头取出的元素总是队列中优先级最高的元素。
由于需要比较队列中元素的优先级,所以加入队列的元素必须实现Comparable接口,或者在构建时指定实现了Comparator接口的比较器。两个元素将通过compareTo方法进行比较,小的元素的优先级高。
与BlockingQueue不同的是,PriorityBlockingQueue是一个无界队列,构造PriorityBlockingQueue时可以指定初始容量,但这并不意味着PriorityBlockingQueue是有界的,它会在队列满时自动扩容。所以需要特别注意由于控制逻辑不严谨导致内存溢出的风险。
另外,使用PriorityBlockingQueue的迭代器遍历队列时,你会发现队列元素是乱序的(与插入顺序不同)。事实上PriorityBlockingQueue只保证依次从队头取出元素是按照优先级排序的(参考最小堆的实现);队列也不保证两个相同优先级元素的顺序,他们可能以任意顺序返回。
2.9 DelayQueue
DelayQueue(延时队列)可以认为是PriorityBlockingQueue+元素必须实现Delayed接口的特定组合。
DelayQueue也是一个带优先级功能的无界阻塞队列,典型操作也同PriorityBlockingQueue基本一致。只不过它对优先级的定义整合了延迟场景的特定抽象。队列里存放实现了Delayed接口的元素。Delayed元素实现了getDelay方法,用于获取剩余到期时间,实现了CompareTo方法,用于按照到期时间排序,以便确定优先级。只有存在到期的元素时,才能从DelayQueue中提取元素,该队列的头部是到期最久的元素。
Delayed接口的典型实现方式如下:
public class DelayedElement implements Delayed { private final long deadlineMillis; public DelayedElement(long deadlineMillis) { this.deadlineMillis = deadlineMillis; } @Override public long getDelay(TimeUnit unit) { //计算剩余到期时间,并将剩余到期时间根据传入的时间单位进行换算 return unit.convert(deadlineMillis - System.currentTimeMillis(), TimeUnit.MILLISECONDS); } @Override public int compareTo(Delayed other) { if (other == this) { return 0; } //剩余到期时间少的,优先级更高 long diff = (getDelay(TimeUnit.MILLISECONDS) - other.getDelay(TimeUnit.MILLISECONDS)); return (diff == 0) ? 0 : ((diff < 0) ? -1 : 1); } }
DelayQueue特别适用于离散事件仿真。在离散事件仿真场景下,每个线程模拟一个独立的实体,在各个特定的时间,向其他线程的实例或总控模块发出约定的事件,使用DelayQueue作为事件消息的传递通道,只需根据事件应当发生的时间实现Delayed接口即可,无需每个线程都引入一个计时器去定时触发事件的发生。
2.10 Exchanger
Exchanger可以在两个线程之间交换数据,当线程A调用Exchanger对象的exchange()方法后,他会陷入阻塞状态,直到线程B也调用了exchange()方法,然后以线程安全的方式交换数据,之后线程A和B继续运行。
Exchanger的典型操作如下:
Exchanger<String> exchanger = new Exchanger<>(); String theirMessage = exchanger.exchange("myMessage"); //将自己的消息与对方的消息进行交换,如果对方没有调用exchange方法,我方将一直等待 theirMessage = exchanger.exchange("myMessage", 2, TimeUnit.MINUTES); //带超时功能的exchange方法,避免无限等待,如果超时将抛出TimeoutException
Exchanger的使用频率相对低一些,因为通常涉及多线程编程,都不会刚好只有两个线程争用共享资源。在一些简单的只涉及两个线程间通信的双线程协作场景下,使用Exchanger会让你的编程更加轻松。
三、总结
多线程协作编程,需要注意线程安全问题。使用JDK自带的并发编程组件,可以让多线程编程更加轻松和安全。本文总结了十大JDK中常见的并发编程组件的典型用法和适用场景,希望对大家有所帮助。