从零开始学Java_第三篇 Map 的实现原理
Map 不属于 Collection 的范畴,它作为 Java 集合框架之外的一部分,在日常开发中使用的也是非常频繁,map 中以键值对的形式储存数据,根据键查找值的效率非常快。
开发中最常用的就是 HashMap 和 ConcurrentHashMap 了,那它们的底层到底是如何实现的呢?带着这个问题我们来开始今天的分享。
首先我们看下这张 Map 的类图:
从类图中可以看出 HashMap、TreeMap 等都继承了 AbstractMap,而 LinkedHashMap 又继承了 HashMap。
那么 HashMap、TreeMap、LinkedHashMap 它们之间有什么区别呢?
-
相同点
-
都是使用哈希表实现,以键值对形式存储数据
-
不同点
-
HashMap 存储数据是无序的,进行 put 或 get 可以达到常数时间的性能
-
LinkedHashMap 可以认为是 HashMap + LinkedList ,按 key 的 put 顺序排序
-
TreeMap 按 key 的自然顺序排序或按创建时实现的 Comparator 接口排序,它是基于红黑树实现的 Map ,操作时间复杂度为O(log(n))
HashMap 又是如何实现的呢?
下面主要从三个方面来分析下 HashMap 的源码:
-
HashMap 内部结构基本点分析
HashMap 的内部结构可以看成是数组+链表的组合。
数组被分为一个个桶,在桶中通过 key 计算得出哈希值决定键值对在这个数组的位置,哈希值相同的键值对,则以链表形式存储,如下图:
需要注意的是如果链表大小超过阈值(TREEIFY_THRESHOLD ,默认是 8),链表会改造成树形结构。
-
容量和负载因子
HashMap 提供了三个构造函数:
HashMap():构造一个具有默认初始容量 (16) 和默认加载因子 (0.75) 的空 HashMap。
HashMap(int initialCapacity):构造一个带指定初始容量和默认加载因子 (0.75) 的空 HashMap。
HashMap(int initialCapacity, float loadFactor):构造一个带指定初始容量和加载因子的空 HashMap。
在这里提到了两个参数:初始容量,加载因子,这两个参数是影响 HashMap 性能的重要参数。
其中容量表示哈希表中桶的数量,初始容量是创建哈希表时的容量,加载因子是哈希表在其容量自动增加之前可以达到多满的一种尺度,它衡量的是一个散列表的空间的使用程度,负载因子越大表示散列表的装填程度越高,反之愈小。
对于使用链表法的散列表来说,查找一个元素的平均时间是 O(1+a),因此如果负载因子越大,对空间的利用更充分,然而后果是查找效率的降低;如果负载因子太小,那么散列表的数据将过于稀疏,对空间造成严重浪费。
系统默认负载因子为 0.75,一般情况下我们是无需修改的。
HashMap 并没有在一开始就初始化好,可能是按照 lazy-load 懒加载的原则,用的时候才去创建。那看看 put 方法:只有一个 putVal 调用,代码如下:
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
final V putVal(int hash, K key, V value, boolean onlyIfAbent,boolean evit) {
Node<K,V>[] tab; Node<K,V> p; int , i;
if ((tab = table) == null || (n = tab.length) = 0)
n = (tab = resize()).length;
if ((p = tab[i = (n - 1) & hash]) == ull)
tab[i] = newNode(hash, key, value, nll);
else {
// ...
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for first
treeifyBin(tab, hash);
// ...
}
}
从代码可以看出:
-
如果 table 是 null,resize 方法会负责初始化它,这从 tab=resize()可以看出
-
放置新的键值对时可能扩容
-
具体键值对在哈希表中的位置(数组 index)取决于下面的位运算:
i = (n - 1) & hash
其实哈希值并不是 key 本身的 hashcode,而是调用了 hashmap 内部的 hash 方法。
static final int hash(Object kye) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>>16);
}
为什么这里需要将高位数据移位到低位进行异或运算呢(h >>>16)?
这是因为有些数据计算出的哈希值差异主要在高位,而 HashMap 里的哈希寻址是忽略容量以上的高位的,那么这种处理就可以有效避免类似情况下的哈希碰撞。
resize 方法不仅负责创建初始存储表格,在容量不足时还负责 map 的扩容。代码如下:
final Node<K,V>[] resize() {
// ...
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACIY &&
oldCap >= DEFAULT_INITIAL_CAPAITY)
newThr = oldThr << 1; // double there
// ...
else if (oldThr > 0) // initial capacity was placed in threshold
newCap = oldThr;
else {
// zero initial threshold signifies using defaultsfults
newCap = DEFAULT_INITIAL_CAPAITY;
newThr = (int)(DEFAULT_LOAD_ATOR* DEFAULT_INITIAL_CAPACITY;
}
if (newThr ==0) {
float ft = (float)newCap * loadFator;
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?(int)ft : Integer.MAX_VALUE);
}
threshold = neThr;
Node<K,V>[] newTab = (Node<K,V>[])new Node[newap];
table = n;
// 移动到新的数组结构 e 数组结构
}
从代码可以看出:
-
门限值(newThr )等于负载因子*容量,如果创建 HashMap 没有指定他们,就是默认值 16*0.75
-
门限值通常以倍数进行调整,(newThr = oldThr << 1)
-
扩容后将老数组数据放到新数组中 这是扩容的主要开销
-
树化
final void treeifyBin(Node<K,V>[] tab, int hash) {
int n, index; Node<K,V> e;
if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
resize();
else if ((e = tab[index = (n - 1) & hash]) != null) {
// 树化改造逻辑
}
}
上面是精简的 treeifyBin 示意,综合 treeifyBin() 和 putVal()当 bin(链表)的数量大于TREEIFY_THRESHOLD:
-
如果容量小于 MIN_TREEIFY_CAPACITY , 扩容
-
如果容量大于 MIN_TREEIFY_CAPACITY,树化
那为什么要树化呢?
如果一个对象 hash 值与 Map 中存储的对象 hash 值冲突,那这个新对象就会被放在同一个桶里形成一个链表,当多个 hash 冲突的对象都放进一个桶时,形成一个大链表时链表查询是很慢的,这样会严重影响存取效率。
HashMap 的底层原理我们主要从 内部结构、容量和负载因子、树化 三个方面进行了分析。
接着我们讨论下ConcurrentHashMap的底层原理
ConcurrentHashMap 是如何实现的呢?
ConcurrentHashMap 的实现随着技术的更新迭代,也是在不断改进的。
-
早期的 ConcurrentHashMap
此时 ConcurrentHashMap 的三大结构:整个 Hash表、Segment、HashEntry。
-
Segment
Segment 继承了 ReetrantLock,当一个线程占用锁访问其中一个段数据的时候,其他段的数据也能被其他线程访问
-
HashEntry
HashEntry 内使用 volatile 保证可见性,如用于统计当前 Segment 大小的 count 字段和用于存储值的 HashEntry 的 value 字段。所以ConcurrentHashMap 的 get 方法是不需要加锁的。
之所以不会读到过期的值 因为 volatile 字段的写入操作有限读操作,这是 Java 内存模型的 happen before原则,即使两个线程同时修改和获取 volatile 变量,get 到的也是新值
实现主要基于锁分段技术,将数据分成一段一段的存储,给每段数据配一把锁,内部进行分段 就是 Segment ,Segment 里面是 HashEntry 数组,与HashMap 类似,哈希相同的条目也是以链表形式存放的。结构如下图:
ConcurrentHashMap 当然也有扩容的问题,不过是段内扩容(段内元素超过该段对应Entry数组长度的 75% 触发扩容,不会对整个 Map 进行扩容),插入数据前前检测需不需要扩容,有效避免无效扩容。
由于 ConcurrentHashMap 在并发时只锁定段,所以效率要提高很多。
-
JDK7 的 ConcurrentHashMap
JDK7 做了一些优化,对于 put 操作,首先通过二次hash避免 hash 冲突,然后以 UnSafe 调用方式获取相应的 Segment,然后进行线程安全的 put 操作。
Unsafe 类的作用:Java 不能直接访问操作系统底层,而是通过本地方法来访问。Unsafe 类提供了硬件级别的原子操作。
put 方法代码如下:
public V put(K key, V value) {
Segment<K,V> s;
if (value == null)
throw new NullPointerException();
// 二次哈希,以保证数据的分散性,避免哈希冲突
int hash = hash(key.hashCode());
int j = (hash >>> segmentShift) & segmentMask;
if ((s = (Segment<K,V>)UNSAFE.getObject //nonvolatile; recheck
(segments, (j << SSHIFT) + SBASE)) == null) // in ensureSegment
s = ensureSegment(j);
return s.put(key, hash, value, false);
}
// s.put(key, hash, value, false) 方法逻辑如下
final V put(K key, int hash, V value, boolean onlyIfAbsent) {
// scanAndLockForPut 会去查找是否有 key 相同 Node
// 无论如何,确保获取锁
HashEntry<K,V> node = tryLock() ? null :
scanAndLockForPut(key, hash, value);
V oldValue;
try {
HashEntry<K,V>[] tab = table;
int index = (tab.length - 1) & hash;
HashEntry<K,V> first = entryAt(tab, index);
for (HashEntry<K,V> e = first;;) {
if (e != null) {
K k;
// 更新已有 value...
}
else {
// 放置 HashEntry 到特定位置,如果超过阈值,进行 rehash
// ...
}
}
} finally {
unlock();
}
return oldValue;
}
从代码可以看出在 JDK7 中,ConcurrentHashMap 进行并发写操作时:
1.先获取锁,保证数据一致性,在并发修改时 Segment 锁定
2.最初阶段进行重复性扫描,确定相应的 key 值是否已经在数组里,进而决定是更新还是放置
3.concurrent 也有扩容问题,扩容只针对 Segment
有些方法需要跨段,比如 size() 和 containsValue() ,它们可能需要锁定整个表而而不仅仅是某个段,这需要按顺序锁定所有段,操作完毕后,又按顺序释放所有段的锁
-
JDK8 的 ConcurrentHashMap
JDK8 的优化如下:
-
总体结构上内部存储更像 HashMap,同样是大的桶数组,内部也是一个个链表
-
内部仍然有 Segment,但仅仅为了保证序列化的兼容,没有实质意义
-
由于不再使用 Segment,初始化操作大大简化,初始化修改为 lazy-load 模式,避免初始开销
-
数据存储利用 volatile 来保证可见性
-
使用 CAS 等操作,在特定场景进行无锁并发
-
使用 UnSafe ,LongAddr 等底层手段
put 方法的初始化操作在 initTable 方法里,这是典型 CAS 场景。利用 CAS 的 sizeCTl 作为互斥手段,发现竞争就 spin(自旋),等待条件回复,否则利用 CAS 设置排他标志。如果成功则进行初始化;否则重试。
当 bin 链表为空,利用 CAS 去进行无锁的安全操作。
当有同步逻辑时使用的 syncronized 而不是 ReentrantLock 因为在 JDK8 synchronized 已经被不断优化,可以不再过分担心性能差异。另外,相比于 ReentrantLock,它可以减少内存消耗。
代码如下:(sizeCTl 是用于多线程之间同步的一个互斥变量)
final V putVal(K key, V value, boolean onlyIfAbsent) { if (key == null || value == null) throw new NullPointerException();
int hash = spread(key.hashCode());
int binCount = 0;
for (Node<K,V>[] tab = table;;) {
Node<K,V> f; int n, i, fh; K fk; V fv;
if (tab == null || (n = tab.length) == 0)
tab = initTable();
else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
// 利用 CAS 去进行无锁线程安全操作,如果 bin 是空的
if (casTabAt(tab, i, null, new Node<K,V>(hash, key, value)))
break;
}
else if ((fh = f.hash) == MOVED)
tab = helpTransfer(tab, f);
else if (onlyIfAbsent // 不加锁,进行检查
&& fh == hash
&& ((fk = f.key) == key || (fk != null && key.equals(fk)))
&& (fv = f.val) != null)
return fv;
else {
V oldVal = null;
synchronized (f) {
// 细粒度的同步修改操作...
}
}
// Bin 超过阈值,进行树化
if (binCount != 0) {
if (binCount >= TREEIFY_THRESHOLD)
treeifyBin(tab, i);
if (oldVal != null)
return oldVal;
break;
}
}
}
addCount(1L, binCount);
return null;
}
private final Node<K,V>[] initTable() {
Node<K,V>[] tab; int sc;
while ((tab = table) == null || tab.length == 0) {
// 如果发现冲突,进行 spin 等待
if ((sc = sizeCtl) < 0)
Thread.yield();
// CAS 成功返回 true,则进入真正的初始化逻辑
else if (U.compareAndSetInt(this, SIZECTL, sc, -1)) {
try {
if ((tab = table) == null || tab.length == 0) {
int n = (sc > 0) ? sc : DEFAULT_CAPACITY;
@SuppressWarnings("unchecked")
Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n];
table = tab = nt;
sc = n - (n >>> 2);
}
} finally {
sizeCtl = sc;
}
break;
}
}
return tab;
}
以上便是 ConcurrentHashMap 的底层原理,以及演变过程。
今天我们分享了 HashMap 和 ConcurrentHashMap 的底层实现逻辑,可能有很多细节还是没有讨论到,以后还要用心去研究。
在日常开发中我们应当做到知其然也要知其所以然,这样才能举一反三,更好的吸收知识,更快的让自己成长起来。
以后的章节,我们开始并发知识的分享,敬请期待。
关注一下,我写的就更来劲儿啦 ~