1、版本变化的问题
(1)JDK1.8之前:数组+链表
(2)JDK1.8之后:数组+链表/红黑树
2、JDK1.7以及之前的HashMap
(1)为什么要使用数组?
优点:可以根据[下标]能够快速的访问到某个元素,效率非常高。
如何利用这个优点?
①所有的对象都有一个hashCode值(因为Object类有public int hashCode()方法,那么所有对象都有这个方法,就可以得到hashCode值)。
hashCode值是int类型。
②理论上,每一个不同的对象,它的hashCode值是唯一的。
JDK开发团队就想 能不能创建一个足够大的“数组”,然后每一个放到HashMap中的(key,value)键值对,
根据key的hashCode值,来决定它放到这个“数组”中的下标
想法: hashCode值 = 这个足够大的数组的[下标]
根据这个想法,就可以快速的“存”和“取”这个(key,value)键值对。
但是因为一些原因,要考虑数组的问题?
③实际中,可能存在两个不同的对象,但是它们的hashCode值一样(虽然散列函数能够保证大多数不一样,但是仍然是有一部分是很难避免重复)
另外,数组没办法真正实现“足够大”,总是要考虑内存的有限性。
如何解决这个问题?
即当两个不同的对象的hashCode值相同了怎么办? ==> 可以在同一个数组的元素位置存储多个不同的对象(hashCode一样,equals不一样),
通过“链表”来链接这些对象
或者说,数组不能足够大怎么办?==> 假设数组的长度为16.
hashCode % 数组的长度 的结果范围 [0, 数组的长度-1]
hashCode & 数组的长度-1 的结果范围 [0, 数组的长度-1]
hashCode值 xxxxxxxx xxxxxxxx xxxxxxxx xxxxxxxx 二进制
16-1=15 00000000 00000000 00000000 00001111 二进制
&-------------------------------------------------------
00000000 00000000 00000000 00000000 0
00000000 00000000 00000000 00000001 1
00000000 00000000 00000000 00000010 2
00000000 00000000 00000000 00000011 3
。。。。。。。。。。。。。。。。。。。。
00000000 00000000 00000000 00001111 15
假设数组的长度为10
hashCode % 数组的长度 的结果范围 [0, 数组的长度-1]
hashCode & 数组的长度-1 的结果范围 [0, 数组的长度-1] 但是只有它们中的几个可能
hashCode值 xxxxxxxx xxxxxxxx xxxxxxxx xxxxxxxx 二进制
10-1=9 00000000 00000000 00000000 00001001 二进制
&-------------------------------------------------------
00000000 00000000 00000000 00000000 0
00000000 00000000 00000000 00000001 1
00000000 00000000 00000000 00001000 8
00000000 00000000 00000000 00001001 9
这就解决了一个问题,数组哪怕 不是足够大,我们也可以用hashCode值 & (数组的长度-1) 得到一个[下标],找到它在数组中的位置。
这里hash冲突有两种可能:(1)hashCode原值一样
(2)hashCode原值不一样,但是 hashCode值 & (数组的长度-1) 一样
如何解决hash冲突?使用“链表”来解决这个问题。
(2)为什么要用链表?
可能存在hash冲突的问题。
(3)HashMap的底层存储特点:
数组的元素此时不是依次存储的,因为存的[下标]是根据 key的 hashCode值 & (数组的长度-1)得到的。
数组[下标]的元素中可能是多个对象,多个对象使用“链表”结构进行连接。这多个对象的特点是hash冲突。
(4)如何设计数组的长度更好?
发现,如果数组的长度为2的n次方的话,数组的长度-1这个值的二进制有一个特征:它的最右边几位的二进制数字都是1.
例如:16 16-1=15 00000000 00000000 00000000 00001111
例如:32 32-1=31 00000000 00000000 00000000 00011111
例如:64 64-1=63 00000000 00000000 00000000 00111111
这样的二进制值,可以保证 hashCode值 xxxxxxxx xxxxxxxx xxxxxxxx xxxxxxxx 二进制
数组的长度-1 00000000 00000000 00000000 00111111
&----------------------------------------------
结果是[0,数组的长度-1]范围内,而且是每一种都可能,即是相对均匀的。
HashMap的底层数组的长度就是设计为2的n次方。
HashMap的无参构造,构造一个具有默认初始容量 (16) 和默认加载因子 (0.75) 的空 HashMap。
HashMap(int initialCapacity):构造一个带指定初始容量和默认加载因子 (0.75) 的空 HashMap。
如果你指定的初始容量不是2的n次方,那么内部会给你纠正为 >= 指定初始容量的一个2的n次方的数字。
(5)理想情况下是希望,(key,value)能够均匀的存储到内部数组中,尽量不要出现某个数组名[下标]下面有多个的(key,value)连接情况,
因为这样的话,会造成效率降低(无论是添加、删除、查找)。
只有提高hashCode值均匀的情况,以及数组的长度是2的n次方,来尽量的减少(但是不能避免)冲突。
3、JDK1.8的HashMap
(1)JDK1.8为什么要修改底层的设计,从数组+链表升级为数组+链表/红黑树?
虽然我们做了一些努力,能够尽量减少hash冲突,但是还是不能避免,所以可能存在某个数组名[下标]下的有很长的“链表”,
这个时候的效率低下。
为了提高效率,把链表升级为查询效率更高的红黑树。
(2)为什么不直接用 数组 + 红黑树,而是用数组+链表/红黑树?
红黑树:
优点:查询效率高
缺点:始终保持平衡关系,一旦“添加”“删除”这个操作影响了它的平衡性,就要“旋转等处理”,这样效率就低了。
所以,当 数组名[下标]下的链表不是特别的长的时,就仍然用“链表”
所以,当 数组名[下标]下的链表特别长时,就改用用“红黑树”
(3)什么时候,链表会变为红黑树?
TREEIFY_THRESHOLD:树化临界值 8
(4)是否 数组名[下标]下的链表长度一达到8个就立刻树化呢?
答案:不是
如果此时数组的长度还不够长,那么我可以尝试先对数组进行“扩容”。
例如:一开始长度假设为16,下标范围[0,15]
如果把数组长度扩容一倍,长度变为32,下标范围[0,31]
这里hash冲突有两种可能:(1)hashCode原值一样 扩容没用
(2)hashCode原值不一样,但是 hashCode值 & (数组的长度-1) 一样 当数组扩容后,可能减少一部分冲突
例如: hashCode值的二进制 00000000 00000000 00000000 00111001
16-1 00000000 00000000 00000000 00001111
&----------------------------------------------------
00000000 00000000 00000000 00001001
例如: hashCode值的二进制 00000000 00000000 00000000 00111001
32-1 00000000 00000000 00000000 00011111
&----------------------------------------------------
00000000 00000000 00000000 00011001
(5)数组扩容到什么时候,就不是用扩容来解决“冲突”问题,而是树化?
MIN_TREEIFY_CAPACITY=64 最小树化容量临界值64
结论:
当数组[下标]链表长度<8个时,还用链表。
当数组[下标]链表长度>8个时,先考虑扩容,如果数组的长度<64,仍然用链表。
当数组[下标]链表长度>8个时,如果数组的长度>=64,链表改为红黑树。
(6)当 数组[下标]下面的结点(key,value)数量越来越少了,会不会从红黑树变回链表呢?
答案:会的
(7)什么时候考虑反树化?
UNTREEIFY_THRESHOLD:6
当红黑树的结点数量<=6个时,可能会反树化。
删除过程中触发从树->链表的条件是: 树的根空了,或是树的左或右空了,树的左的左结点空了,才会触发。
删除后再添加时,需要遇到下一次扩容,扩容过程中,检查table[下标]下的结点个数是<=UNTREEIFY_THRESHOLD,会触发树转为链表的条件。
public class TestHashMap {
public static void main(String[] args) {
HashMap map1 = new HashMap();//默认初始容量 (16)
HashMap map2 = new HashMap(32);//指定初始容量20
map2.put(1,"hello");
System.out.println(Integer.highestOneBit((32 - 1) << 1));
}
}
4、存储到Map中的是(key,value)键值对,那么底层是如何表示这个键值对的呢?
HashMap中的数组叫做table,这个table的元素类型是什么?
答:Map.Entry接口的实现类,不同的版本,实现类的名字不一样。
JDK1.7:Entry<K,V>[] table;
Entry是1.7版HashMap一个内部类,它实现Map.Entry<K,V>接口。
static class Entry<K,V> implements Map.Entry<K,V> {
final K key;
V value;
Entry<K,V> next; 如果table[下标]有冲突,记录下一个结点的地址,这是链表结构
int hash; 是key的hash,这里先备注一下,它不是key调用hashCode()直接得到值,而是再运算后的一个结果。
...
}
JDK1.8:Node<K,V>[] table;
Node是1.8版HashMap一个内部类,它实现了Map.Entry<K,V>接口。
static class Node<K,V> implements Map.Entry<K,V> {
final int hash; 是key的hash,这里先备注一下,它不是key调用hashCode()直接得到值,而是再运算后的一个结果。
final K key;
V value;
Node<K,V> next; 如果table[下标]有冲突,记录下一个结点的地址,这是链表结构
...
}
TreeNode也是HashMap一个内部类,它继承了LinkedHashMap.Entry<K,V>,它又继承了HashMap.Node<K,V>。
TreeNode是Node的子类。
static final class TreeNode<K,V> extends LinkedHashMap.Entry<K,V> {
TreeNode<K,V> parent; // red-black tree links 父结点
TreeNode<K,V> left; //左结点
TreeNode<K,V> right; //右结点
TreeNode<K,V> prev; // needed to unlink next upon deletion //前一个结点
boolean red; //红或黑结点的标识
....
}
5、Debug演示一下HashMap的内部结构
以JDK1.8为例
说明:为了演示“hash"冲突问题,那么我们要造一个自己的key类型,叫做MyKey。
如果按照正常的hashCode的计算,“hash"冲突比较低,想要看到 table[下标]下的结点个数达到8个比较难。
MyKey这个类型的hashCode方法,我故意写成 return 1;
所有的MyKey的hashCode值都是1,全部都是冲突的。
public class TestHashMap2 {
@Test
public void test01(){
HashMap<Integer,String> map = new HashMap<>();
map.put(1,"hello");
map.put(2,"java");
//遍历方式,
//(1)遍历所有key,
Set<Integer> integers = map.keySet();
//(2)遍历所有value
Collection<String> values = map.values();
//(3)遍历所有键值对
Set<Map.Entry<Integer, String>> entries = map.entrySet();
}
@Test
public void test02(){
HashMap<MyKey,String> map = new HashMap<>();
for(int i=1; i<=20; i++){
map.put(new MyKey(i),"value"+i);
}
}
@Test
public void test03(){
HashMap<MyKey,String> map = new HashMap<>();
for(int i=1; i<=20; i++){
map.put(new MyKey(i),"value"+i);
}
//经过上面的for,size=20
for(int i=1; i<=13; i++){
map.remove(new MyKey(i));
}
//经过上面的for,size=4,5,6
for(int i=30; i<=100; i++) {
map.put(new MyKey(i), "value" + i);
}
}
}
class MyKey{
private int k;
public MyKey(int k) {
this.k = k;
}
@Override
public String toString() {
return "MyKey{" +
"k=" + k +
'}';
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
MyKey myKey = (MyKey) o;
return k == myKey.k;
}
@Override
public int hashCode() {
// return Objects.hash(k);
// return 1;
if(k>20){
return 2;
}
return 1;
}
}
6、什么时候会扩容?
(1)JDK1.8时
A:当table[i]下面的链表的结点个数>=8,再添加新的结点到同一个table[i]下,也就是说链表的长度可能要达到9个时,
如果此时table的长度<64,会扩容
B:当size达到“扩容阈值threshold”
threshold = table.length * loadFactor
loadFactor:加载因子,默认值0.75
假设table.length是16,threshold = 16*0.75=12
假设table.length是32,threshold = 32*0.75=24
假设table.length是64,threshold = 64*0.75=48
(2)JDK1.7时
当size达到“扩容阈值threshold”
threshold = table.length * loadFactor
loadFactor:加载因子,默认值0.75
假设table.length是16,threshold = 16*0.75=12
假设table.length是32,threshold = 32*0.75=24
假设table.length是64,threshold = 64*0.75=48
扩容的条件是A:size>=threshold B: null != table[bucketIndex]
bucketIndex:下标,新的(key,value)要存储的table数组的下标位置
public class TestHashMap3 {
@Test
public void test01(){
HashMap<Integer,String> map = new HashMap<>();//默认容量16
for(int i=1; i<=30; i++){
map.put(i,"value"+i);
}
}
@Test
public void test02(){
HashMap<MyClass,String> map = new HashMap<>();//默认容量16
for(int i=1; i<=30; i++){
map.put(new MyClass(i),"value"+i);
}
}
}
class MyClass{
private int k;
public MyClass(int k) {
this.k = k;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
MyClass myClass = (MyClass) o;
return k == myClass.k;
}
@Override
public int hashCode() {
//
if(k<=10){
return Objects.hash(k);
}else{
return 1;
}
}
}
7、hashCode值
问? (key,value)的存储位置如何计算的?
之前分析设计思路: key的hashCode值 & table.length-1
JDK1.7:
int hash = hash(key);
int i = indexFor(hash, table.length);
static int indexFor(int h, int length) {
return h & (length-1);
}
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^k.hashCode();
h ^= (h >>> 20) ^ (h >>> 12); // h = h ^ ( (h >>> 20) ^ (h >>> 12))
return h ^ (h >>> 7) ^ (h >>> 4);
}
为什么不是直接拿k.hashCode()方法返回的值,直接与table.length-1直接做与运算,而是要经过各种^,>>>处理呢?
目的是让认为如果直接拿hashCode值()进行计算的话,会导致hashCode值的二进制的高位部分永远也用不上。
hashCode值 xxxxxxxx xxxxxxxx xxxxxxxx xxxxxxxx
16-1 00000000 00000000 00000000 00001111
即如果直接拿hashCode值()进行计算的话,hash冲突的概率会增加。
如果加入了hashCode值的二进制高位数字特征(高位通过>>>右移后,会参与到&运算中来),使得高位数字被用上,
增加干扰因素,使得冲突概率降低。
JDK1.8版:
hash(key)
n = (tab = resize()).length;
tab[i = (n - 1) & hash]
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
//相当于与key.hashCode值的高16位与低16位做了 ^ 运算,产生干扰
}
结论:没有直接使用key的hashCode值 & table.length-1
而是把key的hashCode值处理了一些(调用hash方法)之后,得到一个hash,hash & table.length-1,
目的是希望冲突概率降低。
8、问? 无论是JDK1.7中的HashMap.Entry类型,还是JDK1.8的HashMap.Node类型中都存储了计算后的hash,
为什么?
A:每一次添加新的(key,value),我们要保证map中的key是不重复的。
需要用新的(key,value) 中的key,与map中已经存在的所有的(key,value)做比较,看是否重复。
①先找到新的(key,value) 的存储位置[index],此时我们只需要和[index]中的的(key,value)的key做比较,
因为如果存储位置不同,说明他们的hash值肯定不同,hash值不同,肯定是不同key对象。
②如果 [index]中已经有多个(key,value),那么需要先比较这些key与新的key的hash值
如果此时我们没有hash,意味着我每次比较时,都要先调用key.hashCode()方法,得到原始的hashCode值,然后在调用hash()计算之后再比较。
如果每次都要算的话,效率就太低了。既然之前添加时,已经算好了,就存储起来,下次直接获取hash值,比较就可以了。
hashCode()和equals方法一定要一起重写。
并且要遵循如下原则:
A:如果两个对象的equals方法调用结果是true,那么它俩的hashCode值一定要一样
B:如果两个对象的hashCode值不同,那么equals方法一定要返回false
C:如果两个对象的hashCode值相同,那么equals方法的结果可能是true,可能是false
例如:"Aa"和"BB"字符串 的hashCode值相同,但是equals却是false
例如:"Aa"和"Aa"字符串 的hashCode值相同,equals也是true
B:查找时,如果hash已经存储了,根据key去调用get(key)查找键值对的时候,就可以快递的找到这个键值对。
比较时,就不用重新计算hash值。
9、问:为什么(key,value)存储到map中后,key就不能修改了?
因为key在存储时,hash已经存储了,你如果修改了key,会导致hashCode值变了,变了之后,原来的hash和新的key不匹配,就会导致找不到。