我们常用的Java并发容器类是由java.util.concurrent包为我们提供的
java.util.concurrent包提供的并发容器主要分为三类:Concurrent*、CopyOnWrite*、Blocking*
其中Concurrent*
的特点大部分通过CAS+synchronized
实现的,CopyOnWrite*
则是通过复制一份原数据来实现的,而Blocking*
是通过AQS
实现的
面试常见的并发容器如ConcurrentHashMap
、CopyOnWriteArrayList
、BlockQueue的实现类
等均是来自juc包,我们只是简单的知道它们是线程安全的是完全不够的,所以,让我们一起来从底层认识下Java并发容器吧!
本文会从常见问题,源码分析,面试题总结三个部分来展开
诞生的历史和原因
整体架构
从整体架构上来说,CopyOnWriteArrayList 数据结构和 ArrayList 是一致的,底层是个数组,只不过 CopyOnWriteArrayList 在对数组进行操作的时候,基本会分四步走:
适用场景
如:
黑名单,每日一次更新就够了
监听器,监听迭代操作次数远高于修改操作
读写规则
对比读写锁的规则:读读共享、读写互斥、写读互斥、写写互斥
CopyOnWriteArrayList的读写规则为:
特征
缺点
案例演示
案例一:
演示下使用ArrayList和CopyOnWriteArrayList迭代时进行修改操作
首先使用ArrayList
public static void main(String[] args) {
ArrayList<String> list = new ArrayList<>();
list.add("1");
list.add("2");
list.add("3");
list.add("4");
list.add("5");
Iterator<String> iterator = list.iterator();
while (iterator.hasNext()){
System.out.println("list is: "+list);
String next = iterator.next();
System.out.println("cur is: "+next);
if (next.equals("2")){
list.remove("5");
}
if (next.equals("3")){
list.add("find 3");
}
}
}
运行抛出异常
list is: [1, 2, 3, 4, 5]
cur is: 1
list is: [1, 2, 3, 4, 5]
cur is: 2
list is: [1, 2, 3, 4]
Exception in thread "main" java.util.ConcurrentModificationException
at java.util.ArrayList$Itr.checkForComodification(ArrayList.java:909)
at java.util.ArrayList$Itr.next(ArrayList.java:859)
at collection.CopyOnWriteArrayListDemo.main(CopyOnWriteArrayListDemo.java:27)
将ArrayList修改为CopyOnWriteArrayList
CopyOnWriteArrayList<String> list = new CopyOnWriteArrayList<>();
运行结果如图
list is: [1, 2, 3, 4, 5]
cur is: 1
list is: [1, 2, 3, 4, 5]
cur is: 2
list is: [1, 2, 3, 4]
cur is: 3
list is: [1, 2, 3, 4, find 3]
cur is: 4
list is: [1, 2, 3, 4, find 3]
cur is: 5
可以很惊奇的发现,最后一个是cur is:5
而不是预想的find 3
CopyOnWriteArrayList在迭代的时候如果有修改是不可见的,会保持开始迭代时的内容
案例二:演示迭代时迭代数据的确定时间
创建一个迭代器之后对容器进行修改,然后再创建一个迭代器,打印两个迭代器的数据
public static void main(String[] args) {
CopyOnWriteArrayList<Integer> list =
new CopyOnWriteArrayList<>(new Integer[]{1, 2, 3});
Iterator<Integer> iterator1 = list.iterator();
list.add(4);
Iterator<Integer> iterator2 = list.iterator();
iterator1.forEachRemaining(System.out::print);
System.out.println();
iterator2.forEachRemaining(System.out::print);
}
打印结果如下:
123
1234
从结果可以得知,迭代器的数据在迭代器生成时就已经确定了,对生成迭代器之后的数据修改时不可见的
1. 新增
新增包括新增到数组尾部,新增到数组某一个索引位置,批量新增等等,操作的思路都是那四步:加锁、拷贝、操作后赋值、解锁
新增到数组尾部的源码:
// 添加元素到数组尾部
public boolean add(E e) {
final ReentrantLock lock = this.lock;
// 加锁
lock.lock();
try {
// 得到所有的原数组
Object[] elements = getArray();
int len = elements.length;
// 拷贝到新数组里面,新数组的长度是 + 1 的,因为新增会多一个元素
Object[] newElements = Arrays.copyOf(elements, len + 1);
// 在新数组中进行赋值,新元素直接放在数组的尾部
newElements[len] = e;
// 替换掉原来的数组
setArray(newElements);
return true;
// finally 里面释放锁,保证即使 try 发生了异常,仍然能够释放锁
} finally {
lock.unlock();
}
}
从源码中可以看出,整个add过程都在持有锁的状态下进行的,通过锁保证了只能有一个线程同时对一个数组进行add操作
add过程中会创建一个老数组长度+1的新数组,然后把老数组的值拷贝到新数组内,再添加值到尾部
question:为什么加锁了不在原数组直接操作呢?
新增到指定下标位置的源码:
// len:数组的长度、index:插入的位置
int numMoved = len - index;
// 如果要插入的位置正好等于数组的末尾,直接拷贝数组即可
if (numMoved == 0)
newElements = Arrays.copyOf(elements, len + 1);
else {
// 如果要插入的位置在数组的中间,就需要拷贝 2 次
// 第一次从 0 拷贝到 index。
// 第二次从 index+1 拷贝到末尾。
newElements = new Object[len + 1];
System.arraycopy(elements, 0, newElements, 0, index);
System.arraycopy(elements, index, newElements, index + 1,
numMoved);
}
// index 索引位置的值是空的,直接赋值即可。
newElements[index] = element;
// 把新数组的值赋值给数组的容器中
setArray(newElements);
从源码可以看出,如果插入的位置是数组末尾,只需要拷贝一次。当插入的位置是中间,就会把原数组分成两部分进行复制,然后添加新值到新数组
2. 删除
指定数组索引位置删除的源码:
// 删除某个索引位置的数据
public E remove(int index) {
final ReentrantLock lock = this.lock;
// 加锁
lock.lock();
try {
Object[] elements = getArray();
int len = elements.length;
// 先得到老值
E oldValue = get(elements, index);
int numMoved = len - index - 1;
// 如果要删除的数据正好是数组的尾部,直接删除
if (numMoved == 0)
setArray(Arrays.copyOf(elements, len - 1));
else {
// 如果删除的数据在数组的中间,分三步走
// 1. 设置新数组的长度减一,因为是减少一个元素
// 2. 从 0 拷贝到数组新位置
// 3. 从新位置拷贝到数组尾部
Object[] newElements = new Object[len - 1];
System.arraycopy(elements, 0, newElements, 0, index);
System.arraycopy(elements, index + 1, newElements, index,
numMoved);
setArray(newElements);
}
return oldValue;
} finally {
lock.unlock();
}
}
步骤分为三步:
加锁
判断索引位置
len-1
的数组返回len-1
的新数组,分两段复制到新数组解锁
批量删除的源码:
// 批量删除包含在 c 中的元素
public boolean removeAll(Collection<?> c) {
if (c == null) throw new NullPointerException();
final ReentrantLock lock = this.lock;
lock.lock();
try {
Object[] elements = getArray();
int len = elements.length;
// 说明数组有值,数组无值直接返回 false
if (len != 0) {
// newlen 表示新数组的索引位置,新数组中存在不包含在 c 中的元素
int newlen = 0;
Object[] temp = new Object[len];
// 循环,把不包含在 c 里面的元素,放到新数组中
for (int i = 0; i < len; ++i) {
Object element = elements[i];
// 不包含在 c 中的元素,从 0 开始放到新数组中
if (!c.contains(element))
temp[newlen++] = element;
}
// 拷贝新数组,变相的删除了不包含在 c 中的元素
if (newlen != len) {
setArray(Arrays.copyOf(temp, newlen));
return true;
}
}
return false;
} finally {
lock.unlock();
}
}
批量删除并不会对数组中的数据挨个删除,而是对老数组的值进行遍历,如果值在传入集合c
中存在,就放入新数组,最后返回的新数组就是不包含待删除数组的数组了
3.indexOf
indexOf正向搜索源码:
// o:我们需要搜索的元素
// elements:我们搜索的目标数组
// index:搜索的开始位置
// fence:搜索的结束位置
private static int indexOf(Object o, Object[] elements,
int index, int fence) {
// 支持对 null 的搜索
if (o == null) {
for (int i = index; i < fence; i++)
// 找到第一个 null 值,返回下标索引的位置
if (elements[i] == null)
return i;
} else {
// 通过 equals 方法来判断元素是否相等
// 如果相等,返回元素的下标位置
for (int i = index; i < fence; i++)
if (o.equals(elements[i]))
return i;
}
return -1;
}
indexOf方法主要用于查找元素在数组中第一次出现的下标位置,如果元素不存在就返回-1,并且支持对null值的搜索
步骤:
4. 迭代
CopyOnWriteArrayList 在迭代过程中,即使原数组的值发生了改变也不会抛出ConcurrentModificationException 异常,因为每次改动都会生成新数组,不会影响老数组
CopyOnWriteArrayList 迭代持有的是老数组的引用,而 CopyOnWriteArrayList 每次的数据变动,都会产生新的数组,对老数组的值不会产生影响,所以迭代也可以正常进行。
为什么需要ConcurrentHashMap?
Hashtable
线程安全,但各种方法操作时都直接使用了synchronized
锁住了整个结构
HashMap
虽然效率高,但是在多线程环境下不安全
需要一个中和了Hashtable
和HashMap
的类在多线程下高效的使用
ConcurrentHashMap的构造方法有哪些
//无参构造函数
public ConcurrentHashMap() {
}
//可传初始容器大小的构造函数
public ConcurrentHashMap(int initialCapacity) {
if (initialCapacity < 0)
throw new IllegalArgumentException();
int cap = ((initialCapacity >= (MAXIMUM_CAPACITY >>> 1)) ?
MAXIMUM_CAPACITY :
tableSizeFor(initialCapacity + (initialCapacity >>> 1) + 1));
this.sizeCtl = cap;
}
//可传入map的构造函数
public ConcurrentHashMap(Map<? extends K, ? extends V> m) {
this.sizeCtl = DEFAULT_CAPACITY;
putAll(m);
}
//可设置阈值和初始容量
public ConcurrentHashMap(int initialCapacity, float loadFactor) {
this(initialCapacity, loadFactor, 1);
}
//可设置初始容量和阈值和并发级别
public ConcurrentHashMap(int initialCapacity,
float loadFactor, int concurrencyLevel) {
if (!(loadFactor > 0.0f) || initialCapacity < 0 || concurrencyLevel <= 0)
throw new IllegalArgumentException();
if (initialCapacity < concurrencyLevel) // Use at least as many bins
initialCapacity = concurrencyLevel; // as estimated threads
long size = (long)(1.0 + (long)initialCapacity / loadFactor);
int cap = (size >= (long)MAXIMUM_CAPACITY) ?
MAXIMUM_CAPACITY : tableSizeFor((int)size);
this.sizeCtl = cap;
}
ConcurrentHashMap使用什么技术来保证线程安全?
segment + ReentrantLock
实现node + CAS + synchronized
实现JDK1.7中
segment
,每个segment
的底层实现和HashMap
类似,任然是数组加链表组成的拉链法segment
单独上一个ReentrantLock
锁,每个segment
之间互不影响,提高了并发效率Segment
,所以最多可以同时支持16个线程并发写,其默认值可以在初始化时设置,一旦初始化完成不可以扩容JDK1.8中
错误的使用concurrentHashMap依然会造成线程安全问题
案例:
构建两个线程,对concurrentHashMap进行读取,修改,重新写入的操作
/**
* 〈组合操作不能保证concurrentHashMap线程安全〉
*
* @author Chkl
* @create 2020/3/28
* @since 1.0.0
*/
public class OptionNotSafe implements Runnable {
private static ConcurrentHashMap<String, Integer> scores
= new ConcurrentHashMap<>();
public static void main(String[] args) throws InterruptedException {
scores.put("张三", 0);
Thread thread1 = new Thread(new OptionNotSafe());
Thread thread2 = new Thread(new OptionNotSafe());
thread1.start();;
thread2.start();
thread1.join();
thread2.join();
System.out.println(scores.get("张三"));
}
@Override
public void run() {
for (int i = 0; i < 1000; i++) {
Integer score = scores.get("张三");
int newScore = score + 1;
scores.put("张三", newScore);
}
}
}
如果是线程安全的,预期结果应该是2000,而实际运行结果不等于2000
虽然concurrentHashMap可以保证并发下的单个操作是安全的,但是不能保证组合操作的安全,这样使用是错误的用法
正确的用法
@Override
public void run() {
for (int i = 0; i < 1000; i++) {
while (true) {
Integer score = scores.get("张三");
int newScore = score + 1;
boolean b = scores.replace("张三", score, newScore);
if (b) break;
}
}
}
concurrentHashMap针对这种情况有相应的解决措施,调用replace方法,参数列表为key,oldVal,newVal
,进行修改时会判断值是否为oldVal,如果不是则修改失败返回false,所以需要不断的进行重试,如果修改成功再退出。这里应用的就是CAS的思想
concurrentHashMap提供 的组合操作方法:
if (!map.containsKey(key))
return map.put(key,value);
else {
return map.get(key);
}
put value的过程
CAS
创建,失败后自旋直到创建成功,public V put(K key, V value) {
return this.putVal(key, value, false);
}
final V putVal(K key, V value, boolean onlyIfAbsent) {
// key 或 value 不允许为 null
if (key == null || value == null) throw new NullPointerException();
// 计算 key 的哈希码
int hash = spread(key.hashCode());
int binCount = 0;
for (Node<K, V>[] tab = table; ; ) {
Node<K, V> f;
int n, i, fh;
// 1. 如果 table 数组为空,则进行初始化
if (tab == null || (n = tab.length) == 0) {
// 基于 CAS 策略初始化 table,初始化大小为 16
tab = this.initTable();
}
// 2. 否则,计算 hash 值对应的下标,获取 table 上对应下标的头结点
else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
/*
* table 对应下标的头结点为 null
* 基于 CAS 设置结点,如果成功则本次 put 操作完成,
* 如果失败则说明期间有并发操作,需要进入一轮新的循环
*/
if (casTabAt(tab, i, null, new Node<>(hash, key, value, null))) {
// 设置结点成功,put 操作完成
break;
}
}
// 3. 否则,如果 Map 正在执行扩容操作(MOVED 哈希值表示正在扩容),则帮助扩容
else if ((fh = f.hash) == MOVED) {
tab = this.helpTransfer(tab, f);
}
// 4. 否则,获取到 hash 值对应下标的头结点,且结点不为 null
else {
V oldVal = null;
synchronized (f) { // 加锁
if (tabAt(tab, i) == f) { // 再次校验头结点为 f
// 头结点的哈希值大于等于 0,说明是链表,如果是树的话应该是 -2
if (fh >= 0) {
binCount = 1;
for (Node<K, V> e = f; ; ++binCount) {
K ek;
// 如果是已经存在的 key,则在允许覆盖的前提下直接覆盖已有的值
if (e.hash == hash &&
((ek = e.key) == key ||
(ek != null && key.equals(ek)))) {
oldVal = e.val;
if (!onlyIfAbsent) {
e.val = value;
}
break;
}
// 如果是不存在的 key,则直接在链表尾部插入一个新的结点
Node<K, V> pred = e;
if ((e = e.next) == null) {
pred.next = new Node<>(hash, key, value, null);
break;
}
}
}
// 红黑树
else if (f instanceof TreeBin) {
Node<K, V> p;
binCount = 2;
// 调用红黑树的方法获取到修改的结点,并插入或更新结点(如果允许)
if ((p = ((TreeBin<K, V>) f).putTreeVal(hash, key, value)) != null) {
oldVal = p.val;
if (!onlyIfAbsent) {
p.val = value;
}
}
}
}
} // end synchronized
if (binCount != 0) {
if (binCount >= TREEIFY_THRESHOLD) {
/*
* 结点数目大于等于 8,对链表执行转换操作
* - 如果 table 长度小于 64,则执行扩容
* - 如果 table 长度大于等于 64,则转换成红黑树
*/
this.treeifyBin(tab, i);
}
if (oldVal != null) {
return oldVal;
}
break;
}
}
}
// size 加 1
this.addCount(1L, binCount);
return null;
}
数组初始化的线程安全保证
通过自旋+CAS+双中检查保证了数组初始化的线程安全
具体源码如下:
//初始化 table,通过对 sizeCtl 的变量赋值来保证数组只能被初始化一次
private final Node<K,V>[] initTable() {
Node<K,V>[] tab; int sc;
//通过自旋保证初始化成功
while ((tab = table) == null || tab.length == 0) {
// 小于 0 代表有线程正在初始化,释放当前 CPU 的调度权,重新发起锁的竞争
if ((sc = sizeCtl) < 0)
Thread.yield(); // lost initialization race; just spin
// CAS 赋值保证当前只有一个线程在初始化,-1 代表当前只有一个线程能初始化
// 保证了数组的初始化的安全性
else if (U.compareAndSwapInt(this, SIZECTL, sc, -1)) {
try {
// 很有可能执行到这里的时候,table 已经不为空了,这里是双重 check
if ((tab = table) == null || tab.length == 0) {
// 进行初始化
int n = (sc > 0) ? sc : DEFAULT_CAPACITY;
@SuppressWarnings("unchecked")
Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n];
table = tab = nt;
sc = n - (n >>> 2);
}
} finally {
sizeCtl = sc;
}
break;
}
}
return tab;
}
新增槽点值时的线程安全保障
通过自旋 + CAS + synchronized保证了新增槽点值的线程安全
扩容时的线程安全保证
ConcurrentHashMap 的扩容时机和HashMap一致,都是在put方法的最后一步检查是否需要扩容,但是扩容的过程完全不同。
ConcurrentHashMap 的扩容方法叫做transfer
,实现思路如下
关键源码如下:
// 扩容主要分 2 步,第一新建新的空数组,第二移动拷贝每个元素到新数组中去
// tab:原数组,nextTab:新数组
private final void transfer(Node<K,V>[] tab, Node<K,V>[] nextTab) {
// 老数组的长度
int n = tab.length, stride;
if ((stride = (NCPU > 1) ? (n >>> 3) / NCPU : n) < MIN_TRANSFER_STRIDE)
stride = MIN_TRANSFER_STRIDE; // subdivide range
// 如果新数组为空,初始化,大小为原数组的两倍,n << 1
if (nextTab == null) { // initiating
try {
@SuppressWarnings("unchecked")
Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n << 1];
nextTab = nt;
} catch (Throwable ex) { // try to cope with OOME
sizeCtl = Integer.MAX_VALUE;
return;
}
nextTable = nextTab;
transferIndex = n;
}
// 新数组的长度
int nextn = nextTab.length;
// 代表转移节点,如果原数组上是转移节点,说明该节点正在被扩容
ForwardingNode<K,V> fwd = new ForwardingNode<K,V>(nextTab);
boolean advance = true;
boolean finishing = false; // to ensure sweep before committing nextTab
// 无限自旋,i 的值会从原数组的最大值开始,慢慢递减到 0
for (int i = 0, bound = 0;;) {
Node<K,V> f; int fh;
while (advance) {
int nextIndex, nextBound;
// 结束循环的标志
if (--i >= bound || finishing)
advance = false;
// 已经拷贝完成
else if ((nextIndex = transferIndex) <= 0) {
i = -1;
advance = false;
}
// 每次减少 i 的值
else if (U.compareAndSwapInt
(this, TRANSFERINDEX, nextIndex,
nextBound = (nextIndex > stride ?
nextIndex - stride : 0))) {
bound = nextBound;
i = nextIndex - 1;
advance = false;
}
}
// if 任意条件满足说明拷贝结束了
if (i < 0 || i >= n || i + n >= nextn) {
int sc;
// 拷贝结束,直接赋值,因为每次拷贝完一个节点,都在原数组上放转移节点,所以拷贝完成的节点的数据一定不会再发生变化。
// 原数组发现是转移节点,是不会操作的,会一直等待转移节点消失之后在进行操作。
// 也就是说数组节点一旦被标记为转移节点,是不会再发生任何变动的,所以不会有任何线程安全的问题
// 所以此处直接赋值,没有任何问题。
if (finishing) {
nextTable = null;
table = nextTab;
sizeCtl = (n << 1) - (n >>> 1);
return;
}
if (U.compareAndSwapInt(this, SIZECTL, sc = sizeCtl, sc - 1)) {
if ((sc - 2) != resizeStamp(n) << RESIZE_STAMP_SHIFT)
return;
finishing = advance = true;
i = n; // recheck before commit
}
}
else if ((f = tabAt(tab, i)) == null)
advance = casTabAt(tab, i, null, fwd);
else if ((fh = f.hash) == MOVED)
advance = true; // already processed
else {
synchronized (f) {
// 进行节点的拷贝
if (tabAt(tab, i) == f) {
Node<K,V> ln, hn;
if (fh >= 0) {
int runBit = fh & n;
Node<K,V> lastRun = f;
for (Node<K,V> p = f.next; p != null; p = p.next) {
int b = p.hash & n;
if (b != runBit) {
runBit = b;
lastRun = p;
}
}
if (runBit == 0) {
ln = lastRun;
hn = null;
}
else {
hn = lastRun;
ln = null;
}
// 如果节点只有单个数据,直接拷贝,如果是链表,循环多次组成链表拷贝
for (Node<K,V> p = f; p != lastRun; p = p.next) {
int ph = p.hash; K pk = p.key; V pv = p.val;
if ((ph & n) == 0)
ln = new Node<K,V>(ph, pk, pv, ln);
else
hn = new Node<K,V>(ph, pk, pv, hn);
}
// 在新数组位置上放置拷贝的值
setTabAt(nextTab, i, ln);
setTabAt(nextTab, i + n, hn);
// 在老数组位置上放上 ForwardingNode 节点
// put 时,发现是 ForwardingNode 节点,就不会再动这个节点的数据了
setTabAt(tab, i, fwd);
advance = true;
}
// 红黑树的拷贝
else if (f instanceof TreeBin) {
// 红黑树的拷贝工作,同 HashMap 的内容,代码忽略
…………
// 在老数组位置上放上 ForwardingNode 节点
setTabAt(tab, i, fwd);
advance = true;
}
}
}
}
}
}
get value的过程
具体源码如下:
public V get(Object key) {
Node<K,V>[] tab; Node<K,V> e, p; int n, eh; K ek;
//计算hashcode
int h = spread(key.hashCode());
//不是空的数组 && 并且当前索引的槽点数据不是空的
//否则该key对应的值不存在,返回null
if ((tab = table) != null && (n = tab.length) > 0 &&
(e = tabAt(tab, (n - 1) & h)) != null) {
//槽点第一个值和key相等,直接返回
if ((eh = e.hash) == h) {
if ((ek = e.key) == key || (ek != null && key.equals(ek)))
return e.val;
}
//如果是红黑树或者转移节点,使用对应的find方法
else if (eh < 0)
return (p = e.find(h, key)) != null ? p.val : null;
//如果是链表,遍历查找
while ((e = e.next) != null) {
if (e.hash == h &&
((ek = e.key) == key || (ek != null && key.equals(ek))))
return e.val;
}
}
return null;
}
相同之处:
不同之处:
0(n)
,1.8中超过阈值转为红黑树查询时间变为n(logn)
为什么要使用队列?
阻塞队列
非阻塞队列
如何选择适合自己的队列
1. LinkedBlockingQueue
类图:
从类图可以看出,直接继承了AbstractQueue类 并实现了BlockingQueue 接口
那么LinkedBlockingQueue应该具有集合的相关方法和Queue接口相关的方法
内部构成源码:
// 链表结构 begin
//链表的元素
static class Node<E> {
E item;
//当前元素的下一个,为空表示当前节点是最后一个
Node<E> next;
Node(E x) { item = x; }
}
//链表的容量,默认 Integer.MAX_VALUE
private final int capacity;
//链表已有元素大小,使用 AtomicInteger,所以是线程安全的
private final AtomicInteger count = new AtomicInteger();
//链表头
transient Node<E> head;
//链表尾
private transient Node<E> last;
// 链表结构 end
// 锁 begin
//take 时的锁
private final ReentrantLock takeLock = new ReentrantLock();
// take 的条件队列,condition 可以简单理解为基于 ASQ 同步机制建立的条件队列
private final Condition notEmpty = takeLock.newCondition();
// put 时的锁,设计两把锁的目的,主要为了 take 和 put 可以同时进行
private final ReentrantLock putLock = new ReentrantLock();
// put 的条件队列
private final Condition notFull = putLock.newCondition();
// 锁 end
// 迭代器
// 实现了自己的迭代器
private class Itr implements Iterator<E> {
………………
}
内部构成主要分为三个部分:链表 + 两个锁 + 迭代器
其中两把锁为take锁和put锁,为了保证线程安全设计了两把锁,保证了take和put可以同时进行,互不影响
构造方法源码:
// 不指定容量,默认 Integer 的最大值
public LinkedBlockingQueue() {
this(Integer.MAX_VALUE);
}
// 指定链表容量大小,链表头尾相等,节点值(item)都是 null
public LinkedBlockingQueue(int capacity) {
if (capacity <= 0) throw new IllegalArgumentException();
this.capacity = capacity;
last = head = new Node<E>(null);
}
// 已有集合数据进行初始化
public LinkedBlockingQueue(Collection<? extends E> c) {
this(Integer.MAX_VALUE);
final ReentrantLock putLock = this.putLock;
putLock.lock(); // Never contended, but necessary for visibility
try {
int n = 0;
for (E e : c) {
// 集合内的元素不能为空
if (e == null)
throw new NullPointerException();
// capacity 代表链表的大小,在这里是 Integer 的最大值
// 如果集合类的大小大于 Integer 的最大值,就会报错
// 其实这个判断完全可以放在 for 循环外面,这样可以减少 Integer 的最大值次循环(最坏情况)
if (n == capacity)
throw new IllegalStateException("Queue full");
enqueue(new Node<E>(e));
++n;
}
count.set(n);
} finally {
putLock.unlock();
}
}
构造方法有三种:
新增(入队)源码:
入队有put、offer、add三种方法,都差不多,以put为例
// 把e新增到队列的尾部。
// 如果有可以新增的空间的话,直接新增成功,否则当前线程陷入等待
public void put(E e) throws InterruptedException {
// e 为空,抛出异常
if (e == null) throw new NullPointerException();
// 预先设置 c 为 -1,约定负数为新增失败
int c = -1;
Node<E> node = new Node<E>(e);
final ReentrantLock putLock = this.putLock;
final AtomicInteger count = this.count;
// 设置可中断锁
putLock.lockInterruptibly();
try {
// 队列满了
// 当前线程阻塞,等待其他线程的唤醒(其他线程 take 成功后就会唤醒此处被阻塞的线程)
while (count.get() == capacity) {
// await 无限等待
notFull.await();
}
// 队列没有满,直接新增到队列的尾部
enqueue(node);
// 新增计数赋值,注意这里 getAndIncrement 返回的是旧值
// 这里的 c 是比真实的 count 小 1 的
c = count.getAndIncrement();
// 如果链表现在的大小 小于链表的容量,说明队列未满
// 可以尝试唤醒一个 put 的等待线程
if (c + 1 < capacity)
notFull.signal();
} finally {
// 释放锁
putLock.unlock();
}
// c==0,代表队列里面有一个元素
// 会尝试唤醒一个take的等待线程
if (c == 0)
signalNotEmpty();
}
// 入队,把新元素放到队尾
private void enqueue(Node<E> node) {
last = last.next = node;
}
步骤:
offer与put只有一点点不同,会自旋尝试,超时了会中断返回false
删除(出队)源码:
以take为例说明删除的原理
// 阻塞拿数据
public E take() throws InterruptedException {
E x;
// 默认负数,代表失败
int c = -1;
// count 代表当前链表数据的真实大小
final AtomicInteger count = this.count;
final ReentrantLock takeLock = this.takeLock;
takeLock.lockInterruptibly();
try {
// 空队列时,阻塞,等待其他线程唤醒
while (count.get() == 0) {
notEmpty.await();
}
// 非空队列,从队列的头部拿一个出来
x = dequeue();
// 减一计算,注意 getAndDecrement 返回的值是旧值
// c 比真实的 count 大1
c = count.getAndDecrement();
// 如果队列里面有值,从 take 的等待线程里面唤醒一个。
// 意思是队列里面有值啦,唤醒之前被阻塞的线程
if (c > 1)
notEmpty.signal();
} finally {
// 释放锁
takeLock.unlock();
}
// 如果队列空闲还剩下一个,尝试从 put 的等待线程中唤醒一个
if (c == capacity)
signalNotFull();
return x;
}
// 队头中取数据
private E dequeue() {
Node<E> h = head;
Node<E> first = h.next;
h.next = h; // help GC
head = first;
E x = first.item;
first.item = null;// 头节点指向 null,删除
return x;
}
步骤:
查看队首元素源码:
以peek为例
// 查看并不删除元素,如果队列为空,返回 null
public E peek() {
// count 代表队列实际大小,队列为空,直接返回 null
if (count.get() == 0)
return null;
final ReentrantLock takeLock = this.takeLock;
takeLock.lock();
try {
// 拿到队列头
Node<E> first = head.next;
// 判断队列头是否为空,并返回
if (first == null)
return null;
else
return first.item;
} finally {
takeLock.unlock();
}
}
步骤:
**2. ArrayBlockingQueue **
数据结构:
// 队列存放在 object 的数组里面
// 数组大小必须在初始化的时候手动设置,没有默认大小
final Object[] items;
// 下次拿数据的时候的索引位置
int takeIndex;
// 下次放数据的索引位置
int putIndex;
// 当前已有元素的大小
int count;
// 可重入的锁
final ReentrantLock lock;
// take的队列
private final Condition notEmpty;
// put的队列
private final Condition notFull;
其中有两个很重要的变量,takeIndex和putIndex,分别表示下次拿数据和放数据的索引位置,只要维护好这两个指针,每次操作就不需要进行计算
初始化:
public ArrayBlockingQueue(int capacity, boolean fair) {
if (capacity <= 0)
throw new IllegalArgumentException();
this.items = new Object[capacity];
lock = new ReentrantLock(fair);
// 队列不为空 Condition,在 put 成功时使用
notEmpty = lock.newCondition();
// 队列不满 Condition,在 take 成功时使用
notFull = lock.newCondition();
}
初始化时有两个参数:数组的大小和是否公平
如果是公平锁,锁竞争时就会按先来后到顺序
如果是不公平锁,锁竞争是随机的
新增
// 新增,如果队列满,无限阻塞
public void put(E e) throws InterruptedException {
// 元素不能为空
checkNotNull(e);
final ReentrantLock lock = this.lock;
lock.lockInterruptibly();
try {
// 队列如果是满的,就无限等待
// 一直等待队列中有数据被拿走时,自己被唤醒
while (count == items.length)
notFull.await();
enqueue(e);
} finally {
lock.unlock();
}
}
private void enqueue(E x) {
// assert lock.getHoldCount() == 1; 同一时刻只能一个线程进行操作此方法
// assert items[putIndex] == null;
final Object[] items = this.items;
// putIndex 为本次插入的位置
items[putIndex] = x;
// ++ putIndex 计算下次插入的位置
// 如果下次插入的位置,正好等于队尾,下次插入就从 0 开始
if (++putIndex == items.length)
putIndex = 0;
count++;
// 唤醒因为队列空导致的等待线程
notEmpty.signal();
}
新增是存在两种情况
public E take() throws InterruptedException {
final ReentrantLock lock = this.lock;
lock.lockInterruptibly();
try {
// 如果队列为空,无限等待
// 直到队列中有数据被 put 后,自己被唤醒
while (count == 0)
notEmpty.await();
// 从队列中拿数据
return dequeue();
} finally {
lock.unlock();
}
}
private E dequeue() {
final Object[] items = this.items;
// takeIndex 代表本次拿数据的位置,是上一次拿数据时计算好的
E x = (E) items[takeIndex];
// 帮助 gc
items[takeIndex] = null;
// ++ takeIndex 计算下次拿数据的位置
// 如果正好等于队尾的话,下次就从 0 开始拿数据
if (++takeIndex == items.length)
takeIndex = 0;
// 队列实际大小减 1
count--;
if (itrs != null)
itrs.elementDequeued();
// 唤醒被队列满所阻塞的线程
notFull.signal();
return x;
}
从源码可以看出,每次拿数据的位置是takeIndex的位置,拿到数据后更新takeIndex的位置,如果拿的数据在中间部分,takeIndex+1,如果位于数组尾部,将takeIndex指针指向数组头部
参考:
慕课网《面试官系统精讲Java源码及大厂真题》
极客时间《Java核心技术面试精讲》
慕课网《玩转Java并发工具,精通JUC,成为并发多面手》
更多Java面试复习笔记和总结可访问我的面试复习专栏《Java面试复习笔记》,或者访问我另一篇博客《Java面试核心知识点汇总》查看目录和直达链接