HashMap 底层是数组,JDK7 和 JDK8 版中HashMap实现是大有不同的。
存储结构:JDK7 是数组+链表,JDK8 是数组+链表+红黑树。
1、为什么要使用数组+链表的形式?
HashMap 底层数组的默认大小是 16,大索引是 15, 数组是可以扩容的。
HashMap 以 key-value 的形式进行存储,key+value 会以一个 Entry 对象的形式来存储,把 Entry 存入数组 中,具体存入的下标不是按顺序存入,而是通过 key 获 取对应的 hash 值,再根据 hash 值与数组大索引进行按位与运算(将操作数转换为二进制,分别对比各位上的值,都为 1 则该位返回 1,否则返回 0,得到的结果就是按位与运算结果),与数组大索引进行按位与运算是为了保证数组下标不越界,A & B 结果一定是小于等于 B
很有可能出现两个 Entry 下标一样,hash 冲突,我们应该尽量避免,如果一旦一样怎么处理?意味着需要将两个 Entry 存入数组中的同一个位置,所以就形成了链表。数组中存储的是第一个 Entry 的地址,第二个 Entry 就 往后排。
2、为什么要使用红黑树呢?
链表的缺点是查询效率低,因为查询链表中的任意一个元素,都需要从头开始遍历。
这种方式会导致 HashMap 取数据很慢,而取数据又是 HashMap 常用的一个操作,所以 JDK 8 引入了红黑树来解决这个问题。
红黑树可以解决链表查询效率低的问题
红黑树是一种数据结构,其实是一个平衡搜索二叉树,二叉树的查询效率很高。
如何让二叉树保持平衡?这就是红黑树的作用。
1、节点是红色或者黑色。
2、跟节点是黑色。
3、每个叶子节点都是黑色的空节点。
4、每个红色节点的两个子节点都是黑色(从每个叶子节 点到根的路径上不能有两连续的红色节点)
5、从任一节点到它每个叶子节点的路径所包含的黑色节点的数目一致。
HashMap的构造函数:
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);
this.loadFactor = loadFactor;
this.threshold = tableSizeFor(initialCapacity);
}
HashMap 有两个重要的参数:容量(capacity)和 负载因子(loadFactor)
容量:是指 HashMap 中桶的数量,默认值是 16
负载因子:判断 HashMap 是否需要扩容,默认值是 0.75
HashMap 存放的元素总数量 / 容量,当该值等于 0.75 的时候,HashMap 就需要进行扩容
16*0.75 = 12
当 HashMap 中元素个数超过 12 的时候,数组就需要进行扩容,成倍扩容 32
//初始默认值 1<<4 表示 1左移四位就扩大为2的4次方 32
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;
// aka 16 //数组最大容量
static final int MAXIMUM_CAPACITY = 1 << 30;
//默认加载因子
static final float DEFAULT_LOAD_FACTOR = 0.75f;
//判断链表是否要转为红黑树
static final int TREEIFY_THRESHOLD = 8;
//判断红黑树是否转为链表
static final int UNTREEIFY_THRESHOLD = 6;
//用来判断到底是链表转红黑树还是数组扩容 容量超过64就转换为红黑树
static final int MIN_TREEIFY_CAPACITY = 64;
HashMap中的hash方法:
//Java 8中的散列值优化函数
static final int hash(Object key) {
int h;
//无符号右移
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16); //key.hashCode()为哈希算法,返回初始哈希值
}
当 key = null 直接返回 0 (即key为空的话,该键值对放置在哈希数组 0索引的位置),否则返回 key 的 hashCode 值与自己的高 16 位进行异或运算
h >>> 16 是用来取出 h 的高 16 位,>>> 无符号右移
0000 0100 1011 0011 1101 1111 1110 0001
>>> 16
0000 0000 0000 0000 0000 0100 1011 0011
为什么不直接采取hashcode的返回值作为hash函数的返回值?
因为hashcode的映射返回值是int类型的,伦理上是很大的,int 的取值范围是 -21 亿到 21 亿。 因此需要想办法解决下述问题。
1、长度为40亿的数组 内存也是装不下的 ,数组也不可能那么长。
2、同时如果只取哈希的地位用来进行异或运算很容易产生哈希冲突。
因此通过使用高16位和低位进行异或运算,这样可以混合hash码的高位和地位,以此来增加地位的随机性,而且混合之后地位同样包含高位的信息,散列分布更加均匀,冲突的概率更小。
拿到 key 的 hash 值之后,还要跟数组大索引进行按 位与运算(都为 1 返回 1,否则返回 0),终的值才 是正在的数组下标。
h=hashCode:1111 1111 1111 1111 1111 0000 1110 1010
h>>>16: 0000 0000 0000 0000 1111 1111 1111 1111
^: 1111 1111 1111 1111 0000 1111 0001 0101
15: 0000 0000 0000 0000 0000 0000 0000 1111
& 0000 0000 0000 0000 0000 0000 0000 0101
index = 5
HashMap的put函数:
//put方法,会先调用一个hash()方法,得到当前key的一个hash值,
//用于确定当前key应该存放在数组的哪个下标位置
//这里的 hash方法,我们姑且先认为是key.hashCode(),其实不是的,一会儿细讲
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) {
// tab指代是hashmap的散列表再,在下方初始化,hashmap不是在创建的时候初始化,而是在put的时候初始化,属于懒初始化
// p表示当前散列表元素
// n表示散列表数组长度
// i表示路由寻址的结果
Node<K,V>[] tab; Node<K,V> p; int n, i;
//判断是否为空,为空的话初始化,不为空对tab和n进行赋值
if ((tab = table) == null || (n = tab.length) == 0)
//resize扩容
n = (tab = resize()).length;
//这个i就是(n-1)和hash做与运算得到的位置,p就是这个位置的Node元素
if ((p = tab[i = (n - 1) & hash]) == null)
//直接在当前下表newNode
tab[i] = newNode(hash, key, value, null);
//如果要插入的元素在这个位置有元素了,执行以下操作
else {
//e 临时的node元素
//k 表示临时的一个key
Node<K,V> e; K k;
//如果这个桶的位置的元素的key和将要插入的key是一个,会进行替换
// 比较 哈希值 : 引用地址 : key
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
//如果相等则老元素地址指向新元素
e = p;
//不相等则判断节点类型:
//结点类型为树 TreeNode是Node的一个子类
else if (p instanceof TreeNode)
//红黑树的插入操作
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
//节点类型为链表
else {
///for循环:1.遍历到链表尾部,进行尾插
//2.判断链表长度,超过8将链表改为红黑树
for (int binCount = 0; ; ++binCount) {
//判断节点的下一个节点为空,遍历到链表尾部,进行尾插
if ((e = p.next) == null) {
//生成一个Node对象,将Node对象作为新节点插入到链表(p.next)
p.next = newNode(hash, key, value, null);
//如果链表长度 >= TREEIFY_THRESHOLD-1 = 7 因为是从0开始遍历,所以此时链表长度为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;
}
}
//这种属于覆盖操作,当e中有值进入操作
if (e != null) { // existing mapping for key
//oldValue保存老的值,方便return
V oldValue = e.value;
//onlyIfAbsent传入的是false,指定能进入判断
if (!onlyIfAbsent || oldValue == null)
//新元素的值将老元素的值覆盖掉
e.value = value;
//HashMap提供给子类的方法
afterNodeAccess(e);
//put操作有返回值,返回的是插入之前已经存在的元素的value值
return oldValue;
}
}
//增加修改次数
++modCount;
//统计当前map中有多少元素,和阈值对比,判断是否需要扩容
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
转红黑树的方法:
final void treeifyBin(Node<K,V>[] tab, int hash) {
int n, index; Node<K,V> e;
//转为红黑树之前 先要判断是否到达数组的最大容量 若没有则先进行扩容
if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
resize();
else if ((e = tab[index = (n - 1) & hash]) != null) {
TreeNode<K,V> hd = null, tl = null;
do {
TreeNode<K,V> p = replacementTreeNode(e, null);
if (tl == null)
hd = p;
else {
p.prev = tl;
tl.next = p;
}
tl = p;
} while ((e = e.next) != null);
if ((tab[index] = hd) != null)
hd.treeify(tab);
}
}
如果数组的长度小于64,则不进行红黑树的转换,而是继续进行数组扩容,如果数组的长度大于64,再将链表转为红黑树。
HashMap的存值过程:
1、根据key计算hash值。
2、在put的时候判断数组是否存在,如果不存在则用resize 方法创建默认长度为16的数组。3、确定要存入的 Node 在数组中的位置,根据 hash 值与数组最大索引进行按位与运算得到索引位置。
4、判断该位置是否有元素,如果没有直接创建一个Node 存入。
5、如果有元素,判断 key 是否相同,如果相同则覆盖,并且将原来的值直接返回。
6、如果 key 不相同,在原 Node 基础上添加新的Node,判断该位置是链表还是红黑树。
7、如果是红黑树,将 Node 存入红黑树。
8、如果是链表,遍历链表,找到最后一位,将 Node 存入。
9、将 Node 存入链表之后,判断链表的结构是否要调整,判断链表长度是否超过 8,如果超过 8 需要将链表转为红黑树,这里还有一个条件,如果数组的容量小于64,不转换红黑树,而是进行数组扩容,当数组的容量大于 64 的时候,再将链表转为红黑树。
10、存完之后,再次判断数组是否进行扩容,根据负载因子来判断。