目录
一、Java中的并发集合类有哪些?
一些常用的并发集合类包括:
- ConcurrentHashMap:线程安全的哈希表,适用于高并发读写的场景。
- CopyOnWriteArrayList:线程安全的动态数组,适用于读多写少的场景。
- ConcurrentLinkedQueue:线程安全的无界队列,适用于高并发的生产者-消费者场景。
- ConcurrentSkipListSet:线程安全的有序集合,基于跳表实现,支持高效的插入、删除和查询操作。
- ConcurrentSkipListMap:线程安全的有序映射表,基于跳表实现,支持高效的插入、删除和查询操作。
- BlockingQueue接口的实现类(如ArrayBlockingQueue、LinkedBlockingQueue):线程安全的阻塞队列,提供了阻塞的插入和删除操作,用于实现生产者-消费者模式。
- BlockingDeque接口的实现类(如LinkedBlockingDeque):线程安全的双端阻塞队列,提供了阻塞的插入和删除操作,支持从两端插入和删除元素。
这些并发集合类提供了线程安全的操作,能够在多线程环境中安全地进行并发访问和修改,减少了编写线程安全代码的复杂性。根据具体的需求和场景,选择合适的并发集合类可以提高程序的性能和并发性能。
以下是一些常见的并发集合及其特点:
ConcurrentHashMap(并发哈希表):它是一个线程安全的哈希表实现,支持高并发的读操作和部分并发的写操作。它采用分段锁的方式来提高并发性能,不同的线程可以同时访问不同的段,从而减少了竞争。
ConcurrentLinkedQueue(并发链表队列):它是一个线程安全的队列实现,支持高并发的入队和出队操作。它使用无锁的算法来实现并发性,每个节点都包含一个指向下一个节点的引用,通过CAS(Compare and Swap)操作来保证数据的一致性。
CopyOnWriteArrayList(写时复制数组列表):它是一个线程安全的动态数组实现,支持高并发的读操作和低并发的写操作。它在写操作时,会创建一个新的数组,并将原数组的内容复制到新数组中,从而实现写时复制的效果,避免了读写冲突。
BlockingQueue(阻塞队列):它是一个支持阻塞操作的队列实现,可以在队列为空时阻塞等待元素的到来,或者在队列已满时阻塞等待空间的释放。常见的实现有LinkedBlockingQueue和ArrayBlockingQueue等。
ConcurrentSkipListMap(并发跳表映射):它是一个线程安全的有序映射表实现,支持高并发的读操作和部分并发的写操作。它采用跳表的数据结构,通过多层索引来提高查找效率,同时使用CAS操作来保证数据的一致性。
ConcurrentSkipListSet(并发跳表集合):它是一个线程安全的有序集合实现,支持高并发的读操作和部分并发的写操作。它采用跳表的数据结构,通过多层索引来提高查找效率,同时使用CAS操作来保证数据的一致性。ConcurrentSkipListSet提供了类似于TreeSet的接口,可以用于存储有序的元素集合,并且支持并发的访问和修改。
ConcurrentLinkedDeque(并发链表双端队列):它是一个线程安全的双端队列实现,支持高并发的入队和出队操作。它使用无锁的算法来实现并发性,每个节点都包含一个指向前后节点的引用,通过CAS操作来保证数据的一致性。ConcurrentLinkedDeque可以同时支持队列和栈的操作,并且可以在队列的两端进行插入、删除和检索操作。
二、并发集合概述
郑老师:常见的并发集合分为Concurrent系列和CopyOnWrite系列。阻塞队列其实也属于并发集合,不过已经讲过了,再次不再赘述。
三、ConcurrentHashMap
3.1 存储的结构
扩展:java最早的线程安全的HashMap是HashTable,但现在不用,因为它在每个方法中都加了一个synchronized,效率很低。(synchronized锁的是当前对象)
ConcurrentHashMap是线程安全的HashMap,在JDK1.7中用的是Segment分段锁,Segment继承了ReentrantLock,实现机制也很简单,其实就是把HashTable的锁细粒度话了。
当我们用HashTable操作时,无论用哪个线程操作,用的都是同一把锁,因为毕竟它锁的是同一对象;如果用了JDK1.7的Segment分段锁,他会分成好多个ReentrantLock锁,这样下来效率比HashTable有了一点提高。
ConcurrentHashMap在JDK1.8中做了进一步优化,基于CAS+synchronized做了“桶”锁实现的线程安全:
- CAS:在没有hash冲突时(Node要放在数组上时);
- synchronized:在出现hash冲突时(Node存放的位置已经有数据了);【尾插法】
存储的结构:数组+链表+红黑树 (JDK1.8)
JDK1.8中什么时候将链表结构转化为红黑树呢?当链表的长度≥8,且数组长度≥64时。为啥还需要数组长度≥64呢?因为尽量保证数据是分布在数组上的,这样查询效率才会高。
3.2 存储操作
3.2.1 put方法
public V put(K key, V value) {
// 在调用put方法时,会调用putVal,第三个参数默认传递为false
// 在调用putIfAbsent时,会调用putVal方法,第三个参数传递的为true
// 如果传递为false,代表key一致时,直接覆盖数据
// 如果传递为true,代表key一致时,什么都不做,key不存在,正常添加(类似于Redis,setnx)
return putVal(key, value, false);
}
3.2.2 putVal方法-散列算法
本节视频老师讲的很清楚:为什么HashMap、ConcurrentHashMap,都要求数组长度为2^n ?一句话概括:为了减少哈希冲突。具体说来,代码中有个 (n - 1) & hash 这样的运算,尽可能保证二进制中低位的数字都是1,这样才能尽量打散数据放到数组上,减少哈希冲突。
(本节视频即序号142节再看看,还有其他内容)
final V putVal(K key, V value, boolean onlyIfAbsent) {
// ConcurrentHashMap不允许key或者value出现为null的值,跟HashMap的区别
if (key == null || value == null) throw new NullPointerException();
// 根据key的hashCode计算出一个hash值,后期得出当前key-value要存储在哪个数组索引位置
int hash = spread(key.hashCode());
// 一个标识,在后面有用!
int binCount = 0;
// 省略大量的代码……
}
// 计算当前Node的hash值的方法
static final int spread(int h) {
// 将key的hashCode值的高低16位进行^运算,最终又与HASH_BITS进行了&运算
// 将高位的hash也参与到计算索引位置的运算当中
// 为什么HashMap、ConcurrentHashMap,都要求数组长度为2^n
// HASH_BITS让hash值的最高位符号位肯定为0,代表当前hash值默认情况下一定是正数,因为hash值为负数时,有特殊的含义
// static final int MOVED = -1; // 代表当前hash位置的数据正在扩容!
// static final int TREEBIN = -2; // 代表当前hash位置下挂载的是一个红黑树
// static final int RESERVED = -3; // 预留当前索引位置……
return (h ^ (h >>> 16)) & HASH_BITS;
// 计算数组放到哪个索引位置的方法 (f = tabAt(tab, i = (n - 1) & hash)
// n:是数组的长度
}
00001101 00001101 00101111 10001111 - h = key.hashCode
运算方式
00000000 00000000 00000000 00001111 - 15 (n - 1)
&
(
(
00001101 00001101 00101111 10001111 - h
^
00000000 00000000 00001101 00001101 - h >>> 16
)
&
01111111 11111111 11111111 11111111 - HASH_BITS
)
3.2.3 putVal方法-添加数据到数组&初始化数组
看到了如何基于CAS的方式将数据放在数组中,以及数组的初始化。
将底层数组 Node<K,V>[] table 初始化为大小16的数组,同时将下次扩容的volatile类型的sizeCtl属性改为12;
final V putVal(K key, V value, boolean onlyIfAbsent) {
// 省略部分代码…………
// 将Map的数组赋值给tab,死循环
for (Node<K,V>[] tab = table;;) {
// 声明了一堆变量~~
// n:数组长度
// i:当前Node需要存放的索引位置
// f: 当前数组i索引位置的Node对象
// fn:当前数组i索引位置上数据的hash值
Node<K,V> f; int n, i, fh;
// 判断当前数组是否还没有初始化
if (tab == null || (n = tab.length) == 0)
// 将数组进行初始化。
tab = initTable();
// 基于 (n - 1) & hash 计算出当前Node需要存放在哪个索引位置
// 基于tabAt获取到i位置的数据
else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
// 现在数组的i位置上没有数据,基于CAS的方式将数据存在i位置上
if (casTabAt(tab, i, null,new Node<K,V>(hash, key, value, null)))
// 如果成功,执行break跳出循环,插入数据成功
break;
}
// 判断当前位置数据是否正在扩容……
else if ((fh = f.hash) == MOVED)
// 让当前插入数据的线程协助扩容
tab = helpTransfer(tab, f);
// 省略部分代码…………
}
// 省略部分代码…………
}
sizeCtl:是数组在初始化和扩容操作时的一个控制变量
-1:代表当前数组正在初始化
小于-1:低16位代表当前数组正在扩容的线程个数(如果1个线程扩容,值为-2,如果2个线程扩容,值为-3)
0:代表数组还没初始化
大于0:代表当前数组的扩容阈值,或者是当前数组的初始化大小
// 初始化数组方法
private final Node<K,V>[] initTable() {
// 声明标识
Node<K,V>[] tab; int sc;
// 再次判断数组没有初始化,并且完成tab的赋值
while ((tab = table) == null || tab.length == 0) {
// 将sizeCtl赋值给sc变量,并判断是否小于0
if ((sc = sizeCtl) < 0)
Thread.yield();
// 可以尝试初始化数组,线程会以CAS的方式,将sizeCtl修改为-1,代表当前线程可以初始化数组
else if (U.compareAndSwapInt(this, SIZECTL, sc, -1)) {
// 尝试初始化!
try {
// 再次判断当前数组是否已经初始化完毕。
if ((tab = table) == null || tab.length == 0) {
// 开始初始化,
// 如果sizeCtl > 0,就初始化sizeCtl长度的数组
// 如果sizeCtl == 0,就初始化默认的长度
int n = (sc > 0) ? sc : DEFAULT_CAPACITY;
// 初始化数组!数组长度n为DEFAULT_CAPACITY即等于16
Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n];
// 将初始化的数组nt,赋值给tab和table
table = tab = nt;
// sc赋值为了数组长度 - 数组长度 右移 2位 16 - 4 = 12
// 将sc赋值为下次扩容的阈值
sc = n - (n >>> 2);
}
} finally {
// 将赋值好的sc,设置给sizeCtl
sizeCtl = sc;
}
break;
}
}
return tab;
}
3.2.4 putVal方法-添加数据到链表
- 覆盖的细节;
- 出现hash冲突时用的是synchronized锁, synchronized锁的是Node节点对象;
- 1.8中链表是尾插法(即:7上8下),链表转化红黑树的条件。
final V putVal(K key, V value, boolean onlyIfAbsent) {
// 省略部分代码…………
int binCount = 0;
for (Node<K,V>[] tab = table;;) {
Node<K,V> f; int n, i, fh;
// n:数组长度
// i:当前Node需要存放的索引位置
// f: 当前数组i索引位置的Node对象
// fn:当前数组i索引位置上数据的hash值
// 省略部分代码…………
else {
// 声明变量为oldVal
V oldVal = null;
// 基于当前索引位置的Node,作为锁对象……
synchronized (f) {
// 判断当前位置的数据还是之前的f么……(避免并发操作的安全问题)
if (tabAt(tab, i) == f) {
// 再次判断hash值是否大于0(不是树)
if (fh >= 0) {
// binCount设置为1(在链表情况下,记录链表长度的一个标识)
binCount = 1;
// 死循环,每循环一次,对binCount加1
for (Node<K,V> e = f;; ++binCount) {
// 声明标识ek
K ek;
// 当前i索引位置的数据,是否和当前put的key的hash值一致
if (e.hash == hash &&
// 如果当前i索引位置数据的key和put的key == 返回为true
// 或者equals相等
((ek = e.key) == key || (ek != null && key.equals(ek)))) {
// key一致,可能需要覆盖数据!
// 当前i索引位置数据的value赋值给oldVal
oldVal = e.val;
// 如果传入的是false,代表key一致,覆盖value
// 如果传入的是true,代表key一致,什么都不做!
if (!onlyIfAbsent)
// 覆盖value
e.val = value;
break;
}
// 拿到当前指定的Node对象
Node<K,V> pred = e;
// 将e指向下一个Node对象,如果next指向的是一个null,可以挂在当前Node下面
if ((e = e.next) == null) {
// 将hash,key,value封装为Node对象,挂在pred的next上
pred.next = new Node<K,V>(hash, key,
value, null);
break;
}
}
}
// 省略部分代码…………
}
}
// binCount长度不为0
if (binCount != 0) {
// binCount是否大于8(链表长度是否 >= 8)
if (binCount >= TREEIFY_THRESHOLD)
// 尝试转为红黑树或者扩容
// 基于treeifyBin方法和上面的if判断,可以得知链表想要转为红黑树,必须保证数组长度大于等于64,并且链表长度大于等于8
// 如果数组长度没有达到64的话,会首先将数组扩容
treeifyBin(tab, i);
// 如果出现了数据覆盖的情况,
if (oldVal != null)
// 返回之前的值
return oldVal;
break;
}
}
}
// 省略部分代码…………
}
// 为什么链表长度为8转换为红黑树,不是能其他数值嘛?
// 因为泊松分布
* The main disadvantage of per-bin locks is that other update
* operations on other nodes in a bin list protected by the same
* lock can stall, for example when user equals() or mapping
* functions take a long time. However, statistically, under
* random hash codes, this is not a common problem. Ideally, the
* frequency of nodes in bins follows a Poisson distribution
* (http://en.wikipedia.org/wiki/Poisson_distribution) with a
* parameter of about 0.5 on average, given the resizing threshold
* of 0.75, although with a large variance because of resizing
* granularity. Ignoring variance, the expected occurrences of
* list size k are (exp(-0.5) * pow(0.5, k) / factorial(k)). The
* first values are:
*
* 0: 0.60653066
* 1: 0.30326533
* 2: 0.07581633
* 3: 0.01263606
* 4: 0.00157952
* 5: 0.00015795
* 6: 0.00001316
* 7: 0.00000094
* 8: 0.00000006
* more: less than 1 in ten million
3.3 扩容操作
3.4 红黑树操作
什么是红黑树?
红黑树是一种特殊的平衡二叉树,首先具备了平衡二叉树的特点:左子树和右子数的高度差不会超过1,如果超过了,平衡二叉树就会基于左旋和右旋的操作,实现自平衡。
红黑树在保证自平衡的前提下,还保证了自己的几个特性:
-
每个节点必须是红色或者黑色。
-
根节点必须是黑色。
-
如果当前节点是红色,子节点必须是黑色
-
所有叶子节点都是黑色。
-
从任意节点到每个叶子节点的路径中,黑色节点的数量是相同的。
当对红黑树进行增删操作时,可能会破坏平衡或者是特性,这时红黑树就需要基于左旋、右旋、变色来保证平衡和特性。
红黑树的插入动画网址:https://www.cs.usfca.edu/~galles/visualization/RedBlack.html
3.5 查询数据
3.5.1 get方法-查询数据的入口
在查询数据时,会先判断当前key对应的value,是否在数组上;其次会判断当前位置是否属于特殊情况:数据被迁移、位置被占用、红黑树结构;最后判断链表上是否有对应的数据。找到返回指定的value,找不到返回null即可。【get整个操作没有加锁】
// 基于key查询value
public V get(Object key) {
// tab:数组, e:查询指定位置的节点 n:数组长度
Node<K,V>[] tab; Node<K,V> e, p; int n, eh; K ek;
// 基于传入的key,计算hash值
int h = spread(key.hashCode());
// 数组不为null,数组上得有数据,拿到指定位置的数组上的数据
if ((tab = table) != null && (n = tab.length) > 0 && (e = tabAt(tab, (n - 1) & h)) != null) {
// 数组上数据恩地hash值,是否和查询条件key的hash一样
if ((eh = e.hash) == h) {
// key的==或者equals是否一致,如果一致,数组上就是要查询的数据
if ((ek = e.key) == key || (ek != null && key.equals(ek)))
return e.val;
}
// 如果数组上的数据的hash为负数,有特殊情况,
else if (eh < 0)
// 三种情况,数据迁移走了,节点位置被占,红黑树
return (p = e.find(h, key)) != null ? p.val : null;
// 肯定走链表操作
while ((e = e.next) != null) {
// 如果hash值一致,并且key的==或者equals一致,返回当前链表位置的数据
if (e.hash == h && ((ek = e.key) == key || (ek != null && key.equals(ek))))
return e.val;
}
}
// 如果上述三个流程都没有知道指定key对应的value,那就是key不存在,返回null即可
return null;
}
若代码进入了上面的 else if 分支中,会走e.find(h,key)的代码,显然e是Node<K,V>类型的,而find方法有四个实现子类(如下图),具体走哪个find方法,就取决于e更细粒度上说是哪种类型的,比如如果是ForwardingNode<K,V>类型的,则走ForwardingNode<K,V>内部类里面的find方法。
(1)ForwardingNode<K,V>的find方法:表示正在查询的元素迁移到了新数组;
(2)ReservationNode<K,V>的find方法:直接返回null,因为当前桶位置被占用的话,说明数据还没放到当前位置,当前位置可以理解为就是null;
(3)TreeBin<K,V>的find方法:在红黑树中检索数据,会有两个情况:
-
如果有线程在持有写锁或者等待获取写锁,当前查询就要在双向链表中锁检索(为了避免读线程等待) ;
-
如果没有线程持有写锁或者等待获取写锁,完全可以对lockState + 4,然后去红黑树中检索,并且在检索完毕后,需要对lockState - 4,再判断是否需要唤醒等待写锁的线程。
总结:整个ConcurrentHashMap并不会让读线程阻塞。
3.6 ConcurrentHashMap其他方法
3.6.1 compute方法
使用场景:根据指定的key对其value进行一定的运算时。注意空指针异常(试试key不存在时,当前的计算会不会空指针;再试试即便key存在但值为null时,当前的计算会不会报空指针)。
compute方法源码分析:
- 整个流程和putVal方法很类似,但是内部涉及到了占位的情况RESERVED
- 整个compute方法和putVal的区别就是,compute方法的value需要计算,如果key存在,基于oldValue计算出新结果,如果key不存在,直接基于oldValue为null的情况,去计算新的value
computeIfPresent、computeIfAbsent、compute区别:
(1)compute的BUG,如果在计算结果的函数中,又涉及到了当前的key,会造成死锁问题。
public static void main(String[] args) {
ConcurrentHashMap<String,Integer> map = new ConcurrentHashMap();
map.compute("key",(k,v) -> {
return map.compute("key",(key,value) -> {
return 1111;
});
});
System.out.println(map);
}
(2)computeIfPresent和computeIfAbsent其实就是将compute方法拆开成了两个方法
compute会在key不存在时,正常存放结果,如果key存在,就基于oldValue计算newValue
computeIfPresent:要求key在map中必须存在,需要基于oldValue计算newValue
computeIfAbsent:要求key在map中不能存在,必须为null,才会基于函数得到value存储进去
3.6.2 replace方法
涉及到类似CAS的操作,需要将ConcurrentHashMap的value从val1改为val2的场景就可以使用replace实现。
replace内部要求key必须存在,替换value值之前,要先比较oldValue,只有oldValue一致时,才会完成替换操作。
3.6.3 merge方法
merge(key,value,Function<oldValue,value>);
在使用merge时,有三种情况可能发生:
-
如果key不存在,就跟put(key,value);
-
如果key存在,就可以基于Function计算,得到最终结果
-
结果不为null,将key对应的value,替换为Function的结果
-
结果为null,删除当前key
-
3.7 ConcurrentHashMap计数器
3.7.1 addCount方法(putVal内部方法)
addCount方法本身就是为了记录ConcurrentHashMap中元素的个数。
两个方向组成:
-
计数器,如果添加元素成功,对计数器 + 1
-
检验当前ConcurrentHashMap是否需要扩容
计数器选择的不是AtomicLong,而是类似LongAdder的一个功能
1.7.2 size方法
size获取ConcurrentHashMap中的元素个数,是基于sumCount()方法去获取大小的。线程安全。
问:size方法是如何获取元素个数的呢?怎么保证线程安全呢?
答:它本身就是线程安全的,因为计数器就是线程安全的;它只需要将baseCount和CounterCell[]数组元素个数累加,就是size的大小。
public int size() {
// 基于sumCount方法获取元素个数
long n = sumCount();
// 做了一些简单的健壮性判断
return ((n < 0L) ? 0 :
(n > (long)Integer.MAX_VALUE) ? Integer.MAX_VALUE :
(int)n);
}
// 整体CounterCell数组数据到baseCount
final long sumCount() {
// 拿到CounterCell[]
CounterCell[] as = counterCells; CounterCell a;
// 拿到baseCount
long sum = baseCount;
// 循环走你,遍历CounterCell[],将值累加到sum中,最终返回sum
if (as != null) {
for (int i = 0; i < as.length; ++i) {
if ((a = as[i]) != null)
sum += a.value;
}
}
return sum;
}
说明:以上所有内容都是基于JDK1.8分析的哦。下面补充个1.7的内容。
补充:JDK1.7的HashMap的环形链表
环形链表的发生,是因为并发扩容,加上头插法导致的。于是在JDK1.8中,头插法被替代,换成了尾插法。
如果面试被问到了:
因为JDK1.7中的HashMap是线程不安全的,可能会出现并发扩容的操作。
同时JDK1.7中的HashMap在迁移数据时,采用的是头插法,导致节点的next指针会有变化。
先迁移完的线程,可能会导致其他线程在扩容时,扩容到最后,将最开始的节点重新的插入到了头节点的位置,导致指针再次变化,从而形成了一个环形链表。
四、CopyOnWriteArrayList
4.1 概述
CopyOnWriteArrayList是一个线程安全的ArrayList。
CopyOnWriteArrayList是基于lock锁和数组副本的形式去保证线程安全。
在写数据时,需要先获取lock锁,需要复制一个副本数组,将数据插入到副本数组中,将副本数组赋值给CopyOnWriteArrayList中的array。
因为CopyOnWriteArrayList每次写数据都要构建一个副本,如果你的业务是写多,并且数组中的数据量比较大,尽量避免去使用CopyOnWriteArrayList,因为这里会构建大量的数组副本,比较占用内存资源。
CopyOnWriteArrayList是弱一致性的,写操作先执行,但是副本还有落到CopyOnWriteArrayList的array属性中,此时读操作是无法查询到的。
4.2 核心属性&方法
主要查看2个核心属性,以及2个核心方法,还有无参构造
/** 写操作时,需要先获取到的锁资源,CopyOnWriteArrayList全局唯一的。 */
final transient ReentrantLock lock = new ReentrantLock();
/** CopyOnWriteArrayList真实存放数据的位置,查询也是查询当前array */
private transient volatile Object[] array;
// 获取array属性
final Object[] getArray() {
return array;
}
// 替换array属性
final void setArray(Object[] a) {
array = a;
}
/**
* 默认new的CopyOnWriteArrayList数组长度为0。
* 不像ArrayList,初始长度是10,每次扩容1/2, CopyOnWriteArrayList不存在这个概念
* 每次写的时候都会构建一个新的数组
*/
public CopyOnWriteArrayList() {
setArray(new Object[0]);
}
4.3 读操作
CopyOnWriteArrayList的读操作就是get方法,基于数组索引位置获取数据。
方法之所以要差分成两个,是因为CopyOnWriteArrayList中在获取数据时,不单单只有一个array的数组需要获取值,还有副本中数据的值。
// 查询数据时,只能通过get方法查询CopyOnWriteArrayList中的数据
public E get(int index) {
// getArray拿到array数组,调用get方法的重载
return get(getArray(), index);
}
// 执行get(int)时,内部调用的方法
private E get(Object[] a, int index) {
// 直接拿到数组上指定索引位置的值
return (E) a[index];
}
4.4 写操作
CopyOnWriteArrayList是基于lock锁和副本数组的形式保证线程安全。
// 写入元素,不指定索引位置,直接放到最后的位置
public boolean add(E e) {
// 获取全局锁,并执行lock
final ReentrantLock lock = this.lock;
lock.lock();
try {
// 获取原数组,还获取了原数组的长度
Object[] elements = getArray();
int len = elements.length;
// 基于原数组复制一份副本数组,并且长度比原来多了一个
Object[] newElements = Arrays.copyOf(elements, len + 1);
// 将添加的数据放到副本数组最后一个位置
newElements[len] = e;
// 将副本数组,赋值给CopyOnWriteArrayList的原数组
setArray(newElements);
// 添加成功,返回true
return true;
} finally {
// 释放锁~
lock.unlock();
}
}
// 写入元素,指定索引位置。(不会覆盖数据)
public void add(int index, E element) {
// 拿锁,加锁~
final ReentrantLock lock = this.lock;
lock.lock();
try {
// 获取原数组,还获取了原数组的长度
Object[] elements = getArray();
int len = elements.length;
// 如果索引位置大于原数组的长度,或者索引位置是小于0的。
if (index > len || index < 0)
throw new IndexOutOfBoundsException("Index: "+index+
", Size: "+len);
// 声明了副本数组
Object[] newElements;
// 原数组长度 - 索引位置等到numMoved
int numMoved = len - index;
// 如果numMoved为0,说明数据要放到最后面的位置
if (numMoved == 0)
// 直接走了原生态的方式,正常复制一份副本数组
newElements = Arrays.copyOf(elements, len + 1);
else {
// 数组要插入的位置不是最后一个位置
// 副本数组长度依然是原长度 + 1
newElements = new Object[len + 1];
// 将原数组从0索引位置开始复制,复制到副本数组中的前置位置
System.arraycopy(elements, 0, newElements, 0, index);
// 将原数组从index位置开始复制,复制到副本数组的index + 1往后放。
// 这时,index就空缺出来了。
System.arraycopy(elements, index, newElements, index + 1,
numMoved);
}
// 数据正常放到指定的索引位置
newElements[index] = element;
// 将副本数组,赋值给CopyOnWriteArrayList的原数组
setArray(newElements);
} finally {
// 释放锁
lock.unlock();
}
}
4.5 移除数据
关于remove操作,要分析两个方法
-
基于索引位置移除指定数据
-
基于具体元素删除数组中最靠前的数据
-
当前这种方式,嵌套了一层,导致如果元素存在话,成本是比较高的。
-
如果元素不存在,这种设计不需要加锁,提升写的效率
-
// 删除指定索引位置的数据
public E remove(int index) {
// 拿锁,加锁
final ReentrantLock lock = this.lock;
lock.lock();
try {
// 获取原数组和原数组长度
Object[] elements = getArray();
int len = elements.length;
// 通过get方法拿到index位置的数据
E oldValue = get(elements, index);
// 声明numMoved
int numMoved = len - index - 1;
// 如果numMoved为0,说明删除的元素是最后的位置
if (numMoved == 0)
// Arrays.copyOf复制一份新的副本数组,并且将最后一个数据不要了
// 基于setArray将副本数组赋值给array原数组
setArray(Arrays.copyOf(elements, len - 1));
else {
// 删除的元素不在最后面的位置
// 声明副本数组,长度是原数组长度 - 1
Object[] newElements = new Object[len - 1];
// 从0开始复制的index前面
System.arraycopy(elements, 0, newElements, 0, index);
// 从index后面复制到最后
System.arraycopy(elements, index + 1, newElements, index,
numMoved);
setArray(newElements);
}
// 返回被干掉的数据
return oldValue;
} finally {
// 释放锁
lock.unlock();
}
}
// 删除元素(最前面的)
……
4.6 覆盖数据&清空集合
覆盖数据就是set方法,可以将指定位置的数据替换。清空就是清空了~~~
4.7 迭代器
用ArrayList时,如果想在遍历的过程中去移除或者修改元素,必须使用迭代器才可以。
但是CopyOnWriteArrayList这哥们即便用了迭代器也不让做写操作。
不让在迭代时做写操作是:不希望迭代操作时会影响到写操作,还有,不希望迭代时还需要加锁。
// 获取遍历CopyOnWriteArrayList的iterator。
public Iterator<E> iterator() {
// 其实就是new了一个COWIterator对象,并且获取了array,指定从0开始遍历
return new COWIterator<E>(getArray(), 0);
}
static final class COWIterator<E> implements ListIterator<E> {
/** 遍历的快照 */
private final Object[] snapshot;
/** 游标,索引~~~ */
private int cursor;
// 有参构造
private COWIterator(Object[] elements, int initialCursor) {
cursor = initialCursor;
snapshot = elements;
}
// 有没有下一个元素,基于遍历的索引位置和数组长度查看
public boolean hasNext() {
return cursor < snapshot.length;
}
// 有没有上一个元素
public boolean hasPrevious() {
return cursor > 0;
}
// 获取下一个值,游标动一下
public E next() {
// 确保下个位置有数据
if (! hasNext())
throw new NoSuchElementException();
return (E) snapshot[cursor++];
}
// 获取上一个值,游标往上移动
public E previous() {
if (! hasPrevious())
throw new NoSuchElementException();
return (E) snapshot[--cursor];
}
// 拿到下一个值的索引,返回游标
public int nextIndex() {
return cursor;
}
// 拿到上一个值的索引,返回游标
public int previousIndex() {
return cursor-1;
}
// 写操作全面禁止!!
public void remove() {
throw new UnsupportedOperationException();
}
public void set(E e) {
throw new UnsupportedOperationException();
}
public void add(E e) {
throw new UnsupportedOperationException();
}
// 兼容函数式编程
@Override
public void forEachRemaining(Consumer<? super E> action) {
Objects.requireNonNull(action);
Object[] elements = snapshot;
final int size = elements.length;
for (int i = cursor; i < size; i++) {
@SuppressWarnings("unchecked") E e = (E) elements[i];
action.accept(e);
}
cursor = size;
}
}