HashMap的相关问题能够说是面试中很常见的问题了,网上也能看到很是多的讲解。可是我的感受,看的再多都不如本身实打实的写一篇总结来的收获多。
html
首先介绍什么是hash表,散列表(Hash table,也叫哈希表),是根据键(Key)而直接访问在内存存储位置的数据结构。也就是说,它经过计算一个关于键值的函数,将所需查询的数据映射到表中一个位置来访问记录,这加快了查找速度。这个映射函数称作散列函数,存放记录的数组称作散列表。(描述引自维基百科)在String类的hashcode()方法中,能够看到这个计算哈希值的过程:java
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算法有两条性质:不可逆和无冲突
node
说到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>
复制代码
翻译一下就是:shell
第三重要的约定是compareTo约定
编程
因此问出第一个问题:设计模式
在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,也就是当链表达到必定的长度以后,会转换成一棵红黑树。具体过程在后面作详细介绍。
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的幂。
首先咱们知道,若是一直往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才会发生由链表向二叉树的转换。
关于为何是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),会下降扩容的效率。可是过小的话,扩容十分频繁,又会占用大量的内存空间。
在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);
}
复制代码
经过这个调整大小的方法,咱们能够看出,扩容的机制就是把原来的数据丢到一个大小是原来两倍大的新表中,而且会从新计算索引值(注意,就是这个过程,将会致使一个很致命的问题)。
这里推荐一篇讲这部分过程的文章 coolshell.cn/articles/96… 里面说的很清楚:
首先,红黑树是一中自平衡二叉查找树,由于传统的二叉树在特殊情$况下会造成一个近似链表的树,很影响效率,因此引出红黑树做为替代。
红黑树的特性(源自维基百科):
红黑树是每一个节点都带有颜色属性的二叉查找树,颜色为红色或黑色。在二叉查找树强制通常要求之外,对于任何有效的红黑树咱们增长了以下的额外要求:
首先,这四种数据结构都实现了Map接口,可是各有各的不一样。
数据结构 | 特色 |
---|---|
HashMap | HashMap不是线程安全的,且不保证顺序,最多只容许一条记录的键为null。可是因为它是根据数据的hash值获取数据,因此查找效率很高 |
LinkedHashMap | 能够插入空值,查找时根据插入的顺序来获取数据,因此先插入的先被找到。可是问题就是这样会致使查找效率下降。一样,也是线程不安全的。 |
HashTable | HashTable是不能存放空值的,另外若是去看HashTable中的方法及参数,发现基本上都是加了synchornized标识的,这就说明HashTable在同一时刻只能被单独一条线程所访问,这就保证了在多线程状况下的安全性,可是一样带来了写入效率较慢的问题。 |
TreeMap | treeMap不一样于HashMap之处在于它是有序的,传入treeMap的参数必须实现Comparable接口,也就是为treeMap指定排序方法,不然就会报错。且键、值不能为空,也不支持在多线程环境下保证安全性。 |
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值进入桶中,若是第一个元素就是要找的数那就返回,否咋就按照链表或者红黑树进行查找。
注意: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;
}
}
复制代码
若是是链表节点,就按链表节点插入。
在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);
复制代码
其余解决哈希碰撞的方法:
都知道在多线程状况下可使用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算法来实现先比较再交换的,保证了当前位置处的数值为空时,进行交换操做的安全性。
参考资料: