本文基于JDK1.7 进行解析,因为JDK1.8的版本,HashMap进行了优化,这个在最后会再进行分析。
一、概述
- 一个存储key-value键值对的容器,key和value支持null值;
- 通过hash算法来计算hascode值,用hashCode标识Entry在table中存储的位置,内部是哈希表来实现的数据结构;
- 在存储的时候是无序的;
- 同时它也是线程不安全的;
继承关系如下:
二、数据结构
transient Entry[] table;
HashMap 的内部,是通过哈希表的方式来实现的,哈希表其实就是数组+链表的形式。在HashMap的内部,有一个成员变量table,它是一个Entry类型的数组。
static class Entry<K,V> implements Map.Entry<K,V> {
final K key;
V value;
Entry<K,V> next; //下一个结点
final int hash; //对应的hash值
Entry(int h, K k, V v, Entry<K,V> n) {
value = v;
next = n;
key = k;
hash = h;
}
public final K getKey() {
return key;
}
public final V getValue() {
return value;
}
public final V setValue(V newValue) {
V oldValue = value;
value = newValue;
return oldValue;
}
public final boolean equals(Object o) {
if (!(o instanceof Map.Entry))
return false;
Map.Entry e = (Map.Entry)o;
Object k1 = getKey();
Object k2 = e.getKey();
if (k1 == k2 || (k1 != null && k1.equals(k2))) {
Object v1 = getValue();
Object v2 = e.getValue();
if (v1 == v2 || (v1 != null && v1.equals(v2)))
return true;
}
return false;
}
public final int hashCode() {
return (key==null ? 0 : key.hashCode()) ^
(value==null ? 0 : value.hashCode());
}
public final String toString() {
return getKey() + "=" + getValue();
}
void recordAccess(HashMap<K,V> m) {}
void recordRemoval(HashMap<K,V> m) {}
}
Entry里面有几个比较重要的成员变量,相应的含义如下:
名称 | 类型 | 用途 |
---|---|---|
key | K | 键值对的key |
value | V | 键值对的value |
next | Entry | 下一个键值对 |
hash | int | 当前键值对的hash值 |
在我们插入一个数据的时候,会先根据key按照hash算法计算获取应该存储的位置,然后在数组的相应位置存储插入数据,如果在不同的key按照hash 算法得到的位置是一样的时候,这个时候就是我们平常所说的哈希冲突,hashmap采用拉链法来解决冲突,也就是将所有的哈希地址相同的元素都放到到同一个链表中。
三、数据操作
通过源码来进一步深入分析。
1、增&改
public V put(K key, V value) {
if (key == null)
return putForNullKey(value);
int hash = hash(key.hashCode());
int i = indexFor(hash, table.length);
for (Entry<K,V> e = table[i]; e != null; e = e.next) {
Object k;
if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
V oldValue = e.value;
e.value = value;
e.recordAccess(this);
return oldValue;
}
}
modCount++;
addEntry(hash, key, value, i);
return null;
}
private V putForNullKey(V value) {
for (Entry<K,V> e = table[0]; e != null; e = e.next) {
if (e.key == null) {
V oldValue = e.value;
e.value = value;
e.recordAccess(this);
return oldValue;
}
}
modCount++;
addEntry(0, null, value, 0);
return null;
}
void addEntry(int hash, K key, V value, int bucketIndex) {
Entry<K,V> e = table[bucketIndex];
table[bucketIndex] = new Entry<>(hash, key, value, e);
if (size++ >= threshold)
resize(2 * table.length);
}
在插入数据的时候,首先调用判断key 是否为null,如果为null的话,调用putForNullKey方法进行存储,null 键哈希值为 0,也就是固定存储在table[0]的,putForNullKey方法首先会先判断,table[0]是否已经有数据了,如果有的话,遍历对应的链表,如果找到了key 为null的数据,直接覆盖其value,如果遍历完整个链表都没有的话,会调用 addEntry 方法,替换掉table[i]的数据。
如果key不为null的话,在插入数据的时候,需要知道插入的数据应该放在table数据的那个位置,而计算位置的算法就是 hash 算法。
- 1)、hash算法
static int hash(int h) {
// This function ensures that hashCodes that differ only by
// constant multiples at each bit position have a bounded
// number of collisions (approximately 8 at default load factor).
h ^= (h >>> 20) ^ (h >>> 12);
return h ^ (h >>> 7) ^ (h >>> 4);
}
/**
* Returns index for hash code h.
*/
static int indexFor(int h, int length) {
return h & (length-1);
}
indexFor的作用就是确定在table数组中的位置,因此只需要将hash
值对length
进行取余%s运算即可,但看到源码我们不禁有些疑惑,它是将hash
值跟 length-1
进行与运算,其实效果是一致的,并且这样子实现能够提高运算效率。为什么可以这样子替换这里有详细介绍。
传入indexFor的参数,不是key的hashCode所对应的值,而是通过调用hash方法(以key的hashCode为参数)进行再次散列得到的值,hash方法的实现,其实就是进行移位和异或运算。为啥要多这一步操作而不是直接使用key的hashCode 呢?这么做的原因是,通过移位和异或运算,可以提高hash的复杂度,让其有更好的分布性,进而降低hash冲突的概率。
hashCode是一个int值(32位),其范围正负21亿多,而我们一开始hashMap默认的长度一般都是16,一般hash值需要对16进行取余运算(HashMap是与运算,length-1的二进制就是00001111),得到的才是table数组的下标,假设某个key的hashCode是0AAA0001,如果不经过hash函数的处理:
进行取与运算之后,获取的值是1,可以看到hash 只有低4位参与了计算,高位的计算可以认为是无效的,这样导致了计算结果只与低位信息有关,高位数据并没发挥作用。如果有另外一个key的hashCode为0BBB0001,其计算的结果也是1,两个数明明非常大,但存储的位置却都是同一个,这样子的算法明显是很差的。因此hash函数的实现是通过若干次的移位、异或运算,把hashcode的变得更“散列”。【如hash ^ (hash >>> 4)
就是将 hash 高4位数据与低4位数据进行异或运算,通过这种方式,让高位数据与低位数据进行异或,以此加大低位信息的随机性,变相的让高位数据参与到计算中】
尽管通过再次hash 能够降低冲突率,但是冲突的情况还是会发生,那么HashMap是怎样解决冲突的呢?
-
2)、解决hash冲突的方法
在发生冲突的时候,HashMap会创建一个新的Entry,并且其next 指针 指向原来 table[i] 的值,令table[i]的值等于新创建的Entry,也就是冲突的时候,table[i]存储的,会一直都是最后插入的数据。 -
3)、例子
例如我们想要存储key为16,value为A的元素,我们忽略 hash 的过程,插入过程如下:
1)、确认在table数组中的位置:16 % 16 = 0
2)、获取在table[0]的数据,发现为空,则直接新建Entry1
插入;
继续插入key为34,value为B的元素:
3)、确认在table数组中的位置:32 % 16 = 0
4)、获取在table[0]的数据,发现不为空,说明发生了冲突碰撞
5)、遍历table[0]的链表,发现key为32的数据不存在,所以新创建一个新的Entry2
,同时为了保证旧值不丢失,会将新的Entry的next指向旧值。这便实现了在一个数组索引空间内存放多个数值项。
2、删
public V remove(Object key) {
Entry<K,V> e = removeEntryForKey(key);
return (e == null ? null : e.value);
}
final Entry<K,V> removeEntryForKey(Object key) {
int hash = (key == null) ? 0 : hash(key.hashCode());
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]位置(要删除的节点被覆盖)
table[i] = next;
else
// 否则将上一个节点的next指向当前判断结点的下一个(要删除的节点去除了引用)
prev.next = next;
e.recordRemoval(this);
return e;
}
prev = e;
e = next;
}
return e;
}
删除的实现很容易理解,首先根据key找到对应的在table 中的位置i,然后遍历table[i]位置的链表,如果hash 相等且key 相等,则进行链表的替换操作。
3、查
public V get(Object key) {
if (key == null)
return getForNullKey();
int hash = hash(key.hashCode());
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.equals(k)))
return e.value;
}
return null;
}
查询的实现更加简单,同样是获取在table数组中的位置i,然后遍历链表,找到hash相等并且key值相等的数据返回。
四、扩容机制
在Java中,数组的长度是固定的,也就是说数组只能存储固定数量的数据,但在开发的过程中,一般我们都无法知道要多大的才合适,小了不够用,大了用不完(浪费空间),因此我们需要一种能够动态变长的数组,HashMap内部已经帮我们实现了这样子的功能,在解释HashMap的扩容机制之前,需要先了解HashMap的几个成员变量。
名称 | 用途 | 默认值 |
---|---|---|
initialCapacity | 初始化容量 | 16 |
loadFactor | 负载因子 | 0.75 |
threshold | 当前 HashMap 所能容纳键值对数量的最大值,超过这个值,则需扩容 | 16 * 0.75 = 12 |
/** The default initial capacity - MUST be a power of two. */
static final int DEFAULT_INITIAL_CAPACITY = 16;
/**
* The maximum capacity, used if a higher value is implicitly specified
* by either of the constructors with arguments.
* MUST be a power of two <= 1<<30.
*/
static final int MAXIMUM_CAPACITY = 1 << 30;
/** The load factor used when none specified in constructor. */
static final float DEFAULT_LOAD_FACTOR = 0.75f;
/** The table, resized as necessary. Length MUST Always be a power of two. */
transient Entry[] table;
/** The number of key-value mappings contained in this map. */
transient int size;
/** The next size value at which to resize (capacity * load factor). */
int threshold;
/**The load factor for the hash table. */
final float loadFactor;
/** The number of times this HashMap has been structurally modified */
transient int modCount;
void resize(int newCapacity) {
Entry[] oldTable = table;
int oldCapacity = oldTable.length;
if (oldCapacity == MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return;
}
Entry[] newTable = new Entry[newCapacity];
transfer(newTable);
table = newTable;
threshold = (int)(newCapacity * loadFactor);
}
/**
* Transfers all entries from current table to newTable.
*/
void transfer(Entry[] newTable) {
Entry[] src = table;
int newCapacity = newTable.length;
for (int j = 0; j < src.length; j++) {
Entry<K,V> e = src[j];
if (e != null) {
src[j] = null;
do {
Entry<K,V> next = e.next;
int i = indexFor(e.hash, newCapacity);
e.next = newTable[i];
newTable[i] = e;
e = next;
} while (e != null);
}
}
}
HashMap有几个常用的构造函数如下,我们在平常使用的过程中,都是调用new HashMap来获得一个HashMap对象,这个无参购买函数使用的都是默认值去初始化哈希表,也就是一开始初始化的时候,table数组的大小是16,
public HashMap(int initialCapacity, float loadFactor) {
if (initialCapacity < 0)
throw new IllegalArgumentException("Illegal initial capacity: " +
initialCapacity);
if (initialCapacity > MAXIMUM_CAPACITY)
initialCapacity = MAXIMUM_CAPACITY;
if (loadFactor <= 0 || Float.isNaN(loadFactor))
throw new IllegalArgumentException("Illegal load factor: " +
loadFactor);
// Find a power of 2 >= initialCapacity
int capacity = 1;
while (capacity < initialCapacity)
capacity <<= 1;
this.loadFactor = loadFactor;
threshold = (int)(capacity * loadFactor);
table = new Entry[capacity];
init();
}
public HashMap(int initialCapacity) {
this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
public HashMap() {
this.loadFactor = DEFAULT_LOAD_FACTOR;
threshold = (int)(DEFAULT_INITIAL_CAPACITY * DEFAULT_LOAD_FACTOR);
table = new Entry[DEFAULT_INITIAL_CAPACITY];
init();
}
因此,在插入的数据大于阈值 threshold
的时候,就需要进行扩容了。扩容的实现是新建了一个Entry数组,长度为原来的两倍,然后调用transfer方法,将旧的数组的全部元素添加到新的Entry数组中(注意,要重新计算元素在新的数组中的索引位置),可以看到扩容是一个相当耗时的操作,因为它需要重新计算这些元素在新的数组中的位置并进行复制处理。
void resize(int newCapacity) {
Entry[] oldTable = table; //存储旧table数组
int oldCapacity = oldTable.length; //旧table数组的长度
if (oldCapacity == MAXIMUM_CAPACITY) {
//如果大于MAXIMUM_CAPACITY,使阈值为Integer.MAX_VALUE,然后返回
threshold = Integer.MAX_VALUE;
return;
}
//新建一个长度为旧数组长度两倍的新数组
Entry[] newTable = new Entry[newCapacity];
transfer(newTable); //做数组迁移
table = newTable; //使用新数组替换掉旧数组
threshold = (int)(newCapacity * loadFactor); //更新阈值
}
void transfer(Entry[] newTable) {
Entry[] src = table;
int newCapacity = newTable.length;
for (int j = 0; j < src.length; j++) {
Entry<K,V> e = src[j];
if (e != null) {
src[j] = null; //置空原来位置的数据
do {
Entry<K,V> next = e.next; // 使用next 变量,暂时存储e的next 位置数据
int i = indexFor(e.hash, newCapacity); //重新计算e在新的table数组中的位置(因为length变了)
e.next = newTable[i]; //将新数组的i位置的数据存储在e的next位置
newTable[i] = e; //让新table数组第i个位置的数据为e
e = next; //将一开始暂存的next 位置的数据赋值给e,继续循环
} while (e != null);
}
}
}
五、JDK1.8的修改
1、底层实现采用数组+链表+红黑树
在JDK1.7,我们知道,当冲突率较高的时候,查询数据其实就是遍历链表,效率是非常低的,在这一方面,JDK1.8做了一些优化。当链表的长度超过阈值8
的时候,会将链表转换为红黑树,在性能上会有一些提升。
其put方法的实现如下,在实现上跟JDK1.7有一些不同的是,在当链表的长度超过阈值8的时候,会转换成为红黑树,并且在hash的实现算法也进行了优化修改。
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
static final int hash(Object var0) {
int var1;
return var0 == null ? 0 : (var1 = var0.hashCode()) ^ var1 >>> 16;
}
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;
//计算存储的索引位置,如果没有元素,直接存储在tab对应的位置
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) // 大于阈值8,则执行转换操作
treeifyBin(tab, hash);
break;
}
//key存在,直接覆盖
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
if (e != null) {
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的数据删除和数据获取比较简单,比较复杂的就是插入数据put
的实现,按照JDK1.8的源码,整个操作的流程图如下。好了,这个HashMap的分析就先到这,限于篇幅问题,另外一些经典的问题会新开一篇文章来讲。