前言
HashMap是java中常用的集合类,为了容器的读写性能,HashMap在读写数据时并没有加锁,即HashMap是非线程安全的集合类。
java中针对线程安全的哈希容器有两个:ConcurrentHashMap、HashTable。HashTable方法中包含大量的Synchronized关键字,导致在多线程环境中性能开销非常大,相比而言,CocurrentHashMap是另一种性能比较好的线程安全的容器。接下来主要对比java7中的HashMap和ConcurrentHashMap,以及java8中对ConcurrentHashMap做的优化。
java7 HashMap
HashMap底层结构是一个table数组,每个table数组内的元素对象是一个Entry链表。HashMap容量的默认初始大小是16,默认负载因子是0.75 。当HashMap的元素数量占比达到0.75时。需要重设HashMap的大小,每次容量重设为原来的二倍。底层数据结构如图1所示:
存储时,有一个table数组,数组的每个元素是Entry类型的对象。而Entry是一个包含key、value的链表。Entry重写了
equals
和hashCode
方法,两个Entry实例对象的key和value相等时,认为这两个实例对象相等。结构为:
1 | static class Entry<K,V> implements Map.Entry<K,V> { |
get方法分析
get方法很简单,是调用的getEntry方法。HashMap支持null作为key,首先判断key是否为null,默认储存在table[0]中,需要遍历数组中的链表查询key为null的Entry结点。
若key非空,查找key所对应的table数组下标i
,之后遍历table[i]中的Entry链表,直到找到key所对应的Entry对象。
1 | // get方法 |
put方法分析
首先判空扩容,threshold默认值是16;其次判断key是否为null,若为null,则插入到下标为0的数组中。
若key非空,求出hash值并求出所对应的数组下标。根据数组下标查找Entry链表中是否包含key值,若包含,则更新key的值,并返回。若不包含key值,则将key,value作为一个新节点插入链表头部。
1 | public V put(K key, V value) { |
resize方法分析
判断oldCapacity是否超过最大值,没有超过则将table进行扩容。扩容时有个tansfer转移函数,为什么需要转移函数呢?直接扩容不就好了吗?
仔细查看之前的put源码可以发现,取完key的hash值后,通过 $ h \& (length-1) $求出数组下标,而调用resize之后,length发生变化,那么key所对应的数组下标必然发生变化。故需将oldtable中的数据进行重新散列存入newtable中。
1 | void resize(int newCapacity) { |
java7 ConcurrentHashMap
ConcurrentHashMap是线程安全的Map容器,支持多线程并发访问的场景。默认初始容量、负载因子等与HashMap相同。底层数据结构是按照Segment分段的,每个Segment中包含有table数组,每个table数组元素是一个Entry链表,如图2所示:
get方法分析
在访问segment以及table数组中的元素时,是通过UNSAFE的一系列方法寻找数组元素在内存中的地址来进行访问的。获取数组中元素的内存地址,主要依赖UNSAFE的两个方法:
arrayBaseOffset方法获取segment数组第一个元素在内存中的偏移地址
arrayIndexScale方法获取segment数组元素在内存中的大小 byte
这两个方法结合使用可以获取segment以及table数组每个元素在内存中的位置。
1 | Class sc = Segment[].class; |
segment元素大小为ss,SSHIFT为ss左起第一个非零位到最低位的位数。ss与SSHIFT的关系如下表所示:即当数组元素大小为 $ ss=2^n $ 时 $ SSHIFT = n $。
ss | numberOfLeadingZeros(ss) | SSHIFT |
---|---|---|
1 | 31 | 0 |
2 | 30 | 1 |
4 | 29 | 2 |
8 | 28 | 3 |
假设数组元素大小$ss=2^n$,则$SSHIFT = n$,数组下标为$i$,$ i \gg SSHIFT = i \gg n = i2^n = iss $ ,因此$\gg SSHIFT$操作相当于乘以数组元素大小。
如下表所示,(h >>> segmentShift) & segmentMask 含义是取h 二进制的高四位,使用示例:h = FX XX XX XX(16进制),若h的高四位是F(1111),则getObjectVolatile(segments, u)获取segments[15]对象。若h的高四位是A(1010),则获取segments[10]对象。
u(16进制) | h >>> segmentShift & segmentMask | segments |
---|---|---|
FF A3 BC D9 | F | segments[15] |
9F A3 BC D9 | 9 | segments[9] |
DC 98 76 54 | D | segments[13] |
如下代码所示,segmentShift默认值是28,segmentMask默认值是15,根据key的hash值的高4位的掩码乘以每个元素的大小来求取相应的地址u。调用getObjectVolatile方法获取内存地址u位置处的segment对象。volatile能够保证其他线程修改segment对象时,本线程能够立刻获取修改后的新值。
通过同样的方法getObjectVolatile获取指定内存位置处的table元素(链表),并遍历其中的链表,查找包含key值得的节点。
1 |
|
put方法分析
根据key的hash值计算segments的数组下标j
,查看内存中相对偏移量j << SSHIFT) + SBASE
处的值是否为空。若为空,则segment[j]内的table没有初始化,segment[j] == null,此时需要对segment[j]进行初始化。
1 | public V put(K key, V value) { |
下面代码是对segment的初始化操作,里面涉及UNSAFE的两个方法:
- 通过
getObjectVolatile
获取内存u位置处的对象segment[k](获取最新的)。 - 初始化segment中的HashEntry数组tab。
- 通过
compareAndSwapObject
CAS乐观锁初始化segment[k]。 - 乐观锁CAS简介:V内存地址,A期望的旧值,B新值。若V处的值与期望值A相等,则将B更新到V处。
由于采用volatile操作,故即使在初始化化egment[k]时,其他线程初始segment[k]的值,本现程也能立刻获取新值,直接返回seg。若其他线程没有更改segment[k],则通过CAS乐观锁将null与s互换。
1 | private Segment<K,V> ensureSegment(int k) { |
若segment存在,则将对应的key、value插入到指定位置,插入操作如下代码所示:
1)首先执行put操作之前,要本线程获取到s(即segment[j])的对象锁。若获取失败,则调用类似于自旋锁的方法scanAndLockForPut,不断重试获取segment[j]对象锁,超过重试次数加入阻塞队列进行等待。
2)获取segment对象锁之后,遍历segment[j]对象内table[index]中的entry链表。之后的操作与HashMap中类似,Entry节点包含相同的key则更新原来的旧值,若不存在,则在链表头部插入新节点。
3)解锁
1 | final V put(K key, int hash, V value, boolean onlyIfAbsent) { |
rehash方法分析
与HashMap的resize方法类似,当table容量大于capacity*loadFactor时,需要进行扩容。每次扩容为原来容量的2倍。rehash方法实在put方法获取lock对象锁之后执行,故不需要考虑多线程问题。只需要将旧的table数组中的元素重新进行hash计算,重新散列到新的table数组中。
1 |
|
java8 HashMap
在java8中,HashMap做了些改进,table数组对象包括链表、树。当链表长度超过某个值之后,就以红黑树的形式存储在table数组中。具体数据结构如图3所示:
Node是包含key、value的一个链表。如下代码所示,Node与java7的Entry类似,重写了equals与HashCode方法。当两个Node节点的key、value字段相等时,这两个Node节点相等。
1 | static class Node<K,V> implements Map.Entry<K,V> { |
get方法分析
与java7类似,
- 根据key的hash值求出table的数组下标,遍历数组下标位置处的元素。
- 若元素是列表节点,则按顺序遍历。
- 若元素是树节点,则按照getTreeNode方法,从根节点开始查找红黑树中key所对应的节点。
1 | public V get(Object key) { |
put方法分析
- 插入时,根据key的hash值找到对应的table下表
i
, - 若table[i]为null,则首先将table[i]初始化,并根据key、value新建节点插入到table[i]中。
若table[i]不为null,则遍历table[i]中的元素查找包含key的元素e。
- 若table[i]是树节点,调用putTreeVal方法查找树中是否存在包含key键值的节点,若找到则返回节点e;未找到则在树中插入新节点。
- 若table[i]是链表,则遍历链表,查找包含key值的节点,若存在则返回节点e;不存在则新建Node节点,若链表长度达到指定长度,则链表转换成树。
遍历结束,若找到包含key的节点e,则更新e的value值,并返回e的oldValue值;若未找到包含key的节点e,则新建节点,并根据HashMap的元素个数判断是否需要扩容操作。
1 | public V put(K key, V value) { |
java8 ConcurrentHashMap
jdk1.6之后,java虚拟机对做了很多锁有话技术:自旋锁、适应性自旋锁、锁消除、锁粗化、轻量级锁和偏向锁等。在多线程场景中,synchronized锁的性能已经可以和ReentrantLock相媲美,可能是基于此原因,java8中ConcurrentHashMap又重新使用synchronized锁来支持并发场景。底层数据结构为数组 + 链表/红黑树:
get方法分析
与java7类似,也是通过UNSAFE的方法进行查找:
- 获取key的hash值。
- 判断table数组、table[(n - 1) & h)]是否为空,通过getObjectVolatile 获取数组中的链表头结点e。
- 若头结点e不为空,且包含key值,泽返回对应val。
- 若头结点的hash值小于0,则节点为树节点,遍历树查找相应的节点。
- 否则,遍历链表查找包含key的节点。
1 | public V get(Object key) { |
为什么hash小于0就是树节点?这里需要解释其中的两个内部类:TreeNode、TreeBin。TreeBin的头结点hash值是负数。如下代码所示:
1 | static final int TREEBIN = -2; // hash for roots of trees |
查找树时,需要先获取读写锁,lockState有四种状态,1:写锁;2:等待写锁;4s:读锁;0:无锁。代码解析:
首先如果当前线程拥有写锁,或者正在等待写锁,则直接进行读取操作,此时其他线程无法对节点进行读写操作。
若当前线程没有写锁或者等待写锁的状态时,通过CAS操作获取读锁,获取成功则进行遍历查找树节点。
- 如果还有其他线程在等待,调用LockSupport.unpark唤醒等待线程。
1 | static final int WRITER = 1; // set while holding write lock |
put方法分析
- 根据key的hash值。
- tab 未初始化则调用initTable()方法(CAS)对table进行初始化。
- 计算数组的下标 $ i = (n-1) \& hash $,并根据tabAt(tab, i)方法调用UNSAFE的getObjectVolatile方法获取table[i]的值,若table[i]未初始化,则将key、value做为到新节点,调用compareAndSwapObject 插入到table[i]中。
- 若table[i]已初始化,首先判断是否有resize操作在进行,如果有则调用helpTransfer方法。没有则锁住table[i],之后的操作与java8 HashMap中的操作类似,先判断存在是否包含key值的节点,有则直接更新。没有则新建节点并插入,并根据链表长度决定是否将链表转为树。
1 | public V put(K key, V value) { |
总结:
1、java8的HashMap与java7相比,
- 数据结构:
- java7 数组+链表
- java8 数组+链表/树。
2、java8的ConcurrentHashMap与java7相比:
- 数据结构
- java7 分段数组segment+数组+链表
- java8 数组+链表/树
- get方法
- java7 UNSAFE.getObjectVolatile方法
- java8 链表UNSAFE.getObjectVolatile方法;树则通过获取读写锁来查询
- put方法
- java7 通过tryLock获取segment的分段对象锁,插入到制定segment的table数组中
- java8 通过sychornized获取table中对象锁,插入树或链表中
- 空对象初始化时均用CAS进行初始化