一、前言
HashMap 是我们日常中最常见的 map,它是线程不安全的,下面我们一起学一下 HashMap 的原理。
二、Java 1.7 中 HashMap 解析
2.1 HashMap 的数据结构
HashMap实际上是一个“链表的数组”的数据结构,每个元素存放链表头结点的数组,即数组和链表的结合体。
从上图中可以看出,HashMap 底层就是一个数组结构,数组中的每一项又是一个链表。
链表是由一系列非连续的节点组成的存储结构,简单分下类的话,链表又分为单向链表和双向链表,而单向/双向链表又可以分为循环链表和非循环链表,下面简单就这四种链表进行图解说明。
1.单向链表
单向链表就是通过每个结点的指针指向下一个结点从而链接起来的结构,最后一个节点的 next 指向 null。
2.单向循环链表
单向循环链表和单向链表的不同是,最后一个节点的 next 不是指向 null,而是指向 head 节点,形成一个“环”。
3.双向链表
从名字就可以看出,双向链表是包含两个指针的,pre 指向前一个节点,next 指向后一个节点,但是第一个节点 head 的 pre 指向 null,最后一个节点 tail 的 next 指向 null。
4.双向循环链表
双向循环链表和双向链表的不同在于,第一个节点的 pre 指向最后一个节点,最后一个节点的 next 指向第一个节点,也形成一个“环”。LinkedList 就是基于双向循环链表设计的。
当新建一个 HashMap 的时候,就会初始化一个数组。源码如下:
/**
* The table, resized as necessary. Length MUST Always be a power of two.
*/
transient Entry[] table;
static class Entry<K,V> implements Map.Entry<K,V> {
final K key;
V value;
Entry<K,V> next;
final int hash;
……
}
可以看出 Entry 就是数组中的元素,每个 Map.Entry 其实就是一个 key-value 对,它持有一个指向下一个元素的引用,这就构成了链表。HashMap 的底层主要是基于数组和链表来实现的,它之所以有相当快的查询速度主要是因为它是通过计算散列码来决定存储的位置。HashMap 中主要是通过 key 的 hashCode 来计算 hash 值的,只要 hashCode 相同,计算出来的 hash 值就一样。如果存储的对象多了,就有可能出现不同的对象所算出来的 hash 值是相同的,这就出现了所谓的 hash 冲突。学过数据结构的同学都知道,解决 hash 冲突的方法有很多,HashMap 底层是通过链表来解决 hash 冲突的。
系统会创建一个长度为 capacity 的 Entry 数组,这个数组里可以存储元素的位置被称为 “桶(bucket)”,每个 bucket 都有其指定索引,系统可以根据其索引快速访问该 bucket 里存储的元素。
2.2 重要方法解析
HashMap提供了以下构造函数:
HashMap() //无参构造方法
HashMap(int initialCapacity) //指定初始容量的构造方法
HashMap(int initialCapacity, float loadFactor) //指定初始容量和负载因子
HashMap(Map<? extends K,? extends V> m) //指定集合,转化为HashMap
在这里提到了两个参数:initialCapacity(初始容量,默认值 16),loadFactor(加载因子,默认值 0.75)。这两个参数是影响 HashMap 性能的重要参数,其中容量表示哈希表中桶的数量,初始容量是创建哈希表时的容量,加载因子是哈希表在其容量自动增加之前可以达到多满的一种尺度,它衡量的是一个散列表的空间的使用程度,负载因子越大表示散列表的装填程度越高,反之愈小。对于使用链表法的散列表来说,查找一个元素的平均时间是 O(1+a),因此如果负载因子越大,对空间的利用更充分,然而后果是查找效率的降低;如果负载因子太小,那么散列表的数据将过于稀疏,对空间造成严重浪费。系统默认负载因子为 0.75,一般情况下我们是无需修改的。
若:加载因子越大,填满的元素则越多,好处是空间利用率高了,但冲突的机会加大了,链表长度会越来越长、查找效率降低;反之,加载因子越小,填满的元素则越少,好处是冲突的机会减小了,但空间浪费多了,表中的数据将过于稀疏(很多空间还没用,就开始扩容了)。
当哈希表中条目数超出了当前 容量 * 加载因子 (其实就是 HashMap 的实际容量)时,则对该哈希表进行 rehash 操作,将哈希表扩充至两倍的桶数。
下面我们来看一下存储方法,代码如下:
public V put(K key, V value) {
//当key为null,调用putForNullKey方法,保存null与table第一个位置中,这是HashMap允许为null的原因
if (key == null)
return putForNullKey(value);
//计算key的hash值
int hash = hash(key.hashCode()); ------(1)
//计算key hash 值在 table 数组中的位置
int i = indexFor(hash, table.length); ------(2)
//从i出开始迭代 e,找到 key 保存的位置
for (Entry<K, V> e = table[i]; e != null; e = e.next) {
Object k;
//判断该条链上是否存在相同的key值
//若存在相同,则直接覆盖value,返回旧value
if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
V oldValue = e.value; //旧值 = 新值
e.value = value;
e.recordAccess(this);
return oldValue; //返回旧值
}
}
//修改次数增加1
modCount++;
//将key、value添加至i位置处
addEntry(hash, key, value, i);
return null;
}
通过源码我们可以清晰看到 HashMap 保存数据的过程为:首先判断 key 是否为 null,若为 null,则直接调用 putForNullKey 方法,将 value 放置在数组第一个位置上。若不为空则根据 key 的 hashCode 重新计算 hash 值,然后根据 hash 值得到这个元素在 table 数组中的位置(即下标),如果 table 数组在该位置处已经存放有其他元素了,则通过循环比较是否存在相同的 key,若存在则覆盖原来 key 的 value,否则将该元素保存在链头(头插法,最先保存的元素放在链尾)。若 table 在该处没有元素,就直接将该元素放到此数组中的该位置上。这个过程看似比较简单,其实深有内幕。有如下几点:
- 先看迭代处。此处迭代原因就是为了防止存在相同的 key 值,若发现两个 key 值相同时,HashMap 的处理方式是用新 value 替换旧 value,这里并没有处理 key,这就解释了 HashMap 中没有两个相同的key。另外,注意一点,对比 key 是否相同,是先比 HashCode 是否相同,HashCode 相同再判断 equals 是否为 true,这样大大增加了 HashMap 的效率。
- 在看(1)、(2)处。这里是 HashMap 的精华所在。首先是 hash 方法,该方法为一个纯粹的数学计算,就是计算 key 的 hash 值。此算法加入了高位计算,防止低位不变,高位变化时,造成的 hash 冲突。
static int hash(int h) {
h ^= (h >>> 20) ^ (h >>> 12);
return h ^ (h >>> 7) ^ (h >>> 4);
}
具体的实现过程见 addEntry 方法,如下:
void addEntry(int hash, K key, V value, int bucketIndex) {
if ((size >= threshold) && (null != table[bucketIndex])) {
resize(2 * table.length); //扩容操作,将数据元素重新计算位置后放入newTable中,链表的顺序与之前的顺序相反
hash = (null != key) ? hash(key) : 0;
bucketIndex = indexFor(hash, table.length);
}
createEntry(hash, key, value, bucketIndex);
}
void createEntry(int hash, K key, V value, int bucketIndex) {
Entry<K,V> e = table[bucketIndex];
table[bucketIndex] = new Entry<>(hash, key, value, e);
size++;
}
添加方法的具体操作,在添加之前先进行容量的判断,如果当前容量达到了阈值,并且需要存储到 Entry[] 数组中,先进行扩容操作,扩充的容量为 table 长度的 2 倍。重新计算 hash 值,和数组存储的位置,扩容后的链表顺序与扩容前的链表顺序相反。然后将新添加的 Entry 实体存放到当前 Entry[] 位置链表的头部。在 Java 1.8 之前,新插入的元素都是放在了链表的头部位置,但是这种操作在高并发的环境下容易导致死锁,所以 1.8 之后,新插入的元素都放在了链表的尾部。
相对于HashMap的存而言,取就显得比较简单了:
public V get(Object key) {
if (key == null)
//返回table[0] 的value值
return getForNullKey();
Entry<K,V> entry = getEntry(key);
return null == entry ? null : entry.getValue();
}
final Entry<K,V> getEntry(Object key) {
if (size == 0) {
return null;
}
int hash = (key == null) ? 0 : hash(key);
for (Entry<K,V> e = table[indexFor(hash, table.length)];
e != null;
e = e.next) {
Object k;
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
return e;
}
return null;
}
在get方法中,首先计算hash值,然后调用indexFor()方法得到该key在table中的存储位置,得到该位置的单链表,遍历列表找到key和指定key内容相等的Entry,返回entry.value值。
再来看看删除方法:
public V remove(Object key) {
Entry<K,V> e = removeEntryForKey(key);
return (e == null ? null : e.value);
}
final Entry<K,V> removeEntryForKey(Object key) {
if (size == 0) {
return null;
}
int hash = (key == null) ? 0 : hash(key);
int i = indexFor(hash, table.length);
Entry<K,V> prev = table[i];
Entry<K,V> e = prev;
while (e != null) {
Entry<K,V> next = e.next;
Object k;
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k)))) {
modCount++;
size--;
if (prev == e)
table[i] = next;
else
prev.next = next;
e.recordRemoval(this);
return e;
}
prev = e;
e = next;
}
return e;
}
删除操作,先计算指定 key 的 hash 值,然后计算出 table 中的存储位置,判断当前位置是否 Entry 实体存在,如果没有直接返回,若当前位置有 Entry 实体存在,则开始遍历列表。定义了三个 Entry 引用,分别为 pre, e ,next。在循环遍历的过程中,首先判断 pre 和 e 是否相等,若相等表明,table的当前位置只有一个元素,直接将 table[i] = next = null 。若形成了 pre -> e -> next 的连接关系,判断e的key是否和指定的key 相等,若相等则让pre -> next ,e 失去引用。
三、Java 1.8 的改变
在 Jdk1.8 中 HashMap 的实现方式做了一些改变,但是基本思想还是没有变的,只是在一些地方做了优化,下面来看一下这些改变的地方。数据结构的存储由 数组+链表 的方式,变化为 数组+链表+红黑树 的存储方式,在性能上进一步得到提升。
我们来看一下它的 put 方法:
public V put(K key, V value) {
//调用putVal()方法完成
return putVal(hash(key), key, value, false, true);
}
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
//判断table是否初始化,否则初始化操作
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
//计算存储的索引位置,如果没有元素,直接赋值
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
else {
Node<K,V> e; K k;
//节点若已经存在,执行赋值操作
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
//判断链表是否是红黑树
else if (p instanceof TreeNode)
//红黑树对象操作
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
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;
//判断是否需要扩容
if (++size > threshold)
resize();
//空操作
afterNodeInsertion(evict);
return null;
}
四、总结
- HashMap 采用 hash 算法来决定 Map 中 key 的存储,并通过 hash 算法来增加集合的大小。
- hash 表里可以存储元素的位置称为桶(bucket),如果通过 key 计算 hash 值发生冲突时,那么将采用链表的形式来存储元素。
- HashMap 的扩容操作是一项很耗时的任务,所以如果能估算Map的容量,最好给它一个默认初始值,避免进行多次扩容。
- HashMap 的线程是不安全的,多线程环境中推荐是 ConcurrentHashMap。
4.1、实现原理
- HashMap是基于hashing的原理,我们使用put(key, value)存储对象到HashMap中,使用get(key)从HashMap中获取对象。
- 当我们给put(key, value)方法传递键和值时,它先调用key.hashCode()方法,返回的hashCode值,用于找到bucket位置,来储存Entry对象。
- Map提供了一些常用方法,如keySet()、entrySet()等方法。
- keySet()方法返回值是Map中key值的集合;entrySet()的返回值也是返回一个Set集合,此集合的类型为Map.Entry。
- “如果两个key的hashcode相同,你如何获取值对象?”答案:当我们调用get(key)方法,HashMap会使用key的hashcode值,找到bucket位置,然后获取值对象。
- “如果有两个值对象,储存在同一个bucket ?”答案:将会遍历链表直到找到值对象。
- “这时会问因为你并没有值对象去比较,你是如何确定确定找到值对象的?”答案:找到bucket位置之后,会调用keys.equals()方法,去找到链表中正确的节点,最终找到要找的值对象。
完美的回答:
- HashMap基于hashing原理,我们通过put(key,value)和get(key)方法储存和获取对象。
- 当储存对象时,我们将键值对传递给put(key,value)方法时,它调用键对象key的hashCode()方法来计算hashcode,然后找到bucket位置,来储存值对象value。
- 当获取对象时,通过key的equals()方法找到正确的键值对key-value,然后返回值对象value。
- HashMap使用链表来解决碰撞问题,当发生碰撞了,对象将会储存在链表的下一个节点中。
- HashMap在每个链表节点中,储存 键值对key-value 对象。
- 当两个不同的键对象key的hashcode相同时,会发生什么?它们会储存在同一个bucket位置的链表中,并通过键对象key的equals()方法用来找到键值对key-value。
- 因为HashMap的好处非常多,我曾经在我的应用中使用HashMap作为缓存。因为金融领域非常多的运用Java,也出于性能的考虑,我们会经常用到HashMap和ConcurrentHashMap。
4.2、底层的数据结构
HashMap的底层主要是基于数组和链表来实现的,它之所以有相当快的查询速度主要是因为它是通过计算散列码来决定存储的位置。
- HashMap中主要是通过key的hashCode来计算hash值的,只要hashCode相同,计算出来的hash值就一样。
- 如果存储的对象对多了,就有可能不同的对象所算出来的hash值是相同的,这就出现了所谓的hash冲突。
- 学过数据结构的同学都知道,解决hash冲突的方法有很多,HashMap底层是通过链表来解决hash冲突的。
补充知识:
- HashMap是基于哈希表的 Map 接口的实现。
- 此实现提供所有可选的映射操作,并允许使用 null 值和 null 键。(除了非同步和允许使用 null 之外,HashMap 类与 Hashtable 大致相同。)
- 此类不保证映射的顺序,特别是它不保证该顺序恒久不变。
- 值得注意的是HashMap不是线程安全的,如果想要线程安全的HashMap,可以通过Collections类的静态方法synchronizedMap获得线程安全的HashMap。
- Map map = Collections.synchronizedMap(new HashMap());
- HashMap结合了ArrayList与LinkedList两个实现的优点,虽然HashMap并不会向List的两种实现那样,在某项操作上性能较高,但是在基本操作(get 和 put)上具有稳定的性能。