HashMap
- HashMap是基于哈希表对map接口的实现,HashMap具有较快的访问速度,但是遍历顺序确实不确定的。
- HashMap并非线程安全的,当存在多个线程同时写入HashMap时,可能会导致数据的不一致性
jdk1.7和1.8的区别
- jdk7是数组+链表、jdk8是数组+链表(单向、双向)+红黑树
- jdk8会将链表转变为红黑树
- 新结点插入顺序不同(jdk7采用头插法、jdk8因为要遍历链表变为红黑树所以采用尾插法)
- jdk8hash算法优化
- resize逻辑修改(jdk7会出现死循环、jdk8不会)
高频问题
1、为什么必须为2的幂次? 详细见【put方法–>putVal方法–>说明一】
2、为什么8采用尾插法? 链表长度大于(新插入的数据为第九条)8时,链表转换为红黑树,怎么能知道链表长度,只能循环列表,新节点插入尾部后,判断是否树化。
3、什么时候链表会变成树? 链表大于8(新插入的数据为第九条)并且数组长度大于等于64时才会触发树化
4、什么时候扩容?
条件一:当hashmap中的元素个数超过数组大小*loadFactor(加载因子0.75)时,就会进行数组扩容,扩容会扩大一倍
条件二:数组容量小于64且链表长度大于8时,会进行数组扩容
5、为什么链表有用到单向和双向?
a、查看源码,链表为单向链表,node节点只有next属性。红黑树采用了双向链表,TreeNode除了parent、left、 right外还有prev属性,TreeNode本身有继承了LinkedHashMap.Entry,LinkedHashMap.Entry继承了HashMap.Node,HashMap.Node是有next属性的,所以TreeNode是拥有prev和next属性。
b、树化treeifyBin方法、往树节点插入值putTreeVal方法中,对prev和next属性均有赋值
6、初始化为多少?什么时候map初始化大小的? 答:无参构造时初始化大小为16,若为有参构造时,则会修改为大于等于初始化值的2的倍数。第一次put时才会初始化(Hashmap源码中putval方法第三行调用了resize扩容方法)。注:HashMap(Map<? extends K, ? extends V> m)是比较特殊,可以debug看一下
jdk1.8源码分析
初始化hashmap
HashMap<String,Object> h1 = new HashMap<>(); // 无参
HashMap<String,Object> h2 = new HashMap<>(10); // 指定初始容量(数组长度) 实际为16 2的四次方
HashMap<String,Object> h3 = new HashMap<>(10,0.75f); // 指定初始容量(实际为16 2的四次方)、负载因子
HashMap<String,Object> h4 = new HashMap<>(h1); // map,这个是一个特例,需要debug看一下
前三种new HashMap均是在第一次put时做的初始化。
第四种比较特殊,若存入Map有值,则会直接初始化,若无值则在第一次put时初始化
hashmap参数说明
// 默认初始化大小 16,在第一次put的时候才会初始化大小
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;
// 链表长度大于(新插入的数据为第九条)8时,链表转换为红黑树
static final int TREEIFY_THRESHOLD = 8;
// 链表长度降低到6(小于等于6)时,转为链表
static final int UNTREEIFY_THRESHOLD = 6;
// 树化最小的阈值,链表大于8(新插入的数据为第九条)并且数组长度大于等于64时才会触发树化
static final int MIN_TREEIFY_CAPACITY = 64;
// 存储元素的数组
transient Node<K,V>[] table;
transient Set<Map.Entry<K,V>> entrySet;
// 存放元素的个数
transient int size;
// 被修改的次数fast-fail机制
transient int modCount;
// 阈值 当实际大小(容量*填充比)超过临界值时,会进行扩容
int threshold;
// 负载因子
final float loadFactor;
put方法(key存在则覆盖)、putIfAbsent(key存则不覆盖)
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
public V putIfAbsent(K key, V value) {
return putVal(hash(key), key, value, true, true);
}
put、putIfAbsent方法是存在返回值的:
测试一(put):
HashMap<String,String> h1 = new HashMap<>();
String put = h1.put(“k”, “v”);
System.out.println(put); // null
System.out.println(h1.get(“k”)); // v
String put1 = h1.put(“k”, “v1”);
System.out.println(put1); // v
System.out.println(h1.get(“k”)); // v1
测试二(putIfAbsent):
HashMap<String,String> h1 = new HashMap<>();
String put = h1.putIfAbsent(“k”, “v”);
System.out.println(put); // null
System.out.println(h1.get(“k”)); // v
String put1 = h1.putIfAbsent(“k”, “v1”);
System.out.println(put1); // v
System.out.println(h1.get(“k”)); // v
解释: 详细查看putval源码,搜索return,可查到return null 或者 return oldValue,return oldValue就是返回覆盖前的value。
hash方法:
1、 查看hash方法中,key等于null时,返回的hash值均为0,所以Hashmap只允许存在一个key为null的值
2、 (h = key.hashCode()) ^ (h >>> 16)
>>> 右移,舍弃低位,高位移动到低位,例如:hash值 0111 0101,舍弃低位0101,将高位移动到低位0000 0111
^ :异或,不同为1,相同为0
h: 0111 0101
h >>> 16: 0000 0111
^异或结果: 0111 0010
为什么要右移(>>>)?
答:putVal方法中说明一,计算下标采用&(与),会舍弃高位,>>>(位移)可以让key的hash值的高位也参与运算
// 计算key的Hash值
static final int hash(Object key) {
int h;
// 将key.hashCode()赋值给h h位运算符右移16位 然后key.hashCode() 和 右移16位后的结果做异或运算
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
putVal方法:
说明一【源码对应putVal中说明一】、数组下标,为什么数组必须是2的幂次?
tab[i = (n - 1) & hash],初始化默认数组容量为16
n-1=15 15的二进制为0000 1111,&(与)均为1是为1,否则为0
二进制运算过程:
15: 0000 1111
hash: 0100 0101
&(与)结果:0000 0101 结果下标为5
&(与)的结果就是舍弃高位,保留低位,最终结果为 0000 0000至0000 1111(结果为0-15),可以保证均匀散列
假设为16:
二进制运算过程:
16: 0001 0000
hash: 0100 0101
&(与)结果: 0000 0000 结果下标为5
&(与)的最终结果只能是0000 0000 或者 0001 0000,所有值只能分布在下标为15 和 16上
为什么数组必须是2的幂次?
答、只有2的幂次,才能保证二进制数只有一位是1,n-1后才能保证高位均是0,低位均是1,&(与)舍弃高位,保留低位,保证数据均匀散列
说明二【源码对应putVal中说明二】:参数onlyIfAbsent
put方法onlyIfAbsent为false,key已经存在则会覆盖
putIfAbsent方法onlyIfAbsent为true,key已经存在不会覆盖
// hashmap插入值的put方法
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
// 参数申明
// tab为当前数组、p表示链表的root节点
Node<K,V>[] tab; Node<K,V> p; int n, i;
// 判断tab是否存在值
if ((tab = table) == null || (n = tab.length) == 0)
// 不存在,则会初始化(jdk1.8将扩容和初始化合在一起),第一次put的时候才会初始化数组容量为16
n = (tab = resize()).length;
***************初始化**************
// 判断hash后数组该下标下是否存在值,【说明一】
if ((p = tab[i = (n - 1) & hash]) == null)
********0000*******下标没有值--开始**************
// 不存在直接赋值
tab[i] = newNode(hash, key, value, null);
*******0000********下标没有值--结束**************
else {
********1111*******下标有值--开始**************
// e表示key值存在时的node节点
Node<K,V> e; K k;
// 判断新插入key值和链表root节点key值是否一致
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
********2222*******下标有值--和root key相等--开始**************
// key值一致
e = p;
*********2222******下标有值--和root key相等--结束**************
// 判断是否为树(jdk8链表会转换为红黑树)
else if (p instanceof TreeNode)
********3333*******下标有值--节点为红黑树--开始**************
// 红黑树插入节点、并校验是否存在key值一样的节点
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
*********3333******下标有值--节点为红黑树--结束**************
else {
********4444*******下标有值--节点为链表--开始**************
// 循环链表(jdk1.8尾插法)
for (int binCount = 0; ; ++binCount) {
********5555*******下标有值--节点为链表--子节点为null--开始**************
// 链表子节点为空时,key不存在
if ((e = p.next) == null) {
// 直接插入链表尾部
p.next = newNode(hash, key, value, null);
// 插入新值之后,链表是否大于等于(8-1)
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
// 链表转换为树
// 树化时还有一个校验,不是链表大于8则会树化
// 还需要满足存储元素的数组(table)大于等于64,否则会先扩容
// if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY) // 来源于treeifyBin方法
treeifyBin(tab, hash);
break;
}
********5555*******下标有值--节点为链表--子节点为null--结束**************
********6666*******下标有值--节点为链表--某一节点key一致--开始**************
// 判断hash和key是否相等,相等会直接跳出此循环,已存在key
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
********6666*******下标有值--节点为链表--某一节点key一致--结束**************
}
********4444*******下标有值--节点为链表--结束**************
********1111*******下标有值--结束**************
}
***************put和putIfAbsent--开始**************
// key值已经存在,则会覆盖原数据,put方法会返回值,返回值为覆盖前的value
if (e != null) { // existing mapping for key
V oldValue = e.value;
// put和putIfAbsent方法,请求的onlyIfAbsent参数不同 【说明二】
// put时onlyIfAbsent为false,key已经存在则会覆盖
// putIfAbsent时onlyIfAbsent为true,key已经存在不会覆盖
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
***************put和putIfAbsent--结束**************
}
++modCount;
// 当hashmap中的元素个数超过数组大小*loadFactor(加载因子0.75)时,就会进行数组扩容,扩容会扩大一倍
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
treeifyBin方法【树化】
treeifyBin方法【树化】:
说明一:数组长度小于MIN_TREEIFY_CAPACITY(64)时,会先扩容,大于等于64后才会树化
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)
// 数组长度小于64时,会扩容
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);
}
}
resize方法【扩容】
resize包含了初始化、扩容两套逻辑
1.7扩容机制:两层循环,循环数组、循环链表,一个一个移动到新的数组中。
说明一:低位链表、高位链表
数组长度由16扩容到32:
相同hash,结果对比一
15: 0000 1111
hash: 0100 0101
&(与)结果:0000 0101 结果下标为5
31: 0001 1111
hash: 0100 0101
&(与)结果:0000 0101 结果下标为老数组下标5
相同hash,结果对比二:
15: 0000 1111
hash: 0101 0101
&(与)结果:0000 0101
31: 0001 1111
hash: 0101 0101
&(与)结果:0001 0101 结果下标为21 = 老数组长度16+老数组下标5
对比发现,hash值不变的情况下,只有31高位0001 1111中的第一个1会影响&(与)操作结果,&(与)结果低位是不会变化的,老数组的同一条链表移动到新数组只会存在两个位置,和老数组下标一致,或者为老数组长度+老数组下标。
所以链表在扩容时会生成两条新的链表,一条为低位链表(和老数组下标一致),一条为高位链表(老数组长度+老数组下标)
final Node<K,V>[] resize() {
Node<K,V>[] oldTab = table;
// 老数组长度
int oldCap = (oldTab == null) ? 0 : oldTab.length;
// 老数组阈值
int oldThr = threshold;
// 新数组长度 阈值
int newCap, newThr = 0;
if (oldCap > 0) {
// 旧数组已经存在值 扩容
if (oldCap >= MAXIMUM_CAPACITY) {
// 数组长度已经大于等于最大值
threshold = Integer.MAX_VALUE;
return oldTab;
}
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
// newCap newThr 新数组长度、阈值 都翻一倍
newThr = oldThr << 1; // double threshold
}
else if (oldThr > 0) // initial capacity was placed in threshold
// 指定数组大小
newCap = oldThr;
else { // zero initial threshold signifies using defaults
// 未指定大小时 初始化
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;
@SuppressWarnings({"rawtypes","unchecked"})
// 生成新的数组 初始化大小为newCap(新数组大小)
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;
// 老数组有值 无值直接进入下次循环
if ((e = oldTab[j]) != null) {
oldTab[j] = null;
if (e.next == null)
// 该下标数组值没有后指针 只有一个值
// hash 于 新数组长度减一 重新计算新数组下标,计算下标逻辑详细见【putVal方法--说明一】
newTab[e.hash & (newCap - 1)] = e;
else if (e instanceof TreeNode)
// 该下标数组值类型为红黑树 详细见【resize方法【扩容】--红黑树处理逻辑】
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
else { // preserve order
// 该下标数组值有后指针 链表
// 低位链表、高位链表 详细说明见【resize方法--说明一】
// 头节点用于直接将整个链表移动到新数组,尾节点用于新的node节点插入到链表尾部,若没有尾节点则需要循环链表找到尾节点进行插入
// 低位链表头节点 低位链表尾节点
Node<K,V> loHead = null, loTail = null;
// 高位链表头节点 高位链表尾节点
Node<K,V> hiHead = null, hiTail = null;
Node<K,V> next;
**********循环列表开始********
do {
next = e.next;
// 低位链表
if ((e.hash & oldCap) == 0) {
if (loTail == null)
// 头节点
loHead = e;
else
// 低位链表尾节点的后指针指向新的node节点
loTail.next = e;
// 新的node节点定义为低位链表尾节点
loTail = e;
}
// 高位链表
else {
if (hiTail == null)
// 头节点
hiHead = e;
else
// 高位链表尾节点的后指针指向新的node节点
hiTail.next = e;
// 新的node节点定义为高位链表尾节点
hiTail = e;
}
} while ((e = next) != null);
**********循环列表结束********
// 低位链表尾节点有值 说明存在低位链表
if (loTail != null) {
// 将尾节点的下一个节点指向null 在上面循环中,并没有处理每一个节点的尾指针,若不操作,可能会指向别的节点
loTail.next = null;
// 将低位链表头节点放到新数组中(下标为老数组下标)
newTab[j] = loHead;
}
// 高位链表尾节点有值 说明存在高位链表
if (hiTail != null) {
// 将尾节点的下一个节点指向null
hiTail.next = null;
// 将高位链表头节点放到新数组中(下标为老数组下标+老数组容量)
newTab[j + oldCap] = hiHead;
}
}
}
}
}
return newTab;
}
resize方法【扩容】–红黑树处理逻辑
final void split(HashMap<K,V> map, Node<K,V>[] tab, int index, int bit) {
TreeNode<K,V> b = this;
// Relink into lo and hi lists, preserving order
// 低位TreeNode(类型为树,实际只记录了前后指针) 高位TreeNode(类型为树,实际只记录了前后指针)
TreeNode<K,V> loHead = null, loTail = null;
TreeNode<K,V> hiHead = null, hiTail = null;
// 低位TreeNode长度 高位TreeNode长度 用于判断是否链化
int lc = 0, hc = 0;
// 循环数 操作逻辑同链表移动一样,将节点从新&(于)操作,计算新数组下标,结果同样是两个位置
for (TreeNode<K,V> e = b, next; e != null; e = next) {
next = (TreeNode<K,V>)e.next;
e.next = null;
if ((e.hash & bit) == 0) {
if ((e.prev = loTail) == null)
loHead = e;
else
loTail.next = e;
loTail = e;
++lc;
}
else {
if ((e.prev = hiTail) == null)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
++hc;
}
}
// 低位头节点有值
if (loHead != null) {
if (lc <= UNTREEIFY_THRESHOLD)
// lc <= 6 untreeify 链化,并将结果(链表)放到新数组中(下标为老数组下标)
// untreeify 链化:循环低位TreeNode,将每一个节点都转化为node,并将尾指针指向下一个节点
tab[index] = loHead.untreeify(map);
else {
tab[index] = loHead;
// 若高位为null,说明整个树都在低位,则不会重新生成红黑树
if (hiHead != null) // (else is already treeified)
// 重新生成红黑树
loHead.treeify(tab);
}
}
// 高位头节点有值(操作同低位)
if (hiHead != null) {
if (hc <= UNTREEIFY_THRESHOLD)
tab[index + bit] = hiHead.untreeify(map);
else {
tab[index + bit] = hiHead;
if (loHead != null)
hiHead.treeify(tab);
}
}
}
jdk1.8源码总结
- jdk8是数组+链表+红黑树
- jdk8采用尾插法
- 只有在链表大于8(插入第九条数据)且数组大于等于64时才会将链表转换为红黑树,反之优先扩容
- 当hashmap中的元素个数超过数组大小*loadFactor(加载因子0.75)时,就会进行数组扩容,扩容会扩大一倍
- 红黑树的数量小于等于6 就开始链化,为什么不是7?如果是7的话,频繁进行插入删除的话会导致容错空间,超过8树化,小于8 链化频繁切换浪费效率。
- 允许存在一个key为null的值
- key值一致的情况下会覆盖原数据
测试题
假设有一个对象user,有两个属性name、age,只要name和age相等的情况下,视为同一个人,Hashmap中只存放一个。
public class HashmapTest {
public static void main(String[] args) {
HashMap<User,User> hashMap = new HashMap<>();
hashMap.put(new User("张三", 18),new User("张三", 18));
hashMap.put(new User("李四", 18),new User("李四", 18));
hashMap.put(new User("张三", 18),new User("张三", 18));
System.out.println(hashMap);
}
}
class User{
private String name;
private int age;
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public int getAge() {
return age;
}
public void setAge(int age) {
this.age = age;
}
public User(String name, int age) {
this.name = name;
this.age = age;
}
@Override
public String toString() {
return "User{" +
"name='" + name + '\'' +
", age=" + age +
'}';
}
}
结果为:
若debug时显示的Hashmap没有table、size等元素时,可查看【此博客】第19条。
解释:
1、根据源码可知道【 if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k))))】,只有在hash值相等的情况下,然后比较两个对象地址是否相等 或 值是否相等,然后才会认为是同一个对象。
2、new User 生成的对象是不同的,最终hashcode值也是不同的,假设Hashmap中计算的hash值相等,后面的==和equals返回的也是false,所以最终结果 Hashmap中还是会存放了三个元素。
解决
原理:
1、重新user对象的hashcode方法,保证name、age一致的情况下,hashcode是相等的,这样hash值也会相等
2、new user生成的地址是不同的,所以需要重写equals方法,保证name、age一致的情况下,
两个对象相等。
代码:
class User{
private String name;
private int age;
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public int getAge() {
return age;
}
public void setAge(int age) {
this.age = age;
}
public User(String name, int age) {
this.name = name;
this.age = age;
}
@Override
public String toString() {
return "User{" +
"name='" + name + '\'' +
", age=" + age +
'}';
}
// 重新equals方法,保证name、age一致时返回true
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
User user = (User) o;
return age == user.age && Objects.equals(name, user.name);
}
// 重写hashcode方法,保证Hashcode一致,Hashmap内部Hash算法得到的hash值就是一致的
@Override
public int hashCode() {
return Objects.hash(name, age);
}
}
重新执行main方法,得到的Hashmap里面只有两个元素,张三 18 只添加了一次
HashSet
HashSet底层实际就是一个Hashmap,HashSet实际就是利用了Hashmap中key不可重复的特点。
// new HashSet实际就是new了一个Hashmap
public HashSet() {
map = new HashMap<>();
}
private static final Object PRESENT = new Object();
// add的值实际就是map的key
// value实际是一个Object,用于占位
// map的put会返回修改前参数,若put返回值为null则是第一次添加,add返回则为true
public boolean add(E e) {
return map.put(e, PRESENT)==null;
}