本文阐述了以下关于HashMap的知识点
HashMap的底层是拉链法,即用数组+链表相结合的数据结构,JDK 1.8后引入了 红黑树
而每一个键值对其实都是一个节点,这种节点是一个 Entry 对象
class Entry<K, V> {
private K k;
private V v;
private Entry next;
public Entry(K k, V v, Entry next) {
this.k = k;
this.v = v;
this.next = next;
}
public K getK() {
return k;
}
}
往Map中插入一个元素,HashMap是如何拿到下标的呢?
在Java 1.7中,put(key, value) 的源码中有以下两行
int hash = hash(key);
int i = indexFor(hash, table.length);
首先将传进来的 key 进行 hash(key) 得到一个 hash 值,再通过 indexFor 算法算出数组下标 i
get同样需要用到这两行代码来获取 key 的数组下标
接下来我们演示下,编译下列代码
HashMap<String, String> hashMap = new HashMap<String, String>();
hashMap.put("水果","苹果");
hashMap.put("蔬菜","菠菜");
hashMap.put("主食","米饭");
hashMap.put("肉类","鸡肉");
hashMap.put("酒水","红酒");
hashMap.put("餐具","刀叉");
for (String key: hashMap.keySet()) {
int hashCode = key.hashCode();
int index = hashCode % 8;
System.out.println(key + "的hashCode是" + hashCode);
}
蔬菜 的hashCode是 1090608, index是 0
酒水 的hashCode是 1180962, index是 2
肉类 的hashCode是 1051922, index是 2
水果 的hashCode是 885224, index是 0
餐具 的hashCode是 1235559, index是 7
主食 的hashCode是 659972, index是 4
可以看到蔬菜和水果,酒水和肉类 的 index 是一样的,术语叫做 hash碰撞,这个时候HashMap就会采用 链表 的形式
发生 hash碰撞 的节点,在JDK 1.7中是加入链表的头部,在JDK 1.8中是加入链表的尾部
所以 put 的简化代码应该是这样的
public V put(K key, V value) {
int hash = hash(key);
int i = indexFor(hash, table.length);
addEntry(key, value, i);
}
现在有一种情况就是,如果往上述 hashMap 对象中再插入一个节点会发生什么?
hashMap.put("主食","面条");
这个过程是:
初始化的时候,有一个默认初始化容器大小值16,默认加载因子0.75
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;
static final float DEFAULT_LOAD_FACTORY = 0.75f;
JDK也允许手动传入一个 size 进来,但会根据你传入的size 找到一个刚好大于或等于2的次方 的值作为真正的容器size,意思就是如果你传入的是30,那么容器的真正大小是32,那么JDK为什么要求容器的大小一定要是 2的次方 呢?
原来在源码当中
如果 put 的节点 key 值为 null,实际上也能put成功,只不过会固定放在数组下标为0的位置
而 key 值不为 null 的时候,实际上JDK中还做了很多工作
JDK 1.7 hash() 方法代码
static int hash(int h) {
// ^ :按位异或
// >>>:无符号右移,忽略符号位,空位都以0补齐
h ^= (h >>> 20) ^ (h >>> 12);
return h ^ (h >>> 7) ^ (h >>> 4);
}
JDK 1.7 indexFor() 方法代码
static int indexFor(int h, int length) {
return h & (length - 1);
}
JDK为什么要在 hashCode() 后有这么多的右移操作和异或运算呢?h & (length - 1) 又是怎么保证数组下标不越界的呢?
我们来看这个例子,h为一个哈希值,随机取值
h : 1010 0110
15: 0000 1111
& : 0000 0110
由于 length 一定是2的次方,所以 length - 1 的二进制其实都是高位全为0,低位全为1的格式
所以在进行 & 运算的时候,真正决定结果的只有低位,而低位的范围只可能在 length -1 内
并且 h % length == h & (length - 1) 的前提是 length 是 2的次方
这就回答了为什么要求容器的大小一定要是 2的次方,并且 h & (length - 1) 能保证数组下标不越界
而在 hashCode() 后进行多次位操作和异或运算则是防止一些实现较差的hashCode(),也就是减少 hash碰撞
threshold是衡量数组是否需要扩增的一个标准
threshold = capacity * loadFactor
当 size >= threshold 并且 当前插入的节点位置不为空 的时候,那么就要考虑对数组的扩增了
在之前的hashMap示例图中,capacity 为8,loadFactor 为0.75,也就是说阈值是6
我们现在插入的节点已达到此阈值,满足 size >= threshold 条件
而条件“当前插入的节点位置不为空”(JDK 1.8已去除此条件)的意思是
扩容后的size是原先的两倍,重新对所有节点进行rehash迁移
迁移源码
void transfer(Entry[] newTable)
{
Entry[] src = table;
int newCapacity = newTable.length;
//下面这段代码的意思是:
// 从OldTable里摘一个元素出来,然后放到NewTable中
for (int j = 0; j < src.length; j++) {
Entry<K,V> e = src[j];
if (e != null) {
src[j] = null;
do {
Entry<K,V> next = e.next;
int i = indexFor(e.hash, newCapacity);
e.next = newTable[i];
newTable[i] = e;
e = next;
} while (e != null);
}
}
}
在JDK 1.7中这种方式的并发扩容会出现 死锁 的问题
do-while循环中,正常情况是这样的
在并发的情况下,假设有两个线程,分别用红色和浅蓝色代表
注意:此时的key(7).next 已经指向了key(3), 环形链表就这样出现了。
于是,当我们的线程一调用到,HashTable.get(11)时,悲剧就出现了——Infinite Loop
图示引用来源https://coolshell.cn/articles/9606.html
static class Node<K,V> implements Map.Entry<K,V> {
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;
}
public final K getKey() { return key; }
public final V getValue() { return value; }
public final String toString() { return key + "=" + value; }
public final int hashCode() {
return Objects.hashCode(key) ^ Objects.hashCode(value);
}
public final V setValue(V newValue) {
V oldValue = value;
value = newValue;
return oldValue;
}
public final boolean equals(Object o) {
if (o == this)
return true;
if (o instanceof Map.Entry) {
Map.Entry<?,?> e = (Map.Entry<?,?>)o;
if (Objects.equals(key, e.getKey()) &&
Objects.equals(value, e.getValue()))
return true;
}
return false;
}
}
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
static final int TREEIFY_THRESHOLD = 8;
static final float UNTREEIFY_THRESHOLD = 6;
HashMap<String, String> map = new HashMap<String, String>();
map.put("1","1");
map.put("2","2");
for (String key: map.keySet()) {
if (key.equals("1")) {
map.remove(key);
}
}
上述代码在执行的时候会抛出ConcurrentModificationException异常
异常所在的源码是这样的
if (modCount != expectedModCount)
throw new ConcurrentModificationException();
这个异常也会在多线程编程的时候出现,一个线程在遍历,另一个线程在remove,那么循环的那个线程也会抛出这个异常
Person person1 = new Person("小明", "男");
HashMap<Person, String> map = new HashMap<Person, String>();
map.put(person1,"12岁");
System.out.println(map.get(person1));
person1 = new Person("小红", "女");
System.out.println(map.get(person1));
上述代码执行结果
12岁
null
可以看到,当key值中的实例对象改变的时候,HashMap是拿不出原先key值对应的value的,所以这也是我们编码时需要注意的地方,应该将 person1 对象定义成 final,防止被修改