目录
本文是我在学习 java集合过程中,针对HashMap的一篇总结文章。由于博主是非科班出身程序员,在学习HashMap原理时遇到了很多困难,所以如果你和博主一样,数据结构基础也不扎实甚至是没有基础,这篇文章可能也非常适合你!
注:本文基于jdk1.8,不包括红黑树部分分析
以下是本文的结构,可根据需要跳转到相应位置,不必按顺序阅读!
一、预备知识
时间复杂度
时间复杂度用来度量算法的运行时间,记作: T(n) = O(f(n))。它表示随着输入 n 的增大,算法执行需要的时间的增长速度可以用 f(n) 来描述。渐进时间复杂度用大写O来表示,所以也被称为大 O表示法。
常用的时间复杂度比较:
O(1)<O(log n)<O(n)<O(nlog n)<O(n^2)
从左到右,算法执行效率逐渐下降,
了解更多:
基本数据结构
HashMap主干是哈希表,这之前先了解一下其他几种数据结构的性能。
数组
采用一段连续的存储单元来存储数据。对于指定下标的查找,时间复杂度为 O(1);通过给定值进行查找(顺序查找),需要遍历数组,逐一比对给定关键字和数组元素,时间复杂度为 O(n);对于有序数组,可采用二分查找,插值查找,斐波那契查找等方式,可将查找复杂度提高为 O(logn);对于指定位置的插入删除操作,涉及到数组元素的移动,其平均复杂度为 O(n);另外修改和查找实现复杂度相同。
链表
不是按线性的顺序存储数据,而是在每一个节点里存到下一个节点的指针(Pointer)。对于链表的新增,删除等操作(在找到指定操作位置后),仅需处理结点间的引用即可,时间复杂度为 O(1),而查找操作需要遍历链表逐一进行比对,复杂度为 O(n)。
数组与链表的根据指定值查询时间复杂度都是O(1),但数组更快
1. 链表需要在遍历时,需要比数组更多的寻址操作 2. CPU缓存会把一片连续的内存空间读入,因为数组结构是连续的内存地址,所以数组全部或者部分元素被连续存在CPU缓存里面,而链表则不会红黑树
是一种自平衡二叉查找树,可以在O(log n)时间内做查找,插入和删除,jdk8之后HashMap桶内链表长度超过树化阀值且总长度超过最小树化容量后会将链表转换为红黑树。
散列表
散列表(Hash table,也叫哈希表)是一种根据 key-value进行访问的数据结构。在散列表中进行添加,删除,查找等操作,性能十分之高,不考虑哈希冲突的情况下,仅需一次定位即可完成,时间复杂度为O(1)。哈希表是 HashMap主干,所以在分析 HashMap前要先详细了解一下哈希表。
散列表(Hash table),是根据键(Key)而直接访问在内存存储位置的数据结构。也就是说,它通过计算一个关于键值的函数f(x),将所需查询的数据映射到表中一个位置来访问记录,这加快了查找速度。这个映射函数f(x)称做散列函数,存放记录的数组称做散列表。
散列函数(Hash function),经散列函数映象到地址集合中任何一个地址的概率是相等的,则称此类散列函数为均匀散列函数(Uniform Hash function),散列函数的设计至关重要,好的散列函数会尽可能地保证 计算简单和散列地址分布均匀。散列函数构造方法包括直接定址法,随机数法,除留余数法等。
冲突(Collision):对不同的Key可能得到同一散列地址,即k1≠ k2,而 f(k1)=f(k2),再好的散列函数也无法避免冲突。产生冲突就要进行处理,通常处理冲突的方法有开发定址法,单独链表法,双散列,再散列等。在java中使用的是单独链表法。
举个例子:
假设有个数组长度为4数组(每一个位置都叫桶bucket),这里有3个人赵四,钱五,孙六要装进去,我们定义一个规则,按姓氏在百家姓中的顺序除以四得到的余数作为索引放入四个位置。
当前存放三个人记录的数组就是散列表,我们定义的规则就是散列函数,根据散列函数进行映射的过程叫做散列过程。
如果又来一个人叫周日,根据我们的规则,周日也要落在1的位置上,此时就产生了冲突。这里我们使用单独链表法处理,把周天放在索引为一的位置和赵四构成链表(新元素是放在链表前端还是后端完全是取决于怎么方便)。
散列表查找是就是先找到桶的位置,再遍历查找需要的数据。如果散列函数设计的不好,所有的元素都落在一个桶里那效率就特别低,和单链表随机访问没什么区别。
基本位运算
了解更多:
二、HashMap实现原理
结构
Node是 HashMap的静态内部,HashMap主干是一个Node数组,Node是HashMap的最基本组成单位。
// HashMap的主干数组
transient Node<K,V>[] table;
Node
static class Node<K,V> implements Map.Entry<K,V> {
// 这个节点所在位置的hash值
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;
}
…………(其他get(),set()等方法省略)
}
在jdk8之前HashMap是数组加链表的形式实现,但是在1.8之后为提高哈希冲突后链表的查询速度,当桶内链表长度超过树化阀值且总长度超过最小树化容量后会将链表转换为红黑树。
jdk7
jdk8
速度
查询与修改
先用散列函数对键进行散列,没有冲突的情况下查询是下标查询,时间复杂度是 O(1),速度很快。
存在哈希冲突的情况,需要对链表/红黑树进行遍历,equals比对查询。
性能上,考虑是链表/红黑树上的元素越是越好,越均匀越好;此外HashMap主干未必越长越好,会有用不到的桶浪费空间。
增加与删除
由于查询速度快,而桶里用链表/红黑树实现,所以添加和删除效率也很高。HashMap会在size超过阀值后进行调整大下(resize),所以根据具体情况提前给HashMap一个合适的初始长度是个不错的习惯。
三、源码分析
基本常量
// 默认初始长度,即主干数组的长度,如果创建对象时没有给长度,默认是16
// 在明确知道元素个数的情况下,初始化时建议可以把容量设置成expectedSize / 0.75F + 1.0F (guava)
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;
// 最大容量,HashMap最大容量是2^30
// 因为int范围是-2^31——2^31-1,但32位2进制最高位是符号位,所以最大是2^30
static final int MAXIMUM_CAPACITY = 1 << 30;
// 默认负载因子,默认是0.75,创建对象时可以自定义
// 可以用于计算扩容阀值transient Node<K,V>[] table;
static final float DEFAULT_LOAD_FACTOR = 0.75f;
// 树化阀值
// 当某一个桶中链表长度超过8时会转化为红黑树
static final int TREEIFY_THRESHOLD = 8;
// 去树化阀值
// 当一个桶里红黑树总结点数小于6时,会转化为链表
static final int UNTREEIFY_THRESHOLD = 6;
// 最小树化容量
// 树化的另一个条件,只有主干数组长度大于64才进行树化
static final int MIN_TREEIFY_CAPACITY = 64;
CAPACITY是 HashMap容量(主干数组长度),size是键值对个数
基本成员变量
// 主干数组
transient Node<K,V>[] table;
// 遍历时经常用到
transient Set<Map.Entry<K,V>> entrySet;
// HashMap中Node的总个数
transient int size;
// 用于快速失败,HashMap是非线程安全的,在迭代过程中,如果结构发生改变,会抛出ConcurrentModificationException
transient int modCount;
// 阀值,是否调整主干数组长度的指标
// 一般由capacity(主干数组长度) * loadFactor计算,超过范围会取最大容量MAXIMUM_CAPACITY
int threshold;
//负载因子,数组的填充量,计算阀值使用,上面有个默认的0.75F
final float loadFactor;
构造方法
主要构造方法
HashMap有四种构造方法,这里只说最核心的一个,只说传入初始容量和负载因子这种。
/**
* 核心构造方法
* @param initialCapacity 初始化容量
* @param loadFactor 负载因子
*/
public HashMap(int initialCapacity, float loadFactor) {
// 初始容量小于0,抛异常
if (initialCapacity < 0)
throw new IllegalArgumentException("Illegal initial capacity: " +
initialCapacity);
// 如果大于了最大容量,就转成最大容量
if (initialCapacity > MAXIMUM_CAPACITY)
initialCapacity = MAXIMUM_CAPACITY;
// 负载因子小于0或是个非法数字(除数为0这种),抛异常
if (loadFactor <= 0 || Float.isNaN(loadFactor))
throw new IllegalArgumentException("Illegal load factor: " +
loadFactor);
this.loadFactor = loadFactor;
// 这里初始容量赋值给了阀值,后面会用到
this.threshold = tableSizeFor(initialCapacity);
}
tableSizeFor(initialCapacity)
方法是用来计算初始容量的,HashMap容量并不是传多少就是多少,而一定是2的次幂。这个方法会返回一个比给定容量大的最小2的次幂的数。
举个例子:如果你给了9,比9大的最小2的次幂是16(2^4);如果你给个27,比27大的最小的2的次幂是32(2^5)。
static final int tableSizeFor(int cap) {
// 先是将cap减1,否则,如果cap是2的次幂,例如16,计算结果就是32,是我们需要的容量的2倍
int n = cap - 1;
// 这里是先将n无符号右移,再与n进行或运算并赋值给n
// 这样好理解一点 => n = n | (n >>> 1)
n |= n >>> 1;
n |= n >>> 2;
n |= n >>> 4;
n |= n >>> 8;
n |= n >>> 16;
// 如果n<0返回1
// 如果n>最大容量返回最大容量
// 否则返回 n+1
return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}
解释:
n=0
经过几次次无符号右移还是0,最后返回n+1是1
n>0
下面这个图演示前三步移动的过程
剩下的大家脑补,最后算出来就是32位以内最高位那个1后面跟的都是1,然后n≠1的情况下会加个1,就是我们要的结果,这里结果是2^8,原来那个显然是大于2^7的一个数。看完这个过程是不是觉得"妙啊!",我也觉得这个算法好机智,哈哈。
其他构造方法
public HashMap(int initialCapacity) {
this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
public HashMap() {
this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
}
以上几个构造函数都没有直接的创建一个切实存在的数组,他们都是在为创建数组需要的一些参数做初始化,table的初始化被推迟到了put方法中,所以这几个构造函数中并没有被初始化的属性都会在实际初始化数组的时候用默认值替换。
这个构造函数有put过程,table已经完成初始化
public HashMap(Map<? extends K, ? extends V> m) {
this.loadFactor = DEFAULT_LOAD_FACTOR;
putMapEntries(m, false);
}
小结
1.构造函数会创建一个容量(主干数组长度)大于等于initialCapacity的最小的2的幂长度的HashMap2.负载因子可以自定义
3.多数构造方法中并没有初始化table,table初始化的过程是在put方法中完成的
put方法
put
put方法是一个重点方法,这里有 HashMap初始化,数据在 HashMap中是如何储存的,什么情况下链表会转换为红黑树等内容,需要仔细研究。
public V put(K key, V value) {
//这里继续调用putVal方法
return putVal(hash(key), key, value, false, true);
}
putVal
putVal是final修饰的方法,子类 LinkedHashMap也是用的这各方法,evict(看下面的的第5个参数)就是给 LinkedHashMap使用的,HashMap中并没有什么用。
/**
* 真正进行插入操作的方法,
* hash 传入key的哈希值
* onlyIfAbsent 如果该值是true,如果存在值就不会进行修改操作
* evict LinekdHashMap尾操作使用,这里暂无用途
*/
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长度是0或table是null会调整一次大小
// 这时tab会指向调整大下后的Node<K,V>[](主干数组)
// n被赋值为新数组长度
// 如果没有调整大小,tab指向table
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
/********开始查找键的位置,并存储value*******/
// i = (n - 1) & hash这个是获取key应该在哪个桶里,下面详说
// 这里将p指向当前key所需要的那个桶
if ((p = tab[i = (n - 1) & hash]) == null)
// 如果空桶,也就是无哈希冲突的情况,直接丢个Node进去。
// 此时的tab就是table
tab[i] = newNode(hash, key, value, null);
//存在冲突,开始寻找我们要找的节点
else {
Node<K,V> e; K k;
// 判断第一个节点是不是我们找的
// 此时k储存了 p.key
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
// hash系值相等,key值相等,定位完成,是修改操作
// e来储存p这个节点,一会修改
e = p;
// 判断是否是红黑树节点
else if (p instanceof TreeNode)
// 是红黑树节点,存在就返回那个节点,不存在就返回null
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
// 最终,是链表了,开始对链表遍历查找
else {
for (int binCount = 0; ; ++binCount) {
// 上面知道第一个接点不是我们要的,直接获取下一个,并储存给e
// 下一个是空,直接丢个Node在这里,然后p.next指向这里
// 这里下一个节点地址给了e
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);
// !大于树化阀值,开始树化
// 注意-1是因为binCount是索引而不是长度
// 其实此时链表长度已经是7+1(索引) + 1(新进来的Node)
// 已经大于树化阀值8,也就是说链表长度为8时是不会树化的
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
//加进去就跳出循环了
break;
}
// 下个节点有值,且是我们找的节点,跳出去
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
//下一个节点不是我们找的节点继续编历
p = e;
}
}
// 上面说了,这有修改操作e才能不是null
if (e != null) { // existing mapping for key
V oldValue = e.value;
// 给e新值
if (!onlyIfAbsent || oldValue == null)
e.value = value;
// 这个是LinkedHashMap用的,HashMap里是个空实现
afterNodeAccess(e);
// 修改就会把旧值返回去
return oldValue;
}
}
/*********修改完成的后续操作**********/
// 修改次数加1
++modCount;
// 如果size大于阀值,会执行resize()方法调整大小
if (++size > threshold)
resize();
// 这个是给LinkedHashMap用的,HashMap里也是个空实现
afterNodeInsertion(evict);
// 添加成功返回null
return null;
}
hash
再来看一下hash()这个方法吧。
static final int hash(Object key) {
int h;
// key是null就返回0,key不是null就先取hashCode()然后与这个hashCode()无符号右移进行亦或运算
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
可能小伙伴有疑惑,好好的hashCode()非弄个亦或运算干啥?
举个例子:下图就是table.length为16时的计算情况,如果没有亦或运算就只和低4位有关,这样就会加大冲突的概率。
resize
这也是一个很重要的方法,主要包括两部分,第一部分是根据size是否超过阀值判断是否需要进行扩容,第二部分是扩容后将原Node[]中数据复制到扩容后的Node[]中
扩容部分
final Node<K,V>[] resize() {
Node<K,V>[] oldTab = table;
// 原容量,table为null返回0,否则返回table长度
int oldCap = (oldTab == null) ? 0 : oldTab.length;
// 原阀值
int oldThr = threshold;
// 新容量,新阀值
int newCap, newThr = 0;
// table已经初始化
if (oldCap > 0) {
// 容量已经超过最大容量,直接返回去
if (oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return oldTab;
}
// 2倍扩容后小于最大容量,并且原容量大于默认初始化容量(我还没想清楚为什么要大于默认初始容量)
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
// 阀值加倍
newThr = oldThr << 1; // double threshold
}
// 原数组容量为0,未初始化,单阀值不为0
// 也就是构造方法里threshold = tableSizeFor(initialCapacity)这个步骤
else if (oldThr > 0)
newCap = oldThr;
// 啥都没有,默认构造
else {
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
// 新数组阀值未被赋值
if (newThr == 0) {
// 使用新的容量*负载因子计算阀值
float ft = (float)newCap * loadFactor;
// 取计算后阀值和最大容量里较小的那个
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
(int)ft : Integer.MAX_VALUE);
}
threshold = newThr;
复制数据部分
看源码前,先看下面这个图
正常来讲,向新数组复制元素时需要重新计算位置,现在有了这个规律,就可以这样做:
- x=0不改变位置
- x≠0原位置+原数组长度获取新位置
判断x是否为0,e.hash & oldCap
可以完成,返回结果是0,代表x处是0,位置不用改变,否则改变位置
//创建新的数组
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
table = newTab;
// 开始复制数据
if (oldTab != null) {
// 开始遍历
for (int j = 0; j < oldCap; ++j) {
Node<K,V> e;
// x获取桶的第一个节点
if ((e = oldTab[j]) != null) {
oldTab[j] = null;
// 只有一个值,直接移过去
if (e.next == null)
newTab[e.hash & (newCap - 1)] = e;
// 如果是红黑树,分裂放入新数组
else if (e instanceof TreeNode)
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
// 如果是链表,进行下方操作
else {
// 不是直接进行计算元素在新数组中的位置,而是原位置加原数组长度
Node<K,V> loHead = null, loTail = null;
Node<K,V> hiHead = null, hiTail = null;
Node<K,V> next;
do {
// 把链表下一个节点放在 next里
next = e.next;
// 该节点不需要移动
if ((e.hash & oldCap) == 0) {
// 尾元素为空,确定首元素
if (loTail == null)
loHead = e;
else
// 尾元素有就直接丢最后
loTail.next = e;
// 确定尾元素
loTail = e;
}
// 该节点需要移动
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;
}
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
}
}
}
}
// 返回新的数组
return newTab;
}
复制过程,a过去,假设计算后位置不边,进到i,此时i为null,a进去后即是head,又是tail
然后循环,到b,假设计算后还是i,i中已经有a,所以b直接丢到a后面,a任是head,单tail已经变成了b
以此类推,a,b,c,d都会放在i,j中
至此,put方法已经说完了,重点是putVal,hash和resize三个方法,如果不理解可以看本文结尾的参考文献,因为不同的人思维方式,表达方式都不同,说不定换一种表述方式就能理解了。
remove
remove就是先找到节点位置,然后移除,核心方法是removeNode()
public V remove(Object key) {
Node<K,V> e;
// 调用removeNode,如果移除成功返回原值,否则返回null
return (e = removeNode(hash(key), key, null, false, true)) == null ? null : e.value;
}
removeNode
/**
* value remove方法重载时使用,只有同时匹配key-value时移除该节点
* matchValue,为true时才会同时匹配key-value进行删除
* movable 删除节点后是否改变红黑树的结构,般都为true只有在iterator的时候才为false
*/
final Node<K,V> removeNode(int hash, Object key, Object value,
boolean matchValue, boolean movable) {
/*******查找节点的部分*******/
Node<K,V>[] tab; Node<K,V> p; int n, index;
// 1.原数组不为null 2. 原数组长度大于0 3.key数组中的位置不为空
if ((tab = table) != null && (n = tab.length) > 0 &&
(p = tab[index = (n - 1) & hash]) != null) {
// 声明两个节点node,e
Node<K,V> node = null, e; K k; V v;
// 第一个节点就我们要找的节点
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
// 先给node,在下面删掉
node = p;
else if ((e = p.next) != null) {
// 如果是红黑树,获取该接点并给node
if (p instanceof TreeNode)
node = ((TreeNode<K,V>)p).getTreeNode(hash, key);
// 如果是链表,循环遍历
else {
do {
// 如果是要找的节点就把这个节点给node
if (e.hash == hash &&
((k = e.key) == key ||
(key != null && key.equals(k)))) {
node = e;
break;
}
// 不是把节点给p记录,继续检查下一个节点
p = e;
} while ((e = e.next) != null);
}
}
/**********删除节点的部分*********/
if (node != null && (!matchValue || (v = node.value) == value ||
(value != null && value.equals(v)))) {
// 如果是红黑树节点,使用removeTreeNode移除
if (node instanceof TreeNode)
((TreeNode<K,V>)node).removeTreeNode(this, tab, movable);
else if (node == p)
// 这里执行的就是上面的第一种情况,桶里的第一个节点就是要移除的
tab[index] = node.next;
else
// 直接将移除的上个节点指向下一个节点
p.next = node.next;
// 修改次数再加1
++modCount;
// 长度 -1
--size;
// 给LinkedList使用,这里没啥用
afterNodeRemoval(node);
// 删除的值返回去
return node;
}
}
// 根本没有这个键
return null;
}
大致过程就是这个样子的~~,勉强看吧没画图天赋!
源码分析到这里就结束,看了这几个方法,只要不是红黑树的部分,看起来就很没那么困难了。
四、日常使用注意事项
在可以明确HashMap长度的情况下,最好给HashMap一个初始容量
看完上面原码后,会发现HashMap使用过程中会出现resize()操作,会涉及到哈希表的重建,这是一个比较消耗资源的操作,如果在明确长度的情况下,能给定合适的容量就可以减少甚至避免扩容操作。
阿里巴巴开发手册给出如下公式:
{% cq %}
initialCapacity = (需要存储的元素个数 / 负载因子) + 1。
{% endcq %}注意负载因子(即loader factor)默认为0.75, 如果暂时无法确定初始值大小,请设置为16(即默认值)
在guava中其实也使用这个公式,并且guava提供下面这个方法来创建HashMap:
{% note info %}Map<String, String> map = Maps.newHashMapWithExpectedSize(10){% endnote %}
其实这个公式是来自putAll()方法,感兴趣的小伙伴可以去看一下。
重写equals方法是一定要重写hashCode方法
老生长谈了,这里主要针对key是对象的情况,举个例子:
class Person{ int idCard; String name; public Person(int idCard, String name) { this.idCard = idCard; this.name = name; } @Override public boolean equals(Object o) { if (this == o) { return true; } if (o == null || getClass() != o.getClass()){ return false; } Person person = (Person) o; //两个对象是否等值,通过idCard来确定 return this.idCard == person.idCard; } @Test public void test(){ Map map = new HashMap(); Person p1 = new Person(1234,"小白"); map.put(p1,"哈哈哈哈"); Person p2 = new Person(1234,"小白"); map.get(p2); }
当用person来做key时,显然,如果在hashcode不重写的情况下,使用p2是无法获得需要的内容的,因为两个对象用来找桶的hashcode是不同的,所以无法找到想同的桶啊!桶都找不到去哪里找值哈哈!
五、总结
本文记录了我学习HashMap的全过程,包括预备知识、实现原理、源码分析、注意事项等几个部分,对我这个没数据结构基础的人来说收获真的很大,希望对各位读者也有一定的帮助!存在问题希望大家指正!
本文参考: