摘要
HashMap的相关问题可以说是面试中很常见的问题了,网上也能看到非常多的讲解。但是个人感觉,看的再多都不如自己实打实的写一篇总结来的收获多。
首先介绍什么是hash表,散列表(Hash table,也叫哈希表),是根据键(Key)而直接访问在内存存储位置的数据结构。也就是说,它通过计算一个关于键值的函数,将所需查询的数据映射到表中一个位置来访问记录,这加快了查找速度。这个映射函数称做散列函数,存放记录的数组称做散列表。(描述引自维基百科)在String类的hashcode()方法中,可以看到这个计算哈希值的过程:
public int hashCode() {
int h = hash;
if (h == 0 && value.length > 0) {
char val[] = value;
for (int i = 0; i < value.length; i++) {
h = 31 * h + val[i];
}
hash = h;
}
return h;
}
但是注意:
Hash算法有两条性质:不可逆和无冲突
- 不可逆:就是你知道X可以求出X的哈希值,但是知道X的Hash值算不出X。
- 无冲突:就是你知道X不能求出Y使得X与Y的哈希值相等。
说到hashCode就让我想到了Java世界的两大约定,既equals与hashCode约定。
equals约定,直接看Object类的Java doc:
* Indicates whether some other object is "equal to" this one.
* <p>
* The {@code equals} method implements an equivalence relation
* on non-null object references:
* <ul>
* <li>It is <i>reflexive</i>: for any non-null reference value
* {@code x}, {@code x.equals(x)} should return
* {@code true}.
* <li>It is <i>symmetric</i>: for any non-null reference values
* {@code x} and {@code y}, {@code x.equals(y)}
* should return {@code true} if and only if
* {@code y.equals(x)} returns {@code true}.
* <li>It is <i>transitive</i>: for any non-null reference values
* {@code x}, {@code y}, and {@code z}, if
* {@code x.equals(y)} returns {@code true} and
* {@code y.equals(z)} returns {@code true}, then
* {@code x.equals(z)} should return {@code true}.
* <li>It is <i>consistent</i>: for any non-null reference values
* {@code x} and {@code y}, multiple invocations of
* {@code x.equals(y)} consistently return {@code true}
* or consistently return {@code false}, provided no
* information used in {@code equals} comparisons on the
* objects is modified.
* <li>For any non-null reference value {@code x},
* {@code x.equals(null)} should return {@code false}.
* </ul>
* <p>
这个不用翻译,直接看也能看明白。
hashCode的约定是Java世界第二重要的约定:
<p>
* The general contract of {@code hashCode} is:
* <ul>
* <li>Whenever it is invoked on the same object more than once during
* an execution of a Java application, the {@code hashCode} method
* must consistently return the same integer, provided no information
* used in {@code equals} comparisons on the object is modified.
* This integer need not remain consistent from one execution of an
* application to another execution of the same application.
* <li>If two objects are equal according to the {@code equals(Object)}
* method, then calling the {@code hashCode} method on each of
* the two objects must produce the same integer result.
* <li>It is <em>not</em> required that if two objects are unequal
* according to the {@link java.lang.Object#equals(java.lang.Object)}
* method, then calling the {@code hashCode} method on each of the
* two objects must produce distinct integer results. However, the
* programmer should be aware that producing distinct integer results
* for unequal objects may improve the performance of hash tables.
* </ul>
* <p>
翻译一下就是:
- 同一个对象必须始终返回相同的hashCode。
- 两个对象的equals返回ture,必须返回相同的hashCode,所以需要重写hashCode方法。
- 两个对象不等,也可能返回相同的hashcode。
第三重要的约定是compareTo约定
- compareTo()方法的返回值是int,他比较的是对应位置处的字符的ASCII码值的大小
- 如果第一个字符与传入参数的第一个字符不相等,那么就计算两者的ACSII码差值,负值代表前者的ASCII码小于后者,正值代表大于,相等则返回零。
- 如果字符串的第一个参数与传入的相等,那么就向后继续比较,直至二者之一被比较完,那么比较两字符串的长度。
所以问出第一个问题:
1.Java7过渡到Java8,hashMap的结构发生了哪些改变?
在Java7中的hashMap就是采用的这种结构,既数组+链表的实现,这样做的好处是查找、插入、删除的时间复杂度都是O(1),但是致命的缺陷就是哈希桶的碰撞。也就是所有的value都对应一个key值,比如这种情况:
public static void main(String[] args) {
HashMap<String,String> hashMap = new HashMap<>();
List<String> list = Arrays.asList("Aa","BB","C#");
for (String s:list
) {
hashMap.put(s,s);
System.out.println(s.hashCode());
}
}
结果:
2112
2112
2112
Process finished with exit code 0
这样hash表就成了一个链表,性能急剧退化。
所以在Java8之后,就采用了数组+链表+红黑树的结构来实现HashMap,也就是当链表达到一定的长度之后,会转换成一棵红黑树。具体过程在后面做详细介绍。
2.为什么hashMap的初始化容量一定要是2的幂?
hashMap的Java doc初始化容量上面有这一句话:
/**
* The default initial capacity - MUST be a power of two.
*/
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
那么我们就偏偏定义一个初始容量不是2的幂的hashMap,
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);
}
这里有个tableSizeFor()方法,
static final int tableSizeFor(int cap) {
int n = cap - 1;
n |= n >>> 1;
n |= n >>> 2;
n |= n >>> 4;
n |= n >>> 8;
n |= n >>> 16;
return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}
乍一看好像看不太懂,没事,拿着自己的例子进去试一遍就知道这个方法的作用了。就拿9为例(>>>表示无符号右移一位,<<<则表示无符号左移一位):
得到的是15最终返回16,而16是比9大并且离的9最近的为2的幂的数字,所以这就是这个方法的作用。
那么为什么hashMap要保证初始容量为2的幂的呢?先问自己这样一个问题,int的范围是(
至
),但是你一个HashMap的大小,刚开始的时候也就是几十个,那么是怎么把哈希值放入这数组大小为几十的桶中呢?最容易想到的就是取模运算了,但是有问题:如果哈希值是负数呢?数组的位置可没有负数。并且取模运算在磁盘中就是在做一遍又一遍的减法,这是很没有效率的。那么实际上HashMap是怎么做的呢?从JDK1.7,HashMap的put方法中可以看到:
public V put(K key, V value) {
if (table == EMPTY_TABLE) {
inflateTable(threshold);
}
if (key == null)
return putForNullKey(value);
int hash = hash(key);
//参数的索引值是根据indexFor方法计算出来的
int i = indexFor(hash, table.length);
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;
}
}
static int indexFor(int h, int length) {
// assert Integer.bitCount(length) == 1 : "length must be a non-zero power of 2";
//使用位运算获取数组的下标
return h & (length-1);
}
演示一下这个过程,随便取一个数,以HashMap的大小为32为例:
那如果是30呢?
可以看到倒数第二位被擦除了,也就是说,无论hash值算的是多少,它的这一位都是0,那就带来了索引不连续的问题,就不能保证元素在HashMap中是连续存放的。所以为了保证连续,HashMap的大小-1必须保证每一位上都是1,故而HashMap的大小必须为2的幂。
3.链表树化的阈值是多少?
首先我们知道,如果一直往Map里面丢元素,那么某个桶里面的元素个数超过某个数值的时候,链表会转换成红黑树。所以,想知道阈值是多少,就去看HashMap的put方法,put方法的源码解读网上也有很多。我这就直接给出答案了:
截取put方法的一小部分:
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
break;
/**
* The bin count threshold for using a tree rather than list for a
* bin. Bins are converted to trees when adding an element to a
* bin with at least this many nodes. The value must be greater
* than 2 and should be at least 8 to mesh with assumptions in
* tree removal about conversion back to plain bins upon
* shrinkage.
*/
static final int TREEIFY_THRESHOLD = 8;
可以看到,当桶里面的元素个数大于等于7个的时候,进入数化方法,再进去数化方法:
/**
* The smallest table capacity for which bins may be treeified.
* (Otherwise the table is resized if too many nodes in a bin.)
* Should be at least 4 * TREEIFY_THRESHOLD to avoid conflicts
* between resizing and treeification thresholds.
*/
static final int MIN_TREEIFY_CAPACITY = 64;
可以看到,不光是链表个数要超过8,还要桶的个数超过64才会发生由链表向二叉树的转换。
4.紧接着上面的问题,为什么是8,为什么是64,负载因子为什么是0.75?
关于为什么是8这个问题, 源码中给出了答案:
* 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
解释一下就是:因为桶里面元素的个数的概率服从参数为0.5的泊松分布。由计算结果可知,在正常情况下,桶里面元素个数为8的概率为千万分之级,非常小。如果超过8,说明出现了碰撞,这时候将链表转换为红黑树可以及时的遏制性能下降的问题。听到的另外一个说法就是说,因为链表的平均查找时间复杂度为n/2,二叉树的查找为
,当个数为8的时候,
=3<4,进行数化才会提高查找效率,否则没必要,感觉也蛮有道理的。
至于另一个阈值为什么是64,原因在于:进行树化本质上是为了提高查找效率,节约查找时间而做的操作。但是如果桶的个数很少,达不到一定的规模,就没必要进行树化,直接扩容即可。至于为什么是64?我暂时还没看到有关的解析,以后找到了再做补充。
为什么是0.75?
这个问题在在Stack Overflow上找到了答案:
通常,默认负载系数(0.75)在时间和空间成本之间提供了良好的折衷。较高的值减少了空间开销,但会增加查找成本(反映在HashMap类的大多数操作中,包括GET和PUT),会降低扩容的效率。但是太小的话,扩容十分频繁,又会占用大量的内存空间。
5.HashMap的扩容是如何实现的?
在HashMap的put方法中(jdk1.7更加简单易懂)可以看到:
public V put(K key, V value) {
if (table == EMPTY_TABLE) {
inflateTable(threshold);
}
if (key == null)
return putForNullKey(value);
int hash = hash(key);
int i = indexFor(hash, table.length);
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);
return null;
}
当没有找到相同的元素时,会给这个元素加一个桶。
void addEntry(int hash, K key, V value, int bucketIndex) {
if ((size >= threshold) && (null != table[bucketIndex])) {
resize(2 * table.length);
hash = (null != key) ? hash(key) : 0;
bucketIndex = indexFor(hash, table.length);
}
createEntry(hash, key, value, bucketIndex);
}
在这个增加桶的方法中就可以看出,当桶的个数大于阈值(负载因子*容量)的时候,就产生一个大小是原来两倍的新表。
void resize(int newCapacity) {
Entry[] oldTable = table;
int oldCapacity = oldTable.length;
if (oldCapacity == MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return;
}
Entry[] newTable = new Entry[newCapacity];
transfer(newTable, initHashSeedAsNeeded(newCapacity));
table = newTable;
threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
}
通过这个调整大小的方法,我们可以看出,扩容的机制就是把原来的数据丢到一个大小是原来两倍大的新表中,并且会重新计算索引值(注意,就是这个过程,将会导致一个很致命的问题)。
6.HashMap为什么不是线程安全的?
这里推荐一篇讲这部分过程的文章 coolshell.cn/articles/96…
里面说的很清楚:
在知乎上看到一篇文章还提到另一个原因:
7.什么是红黑树?
首先,红黑树是一中自平衡二叉查找树,因为传统的二叉树在特殊情$况下会形成一个近似链表的树,很影响效率,所以引出红黑树作为替代。
红黑树的特性(源自维基百科):
红黑树是每个节点都带有颜色属性的二叉查找树,颜色为红色或黑色。在二叉查找树强制一般要求以外,对于任何有效的红黑树我们增加了如下的额外要求:
- 1.节点是红色或黑色。
- 2.根是黑色。
- 3.所有叶子都是黑色(叶子是NIL节点)。
- 4.每个红色节点必须有两个黑色的子节点。(从每个叶子到根的所有路径上不能有两个连续的红色节点。)
- 5.从任一节点到其每个叶子的所有简单路径都包含相同数目的黑色节点。
下面是一个具体的红黑树的图例:
左旋:
8.HashMap、LinkedHashMap、HashTable、TreeMap有什么区别?
首先,这四种数据结构都实现了Map接口,但是各有各的不同。
| 数据结构 | 特点 |
|---|---|
| HashMap | HashMap不是线程安全的,且不保证顺序,最多只允许一条记录的键为null。但是由于它是根据数据的hash值获取数据,所以查找效率很高 |
| LinkedHashMap | 可以插入空值,查找时根据插入的顺序来获取数据,所以先插入的先被找到。但是问题就是这样会导致查找效率降低。同样,也是线程不安全的。 |
| HashTable | HashTable是不能存放空值的,另外如果去看HashTable中的方法及参数,发现基本上都是加了synchornized标识的,这就说明HashTable在同一时刻只能被单独一条线程所访问,这就保证了在多线程情况下的安全性,但是同样带来了写入效率较慢的问题。 |
| TreeMap | treeMap不同于HashMap之处在于它是有序的,传入treeMap的参数必须实现Comparable接口,也就是为treeMap指定排序方法,否则就会报错。且键、值不能为空,也不支持在多线程环境下保证安全性。 |
9.能否简单描述一下HashMap的get(),put()方法?
- get()方法: 看源代码:
public V get(Object key) {
Node<K,V> e;
return (e = getNode(hash(key), key)) == null ? null : e.value;
}
拿到key的哈希值,多态进入另一个get()方法。截取部分代码:
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);
}
根据拿到的Hash值进入桶中,如果第一个元素就是要找的数那就返回,否咋就按照链表或者红黑树进行查找。
- put()方法::先进入put方法
注意:onlyifAbsent表示插入重复的键值对时是否保留原来的,ture表示保留原来的,false表示用新的覆盖掉旧的。evict表示建造者模式,设计模式的一种。 接下来就一段段分析代码:
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
如果table为空,就初始化一个。
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
如果要插入的位置上为空,则直接new一个Node在这个位置上。
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
如果插入的位置上key,value都重复了,直接覆盖。
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;
}
}
如果是链表节点,就按链表节点插入。
10.HashMap是如何实现减少hash碰撞的,解决hash碰撞的方法还有哪些?
在JDK1.7中,计算hash值的方法是:
h ^= (h >>> 20) ^ (h >>> 12);
return h ^ (h >>> 7) ^ (h >>> 4);
咋一看可能不知道这段代码在干什么,其实随便拿两个数字试一试就明白了, 自己模拟这个过程:
private static int cacIndex(int h, int size) {
return h & (size - 1);
}
public static int cacHashCode(int h) {
h ^= (h >>> 20) ^ (h >>> 12);
return h ^ (h >>> 7) ^ (h >>> 4);
}
随便用两个按道理会产生hash碰撞的数字:
public static void main(String[] args) {
System.out.println(cacIndex(212123371, 4));
System.out.println(cacIndex(121311123, 4));
}
结果如下:
3
3
Process finished with exit code 0
调用扰动方法:
public static void main(String[] args) {
System.out.println(cacIndex(cacHashCode(212123371), 4));
System.out.println(cacIndex(cacHashCode(121311123), 4));
}
结果:
0
1
Process finished with exit code 0
那么给出结论:
HashMap减少哈希碰撞的方法就在于使用扰动方法,使得hashCode的高低位都参与计算,所以降低了因高位不同,低位相同的hashCode在HashMap的容量较小时而导致哈希碰撞的概率。
与此同时,这个方法在JDK1.8中简化为:
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
其他解决哈希碰撞的方法:
- 1.开放定址法:
开发定址法就是当发生冲突时,就往后面找空的位置,把数值存入数据为空的地址上。
- 2.链地址法:
就像HashMap做的那样,发生冲突时,就把发生冲突的位置进化成一个链表,然后把后面产生冲突的数值插入链表中。
- 3.再哈希法:
正如他的名字一样,当产生哈希碰撞的时候,用另一个函数去计算哈希值,直到不产生碰撞为止。
- 4.建立公共溢出去区:
既把产生冲突的数值不插入表中,而是新建一个溢出表,把冲突数值放入其中。
11.多线程情况情况下如何创建HashMap,它实现线程安全的的原理是什么?
都知道在多线程情况下可以使用ConcurrentHashMap来创建线程安全的HashMap,但是它为什么是线程安全的?
先看JDK1.7中的源代码:
/**
* Segments are specialized versions of hash tables. This
* subclasses from ReentrantLock opportunistically, just to
* simplify some locking and avoid separate construction.
*/
static final class Segment<K,V> extends ReentrantLock implements Serializable
从ConcurrentHashMap的描述中可以看到,在JDK1.7中使用的是分段锁技术,既使用继承了ReentrantLock类的Segment对每一段进行加锁,然后在put方法之前,会先检查当前线程有没有持有锁,put方法部分源代码:
final V put(K key, int hash, V value, boolean onlyIfAbsent) {
HashEntry<K,V> node = tryLock() ? null :
scanAndLockForPut(key, hash, value);
V oldValue;
而put()方法
public V put(K key, V value) {
Segment<K,V> s;
if (value == null)
throw new NullPointerException();
int hash = hash(key);
int j = (hash >>> segmentShift) & segmentMask;
if ((s = (Segment<K,V>)UNSAFE.getObject // nonvolatile; recheck
(segments, (j << SSHIFT) + SBASE)) == null) // in ensureSegment
s = ensureSegment(j);
return s.put(key, hash, value, false);
}
中的value更是加上了volatile关键字,也保证了线程安全。
static final class HashEntry<K,V> {
final int hash;
final K key;
volatile V value;
volatile HashEntry<K,V> next;
HashEntry(int hash, K key, V value, HashEntry<K,V> next) {
this.hash = hash;
this.key = key;
this.value = value;
this.next = next;
}
那么在Java8中,情况又不同了起来:
Java8舍弃了分段式锁的模式,而是采用synchronized关键字以及CAS(compare and swap)算法相结合的机制,实现线程安全。何谓CAS算法:比较并交换(compare and swap, CAS),是原子操作的一种,可用于在多线程编程中实现不被打断的数据交换操作,从而避免多线程同时改写某一数据时由于执行顺序不确定性以及中断的不可预知性产生的数据不一致问题。 该操作通过将内存中的值与指定数据进行比较,当数值一样时将内存中的数据替换为新的值。(源自维基百科)
那么进入ConcurrentHashMap源代码中:
截取put()方法中的部分源码:
synchronized (f) {
if (tabAt(tab, i) == f) {
if (fh >= 0) {
binCount = 1;
for (Node<K,V> e = f;; ++binCount) {
K ek;
if (e.hash == hash &&
((ek = e.key) == key ||
(ek != null && key.equals(ek)))) {
oldVal = e.val;
if (!onlyIfAbsent)
e.val = value;
break;
}
Node<K,V> pred = e;
if ((e = e.next) == null) {
pred.next = new Node<K,V>(hash, key,
value, null);
break;
}
}
}
else if (f instanceof TreeBin) {
Node<K,V> p;
binCount = 2;
if ((p = ((TreeBin<K,V>)f).putTreeVal(hash, key,
value)) != null) {
oldVal = p.val;
if (!onlyIfAbsent)
p.val = value;
}
}
可以看到,对于put方法的插入操作都是在synchornized操作下的,所以同一时间最多只能有一条线程进行操作,并且对于交换数值的操作:
static final <K,V> boolean casTabAt(Node<K,V>[] tab, int i,
Node<K,V> c, Node<K,V> v) {
return U.compareAndSwapObject(tab, ((long)i << ASHIFT) + ABASE, c, v);
}
是通过实现CAS算法来实现先比较再交换的,保证了当前位置处的数值为空时,进行交换操作的安全性。
参考资料:
- 1.简书.《HashMap的put方法源码(jdk1.8)》跳转至源文章
- 2.掘金.《MarkDown 插入数学公式实验大集合》跳转至源文章
- 3.酷壳.《疫苗:JAVA HASHMAP的死循环》跳转至源文章
- 4.知乎.《一文读懂HashMap》跳转至源文章
- 5.维基百科.《ASCII》 跳转至源文章
- 6.维基百科.《红黑树》跳转至源文章
- 7.掘金.《漫画:什么是红黑树?》跳转至源文章
- 8.简书.《深入理解CAS算法》 跳转至源文章
- 9.博客园.《HashMap?面试?我是谁?我在哪?》跳转至源文章
- 10.掘金.《彻底理解volatile关键字》跳转至源文章
- 11.知乎.《这段代码是什么意思?代码源自HashMap中的hash实现》跳转至文章
- 12.Hollins.《全网把Map中的hash()分析得最透彻得文章,别无二家》跳转至文章