1. 并发包概述
在多线程编程时,java.util.concurrent包是非常重要的
按照功能划分:
- locks 锁框架
- atomic 原子类框架
- sync 同步器框架
- collections 集合框架
- executors 执行器框架
早期的JDK版本中,仅仅提供了synchronizd、wait、notify等等比较底层的多线程同步工具,开发人员如果需要开发复杂的多线程应用,通常需要基于JDK提供的这些基础工具进行封装,开发自己的工具类。JDK1.5+后,Doug Lea根据一系列常见的多线程设计模式,设计了JUC并发包,其中java.util.concurrent.locks
包下提供了一系列基础的锁工具,用以对synchronizd、wait、notify等进行补充、增强。
查看官方文档会发现,该包下存在如下接口和类
1.1 Lock接口
可以视为synchronized的增强版,提供了更灵活的功能。该接口提供了限时锁等待、锁中断、锁尝试等功能。synchronized实现的同步代码块,它的锁是自动加的,且当执行完同步代码块或者抛出异常后,锁的释放也是自动的
Lock l = ...;
l.lock();
try {
// access the resource protected by this lock
} finally {
l.unlock();
}
但是Lock锁是需要手动去加锁和释放锁,所以Lock相比于synchronized更加的灵活。且还提供了更多的功能比如说
tryLock()方法会尝试获取锁,如果锁不可用则返回false,如果锁是可以使用的,那么就直接获取锁且返回true,官方代码如下:
Lock lock = ...;
if (lock.tryLock()) {
try {
// manipulate protected state
} finally {
lock.unlock();
}
} else {
// perform alternative actions
}
1.2 Condition接口
Condition可以看做是Obejct类的wait()
、notify()
、notifyAll()
方法的替代品,与Lock配合使用。
当线程执行condition对象的await
方法时,当前线程会立即释放锁,并进入对象的等待区,等待其它线程唤醒或中断。唤醒对应的方法是signal和signalAll
1.3 ReadWriteLock接口
该锁是读写锁,该接口中只有两个方法,readLock()和writeLock(),这两个方法分别返回读锁和写锁。
传统的使用同步代码块等方式的锁,都会造成读与读,写与读的操作是同步的,但是如果需要高频读取,而低频写入,比如订单数据,使用传统的锁就会造成严重的性能问题。
读写锁的作用就是读与读之间是不互斥的,可以并行读取,但是写与读是会同步的。这样就能有效改善高频读取的性能问题
所以读写锁的使用场景
- 高频次的读操作,相对较低频次的写操作;
- 读操作所用时间不会太短。(否则读写锁本身的复杂实现所带来的开销会成为主要消耗成本)
1.4 CAS无锁
我觉得cas需要先写一下,因为并发包中有大量的CAS
如果想实现线程同步,常用的是加锁,而还有一种无锁机制也可以实现线程同步
原理:
数据库有两种锁,悲观锁的原理是每次实现数据库的增删改的时候都进行阻塞,防止数据发生脏读;乐观锁的原理是在数据库更新的时候,用一个version字段来记录版本号,然后通过比较是不是自己要修改的版本号再进行修改。这其中就引出了一种比较替换的思路来实现数据的一致性,事实上,cas也是基于这样的原理。
cas的英文翻译全称是compare and set ,也就是比较替换技术,·它包含三个参数,CAS(V,E,N),其中V(variile)表示欲更新的变量,E(Excepted)表示预期的值,N(New)表示新值,只有当V等于E值的时候吗,才会将V的值设为N,如果V值和E值不同,则说明已经有其它线程对该值做了更新,则当前线程什么都不做,直接返回V值。
举个例子,假如现在有一个变量int a=5;我想要把它更新为6,用cas的话,我有三个参数cas(5,5,6),我们要更新的值是5,找到了a=5,符合V值,预期的值也是5符合,然后就会把N=6更新给a,a的值就会变成6;
cas是以乐观的态度运行的,它总是认为当前的线程可以完成操作,当多个线程同时使用CAS的时候只有一个最终会成功,而其他的都会失败。这种是由欲更新的值做的一个筛选机制,只有符合规则的线程才能顺利执行,而其他线程,均会失败,但是失败的线程并不会被挂起,仅仅是尝试失败,并且允许再次尝试(当然也可以主动放弃)
Atmoicxxx类就是使用的CAS作的同步,主要使用compareAndSet(),传入两个参数,一个参数是期望值,一个是新值,在调这个方法时,会使用你传入的期望值与当时的该值进行比较,如果相同,则说明没有被修改过,就设置新值
具体用法可以查看本博客AtomicReference部分,或者直接查看Atomicxxx类源码
1.5 自旋锁
自旋锁是指当一个线程尝试获取某个锁时,如果该锁已被其他线程占用,就一直循环检测锁是否被释放,而不是进入线程挂起或睡眠状态。
一般是和无锁配合使用
private AtomicReference<Thread> ref = new AtomicReference<>();
。。。
while(!ref.compareAndSet(null, currentThread)){
//当ref为null的时候compareAndSet返回true,反之为false
//通过循环不断的自旋判断锁是否被其他线程持有
}
当在比较新值和旧值前需要对新值进行操作,如下,不要使用while,有可能会出现死锁,因为在你只是循环了比较的过程,没有循环获取当前最新的值,那么就会导致一直比较失败
private AtomicReference<User> userRef;
。。。
User referenceOld = userRef.get();
int newId = referenceOld.getId()+1;
User referenceNew = new User(newId);
while (!userRef.compareAndSet(referenceOld, referenceNew)) {
}
改造成如下方式
for (; ; ) { //自旋操作
User referenceOld = userRef.get();
int newId = referenceOld.getId()+1;
User referenceNew = new User(newId);
if(userRef.compareAndSet(referenceOld, referenceNew)){
break;
}
}
1.6 AQS
AQS原理
AQS:AbstractQuenedSynchronizer抽象的队列式同步器。是除了java自带的synchronized关键字之外的锁机制。
AQS的核心思想是,如果被请求的共享资源空闲,则将当前请求资源的线程设置为有效的工作线程,并将共享资源设置为锁定状态,如果被请求的共享资源被占用,那么就需要一套线程阻塞等待以及被唤醒时锁分配的机制,这个机制AQS是用CLH队列锁实现的,即将暂时获取不到锁的线程加入到队列中。
CLH(Craig,Landin,and Hagersten)队列是一个虚拟的双向队列,虚拟的双向队列即不存在队列实例,仅存在节点之间的关联关系。
AQS是将每一条请求共享资源的线程封装成一个CLH锁队列的一个结点(Node),来实现锁的分配。
用大白话来说,AQS就是基于CLH队列,用volatile修饰共享变量state,线程通过CAS去改变状态符,成功则获取锁成功,失败则进入等待队列,等待被唤醒。
**注意:AQS是自旋锁:**在等待唤醒的时候,经常会使用自旋(while(!cas()))的方式,不停地尝试获取锁,直到被其他线程获取成功
实现了AQS的锁有:自旋锁、互斥锁、读锁写锁、条件产量、信号量、栅栏都是AQS的衍生物
参考:AQS详解(面试)_木霖森77的博客-CSDN博客_aqs 面试
多线程框架一般都是基于AQS的acquire和release方法来实现的,具体实现方式包括以下步骤:
-
定义同步器的状态变量state,表示同步资源的状态。
-
实现acquire方法,用于获取同步资源。acquire方法首先需要通过CAS操作来修改state的值,如果成功则表示获取同步资源成功,否则将当前线程加入FIFO双向队列中,并进入阻塞状态,等待获取同步资源。
-
实现release方法,用于释放同步资源。release方法首先需要通过CAS操作来修改state的值,如果成功则表示释放同步资源成功,否则抛出异常。然后从FIFO双向队列中取出一个等待线程,并唤醒它,使其尝试重新获取同步资源。
-
根据具体的需求,可以实现tryAcquire、tryRelease等方法,用于非阻塞式地获取和释放同步资源。
-
利用AQS提供的同步状态,可以实现各种同步器,比如互斥锁、读写锁、信号量等。
总的来说,AQS提供了一种基础的同步机制,通过定义同步状态、acquire和release等方法,可以方便地实现各种同步器,从而构建一个高效、可靠的多线程框架。
1.7 双重检测锁
最常用的地方就在单例模式
一般不考虑线程安全是这样写的:
public class SingleDemo {
private static SingleDemo single;
private SingleDemo() {}
public static SingleDemo getInstance () {
if (null == single) {
single = new SingleDemo();
}
return single;
}
这在多线程环境下肯定是有问题的,会new出不止一个单例对象。为了避免这个问题,肯定就是加锁嘛,把getInstance方法用锁,锁起来,但是这样做的话性能就太低了,每次都需要获取锁,所以我们就考虑在外面再加一层判断,最后就变成了如下:
public class Single {
/**
* volatile是避免Single对象在创建时进行重排序,导致出现线程安全问题
* 对象的创建分解成以下三个步骤:
* 1. 分配内存空间
* 2. 初始化对象
* 3. 将对象指向刚分配的内存空间
* 但是有些编译器为了性能的原因,可能会将第二步和第三步进行重排序,
* 这样就导致了
* 1.其实对象还没创建完成,只是有了引用,然后就释放了锁
* 2.释放锁后,其他线程就能进来了。
* 造成的结果就是对象还没new完,其他线程又在开始比较null == single,那此时结果肯定是true,然后又会创建一个对象
*/
private volatile static Single single;
private Single() {
}
public static Single getInstance () {
if (null == single) {
synchronized (Single.class){
if (null == single) {
single = new Single();
}
}
}
return single;
}
}
双重检测锁经常用在大量并发下,函数中有需要进行判断的业务场景。
比如一般读取数据,是先从redis中读取数据,如果redis中没有再从数据库中查找,数据库查出来后再写入redis,以便之后的请求可以直接从redis中查。
那在高并发的情况下,第一批进入此方法的线程基本上都会绕过redis而使用数据库查找数据,那肯定是不行的,我们必须保证,只能有一个线程能够读到数据库,其他线程先等着。如果给整个方法加锁,那肯定性能会非常差。这种情况下,使用双重检测锁是非常好的
2 Lock锁
2.1 ReentrantLock
2.1.1 重入锁
重入锁,什么是重入锁?从字面意思看就是可反复进入的锁,但仅限于当前线程。
可重入锁指的是一个线程在执行一个带锁的方法,该方法中又调用了另一个需要相同锁的方法,则该线程可以直接执行调用的方法,而无需重新获得锁,递归调用,这样的锁就叫做可重入锁。
ReentrantLock和synchronized都是可重入锁
如下代码,线程在run方法中获取lock锁,并且run方法中又调用了set方法,set方法也需要lock锁。
如果ReentrantLock不是重入锁,那么在执行set方法时必定会出现死锁,因为锁已经在run方法中被获取了,set方法里面获取不到锁了。所以重入锁可以避免这种情况下的死锁
还有一点就是一个lock()对应一个unlock(),必须成对出现,否则会释放不了锁
public class ReentrantLockDemo {
public static void main(String[] args) {
Lock lock = new ReentrantLock();
LockDemo d1 = new LockDemo(lock);
LockDemo d2= new LockDemo(lock);
d1.start();
d2.start();
}
}
class LockDemo extends Thread{
private Lock lock ;
public LockDemo (Lock lock) {
this.lock = lock;
}
@Override
public void run() {
while (true) {
lock.lock();
set();
lock.unlock();
}
}
public void set () {
lock.lock();
System.out.println(Thread.currentThread().getName() + "设置中");
lock.unlock();
}
}
2.1.2 公平性
ReentrantLock类的其中一个构造器提供了指定公平策略 / 非公平策略的功能,默认为非公平策略。
ReentrantLock(boolean fair)
公平策略:在多个线程争用锁的情况下,公平策略倾向于将访问权授予等待时间最长的线程。也就是说,相当于有一个线程等待队列,先进入等待队列的线程后续会先获得锁,这样按照“先来后到”的原则,对于每一个等待线程都是公平的
非公平策略:在多个线程争用锁的情况下,能够最终获得锁的线程是随机的(由底层OS调度)。
一般情况下,使用公平策略的程序在多线程访问时,总体吞吐量(即速度很慢,常常极其慢)比较低,因为此时在线程调度上面的开销比较大。
ReentrantLock通过内部类实现了AQS框架
原理可看:Java多线程进阶(七)—— J.U.C之locks框架:AQS独占功能剖析(2) - 透彻理解Java并发编程 - SegmentFault 思否
2.2 ReentrantReadWriteLock
ReentrantReadWriteLock类,是一种读写锁,它是ReadWriteLock接口的直接实现,该类在内部实现了具有独占锁特点的写锁,以及具有共享锁特点的读锁,和ReentrantLock一样,ReentrantReadWriteLock类也是通过定义内部类实现AQS框架的API来实现独占/共享的功能。它其中有两把锁,读锁和写锁。
类的结构:需要关注的就是它其中的两个静态类
/** Inner class providing readlock */
private final ReentrantReadWriteLock.ReadLock readerLock;
/** Inner class providing writelock */
private final ReentrantReadWriteLock.WriteLock writerLock;
一个读锁,一个写锁,分别使用readLock()和writeLock()方法获取到锁
它具有以下特点:
2.2.1 支持公平/非公平策略
与ReentrantLock类一样,ReentrantReadWriteLock对象在构造时,可以传入参数指定是公平锁还是非公平锁。
2.2.2 支持锁重入
- 同一读线程在获取了读锁后还可以获取读锁;但是在获取了读锁后不能获取写锁(实践证明,会处于堵塞状态)
- 同一写线程在获取了写锁之后既可以再次获取写锁又可以获取读锁;
2.2.3.支持锁降级
先获取写锁,然后获取读锁,最后释放写锁,这样写锁就降级成了读锁。但是,读锁不能升级到写锁。简言之,就是:写锁可以降级成读锁,读锁不能升级成写锁。大致分别以下6个步骤:
1. writeLock.lock();
2. //TODO 更新数据
3. readLock.lock();
4. writeLock.unlock();
5. // TODO 读取操作
6. readLock.unlock();
如果没有锁降级,即3 4位置互换,那么就有可能出现数据读取问题:
假设有两个线程:线程A和线程B,且3 4位置互换
当A线程在第4步释放了写锁后,正想要获取读锁,但是此时有可能B线程获取到了写锁并修改了数据,而线程A无法感知线程B修改了数据,那么线程A在第5步执行读取操作时,使用的就是旧数据了。就造成了数据读取问题。
那如果是锁降级的机制,我同时拥有读写锁,就算我释放了写锁,其他线程依然获取不了写锁,必须等我读锁也释放出去,其他线程才可以获取写锁。这样我就能一直使用最新的数据。
问题:读锁和写锁不是排斥的吗?为什么一个线程既能获取读锁又能获取写锁?
答:读锁和写锁排斥那是针对不同的线程,如果是一个线程的话,是可以同时拥有的。但是也是有顺序的,必须是先获取写锁再获取读锁。如果先获取读锁,再获取写锁,就会有问题。
2.2.4 Condition条件支持
ReentrantReadWriteLock的内部读锁类、写锁类实现了Lock接口,所以可以通过newCondition()
方法获取Condition对象。但是这里要注意,读锁是没法获取Condition对象的,读锁调用newCondition()
方法会直接抛出UnsupportedOperationException
。
我们知道,condition的作用其实是对Object类的wait()
和notify()
的增强,是为了让线程在指定对象上等待,是一种线程之间进行协调的工具。当线程调用condition对象的await
方法时,必须拿到和这个condition对象关联的锁。由于线程对读锁的访问是不受限制的(在写锁未被占用的情况下),那么即使拿到了和读锁关联的condition对象也是没有意义的,因为读线程之间不需要进行协调。
2.2.5 使用案例
public class Cache {
static private volatile Map<String, Object> map = new HashMap<>();
static ReentrantReadWriteLock rwl = new ReentrantReadWriteLock();
// 读锁
static Lock r = rwl.readLock();
// 写锁
static Lock w = rwl.writeLock();
/**
* 写
*/
static public Object put(String key, Object value) {
try {
w.lock();
System.out.println("正在写入 key:" + key + ",value:" + value + "开始.....");
Object oj = map.put(key, value);
System.out.println("正在写入 key:" + key + ",value:" + value + "结束.....");
return oj;
} catch (Exception e) {
} finally {
w.unlock();
}
return value;
}
/**
* 读
*/
static public void get(String key) {
try {
r.lock();
System.out.println("正在读取 key:" + key + "开始");
Object value = map.get(key);
System.out.println("正在读取 key:" + key + ",value:" + value + "结束.....");
} catch (Exception e) {
} finally {
r.unlock();
}
}
public static void main(String[] args) {
new Thread(new Runnable() {
@Override
public void run() {
for (int i = 0; i < 10; i++) {
Cache.put(i + "", i + "");
}
}
}).start();
new Thread(new Runnable() {
@Override
public void run() {
for (int i = 0; i < 10; i++) {
Cache.put(i + "", i + "");
}
}
}).start();
new Thread(new Runnable() {
@Override
public void run() {
for (int i = 0; i < 10; i++) {
Cache.get(i + "");
}
}
}).start();
}
}
3. Atomic原子类
为什么使用原子类?
我们拿数据自增操作来说,自增操作其实是分了3步的,包括读取变量的原始值、进行加1操作、写入工作内存。
拿如果此时我们使用多线程对同一数据进行自增操作,那么就势必会出现数据混乱的情况。而原子类中就封装了一些操作,使得这些操作成为原子性操作,比如自增操作
3.1 AtomicInteger
public class VolatileNoAtomic extends Thread {
// 需要10个线程同时共享count static修饰关键字, 存放在静态区, 只会存放一次,所有的线程中都共享,
private static AtomicInteger count = new AtomicInteger(0);
@Override
public void run() {
for (int i = 0; i < 1000; i++) {
count.incrementAndGet();
}
System.out.println(getName()+","+count.get());
}
public static void main(String[] args) {
// 创建10个线程
VolatileNoAtomic[] volatileNoAtomicList=new VolatileNoAtomic[10];
for (int i = 0; i < volatileNoAtomicList.length; i++) {
volatileNoAtomicList[i]=new VolatileNoAtomic();
}
for (int i = 0; i < volatileNoAtomicList.length; i++) {
volatileNoAtomicList[i].start();
}
}
}
执行结果会发现,无论这么执行,最后一次输出都是10000。因为incrementAndGet方法使得自增成为了原子性操作。那该操作一共是被执行了10000次,所以结果是10000。incrementAndGet方法内部调用了Unsafe类的getAndAddInt方法,以原子方式将value值增加1,然后返回增加前的原始值。
但如果不用原子性操作,那最后结果不会是10000的,因为有可能A线程在自增过程中,其他线程拿了旧数据进行自增。
与AtomicInteger类似的原子类还有AtomicBoolean和AtomicLong,底层都是通过Unsafe类做CAS操作,来原子的更新状态值。
3.2 AtomicReference
AtomicReference的引入是为了可以用一种类似乐观锁的方式操作共享资源,在某些情景下以提升性能。
我们知道,当多个线程同时访问共享资源时,一般需要以加锁的方式控制并发,如使用lock:这种方式属于对共享资源加悲观锁
而AtomicReference提供了以无锁方式访问共享资源的能力,即通过改变版本号的方式进行同步操作(乐观锁)
3.2.1 错误实例
先看个线程不安全的情况:3个线程同时对一个对象的id属性进行加1的操作
public class AtomicReferenceDemo {
public static void main(String[] args) {
User user = new User(1);
Oper oper = new Oper(user);
Thread t1 = new Thread(oper);
Thread t2 = new Thread(oper);
Thread t3 = new Thread(oper);
t1.start();
t2.start();
t3.start();
}
}
class Oper implements Runnable {
private User user;
public Oper(User user) {
this.user = user;
}
@Override
public void run() {
for (int i=0;i< 1000;i++) {
user.setId(user.getId()+1);
System.out.println(Thread.currentThread().getName() + ":" +user.getId());
}
}
}
class User {
private Integer id;
public User (Integer id) {
this.id = id;
}
public Integer getId() {
return id;
}
public void setId(Integer id) {
this.id = id;
}
}
结果最后一个输出是3001,显然是不对的
3.2.2 使用AtomicReference操作解决线程安全问题
public class AtomicReferenceDemo {
public static void main(String[] args) {
User user = new User(0);
AtomicReference<User> userRef = new AtomicReference<>(user);
Oper oper = new Oper(userRef);
Thread t1 = new Thread(oper);
Thread t2 = new Thread(oper);
Thread t3 = new Thread(oper);
// 让线程顺序执行,这样最后打印的就是最后一个值了
t1.start();
t2.start();
t3.start();
try {
t1.join();
t2.join();
t3.join();
}catch (Exception e){
}
System.out.println(userRef.get().getId());
}
}
class Oper implements Runnable {
private AtomicReference<User> userRef;
public Oper(AtomicReference<User> userRef) {
this.userRef = userRef;
}
@Override
public void run() {
for (int i=0;i< 100;i++) {
//必须自旋操作,否则,当compareAndSet返回false时,就会导致此次循环无作为(我们理想是让每次的外层循环都能对user对象的id加1),从而最后打印时就会小于300
for (; ; ) { //自旋操作
User referenceOld = userRef.get();
int newId = referenceOld.getId()+1;
User referenceNew = new User(newId);
if(userRef.compareAndSet(referenceOld, referenceNew)){
break;
}
}
}
}
}
class User {
private Integer id;
public User (Integer id) {
this.id = id;
}
public Integer getId() {
return id;
}
}
该代码中有两点注意:
1.使用join,让线程有序执行,这样我们最后打印的才能是最后一次更新的数据
2. 必须自旋操作,否则,当compareAndSet返回false时,就会导致此次循环无作为,从而导致最后打印时会小于300
我们理想的是让每次的外层循环(for (int i=0;i< 100;i++) )都能对user对象的id加1,使用自旋操作的话,如果compareAndSet返回false(对象已被操作过)也会一直循环直到能操作到当时获取到的最新数据。
3.2.3 compareAndSet方法的作用
public final boolean compareAndSet(V expect, V update) {
return unsafe.compareAndSwapObject(this, valueOffset, expect, update);
}
compareAndSet方法会将入参的expect变量所指向的对象和AtomicReference中的引用对象进行比较,如果两者指向同一个对象,则将AtomicReference中的引用对象重新置为update,修改成功返回true,失败则返回false。也就是说,AtomicReference其实是比较对象的引用。
3.2.3 AtomicStampedReference
CAS操作(即上面说的无锁机制)可能存在ABA的问题,就是说:
假如一个值原来是A,变成了B,又变成了A,那么CAS检查时会发现它的值没有发生变化,但是实际上却变化了。
一般来讲这并不是什么问题,比如数值运算,线程其实根本不关心变量中途如何变化,只要最终的状态和预期值一样即可。
但是,有些操作会依赖于对象的变化过程,此时的解决思路一般就是使用版本号。在变量前面追加上版本号,每次变量更新的时候把版本号加一,那么A-B-A 就会变成1A - 2B - 3A。
而AtomicStampedReference类就可以实现这样的方式。
使用方式:不仅需要比较对象的引用地址,还会比较版本号
AtomicStampedReference<Foo> asr = new AtomicStampedReference<>(null,0); // 创建AtomicStampedReference对象,持有Foo对象的引用,初始为null,版本为0
int[] stamp=new int[1];
Foo oldRef = asr.get(stamp); // 调用get方法获取引用对象和对应的版本号
int oldStamp=stamp[0]; // stamp[0]保存版本号
asr.compareAndSet(oldRef, null, oldStamp, oldStamp + 1) //尝试以CAS方式更新引用对象,并将版本号+1
3.2.4 AtomicMarkableReference
和AtomicStampedReference类似,但是有时候我们不需要关注对象到底被修改过几次,只是想看它是否是修改过。
AtomicMarkableReference和AtomicStampedReference的唯一区别就是不再用int标识引用,而是使用boolean变量——表示引用变量是否被更改过。该类对于那些不关心引用变化过程,只关心引用变量是否变化过的应用会更加友好。
3.5 Atomic数组
Atomic数组,顾名思义,就是能以原子的方式,操作数组中的元素。
JDK提供了三种类型的原子数组:AtomicIntegerArray
、AtomicLongArray
、AtomicReferenceArray
。
这三种类型大同小异,AtomicIntegerArray对应AtomicInteger,AtomicLongArray对应AtomicLong,AtomicReferenceArray对应AtomicReference。
原子数组并不是说可以让线程以原子方式一次性地操作数组中所有元素的数组,而是指对于数组中的每个元素,可以以原子方式进行操作
说得简单点,原子数组类型其实可以看成原子类型组成的数组。
比如:
AtomicIntegerArray array = new AtomicIntegerArray(10);
array.getAndIncrement(0); // 将第0个元素原子地增加1
等同于
AtomicInteger[] array = new AtomicInteger[10];
array[0].getAndIncrement(); // 将第0个元素原子地增加1
3.6 FieldUpdater
所谓AtomicXXXFieldUpdater,就是可以以一种线程安全的方式操作非线程安全对象的某些字段.
有3中:AtomicIntegerFieldUpdater
、AtomicLongFieldUpdater
、AtomicReferenceFieldUpdater
假设有一个公司账户Account,100个人同时往里面存钱1块钱,那么正常情况下,最终账户的总金额应该是100。
如果不使用同步代码块或者AtmoicInteger,结果往往是不正确的。那除了使用这两种方式,还可以使AtomicXXXFieldUpdater类
代码展示:
public class FieldUpdateDemo {
public static void main(String[] args) throws InterruptedException {
Account account = new Account(0);
List<Thread> list = new ArrayList<>();
for (int i = 0; i < 100; i++) {
Thread t = new Thread(new Task1(account));
list.add(t);
t.start();
}
for (Thread t : list) {
t.join();
}
System.out.println(account.getMoney());
}
}
class Task1 implements Runnable {
private Account account;
Task1(Account account) {
this.account = account;
}
@Override
public void run() {
account.increMoney();
}
}
class Account {
private volatile int money;
private static final AtomicIntegerFieldUpdater<Account> updater = AtomicIntegerFieldUpdater.newUpdater(Account.class, "money"); // 引入AtomicIntegerFieldUpdater
Account(int initial) {
this.money = initial;
}
public void increMoney() {
// 通过AtomicIntegerFieldUpdater操作字段
updater.incrementAndGet(this);
}
public int getMoney() {
return money;
}
}
再来看概念:以一种线程安全的方式操作非线程安全对象的某些字段
非线程安全对象是指Account,某些字段指的是money。
可以看出该代码是符合代码的开闭原则的,我们只需要添加一个AtomicIntegerFieldUpdater成员变量,然后指定哪个字段是需要原子性的。简单的新增和修改代码就能实现线程并发的安全性。
一般用在对一些遗留代码的改造上
3.7 LongAdder
LongAddr是1.8提出的
我们知道,AtomicLong是利用了底层的CAS操作来提供并发性的,比如addAndGet方法:它的逻辑是采用自旋的方式不断更新目标值,直到更新成功。
在并发量较低的环境下,线程冲突的概率比较小,自旋的次数不会很多。但是,高并发环境下,N个线程同时进行自旋操作,会出现大量失败并不断自旋的情况,此时AtomicLong的自旋会成为瓶颈。这就是LongAdder引入的初衷——解决高并发环境下AtomicLong的自旋瓶颈问题。
AtomicLong中有个内部变量value保存着实际的long值,所有的操作都是针对该变量进行。也就是说,高并发环境下,value变量其实是一个热点,也就是N个线程竞争一个热点。
LongAdder的基本思路就是分散热点,将value值分散到一个数组中,不同线程会命中到数组的不同槽中,各个线程只对自己槽中的那个值进行CAS操作,这样热点就被分散了,冲突的概率就小很多。如果要获取真正的long值,只要将各个槽中的变量值累加返回。这种做法和ConcurrentHashMap中的“分段锁”思路是类似的
其用法和AtmoicLong类似,不过多了一些api,比如可直接返回值等
4. sync 同步器框架
这里的jsync同步器框架,是指java.util.concurrent
包下一些辅助同步器类,每个类都有自己适合的使用场景:
同步器名称 | 作用 |
---|---|
CountDownLatch | 倒数计数器,构造时设定计数值,当计数值归零后,所有阻塞线程恢复执行;其内部实现了AQS框架 |
CyclicBarrier | 循环栅栏,构造时设定等待线程数,当所有线程都到达栅栏后,栅栏放行;其内部通过ReentrantLock和Condition实现同步 |
Semaphore | 信号量,类似于“令牌”,用于控制共享资源的访问数量;其内部实现了AQS框架 |
Exchanger | 交换器,类似于双向栅栏,用于线程之间的配对和数据交换;其内部根据并发情况有“单槽交换”和“多槽交换”之分 |
Phaser | 多阶段栅栏,相当于CyclicBarrier的升级版,可用于分阶段任务的并发控制执行;其内部比较复杂,支持树形结构,以减少并发带来的竞争 |
4.1 CountDownLatch
CountDownLatch
是一个辅助同步器类,用来作计数使用,它的作用有点类似于生活中的倒数计数器,先设定一个计数初始值,当计数降到0时,将会触发一些事件,如火箭的倒数计时。
初始计数值在构造CountDownLatch对象时传入,每调用一次 countDown() 方法,计数值就会减1。
线程可以调用CountDownLatch的await方法进入阻塞,当计数值降到0时,所有之前调用await阻塞的线程都会释放。
ContDownLatch一般有以下几种用法:
4.1.1 作为一个开关
将初始计数值为1的 CountDownLatch 作为一个的开关或入口:
在调用 countDown() 的线程打开入口前,所有调用 await 的线程都一直在入口处等待。
public class Driver {
private static final int N = 10;
public static void main() throws InterruptedException {
CountDownLatch switcher = new CountDownLatch(1);
for (int i = 0; i < N; ++i) {
new Thread(new Worker(switcher)).start();
}
doSomething();
switcher.countDown(); // 主线程开启开关
}
public static void doSomething() {
}
}
class Worker implements Runnable {
private final CountDownLatch startSignal;
Worker(CountDownLatch startSignal) {
this.startSignal = startSignal;
}
public void run() {
try {
startSignal.await(); //所有执行线程在此处等待开关开启
doWork();
} catch (InterruptedException ex) {
}
}
void doWork() { ...}
}
4.1.2 作为一个完成信号
将初始计数值为N的 CountDownLatch作为一个完成信号点:使某个线程在其它N个线程完成某项操作之前一直等待。
public class Driver {
private static final int N = 10;
public static void main() throws InterruptedException {
CountDownLatch compsignal = new CountDownLatch(N);
for (int i = 0; i < N; ++i) {
new Thread(new Worker(compsignal)).start();
}
compsignal.await(); // 主线程等待其它N个线程完成
doSomething();
}
public static void doSomething() {
}
}
class Worker implements Runnable {
private final CountDownLatch compSignal;
Worker(CountDownLatch compSignal) {
this.compSignal = compSignal;
}
public void run() {
try {
doWork();
compSignal.countDown(); //每个线程做完自己的事情后,就将计数器减去1
} catch (InterruptedException ex) {
}
}
void doWork() { ...}
}
4.2 CyclicBarrier
CyclicBarrier
可以认为是一个栅栏,栅栏的作用是什么?就是阻挡前行。
顾名思义,CyclicBarrier是一个可以循环使用的栅栏,它做的事情就是:
让线程到达栅栏时被阻塞(调用await方法),直到到达栅栏的线程数满足指定数量要求时,栅栏才会打开放行。
这其实有点像军训报数,报数总人数满足教官认为的总数时,教官才会安排后面的训练。
该类有两个构造函数:
public CyclicBarrier(int parties, Runnable barrierAction) {
}
public CyclicBarrier(int parties) {
this(parties, null);
}
参数parties
就是之前说的需要满足的计数总数。barrierAction表示当最后一个线程到达栅栏时,后续立即要执行的任务。
4.2.1 示例
假设现在有这样一个场景:
5个运动员准备跑步比赛,运动员在赛跑前会准备一段时间,当裁判发现所有运动员准备完毕后,就举起发令枪,比赛开始。
这里的起跑线就是屏障,运动员必须在起跑线等待其他运动员准备完毕。
public class CyclicBarrierTest {
public static void main(String[] args) {
int N = 5; // 运动员数
CyclicBarrier cb = new CyclicBarrier(N, new Runnable() {
@Override
public void run() {
System.out.println("****** 所有运动员已准备完毕,发令枪:跑!******");
}
});
for (int i = 0; i < N; i++) {
Thread t = new Thread(new PrepareWork(cb), "运动员[" + i + "]");
t.start();
}
}
private static class PrepareWork implements Runnable {
private CyclicBarrier cb;
PrepareWork(CyclicBarrier cb) {
this.cb = cb;
}
@Override
public void run() {
try {
Thread.sleep(500);
System.out.println(Thread.currentThread().getName() + ": 准备完成");
cb.await(); // 在栅栏等待
} catch (InterruptedException e) {
e.printStackTrace();
} catch (BrokenBarrierException e) {
e.printStackTrace();
}
}
}
}
执行结果:
运动员[3]: 准备完成
运动员[1]: 准备完成
运动员[0]: 准备完成
运动员[2]: 准备完成
运动员[4]: 准备完成
****** 所有运动员已准备完毕,发令枪:跑!******
4.2.2 CyclicBarrier对异常的处理
我们知道,线程在阻塞过程中,可能被中断,那么既然CyclicBarrier放行的条件是等待的线程数达到指定数目,万一线程被中断导致最终的等待线程数达不到栅栏的要求怎么办?
CyclicBarrier一定有考虑到这种异常情况,不然其它所有等待线程都会无限制地等待下去。那么CyclicBarrier是如何处理的呢?
通过查看其await方法可以看到这个方法抛出了两个异常:除了抛出InterruptedException异常外,还会抛出BrokenBarrierException。
BrokenBarrierException表示当前的CyclicBarrier已经损坏了,可能等不到所有线程都到达栅栏了,所以已经在等待的线程也没必要再等了,可以散伙了。
出现以下几种情况之一时,当前等待线程会抛出BrokenBarrierException异常:
- 其它某个正在await等待的线程被中断了
- 其它某个正在await等待的线程超时了
- 某个线程重置了CyclicBarrier(调用了reset方法)
另外,只要正在Barrier上等待的任一线程抛出了异常,那么Barrier就会认为肯定是凑不齐所有线程了,就会将栅栏置为损坏(Broken)状态,并传播BrokenBarrierException给其它所有正在等待(await)的线程。
public class CyclicBarrierTest {
public static void main(String[] args) throws InterruptedException {
int N = 5; // 运动员数
CyclicBarrier cb = new CyclicBarrier(N, new Runnable() {
@Override
public void run() {
System.out.println("****** 所有运动员已准备完毕,发令枪:跑!******");
}
});
List<Thread> list = new ArrayList<>();
for (int i = 0; i < N; i++) {
Thread t = new Thread(new PrepareWork(cb), "运动员[" + i + "]");
list.add(t);
t.start();
if (i == 3) {
t.interrupt(); // 运动员[3]置中断标志位
}
}
Thread.sleep(2000);
System.out.println("Barrier是否损坏:" + cb.isBroken());
}
private static class PrepareWork implements Runnable {
private CyclicBarrier cb;
PrepareWork(CyclicBarrier cb) {
this.cb = cb;
}
@Override
public void run() {
try {
System.out.println(Thread.currentThread().getName() + ": 准备完成");
cb.await();
} catch (InterruptedException e) {
System.out.println(Thread.currentThread().getName() + ": 被中断");
} catch (BrokenBarrierException e) {
System.out.println(Thread.currentThread().getName() + ": 抛出BrokenBarrierException");
}
}
}
}
结果:
运动员[0]: 准备完成
运动员[2]: 准备完成
运动员[1]: 准备完成
运动员[3]: 准备完成
运动员[3]: 被中断
运动员[4]: 准备完成
运动员[4]: 抛出BrokenBarrierException
运动员[0]: 抛出BrokenBarrierException
运动员[1]: 抛出BrokenBarrierException
运动员[2]: 抛出BrokenBarrierException
Barrier是否损坏:true
4.3 Semaphore
Semaphore
,又名信号量,这个类的作用有点类似于“许可证”。有时,我们因为一些原因需要控制同时访问共享资源的最大线程数量,比如出于系统性能的考虑需要限流,或者共享资源是稀缺资源,我们需要有一种办法能够协调各个线程,以保证合理的使用公共资源。Semaphore维护了一个许可集,其实就是一定数量的“许可证”。当有线程想要访问共享资源时,需要先获取(acquire)的许可;如果许可不够了,线程需要一直等待,直到许可可用。当线程使用完共享资源后,可以归还(release)许可,以供其它需要的线程使用
4.3.1 实例
模拟10个人上厕所,但是只有3个坑位
class Parent extends Thread {
Semaphore wc;
String name;
public Parent(Semaphore wc, String name) {
this.wc = wc;
this.name = name;
}
@Override
public void run() {
// 获取到资源,减去1
int availablePermits = wc.availablePermits();
if (availablePermits > 0) {
System.out.println(name + "天主我也,我有茅坑啦!!");
} else {
System.out.println(name + "怎么没有茅坑了.....");
}
try {
wc.acquire();
System.out.println(name+"终于能上厕所了,爽!!!");
Thread.sleep(new Random().nextInt(1000)); // 模拟上厕所时间。
System.out.println(name+"厕所终于上完啦!");
} catch (InterruptedException e) {
}finally {
//释放茅坑
wc.release();
}
}
}
class Test004 {
public static void main(String[] args) throws InterruptedException {
Semaphore semaphore = new Semaphore(3);
for (int i = 1; i <=10; i++) {
new Parent(semaphore,"第"+i+"个"+",").start();
}
}
}
主要使用4个方法
Semaphore semaphore = new Semaphore(5);初始化可用的许可证数量
wc.availablePermits(): 获取目前可用的许可证数量
wc.acquire(): 尝试获取1个许可证,如果获取到了,那么直接向下运行,如果没有获取到,那么就堵塞,知道有其他线程释放许可,或者线程被interrupt
wc.release():释放许可证
总结:许可数 ≤ 0代表共享资源不可用。许可数 > 0,代表共享资源可用,且多个线程可以同时访问共享资源。
4.4 Exchanger
xchanger有点类似于CyclicBarrier
,我们知道CyclicBarrier是一个栅栏,到达栅栏的线程需要等待其它一定数量的线程到达后,才能通过栅栏。
Exchanger可以看成是一个双向栅栏,如下图:
Thread1线程到达栅栏后,会首先观察有没其它线程已经到达栅栏,如果没有就会等待,如果已经有其它线程(Thread2)已经到达了,就会以成对的方式交换各自携带的信息,因此Exchanger非常适合用于两个线程之间的数据交换。
4.4.1 示例
假设现在有1个生产者,1个消费者,如果要实现生产者-消费者模式,一般的思路是利用队列作为一个消息队列,生产者不断生产消息,然后入队;消费者不断从消息队列中取消息进行消费。如果队列满了,生产者等待,如果队列空了,消费者等待。那我们使用Exchanger来实现该模式
生产者:
public class Producer implements Runnable {
private final Exchanger<Message> exchanger;
public Producer(Exchanger<Message> exchanger) {
this.exchanger = exchanger;
}
@Override
public void run() {
Message message = new Message(null);
for (int i = 0; i < 3; i++) {
try {
Thread.sleep(1000);
message.setV(String.valueOf(i));
System.out.println(Thread.currentThread().getName() + ": 生产了数据[" + i + "]");
message = exchanger.exchange(message);
System.out.println(Thread.currentThread().getName() + ": 交换得到数据[" + String.valueOf(message.getV()) + "]");
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
消费者:
public class Consumer implements Runnable {
private final Exchanger<Message> exchanger;
public Consumer(Exchanger<Message> exchanger) {
this.exchanger = exchanger;
}
@Override
public void run() {
Message msg = new Message(null);
while (true) {
try {
Thread.sleep(1000);
msg = exchanger.exchange(msg);
System.out.println(Thread.currentThread().getName() + ": 消费了数据[" + msg.getV() + "]");
msg.setV(null);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
main:
public class Main {
public static void main(String[] args) {
Exchanger<Message> exchanger = new Exchanger<>();
Thread t1 = new Thread(new Consumer(exchanger), "消费者-t1");
Thread t2 = new Thread(new Producer(exchanger), "生产者-t2");
t1.start();
t2.start();
}
}
结果:
生产者-t2: 生产了数据[0]
生产者-t2: 交换得到数据[null]
消费者-t1: 消费了数据[0]
生产者-t2: 生产了数据[1]
消费者-t1: 消费了数据[1]
生产者-t2: 交换得到数据[null]
生产者-t2: 生产了数据[2]
消费者-t1: 消费了数据[2]
生产者-t2: 交换得到数据[null]
上述示例中,生产者生产了3个数据:0、1、2。通过Exchanger与消费者进行交换。可以看到,消费者消费完后会将空的Message交换给生产者。
5. collections 集合框架
5.1 ConcurrentHashMap
我们都知道HashMap是线程不安全的。因为多线程环境下,使用Hashmap进行put操作可能会引起死循环,导致CPU利用率接近100%,所以在并发情况下不能使用HashMap
Hashtable容器使用synchronized来保证线程安全(get和set都上了锁),但在线程竞争激烈的情况下Hashtable的效率非常低下。因为当一个线程访问Hashtable的同步方法时,其他线程访问Hashtable的同步方法时,可能会进入阻塞或轮询状态。如线程1使用put进行添加元素,线程2不但不能使用put方法添加元素,并且也不能使用get方法来获取元素,所以竞争越激烈效率越低。
ConcurrentHashMap的锁分段技术:
HashTable容器在竞争激烈的并发环境下表现出效率低下的原因,是因为所有访问HashTable的线程都必须竞争同一把锁,那假如容器里有多把锁,每一把锁用于锁容器其中一部分数据,那么当多线程访问容器里不同数据段的数据时,线程间就不会存在锁竞争,从而可以有效的提高并发访问效率,这就是ConcurrentHashMap所使用的锁分段技术,首先将数据分成一段一段的存储,然后给每一段数据配一把锁,当一个线程占用锁访问其中一个段数据的时候,其他段的数据也能被其他线程访问。
查看ConcurrentHashMap类的关系:
ConcurrentHashMap是由Segment数组结构和HashEntry数组结构组成。Segment是一种可重入锁ReentrantLock,在ConcurrentHashMap里扮演锁的角色,HashEntry则用于存储键值对数据。一个ConcurrentHashMap里包含一个Segment数组,Segment的结构和HashMap类似,是一种数组和链表结构, 一个Segment里包含一个HashEntry数组,每个HashEntry是一个链表结构的元素, 每个Segment守护者一个HashEntry数组里的元素,当对HashEntry数组的数据进行修改时,必须首先获得它对应的Segment锁。总的结构如下:
JDK1.8的实现已经抛弃了Segment分段锁机制,利用CAS+Synchronized来保证并发更新的安全。数据结构采用:数组+链表+红黑树。
5.2 ConcurrentSkipListMap
我们知道,一般的Map都是无序的,也就是只能通过键的hash值进行定位。JDK为了实现有序的Map,提供了一个SortedMap接口,SortedMap提供了一些根据键范围进行查找的功能,比如返回整个Map中 key最小/大的键、返回某个范围内的子Map视图等等。为了进一步对有序Map进行增强,JDK又引入了NavigableMap接口,该接口进一步扩展了SortedMap的功能,提供了根据指定Key返回最接近项、按升序/降序返回所有键的视图等功能。同时,也提供了一个基于NavigableMap的实现类——TreeMap,TreeMap底层基于红黑树设计,是一种有序的Map。
JDK1.6时,为了对高并发环境下的有序Map提供更好的支持,J.U.C新增了一个ConcurrentNavigableMap接口,而ConcurrentSkipListMap就是它的实现类,ConcurrentSkipListMap可以看成是并发版本的TreeMap,但是和TreeMap不同是,ConcurrentSkipListMap并不是基于红黑树实现的,其底层是一种类似跳表(Skip List)的结构。
5.2.1 Skip List
Skip List(以下简称跳表),是一种类似链表的数据结构,其查询/插入/删除的时间复杂度都是O(logn)
。
在讲Skip List之前,我们先来看下传统的单链表:
上图的单链表中(省去了结点之间的链接),当想查找7、15、46这三个元素时,必须从头指针head开始,遍历整个单链表,其查找复杂度很低,为O(n)
。
来看下Skip List的数据结构是什么样的:
上图是Skip List一种可能的结构,它分了2层,假设我们要查找“15”这个元素,那么整个步骤如下:
- 从头指针head开始,找到第一个结点的最上层,发现其指向的下个结点值为8,小于15,则直接从1结点跳到8结点。
- 8结点最上层指向的下一结点值为18,大于15,则从8结点的下一层开始查找。
- 从8结点的最下层一直向后查找,依次经过10、13,最后找到15结点。
上面就是跳跃表的基本思想了,每个结点不仅仅只包含指向下一个结点的指针,可能还包含很多个其它指向后续结点的指针。并且,一个结点本身可以看成是一个链表(自上向下链接)。这样就可以跳过一些不必要的结点,从而加快查找、删除等操作,这其实是一种“空间换时间”的算法设计思想。
那么一个结点可以包含多少层呢?
层数是根据一种随机算法得到的,为了不让层数过大,还会有一个最大层数MAX_LEVEL限制,随机算法生成的层数不得大于该值。以上就是Skip List的基本思想了,总结起来,有以下几点:
- 跳表由很多层组成;
- 每一层都是一个有序链表;
- 对于每一层的任意结点,不仅有指向下一个结点的指针,也有指向其下一层的指针。
5.3 CopyOnWriteArrayList
ArrayList
是一种“列表”数据机构,其底层是通过数组来实现元素的随机访问。JDK1.5之前,如果想要在并发环境下使用“列表”,一般有以下2种方式:使用Vector类,使用Collections.synchronizedList
返回一个同步代理类。这两种方式都相当于加了一把“全局锁”,访问任何方法都需要首先获取锁。
JDK1.5时,引入了CopyOnWriteArrayList:
大多数业务场景都是一种“读多写少”的情形,CopyOnWriteArrayList就是为适应这种场景而诞生的。
CopyOnWriteArrayList,运用了一种“写时复制”的思想。通俗的理解就是当我们需要修改(增/删/改)列表中的元素时,不直接进行修改,而是先将列表Copy,然后在新的副本上进行修改,修改完成之后,再将引用从原列表指向新列表。
这样做的好处是读/写是不会冲突的,可以并发进行,读操作还是在原列表,写操作在新列表。仅仅当有多个线程同时进行写操作时,才会进行同步。当需要遍历时,仅仅返回一个当前内部数组的快照,也就是说,如果此时有其它线程正在修改元素,并不会在迭代中反映出来,因为修改都是在新数组中进行的
5.4 CopyOnWriteArraySet
和CopyOnWriteArrayList相似,只是它存放数据不能重复
5.5 ConcurrentLinkedQueue
ConcurrentLinkedQueue是JDK1.5时随着J.U.C一起引入的一个支持并发环境的队列。从名字就可以看出来,ConcurrentLinkedQueue底层是基于链表实现的。它是无界线程安全队列。
Queue是一种具有FIFO特点的数据结构(先入先出)
其底层没有利用锁或底层同步原语,而是完全基于自旋+CAS的方式实现了该队列,所以是无锁队列
由于是完全基于无锁算法实现的,所以当出现多个线程同时进行修改队列的操作(比如同时入队),很可能出现CAS修改失败的情况,那么失败的线程会进入下一次自旋,再尝试入队操作,直到成功。所以,在并发量适中的情况下,ConcurrentLinkedQueue一般具有较好的性能。
常用方法:
public class ConcurrentLinkedQueueDemo {
public static void main(String[] args) {
ConcurrentLinkedQueue<Integer> q = new ConcurrentLinkedQueue();
// 往队尾添加元素
q.offer(1);
// 和offer方法一样
q.add(1);
// 从队头取出元素,并删除元素
q.poll();
// 从队头取出元素,不删除元素
q.peek();
// 删除指定元素
q.remove(1);
// 判断列表是否包含某元素
boolean flag = q.contains(1);
System.out.println(q);
}
}
5.6 ConcurrentLinkedDeque
无锁双端队列。
Deque(double-ended queue)是一种双端队列,也就是说可以在任意一端进行“入队”,也可以在任意一端进行“出队”
它和ConcurrentLinkedQueue类似,只是结构不同,多出了一些方法:从队首入队,从队尾插入,队尾出队等
5.7 BlockingQueue接口
阻塞队列在实际应用中非常广泛,许多消息中间件中定义的队列,通常就是一种“阻塞队列”。
“阻塞队列”通常利用了“锁”来实现,也就是会阻塞调用线程,其使用场景一般是在“生产者-消费者”模式中,用于线程之间的数据交换或系统解耦
BlockingQueue
是在JDK1.5时引入的,BlockingQueue继承了Queue接口,提供了一些阻塞方法,主要作用如下:
- 当线程向队列中插入元素时,如果队列已满,则阻塞线程,直到队列有空闲位置(非满);
- 当线程从队列中取元素(删除队列元素)时,如果队列未空,则阻塞线程,直到队列有元素;
既然BlockingQueue是一种队列,所以也具备队列的三种基本方法:插入、删除、读取:
操作类型 | 抛出异常 | 返回特殊值 | 阻塞线程 | 超时 |
---|---|---|---|---|
插入 | add(e) | offer(e) | put(e) | offer(e, time, unit) |
删除 | remove() | poll() | take() | poll(time, unit) |
读取 | element() | peek() | / | / |
可以看到,对于每种基本方法,“抛出异常”和“返回特殊值”的方法定义和Queue是完全一样的。BlockingQueue只是增加了两类和阻塞相关的方法:put(e)
、take()
;offer(e, time, unit)
、poll(time, unit)
。
put(e)和take()方法会一直阻塞调用线程,直到线程被中断或队列状态可用;
offer(e, time, unit)和poll(time, unit)方法会限时阻塞调用线程,直到超时或线程被中断或队列状态可用。
5.7.1 ArrayBlockQueue
它实现了BlockingQueue接口,底层基于数组实现:
ArrayBlockingQueue是一种有界阻塞队列,在初始构造的时候需要指定队列的容量。具有如下特点:
- 队列的容量一旦在构造时指定,后续不能改变;
- 插入元素时,在队尾进行;删除元素时,在队首进行;
- 队列满时,调用特定方法插入元素会阻塞线程;队列空时,删除元素也会阻塞线程;
- 支持公平/非公平策略,默认为非公平策略。这里的公平策略,是指当线程从阻塞到唤醒后,以最初请求的顺序(FIFO)来添加或删除元素;非公平策略指线程被唤醒后,谁先抢占到锁,谁就能往队列中添加/删除顺序,是随机的。
它的堵塞方法参考BlockingQueue接口。put(),take()...
ArrayBlockingQueue的内部数组其实是一种环形结构。假设我有队列容量是12,那么就可以想象成一个时钟,有12个槽,每个槽都存放一个数据。然后针对插入和删除分别有一个指针,分别是putIndex和takeIndex。每有一个数据插入,putIndex指针都会顺时针移一格。每有一个数据删除,takeIndex也会顺时针移一格。当插入时,如果指针所对应的槽是空数据,那么就可以插入,如果有数据了,那线程就堵塞住。当队列满了,takeIndex就会重新置为0.
总结:
ArrayBlockingQueue利用了ReentrantLock来保证线程的安全性,且只有一把锁,无论是出队还是入队,都共用这把锁,这就导致任一时间点只有一个线程能够执行,意味着生产者和消费者不能并发执行。
在一般的应用场景下已经足够。对于超高并发的环境,由于生产者-消息者共用一把锁,可能出现性能瓶颈。另外,由于ArrayBlockingQueue是有界的,且在初始时指定队列大小,所以如果初始时需要限定消息队列的大小,则ArrayBlockingQueue 比较合适
5.7.2 LinkedBlockingQueue
实现了BlockingQueue接口,底层基于单链表实现
LinkedBlockingQueue是一种近似有界阻塞队列,为什么说近似?因为LinkedBlockingQueue既可以在初始构造时就指定队列的容量,也可以不指定,如果不指定,那么它的容量大小默认为Integer.MAX_VALUE
。
LinkedBlockingQueue除了底层数据结构(单链表)与ArrayBlockingQueue(数组)不同外,另外一个特点就是:
它维护了两把锁——takeLock
和putLock
。
takeLock用于控制出队的并发,putLock用于入队的并发。这也就意味着,同一时刻,只能只有一个线程能执行入队/出队操作,其余入队/出队线程会被阻塞;但是,入队和出队之间可以并发执行,即同一时刻,可以同时有一个线程进行入队,另一个线程进行出队,这样就可以提升吞吐量。
归纳一下,LinkedBlockingQueue和ArrayBlockingQueue比较主要有以下区别:
- 队列大小不同。ArrayBlockingQueue初始构造时必须指定大小,而LinkedBlockingQueue构造时既可以指定大小,也可以不指定(默认为
Integer.MAX_VALUE
,近似于无界); - 底层数据结构不同。ArrayBlockingQueue底层采用数组作为数据存储容器,而LinkedBlockingQueue底层采用单链表作为数据存储容器;
- 两者的加锁机制不同。ArrayBlockingQueue使用一把全局锁,即入队和出队使用同一个ReentrantLock锁;而LinkedBlockingQueue进行了锁分离,入队使用一个ReentrantLock锁(putLock),出队使用另一个ReentrantLock锁(takeLock);
- LinkedBlockingQueue不能指定公平/非公平策略(默认都是非公平),而ArrayBlockingQueue可以指定策略。
5.7.3 PriorityBlockingQueue
实现了BlockingQueue接口,底层基于堆实现
PriorityBlockingQueue是一种无界阻塞队列,在构造的时候可以指定队列的初始容量。具有如下特点:
- PriorityBlockingQueue与之前介绍的阻塞队列最大的不同之处就是:它是一种优先级队列,也就是说元素并不是以FIFO的方式出/入队,而是以按照权重大小的顺序出队;这里的权重指的是顺序
- PriorityBlockingQueue是真正的无界队列(仅受内存大小限制),它不像ArrayBlockingQueue那样构造时必须指定最大容量,也不像LinkedBlockingQueue默认最大容量为
Integer.MAX_VALUE
; - 由于PriorityBlockingQueue是按照元素的权重进入排序,所以队列中的元素必须是可以比较的,也就是说元素必须实现
Comparable
接口; - 由于PriorityBlockingQueue无界队列,所以插入元素永远不会阻塞线程;
- PriorityBlockingQueue底层是一种基于数组实现的堆结构。
PriorityBlockingQueue如果不指定容量,默认容量为11。需要注意的是,PriorityBlockingQueue只有一个条件等待队列——notEmpty
,因为构造时不会限制最大容量且会自动扩容,所以插入元素并不会阻塞,仅当队列为空时,才可能阻塞“出队”线程。
扩容:调用的是tryGrow方法,由于调用tryGrow的方法一定需要获取全局锁,所以需要先释放锁(因为可能有线程正在出队)扩容/出队是可以并发执行的(扩容的前半部分只是新建一个内部数组,不会对出队产生影响)。扩容后的内部数组大小一般为原来的2倍。
总结:
PriorityBlockingQueue属于比较特殊的阻塞队列,适用于有元素优先级要求的场景。它的内部和ArrayBlockingQueue一样,使用一个了全局独占锁来控制同时只有一个线程可以进行入队和出队,另外由于该队列是无界队列,所以入队线程并不会阻塞。
PriorityBlockingQueue始终保证出队的元素是优先级最高的元素,并且可以定制优先级的规则,内部通过使用堆(数组形式)来维护元素顺序,它的内部数组是可扩容的,扩容和出/入队可以并发进行。
5.7.4 SynchronousQueue
SynchronousQueue的底层实现包含两种数据结构——栈和队列。这是一种非常特殊的阻塞队列,它的特点简要概括如下:
- 入队线程和出队线程必须一一匹配,否则任意先到达的线程会阻塞。比如ThreadA进行入队操作,在有其它线程执行出队操作之前,ThreadA会一直等待,反之亦然;
- SynchronousQueue内部不保存任何元素,也就是说它的容量为0,数据直接在配对的生产者和消费者线程之间传递,不会将数据缓冲到队列中。
- SynchronousQueue支持公平/非公平策略。其中非公平模式,基于内部数据结构——“栈”来实现,公平模式,基于内部数据结构——“队列”来实现;
- SynchronousQueue基于一种名为“Dual stack and Dual queue”的无锁算法实现。
SynchronousQueue主要用于线程之间的数据交换,由于采用无锁算法,其性能一般比单纯的其它阻塞队列要高。它的最大特点时不存储实际元素,而是在内部通过栈或队列结构保存阻塞线程。所以调用该队列的put方法,其实存的是线程对象
5.8 BlockingDeque接口
阻塞的双端队列接口
我们知道,BlockingQueue中阻塞方法一共有4个:put(e)
、take()
;offer(e, time, unit)
、poll(time, unit)
,忽略限时等待的阻塞方法,一共就两个:队尾入队:put(e)和队首出队:take()
BlockingDeque相对于BlockingQueue,最大的特点就是增加了在队首入队/队尾出队的阻塞方法。下面是两个接口的比较:
阻塞方法 | BlockingQueue | BlockingDeque |
---|---|---|
队首入队 | / | putFirst(e) |
队首出队 | take() | takeFirst() |
队尾入队 | put(e) | putLast(e) |
队尾出队 | / | takeLast() |
双端队列相比普通队列,主要是多了【队尾出队元素】/【队首入队元素】的功能。
5.8.1 LinkedBlockingDeque
LinkedBlockingDeque是BlockingDeque的实现类,作为一种阻塞双端队列,提供了队尾删除元素和队首插入元素的阻塞方法。该类在构造时一般需要指定容量,如果不指定,则最大容量为Integer.MAX_VALUE
。另外,由于内部通过ReentrantLock来保证线程安全,所以LinkedBlockingDeque的整体实现时比较简单的。
5.9 LinkedTransferQueue
LinkedTransferQueue
是在JDK1.7时,J.U.C包新增的一种比较特殊的阻塞队列,它除了具备阻塞队列的常用功能外,还有一个比较特殊的transfer
方法。它是无界队列
我们知道,在普通阻塞队列中,当队列为空时,消费者线程(调用take或poll方法的线程)一般会阻塞等待生产者线程往队列中存入元素。而LinkedTransferQueue的transfer方法则比较特殊:
- 当有消费者线程阻塞等待时,调用transfer方法的生产者线程不会将元素存入队列,而是直接将元素传递给消费者;
- 如果调用transfer方法的生产者线程发现没有正在等待的消费者线程,则会将元素入队,然后会阻塞等待,直到有一个消费者线程来获取该元素。
LinkedTransferQueue其实兼具了SynchronousQueue的特性以及无锁算法的性能,并且是一种无界队列:
- 和SynchronousQueue相比,LinkedTransferQueue可以存储实际的数据;
- 和其它阻塞队列相比,LinkedTransferQueue直接用无锁算法实现,性能有所提升。
5.10 总结
总结所有阻塞队列,如下表所示:
队列特性 | 有界队列 | 近似无界队列 | 无界队列 | 特殊队列 |
---|---|---|---|---|
有锁算法 | ArrayBlockingQueue | LinkedBlockingQueue、LinkedBlockingDeque | / | PriorityBlockingQueue、DelayQueue |
无锁算法 | / | / | LinkedTransferQueue | SynchronousQueue |