今天利用下班之后的一点时间来整理一下之前看过的有关JUC集合的东西吧。
目录:
1.List和Set
2.Map
3.延伸内容
在JUC List和Set集合中,比较常见的有三个
- CopyOnWriteArrayList
- ConcurrentSkipListSet
- ConcurrentHashMap
CopyOnWriteArrayList
其中CopyOnWriteArrayList
,简单看一下它的声明:
CopyOnWriteArrayList implements List<E>, RandomAccess, Cloneable, java.io.Serializable
再看一下它内部的两个重要成员变量:
transient volatile Object[] array,
transient ReentrantLock lock = new ReentrantLock();
一个是volatile
类型的对象数组,一个是一个可重入锁ReentrantLock
。
我们知道,无论是ArrayList
还是CopyOnWriteArrayList
,其内部的实现原理都是基于一个对象数组,那么在ArrayList
类中,其内部的对象数组并没有用volatile
来修饰,那么CopyOnWriteArrayList
内部的这个数组为什么要用该关键字来修饰呢?
这个时候就要想到这个关键字的作用了:为了保证每个线程在对该数组进行操作时,总能看到其它线程对该volatile变量最后的写入
。这样一来,就能够完全保证每个线程读取到该数组的值都是修改之后,最新的。(但是它并没有使线程同步的功能)
另外多说一点个人理解,之前看过一些java内存模型,这个关键字之所以有上述的作用,其实是由于它能够插入一些内存屏障来阻止指令重排序,所以才能起到上述的作用。
那么ReentrantLock
在这里的作用是什么呢?
既然它是锁嘛,所以起的肯定就是一个同步的作用。
那么将这两个属性放到一起来理解该CopyOnWriteArrayList
是如何保证线程安全,做到同步的呢?
首先ReentrantLock
排它锁保证每一次只能有一个线程对其内部的数据进行访问,这首先保证了互斥,保证 了临界资源的安全性。
接着某个线程对其进行了写
操作之后,由于在java内存模型中,每个线程都有自己独立的内存空间,也叫线程缓存。而所有线程共享访问的是主存区域的变量、资源。所以当某个线程对某个资源进行了写
操作之后,如果没有及时更新到主存中去,那么其他线程访问的这个资源则就是已过期的,无效的。这个时候关键字volatile
通过在底层加入内存屏障,防止指令重排序,来保证每个线程在对该数组进行操作时,总能看到其它线程对该volatile变量最后的写入。(注:有关volatile关键字的具体原理大家可以去看一下java内存模型)
这样最终就做到了线程之间的同步以及资源的安全。
CopyOnWriteArrayList
特点:
在进行(add,set,remove)操作时需要重新复制一份原集合数据(Arrays.copyOf),然后再此基础上再进行操作,最后再与原集合合并。
所以,基于上边的那个特点,可以推测出CopyOnWriteArrayList的应用场景如下:
- CopyOnWriteArrayList尽可能的用在读操作多于写操作的业务逻辑中
- 由于其内部有一个锁ReentrantLock,所以支持并发操作,能够保证线程安全。
- 其迭代器支持hasNext(), next()等不可变操作,但不支持可变 remove()等操作。
- 使用迭代器(在该类中,迭代器的类型为COWIterator)进行遍历的速度很快,并且不会与其他线程发生冲突。在构造迭代器时,迭代器依赖于不变的数组快照。
接着从add
方法源码的角度来分析一下,顺便提高一下自己阅读代码的能力,啊哈哈:
public boolean add(E e) {
final ReentrantLock lock = this.lock;
// 获取“锁”
lock.lock();
try {
// 获取原始”volatile数组“中的数据和数据长度。
Object[] elements = getArray();
int len = elements.length;
// 新建一个数组newElements,并将原始数据拷贝到newElements中;
// newElements数组的长度=“原始数组的长度”+1
Object[] newElements = Arrays.copyOf(elements, len + 1);
// 将“新增加的元素”保存到newElements中。
newElements[len] = e;
// 将newElements赋值给”volatile数组“。
setArray(newElements);
return true;
} finally {
// 释放“锁”
lock.unlock();
}
}
实现原理无非就以下几步:
- 首先获取一个Lock,然后进行加锁操作。
- 然后调用getArray()方法来获取原来的数据集合,
- 调用Arrays.copyOf()方法来复制一个新的数组,该数组长度+1
- 然后将新的元素放入到数组的最后一位
- 最后再调用setArray()方法来重新赋值给valatile数组
CopyOnWriteArraySet
首先来看一下这个类的声明:
CopyOnWriteArraySet extends AbstractSet<E> implements java.io.Serializable
其内部的一个重要的成员属性:
final CopyOnWriteArrayList<E> al
由此可以推断,CopyOnWriteArraySet
是由内部的CopyOnWriteArrayList
实现的。其实事实也是如此。
所以CopyOnWriteArraySet
所具有的特点其实就是CopyOnWriteArrayList
的那几个特点:宜读不宜写,线程安全,迭代速度快,无remove方法。
由于CopyOnWriteArraySet
不允许有重复的元素,那么来看一下其内部是如何实现的:
public boolean add(E e) {
return al.addIfAbsent(e);
}
很明显,该方法其实是CopyOnWriteArrayList
的addIfAbsent的方法实现,那么来看一下addIfAbsent
的方法实现吧:
public boolean addIfAbsent(E e) {
Object[] snapshot = getArray();
return indexOf(e, snapshot, 0, snapshot.length) >= 0 ? false : addIfAbsent(e, snapshot);
}
实现原理简单概括一下就两步:
- 首先获取一个原数组的
快照
,然后调用indexOf()方法来判断原数组中是否有E对象, - 如果有,则返回false,否则添加到数组中。
ConcurrentHashMap
首先看一下ConcurrentHashMap
类的声明:
ConcurrentHashMap extends AbstractMap<K,V> implements ConcurrentMap<K,V>, Serializable
实现原理:锁分段
ConcurrentHashMap将哈希表分成许多片段(Segment),每一个片段除了保存哈希表之外,本质上也是一个“可重入的互斥锁”(ReentrantLock)。多线程对同一个片段的访问,是互斥的;但是,对于不同片段的访问,却是可以同步进行的。
在jdk1.7中,ConcurrentHashMap大致是这样被实现的:
一个ConcurrentHashMap对应着n个Segment片段,然后每个Segment片段又对应着n个HashEntry<K,V>。以get方法为例:
public V get(Object key) {
Segment<K,V> s; // manually integrate access methods to reduce overhead
HashEntry<K,V>[] tab;
int h = hash(key);
long u = (((h >>> segmentShift) & segmentMask) << SSHIFT) + SBASE;
// 获取key对应的Segment片段。
// 如果Segment片段不为null,则在“Segment片段的HashEntry数组中”中找到key所对应的HashEntry列表;
// 接着遍历该HashEntry链表,找到于key-value键值对对应的HashEntry节点。
if ((s = (Segment<K,V>)UNSAFE.getObjectVolatile(segments, u)) != null &&
(tab = s.table) != null) {
for (HashEntry<K,V> e = (HashEntry<K,V>) UNSAFE.getObjectVolatile
(tab, ((long)(((tab.length - 1) & h)) << TSHIFT) + TBASE);
e != null; e = e.next) {
K k;
if ((k = e.key) == key || (e.hash == h && key.equals(k)))
return e.value;
}
}
return null;
}
实现过程概括为两步:
- 首先将Key值hash,然后获取key所对应的Segment片段。
- 如果Segment片段不为null,则在Segment片段的HashEntry数组中找到key所对应的HashEntry列表;遍历该HashEntry链表,找到于key-value键值对对应的HashEntry节点。
增加方法put:
public V put(K key, V value) {
Segment<K,V> s;
if (value == null)
throw new NullPointerException();
// 获取key对应的哈希值
int hash = hash(key);
int j = (hash >>> segmentShift) & segmentMask;
// 如果找不到该Segment,则新建一个。
if ((s = (Segment<K,V>)UNSAFE.getObject // nonvolatile; recheck
(segments, (j << SSHIFT) + SBASE)) == null) // in ensureSegment
s = ensureSegment(j);
return s.put(key, hash, value, false);
}
实现过程大致如下:
- put()根据key获取对应的哈希值,再根据哈希值找到对应的Segment片段。如果Segment片段不存在,则新增一个Segment。
- 将key-value键值对添加到Segment片段中。
** 该类解决hash冲突的办法为:拉链法 **
通过上边的两个实现,再结合jdk1.8对其的优化,可以想到,利用segment片段来进行分段加锁访问,其实这种控制粒度相对来说还比较大,如果能够精确控制到某一个HashEntry上,那么这种效率就更高了。其实jdk1.8就是这样做的。
jdk1.8的优化有两方面的内容:
1.去掉了segment,直接采用Node<K,V> table来保存数据,用table数组元素作为锁,从而实现了对每一行数据进行加锁,进一步减少并发冲突的概率。
2.将原先table数组+单向链表的数据结构,变更为table数组+单向链表+红黑树的结构。
相关知识
1.解决冲突的其他方法:
- 开放地址法:如果冲突,则向后查找,直接有一个空位置
- 建立公共溢出区:多分配一块内存用以存放冲突的对象
- 再哈希法
2.红黑书参考资料:
3.ConcurrentHashMap学习资料:
后记
并发集合框架是自己平时空闲时间里面边看博客边看代码学习的,今天稍作整理,来记录一下自己的学习过程和成果。不过还有许多并发类,比如ConcurrentSkipListMap
没来得及看。抽空多复习一下自己总结的,同时也要保持一颗好奇心多去了解一些新奇的东西。我觉得我会做的越来越好的!