Map综述
Java为数据结构中的映射提供了一个接口java.util.Map
,这个接口有四个常用的实现类:HashMap
、LinkedHashMap
、TreeMap
以及HashTable
,继承关系如下:
四个类的简单说明
HashMap
- 根据键的
hashCode
值来存储数据,大多数情况下可以直接定位到它的值,因而具有很快的访问速度,但是遍历顺序却是不确定的 - 最多允许一条记录的键(key)为
null
,允许多条记录的值(value)为null
- 是线程不安全的,即任意时刻有多个线程可以同时操作
HashMap
,可能会导致数据不一致以及访问数据时的无限循环 - 如果需要满足线程安全,可以使用
Collections.synchronizedMap()
来使HashMap
成为线程安全的类,或者使用ConcurrentHashMap
HashTable
HashTable
是遗留类(源自JDK1.0),与此相同的还有Vector
、Stack
,目前不推荐使用HashTable
的大部分功能与HashMap
相似,不同的是HashTable
还继承了Dictionary
类HashTable
不允许键和值为null
- 是线程安全的,所有的公有方法均是同步方法,同一时间只能有一个线程访问
HashTable
,并发性不如ConcurrentHashMap
。不需要线程安全的环境下可以使用HashMap
替换,需要线程安全的环境可以使用ConcurrentHashMap
替换
LinkedHashMap
LinkedHashMap
是HashMap
的子类,底层使用双向链表来维护插入顺序- 在使用
Iterator
遍历LinkedHashMap
时,默认情况下先得到的记录肯定是先插入的,也可以在构造时指定是使用LRU算法,将最近访问的元素移动到链表的尾部 - 是线程不安全的,目前JUC包下没有对应的并发容器,可以采用
Collections.synchronizedMap()
来获取一个线程安全的LinkedHashMap
TreeMap
TreeMap
实现了SortedMap
接口,能够把它保存的记录根据键排序,默认是升序,也可以指定Comparator
来进行比较TreeMap
构造时未指定比较器,则不允许键(key)为null
。如果指定了比较器,则由比较器的实现决定TreeMap
是一个有序的key-value集合,通过红黑树实现的- 使用
Iterator
遍历TreeMap
时,得到的记录是排过序的,与前面的三种Map
一样,都是fail-fast
(快速失败)的 TreeMap
同样是线程不安全的,除了使用Collections.synchronizedMap()
以外,还可以使用ConcurrentSkipListMap
来代替
总结
对于上述四种Map
接口的实现类,要求映射中的key是在创建以后它的哈希值不会改变,如果哈希值发生变化,则很有可能在Map
中定位不到对应的位置
HashMap
类图
存储实现
从存储结构上来讲,HashMap
采用了链地址法实现,数组+链表+红黑树,如下图所示
具体实现
从源码可知,HashMap
中定义了
transient Node<K,V>[] table;
即哈希桶数组,而具体对象则是Node
,下面是Node
对象的定义
static class Node<K,V> implements Map.Entry<K,V> {
final int hash; // 用来定义数组索引位置
final K key;
V value;
Node<K,V> next; // 指向的下一个结点
Node(int hash, K key, V value, Node<K,V> next) {
this.hash = hash;
this.key = key;
this.value = value;
this.next = next;
}
public final K getKey() { return key; }
public final V getValue() { return value; }
public final String toString() { return key + "=" + value; }
public final int hashCode() {
return Objects.hashCode(key) ^ Objects.hashCode(value);
}
public final V setValue(V newValue) {
V oldValue = value;
value = newValue;
return oldValue;
}
public final boolean equals(Object o) {
if (o == this)
return true;
if (o instanceof Map.Entry) {
Map.Entry<?,?> e = (Map.Entry<?,?>)o;
if (Objects.equals(key, e.getKey()) &&
Objects.equals(value, e.getValue()))
return true;
}
return false;
}
}
Node
是HashMap
的一个内部类,实现了Map.Entry
接口,本身就是一个映射(键值对)
存储优势
HashMap
使用的是哈希表来存储。哈希表为了解决冲突,可以采用开放地址法和链地址法等来解决问题。而在HashMap
中采用的是链地址法,简单来说就是数组+链表的结合。在每个数组元素上都是一个链表,当数据插入时,通过哈希值计算出数据所在数组的下标,然后直接将数据置于对应链表的末尾
为了减少哈希冲突以及将哈希桶数组控制在较少的情况下,HashMap
实现了一套比较好的Hash
算法和扩容机制
Hash算法和扩容机制
从HashMap
的构造方法来看,主要是对以下几个字段进行初始化
transient Node<K,V>[] table; // 哈希桶数组
int threshold; // 当前哈希桶数组最多容纳的key-value键值对的个数
final float loadFactor; // 负载因子,可以知道负载因子在HashMap创建以后就不能被修改
transient int size;
transient int modCount;
- 在初始化
table
时,可以通过构造函数指定初始容量,如果不指定则使用默认的的长度(16)。此时除了传入Map
参数的构造函数以外,都不会进行Node
数组的创建 loadFactor
为负载因子,默认值为0.75threshold
是HashMap
所能容纳的最多Node
的个数,threshold = table.length * loadFactor
,也就是说,在数组定义好长度以后,负载因子越大,所能容纳的键值对个数越多size
字段用于记录HashMap
中实际存在的键值对数量modCount
用于记录HashMap
内部结构变化的次数,主要用于迭代时的快速失败。需要注意的是,内部结构变化是指结构发生变化,例如新增一个结点、删除一个结点、改变哈希桶数组,但是在put()
方法中,键对应的值被覆盖则不属于结构变化
具体分析
负载因子与threshold
- 结合负载因子的计算公式可知,
threshold
是在此负载因子与当前数组对应下所允许的最大数目 - 当
Node
结点的个数超过threshold
时就需要resize
(扩容),扩容后的容量是之前的两倍 - 默认的负载因子值为0.75,这个值是对空间和时间效率的一个平衡选择,一般来说不需要修改
- 如果内存空间很多而又对时间效率要求很高,可以降低负载因子的值,这个值必须大于0
- 如果内存空间紧张而对时间效率要求不高,可以增加负载因子的值,这个值可以大于1
哈希桶数组的长度
- 在
HashMap
中,哈希桶数组table
的长度必须为$ 2^n $
(即一定为合数),这是一种非常规的设计
- 常规的设计是把桶的个数设计为素数,相对来说素数导致冲突的概率小于合数
- 在
HashTable
中,初始化桶的个数为11,这是桶个数设计为素数的应用(当然,HashTable
不保证扩容以后还是素数)
HashMap
采用这种非常规设计,主要是为了在取模和扩容时做优化- 为了减少冲突,
HashMap
在定位哈希桶索引时,加入了高位参与运算的过程
红黑树的加入
- 负载因子与Hash算法即使设计得再合理,也有可能出现链表过长的情况,一旦出现链表过长,则会严重影响
HashMap
的性能 - 在JDK1.8中,引入了红黑树,在链表长度太长(默认超过8)时,会将链表转换为红黑树。利用红黑树插入、删除、查找都为
$ logN $
的时间复杂度来提升HashMap
的性能
功能实现
主要从根据键获取哈希桶数组的索引位置、put()
方法的详细执行以及如何扩容来分析
确定哈希桶数组的索引位置
在实现过程,HashMap
采用了两步来键映射到对应的哈希桶数组的索引上
对键的哈希值进行再哈希,将键的哈希值的高位参与运算
static final int hash(Object key) { int h; // h = key.hashCode() 第一步获取hashCode // h ^ (h >>> 16) 第二步将高位参与运算 return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16); }
取模运算定位索引
// n为哈希桶数组的长度 // hash值为再哈希的结果 // (table.length - 1) & hash p = tab[i = (n - 1) & hash]);
对于第一步:
- 对于任意对象,只要它的
hashCode()
方法返回的哈希值一样,经过hash()
这个方法,那么再哈希的结果都是一样的。 - 在JDK1.8中,优化了高位运算的算法,通过
hashCode()
的高16位异或低16位实现的:h = key.hashCode()) ^ (h >>> 16
,主要是从速度、功效、质量来考虑的,这么做可以在哈希桶数组较小时,也能保证高低位都参与到哈希值的计算中,同时不会有太大开销
对于第二步:
* 一般在计算哈希值对应的数组索引时,会采用取模运算,但是模运算的消耗是比较大
* 在HashMap
中是通过(table.length - 1) & hash
的方式来计算对应的数组索引。这是一个很巧妙的做法,前面提到过哈希桶数组的长度始终为$ 2^n $
,在优化了操作速度以外,(table.length - 1) & hash
这个运算等同于对table.length
取模,即hash % table.length
,但是&
比%
运算效率更高
举例说明,n为哈希桶数组的长度
put() 方法的实现
- 判断哈希桶数组是否为
null
或者长度为0,否则执行resize()
进行扩容 - 根据键值
key
计算得到的数组索引i
,如果table[i] == null
,直接新建结点进行添加,然后执行6。如果table[i] != null
,继续执行3 - 判断
table[i]
的首个元素是否与key
相等(指的hashCode
、地址以及equals()
),如果相等,直接覆盖value
,然后返回旧值。否则继续执行4 - 判断
table[i]
是否为TreeNode
,即table[i]
是否是红黑树,如果是红黑树,则在树中插入新的结点,同时如果树中存在相应的key
,也会直接覆盖value
,然后返回旧值。否则继续执行5 - 遍历
table[i]
,判断链表长度是否大于8,
- 大于8的话会将链表转换为红黑树,在红黑树中执行插入操作
- 否则进行链表的插入操作
- 遍历过程中若发现
key
已经存在,直接覆盖value
后,返回旧值即可
- 插入成功后,判断实际存在的键值对数是否超过了最大容量
threshold
,如果超过进行扩容
put() 方法源码
public V put(K key, V value) {
// 对key的hashCode进行再哈希
return putVal(hash(key), key, value, false, true);
}
/**
* 插入时如果key已存在,对值进行替换,然后返回旧值
* 如果onlyIfAbsent为true,则不会对已存在的值进行替换
* 如果不存在,直接插入,然后返回null
*/
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
// 1. table为空或者长度为0则进行创建
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
// 2. 计算key在哈希桶数组中的下标,如果这个位置没有节点则直接进行插入
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
else {
Node<K,V> e; K k;
// 3. 如果key存在,直接覆盖value
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
// 4. 判断链表是否是红黑树
else if (p instanceof TreeNode)
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
// 5. 该链是链表
else {
for (int binCount = 0; ; ++binCount) {
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);
// 链表长度大于8,转换为红黑树
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
break;
}
// key 已存在直接覆盖
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
++modCount;
// 6. 超过最大容量就进行扩容
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
扩容机制
扩容resize()
就是重新计算容量,并将结点重新置于哈希桶数组。Java中的数组是无法自动扩容的,方法是使用各新数组代替已有的容量小的数组,然后将结点重新放入哈希桶中
final Node<K,V>[] resize() {
// 1. 记录扩容前的哈希桶数组
Node<K,V>[] oldTab = table;
// 2. 记录旧哈希桶数组的长度,如果为null就是0
int oldCap = (oldTab == null) ? 0 : oldTab.length;
// 3. 记录旧的最大限制
int oldThr = threshold;
int newCap, newThr = 0;
// 4. 旧长度大于0
if (oldCap > 0) {
// 判断旧的长度是否已经达到最大的容量,
// 如果是,则修改键值对的最大限制为Integer.MAX_VALUE
// 并在以后的操作,都不在会扩容
if (oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return oldTab;
}
// 新的数组长度为旧长度的两倍,同时threshold也会扩大两倍
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1; // double threshold
}
// 5. 此时是首次创建哈希桶数组,但是在创建HashMap时指定了键值对的最大限制
// 那么哈希桶数组的长度为这个最大限制(这个值是 2^n)
else if (oldThr > 0) // initial capacity was placed in threshold
newCap = oldThr;
// 6. 此时是首次创建哈希桶数组,将容量与限制设置为默认值
else { // zero initial threshold signifies using defaults
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
// 7. 如果新的限制是0,也就是还没有计算过
// 则通过新的长度与负载因子来计算出
if (newThr == 0) {
float ft = (float)newCap * loadFactor;
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
(int)ft : Integer.MAX_VALUE);
}
// 8. 将新的限制保存到threshold
threshold = newThr;
// 9. 创建新的哈希桶数组并赋值给table
@SuppressWarnings({"rawtypes","unchecked"})
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
table = newTab;
// 10. 旧的哈希桶数组里面存有结点,需要将结点置于新的哈希桶数组
if (oldTab != null) {
// 从头到尾遍历旧的哈希桶数组
for (int j = 0; j < oldCap; ++j) {
Node<K,V> e;
// 如果是空的就跳过
if ((e = oldTab[j]) != null) {
oldTab[j] = null;
// 这个索引位置只有一个结点,直接再hash后置于新的哈希桶数组
if (e.next == null)
newTab[e.hash & (newCap - 1)] = e;
// 这个链是红黑树,对红黑树进行再hash
else if (e instanceof TreeNode)
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
// 直接是一个单向链表,将单向链表的每个结点进行再hash存入新的哈希桶数组
// 相比JDK1.7(1.7是会在扩容后将之前的链表倒置)会保留结点之前的顺序
else { // preserve order
Node<K,V> loHead = null, loTail = null;
Node<K,V> hiHead = null, hiTail = null;
Node<K,V> next;
do {
next = e.next;
// 旧的索引
if ((e.hash & oldCap) == 0) {
if (loTail == null)
loHead = e;
else
loTail.next = e;
loTail = e;
}
// 旧的索引+oldCap
else {
if (hiTail == null)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
}
} while ((e = next) != null);
// 将旧索引部分放置新的哈希桶数组中
if (loTail != null) {
loTail.next = null;
newTab[j] = loHead;
}
// 将旧索引+oldCap放置新的哈希桶数组中
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
}
}
}
}
return newTab;
}
在JDK1.8中,HashMap
的哈希桶数组长度为$ 2^n $
(扩容是之前的2倍),所以元素的位置要么是在原位置,要么就是原位置+之前容量的位置
举个例子来说明,n是哈希桶数组的长度,
- 图(a)表示扩容前的key1和key2两种key确定索引位置的示例
- 图(b)表示扩容后的key1和key2两种key确定索引位置的实例
- hash1是key1对应的哈希值与高位的运算结果(hash2同理)
元素在重新计算哈希值以后,因为哈希桶数组长度n变为2倍,那么n-1的mask范围在高位多1bit,因此新的索引变化就如下:
因此,在将已有的哈希桶结点放入新的哈希桶数组时,就不需要每个结点都重新计算hash,只需要看原来的hash值新增的1bit是1还是0,是0的话索引不变,是1的话索引就变成“旧索引+oldCap”
线程安全性
HashMap
是线程不安全的,在多线程场景下使用HashMap
可能造成无限循环而导致CPU使用100%
无限循环的出现是因为如果有多个线程在HashMap
中插入新值,并同时触发了resize()
对哈希桶数组进行扩容,在对同一条链的所有结点进行再hash分配到新的哈希桶数组的过程中,可能会使链上的某个结点指向自身前面的结点,而不是后面的结点,那么在后面的get()
/put()
操作中,对相应的链访问时就会出现无限循环
总结
- 扩容是一个很消耗性能的操作,所以在使用
HashMap
时,最好能估算一下大致的容量,避免HashMap
频繁的扩容 - 负载因子是可以自己修改的,值可以大于1,但不能小于等于0。默认值0.75是一个权衡空间与时间效率的值,一般没有特殊需求最好不要轻易修改
HashMap
是线程不安全的,在并发环境中使用HashMap
可能会出现数据不一致、数据丢失以及无限循环等问题,建议使用ConcurrentHashMap
- 红黑树的引入优化了
HashMap
的性能,同时扩容机制也相比JDK1.7更为优化