系列文章索引
并发系列:线程锁事
-
[篇三:从ReentrientLock看锁的正确使用姿势](创作中)
新系列:Android11系统源码解析
-
Android11源码分析:binder是如何实现跨进程的?(创作中)
-
Android11源码分析:SurfaceFlinger是如何对vsync信号进行分发的?(创作中)
经典系列:Android10系统启动流程
前言
前篇文章介绍了CountdownLatch
的使用及原理,而在并发中,除了多线程协作,我们还需要考虑如何实现高效并发
今天继续从源码的角度出发,聊一聊并发容器是如何实现高效并发的
下面,正文开始!
为什么Hashtable
并发效率低?
在提高效并发前,我们先讲一讲HashMap
和Hashtable
Hashmap
是面试中的高频考点,也是实现hash表的重要的数据结构,可以用来存储键值对信息,可以根据hash快速的查找对一个的value值
其中最重要的两个函数是put()
和get()
, Key和Value均可以通过范型指定
put()
函数中会对key值进行hash,并调用putVal()
将数据进行存储,内部使用数组 + 链表 + 红黑树
的方式进行存储
其中,数组用来存储对应的Node<K,V>保存键值对信息,在发生hash冲突后,会使用开链法
解决hash冲突问题,这里对应的就是Node
就是链表的实现。在重复的hash值>8时,则会对其进行树化,使用红黑树进行处理,降低查询的时间复杂度(时间复杂度为O(nlog(n)),但代价是会占用额外的空间)
在平常的使用中,发生hash冲突发生8次以上的概率为千万分之一,因此一般情况下不会发生树化
在初始化时,会调用resize()
对数组进行初始化大小的赋值,初始大小为1>>2
,即16,最大大小为1>>30
,约为10亿
当后续put数据大小不足时,会扩容为原来的2倍(oldVal>>1),并创建新的数组进行搬移操作
具体逻辑请查看以下代码中的注释
static final int MAXIMUM_CAPACITY = 1 << 30; //最大大小约为10亿
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // 初始值为16
final Node<K,V>[] resize() {
Node<K,V>[] oldTab = table;
int oldCap = (oldTab == null) ? 0 : oldTab.length;
int oldThr = threshold;
int newCap, newThr = 0;
if (oldCap > 0) {
if (oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return oldTab;
}
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY && //最大大小为1>>30,约为10亿
oldCap >= DEFAULT_INITIAL_CAPACITY) //初始大小为16
newThr = oldThr << 1; // double threshold // 扩容大小为原来对2倍(<<1)
}
//...略
threshold = newThr;
@SuppressWarnings({"rawtypes","unchecked"})
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap]; //创建一个新的数组
table = newTab;
if (oldTab != null) {
for (int j = 0; j < oldCap; ++j) { //将原数组搬移到新数组中
Node<K,V> e;
if ((e = oldTab[j]) != null) { //将原来的元素指向新的引用
oldTab[j] = null; //将原来的元素置为null(便于GC进行回收)
if (e.next == null) //判断是否有重复hash存储在当前链表中,
newTab[e.hash & (newCap - 1)] = e; 如果没有则直接放在新的数组的位置
else if (e instanceof TreeNode) //如果是使用TreeNode进行存储,则需要对TreeNode进行搬移处理
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
else { // preserve order //如果是链表结构,则需要循环遍历链表,完成链表的搬移操作
//...略
}
}
}
}
return newTab;
}
要对Hahstable进行数据同步,需要保护的数据及为在put()
和get()
中多个线程所操作的共享数据
HahshTable
的实现同步直接在这两个函数上加上了synchornize
,但弊端在于锁的粒度过大,且put
执行是相当耗时的操作,所以该类早就不推荐使用了,因此才有了高效并发容器CurrentHashMap
CurrentHashMap
做了哪些优化工作?
我们先从java7
版本的CurrentHashMap
说起
Java7版本中的并发优化
在Java7中,使用分段锁对数据进行并发操作,其中会使用Segment
对数据进行分段加锁,Segment
本身是ReentrantLock
的子类对象,在对每个Segement进行数据put
操作时,其中的逻辑与Hashmap
基本一致,同样会使用数组存储键值对信息,在发生hash冲突
时会使用拉链法,用链表对其进行维护
在CurrentHashmap
进行初始化时,可以指定Segment的数量,即指定并发度,如果未指定或超过了最大值(DEFAULT_CONCURRENCY_LEVEL)16
,则会初始化为16个Segment
,使用数组进行维护
初始化代码如下
public ConcurrentHashMap(int initialCapacity,
float loadFactor, int concurrencyLevel) { //可以通过concurrencyLevel设置它的并发度(支持多少个线程)
//...略
if (!(loadFactor > 0) || initialCapacity < 0 || concurrencyLevel <= 0)
throw new IllegalArgumentException();
//segment是ReentrantLock的是实现类,用来支持并发操作,即Currenthashmap最高支持16个线程同时进行并发操作(16个锁对象)
Segment<K,V> s0 =
new Segment<K,V>(loadFactor, (int)(cap * loadFactor),
(HashEntry<K,V>[])new HashEntry[cap]); //创建第一个Segment
Segment<K,V>[] ss = (Segment<K,V>[])new Segment[ssize];//创建segment数组
}
}
java8版本中的并发优化
针对我们现在使用的Java8
版本的CurrentHashMap,又针对java7
版本进行了进一步优化
具体优化在于舍弃了Segment
分段锁的同步思想,改为使用CAS
操作+ synchronize
实现,并发度由原来支持16个线程同时执行,提高到每个数据节点都可以支持并发操作
另外,在此基础上,在发生hash冲突时,如果hash冲突次数小于8
,继续使用链表解决冲突问题;如果hash冲突次数大于8
,则会对其进行树化,时间复杂度由原来的0(n)
降低为O(nlongn)
,但缺点是红黑树也会占用更多的内存空间
小结
在java7版本中,CurrentHashmap
采用Segment
对数据进行分段操作,最高支持16个线程同时并发执行
在java8版本中,CurrentHashmap
采用CAS
的方式对每个HashEntry数据进行并发操作,提高了并发度;并在hash冲突次数大于8时,会对链表进行树化,减少查找的时间复杂度
CopyOnWriteArrayList
是如何实现高效并发的?
CopyOnWriteArrayList
的主要思想是,写时复制,读写分离
在进行add()
操作时,会将原数组拷贝一份,在拷贝的数据上进行修改,修改完成后再将其指向原数组,完成数据的更新
具体代码如下
public void add(int index, E element) {
final ReentrantLock lock = this.lock;
lock.lock(); //加锁,保证多线程操作的原子性
try {
Object[] elements = getArray(); //获取到原数组
int len = elements.length;
if (index > len || index < 0)
throw new IndexOutOfBoundsException("Index: "+index+
", Size: "+len);
Object[] newElements; //创建新数组,并将原数组拷贝进去
int numMoved = len - index;
if (numMoved == 0)
newElements = Arrays.copyOf(elements, len + 1);
else {
newElements = new Object[len + 1];
System.arraycopy(elements, 0, newElements, 0, index);
System.arraycopy(elements, index, newElements, index + 1,
numMoved);
}
newElements[index] = element; //在拷贝数组上进行数据添加操作
setArray(newElements); //将拷贝数组指向原数组
} finally {
lock.unlock(); //解锁,保证完成后对锁进行释放
}
}
这样做的好处在于,当我们需要进行读操作时,由于每次的add()
操作都不会对原数组直接修改,相当于原数组是不可变的,因此在get()
方法进行取数据时,完全不需要加锁,直接取就可以了
代码如下
private E get(Object[] a, int index) {
return (E) a[index];
}
小结
CopyOnWriteArrayList
的通过写时复制,读写分离实现了在无锁的get()
操作,因此在读多写少的场景下,CopyOnWriteArrayList
可以实现高效的并发操作
深入思考:高效并发的本质是什么?
在上文中,我们对HashTable
,CurrentHashMap
和CopyOnWriteArrayList
进行了源码分析;低效的并发往往将逻辑简单化,使用同一把锁对数据进行保护
高效的并发
要么将锁的粒度细化,比如使用分段锁将数据分段,或者使用CAS操作(乐观锁)来支持每个数据更新的原子性,或者将读写进行分离(读写锁也是读写分离的思想)
要么利用数据的不变性,只要数据不被改变,就没有并发问题
后记
针对并发系列已经完成了第二篇,对并发容器的相关进行了分析,在本文中提到了一些锁的类型,比如悲观锁/乐观锁
,读锁/写锁
,粗粒度锁/细粒度锁
,等等
下篇文章中我们将对这些锁
的类型,从源码的角度,结合相关理论进行分析
我是释然,我们下期文章再见!