文章目录
1. HashMap的继承关系
public class HashMap<K,V> extends AbstractMap<K,V>
implements Map<K,V>, Cloneable, Serializable
1.1 AbstractMap
实现了Map的接口,实现了基础的Map的方法。
public abstract class AbstractMap<K,V> implements Map<K,V>
public int size() //当前Map的大小
public boolean isEmpty()
public boolean containsValue(Object value)
public boolean containsKey(Object key)
public V get(Object key)
public V put(K key, V value)
public V remove(Object key)
public void putAll(Map<? extends K, ? extends V> m)
public void clear()
public Set keySet()
public Collection values()
2. HashMap的变量
//静态变量
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;
static final int MIN_TREEIFY_CAPACITY = 64;
//成员变量
transient Node<K,V>[] table;
transient Set<Map.Entry<K,V>> entrySet;
transient int size;
transient int modCount;
int threshold;
final float loadFactor;
其中最主要的成员变量:
-
table
变量:HashMap的底层数据结构,是Node类的实体数组,Node是一个静态内部类,一种数组和链表相结合的复合结构,用于保存key-value对; -
size
变量:表示已存储的HashMap的key-value对的数量; -
loadFactor
变量:装载因子,用于衡量满的程度,默认值为0.75f(static final float DEFAULT_LOAD_FACTOR = 0.75f;); -
threshold
变量:临界值,当实际KV个数超过threshold时,HashMap会将容量扩容,threshold=容量*加载因子; -
capacity
:并不是一个成员变量,但却是一个必须要知道的概念,表示容量,默认容量是16(static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;)
了解这些变量的基本含义后,围绕这几个问题来开始学习:
2.1 为什么建议HashMap初始化时设置初始容量?
为了提高性能!
如果我们没有设置初始容量大小,随着元素的不断增加,HashMap会发生多次扩容,而HashMap中的扩容机制决定了每次扩容,原数组中的数据必须重新计算其在新数组中的位置,并放进去,这就是resize是非常影响性能的。
当达到临界值(容量*加载因子)时,发生扩容,因此初始容量设定时采用这个公式:expectedSize / 0.75F + 1.0F
。
2.2 为什么初始容量是16(2的幂次)?
参考这里
为了减少碰撞!
首先来看HashMap中的hash算法,其实就是调用Object对象的hashCode()方法,该方法会返回一个整数,然后用这个数对HashMap的容量进行取模就行了。
Java之所以使用位运算(&)来代替取模运算(%),最主要的考虑就是效率,位运算直接对内存数据进行操作,不需要转成十进制,因此处理速度非常快。
//JDK1.7
//该方法主要是将Object转换成一个整型。
final int hash(Object k) {
int h = hashSeed;
if (0 != h && k instanceof String) {
return sun.misc.Hashing.stringHash32((String) k);
}
//下面这段代码是为了对key的hashCode进行扰动计算,防止不同hashCode的高位不同但低位相同导致的hash冲突。简单点说,就是为了把高位的特征和低位的特征组合起来,降低哈希冲突的概率,也就是说,尽量做到任何一位的变化都能对最终得到的结果产生影响。
h ^= k.hashCode();
h ^= (h >>> 20) ^ (h >>> 12);
return h ^ (h >>> 7) ^ (h >>> 4);
}
//将hash生成的整型转换成链表数组中的下标。
static int indexFor(int h, int length) {
return h & (length-1);
}
- 假定HashMap长度length是默认的16,计算length-1的结果为十进制的15,二进制的1111。
- 十进制的3029737,二进制的101110001110101110 1001,与length-1进行&运算,101110001110101110 1001 & 1111 = 1001,十进制是9,所以 index=9;可以说,Hash算法最终得到的index结果,完全取决于Key的Hashcode值的最后几位。
- 假定HashMap长度length是默认的10,
101110001110101110 1001 & 1001 = 1001
101110001110101110 1011 & 1001 = 1001
101110001110101110 1111 & 1001 = 1001
虽然HashCode的倒数第二第三位从0变成了1,但是运算的结果都是1001。也就是说,当HashMap长度为10的时候,有些index结果的出现几率会更大,而有些index结果永远不会出现(比如0111)!
这样,显然不符合Hash算法均匀分布的原则。
反观长度16或者其他2的幂,Length-1的值是所有二进制位全为1,这种情况下,index的结果等同于HashCode后几位的值。只要输入的HashCode本身分布均匀,Hash算法的结果就是均匀的。
2.3 为什么加载因子是0.75f?
参考这里
提高空间利用率和 减少查询成本的折中,0.75的话碰撞最小(泊松分布)!
HashMap有两个参数影响其性能:初始容量和加载因子。容量是哈希表中桶的数量,初始容量只是哈希表在创建时的容量。
加载因子是哈希表在其容量自动扩容之前可以达到多满的一种度量。
当哈希表中的条目数超出了加载因子与当前容量的乘积时,则要对该哈希表进行扩容、rehash操作(即重建内部数据结构),扩容后的哈希表将具有两倍的原容量。
- 加载因子过高,例如为1,虽然减少了空间开销,提高了空间利用率,但同时也增加了查询时间成本;
- 加载因子过低,例如0.5,虽然可以减少查询时间成本,但是空间利用率很低,同时提高了rehash操作的次数。
选择0.75作为默认的加载因子,完全是时间和空间成本上寻求的一种折衷选择, 加载因子越大,填满的元素越多,空间利用率越高,但冲突的机会加大了。反之,加载因子越小,填满的元素越少,冲突的机会减小,但空间浪费多了。冲突的机会越大,则查找的成本越高。反之,查找的成本越小。
因此,必须在 "冲突的机会"与"空间利用率"之间寻找一种平衡与折衷。
简单翻译一下就是在理想情况下,使用随机哈希码,节点出现的频率在hash桶中遵循泊松分布,同时给出了桶中元素个数和概率的对照表。
从上面的表中可以看到当桶中元素到达8个的时候,概率已经变得非常小,也就是说用0.75作为加载因子,每个碰撞位置的链表长度超过8个是几乎不可能的。
3. HashMap的数据结构
3.1 一些基本概念
1. hash
一般翻译做“散列”,也有直接音译为“哈希”的,就是把任意长度的输入,通过散列算法,变换成固定长度的输出,该输出就是散列值。
简单的说就是一种将任意长度的消息压缩到某一固定长度的消息摘要的函数。
常见hash算法
- 直接定址法:直接以关键字k或者k加上某个常数(k+c)作为哈希地址。
- 数字分析法:提取关键字中取值比较均匀的数字作为哈希地址。
- 除留余数法:用关键字k除以某个不大于哈希表长度m的数p,将所得余数作为哈希表地址。
- 分段叠加法:按照哈希表地址位数将关键字分成位数相等的几部分,其中最后一部分可以比较短。然后将这几部分相加,舍弃最高进位后的结果就是该关键字的哈希地址。
- 平方取中法:如果关键字各个部分分布都不均匀的话,可以先求出它的平方值,然后按照需求取中间的几位作为哈希地址。
- 伪随机数法:采用一个伪随机数当作哈希函数。
深入了解请参考:hash算法详解
2. 碰撞
两个不同的输入值,根据同一散列函数计算出的散列值相同的现象叫做碰撞。
衡量一个哈希函数的好坏的重要指标就是发生碰撞的概率以及发生碰撞的解决方案。任何哈希函数基本都无法彻底避免碰撞,常见的解决碰撞的方法有以下几种:
-
开放定址法
开放定址法就是一旦发生了冲突,就去寻找下一个空的散列地址,只要散列表足够大,空的散列地址总能找到,并将记录存入。 -
链地址法
将哈希表的每个单元作为链表的头结点,所有哈希地址为i的元素构成一个同义词链表。即发生冲突时就把该关键字链在以该单元为头结点的链表的尾部。 -
再哈希法
当哈希地址发生冲突用其他的函数计算另一个哈希函数地址,直到冲突不再产生为止。 -
建立公共溢出区
将哈希表分为基本表和溢出表两部分,发生冲突的元素都放入溢出表中。
3.2 数据结构
那么为什么要用到数组和链表结合的数据结构?
我们知道数组和链表都有其各自的优点和缺点,数组连续存储,寻址容易,插入删除操作相对困难;而链表离散存储,寻址相对困难,而插入删除操作容易;而HashMap结合了这两种数据结构,保留了各自的优点,又弥补了各自的缺点,当然链表长度太长的话,在JDK8中会转化为红黑树。
Node的源码
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;
}
}
4. HashMap的方法
4.1 put()方法
简单解释一下:
1)通过hash(Object key)算法得到hash值;
2)判断table是否为null或者长度为0,如果是执行resize()进行扩容;
3)通过hash值以及table数组长度得到插入的数组索引i,判断数组table[i]是否为空或为null;
4)如果table[i] == null,直接新建节点添加,转向 8),如果table[i]不为空,转向 5);
5)判断table[i]的首个元素是否和key一样,如果相同直接覆盖value,这里的相同指的是hashCode以及equals,否则转向 6);
6)判断table[i] 是否为treeNode,即table[i] 是否是红黑树,如果是红黑树,则直接在树中插入键值对,否则转7);
7)遍历table[i],判断链表长度是否大于8,大于8的话把链表转换为红黑树,在红黑树中执行插入操作,否则进行链表的插入操作;遍历过程中若发现key已经存在直接覆盖value即可;
8)插入成功后,判断实际存在的键值对数量size是否超多了最大容量threshold,如果超过,进行扩容。
我们关注一下这里面最重要的三个方法,hash(),putVal(),resize().
4.1.1 hash() 方法
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
最开始的hashCode: 1111 1111 1111 1111 0100 1100 0000 1010
右移16位的hashCode:0000 0000 0000 0000 1111 1111 1111 1111
异或运算后的hash值: 1111 1111 1111 1111 1011 0011 1111 0101
将hashcode 与 hashcode的低16位做异或运算,混合了高位和低位得出的最终hash值(扰动算法),冲突的概率就小多了。
4.1.2 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;
/**
* 如果当前HashMap的table数组还未定义或者还未初始化其长度,则先通过resize()进行扩容,
* 返回扩容后的数组长度n
*/
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
//通过数组长度与hash值做按位与&运算得到对应数组下标,若该位置没有元素,则new Node直接将新元素插入
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
//否则该位置已经有元素了,我们就需要进行一些其他操作
else {
Node<K,V> e; K k;
//如果插入的key和原来的key相同,则替换一下就完事了
if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k))))
e = p;
/**
* 否则key不同的情况下,判断当前Node是否是TreeNode,如果是则执行putTreeVal将新的元素插入
* 到红黑树上。
*/
else if (p instanceof TreeNode)
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
//如果不是TreeNode,则进行链表遍历
else {
for (int binCount = 0; ; ++binCount) {
/**
* 在链表最后一个节点之后并没有找到相同的元素,则进行下面的操作,直接new Node插入,
* 但条件判断有可能转化为红黑树
*/
if ((e = p.next) == null) {
//直接new了一个Node
p.next = newNode(hash, key, value, null);
/**
* TREEIFY_THRESHOLD=8,因为binCount从0开始,也即是链表长度超过8(包含)时,
* 转为红黑树。
*/
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
break;
}
/**
* 如果在链表的最后一个节点之前找到key值相同的(和上面的判断不冲突,上面是直接通过数组
* 下标判断key值是否相同),则替换
*/
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
if (e != null) { // existing mapping for key
V oldValue = e.value;
//onlyIfAbsent为true时:当某个位置已经存在元素时不去覆盖
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
++modCount;
//最后判断临界值,是否扩容。
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
4.1.3 resize() 方法
HashMap 初始容量是16,扩容方式为2N;
final Node<K,V>[] resize() {
Node<K,V>[] oldTab = table;
int oldCap = (oldTab == null) ? 0 : oldTab.length;
int oldThr = threshold;
int newCap, newThr = 0;
//以前的容量大于0,也就是hashMap中已经有元素了,或者new对象的时候设置了初始容量
if (oldCap > 0) {
//如果以前的容量大于限制的最大容量1<<30,则设置临界值为int的最大值2^31-1
if (oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return oldTab;
}
/**
* 如果以前容量的2倍小于限制的最大容量,同时大于或等于默认的容量16,则设置临界值为以前临界值的2
* 倍,因为threshold = loadFactor*capacity,capacity扩大了2倍,loadFactor不变,
* threshold自然也扩大2倍。
*/
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1; // double threshold
}
/**
* 在HashMap构造器Hash(int initialCapacity, float loadFactor)中有一句代码,this.threshold
* = tableSizeFor(initialCapacity), 表示在调用构造器时,默认是将初始容量暂时赋值给了
* threshold临界值,因此此处相当于将上一次的初始容量赋值给了新的容量。什么情况下会执行到这句?当调用
* 了HashMap(int initialCapacity)构造器,还没有添加元素时
*/
else if (oldThr > 0)
newCap = oldThr;
/**
* 调用了默认构造器,初始容量没有设置,因此使用默认容量DEFAULT_INITIAL_CAPACITY(16),临界值
* 就是16*0.75
*/
else {
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
//对临界值做判断,确保其不为0,因为在上面第二种情况(oldThr > 0),并没有计算newThr
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"})
/**构造新表,初始化表中数据*/
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
//将刚创建的新表赋值给table
table = newTab;
if (oldTab != null) {
//遍历将原来table中的数据放到扩容后的新表中来
for (int j = 0; j < oldCap; ++j) {
Node<K,V> e;
if ((e = oldTab[j]) != null) {
oldTab[j] = null;
//没有链表Node节点,直接放到新的table中下标为【e.hash & (newCap - 1)】位置即可
if (e.next == null)
newTab[e.hash & (newCap - 1)] = e;
//如果是treeNode节点,则树上的节点放到newTab中
else if (e instanceof TreeNode)
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
//如果e后面还有链表节点,则遍历e所在的链表,
else { // 保证顺序
Node<K,V> loHead = null, loTail = null;
Node<K,V> hiHead = null, hiTail = null;
Node<K,V> next;
do {
//记录下一个节点
next = e.next;
/**
* newTab的容量是以前旧表容量的两倍,因为数组table下标并不是根据循环逐步递增
* 的,而是通过(table.length-1)& hash计算得到,因此扩容后,存放的位置就
* 可能发生变化,那么到底发生怎样的变化呢,就是由下面的算法得到.
*
* 通过e.hash & oldCap来判断节点位置通过再次hash算法后,是否会发生改变,如
* 果为0表示不会发生改变,如果为1表示会发生改变。到底怎么理解呢,举个例子:
* e.hash = 13 二进制:0000 1101
* oldCap = 32 二进制:0001 0000
* &运算: 0 二进制:0000 0000
* 结论:元素位置在扩容后不会发生改变
*/
if ((e.hash & oldCap) == 0) {
if (loTail == null)
loHead = e;
else
loTail.next = e;
loTail = e;
}
/**
* e.hash = 18 二进制:0001 0010
* oldCap = 32 二进制:0001 0000
* &运算: 32 二进制:0001 0000
* 结论:元素位置在扩容后会发生改变,那么如何改变呢?
* newCap = 64 二进制:0010 0000
* 通过(newCap-1)&hash
* 即0001 1111 & 0001 0010 得0001 0010,32+2 = 34
*/
else {
if (hiTail == null)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
}
} while ((e = next) != null);
if (loTail != null) {
loTail.next = null;
/**
* 若(e.hash & oldCap) == 0,下标不变,将原表某个下标的元素放到扩容表同样
* 下标的位置上
*/
newTab[j] = loHead;
}
if (hiTail != null) {
hiTail.next = null;
/**
* 若(e.hash & oldCap) != 0,将原表某个下标的元素放到扩容表中
* [下标+增加的扩容量]的位置上
*/
newTab[j + oldCap] = hiHead;
}
}
}
}
}
return newTab;
}
4.2 get()方法
get(Object key)流程,通过传入的key通过hash()算法得到hash值,在通过(n - 1) & hash找到数组下标,如果数组下标所对应的node值正好key一样就返回,否则找到node.next找到下一个节点,看是否是treenNode,如果是,遍历红黑树找到对应node,如果不是遍历链表找到node。我们看一下源码
public V get(Object key) {
Node<K,V> e;
//先通过hash(key)找到hash值,然后调用getNode(hash,key)找到节点
return (e = getNode(hash(key), key)) == null ? null : e.value;
}
/**
* Implements Map.get and related methods
*
* @param hash hash for key
* @param key the key
* @return the node, or null if none
*/
final Node<K,V> getNode(int hash, Object key) {
Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
//通过(n - 1) & hash找到数组对应位置上的第一个node
if ((tab = table) != null && (n = tab.length) > 0 &&
(first = tab[(n - 1) & hash]) != null) {
//如果这个node刚好key值相同,直接返回
if (first.hash == hash &&
((k = first.key) == key || (key != null && key.equals(k))))
return first;
//如果不相同就再往下找
if ((e = first.next) != null) {
//如果是treeNode,就遍历红黑树找到对应node
if (first instanceof TreeNode)
return ((TreeNode<K,V>)first).getTreeNode(hash, key);
//如果是链表,遍历链表找到对应node
do {
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
return e;
} while ((e = e.next) != null);
}
}
return null;
}
5. HashMap的扩容机制
static final int tableSizeFor(int cap) {
int n = cap - 1; //当传递的cap本身就是2的幂次情况下,假如为4,不-1最后得到的结果将是传递的cap的2倍
//总的来说,其实是对一个二进制数依次向右移位,然后与原值取或。其目的对于一个数字的二进制,从第一个不为0的位开始,把后面的所有位都设置成1
n |= n >>> 1; // n右移1位,高位补0,并与n做或运算,然后赋值给n
n |= n >>> 2; // n右移2位,高位补0,并与n做或运算,然后赋值给n
n |= n >>> 4; // n右移4位,高位补0,并与n做或运算,然后赋值给n
n |= n >>> 8; // n右移8位,高位补0,并与n做或运算,然后赋值给n
n |= n >>> 16; // n右移16位,高位补0,并与n做或运算,然后赋值给n
return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}
6. HashMap的线程不安全
HashMap中的方法都是不同步的
-
put方法不同
addEntry方法依然不是同步的,所以导致了线程不安全出现伤处问题,其他类似操作不再说明。 -
resize方法不同步
扩容过程中,会新生成一个新的容量的数组,然后对原数组的所有键值对重新进行计算和写入新的数组,之后指向新生成的数组。
当多个线程同时检测到总数量超过门限值的时候就会同时调用resize操作,各自生成新的数组并rehash后赋给该map底层的数组table,结果最终只有最后一个线程生成的新数组被赋给table变量,其他线程的均会丢失。而且当某些线程已经完成赋值而其他线程刚开始的时候,就会用已经被赋值的table作为原始数组,这样也会有问题。