哈希表
在介绍HashMap之前,先介绍一下哈希表的概念。
哈希表(Hash table,也叫做散列表),是根据关键码值(Key Value)而直接进行访问的数据结构。也就是说,它通过把关键码值映射到表中一个位置来访问记录,以加快查找的速度。这个映射函数叫做散列函数,存放记录的数组叫做散列表。
记录的存储位置 = f(关键字)
这里的对应关系 f 称为散列函数,又称为哈希(Hash函数)函数,采用散列技术将记录存储在一块连续的存储空间中,这块连续存储空间称为散列表或哈希表(Hash Table)。
哈希表就是把key通过一个固定的算法函数即将所谓的哈希函数转换成一个整型数字,然后就将改数字对数组的长度进行取余,取余结果就当作数组的下标,将value存储在以该数字为下标的数组空间里。而当使用哈希表进行查询的时候,就是再次使用哈希函数将key转换为对应的数组下标,并定位到该空间获取value,如此一来,就可以充分利用到数组的定位性能进行数据定位。
数组的特点是:寻址容易、插入和删除困难
链表的特点是:寻址困难、插入和删除容易
那么我们能不能综合两者的特性,做出一种寻址容易,插入删除也容易的数据结构?答案是肯定的,这就是我们提到的哈希表,哈希表有多种不同的实现方法,接下来解释的是最常用的一种方法----拉链法,可以理解为“链表的数组”:
左边很明显是个数组,数组的每个成员包括一个指针,指向一个链表的头,当然这个链表可能为空,也可能元素很多。我们根据元素的一些特征把元素分配到不同的链表中去,也是根据这些特征,找到正确的链表,再从链表中找出这个元素。
初始HashMap
之前讲了ArrayList、LinkedList,就这两者而言,反映的是两种思想:
(1)ArrayList以数组形式实现,顺序插入、查找快,插入、删除慢
(2)LinkedList以链表形式实现,顺序插入、查找较慢,插入、删除较快
那么是否有一种数据结构能够结合上面两种的优点呢?答案就是HashMap
HashMap是一种非常常见、方便和有用的集合,是一种键值对(K-V)形式的存储结构。
四个关注点在HashMap上的答案
关 注 点 | 结 论 |
HashMap是否允许空 | Key和Value都允许为空 |
HashMap是否允许重复数据 | Key重复会覆盖、Value允许重复 |
HashMap是否有序 | 无序,特别说明这个无序指的是遍历HashMap的时候,得到的元素的顺序基本不可能是put的顺序 |
HashMap是否线程安全 | 非线程安全 |
源码解析
HashMap的存储结构如上图所示(JDK1.8以后,当链表长度大于8的时候,链表会变为红黑树)。
看一下HashMap的存储单元Entry的源码结构:
static class Node<K,V> implements Map.Entry<K,V> { final int hash; final K key; V value; Node<K,V> next; ... }
HashMap是采用Entry数组来存储key-value对,每一个键值对组成了一个Entry实体,Entry类实际上是一个单向的链表结构,它里面包含next指针、key的hash值、value、key,其中next指针可以连接下一个Entry实体,HashMap是以此来解决hash冲突的问题的,因为HashMap是按照key的hash值来计算Entry在HashMap中存储的位置的,如果hash值相同,而key不相等,那么就用链表来解决这种hash冲突。
构造函数
HashMap() //无参构造方法 HashMap(int initialCapacity) //指定初始容量的构造方法 HashMap(int initialCapacity, float loadFactor) //指定初始容量和负载因子 HashMap(Map<? extends K,? extends V> m) //指定集合,转化为HashMap
HashMap的KPI
void clear() Object clone() boolean containsKey(Object key) boolean containsValue(Object value) Set<Entry<K, V>> entrySet() V get(Object key) boolean isEmpty() Set<K> keySet() V put(K key, V value) void putAll(Map<? extends K, ? extends V> map) V remove(Object key) int size() Collection<V> values()
HashMap的签名
public class HashMap<K,V> extends AbstractMap<K,V> implements Map<K,V>, Cloneable, Serializable {
从签名可以看出:
(1)HashMap继承于AbstractMap类,实现了Map接口。Map是“key-value键值对”接口,AbstractMap实现了“键值对”的通用函数接口。
(2)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机制的
添加方法(JDK1.7)
public V put(K key, V value) { if (table == EMPTY_TABLE) { //是否初始化 inflateTable(threshold); } if (key == null) //放置在0号位置 return putForNullKey(value); int hash = hash(key); //计算hash值 int i = indexFor(hash, table.length); //计算在Entry[]中的存储位置 for (Entry<K,V> e = table[i]; e != null; e = e.next) { Object k; if (e.hash == hash && ((k = e.key) == key || key.equals(k))) { V oldValue = e.value; e.value = value; e.recordAccess(this); return oldValue; } } modCount++; addEntry(hash, key, value, i); //添加到Map中 return null; }
在该方法中,添加键值对时,首先进行的是table是否初始化的判断,如果没有进行初始化(分配空间也就是Entry[]数组的长度为0)。然后进行key是否为null的判断,如果key == null,放置在Entry[]的0号位置。然后计算Entry[]数组的存储位置,判断该位置上是否已经有元素,如果已经有元素存在,则遍历该Entry[]数组上的单链表,判断key是否存在,如果key已经存在,则用新的value值,替换旧的value值,并将旧的value值返回;如果key不存在于HashMap中,程序将对应的key-value值,生成Entry[]实体,添加到HashMap中的Entry[]数组中。
注意:JDK1.6和以前的版本,初始化时的容量是16,在JDK1.7以后,采用懒加载的方式,到真正放入元素的时候再初始化长度,长度也是16(使用无参构造器时)。
addEntry()
/* * hash hash值 * key 键值 * value value值 * bucketIndex Entry[]数组中的存储索引 * / void addEntry(int hash, K key, V value, int bucketIndex) { if ((size >= threshold) && (null != table[bucketIndex])) { resize(2 * table.length); //扩容操作,将数据元素重新计算位置后放入newTable中,链表的顺序与之前的顺序相反 hash = (null != key) ? hash(key) : 0; bucketIndex = indexFor(hash, table.length); } createEntry(hash, key, value, bucketIndex); } void createEntry(int hash, K key, V value, int bucketIndex) { Entry<K,V> e = table[bucketIndex]; table[bucketIndex] = new Entry<>(hash, key, value, e); size++; }
添加Entry[]的操作,在添加之前先进行容量的判断,如果当前容量达到了阀值,并且需要存储到Entry[]数组中,先进行扩容操作,扩充的容量为table长度的两倍,重新计算hash值和数组存储的位置,扩容后的链表顺序与扩容前的链表顺序相反,然后将新添加的Entry实体存放到当前Entry[]位置链表的头部。
注意:在JDK1.8之前,新插入的元素都是放在了链表的头部位置,但是这种操作在高并发的环境下容易导致死锁,所以在JDK1.8及以后,新插入的元素都放在了链表的尾部。
获取方法
public V get(Object key) { if (key == null) //返回table[0] 的value值 return getForNullKey(); Entry<K,V> entry = getEntry(key); return null == entry ? null : entry.getValue(); } final Entry<K,V> getEntry(Object key) { if (size == 0) { return null; } int hash = (key == null) ? 0 : hash(key); for (Entry<K,V> e = table[indexFor(hash, table.length)]; e != null; e = e.next) { Object k; if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k)))) return e; } return null; }
在get方法中,首先计算hash值,然后调用indexFor方法得到该key在table中的存储位置,得到该位置的单链表,遍历列表找到key和指定key内容相等的Entry,返回Entry.value值。
删除方法
public V remove(Object key) { Entry<K,V> e = removeEntryForKey(key); return (e == null ? null : e.value); } final Entry<K,V> removeEntryForKey(Object key) { if (size == 0) { return null; } int hash = (key == null) ? 0 : hash(key); int i = indexFor(hash, table.length); Entry<K,V> prev = table[i]; Entry<K,V> e = prev; while (e != null) { Entry<K,V> next = e.next; Object k; if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k)))) { modCount++; size--; if (prev == e) table[i] = next; else prev.next = next; e.recordRemoval(this); return e; } prev = e; e = next; } return e; }
删除操作,先计算指定key的hash值,然后计算出table中的存储位置,判断当前位置是否存在Entry实体,如果没有,直接返回,若当前位置有Entry实体存在,则开始遍历列表。定义了三个Entry引用,分别为pre、e、next。在循环遍历的过程中,首先判断pre和e是否相等,若相等则表明table当前位置只有一个元素,直接将table[i] = next = null。若形成了 pre -> e ->next 的连接关系,判断e的key是否和指定的key相等,若相等则让pre -> next,e 失去引用。
上述源码皆是JDK1.7的源码。
JDK1.8的改变
在Jdk1.8中HashMap的实现方式做了一些改变,但是基本思想还是没有变得,只是在一些地方做了优化,下面来看一下这些改变的地方,数据结构的存储由数组+链表的方式,变化为数组+链表+红黑树的存储方式,在性能上进一步得到提升。
数据存储方式
当链表长度大于8的时候,链表将会转化为红黑树存储,对极端情况下的HashMap性能大大提升。
put方法简单解析
public V put(K key, V value) { //调用putVal()方法完成 return putVal(hash(key), key, value, false, true); } final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) { Node<K,V>[] tab; Node<K,V> p; int n, i; //判断table是否初始化,否则初始化操作 if ((tab = table) == null || (n = tab.length) == 0) n = (tab = resize()).length; //计算存储的索引位置,如果没有元素,直接赋值 if ((p = tab[i = (n - 1) & hash]) == null) 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); //链表长度8,将链表转化为红黑树存储 if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st treeifyBin(tab, hash); break; } //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; if (!onlyIfAbsent || oldValue == null) e.value = value; afterNodeAccess(e); return oldValue; } } //记录修改次数 ++modCount; //判断是否需要扩容 if (++size > threshold) resize(); //空操作 afterNodeInsertion(evict); return null; }
HashMap的table为什么是 transient的
一个很小的细节:
transient Node<K,V>[] table;
看到table用了transient修饰,也就是说table里面的内容全都不会被序列化,不知道大家有没有想过这么写的原因?
在我看来,这么写是非常必要的。因为HashMap是基于HashCode的,HashCode作为Object的方法,是native的:
public native int hashCode();
这意味着的是:HashCode和底层实现相关,不同的虚拟机可能有不同的HashCode算法。再进一步说得明白些就是,可能同一个Key在虚拟机A上的HashCode=1,在虚拟机B上的HashCode=2,在虚拟机C上的HashCode=3。
这就有问题了,Java自诞生以来,就以跨平台性作为最大卖点,好了,如果table不被transient修饰,在虚拟机A上可以用的程序到虚拟机B上可以用的程序就不能用了,失去了跨平台性,因为:
1、Key在虚拟机A上的HashCode=100,连在table[4]上
2、Key在虚拟机B上的HashCode=101,这样,就去table[5]上找Key,明显找不到
整个代码就出问题了。因此,为了避免这一点,Java采取了重写自己序列化table的方法,在writeObject选择将key和value追加到序列化的文件最后面:
private void writeObject(java.io.ObjectOutputStream s) throws IOException { Iterator<Map.Entry<K,V>> i = (size > 0) ? entrySet0().iterator() : null; // Write out the threshold, loadfactor, and any hidden stuff s.defaultWriteObject(); // Write out number of buckets s.writeInt(table.length); // Write out size (number of Mappings) s.writeInt(size); // Write out keys and values (alternating) if (i != null) { while (i.hasNext()) { Map.Entry<K,V> e = i.next(); s.writeObject(e.getKey()); s.writeObject(e.getValue()); } } }
而在readObject的时候重构HashMap数据结构:
private void readObject(java.io.ObjectInputStream s) throws IOException, ClassNotFoundException { // Read in the threshold, loadfactor, and any hidden stuff s.defaultReadObject(); // Read in number of buckets and allocate the bucket array; int numBuckets = s.readInt(); table = new Entry[numBuckets]; init(); // Give subclass a chance to do its thing. // Read in size (number of Mappings) int size = s.readInt(); // Read the keys and values, and put the mappings in the HashMap for (int i=0; i<size; i++) { K key = (K) s.readObject(); V value = (V) s.readObject(); putForCreate(key, value); } }
一种麻烦的方式,但却保证了跨平台性。
这个例子也告诉了我们:尽管使用的虚拟机大多数情况下都是HotSpot,但是也不能对其它虚拟机不管不顾,有跨平台的思想是一件好事。
HashMap和Hashtable的区别
HashMap和Hashtable是一组相似的键值对集合,它们的区别也是面试常被问的问题之一,我这里简单总结一下HashMap和Hashtable的区别:
1、Hashtable是线程安全的,Hashtable所有对外提供的方法都使用了synchronized,也就是同步,而HashMap则是线程非安全的
2、Hashtable不允许空的value,空的value将导致空指针异常,而HashMap则无所谓,没有这方面的限制
3、上面两个缺点是最主要的区别,另外一个区别无关紧要,我只是提一下,就是两个的rehash算法不同,Hashtable的是:
private int hash(Object k) { // hashSeed will be zero if alternative hashing is disabled. return hashSeed ^ k.hashCode(); }
这个hashSeed是使用sun.misc.Hashing类的randomHashSeed方法产生的。HashMap的rehash算法上面看过了,也就是:
static int hash(int h) { // This function ensures that hashCodes that differ only by // constant multiples at each bit position have a bounded // number of collisions (approximately 8 at default load factor). h ^= (h >>> 20) ^ (h >>> 12); return h ^ (h >>> 7) ^ (h >>> 4); }
HashMap的长度为什么要求是2的幂次方?
HashMap为了存取高效,要尽量较少碰撞,就是要尽量把数据分配均匀,每个链表长度大致相同,这个实现就在把数据存到哪个链表中的算法,这个算法实际就是取模,hash%length,计算机中直接求余效率不如位移运算,源码中做了优化hash&(length-1),hash%length==hash&(length-1)的前提是length是2的n次方;为什么这样能均匀分布减少碰撞呢?2的n次方实际就是1后面n个0,2的n次方-1 实际就是n个1:
例如长度为9时候,3&(9-1)=0 2&(9-1)=0 ,都在0上,碰撞了;
例如长度为8时候,3&(8-1)=3 2&(8-1)=2 ,不同位置上,不碰撞;
其实就是按位“与”的时候,每一位都能 &1 ,也就是和1111……1111111进行与运算
0000 0011 3
& 0000 1000 8
= 0000 0000 0
0000 0010 2
& 0000 1000 8
= 0000 0000 0
----------------------------------------------------------------------------
0000 0011 3
& 0000 0111 7
= 0000 0011 3
0000 0010 2
& 0000 0111 7
= 0000 0010 2
当然如果不考虑效率直接求余即可(就不需要要求长度必须是2的n次方了);
总结:
(1)为了更高的效率(位移运算比取余速度快,效率高)
(2)为了更好的利用空间,减少重复率,比如说:当数组长度为15的时候,hashcode的值会与14(1110)进行“与”,那么最后一位永远是0,而0001,0011,0101,1001,1011,0111,1101这几个位置永远都不能存放元素了,空间浪费相当大,更糟的是这种情况中,数组可以使用的位置比数组长度小了很多,这意味着进一步增加了碰撞的几率,减慢了查询的效率!
capacity 永远都是 2 次幂,那么如果我们指定 initialCapacity 不为 2次幂时呢,是不是就破坏了这个规则?
答案是不会的,HashMap的tableSizeFor方法做了处理,能保证n永远都是2次幂。
/** * Returns a power of two size for the given target capacity. */ static final int tableSizeFor(int cap) { //cap-1后,n的二进制最右一位肯定和cap的最右一位不同,即一个为0,一个为1,例如cap=17(00010001),n=cap-1=16(00010000) int n = cap - 1; //n = (00010000 | 00001000) = 00011000 n |= n >>> 1; //n = (00011000 | 00000110) = 00011110 n |= n >>> 2; //n = (00011110 | 00000001) = 00011111 n |= n >>> 4; //n = (00011111 | 00000000) = 00011111 n |= n >>> 8; //n = (00011111 | 00000000) = 00011111 n |= n >>> 16; //n = 00011111 = 31 //n = 31 + 1 = 32, 即最终的cap = 32 = 2 的 (n=5)次方 return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1; }
总结:可以确保capacity为大于或等于cap的最接近cap的二次幂,比如cap=13,则capacity=16;cap=16,capacity=16;cap=17,capacity=32。
参考:https://www.cnblogs.com/xrq730/p/5030920.html