HashMap的底层原理
【HashMap简介】
-
HashMap是用于存储键值对的容器
-
根据键的hashcode存储数据;
-
允许一条记录的key为null,允许多条记录的value为null;
-
非线程安全
【HashMap存储结构】
在JDK1.7时HashMap采取的是数组+链表的形式存储数据,JDK1.8对HashMap进行了存储结构上的优化,引入了红黑树数据结构,极大增强了HashMap的存取性能!(解决链表上拉链过长的问题)
**横向(主干):**数组;**纵向:**链表
JDK1.7源码分析
主干
主干是一个Entry数组,Entry是HashMap的基本组成单位,每个Entry包含一个Key-value键值对
Entry<K,V> table = (Entry<K,V>[])EMPTY_TABLE;
// 此数组为HashMap的主干数组,可以看做是一个Entry数组,初始值为空数组,主干数组的长度一定是2的次幂。
节点元素
// 节点是Entry类型:此类型包含了键值对:
static class Entry<K,V> implements Map.Entry<K,V>{
K key;
V value;
Entry<K,V> next; // 存储指向下一个Entry的引用,单链表结构
int hash; // 对key的hashcode值进行hash运算后得到的值,存储在Entry中,避免重复计算
// 构造方法
......
}
其他重要字段
/**实际存储的key-value键值对的个数*/
transient int size;
/**阈值,当table == {}时,该值为初始容量(初始容量默认为16);当table被填充了,也就是为table分配内存空间后,
threshold一般为 capacity*loadFactory。HashMap在进行扩容时需要参考threshold,后面会详细谈到*/
int threshold;
/**负载因子,代表了table的填充度有多少,默认是0.75
加载因子存在的原因,还是因为减缓哈希冲突,如果初始桶为16,等到满16个元素才扩容,某些桶里可能就有不止一个元素了。
所以加载因子默认为0.75,也就是说大小为16的HashMap,到了第13个元素,就会扩容成32。
*/
final float loadFactor;
/**HashMap被改变的次数,由于HashMap非线程安全,在对HashMap进行迭代时,
如果期间其他线程的参与导致HashMap的结构发生变化了(比如put,remove等操作),
需要抛出异常ConcurrentModificationException*/
transient int modCount;
构造方法
HashMap有4个构造器,其他构造器如果用户没有传入initialCapacity 和loadFactor这两个参数,会使用默认值
initialCapacity默认为16,loadFactory默认为0.75
// 看一个参数比较全的构造函数,构造函数中并未给table分配内存空间,此构造函数HashMap(Map<? extends K, ? extends V> m)会给table分配内存空间
public HashMap(int initialCapacity, float loadFactor) {
// 判断初始化容量是否合法,如果<0则抛出异常
if (initialCapacity < 0)
throw new IllegalArgumentException("Illegal initial capacity: " + initialCapacity);
// 判断initialCapacity是否大于 1<<30,如果大于则取 1<<30 = 2^30
if (initialCapacity > MAXIMUM_CAPACITY)
initialCapacity = MAXIMUM_CAPACITY;
// 判断负载因子是否合法,如果小于等于0或者isNaN,loadFactor!=loadFactor,则抛出异常
if (loadFactor <= 0 || Float.isNaN(loadFactor))
throw new IllegalArgumentException("Illegal load factor: " +loadFactor);
// 赋值loadFactor
this.loadFactor = loadFactor;
// 通过位运算将threshold设值为最接近initialCapacity的一个2的幂次方(这里非常重要)
this.threshold = tableSizeFor(initialCapacity);
}
在构造HashMap时,没有为table分配空间,而是在执行put操作时才真正构建table数组。
put方法
public V put(K key, V value) {
//如果table数组为空数组{},进行数组填充(为table分配实际内存空间),入参为threshold,
//此时threshold为initialCapacity 默认是1<<4(24=16)
if (table == EMPTY_TABLE) {
inflateTable(threshold);
}
//如果key为null,存储位置为table[0]或table[0]的冲突链上
if (key == null)
return putForNullKey(value);
//1. 计算插入下标
int hash = hash(key);//对key的hashcode进一步计算,确保散列均匀
int i = indexFor(hash, table.length);//获取在table中的实际位置
//2.在该主干下标对应的链表上遍历,查找重复项去覆盖
for (Entry<K,V> e = table[i]; e != null; e = e.next) {
//如果该对应数据已存在,执行覆盖操作。用新value替换旧value,并返回旧value
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;
}
}
//3. 无重复项,就直接在头结点插入
modCount++;//保证并发访问时,若HashMap内部结构发生变化,快速响应失败
addEntry(hash, key, value, i);//新增一个entry
return null;
}
put方法内部从上到下分析:
(1)inflateTable()
private void inflateTable(int toSize) {
int capacity = roundUpToPowerOf2(toSize);//capacity一定是2的次幂
/**此处为threshold赋值,取capacity*loadFactor和MAXIMUM_CAPACITY+1的最小值,
capaticy一定不会超过MAXIMUM_CAPACITY,除非loadFactor大于1 */
threshold = (int) Math.min(capacity * loadFactor, MAXIMUM_CAPACITY + 1);
table = new Entry[capacity];
initHashSeedAsNeeded(capacity);
}
**作用:**为主干数组table在内存中分配存储空间。
**方法内部具体实现:**通过roundUpToPowerOf2方法确保capacity为大于等于toSize的最接近于toSize的二次幂
toSize=13,则capacity=16;
toSIze=17,则capacity=32
(2)hash()
/**这是一个神奇的函数,用了很多的异或,移位等运算
对key的hashcode进一步进行计算以及二进制位的调整等来保证最终获取的存储位置尽量分布均匀*/
final int hash(Object k) {
int h = hashSeed;
if (0 != h && k instanceof String) {
return sun.misc.Hashing.stringHash32((String) k);
}
h ^= k.hashCode();
h ^= (h >>> 20) ^ (h >>> 12);
return h ^ (h >>> 7) ^ (h >>> 4);
}
以上计算出hash函数的值,通过indexFor进一步处理来获取实际的存储位置
//返回数组下标
static int indexFor(int h, int length){
return h & (length - 1);
}
h & (length - 1) 确保获取的index一定在数组范围内,保证不会越界!
整理确定存储位置的整个流程:
(3)addEntry()
void addEntry(int hash, K key, V value, int bucketIndex) {
if ((size >= threshold) && (null != table[bucketIndex])) {
resize(2 * table.length);//当size超过临界阈值threshold,并且即将发生哈希冲突时进行扩容
hash = (null != key) ? hash(key) : 0;
bucketIndex = indexFor(hash, table.length);
}
createEntry(hash, key, value, bucketIndex);
}
resize()扩容
扩容后,数组长度发生变化,导致存储位置index=h&(length - 1)
随之发生改变
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, initHashSeedAsNeeded(newCapacity));
table = newTable;
threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
}
transfer方法:将老数组中的数据逐个遍历,放到扩容后数组中新的索引位置
索引位置是通过key值的hashcode进行hash扰乱运算后,再通过和length-1进行位运算得到的最终数组索引位置
void transfer(Entry[] newTable, boolean rehash) {
int newCapacity = newTable.length;
//for循环中的代码,逐个遍历链表,重新计算索引位置,将老数组数据复制到新数组中去(数组不存储实际数据,所以仅仅是拷贝引用而已)
for (Entry<K,V> e : table) {
while(null != e) {
Entry<K,V> next = e.next;
if (rehash) {
e.hash = null == e.key ? 0 : hash(e.key);
}
int i = indexFor(e.hash, newCapacity);
//将当前entry的next链指向新的索引位置,newTable[i]有可能为空,有可能也是个entry链,如果是entry链,直接在链表头部插入。
e.next = newTable[i];
newTable[i] = e;
e = next;
}
}
}
扩容后索引变动原因:
HashMap的数组长度一定保持2的次幂,比如16的二进制表示为 10000,那么length-1就是15,二进制为01111,同理扩容后的数组长度为32,二进制表示为100000,length-1为31,二进制表示为011111。
从下图可以我们也能看到这样会保证低位全为1,而扩容后只有一位差异,也就是多出了最左位的1,这样在通过 h&(length-1)的时候,只要h对应的最左边的那一个差异位为0,就能保证得到的新的数组索引和老数组索引一致(大大减少了之前已经散列良好的老数组的数据位置重新调换)。
createEntry()插入
put插入新的Entry对象时,令Entry.next等于原本的链表头部,然后将主干table[bucketIndex]指向该Entry对象,完成了整体的向下移动。
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++;
}
(4)for 循环
put(key, value)中的value是新的值,而该方法返回值是key对应曾经的老值。
for (Entry<K,V> e = table[i]; e != null; e = e.next) {
//如果该对应数据已存在,执行覆盖操作。用新value替换旧value,并返回旧value
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; // 返回被覆盖的旧值
}
}
if条件中为什么把hash的值放在&&前面而不把key的值放在前面?
hashcode不相等 => 两个对象的key不相等
key不相等 !=> 两个对象的hashcode不相等
将hashcode的判断放在前面,执行效率会更高!
get方法
public V get(Object key) {
//如果key为null,则直接去table[0]处去检索即可。
if (key == null)
return getForNullKey();
Entry<K,V> entry = getEntry(key);
return null == entry ? null : entry.getValue();
}
get方法通过key值返回对应value,如果key为null,直接去table[0]处检索;
getEntry ()
final Entry<K,V> getEntry(Object key) {
if (size == 0) {
return null;
}
//通过key的hashcode值计算hash值
int hash = (key == null) ? 0 : hash(key);
//indexFor (hash&length-1) 获取最终数组索引,然后遍历链表,通过equals方法比对找出对应记录
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;
}
流程简述:
- 计算主干索引为位置:key(hashcode) ~ hash ~ indexFor ~ 最终索引位置
- 在table[i]上查看是否有链表,遍历链表。通过key的hashcode和equals方法对比查找对应的记录。
【问题】
为什么在定位到数组位置之后遍历链表,还是需要用hashcode来判读呢?
因为有可能传入的key对象重写了equals方法,却没有重写hashcode方法。导致仅用equals判断是可能相等的,但是hashcode和当前对象不一致。这种情况,应该返回null。
※重写equals方法的同时为什么需要同时重写hashcode方法?
- 默认的equals判断的是两个对象的引用指向的是不是同一个对象;
- 默认的hashcode也是根据对象地址生成一个整数数值;
举例:
如果有很多student对象,默认判断两个学生对象是否相等,需要根据对象地址进行判断。
但是新判定规则判定:当学生只有姓名、年龄、性别相等时,student对象即为相等,而不需要对象地址完全相同。
以下是对student对象的equals重写,但未重写hashcode方法:
public class Student {
private String name;// 姓名
private String sex;// 性别
private String age;// 年龄
private float weight;// 体重
private String addr;// 地址
// 重写equals方法
@Override
public boolean equals(Object obj) {
if(!(obj instanceof Student)) {
// instanceof 已经处理了obj = null的情况
return false;
}
Student stuObj = (Student) obj;
// 地址相等
if (this == stuObj) {
return true;
}
// 如果两个对象姓名、年龄、性别相等,我们认为两个对象相等
if (stuObj.name.equals(this.name) && stuObj.sex.equals(this.sex) && stuObj.age.equals(this.age)) {
return true;
} else {
return false;
}
}
重写了student的equals方法后,进行测试:
public static void main(String[] args) {
// 学生s1
Student s1 =new Student();
s1.setAddr("1111");
s1.setAge("20");
s1.setName("allan");
s1.setSex("male");
s1.setWeight(60f);
// 学生s2
Student s2 =new Student();
s2.setAddr("222");
s2.setAge("20");
s2.setName("allan");
s2.setSex("male");
s2.setWeight(70f);
if(s1.equals(s2)) {
System.out.println("s1==s2");
}else {
System.out.println("s1 != s2");
}
}
// 输出结果
s1 == s2
如果没有重写equals方法,那么上段代码结果是:s1 != s2
此时,student1和student2在重写完equals方法后被人为是相等的,但是若将这两个对象放入Set中,操作如下:
Set set = new HashSet();
set.add(s1);
set.add(s2);
System.out.println(set);
// 输出结果
[jianlejun.study.Student@7852e922, jianlejun.study.Student@4e25154f]
啊?怎么去重的Set容器中,会有两个元素,两个student对象不是相等的吗?为什么呢?
原因是:Set和Map的底层都是通过比较对象的hashcode来判断对象是否相同的。此时我们在student方法里加上重写hashcode方法的代码:
@Override
public int hashCode() {
int result = name.hashCode();
result = 17 * result + sex.hashCode();
result = 17 * result + age.hashCode();
return result;
}
hashcode方法重写的固定写法:先整理判断对象相等的属性,然后取一个尽可能小的正整数(尽可能小时因为怕最终结果超出了int的取值范围),这里取17,然后用17 * 属性 + 其他属性的hashcode,重复步骤
重写后,再重新测试将s1、s2放入Set的方法,结果为:
[jianlejun.study.Student@43c2ce69]
该问题总结:
- 若没有重写hashcode方法,hashcode方法会根据两个对象的地址生成对应的hashcode;
- s1和s2是分别new出来的,那么他们的地址肯定是不一样的,自然hashcode的值也会不一样;
- Set区别对象一不一样就是通过判断:两个对象的hashcode是不是一样的,再去判定两个对象是否equals
- Map是先根据Key值的hashcode分配和获取对象保存数组下标的,然后再根据hashcode和equals区分唯一值。
JDK1.8源码分析
主干
// HashMap的主干,也就是上面的绿色部分,是一个Node<K,V>数组,每个Node包含一个K-V键值对
transient Node<K,V>[] table;
节点元素
// Node<K,V>是HashMap的静态内部类,实现了Map接口中的内部Entry接口
static class Node<K,V> implements Map.Entry<K,V> {
// 记录当前Node的key的hash值,可以避免重复计算,空间换时间
final int hash;
// 键
final K key;
// 值
V value;
// 存储指向下一个Entry的引用,是单向链表结构
Node<K,V> next;
// ...
}
其他重要字段:
// 默认的初始容量 16
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;
// 最大容量,1左移30位
static final int MAXIMUM_CAPACITY = 1 << 30;
// 默认扩容因子 0.75
static final float DEFAULT_LOAD_FACTOR = 0.75f;
// 链表转红黑树的链表长度
static final int TREEIFY_THRESHOLD = 8;
// 红黑树转链表的阈值
static final int UNTREEIFY_THRESHOLD = 6;
// 链表转红黑树的数组长度
static final int MIN_TREEIFY_CAPACITY = 64;
// 实际存储K-V键值对的个数
transient int size;
// 记录HashMap被改动的次数,由于HashMap非线程安全,modCount可用于FailFast机制
transient int modCount;
// 扩容阈值,默认16*0.75=12,当填充到13个元素时,扩容后将会变为32,
int threshold;
// 负载因子 loadFactor=capacity*threshold,HashMap扩容需要参考loadFactor的值
final float loadFactor;
构造函数
// 看一个参数比较全的构造函数,构造函数中并未给table分配内存空间,此构造函数HashMap(Map<? extends K, ? extends V> m)会给table分配内存空间
public HashMap(int initialCapacity, float loadFactor) {
// 判断初始化容量是否合法,如果<0则抛出异常
if (initialCapacity < 0)
throw new IllegalArgumentException("Illegal initial capacity: " + initialCapacity);
// 判断initialCapacity是否大于 1<<30,如果大于则取 1<<30 = 2^30
if (initialCapacity > MAXIMUM_CAPACITY)
initialCapacity = MAXIMUM_CAPACITY;
// 判断负载因子是否合法,如果小于等于0或者isNaN,loadFactor!=loadFactor,则抛出异常
if (loadFactor <= 0 || Float.isNaN(loadFactor))
throw new IllegalArgumentException("Illegal load factor: " +loadFactor);
// 赋值loadFactor
this.loadFactor = loadFactor;
// 通过位运算将threshold设值为最接近initialCapacity的一个2的幂次方(这里非常重要)
this.threshold = tableSizeFor(initialCapacity);
}
hash算法实现:
- 通过hashcode()获取key的hashcode;
- 通过
(h=key.hashcode())^(h>>>16)
进行高16位的位运算; - 通过(n - 1) & hash对计算的hash值取模运算,得到节点插入的主干数组所在下标位置
其中第二步使用hash值高16位参与位运算,是为了保证在数组的length比较小的时候,可以保证高低bit都参与到hash运算中,过程图如下:
put方法
-
判断主干数组table是否为空,是空则执行resize()扩容
1.8中的resize方法实现的功能包含了1.7中的inflateTable()往空数组中分配内存的功能;
-
根据key计算hash值得到插入数组的索引i
- tab[i]==null,直接插入,执行第六步
- tab[i]!=null, 执行第三步
-
判断tab[i]的第一个元素与插入元素key的hashcode & equals是否相等,相等则覆盖,否则执行第4步
-
判断tab[i]是否是红黑树节点TreeNode,是则在红黑树中插入节点,否则执行第5步
-
遍历tab[i]判断链表是否大于8,大于8则可能转成红黑树(前提:数组长度大于64)
- 满足则在红黑树中插入节点
- 不满足则在链表末尾插入,如果在遍历链表中存在和key的hashcode&equals相等的则替换
-
插入成功,判断hashmap的size是否超过threhold的值,超过则扩容。
put方法
public V put(K key, V value) {
// hash(key) 根据key计算一个hash值,具体实现如下函数
return putVal(hash(key), key, value, false, true);
}
hash方法
static final int hash(Object key) {
int h;
// 如果key等于null,返回0
// 否则取key的hashcode值h, 将h无符号右移16位也就是获取高16位,将两个值做异或位运算后返回
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
putVal方法
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是否为空,为空则resize()创建
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
// (n - 1) & hash计算插入节点在数组中的索引index,为空则直接插入
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
else {
Node<K,V> e; K k;
// 如果节点key存在,则覆盖当前元素
if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k))))
e = p;
// 节点不存在且当前的数组节点上的链为RBT红黑树,则按照红黑树的方式插入节点
else if (p instanceof TreeNode)
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
// 链为链表
else {
for (int binCount = 0; ; ++binCount) {
// 节点的下一个节点为null,说明遍历到了链表的最后一个节点,将当前遍历到的最后一个节点的next指向新插入节点
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);
// 链表长度大于8可能需要做红黑树转换,具体要看treeifyBin()中判断数组的长度是否大于64
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;
// 不存在且写一个节点不为null,则将下一个节点赋值给当前节点,继续下一次循环
p = e;
}
}
// 节点存在替换后直接返回,不会记录HashmMap结构变化
if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
// 记录HashMap的结构变化
++modCount;
// 判断节点插入后是否需要对hashMap的数组进行扩容
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
get方法
public V get(Object key) {
Node<K,V> e;
// 根据当前key计算hash值,这个计算方式上面已经详细介绍过
return (e = getNode(hash(key), key)) == null ? null : e.value;
}
final Node<K,V> getNode(int hash, Object key) {
Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
// 当数组不为空,并且key对应的数组下标的元素存在
if ((tab = table) != null && (n = tab.length) > 0 && (first = tab[(n - 1) & hash]) != null) {
// 检查获取的节点是否是第一个节点,是则返回
if (first.hash == hash && ((k = first.key) == key || (key != null && key.equals(k))))
return first;
// 不是头结点则判断下一个节点是否存在
if ((e = first.next) != null) {
// 如果节点是红黑树,则在红黑树中获取元素
if (first instanceof TreeNode)
return ((TreeNode<K,V>)first).getTreeNode(hash, key);
// 如果是链表则遍历链表中的节点,匹配key则返回,当e不存在下一个节点则结束循环返回null
do {
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
return e;
} while ((e = e.next) != null);
}
}
// 为匹配上则返回null
return null;
}
总结:
1.8的优化点:
- hash值的算法的优化
- 底层数据结构由:数组+链表 变为 数组 + 链表/红黑树
在使用HashMap时,有必要情况下一定要重写key的hashcode方法和equals方法
HashMap就是用空间来换取时间,所以要合理优化空间消耗!