首页 > 代码库 > 并发容器1
并发容器1
主要分析 List Map Set 中的 并发集合。 默认基于1.6分析
1 CopyOnWriteArrayList
juc包下的类;
该类是支持随机访问的List, 和Vector(同步锁实现线程安全)和ArrayList(非线程安全)对照。
1.1 属性
transient final ReentrantLock lock = new ReentrantLock();
private volatile transient Object[] array;
array 和ArrayList 一样。 还是Object类型数组。volatile 保证该引用线程可见性。
对于array中数据的读写,此类都是通过函数getArray/setArray实现。 也就是简单的get(i) 也不能直接返回array[i] 而是需要先通过getArray()获取array然后使用索引获取值。
- 为什么这样实现?
HB规则? 利用volatile写HB于volatile读 ,包装可见性?
1.2 构造函数
ArrayList 中默认数组大小10 ; 此处默认创建空的Object数组。
由于Arrays.ArrayList.toArray() 该方法返回的数组不是Object类型数组(数组本质类型不是引用)。 这属于javaBug .所以集合中基于 数组存储的类像ArrayList Vector等都需要做检查,防止array指向非Obeject数组。
http://www.cnblogs.com/zhizhizhiyuan/p/3662371.html
- 关于容量:
- ArrayList默认10长度的数组,add()当数组容量不足时,会一次性扩容一般1.5倍; 即ArrayList中数组长度!=实际元素。 所以存在trimToSize()函数将数组空位去除。
- 而该类数组元素==数组长度。 初始0,每次add修改数组就会创建新的数组,所以不涉及一次性扩容操作。
1.3 不可变函数
即对于数组元素不会修改的函数。
contains。indexOf。lastIndexOf。toArray()。get。containsAll().
这些函数对底层数组并不修改但是执行之前都必须使用getArray()获取 内部的数组array进而操作!
- equals():
List 接口下的equals语义都一样。 ArrayList中使用的是AbstractList中实现的方法。
对象相同肯定true, 只有都是List才能比较(否则false), 对两个List从前向后依次遍历 出现不一致的元素就是False;
1.4 可变函数
以下的这些函数都会对数组发生修改,他们在执行时都会加锁解锁(独占锁),这些函数都会创建新的数组然后将array指向新的数组。
1.4.1 clone() :
该类实现的同样是浅拷贝。 该类中默认会持有独占锁该变量是transient的,所以需要单独克隆(初始化该变量)。
1.4.2 set() :
- 执行此函数 需要使用独占锁 加锁解锁
- 对于数组元素的修改,每次都是创建新的数组,然后使用setArray()更新数组的引用。
- 即使 数组值不做更新。那么也要调用setArray()
关于此处为什么不更新数组引用还要调用setArray?
A: 估计是为了HB规则使用,保证可见性。
1.4.3 add()
执行前后都要使用 独占锁 加锁解锁。
同样该操作每次也会创建新的数组,然后直接更新数组引用。
对于数组使用都是使用getArray和setArray 不能直接操作该引用。
1.4.4 remove(int)
remove 同上。也是创建新的数组。修改array指向的数组。
removeAll(Collection<?>) 也是创建新的数组,将留下的元素放到新的数组中,最后修改array引用。
1.4.5 clear()
直接将数组指向新的空数组。
1.4.6 addIfAbsent()
该方法在ArrayList中没有; 如果元素不存在就添加
addAllAbsent(Collection<? extends E>) 同理。
1.5 关于迭代器
- ArrayList中有两个迭代器,单向和双向(List接口就是这样规定);
- 单向迭代器支持 remove;
- 双向迭代器支持 add remove;
- 但是多线程环境下会抛出异常。如果A在迭代过程中别的迭代器修改了List结构,那么就会导致ConcurrentModificationException。
此类直接实现List接口, 所以自然也支持两种迭代器。 但是该类实现时将这两个迭代器使用一个类实现COWIterator
1.5.1 COWIterator迭代器:
支持双向遍历,不支持remove ,set, add
- 属性:
- cursor 语义和ArrayList中一样,向后遍历时指向下一个被访问的元素。向前遍历时cursor-1指向下一个遍历的元素。
- snapshot: 数组内容快照,此处仅仅是传入引用为何称为快照?
A: 该类中不可变函数(不修改数组内容结构)并不会修改array引用, 而可变函数当add元素后就会修改该类中array引用指向新的数组。也就是当次迭代器在迭代过程中时,别的线程add修改底层数组,这只会导致array指向别的数组。而该迭代器snapshot指向的还是之前的数组,并不会受到影响。
1.6 总结
该类本质就是对 能造成数组修改的函数 : 每次都创建新的数组,将array指向新的数组。
对于迭代器: 不支持修改数组操作。 不同的迭代器实现的效果就是保存当前 array的镜像, 不会受到外部数组修改影响(对此也不可见)。
场景
- 对数组的读操作(contain等也是 不对数组修改) 远大于 写操作。 由于add等方法每次都会创建新的数组,所以开销较大,当数组大时更是这样。
- 迭代器是线程安全的。但是不支持修改操作,它遍历的仅仅是当时的快照。多线程下是安全的
线程安全:
该类的线程安全是使用volatile+独占锁 实现。- 该类内部是volatile数组。每次读操作都会读取该引用,每次修改都会创建新的数组然后修改该引用。 正是这样volatile写HB于volatile读,保证每次读取都能读取到最新的数据。
- 独占锁: set add 等修改数组的函数 采用独占锁,保证互斥执行。
Q: 对于修改操作只能是使用创建新的数组这样方式?
A: volatile仅仅能保证该引用的可见性。juc中原子类也仅仅能提供对对象个别字段的原子更新。如果不使用此方法那么我们就需要对get等读方法采取加锁以保证看到最新值。 此类使用此方法就是适合读多写少场景,所以写操作开销稍微大点。以上解释有误;
参见ConcurrentHashMap中对于每个Segment(相当于一个HashMap)中维护的 volatile Entry[] 如何保证读写可见性。 ????
2 CopyOnWriteArraySet
使用:
该描述和CopyOnWriteArrayList 几乎一模一样,只是此是Set继承体系
该类直接继承自AbstractSet,该类并不像AbstractList中实现了许多方法,仅仅实现了个别方法。
2.1 构造函数
该类持有CopyOnWriteArrayList, 二者本身功能类似只是此类是Set不支持重复,那么只要在add时控制重复元素即可。(实际上也是这么做的使用addIfAbsent代替add 防止重复)
- Set 架构中,非并发的容器并没有基于数组实现的类,HashSet等底层都是基于HashMap。该类属于特例吧。
2.2 函数
此类基于CopyOnWriteArrayList(代理模式or 适配?)
该类仅仅是对add方法稍加修改使用 addIfAbsent防止重复,迭代器等实现都一样,代理调用;
equals(Object): 实现Set版本的比较。无序比较。
3 ConcurrentHashMap
https://www.ibm.com/developerworks/cn/java/java-lo-concurrenthashmap/
http://www.infoq.com/cn/articles/ConcurrentHashMap
3.1 概述
- 继承体系
该类还实现 ConcurrentMap 接口;
该类和Map相比也就是增加了几个方法。
put(): 不管键值对存不存在 都会add到Map;
putIfAbsent(): 只有键值对不存在时才会 add; 已经存在的不会修改;
replace(K,V) : 和putIfAbsent相反,只有该映射存在时才会修改,不存在不会add;
replace(K,V,V): K-V 全匹配时才会修改。
3.2 基本结构:
- 基于分段锁提高并发: ConcurrentHashMap本质是将map划分为多个区域(Segment), 每个区域等价于一个HashMap(该Map本身就持有一个锁) 也就是在此类中对于get put这种操作: 首先是依据hashcode定位segment区域然后继续在该区域中找对应位置。
该类中主要有四种函数
- 构造函数;
- isEmpty(),size() ,containsValue(); 由于ConcurrentHashMap采用分段,而在外层类中并没有保存关于当前Map总数等信息,这些计数都存放到各自Segment,所以对于这三个函数区别分析;
- get() , put(), contain(), remove(), clear() ; 这几种函数大部分都是采用先定位Segment然后再在该区域执行操作。
- keySet() ,values(),entrySet() 三种视图 + enumeration迭代器。
该类中键值 均不允许使用
null
, hashMap中允许;
3.2.1 字段
- segments : 就是分割区域的数组.
segmentMask 和 segmentShift : 二者是用来定位segment区域的。构造函数中会初始化此变量。
segment数组长度默认16,(它也会选取2的N次幂作为长度,方便hash定位区域,和HashMap类似) 简单来说当get(O)时:- 首先hash(o)再hash得到hashcode.
- 利用这个hashcode要确定区域segmnet和区域内所在的桶。
- 所以当segment数组长为16时:segmentMask 就是0x000...1111, segmentShift=28, 也就是取hashcode的最高4位来判断所在的segment区域。
三个视图变量: AbstractMap中以及有了两个视图变量(本来是volatile),此处覆盖后取消volatile;(而在HashMap中直接使用抽象类中定义的volatile变量。)
一个Map对象中,这三个变量实质上都是只有一个实例。也并不涉及引用的改变,所以也没必要volatile?
3.3.2 构造函数
和HashMap相比,此函数多了一个参数concurrencyLevel;
- concurrencyLevel: 大意就是制定当前Map应该分为几个区域。默认16最大个数为2^16. 为了hash方便此也只能是2的整次幂。
- loadFactor: 由于ConcurrentHashMap外层类中并不记录当前Map中的总个数。Map的容量调整也都是发生在各自的Segment区域中, 所以该变量直接针对的就是每个segment中的负载因子,而不是针对全局的Map存放而言
- initialCapacity: 和HashMap一样,该指定的是桶的个数(也是2的次幂),最大和HashMap一样2^30,默认16.
- 由于此外层类中并不存储实际的桶,桶实际是在各个segmrnt中。 利用initialCapacity/区域个数(即concurrencyLevel) 来计算每个区域平均应该存放多少个桶。
- 注意: ConcurrentHashMap中虽然指定最大桶(initialCapacity)的数目是2^30, 并不是指全部桶只能这么大, 每个区域各自扩容时上限2^30. 将每个区域视为一个hashMap即可。
3.3.3 hash定位
对get() , put(), contain(), remove(), clear() ; 这些函数。 外层类中主要工作就是找到该元素所在区域segment, 然后在该区域中执行操作. 核心就是区域定位
get:
- 首先就是hash() 方法再次hash.(防止对象本身hashcode分布不均匀)
- segmentFor 确定区域; 例如当前共有64个区域那么segmentShift 就是32-6; 而segmentMask = 0x111111; 效果就是按照hashcode的最高6位来直接确定区域segment的索引。
3.3 Segment
该类就是一个Map中的一个区域, 功能等于HashMap, 只是get put 程安全。
每个区域相对于别的区域都是独立的 ,ConcurrentHashMap 正是将Map划分为不同的区域才能提供并发。(缩小冲突范围)
该类的实现基本和HashMap一致。 此处选取个别方法分析 如get put;
3.3.1 结构:
该类为ReentrantLock子类,也就是该segment本身就能当作锁来使用。
在get等读操作中不会锁,只有put等写操作才会采用锁。 而且各个区域Segment持有各自不同的锁。变量:
- 作为HashMap该类持有变量threshold,table,loadFactor 等; 各个区域之间是独立的,每个区域维护各自的Map变量。扩容操作也是在各个区域单独进行。
transient int modCount;
: 和HashMap一样,该变量记录Map结构修改次数。 HashMap中使用此变量实现fail-fast机制,而此类并不用此机制。 此变量主要是用来ConcurrentHashMap.size().(外部类中并不记录总的映射个数,利用此变量提高并发,见size()分析)- volatile变量: 该类将这两个变量设置为volatile,利用volatile写读的内存语义,尽可能减少锁的使用,实现内存可见性。 例如在每次get总会读取volatile count, 每次put总会 写入 volatile count, 这样保证get在不加锁的情况下 读取到table中的最新值。 volatile写HB于volatile读,即前者对后者可见。
3.3.2 hash 与扩容:
hash : 和HashMap中一致:
return tab[hash & (tab.length - 1)];
都是直接利用hashcode直接确定。如当前segment中桶为32,即依据hashcode的低5位直接判断所在的桶;rehash() : 此函数只在put()中被调用,而put每次执行需要加锁解锁,所以此函数不会出现多个线程同时执行。
- 容量变化:和HashMap一样 容量2倍;
- 首先会创建新的table数组,然后遍历旧的Map,将对象挂载到对应的桶中。
假设当前桶的长度是8也就是从000-111, 那么每次使用hashcode的低3位便可以确定所在的桶。 假设现在扩容为16长度也即使用末尾4位判断桶位置。
所以对于原来某个桶中的元素(如0号):只会被分到0号或者8号桶中;该函数执行过程中会尽量使用原有的Entry. 但是绝对不会破话原有Entery的结构。 实际上HashEntry的next引用也被设置为final防止修改。
- 如何保证rehash()执行过程中,get线程不会读取到错误的值?
A: 当创建新的table后,此函数会遍历旧的table,clone原有的对象,并保证不会破坏旧的结构。 当函数执行完毕后才会将table指向新的数组; 也就是在此时间段内get访问的还是旧的table.
对于一些HashEntry如果能利用那么该函数会尽可能利用:例如0号桶中原来之挂载了HashEntry e,那么此对象就完全可以重新利用, 没必要clone. 总之就是不修改原有Entry next引用,实际中也无法修改,从而实现保护旧的table直到新的table创建完成
3.3.3 get(Object, int)
get操作不需要获取锁,这也是ConcurrentHashMap能高效并发的原因!
该类中不允许key-value为null;
count作用:
A: count是volatile变量。在每次get前都会读取,而在每次put后都会执行写入操作。这样做是利用volatile内存语义。保证上次put操作的修改能够对此get可见。
在contain等函数中同样每次都会读取count;利用传入的hash定位桶,在该桶中变量查找键值对然后返回。segment中不允许键值对为null,为什么此处v可能为null?
A: 写操作put是需要持有锁的,而get不需要。put执行时对于已经存在的键直接修改value即可, 对于新的键需要创建新的HashEntry。由于put和get并没有同步。所以就会造成get定位到HashEntry时该对象还没有完成初始化。
此时就需要尝试通过获取锁来获取value值。 即readValueUnderLock函数
Q: 关于get put多线程环境下可见性,当一个线程put多个线程get时,count的可见性语义仅仅能保证此时刻之前的操作可见, 但是get毕竟不是原子操作。所以get可能会读取到旧的值,但是肯定不会是异常值? 而HashTable中get put 全部持有锁,造成执行串行话,但是HashTable肯定是能看到最新的值。 所以此处get并不是严格地能够获取到最新值??
A: get弱一致性; 以上说的情况的确可能会发生;api中也提到 Retrievals reflect the results of the most recently completed update operations holding upon their onset.
由于get操作无锁,所以他能观测到put中途执行结果,无锁并发,就是把原来的竞争块变成了点,为成了一个一个的点,就不存在冲突了,并且前后顺序的关键点不是原来的数据写入或读取,而是用来同步的标记之类的读写,上面就是count变量的读写,是一种思维的转变。如果要保证一个并发工具,在执行某个操作后保持该状态,那这就是锁的使用场景
但是从宏观角度put方法执行完之后,get肯定能看到put的结果;
http://ifeve.com/concurrenthashmap-weakly-consistent/
3.3.4 put(K, int, V, boolean)
- put操作必须持有锁。
- 当Segment中键值对数量超过阈值,需要调用rehash扩容 各个区域的扩容独立进行。
- put对于已经存在的键值对: 直接修改value
- put对于新的键值对:只能从链表的头部插入。 因为HashEntry的next引用被设置为final,所以只能创建新的HashEntry指向head;
- 每次创建新的Entry后,modCount++ 表示Map结构的调整。 该变量会被用在外部类size()中。
- 此函数支持外部类put和putIfAbsent调用。 (后者只有在旧值存在才会修改)
关于读写并发?
A: 参照3.3.3中讨论,1写+多读如何保证不会出现异常。当创建新的Entry后 写入volatile 变量 count. 但是修改value时为什么写入该变量呢???
3.3.5 remove(Object, int, Object)
- remove 操作是对Map的修改,所以同样需要持有锁。
- 对Map结构修改后同样会记录 modCount++; 并写入volatile count , 保证get函数调用时可见性。
- 删除HashEntry过程中,尽可能利用之前的HashEntry节点,但是链表顺序可能改变。
- remove函数主要是被外部类remove函数调用。包括只匹配键和键值全匹配两个版本;
- remove如何删除Entry?
A: e就表示要删除的Entry.如图C是要删除的Node,A是链表head. 此时D E节点完全可以重复利用,C之前的AB 由于next引用为final不能修改,所以不能引用所以必须创建新的Entry. 从A开始依次遍历头插到DE队列中。 当BADE新的链表创建好后,直接修改table[index]
多线程环境下,如果一个线程在remove,别的线程在get, remove在删除过程中并不会修改原有的链表。只是在创建好新的链表后直接修改table[index],并写入volatile count遍历保证get线程可见此修改。
此和copyOnwriteArrayList中方法类似。
3.3.6 :contain ,replace
contain: 包含两个版本使用key或者value.
- contain(key): key不存在是否为null, 该函数只要直接定位桶遍历该桶即可。
- containsValue(): 需要对Map遍历查找。该函数和get类似,遍历过程中可能看到没有初始化完成的Entry,此时和get一样需要获取锁来获取value;
replace() 也是两种,直接使用key替换,或者基于key-value。 需要持有锁
- 两种方法类似,都是ConcurrentMap中定义方法。
clear(): 需要持有锁。直接将table[i]设置为null。
3.4 modCount使用
外部类ConcurrentHashMap中不记录总的键值对,Map修改次数等。这些都是记录在各自区域Segment中。
在HashMap中modCount被用来实现fail-fast机制。 而此处并没有此机制。
- size() isEmpty() containsValue() 都会使用modCount尽可能不对Map加锁来实现功能;
containsKey能直接定位区域而containsValue需要扫描全局Map.
3.4.1 size() 实现
size需要读取各个区域的count变量,最暴力的方法便是一次性对所有区域加锁,然后计算count总和然后释放全部锁。但是这样代价太高
ConcurrentHashMap中充分利用各个区域的modCount(记录该区域Map结构修改次数)
- 首先他会连续计算两次 count总和 和 各个区域的modCount。
- 如果两次的count和 各个区域的modCount 都相等,说明在此期Map没有发生改变。 所以该count总和是准确的。
- 如果以上两次便利的 count和不一样 说明此期间Map发生改变。然后就执行以上暴力方法加锁统计
ConcurrentHashMap会对以上不加锁的统计方法尝试两次
3.4.2 isEmpty()
该函数仅仅是判断空,所以只要某个区域count!=0 就能返回false.
- 该函数第一次遍历:如果发现某个count!=0那么必然非空Map。 遍历过程中记录各个区域的modCount. 如果该总和为0 说明此时Map还没有put任何元素。此时Map是空的。
- 第二次遍历: 除了判断count!=0外,还会比较再此期间modCount是否发生变化. 如果count是0但是modcount发生变化那么此也被视为非空Map.
ABA 问题:
假如区域A 在第一次遍历时count = 0, modCount = 1; 第二次遍历 count = 0, modCount = 100;
如果仅仅依靠modCount那么就认为Map没有改变,即Map为空(即使中间经历了许多操作ABA问题 , 0再次被视为同一状态,但是二者其实是不同状态。)
一般对于ABA问题,例如原子类中, 一般是状态绑定时间戳int值来表示二者状态变化。 此处就可以将modCount视为时间戳变量 前后两次的遍历可以认为是不同状态。该函数可靠性?
A: 由于该函数并没有持有锁,所以其必然没有HashTable那样的可信度。 也就是即使我们两次for循环遍历认为当前Map为空, 但是在执行return之前还可能发生许多次的put操作。(虽然可能性比较小。)
该类中许多函数都是这样。由于不加锁,所以并不具有绝对的语义??
3.4.2 containsValue
该函数同上类似,只是涉及到全局Map遍历查找。
3.5 视图+迭代器
视图: keySet()。values()。values()。
Enumeration: keys。elements()。
视图:
视图概念和HashMap一致,都是对底层的Map数据操作。 各个视图都支持使用iterator返回各自视图的迭代器。
- 这三种迭代器核心都是HashIterator: 迭代器不支持fail-fast 不会抛出异常
- 在遍历过程中,如果已经遍历的数组上的内容变化了,迭代器不会抛出ConcurrentModificationException异常。如果未遍历的数组上的内容发生了变化,则有可能反映到迭代过程中。这就是ConcurrentHashMap迭代器弱一致的表现。
和HashMap相比,此类涉及到分区域Segment, 所以迭代器实现时需要记录更多信息。
abstract class HashIterator {
int nextSegmentIndex; //下一个需要遍历的区域编号
int nextTableIndex; // 当前区域内,下一个需要遍历的 桶编号
HashEntry<K,V>[] currentTable;
HashEntry<K, V> nextEntry; // 下一个需要遍历的Entry
HashEntry<K, V> lastReturned;
next和remove: 不支持fail-fast, 所以也不必检查modCount等变量。remove函数虽然不是同步的但是它调用的底层remove函数需要获取独占锁,所以此操作是线程安全的。
advance() : 用来获取一个Entry后调整nextSegmentIndex,nextTableIndex,nextEntry等变量方便下次操作。
Enumeration
本质也是迭代器,只是不支持remove操作。 对于keys和elements 都是返回各自的迭代器。 和各自视图中使用相同的迭代器。
在此类中Enumeration和Iterator 遍历效果一模一样,调用函数都是一样的;
3.6 总结
ConcurrentHashMap 是一个并发散列映射表的实现,它允许完全并发的读取,并且支持给定数量的并发更新。相比于 HashTable 和
用同步包装器包装的 HashMap(Collections.synchronizedMap(new HashMap())),ConcurrentHashMap 拥有更高的并发性。在 HashTable 和由同步包装器包装的 HashMap 中,使用一个全局的锁来同步不同线程间的并发访问。同一时间点,只能有一个线程持有锁,也就是说在同一时间点,只能有一个线程能访问容器。这虽然保证多线程间的安全并发访问,但同时也导致对容器的访问变成_串行化_
的了。
ConcurrentHashMap 的高并发性主要来自于三个方面:
- 用分离锁实现多个线程间的更深层次的共享访问。
- 用 HashEntery 对象的不变性来降低执行读操作的线程在遍历链表期间对加锁的需求。
- 通过对同一个 Volatile 变量的写 / 读访问,协调不同线程间读 / 写操作的内存可见性。(get put前后count读写)
- 关于HashEntery对象不变性:
- HashEntry 中的 key,hash,next 都声明为 final 型。这意味着,不能把节点添加到链接的中间和尾部,也不能在链接的中间和尾部删除节点。这个特性可以保证:在访问某个节点时,这个节点之后的链接不会被改变。这个特性可以大大降低处理链表时的复杂性。
- 查看remove put等函数的实现: 在修改Map结构时,总是采取不修改原有链表结构,colne原有Entry(对于能够复用的尽量复用), 当结构修改好后直接修改Tbale[i]引用。 这样使得在remove,put等操作时get线程不会受到影响,他们读取的还是旧的链表结构
https://www.ibm.com/developerworks/cn/java/java-lo-concurrenthashmap/
3.6.1 关于弱一致性
http://ifeve.com/concurrenthashmap-weakly-consistent/
get, clear() 以及迭代器等, 都是弱一致性。
clear: 因为没有全局的锁,在清除完一个segments之后,正在清理下一个segments的时候,已经清理segments可能又被加入了数据,因此clear返回的时候,ConcurrentHashMap中是可能存在数据的。因此,clear方法是弱一致的。
get() :3.3.3;
- ConcurrentHashMap的弱一致性主要是为了提升效率,是一致性与效率之间的一种权衡。要成为强一致性,就得到处使用锁,甚至是全局锁,这就与Hashtable和同步的HashMap一样了。
Task && Q
- 关于CopyOnWriteArrayList为什么每次总是创建新的数组保证可见性? 即1.6分析有误! 到底是为了迭代器视图还是为了get set并发可见性。 如果仅仅是可见性完全可以类似ConcurrentHashMap Segment中 每次get set 读写 volatile count变量, 进而保证内存可见性?
3.3.3 中问题, get和put多线程下 可见性》
A: put方法执行完之后,get肯定能看到put的结果3.3.4 中put函数count变量为什么不在 修改value地方写入,而仅仅在创建新的节点后写入??? 这样能保证get可见性????
- 3.4.2 可靠性? ConcurrentMap是牺牲一些可靠性来换取 并发??
遗留:
- ConcurrentNavigableMap 接口
- ConcurrentSkipListMap 类
- ConcurrentSkipListSet 类 和2类似;
并发容器1