HashMap简介
- HashMap基于哈希表的Map接口实现,是以key-value存储形式存在,即主要用来存放键值对。HashMap 的实现不是同步的,这意味着它不是线程安全的。它的key、value都可以为null。此外,HashMap中的映射不是有序的。
- HashMap在jdk1.8之前是使用数组+链表的形式进行存储,在jdk1.8及其以后使用数组+链表+红黑树进行实现。其中数组是HashMap的主体,用来区分存取key的hash值。链表和红黑树则是应用于Hash冲突时索引相同的情况,将具有相同的hash数不同的key用链表进行存储。
- 对于key、value则是将其装进一个Node里,以Node.key进行hash计算,存储
- 需要明确的一点是,一切的改变都是为了高效
HashMap的结构
如下是HashMap的源码以及UML类图。
public class HashMap<K,V> extends AbstractMap<K,V>implements Map<K,V>, Cloneable, Serializable
可以看出HashMap继承了AbstarctMap类,实现了三个接口Map<K,V>、Cloneable、Serializable,其实可以发现一个冗余,既然继承的AbstarctMap类已经实现了Map接口,为什么HashMap还要实现Map接口呢。
其实这就是写集合的人的一个失误(其它集合也是这样),写的时候认为这样有价值,但是后来发现没有,但又不值得去更改,所以就留了下来(我也是听将视频的老师说的)。
Cloneable接口没啥好说的,为了可以被克隆,Serializable也是,进行序列化,反序列化。
HashMap的Filed
如下是HashMap中定义的成员变量
- serialVersionUID
序列化版本号
private static final long serialVersionUID = 362498820763181265L;
- DEFAULT_INITIAL_CAPACITY
new HashMap()的时候的初始容量
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
<<位运算,将1进行位运算左移4次,相当于1*2的4次方,结果为16,即HashMap的默认初始容量是16。但是我奇怪地是为啥不直接写个16呢。
- MAXIMUM_CAPACITY
HashMap的最大容量
static final int MAXIMUM_CAPACITY = 1 << 30;
1<<30 的结果是1073741824,为什么是这个数字呢,虽然1<<31的结果是-2147483648,即Integer.MIN_VALUE,但是为什么不能是Integer.MAX_VALUE呢。这就涉及到一个问题,HashMap要求其容量必须是2的整数幂,也就是说CAPACITY只能为2、4、8、16···1 << 30之中的一个。这个问题后面会说明。
- DEFAULT_LOAD_FACTOR
默认负载因子,值为0.75
static final float DEFAULT_LOAD_FACTOR = 0.75f;
- loadFactor可更改的负载因子,衡量HashMap满的程度,表示HashMap的疏密程度,loadFactor = size/capacity,size就是当前HashMap种存入的数据个数,capacity就是当前HashMap的容量
- loadFactor默认值是0.75是基于一种权衡,太大导致查找效率低、太小导致数组利用率低。
- loadFactor = 0.75 表示若当前的存入数据量达到capacity的75%了就表示HashMap太拥挤,需要进行扩容,扩容涉及rehash,复制数据等操作,消耗性能,因此需要避免扩容。
- TREEIFY_THRESHOLD
表示当链表长度达到8的时候,将链表转成红黑树进行存储。(条件1)
static final int TREEIFY_THRESHOLD = 8;
- UNTREEIFY_THRESHOLD
表示当前索引值下的数据个数小于等于6了,就将原来的红黑树结构转换成链表进行存储
static final int UNTREEIFY_THRESHOLD = 6;
- MIN_TREEIFY_CAPACITY
将链表转成红黑树需要满足的第二个条件,当前HashMap的capacity >=64,也就是说需要同时满足当前索引的数据达到8且当前capacity>=64才将该链表转成红黑树,否则进行扩容
static final int MIN_TREEIFY_CAPACITY = 64;
- table;
transient Node<K,V>[] table;
就是HashMap中的数组,存放Node类,即键值对
jdk1.8之前数组类型是Entry<K,V>,因为加入了红黑树所以更改了类型,但他们都实现了一样的接口Map.Entry<K,V>
- entrySet
存放具体元素的集合
transient Set<Map.Entry<K,V>> entrySet;
- size
HashMap存放键值对数
transient int size;
- modCount
记录HashMap的修改次数,不知道有啥用
transient int modCount;
- threshold
调整容量的阈值 threshold = loadFacrot * capacity 当size超过该阈值就会扩容
int threshold;
问题
- HashMap是如何产生hash值进行索引产生的
- hash值产生
判断key是否为null,为null则其hash值为0,否则调用Objects的hashCode方法得到hash值h,将h与h进行无符号右移16位的结果进行异或返回。最后得到该键值对(根据key产生)的hash值。
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
扩展:hash函数的实现还有平方取中法,伪随机数法和取余数法。这三种效率都比较低。而无符号右移16位异或运算效率是最高的。
- 索引产生
因为HashMap主体是个数组table,所以必有索引进行存储数据,其索引产生原理很简单,就是单纯的取余(为了均匀分布,减少碰撞),比如要存入的键值对的hash值为18,而当前容量为16,进行18%16的结果为2,所以该键值对产生的Node存入table[2]中。
但是HashMap的取余操作并不是这样的,这就引出下一个问题,为什么HashMap的容量必须为2的整数幂
- 为什么HashMap的容量必须为2的整数幂
为了高效,以下就是索引的计算,用通过上一问中产生的hash值与capacity-1进行位运算与操作得到索引(对源码稍微修改了一下,为了直观),其结果就是hash%capacity的结果,当然这是在capacity为2的整数幂的前提下。
index = (capacity - 1) & hash
举个栗子
hash = 18,capacity = 16;
18的二进制表示:0001 0010
15的二进制表示:0000 1111
0001 0010
& 0000 1111
-------------------
0000 0010
0000 0010的十进制表示为2
初看感觉挺神奇的,但是仔细想想,不过是把hash值的二进制进行末尾截取,而截取长度就是capacity - 1,为什么是减1呢。因为二进制,capacity为2的整数幂,所以肯定的是capacity的二进制表示中只有一个1,其余全0,要表得到索引,就是在0~capacity-1中选取,上例中即0 ~ 15,即0000 0000 ~0000 1111。所以只需这样就能得到索引。
要知道的是位运算是非常高效的。所以为了数据的均匀分布以及高效,使得HashMap的容量必须为2的整数幂。
当然,如果不考虑效率问题直接求余也可,这样就不要求capacity必须是2的整数幂了。
通过这个问题也知道了,HashMap扩容的时候,容量为其原来的两倍(相当于左移一位),又原有数据的存放位置要么就是当前索引,要么就是当前索引+原数组容量。
- 如果创建HashMap时,输入数组长度不为2的整数幂会怎样
首先结果就是HashMap会创建一个大于且离那个数最近的2的幂数
源码
// 只传初始容量的构造器
public HashMap(int initialCapacity) {
this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
// 传初始容量和负载因子的构造器
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);
}
// 调整初始容量
static final int tableSizeFor(int cap) {
int n = -1 >>> Integer.numberOfLeadingZeros(cap - 1);
return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}
// Integer中的方法,返回无符号整型i的最高非零位前面的0的个数,包括符号位在内;
public static int numberOfLeadingZeros(int i) {
// HD, Count leading 0's
if (i <= 0)
return i == 0 ? 32 : 0;
int n = 31;
if (i >= 1 << 16) { n -= 16; i >>>= 16; }
if (i >= 1 << 8) { n -= 8; i >>>= 8; }
if (i >= 1 << 4) { n -= 4; i >>>= 4; }
if (i >= 1 << 2) { n -= 2; i >>>= 2; }
return n - (i >>> 1);
}
关于numberOfLeadingZeros可以看这里向天葵,我的jdk是12,应该是改进了,不过大概意思差不多。其实我看其它博客讲的HashMap中的这个得到大于等于该数的最近2的整数幂的代码跟我这也不一样,也是应为我这是jdk12的原因吧。
- 为什么要进行cap - 1:为了防止cap已经是2的幂,不然的话,若输入为16则会得到32容量的HashMap
- 关于numberOfLeadingZeros,我们知道8 ~ 15的二进制表示,他们的无符号最高非零位前面的0的个数都一样为28,所以可以知道的是在2 ^ n ~ 2 ^ (n+1) - 1之间的数其无符号最高非零位前面的0的个数都一样。
- int n = -1 >>> Integer.numberOfLeadingZeros(cap - 1);的意义,得到了无符号最高非零位前面的0的个数,又有-1的二进制表示为其补码,即32个1,比如cap = 10,则无符号最高非零位前面的0的个数为28,n的二进制就是-1>>>28,表示为0000 1111(前面24个0省略了),即为15,最后有个+1操作,得到capacity为16。所以该句结果为将二进制表示的最高位非零后的值变为1。
- 哈希碰撞
数组长度有限的时候,即便我们使用了很好的hash函数有效减少了不同数据产生相同哈希值,或者是不同hash值在进行索引定位的时候,由于数组长度的限制,定位到同一位置,这就出现了哈希碰撞。这就是为什么要使用数组+链表+红黑树结构进行存储的原因,数组的同一位置,通过链表+红黑树的方式对不同键值对数据进行区分,如果键值相同,则替换其value,键值不同则在链表或红黑树中添加。 - 链表与红黑树的转换
- 当链表长度达到8且此时capacity >= 64 时,链表将转成红黑树
- 当红黑树元素低于6时,转为链表
- 为什么阈值为8呢
源码注释:
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:
因为树节点的大小大约是普通节点的两倍,所以我们只在箱子包含足够的节点时才使用树节点(参见TREEIFY_THRESHOLD)。当它们变得太小(由于删除或调整大小)时,就会被转换回普通的桶。在使用分布良好的用户hashcode时,很少使用树箱。理想情况下,在随机哈希码下,箱子中节点的频率服从泊松分布
(http://en.wikipedia.org/wiki/Poisson_distribution),默认调整阈值为0.75,平均参数约为0.5,尽管由于调整粒度的差异很大。忽略方差,列表大小k的预期出现次数是(exp(-0.5)*pow(0.5, k)/factorial(k))。
第一个值是:
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
可以看到当hashCode离散性很好的时候,通过泊松分布计算,当元素达到8的时候概率已经很小了。但是若离散型不好,就会导致不均匀的数据分布,出现一个索引位置有很多元素,而其余索引元素较少的情况,为了增加检索效率,所以引入红黑树。
当然前面是官方说法,还有一种说法是
红黑树的平均查找长度是log(n),如果长度为8,平均查找长度为log(8)=3,链表的平均查找长度为n/2,当长度为8时,平均查找长度为8/2=4,这才有转换成树的必要;链表长度如果是小于等于6,6/2=3,而log(6)=2.6,虽然速度也很快的,但是转化为树结构和生成树的时间并不会太短。