一、Map的概念
Map:一个集合,一种依照键(key)存储元素的容器,键(key)很像下标,在List中下标是整数。在Map中键(key)可以使任意类型的对象。Map中不能有重复的键(Key),每个键(key)都有一个对应的值(value)。一个键(key)和它对应的值构成map集合中的一个元素。Map中的元素是两个对象,一个对象作为键,一个对象作为值。键不可以重复,但是值可以重复。Map本身是一个接口,要使用Map需要通过子类进行对象实例化。Map接口的常用子类有:HashMap、HashTable、TreeMap、ConcurrentHashMap。
- key值不允许重复,如果重复,则会把对应value值更新;
- key和value都允许为null,key为null有且只有一个。
二、关于HashMap
HashMap:Java中最重要的一种数据结构,用于存储Key-Value键值对的集合,每一个键值对也叫做Entry。这些个键值对(Entry)分散存储在一个数组当中,这个数组就是HashMap的主干。HashMap数组每一个元素的初始值都是Null。
HashMap由数组+链表组成的,数组是HashMap的主体,链表则是主要为了解决哈希冲突而存在的,如果定位到的数组位置不含链表(当前Entry的next指向null),那么查找,添加等操作很快,仅需一次寻址即可;如果定位到的数组包含链表,对于添加操作,其时间复杂度为O(n),首先遍历链表,存在即覆盖,否则新增;对于查找操作来讲,仍需遍历链表,然后通过key对象的equals方法逐一比对查找。所以性能考虑,HashMap中的链表出现越少,性能才会越好。
HashMap的常用方法是Put和Get。
Put方法原理:当我们给put()方法传递键和值时,我们先对键调用hashCode()方法,返回的hashCode用于找到bucket位置来储存Entry对象。比如调用 hashMap.put("zhangsan", 0) ,插入一个Key为“zhangsan"的元素,这时候我们需要利用一个哈希函数来确定Entry的插入位置(index):index = Hash(“zhangsan”)。假定最后计算出的index是3,那么结果如下:
因为HashMap的长度是有限的,当插入的Entry数量逐渐增加时,Hash函数必定会出现哈希冲突。比如下面这样:
众所周知,哈希冲突是不可能完全避免的,但可以用链表来解决hash冲突。HashMap数组的每一个元素不止是一个Entry对象,也是一个链表的头节点。每一个Entry对象通过Next指针指向它的下一个Entry节点。当新来的Entry映射到冲突的数组位置时,只需要插入到对应的链表即可(新来的Entry节点插入链表时,使用的是“头插法”):
Get方法原理:使用Get方法根据Key来查找Value的时候,将输入的Key做一次Hash映射,得到对应的index。当获取对象时,通过键对象的equals()方法找到正确的键值对,然后返回值对象。具体来说当我们调用get()方法,HashMap会使用键对象的hashcode找到bucket位置,找到bucket位置之后,会调用keys.equals()方法去找到链表中正确的节点,最终找到要找的值对象。比如要查找的Key为"zhangsan":index = Hash(“zhangsan”),首先查看的是头节点Entry5,Entry5的Key是lisi,不匹配。继续查看的是Next节点Entry1,Entry1的Key是zhangsan,查找成功。
HashMap的长度:HashMap默认初始长度为16,每次自动扩展或手动初始化时长度必须是2的幂。此做法是为了服务于从key映射到index的Hash算法。从Key映射到HashMap数组的对应位置,会用到Hash函数。如果将Key的HashCode值与HashMap长度Length做取模运算,即index = HashCode(Key) % Length,此法实现简单但效率极低。故一般采用位运算方法:
index = HashCode(Key) & (Length - 1)
假定zhangsan的hashcode计算值为十进制的6,二进制的0000 0110。HashMap长度是默认的16,计算Length-1的结果为十进制的15,二进制的0000 1111。将上述两个值作与运算:0000 0110 & 0000 1111 = 0000 0110,转化为10进制为6。所以可得到 index=6。
为什么是16或者2的幂?假定HashMap长度为11,此时有:
HashCode: 0000 0110 0000 0101 0000 0010 0000 0111
Length-1: 0000 1010 0000 1010 0000 1010 0000 1010
index: 0000 0010 0000 0000 0000 0010 0000 0010
由上述结果可知,当HashMap长度为11的时候, 0000 0010 出现了3次,即有些index结果的出现几率会更大,这显然不符合Hash算法均匀分布原则。而当长度16或者其他2的幂,Length-1的值是所有二进制位全为1,这种情况下,index的结果等同于HashCode后几位的值。只要输入的HashCode本身分布均匀,Hash算法的结果就是均匀的。
当数组长度为16时,即为2的n次方时,2n-1得到的二进制数的每个位上的值都为1(比如(24-1)2=1111),这使得在低位上&时,得到的和原hash的低位相同,加之hash(int h)方法对key的hashCode的进一步优化,加入了高位计算,就使得只有相同的hash值的两个值才会被放到数组中的同一个位置上形成链表。
所以说,当数组长度为2的n次幂的时候,不同的key算得得index相同的几率较小,那么数据在数组上分布就比较均匀,也就是说碰撞的几率小,相对的,查询的时候就不用遍历某个位置上的链表,这样查询效率也就较高了。
HashMap的头插法与尾插法:JDK1.8之前是头插,JDK1.8是尾插法。如果遍历整个链表发现没有相应的key值,则会调用addEntry方法在链表添加一个Entry,问题在于addEntry方法是如何插入链表的,addEntry方法源码如下:
JDK1.6源码:这里构造了一个新的Entry对象(构造方法的最后一个参数传入了当前的Entry链表),然后直接用这个新的Entry对象取代了旧的Entry链表,可以猜测这应该是头插法。
void addEntry(int hash, K key, V value, int bucketIndex) {
Entry<K,V> e = table[bucketIndex];
table[bucketIndex] = new Entry<K,V>(hash, key, value, e);
if (size++ >= threshold)
resize(2 * table.length);
}
接着再看一下Entry的构造方法:从构造方法中的next=n可以看出确实是把原本的链表直接链在了新建的Entry对象的后边,可以看出是插入头部。
Entry( int h, K k, V v, Entry<K,V> n) {
value = v;
next = n;
key = k;
hash = h;
}
JDK1.8源码:从这段代码中可以很显然地看出当到达链表尾部(即p是链表的最后一个节点)时,e被赋为null,会进入这个分支代码,然后会用newNode方法建立一个新的节点插入尾部。
//e是p的下一个节点
if ((e = p.next) == null) {
//插入链表的尾部
p.next = newNode(hash, key, value, null);
//如果插入后链表长度大于8则转化为红黑树
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
break;
}
在jdk1.6中,HashMap中有个内置Entry类,它实现了Map.Entry接口;而在jdk1.8中,这个Entry类不见了,变成了Node类,也实现了Map.Entry接口,与jdk1.6中的Entry是等价的。
为什么开始是头插法,而后更改为尾插法?
一是为了避免老版本hashmap在并发Resize时会出现的死循环问题。再者HashMap的作者认为,后插入的Entry被查找的可能性更大。即所谓的热点数据(新插入的数据可能会更早用到),但这其实只是出于部分角度考虑,因为JDK1.7中Rehash过程中旧链表迁移新链表的时候,如果在新表的数组索引位置相同,则链表元素会倒置(就是因为头插) 所以最后的结果还是打乱了插入的顺序,故换成尾插。
三、高并发下的HashMap
Rehash是HashMap在扩容的一个步骤。HashMap的容量是有限的。当经过多次元素插入,使得HashMap达到一定饱和度时,Key映射位置发生冲突的几率会逐渐提高。这时候,HashMap需要扩展它的长度,也就是进行Resize。
Resize:影响发生Resize的因素有两个:
- Capacity:HashMap的当前长度。上文中提到过HashMap的长度是2的幂。
- LoadFactor:HashMap负载因子,默认值为0.75f。 负载因子衡量的是一个散列表的空间的使用程度,负载因子越大表示散列表的装填程度越高,反之愈小。对于使用链表法的散列表来说,查找一个元素的平均时间是O(1+a),因此如果负载因子越大,对空间的利用更充分,然而后果是查找效率的降低;如果负载因子太小,那么散列表的数据将过于稀疏,对空间造成严重浪费。
HashMap是否进行Resize:HashMap.Size >= Capacity * LoadFactor
Resize并非简单的将长度扩大,需要经过以下两个步骤:
- 扩容:创建一个新的Entry空数组,长度是原数组的2倍。
- Rehash:遍历原Entry数组,把所有的Entry重新Hash到新数组。Entry数组长度扩大以后,Hash的规则也随之改变。
由Hash公式 index = HashCode(Key)& (Length - 1)可知,当长度Length发生改变,则Hash结果也可能会随之改变,比如下图:
Resize前的HashMap:
Resize后的HashMap:
Rehash源码:
/**
* Transfers all entries from current table to newTable.
*/
void transfer(Entry[] newTable, boolean rehash) {
int newCapacity = newTable.length;
for (Entry<K,V> e : table) {
while(null != e) {
Entry<K,V> next = e.next;
if (rehash) {
e.hash = null == e.key ? 0 : hash(e.key);
}
int i = indexFor(e.hash, newCapacity);
e.next = newTable[i];
newTable[i] = e;
e = next;
}
}
}
HashMap由于是非线程安全的,故上述操作只能发生在单线程中,多线程下会带来诸多问题。
HashMap的线程安全
HashMap在多线程环境下,1.7 会产生死循环、数据丢失、数据覆盖的问题,1.8 中会有数据覆盖的问题。
JDK1.7下的死循环:假定一个HashMap已经到了Resize的临界点。此时有两个线程A和B,在同一时刻对HashMap进行Put操作:
此时达到Resize条件,两个线程各自进行Reszie的第一步,也就是扩容:
此时,A、B两个线程都到了ReHash的步骤,review一下ReHash的代码:
假如此时线程B遍历到Entry3对象,刚执行完红框里的这行代码,线程就被挂起。对于线程B来说:e=Entry3,next=Entry2这时候线程A顺利执行Rehash,当ReHash完成后,结果如下(图中的e和next,代表线程B的两个引用):
至此,线程A Rehash完毕。接下来线程B恢复,继续执行属于它的ReHash。线程B刚才的状态是:e=Entry3,next=Entry2
当执行到上图代码中红色方框中时,由于刚才线程A进行Rehash时对于Entry3的结果是3,故此时 i=3 。
代码继续向下执行之上图中红色方框时,Entry3放入了线程B的数组下标为3的位置,此时e指向了Entry2,而此时 e 和 next 的指向如下图所示:
接着进行第二轮循环,再次执行至红色方框代码:e=Entry2,next=Entry3
接下来执行下面的三行,用头插法把Entry2插入到了线程B的数组的头结点:
此时,整体状况为:
第三次循环,执行至红色方框:e=Entry3,next=Entry3.next=null
最后一步,当执行至 e.next = newTable[ i ] 时有:
newTable[ i ] = Entry2;
e=Entry3;
Entry2.next=Entry3;
Entry3.next=Entry2;
至此,环形链表出现。如下图:
此时,问题还没有直接产生。当调用Get查找一个不存在的Key,而这个Key的Hash结果恰好等于3的时候,由于位置3带有环形链表,所以程序将会进入死循环。
JDK1.8下的数据覆盖:JDK1.8以后 HashMap的数据结构从单纯的数组加链表结构变成数组+链表+红黑树。所有元素都是Node<k,v>,当链表长度大于8时将转化为红黑树。其中Node是HashMap的一个内部类,实现Map。Entry接口本质是一个KV映射。根据上面JDK1.7出现的环形链表问题,在JDK1.8中已经得到了很好的解决,1.8的源码找不到transfer
函数,因为JDK1.8直接在Resize
函数中完成了数据迁移。
JDK1.8源码:
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
if ((p = tab[i = (n - 1) & hash]) == null) // 如果没有hash碰撞则直接插入元素
tab[i] = newNode(hash, key, value, null);
else {
Node<K,V> e; K k;
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
else if (p instanceof TreeNode)
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
else {
for (int binCount = 0; ; ++binCount) {
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
break;
}
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;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
++modCount;
if (++size > threshold)// 多个线程走到这,可能重复resize()
resize();
afterNodeInsertion(evict);
return null;
}
其中第5行代码是判断是否出现hash碰撞,假设两个线程A、B都在进行put操作,并且hash函数计算出的插入下标是相同的,当线程A执行完第5行代码后由于时间片耗尽导致被挂起,而线程B得到时间片后在该下标处插入了元素,完成了正常的插入,然后线程A获得时间片,由于之前已经进行了hash碰撞的判断,所有此时不会再进行判断,而是直接进行插入,这就导致了线程B插入的数据被线程A覆盖了,从而线程不安全。
除此之前,还有就是代码的第37行处有个++size,我们这样想,还是线程A、B,这两个线程同时进行put操作时,假设当前HashMap的size大小为10,当线程A执行到第37行代码时,从主内存中获得size的值为10后准备进行+1操作,但是由于时间片耗尽只好让出CPU,线程B拿到CPU还是从主内存中拿到size的值10进行+1操作,完成了put操作并将size=11写回主内存,然后线程A再次拿到CPU并继续执行(此时size的值仍为10),当执行完put操作后,还是将size=11写回内存,此时,线程A、B都执行了一次put操作,但是size的值只增加了1,所有说还是由于数据覆盖又导致了线程不安全。
Rehash的代价:ReHash的代价其实并不小,我们平常理解中的哈希表是" 以空间换时间的一种数据结构 "。理所当然的认为哈希表就是以牺牲空间代价来换取时间。然而,ReHash的过程其实是空间和时间的都是非常耗费的。ReHash的过程实质是一个动态扩容的过程。扩容过程中,散列表内部要重新Resize(new)一个更大的数组,然后把原来数组的内容拷贝到新数组,并进行重新散列,new出来的这个更大的新数组容量有多大,一般来说新数组的大小会设置成原数组双倍大小。
" 以空间换时间 ",其实是指哈希具有O(1)复杂度的数据检索效率,但它受填充因子影响,空间开销通常很大,空间利用率不高。因此哈希表适用于读操作频繁,写操作较少应用场景,比如把哈希表当做缓存容器。
1.8中红黑树的由来:假设现在HashMap集合中大多数的元素都放到了同一个桶里(由hash值计算而得的桶的位置相同),那么这些元素就在这个桶后面连成了链表。现在需要查询某个元素,那么此时的查询效率就很慢了,它是在做链表查询( O(N) 的查询效率)。为了解决这个问题,就引入了红黑树( log(n) 的查询效率):当链表到达一定长度时就在链表的后面创建红黑树。