HashMap的学习笔记
1、HashMap简介
- HashMap的一个无序的Key-Value键值对数据结构
- 在JDK1.6和1.7中,HashMap采用位桶和链表实现,在JDK1.8中采用位桶+链表+红黑树实现
- HashMap的初始容量为16,且每次扩容均为2的次幂
- HashMap的负载因子为0.75
- HashMap的线程不安全的
- 当HashMap某一个桶中的链表个数大于8时(既阈值超过8),链表自动转为红黑树
HashMap底层为Node节点数组
Node(int hash, K key, V value, Node<K,V> next) {
this.hash = hash;
this.key = key;
this.value = value;
this.next = next;
}
HashMap是通过”拉链法”实现的哈希表。它包括几个重要的成员变量: table, size, threshold, loadFactor, modCount。
①table是一个Entry[]数组类型,而Entry实际上就是一个单向链表。哈希表的”key-value键值对”都是存储在Entry数组中的。
②size是HashMap的大小,它是HashMap保存的键值对的数量。
③threshold是HashMap的阈值,用于判断是否需要调整HashMap的容量。threshold的值=”容量*负载因子”,当HashMap中存储数据的数量达到threshold时,就需要将HashMap的容量加倍。
④loadFactor就是负载因子。
⑤modCount是用来实现fail-fast机制的。
2、HashMap的构造函数
HashMap提供了四种构造函数
①public HashMap(int initialCapacity, float loadFactor)
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);
}
可以指定初始容量和负载因子的构造函数
②public HashMap
public HashMap(int initialCapacity) {
this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
指定初始化容量,和使用默认的负载因子0.75,this调用的是①中的构造函数
③public HashMap()
public HashMap() {
this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
}
使用默认容量和默认的负载因子
④public HashMap(Map<? extends K, ? extends V> m
public HashMap(Map<? extends K, ? extends V> m) {
this.loadFactor = DEFAULT_LOAD_FACTOR;
putMapEntries(m, false);
}
包含子Map的构造函数
3、HashMap的原理
HashMap采用的是链地址法(拉链法)
首先初始的是一个大小为16的数组
我们这里有不同的数组要进行存储。如12,28,91,31,108
我们定义一个最简单的Hash运算 y = key % n (key代表需要存储的数,n代表数组的大小)
12 % 16 = 12,我们就把这个数据放置在数组位置12上
31 % 16 = 15,同上余15,那我们就防止在15上面
28 % 16 = 12, 这时我们发现余数和上面第一个是一样的,这时候就发生了Hash碰撞,但是这个没问题,因为HashMap是运行Hash碰撞的,而且HashMap的底层结构是单向链表,我们只需要把28链接在12后面即可
Tips
当一个位置的链表长度(如果数组12这条链表后面挂载超过8个数据,那么这条链表就会转换成红黑树)
当各个位置存在数据的数量大于容量负载因子时,HashMap便会产生扩容
4、拉链法的数据结构
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 int hashCode() {
return Objects.hashCode(key) ^ Objects.hashCode(value);
}
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;
}
......
}
在Node类中,我们可以发现只存在一个next节点,由此证明Node是单链结构,但是还需要注意的是
1、Node类重写了equals方法,这个方法就是用来判断当Hash值一样时,如何判断是否是我们所想要取得的那个值
判断流程
①判断两者地址是否相同,相同则为所寻找的值
②判断目标对象类型是不是Map.Entry,如果是判断两者的key是否相等,再判断Vaule是否相等,都想得则为寻找的值,两者有其中之一不满足则返回false
2、Hashcode方法则就是同时对Key和Value进行hash操作,然后取异或(^)相同取0,不同取1
5、HashMap的初始化声明
1、DEFAULT_INITIAL_CAPACITY
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
声明默认容量 <<
是位运算符,1 << 4代表将二进制数1左移4位
1的二进制为1,左移4位为1000,再转换为十进制为16
2、MAXIMUM_CAPACITY
static final int MAXIMUM_CAPACITY = 1 << 30;
声明最大容量为2的30次方
3、DEFAULT_LOAD_FACTOR
static final float DEFAULT_LOAD_FACTOR = 0.75f;
默认的负载因子0.75,初始的默认容量为16,既160.75 = 12 ,在数组中有*超过12个位置有数据时,HashMap既需要扩容,那么为什么负载因子设定为0.75而不设定为1呢?
到这里就不得不介绍一下HashMap最基本的原理Hash操作
Hash,一般翻译做“散列”,也有直接音译为“哈希”的。那么哈希函数的是什么样的?大概就是 value = hash(key),我们希望key和value之间是唯一的映射关系。
因为Hash算法的问题,是存在两个不同的key经过Hash算法后获得相同的Hash的值的
因为HashMap是拉链法,所有相同的Hash值的数据会挂载在数组的同一个位置
在这里12,28,108,140都是相同的hash值(y = key % n )
当不断出现hash值为12的数,就会不断的挂载在12上面,会导致这条链表越来越长,当超过8个时会转换成红黑树,但是虽然转换为红黑树,但是其存储结构任然为链表
链表我们知道有一个特性,删除和添加操作时间复杂度都非常低,但是对于查找来说,会花费很多的时间
因为链表需要从第一个不断的查找到所需要的那个值,无法进行快速的访问
所以减小Hash的碰撞,既在上面这个例子中,我能减少12这个hash值出现的频率,那么我们12这个位置挂载的数据不就会减少了吗?链表的长度缩短,链表的查找时间就会大大提升
既然我们已经明白了只要能减小碰撞,那么就可以使HashMap保持在一个比较快速的情况下
那么现在我们举个例子
情况1:负载因子为0.75,存储1到16,16个数
情况2:负载因子为0.25,存储1到16,16个数
情况3:负载因子为1.00,存储1到16,16个数
HashMap容量为16,假设花费空间为16b
①情况1,16 * 0.75 = 12 ,在存储前12个数时,分布在12个地方,在存储第13个数时,HashMap的容量开始扩容,变为32个,前12个已经存储过的数字位置不变,剩余4个可能会随机分布在剩下的(32-12)20个位置,此时花费空间为32b
总结:花费空间位32,4个数据分布在20个位置,Hash碰撞概率较低
②情况2,16 * 0.25 = 4,在存储钱4个数时,分布在4个地方,在存储第4到8个数时,HashMap开始扩容,容量变为32,4个数据可能分布在(32 - 4) 28个位置,在存储第8到12个数时,HashMap开始扩容,容量变为64,4个数据可能分布在(64 - 8) 56个位置,在存储第12到16个数时,HashMap开始扩容,容量变为128,4个数据可能分布在(128 - 12) 112个位置,空间花费128b
总结:空间花费128b,数据分布较广,Hash碰撞概率极低,但是空间花费很大
③情况3,16 * 1 = 16 ,存储前12个数据时,分布在12个位置,在存储13-16四个数据时,因为没有扩容,位置还是4个,所以4个数据分布在4个位置,碰撞概率较高
总结:空间花费16b,数据集中,碰撞概率大
所以总的来说,如果负载因子很大的话,链表会很长,查询会很费时间,如果很小的话,又会造成空间的大量浪费
0.75是一个在空间和时间上面折中的考虑
JDK1.8HashMap源码中也有介绍
* Because TreeNodes are about twice the size of regular nodes, we
* use them only when bins contain enough nodes to warrant use
* (see TREEIFY_THRESHOLD). And when they become too small (due to
* removal or resizing) they are converted back to plain bins. In
* usages with well-distributed user hashCodes, tree bins are
* rarely used. Ideally, under random hashCodes, the frequency of
* nodes in bins follows a Poisson distribution
* (http://en.wikipedia.org/wiki/Poisson_distribution) with a
* parameter of about 0.5 on average for the default resizing
* threshold of 0.75, although with a large variance because of
* resizing granularity. Ignoring variance, the expected
* occurrences of list size k are (exp(-0.5) * pow(0.5, k) /
* factorial(k)). The first values are:
*
* 0: 0.60653066
* 1: 0.30326533
* 2: 0.07581633
* 3: 0.01263606
* 4: 0.00157952
* 5: 0.00015795
* 6: 0.00001316
* 7: 0.00000094
* 8: 0.00000006
* more: less than 1 in ten million
翻译过来就是
理想状态下,在随机哈希值的情况,对于loadfactor = 0.75 ,虽然由于粒度调整会产生较大的方差,桶中的Node的分布频率服从参数为0.5的泊松分布。
stackoverflow一个回答为
一个bucket空和非空的概率为0.5,通过牛顿二项式等数学计算,得到这个loadfactor的值为log(2),约等于0.693. 同回答者所说,可能小于0.75 大于等于log(2)的factor都能提供更好的性能,0.75这个数说不定是 pulled out of a hat。
4、TREEIFY_THRESHOLD
static final int TREEIFY_THRESHOLD = 8;
我翻译过来是树化阈值
也就是说当链表的长度超过8时,会转换成红黑树
5、UNTREEIFY_THRESHOLD
static final int UNTREEIFY_THRESHOLD = 6;
当执行resize操作时,当桶中bin的数量少于UNTREEIFY_THRESHOLD时使用链表来代替树。默认值是6
6、MIN_TREEIFY_CAPACITY
static final int MIN_TREEIFY_CAPACITY = 64;
当桶中的bin被树化时最小的hash表容量。(如果没有达到这个阈值,即hash表容量小于MIN_TREEIFY_CAPACITY,当桶中bin的数量太多时会执行resize扩容操作)这个MIN_TREEIFY_CAPACITY的值至少是TREEIFY_THRESHOLD的4倍。
7、table
transient Node<K,V>[] table;
HashMap中初始化的数组
8、size
transient int size;
size为HashMap中存储数据的数量
9、modCount
transient int modCount;
modCount是Fail-Fast判断的一个重要方式Fial-Fast机制详解
10、entrySet
transient Set<Map.Entry<K,V>> entrySet;
返回全部的Map集合
6、HashMap经典方法解释
1、Get方法
public V get(Object key) {
Node<K,V> e;
return (e = getNode(hash(key), key)) == null ? null : e.value;
}
我们发现在get方法中,并不是直接通过get方法获取到这个Map,是调用了getNode这个方法
那我们便来看看getNode方法是干什么的
final Node<K,V> getNode(int hash, Object key) {
Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
if ((tab = table) != null && (n = tab.length) > 0 &&
(first = tab[(n - 1) & hash]) != null) {
if (first.hash == hash && // always check first node
((k = first.key) == key || (key != null && key.equals(k))))
return first;
if ((e = first.next) != null) {
if (first instanceof TreeNode)
return ((TreeNode<K,V>)first).getTreeNode(hash, key);
do {
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
return e;
} while ((e = e.next) != null);
}
}
return null;
}
方法乍一看很复杂,我们便一层一层来看
①首先,我们来看看声明情况
Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
声明并没有什么复杂的,声明了一个数组tab和一个节点first,e,还有一些int型变量
②第一层if
if ((tab = table) != null && (n = tab.length) > 0 &&
(first = tab[(n - 1) & hash]) != null) {
首先映入眼帘的是这个table其次呢是这个tab[(n - 1) & hash])
这个table其实是Hashmap的初始化数组,上来便把这个数组赋值给tab,然后判断是否为空,而且判断length是否大于0
但是呢,这里面的重点是tab[(n - 1) & hash])是什么意思
tab我们已经知道了,是HashMap里面初始化的数组,它的每一个下标都代表着一条单链
我们假设访问tab[1],如果tab[1]上面有数据的话,我们就能访问到这条单链,通过遍历这个单链,我们就能寻找到这条单链上的全部数据
那么为什么 (n - 1) & hash 能代替数组下表呢
首先,我们先举几个例子
9 % 4 = 1 , 21 % 16 = 5
12的二进制为1001,3的二进制为0011,两者进行于操作(只有两者都为1,才为1) 得到的是0001 既1
21的二进制为10101,15的二进制位01111,两者也同样进行于操作,得到00101 既5
这时我们就会发现当一个数和2的次幂进行求余运算时, 当 lenth = 2n 时,X % length = X & (length - 1)
所以说,为什么HashMap的容量设计为2的次幂,因为于运算的速度大大快于求余运算,通过2的次幂的大小,我们可以快速的寻找到目标位置
那么get方法后面的代码我们也就能很容易的理解了
7、HashMap面试问题总结
1、你了解重新调整HashMap大小存在什么问题吗?
答:在多线程环境下,如果两个线程都发现HashMap需要重新调整大小了,它们会同时试着调整大小。在调整大小的过程中,存储在链表中的元素的次序会反过来,因为移动到新的bucket位置的时候,HashMap并不会将元素放在链表的尾部,而是放在头部,这是为了避免尾部遍历(tail traversing)。如果条件竞争发生了,那么就死循环了。