简单回顾ConcurrentHashMap在jdk1.7中的设计
与Hashtable不同的是,ConcurrentHashMap使用的是分段锁的技术,将ConcurrentHashMap容器的数据分段存储,每一段数据就分配一个Segment,当线程占用一个Segment时,其他线程可以访问其他段的数据(每个segment都是一个锁)。与HashTable相比,这么设计的目的是对于put、remove等操作,可以减少并发冲突,对不属于同一个片段的节点可以并发操作,大大提高了性能。
(1)Segment:可重入锁(在Java环境下ReentrantLock和synchronized都是可重入锁),继承自ReentrantLock,也称之为桶(本质上Segment类就是一个小型的hashMap),每个Segment守护着一个HashEntry数组里的元素,当对HashEntry数组的数据进行修改时,必须首先获取到它对应的Segment锁。
(2)HashEntry:主要存储键值对,这里也可以叫节点
HashEntry源码:
static final class HashEntry<K, V> {
final int hash;
final K key;
volatile V value;
volatile HashEntry<K, V> next;
}
其中,volatile关键字保证了多线程读取到的一定是最新值,ConcurrentHashMap包含一个Segment数据,每个Segment包含一个HashEntry数组。
ConcurrentHashMap在jdk1.8中做了两方面的改进
改进一:取消segments字段,直接采用transient volatile HashEntry<K,V>[] table保存数据,采用table数组元素作为锁,从而实现了对每一行数据进行加锁,进一步减少并发冲突的概率。(transient修饰的成员变量,在类的实例对象的序列化处理过程中会被忽略,因为transient变量不会贯穿对象的序列化和反序列化,生命周期仅存于调用者的内存中而不会写到磁盘里进行持久化)。
改进二:将原先table数组+单向链表的数据结构,变更为table数组+单向链表+红黑树的结构。对于hash表来说,最核心的能力在于将key hash之后能均匀的分布在数据中。如果hash之后的散列很均匀,那么table数组中的每个队列的长度为0或者1。但是实际情况并非总是如此理想,虽然ConcurrentHashMao默认的加载因子为0.75。(加载因子是衡量一个哈希表何时需要扩容,也就是增加存储空间的阈值,加载因子的值是哈希表中已经存储的键值对与哈希表长度的比值,当这个比值超过加载因子时,哈希表就会进行扩容)。但是在数据量过大或者运气不佳的情况下还是会存在一些队列长度过长的情况,如果还是采用单向链表的方式,那么查询某个节点的时间复杂度为O(n);因此,对于个数超过默认值8的列表,jdk1.8采用了红黑树的结构,那么此时查询的时间复杂度可以降低到O(logN),以此改进性能。
为什么阈值为8
(1)红黑树平均查找时间复杂度为log(N),log(8) = 3,链表平均查找长度为N/2, 8/2 = 4,转换后性能更高。
(2)立项情况下,hash桶中的节点的频率遵循泊松分布,桶长度超过8的概率非常小,通常情况下不会发生结构改变。
为什么不直接使用红黑树
(1)因为二叉树(红黑树)虽然查询效率高,但是空间开销很大,单个TreeNode需要占用的空间大约是普通Node的两倍,而在数据量很小时,红黑树与链表的查询效率不会差太多。
(2)因为桶长度超过8的概率很小,所以大概率情况下链表是更好的选择。