引言
Java 类库中包含了 Map 的几种实现,包括:HashMap,TreeMap,LinkedHashMap,WeakHashMap,ConcurrentHashMap,IdentityHashMap。
下面对 HashMap 进行分析,几个问题:构造函数?如何存取?
HashMap
HashMap 底层是 哈希表(Hash table) 或称为 散列表,散列表的实现常常叫做 散列(hashing)。散列是一种用于以常数平均时间执行插入、删除和查找的技术。
散列是数据结构的一种。(关于数据结构有个可视化网站,对数据结构的理解很有帮助 Data Structure Visualizations)
对 HashMap 来说,Key 不允许重复,Value 允许重复,k、v都可以是null。
示例解析
在开发过程中最常用的是 put 和 get 方法存取值。先来个 hello world:
package com.xgcd.map; import java.util.HashMap; public class HashMapTest { public static void main(String[] args) { HashMap<Object, Object> map = new HashMap<>(); map.put("abc", "123"); System.out.println(map.get("abc"));// 123 } }
下面对这三行代码分析一下,new、put、get。
一、new HashMap<>()
在创建 map 对象时,new HashMap 用的是 HashMap 的无参构造方法,看源码,默认初始容量(initial capacity) 16,负载因子(load factory) 0.75 。
/** * Constructs an empty <tt>HashMap</tt> with the default initial capacity * (16) and the default load factor (0.75). */ public HashMap() { this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted }
初始容量默认 16,用于设置 map 对象中元素个数。
负载因子默认 0.75,是指当 map 中元素超过容量的75%时会自动扩容(resize),扩容放到后面说。
二、put() 方法
进入 put() 方法,key 和 value 相关联:
public V put(K key, V value) { return putVal(hash(key), key, value, false, true); }
继续,进入 putVal() 方法:
/** * Implements Map.put and related methods. * * @param hash hash for key * @param key the key * @param value the value to put * @param onlyIfAbsent if true, don't change existing value * @param evict if false, the table is in creation mode. * @return previous value, or null if none */ final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) { // tab为数组,p是每个桶 HashMap.Node<K,V>[] tab; HashMap.Node<K,V> p; int n, i; // 如果数组(table)为空,则调用resize()扩容创建一个数组 if ((tab = table) == null || (n = tab.length) == 0) n = (tab = resize()).length; // 计算元素索要存储的数组下标,算法是(n-1)&hash,如果此下标没有元素则直接插入 if ((p = tab[i = (n - 1) & hash]) == null) tab[i] = newNode(hash, key, value, null); // 如果在数组table的下标i位置已经有元素了,也就是发生了所谓的hash碰撞,有两种情况: // 1、key值是一样的,直接替换value值(也就是覆盖) // 2、key值不一样,又有两种处理方式,判断链表是否是红黑树: // 2.1 是红黑树,存储在红黑树中 // 2.2 是正常的链表,则存储在i位置的链表中(直接插到最后面) else { HashMap.Node<K,V> e; K k; // 1、key值一样 if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k)))) e = p; // 2.1 是红黑树 else if (p instanceof HashMap.TreeNode) e = ((HashMap.TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value); // 2.2 不是红黑树即是链表,遍历链表 else { for (int binCount = 0; ; ++binCount) { // 遍历直到链表尾端都没有找到key值相同的节点,则生成一个新的Node 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存在,则覆盖原来位置上的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; }
其中,table 是一个成员变量,用散装英语翻译一下:
该 表(table) 在首次使用时初始化,并根据需要调整大小。 分配后,长度始终是2的幂。 (在某些操作中,我们还允许长度为零,以允许使用当前不需要的引导机制。)
/** * The table, initialized on first use, and resized as * necessary. When allocated, length is always a power of two. * (We also tolerate length zero in some operations to allow * bootstrapping mechanics that are currently not needed.) */ transient Node<K,V>[] table;
查看 table 数据类型 Node<K,V>[] 发现 Node 是实现了 Map.Entry<K,V> 而 Entry 是单向链表,table 就是以 Node<K,V> 为元素的数组,这也就是称 HashMap 底层是 数组+链表 的原因。JDK1.8 后,HashMap 又引入了红黑树的数据结构。
需要注意的是,在计算元素所在数组下标 index 时,算法是(n - 1) & hash;代码如下:
int index = hash(key) & (capacity - 1)
hash 值的计算是调用的 hash(Object key) 方法。
static final int hash(Object key) { int h; return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16); }
可见,hash 值并不是 key 本身的 hashcode,而是一种算法。
这里思考两个问题:
1、为什么必须是右移16位
首先hashcode本身是个32位整型值(int是32位)。获取对象的hashcode之后,先进行移位运算,再和自己做异或运算,非常巧妙,将高16位移到低16位,这样计算得到的整型值将“具有”高位和低位的性质。
因为需要考虑这样的情况:有些数据计算出的hash值差异主要在高位,而hashmap里的hash寻址(也就是计算放置到数组的索引位置)是忽略容量(初始16)以上的高位的,这种处理可以有效避免类似情况的哈希碰撞。
举个例子:我们假设有一种情况,对象A的hashCode 为 1000010001110001000001111000000,对象 B 的 hashCode 为 0111011100111000101000010100000。
如果容量是16,16-1=15,二进制1111,对 与运算这两个数, 你会发现高位都未参与运算,结果都是0。这样的散列结果太让人失望了。很明显不是一个好的hash算法。
但是如果我们将 hashCode 值右移 16 位,也就是取 int 类型的一半,刚好将该二进制数对半切开。并且使用位异或运算(如果两个数对应的位置相反,则结果为1,反之为0),这样的话,就能避免我们上面的情况的发生。
2、为什么要容量减1
最后,用hash表当前的容量减1,再和刚计算出来的整型值做位与运算,为什么要容量减1呢?
因为A%B = A & (B-1),该式子在B是2的指数时成立,转换为取模运算,结果只取决于hash值。这也是为什么容量建议2的幂次方,这样保证&中的二进制位全是1,最大限度利用hash值,更好的散列,让hash值均匀的分布在桶中!
另外思考两个问题,在 putVal 方法中还提到了当超过链表的设置长度时,会转为红黑树,那为什么要引入红黑树?为什么树化的临界值又是8呢?
为什么会引入红黑树?
引入红黑树目的是做查询优化。在平常用 HashMap 的时候,HashMap 里面存储的 key 是具有良好的 hash 算法的 key(比如String、Integer等包装类),冲突几率自然微乎其微,此时链表几乎不会转化为红黑树,但是当 key 为我们自定义的对象时,我们可能采用了
不好的 hash 算法,使 HashMap 中 key 的冲突率极高,但是这时HashMap为了保证高速的查找效率,就引入了红黑树来优化查询了。
为什么树化的临界值为8?
通过源码我们得知 HashMap 源码作者通过泊松分布算出,当桶中结点个数为8时,出现的几率是亿分之6的,因此常见的情况是桶中个数小于8的情况,此时链表的查询性能和红黑树相差不多,因为转化为树还需要时间和空间,所以此时没有转化成树的必要。
既然个数为8时发生的几率这么低,为什么还要当链表个数大于8时来树化,来优化这几乎不会发生的场景呢?
首先我们要知道亿分之6这个几乎不可能的概率是建立在什么情况下的?答案是建立在良好的 hash 算法情况下。例如 String,Integer 等包装类的 hash 算法、一旦发生桶中元素大于8,说明是不正常情况,可能采用了冲突较大的hash算法,此时桶中个数出现超过8
的概率是非常大的,可能有n个key冲突在同一个桶中,此时再看链表的平均查询复杂度和红黑树的时间复杂度,就知道为什么要引入红黑树了。举个例子,若hash算法写的不好,一个桶中冲突1024个 key,使用链表平均需要查询512次,但是红黑树仅仅10次,红黑
树的引入保证了在大量 hash 冲突的情况下,HashMap 还具有良好的查询性能。
三、get() 方法
public V get(Object key) { Node<K,V> e; return (e = getNode(hash(key), key)) == null ? null : e.value; }
进入 getNode() 方法:
/** * Implements Map.get and related methods. * * @param hash hash for key * @param key the key * @return the node, or null if none */ final HashMap.Node<K,V> getNode(int hash, Object key) { HashMap.Node<K,V>[] tab; HashMap.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 HashMap.TreeNode) return ((HashMap.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; }
这里看一下 equals() 方法:
public boolean equals(Object obj) { return (this == obj); }
思考两个问题:
为什么equals()方法要重写?
判断两个对象在逻辑上是否相等,如根据类的成员变量来判断两个类的实例是否相等,而继承Object中的equals方法只能判断两个引用变量是否是同一个对象。这样我们往往需要重写equals()方法。
我们向一个没有重复对象的集合中添加元素时,集合中存放的往往是对象,我们需要先判断集合中是否存在已知对象,这样就必须重写equals方法。
怎样重写equals()方法?
重写equals方法的注意点:
1、自反性:对于任何非空引用x,x.equals(x)应该返回true。
2、对称性:对于任何引用x和y,如果x.equals(y)返回true,那么y.equals(x)也应该返回true。
3、传递性:对于任何引用x、y和z,如果x.equals(y)返回true,y.equals(z)返回true,那么x.equals(z)也应该返回true。
4、一致性:如果x和y引用的对象没有发生变化,那么反复调用x.equals(y)应该返回同样的结果。
5、非空性:对于任意非空引用x,x.equals(null)应该返回false。
扩容机制 resize()
插入的元素太多,数组装不下了就只能扩容了,HashMap会在原来的基础上把数组的容量增加一倍。
当然Java里的数组是无法自动扩容的,方法就是创建一个新的更大的数组代替已有的容量小的数组。
然后Node类的hash对数组的长度重新取余,以确定数组的下标。于是乎HashMap里元素的顺序又重排了。
扩容:一是扩大table的长度,而是修改node的位置。容量n扩大一倍,新table中,node的下标要么还是原来的t,要么是t+n。
HashMap有两个成员变量:
DEFAULT_INITIAL_CAPACITY: HashMap默认的初始化数组的大小,默认为16
DEFAULT_LOAD_FACTOR: 加载因子,默认为0.75,,当HashMap的大小达到数组的0.75的时候就会扩容。
查看 resize() 方法代码:
final Node<K,V>[] resize() { //创建一个oldTab数组用于保存之前的数组 Node<K,V>[] oldTab = table; //获取原来数组的长度 int oldCap = (oldTab == null) ? 0 : oldTab.length; //原来数组扩容的临界值 int oldThr = threshold; int newCap, newThr = 0; if (oldCap > 0) { //如果原来的数组长度大于最大值(2^30) if (oldCap >= MAXIMUM_CAPACITY) { //扩容临界值提高到正无穷 threshold = Integer.MAX_VALUE; //返回原来的数组,也就是系统已经管不了了 return oldTab; } //新数组(newCap)长度乘2 < 最大值(2^30) && (原来的数组长度) >= 初始长度(2^4) else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY && oldCap >= DEFAULT_INITIAL_CAPACITY) //这个else if中实际上就是咋判断新数组(此时刚创建还为空)和老数组的长度合法性, //同时交待了扩容是以2^1为单位扩容的。 newThr = oldThr << 1; }// newThr(新数组的扩容临界值)一样,在原有临界值的基础上扩2^1 else if (oldThr > 0) // initial capacity was placed in threshold //新数组的初始容量设置 为老数组扩容的临界值 newCap = oldThr; // 否则 oldThr == 0,零初始阈值表示使用默认值 else { //新数组初始容量设置为默认值 newCap = DEFAULT_INITIAL_CAPACITY; newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY); } //如果newThr ==0,说明为上面 else if(oldThr > 0)的情况(其他两种情况都对newThr的值做了改变), //此时newCap = oldThr; if (newThr == 0) { //ft为临时变量,用于判断阈值的合法性, float ft = (float)newCap * loadFactor; //计算新的阈值 newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ? (int)ft : Integer.MAX_VALUE); } //改变threshold值为新的阈值 threshold = newThr; @SuppressWarnings({"rawtypes","unchecked"}) Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap]; //改变table全局变量为扩容后的newTable table = newTab; if (oldTab != null) { //遍历老数组,将老数组(或者原来的桶)迁移到新的数组(新的桶)中 for (int j = 0; j < oldCap; ++j) { Node<K,V> e; //新建一个Node<K,V>类对象,用它来遍历整个数组。 if ((e = oldTab[j]) != null) { oldTab[j] = null;//老的table不用了,赋值为null,垃圾回收 //如果e的下一个节点是null说明没有链表或树的结构,重新计算下标,赋值到新的table if (e.next == null) newTab[e.hash & (newCap - 1)] = e; //如果e已经是一个红黑树的元素 else if (e instanceof TreeNode) ((TreeNode<K,V>)e).split(this, newTab, j, oldCap); // 链表重排,注意,原table的某些key会被计算到同一个下标,但是新的table中不一定 // 因此,链表可能会拆散,变成0-2个链表 // 所以,定义两个node对,一个是loHead,loTail;一个是hiHead,hiTail else { Node<K,V> loHead = null, loTail = null; Node<K,V> hiHead = null, hiTail = null; Node<K,V> next; do { next = e.next; // e.hash & oldCap==0的Node会被分配到同一个位置,确切的说,和原table下标一样 if ((e.hash & oldCap) == 0) { if (loTail == null) loHead = e; else loTail.next = e; loTail = e; } //其余节点会被分配到另一个的同一位置,确切说是原table下标+oldCap else { if (hiTail == null) hiHead = e; else hiTail.next = e; hiTail = e; } } while ((e = next) != null); // 在对应的位置上赋值 if (loTail != null) { loTail.next = null; newTab[j] = loHead; } if (hiTail != null) { hiTail.next = null; newTab[j + oldCap] = hiHead; } } } } } return newTab; }
这里思考扩容涉及到一个问题
1、如何知道要将原数组的某个元素放到新数组的哪个索引位置上?
也就是说如何确定元素e在新数组的位置。之前put的时候,用的是hash(key) & (capacity - 1)确定,为什么不继续用该方法,却转而判断(e.hash & oldCap) == 0,判断原来的元素在新数组上是否移位,假设capacity是16,只需要看倒数第五位,如果为0,下标不变,
如果是1,下标加上容量oldCap。
HashMap为什么是线程不安全的?
HashMap 在并发时出现的问题可能是两方面:
1、put的时候导致的多线程数据不一致
比如有两个线程A和B,首先A希望插入一个key-value对到HashMap中,首先计算记录所要落到的 hash桶的索引坐标,然后获取到该桶里面的链表头结点,此时线程A的时间片用完了,而此时线程B被调度得以执行,和线程A一样执行,只不过线程B成功将记录插到了
桶里面,假设线程A插入的记录计算出来的 hash桶索引和线程B要插入的记录计算出来的 hash桶索引是一样的,那么当线程B成功插入之后,线程A再次被调度运行时,它依然持有过期的链表头但是它对此一无所知,以至于它认为它应该这样做,如此一来就覆盖了线
程B插入的记录,这样线程B插入的记录就凭空消失了,造成了数据不一致的行为。
2、resize 而引起死循环
这种情况发生在HashMap自动扩容时,当2个线程同时检测到元素个数超过 数组大小 × 负载因子。此时2个线程会在put()方法中调用了resize(),两个线程同时修改一个链表结构会产生一个循环链表(JDK1.7中,会出现resize前后元素顺序倒置的情况)。接下来再想
通过get()获取某一个元素,就会出现死循环。
HashMap和HashTable的区别?
1、HashMap和Hashtable都实现了Map接口,但决定用哪一个之前先要弄清楚它们之间的分别。主要的区别有:线程安全性,同步(synchronization),以及速度。
HashMap几乎可以等价于Hashtable,除了HashMap是非synchronized的,并可以接受null (HashMap可以接受为null的键值(key)和值(value),而Hashtable则不行)。
2、HashMap是非synchronized,而Hashtable是synchronized,这意味着Hashtable是线程安全的,多个线程可以共享一个Hashtable;而如果没有正确的同步的话,多个线程是不能共享HashMap的。Java 5提供了ConcurrentHashMap,它是HashTable的替代,比
HashTable的扩展性更好。
3、另一个区别是HashMap的迭代器(Iterator)是fail-fast迭代器,而Hashtable的enumerator迭代器不是fail-fast的。所以当有其它线程改变了HashMap的结构(增加或者移除元素),将会抛出ConcurrentModificationException,但迭代器本身的remove()方法移除元素则不会抛出ConcurrentModificationException异常。但这并不是一个一定发生的行为,要看JVM。这条同样也是Enumeration和Iterator的区别。
4、由于Hashtable是线程安全的也是synchronized,所以在单线程环境下它比HashMap要慢。如果你不需要同步,只需要单一线程,那么使用HashMap性能要好过Hashtable。
5、HashMap不能保证随着时间的推移Map中的元素次序是不变的。
拉链法导致的链表过深,为什么不用二叉查找树代替而选择红黑树?为什么不一直使用红黑树?
之所以选择红黑树是为了解决二叉查找树的缺陷:二叉查找树在特殊情况下会变成一条线性结构(这就跟原来使用链表结构一样了,造成层次很深的问题),遍历查找会非常慢。而红黑树在插入新数据后可能需要通过左旋、右旋、变色这些操作来保持平衡。引入红
黑树就是为了查找数据快,解决链表查询深度的问题。我们知道红黑树属于平衡二叉树,为了保持“平衡”是需要付出代价的,但是该代价所损耗的资源要比遍历线性链表要少。所以当长度大于8的时候,会使用红黑树;如果链表长度很短的话,根本不需要引入红黑
树,引入反而会慢。
扩展问题简答
table数组什么时候获得初始化?
第一次插入元素的时候
初始化hashMap后,第一次放入元素,table的长度是多少?
16
new HashMap(19),创建的map中table数组长度多大?
初始化时实际上为null,第一次插入元素时32.
你知道HashMap的工作原理吗?你知道HashMap的get()方法的工作原理吗?
HashMap是基于hashing的原理,我们使用put(key, value)存储对象到HashMap中,使用get(key)从HashMap中获取对象。
当我们给put()方法传递键和值时,我们先对键调用hashCode()方法,返回的hashCode用于找到bucket位置来储存Entry对象。
这里关键点在于指出,HashMap是在bucket中储存键对象和值对象,作为Map.Entry。
这一点有助于理解获取对象的逻辑。如果你没有意识到这一点,或者错误的认为仅仅只在bucket中存储值的话,你将不会回答如何从HashMap中获取对象的逻辑。这个答案相当的正确,也显示出面试者确实知道hashing以及HashMap的工作原理。
两个hashcode相同的时候会发生什么?
hashcode相同,bucket的位置会相同,也就是说会发生碰撞,哈希表中的结构其实有链表(LinkedList),这种冲突通过将元素储存到LinkedList中,解决碰撞。储存顺序是放在表头。
如果两个键的hashcode相同,如何获取值对象?
如果两个键的hashcode相同,即找到bucket位置之后,我们通过key.equals()找到链表LinkedList中正确的节点,最终找到要找的值对象。
一些优秀的开发者会指出使用不可变的、声明作final的对象,并且采用合适的equals()和hashCode()方法的话,将会减少碰撞的发生,提高效率。不可变性使得能够缓存不同键的hashcode,这将提高整个获取对象的速度,使用String,Interger这样的wrapper类作为键
是非常好的选择。
如果HashMap的大小超过了负载因子(load factor)定义的容量?怎么办?
HashMap里面默认的负载因子大小为0.75,也就是说,当一个map填满了75%的bucket时候,和其它集合类(如ArrayList等)一样,将会创建原来HashMap大小的两倍的bucket数组,来重新调整map的大小,并将原来的对象放入新的bucket数组中。这个过程叫作
rehashing,因为它调用hash方法找到新的bucket位置。
HashMap在并发执行put操作,会引起死循环,为什么?
hashmap本身就不是线程安全的。多线程会导致hashmap的node链表形成环形链表,一旦形成环形链表,node 的next节点永远不为空,就会产生死循环获取node。从而导致CPU利用率接近100%。
hashing的概念
散列法(Hashing)或哈希法是一种将字符组成的字符串转换为固定长度(一般是更短长度)的数值或索引值的方法,称为散列法,也叫哈希法。由于通过更短的哈希值比用原始值进行数据库搜索更快,这种方法一般用来在数据库中建立索引并进行搜索,同时还用在
各种解密算法中。
为什么String, Interger这样的wrapper类适合作为键?
因为他们一般不是不可变的,源码上面final,使用不可变类,而且重写了equals和hashcode方法,避免了键值对改写。提高HashMap性能。
String, Interger这样的wrapper类作为HashMap的键是再适合不过了,而且String最为常用。因为String是不可变的,也是final的,而且已经重写了equals()和hashCode()方法了。其他的wrapper类也有这个特点。不可变性是必要的,因为为了要计算hashCode(),就要
防止键值改变,如果键值在放入时和获取时返回不同的hashcode的话,那么就不能从HashMap中找到你想要的对象。不可变性还有其他的优点如线程安全。如果你可以仅仅通过将某个field声明成final就能保证hashCode是不变的,那么请这么做吧。因为获取对象的时
候要用到equals()和hashCode()方法,那么键对象正确的重写这两个方法是非常重要的。如果两个不相等的对象返回不同的hashcode的话,那么碰撞的几率就会小些,这样就能提高HashMap的性能。