并发容器
1.时间复杂度
算法(Algorithm)是指用来操作数据、解决程序问题的一组方法。对于同一个问题,使用不同的算法,也许最终得到的结果是一样的,比如排序就有前面的十大经典排序和几种奇葩排序,虽然结果相同,但在过程中消耗的资源和时间却会有很大的区别,比如快速排序与猴子排序:)。
那么我们应该如何去衡量不同算法之间的优劣呢?
主要还是从算法所占用的「时间」和「空间」两个维度去考量。
-
时间维度:是指执行当前算法所消耗的时间,我们通常用「时间复杂度」来描述。
-
空间维度:是指执行当前算法需要占用多少内存空间,我们通常用「空间复杂度」来描述。
本小节将从「时间」的维度进行分析。
1.1 什么是大O
当看「时间」二字,我们肯定可以想到将该算法程序运行一篇,通过运行的时间很容易就知道复杂度了。
这种方式可以吗?当然可以,不过它也有很多弊端。
比如程序员小吴的老式电脑处理10w数据使用冒泡排序要几秒,但读者的iMac Pro 可能只需要0.1s,这样的结果误差就很大了。更何况,有的算法运行时间要很久,根本没办法没时间去完整的运行,还是比如猴子排序:)。
那有什么方法可以严谨的进行算法的时间复杂度分析呢?
有的!
「 远古 」的程序员大佬们提出了通用的方法:「 大O符号表示法 」,即 T(n) = O(f(n))。
其中 n 表示数据规模 ,O(f(n))表示运行算法所需要执行的指令数,和f(n)成正比。
上面公式中用到的 Landau符号是由德国数论学家保罗·巴赫曼(Paul Bachmann)在其1892年的著作《解析数论》首先引入,由另一位德国数论学家艾德蒙·朗道(Edmund Landau)推广。Landau符号的作用在于用简单的函数来描述复杂函数行为,给出一个上或下(确)界。在计算算法复杂度时一般只用到大O符号,Landau符号体系中的小o符号、Θ符号等等比较不常用。这里的O,最初是用大写希腊字母,但现在都用大写英语字母O;小o符号也是用小写英语字母o,Θ符号则维持大写希腊字母Θ。
注:本文用到的算法中的界限指的是最低的上界。
1.2 常见的时间复杂度量级
我们先从常见的时间复杂度量级进行大O的理解:
-
常数阶O(1)
-
线性阶O(n)
-
平方阶O(n²)
-
对数阶O(logn)
-
线性对数阶O(nlogn)
O(1)
无论代码执行了多少行,其他区域不会影响到操作,这个代码的时间复杂度都是O(1)
void swapTwoInts(int &a, int &b){
int temp = a;
a = b;
b = temp;
}
O(n)
在下面这段代码,for循环里面的代码会执行 n 遍,因此它消耗的时间是随着 n 的变化而变化的,因此可以用O(n)来表示它的时间复杂度。
int sum ( int n ){
int ret = 0;
for ( int i = 0 ; i <= n ; i ++){
ret += i;
}
return ret;
}
O(n2)
当存在双重循环的时候,即把 O(n) 的代码再嵌套循环一遍,它的时间复杂度就是 O(n²) 了。
void selectionSort(int arr[],int n){
for(int i = 0; i < n ; i++){
int minIndex = i;
for (int j = i + 1; j < n ; j++ )
if (arr[j] < arr[minIndex])
minIndex = j;
swap ( arr[i], arr[minIndex]);
}
}
这里简单的推导一下
- 当 i = 0 时,第二重循环需要运行 (n - 1) 次
- 当 i = 1 时,第二重循环需要运行 (n - 2) 次
- 。。。。。。
不难得到公式:
(n - 1) + (n - 2) + (n - 3) + ... + 0
= (0 + n - 1) * n / 2
= O (n ^2)
O(logn)
int binarySearch( int arr[], int n , int target){
int l = 0, r = n - 1;
while ( l <= r) {
int mid = l + (r - l) / 2;
if (arr[mid] == target) return mid;
if (arr[mid] > target ) r = mid - 1;
else l = mid + 1;
}
return -1;
}
在二分查找法的代码中,通过while循环,成 2 倍数的缩减搜索范围,也就是说需要经过 log2^n 次即可跳出循环。
O(nlogn)
将时间复杂度为O(logn)的代码循环N遍的话,那么它的时间复杂度就是 n * O(logn),也就是了O(nlogn)。
void hello (){
for( m = 1 ; m < n ; m++){
i = 1;
while( i < n ){
i = i * 2;
}
}
}
2.HashMap源码分析
3.ConcurrentHashMap
Hashmap多线程会导致HashMap的Entry链表形成环形数据结构,一旦形成环形数据结构,Entry的next节点永远不为空,就会产生死循环获取Entry。
HashTable使用synchronized来保证线程安全,但在线程竞争激烈的情况下HashTable的效率非常低下。因为当一个线程访问HashTable的同步方法,其他线程也访问HashTable的同步方法时,会进入阻塞或轮询状态。如线程1使用put进行元素添加,线程2不但不能使用put方法添加元素,也不能使用get方法来获取元素,所以竞争越激烈效率越低。
putIfAbsent() :没有这个值则放入map,有这个值则返回key本来对应的值。
3.1 JDK1.7中原理和实现
ConcurrentHashMap中的数据结构
ConcurrentHashMap是由Segment数组结构和HashEntry数组结构组成。Segment实际继承自可重入锁(ReentrantLock),在ConcurrentHashMap里扮演锁的角色;HashEntry则用于存储键值对数据。一个ConcurrentHashMap里包含一个Segment数组,每个Segment里包含一个HashEntry数组,我们称之为table,每个HashEntry是一个链表结构的元素。
初始化做了什么事?
初始化有三个参数
- initialCapacity:初始容量大小 ,默认16。
- loadFactor, 扩容因子,默认0.75,当一个Segment存储的元素数量大于initialCapacity* loadFactor时,该Segment会进行一次扩容。
- concurrencyLevel 并发度,默认16。并发度可以理解为程序运行时能够同时更新ConccurentHashMap且不产生锁竞争的最大线程数,实际上就是ConcurrentHashMap中的分段锁个数,即Segment[]的数组长度。如果并发度设置的过小,会带来严重的锁竞争问题;如果并发度设置的过大,原本位于同一个Segment内的访问会扩散到不同的Segment中,CPU cache命中率会下降,从而引起程序性能下降
构造方法中部分代码解惑
保证Segment数组的大小,一定为2的幂,例如用户设置并发度为17,则实际Segment数组大小则为32
保证每个Segment中tabel数组的大小,一定为2的幂,初始化的三个参数取默认值时,table数组大小为2
初始化Segment数组,并实际只填充Segment数组的第0个元素。
用于定位元素所在segment。segmentShift表示偏移位数,通过前面的int类型的位的描述我们可以得知,int类型的数字在变大的过程中,低位总是比高位先填满的,为保证元素在segment级别分布的尽量均匀,计算元素所在segment时,总是取hash值的高位进行计算。segmentMask作用就是为了利用位运算中取模的操作:a % 2 n = a & (2 n - 1)
在get和put操作中,是如何快速定位元素放在哪个位置的?
对于某个元素而言,一定是放在某个segment元素的某个table元素中的,所以在定位上,
定位segment:取得key的hashcode值进行一次再散列(通过Wang/Jenkins算法),拿到再散列值后,以再散列值的高位进行取模得到当前元素在哪个segment上。
定位table:同样是取得key的再散列值以后,用再散列值的全部和table的长度进行取模,得到当前元素在table的哪个元素上。
get()方法
定位segment和定位table后,依次扫描这个table元素下的的链表,要么找到元素,要么返回null。
在高并发下的情况下如何保证取得的元素是最新的?
答:用于存储键值对数据的HashEntry,在设计上它的成员变量value等都是volatile类型的,这样就保证别的线程对value值的修改,get方法可以马上看到。
put()方法
-
1.首先定位segment,当这个segment在map初始化后,还为null,由ensureSegment方法负责填充这个segment。
-
2.对Segment 加锁
-
3.定位所在的table元素,并扫描table下的链表,
找到时:
没有找到时:
扩容操作
Segment 不扩容,扩容下面的table数组,每次都是将数组翻倍
带来的好处
假设原来table长度为4,那么元素在table中的分布是这样的:
Hash值 | 15 | 23 | 34 | 56 | 77 |
---|---|---|---|---|---|
在table中下标 | 3 = 15%4 | 3 = 23 % 4 | 2 = 34%4 | 0 = 56%4 | 1 = 77 % 4 |
扩容后table长度变为8,那么元素在table中的分布变成:
Hash值 | 56 | 34 | 77 | 15,23 | ||||
---|---|---|---|---|---|---|---|---|
下标 | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 |
可以看见 hash值为34和56的下标保持不变,而15,23,77的下标都是在原来下标的基础上+4即可,可以快速定位和减少重排次数。
size方法
size的时候进行两次不加锁的统计,两次一致直接返回结果,不一致,重新加锁再次统计
弱一致性
get方法和containsKey方法都是通过对链表遍历判断是否存在key相同的节点以及获得该节点的value。但由于遍历过程中其他线程可能对链表结构做了调整,因此get和containsKey返回的可能是过时的数据,这一点是ConcurrentHashMap在弱一致性上的体现。
3.2 JDK1.8中原理和实现
与1.7相比的重大变化
-
1.取消了segment数组,直接用table保存数据,锁的粒度更小,减少并发冲突的概率。
-
2.存储数据时采用了链表+红黑树的形式,纯链表的形式时间复杂度为O(n),红黑树则为O(logn),性能提升很大。什么时候链表转红黑树?当key值相等的元素形成的链表中元素个数超过8个的时候。
主要数据结构和关键变量
Node类存放实际的key和value值。
sizeCtl:
负数:表示进行初始化或者扩容,-1表示正在初始化,-N,表示有N-1个线程正在进行扩容
正数:0 表示还没有被初始化,>0的数,初始化或者是下一次进行扩容的阈值
TreeNode 用在红黑树,表示树的节点, TreeBin是实际放在table数组中的,代表了这个红黑树的根。
初始化做了什么事?
只是给成员变量赋值,put时进行实际数组的填充
在get和put操作中,是如何快速定位元素放在哪个位置的?
get()方法
put()方法
数组的实际初始化
扩容操作
transfer()方法进行实际的扩容操作,table大小也是翻倍的形式,有一个并发扩容的机制。
size方法
估计的大概数量,不是精确数量
一致性
弱一致
4.ConcurrentSkipListMap 和 ConcurrentSkipListSet
TreeMap和TreeSet有序的容器,这两种容器的并发版本
5.跳表SkipList
跳跃表(SkipList)是一种可以替代平衡树的数据结构。跳跃表让已排序的数据分布在多层次的链表结构中,默认是将Key值升序排列的,以 0-1 的随机值决定一个数据是否能够攀升到高层次的链表中。它通过容许一定的数据冗余,达到 “以空间换时间” 的目的。
5.1 有序表的搜索
考虑一个有序表:
从该有序表中搜索元素 < 23, 43, 59 > ,需要比较的次数分别为 < 2, 4, 6 >,总共比较的次数
为 2 + 4 + 6 = 12 次。有没有优化的算法吗? 链表是有序的,但不能使用二分查找。类似二叉
搜索树,我们把一些节点提取出来,作为索引。得到如下结构:
提取出来作为一级索引,这样搜索的时候就可以减少比较次数了。
我们还可以再从一级索引提取一些元素出来,作为二级索引,三级索引…
5.2 跳表
下面的结构是就是跳表:
其中 -1 表示 INT_MIN, 链表的最小值,1 表示 INT_MAX,链表的最大值。
5.2.1 跳表具有如下性质
- (1) 由很多层结构组成
- (2) 每一层都是一个有序的链表
- (3) 最底层(Level 1)的链表包含所有元素
- (4) 如果一个元素出现在 Level i 的链表中,则它在 Level i 之下的链表也都会出现。
- (5) 每个节点包含两个指针,一个指向同一链表中的下一个元素,一个指向下面一层的元素。
5.2.2 跳表的搜索
例子:查找元素 117
(1) 比较 21, 比 21 大,往后面找
(2) 比较 37, 比 37大,比链表最大值小,从 37 的下面一层开始找
(3) 比较 71, 比 71 大,比链表最大值小,从 71 的下面一层开始找
(4) 比较 85, 比 85 大,从后面找
(5) 比较 117, 等于 117, 找到了节点。
5.2.3 跳表的实现
首先,我们需要定义链表中节点的模型
Java代码实现如下:
// data
public String key;
public Integer value;
// links
public SkipListEntry up;
public SkipListEntry down;
public SkipListEntry left;
public SkipListEntry right;
// special
public static final String negInf = "-oo";
public static final String posInf = "+oo";
// constructor
public SkipListEntry(String key, Integer value) {
this.key = key;
this.value = value;
}
// methods...
}
可以看到节点模型主要分为2个部分。
data部分包含具体的存储数据,这里为了不引入其他杂乱的问题,使用String作为key的类型,Integer作为value的类型。
links部分包含4个指针,分别是up,down,left,right,单从名字上就能够明白它们的作用。
最后一个需要解释的是,2个special的字符串变量,它们是用来处理一些特殊节点的初始化的,从上面的图中可以看到,跳跃表每一层链表中都有那么2个节点。具体的初始化方法如下:
SkipListEntry x = new SkipListEntry(SkipListEntry.negInf, null);
SkipListEntry y = new SkipListEntry(SkipListEntry.posInf, null);
接下来,我们回到跳跃表本身的模型
public class SkipList {
public SkipListEntry head; // First element of the top level
public SkipListEntry tail; // Last element of the top level
public int n; // number of entries in the Skip List
public int h; // Height
public Random r; // Coin toss
}
Note: Random类的实例对象r用来决定新添加的节点是否能够向更高一层的链表攀升。
初始化一个跳跃表的实例
构造函数将初始化一个空的跳跃表看起来像下面这样:
构造函数的Java代码:
// constructor
public SkipList() {
SkipListEntry p1, p2;
// 创建一个 -oo 和一个 +oo 对象
p1 = new SkipListEntry(SkipListEntry.negInf, null);
p2 = new SkipListEntry(SkipListEntry.posInf, null);
// 将 -oo 和 +oo 相互连接
p1.right = p2;
p2.left = p1;
// 给 head 和 tail 初始化
head = p1;
tail = p2;
n = 0;
h = 0;
r = new Random();
}
实现Map的基本操作
Map的基本操作:
-
get(String key) : 根据key值查找某个元素
-
put(String key, Integer value) :插入一个新的元素,元素已存在时为修改操作
-
remove(String key): 根据key值删除某个元素
虽然看似是3个不同的操作,但是究其本质,要实现这3个操作,都得先找到某个元素 或是 定位到一个元素,好在下一个位子插入新元素。那么,我们就先把这个findEntry的方法实现吧。
上面的图示使用紫色的箭头画出了在一个SkipList中查找key值50的过程。简述如下:
1.从head出发,因为head指向最顶层(top level)链表的开始节点,相当于从顶层开始查找;
2.移动到当前节点的右指针(right)指向的节点,直到右节点的key值大于要查找的key值时停止;
3.如果还有更低层次的链表,则移动到当前节点的下一层节点(down),如果已经处于最底层,则退出;
4.重复第2步 和 第3步,直到查找到key值所在的节点,或者不存在而退出查找;
Java代码实现如下:
private SkipListEntry findEntry(String key) {
SkipListEntry p;
// 从head头节点开始查找
p = head;
while(true) {
// 从左向右查找,直到右节点的key值大于要查找的key值
while(p.right.key != SkipListEntry.posInf
&& p.right.key.compareTo(key) <= 0) {
p = p.right;
}
// 如果有更低层的节点,则向低层移动
if(p.down != null) {
p = p.down;
} else {
break;
}
}
// 返回p,!注意这里p的key值是小于等于传入key的值的(p.key <= key)
return p;
}
注意以下几点:
-
1.如果传入的key值在跳跃表中存在,则findEntry返回该对象的底层节点;
-
2.如果传入的key值在跳跃表中不存在,则findEntry返回跳跃表中key值小于key,并且key值相差最小的底层节点;
示例,在跳跃表中查找key=42的元素节点,将返回key=39的节点。如下图所示:
基于findEntry方法,我们就能很容易的实现前面所说的一些操作了。
实现get方法
public Integer get(String key) {
SkipListEntry p;
p = findEntry(key);
if(p.key.equals(key)) {
return p.value;
} else {
return null;
}
}
实现put方法
put方法有一些需要注意的步骤:
-
1.如果put的key值在跳跃表中存在,则进行修改操作;
-
2.如果put的key值在跳跃表中不存在,则需要进行新增节点的操作,并且需要由random随机数决定新加入的节点的高度(最大level);
-
3.当新添加的节点高度达到跳跃表的最大level,需要添加一个空白层(除了-oo和+oo没有别的节点)
下面我们一步一步的通过图示看一下插入节点的过程:
第一步,查找适合插入的位子
第二步,在查找到的p节点后面插入新增的节点q
第三步,重复下面的操作,使用随机数决定新增节点的高度
从p节点开始,向左移动,直到找到含有更高level节点的节点;
将p指针向上移动一个level;
创建一个和q节点data一样的节点,插入位子在跳跃表中p的右方和q的上方;
直到随机数不满足向上攀升的条件为止;
图示如下:
只要随机数满足条件,key=42的节点就会一直向上攀升,直到它的level等于跳跃表的高度(height)。这个时候我们需要在跳跃表的最顶层添加一个空白层,同时跳跃表的height+1,以满足下一次新增节点的操作。
只要随机数满足条件,key=42的节点就会一直向上攀升,直到它的level等于跳跃表的高度(height)。这个时候我们需要在跳跃表的最顶层添加一个空白层,同时跳跃表的height+1,以满足下一次新增节点的操作。
Java代码实现如下:
public Integer put(String key, Integer value) {
SkipListEntry p, q;
int i = 0;
// 查找适合插入的位子
p = findEntry(key);
// 如果跳跃表中存在含有key值的节点,则进行value的修改操作即可完成
if(p.key.equals(key)) {
Integer oldValue = p.value;
p.value = value;
return oldValue;
}
// 如果跳跃表中不存在含有key值的节点,则进行新增操作
q = new SkipListEntry(key, value);
q.left = p;
q.right = p.right;
p.right.left = q;
p.right = q;
// 再使用随机数决定是否要向更高level攀升
while(r.nextDouble() < 0.5) {
// 如果新元素的级别已经达到跳跃表的最大高度,则新建空白层
if(i >= h) {
addEmptyLevel();
}
// 从p向左扫描含有高层节点的节点
while(p.up == null) {
p = p.left;
}
p = p.up;
// 新增和q指针指向的节点含有相同key值的节点对象
// 这里需要注意的是除底层节点之外的节点对象是不需要value值的
SkipListEntry z = new SkipListEntry(key, null);
z.left = p;
z.right = p.right;
p.right.left = z;
p.right = z;
z.down = q;
q.up = z;
q = z;
i = i + 1;
}
n = n + 1;
// 返回null,没有旧节点的value值
return null;
}
private void addEmptyLevel() {
SkipListEntry p1, p2;
p1 = new SkipListEntry(SkipListEntry.negInf, null);
p2 = new SkipListEntry(SkipListEntry.posInf, null);
p1.right = p2;
p1.down = head;
p2.left = p1;
p2.down = tail;
head.up = p1;
tail.up = p2;
head = p1;
tail = p2;
h = h + 1;
}
实现remove方法
删除节点的操作相对put就比较简单了,首先查找到包含key值的节点,将节点从链表中移除,接着如果有更高level的节点,则repeat这个操作即可。
Java代码实现如下:
public Integer remove(String key) {
SkipListEntry p, q;
p = findEntry(key);
if(!p.key.equals(key)) {
return null;
}
Integer oldValue = p.value;
while(p != null) {
q = p.up;
p.left.right = p.right;
p.right.left = p.left;
p = q;
}
return oldValue;
}
跳跃表的原理和实现到这里就结束了。
还有需要说明的一点是:跳跃表每次运行的结果是不一样的,这就是为什么说跳跃表是属于随机化数据结构。(Random的存在导致的)
5.2.4 跳跃表在Java中的应用
-
ConcurrentSkipListMap:在功能上对应HashTable、HashMap、TreeMap;
-
ConcurrentSkipListSet : 在功能上对应HashSet;
确切的说,SkipList更像Java中的TreeMap,TreeMap基于红黑树(一种自平衡二叉查找树)实现的,时间复杂度平均能达到O(log n)。
HashMap是基于散列表实现的,查找时间复杂度平均能达
到O(1)。ConcurrentSkipListMap是基于跳跃表实现的,查找时间复杂度平均能达到O(log n)。
ConcurrentSkipListMap具有SkipList的性质 ,并且适用于大规模数据的并发访问。多个线程可以安全地并发执行插入、移除、更新和访问操作。与其他有锁机制的数据结构在巨大的压力下相比有优势。
TreeMap插入数据时平衡树采用严格的旋转操作(比如平衡二叉树有左旋右旋)来保证平衡,因此SkipList比较容易实现,而且相比平衡树有着较高的运行效率。
6.ConcurrentLinkedQueue
无界非阻塞队列,底层是个链表,遵循先进先出原则。
add,offer将元素插入到尾部,peek(拿头部的数据,但是不移除)和poll(拿头部的数据,但是移除)
7.写时复制容器
-
CopyOnWriteArrayList
-
CopyOnWriteArraySet
写时复制的容器。通俗的理解是当我们往一个容器添加元素的时候,不直接往当前容器添加,而是先将当前容器进行Copy,复制出一个新的容器,然后新的容器里添加元素,添加完元素之后,再将原容器的引用指向新的容器。
这样做的好处是我们可以对容器进行并发的读,而不需要加锁,因为当前容器不会添加任何元素。所以写时复制容器也是一种读写分离的思想,读和写不同的容器。如果读的时候有多个线程正在向容器添加数据,读还是会读到旧的数据,因为写的时候不会锁住旧的,只能保证最终一致性。
适用读多写少的并发场景,常见应用:白名单/黑名单, 商品类目的访问和更新场景。
存在内存占用问题。