这次主要是分析下HashMap的工作原理,为什么我会拿这个东西出来分析,原因很简单,以前我面试的时候,偶尔问起HashMap,99%的程序员都知道HashMap,基本都会用Hashmap,这其中不仅仅包括刚毕业的大学生,也包括已经工作5年,甚至是10年的程序员。HashMap涉及的知识远远不止put和get那么简单。本次的分析希望对于面试的人起码对于面试官的问题有所应付
一、先来回忆下我的面试过程
问:“你用过HashMap,你能跟我说说它吗?
答:“当然用过,HashMap是一种
问:“你知道HashMap 在put和get的时候是怎么工作的吗?”
答:“HashMap是通过key计算出Hash值,然后将这个Hash值映射到对象的引用上,get的时候先计算key的hash值,然后找到对象”。这个时候已经显得不自信了。
问:“HashMap的key为什么一般用字符串比较多,能用其他对象,或者自定义的对象吗?为什么?”
答:“这个没研究过,一般习惯用String。”
问:“你刚才提到HashMap不是线程安全的,你怎么理解线程安全。原理是什么?几种方式避免线程安全的问题。”
答:“线程安全就是多个线程去访问的时候,会对对象造成不是预期的结果,一般要加锁才能线程安全。”
其实,问了以上那些问题,我基本能判定这个程序员的基本功了,一般技术中等,接下来的问题没必要问了。
从我的个人角度来看,HashMap的面试问题能够考察面试者的线程问题、Java内存模型问题、线程可见与不可变问题、Hash计算问题、链表结构问题、二进制的&、|、<<、>>等问题。所以一个HashMap就能考验一个人的技术功底了。
二、概念分析
1、HashMap的类图结构
此处的类图是根据JDK1.6版本画出来的。如下图1:
2、HashMap存储结构
** **HashMap的使用那么简单,那么问题来了,它是怎么存储的,它的存储结构是怎样的,很多程序员都不知道,其实当你put和get的时候,稍稍往前一步,你看到就是它的真面目。其实简单的说HashMap的存储结构是由数组和链表共同完成的。如图:
从上图可以看出HashMap是Y轴方向是数组,X轴方向就是链表的存储方式。大家都知道数组的存储方式在内存的地址是连续的,大小固定,一旦分配不能被其他引用占用。它的特点是查询快,时间复杂度是O(1),插入和删除的操作比较慢,时间复杂度是O(n),链表的存储方式是非连续的,大小不固定,特点与数组相反,插入和删除快,查询速度慢。HashMap可以说是一种折中的方案吧。
3、HashMap基本原理
1、首先判断Key是否为Null,如果为null,直接查找Enrty[0],如果不是Null,先计算Key的HashCode,然后经过二次Hash。得到Hash值,这里的Hash特征值是一个int值。
2、根据Hash值,要找到对应的数组啊,所以对Entry[]的长度length求余,得到的就是Entry数组的index。
3、找到对应的数组,就是找到了所在的链表,然后按照链表的操作对Value进行插入、删除和查询操作。
4、HashMap概念介绍
5、HashMap初始化
默认情况下,大多数人都调用new HashMap()来初始化的,我在这里分析new HashMap(int initialCapacity, float loadFactor)的构造函数,代码如下:
由上面的代码可以看出,初始化的时候需要知道初始化的容量大小,因为在后面要通过按位与的Hash算法计算Entry数组的索引,那么要求Entry的数组长度是2的N次方。
6、HashMap中的Hash计算和碰撞问题
HashMap的hash计算时先计算hashCode(),然后进行二次hash。代码如下:
// 计算二次Hash int hash = hash(key.hashCode()); // 通过Hash找数组索引 int i = indexFor(hash, table.length);
先不忙着学习HashMap的Hash算法,先来看看JDK的String的Hash算法。代码如下:
从JDK的API可以看出,它的算法等式就是s[0]31^(n-1) + s[1]31^(n-2) + ... + s[n-1],其中s[i]就是索引为i的字符,n为字符串的长度。这里为什么有一个固定常量31呢,关于这个31的讨论很多,基本就是优化的数字,主要参考Joshua Bloch's Effective Java的引用如下:
The value 31 was chosen because it is an odd prime. If it were even and the multiplication overflowed, information would be lost, as multiplication by 2 is equivalent to shifting. The advantage of using a prime is less clear, but it is traditional. A nice property of 31 is that the multiplication can be replaced by a shift and a subtraction for better performance: 31 * i == (i << 5) - i. Modern VMs do this sort of optimization automatically.
大体意思是说选择31是因为它是一个奇素数,如果它做乘法溢出的时候,信息会丢失,而且当和2做乘法的时候相当于移位,在使用它的时候优点还是不清楚,但是它已经成为了传统的选择,31的一个很好的特性就是做乘法的时候可以被移位和减法代替的时候有更好的性能体现。例如31i相当于是i左移5位减去i,即31i == (i<<5)-i。现代的虚拟内存系统都使用这种自动优化。
现在进入正题,HashMap为什么还要做二次hash呢? 代码如下:
回答这个问题之前,我们先来看看HashMap是怎么通过Hash查找数组的索引的。
/** * Returns index for hash code h. */ static int indexFor(int h, int length) { return h & (length-1); }
其中h是hash值,length是数组的长度,这个按位与的算法其实就是h%length求余,一般什么情况下利用该算法,典型的分组。例如怎么将100个数分组16组中,就是这个意思。应用非常广泛。
既然知道了分组的原理了,那我们看看几个例子,代码如下:
运行结果都是15,为什么呢?我们换算成二进制来看看。
这里你就发现了,在做按位与操作的时候,后面的始终是低位在做计算,高位不参与计算,因为高位都是0。这样导致的结果就是只要是低位是一样的,高位无论是什么,最后结果是一样的,如果这样一来,hash碰撞始终在一个数组上,导致这个数组开始的链表无限长,那么在查询的时候就速度很慢,又怎么算得上高性能的啊。所以hashmap必须解决这样的问题,尽量让key尽可能均匀的分配到数组上去。避免造成Hash堆积。
回到正题,HashMap怎么处理这个问题,怎么做第二次Hash。
这里就是解决Hash的的冲突的函数,解决Hash的冲突有以下几种方法: 1. 开放定址法 线性探测再散列,二次探测再散列,伪随机探测再散列) 2. 再哈希法 3. 链地址法 4. 建立一 公共溢出区
而HashMap采用的是链地址法,这几种方法在以后的博客会有单独介绍,这里就不做介绍了。
7、HashMap的put()解析
以上说了一些基本概念,下面该进入主题了,HashMap怎么存储一个对象的,代码如下:
/** * Associates the specified value with the specified key in this map. * If the map previously contained a mapping for the key, the old * value is replaced. * * @param key key with which the specified value is to be associated * @param value value to be associated with the specified key * @return the previous value associated with key, or * null if there was no mapping for key. * (A null return can also indicate that the map * previously associated null with key.) */ public V put(K key, V value) { if (key == null) return putForNullKey(value); int hash = hash(key.hashCode()); int i = indexFor(hash, table.length); for (Entrye = 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; }
从代码可以看出,步骤如下:
(1) 首先判断key是否为null,如果是null,就单独调用putForNullKey(value)处理。代码如下:
从代码可以看出,如果key为null的值,默认就存储到table[0]开头的链表了。然后遍历table[0]的链表的每个节点Entry,如果发现其中存在节点Entry的key为null,就替换新的value,然后返回旧的value,如果没发现key等于null的节点Entry,就增加新的节点。
(2) 计算key的hashcode,再用计算的结果二次hash,通过indexFor(hash, table.length);找到Entry数组的索引i。
(3) 然后遍历以table[i]为头节点的链表,如果发现有节点的hash,key都相同的节点时,就替换为新的value,然后返回旧的value。
(4) modCount是干嘛的啊? 让我来为你解答。众所周知,HashMap不是线程安全的,但在某些容错能力较好的应用中,如果你不想仅仅因为1%的可能性而去承受hashTable的同步开销,HashMap使用了Fail-Fast机制来处理这个问题,你会发现modCount在源码中是这样声明的。
volatile关键字声明了modCount,代表了多线程环境下访问modCount,根据JVM规范,只要modCount改变了,其他线程将读到最新的值。其实在Hashmap中modCount只是在迭代的时候起到关键作用。
private abstract class HashIteratorimplements Iterator { Entry next; // next entry to return int expectedModCount; // For fast-fail int index; // current slot Entry current; // current entry HashIterator() { expectedModCount = modCount; if (size > 0) { // advance to first entry Entry[] t = table; while (index < t.length && (next = t[index++]) == null) ; } } public final boolean hasNext() { return next != null; } final Entry nextEntry() { // 这里就是关键 if (modCount != expectedModCount) throw new ConcurrentModificationException(); Entry e = next; if (e == null) throw new NoSuchElementException(); if ((next = e.next) == null) { Entry[] t = table; while (index < t.length && (next = t[index++]) == null) ; } current = e; return e; } public void remove() { if (current == null) throw new IllegalStateException(); if (modCount != expectedModCount) throw new ConcurrentModificationException(); Object k = current.key; current = null; HashMap.this.removeEntryForKey(k); expectedModCount = modCount; } }
使用Iterator开始迭代时,会将modCount的赋值给expectedModCount,在迭代过程中,通过每次比较两者是否相等来判断HashMap是否在内部或被其它线程修改,如果modCount和expectedModCount值不一样,证明有其他线程在修改HashMap的结构,会抛出异常。
所以HashMap的put、remove等操作都有modCount++的计算。
(5) 如果没有找到key的hash相同的节点,就增加新的节点addEntry(),代码如下:
void addEntry(int hash, K key, V value, int bucketIndex) { Entrye = table[bucketIndex]; table[bucketIndex] = new Entry (hash, key, value, e); if (size++ >= threshold) resize(2 * table.length); }
这里增加节点的时候取消了,每个新添加的节点都增加到头节点,然后新的头节点的next指向旧的老节点。
(6) 如果HashMap大小超过临界值,就要重新设置大小,扩容,见第9节内容。
8、HashMap的get()解析
理解上面的put,get就很好理解了。代码如下:
别看这段代码,它带来的问题是巨大的,千万记住,HashMap是非线程安全的,所以这里的循环会导致死循环的。为什么呢?当你查找一个key的hash存在的时候,进入了循环,恰恰这个时候,另外一个线程将这个Entry删除了,那么你就一直因为找不到Entry而出现死循环,最后导致的结果就是代码效率很低,CPU特别高。一定记住。
9、HashMap的size()解析
HashMap的大小很简单,不是实时计算的,而是每次新增加Entry的时候,size就递增。删除的时候就递减。空间换时间的做法。因为它不是线程安全的。完全可以这么做。效力高。
9、HashMap的reSize()解析
当HashMap的大小超过临界值的时候,就需要扩充HashMap的容量了。代码如下:
从代码可以看出,如果大小超过最大容量就返回。否则就new 一个新的Entry数组,长度为旧的Entry数组长度的两倍。然后将旧的Entry[]复制到新的Entry[].代码如下:
在复制的时候数组的索引int i = indexFor(e.hash, newCapacity);重新参与计算。
至此,HashMap还有一些迭代器的代码,这里不一一做介绍了,在JDK1.7版本中HashMap也做了一些升级,具体有Hash因子的参与。
今天差不多完成了HashMap的源码解析,下一步将会分析ConcurrencyHashMap的源码。ConcurrencyHashMap弥补了HashMap线程不安全、HashTable性能低的缺失。是目前高性能的线程安全的HashMap类。
欢迎大家来关注我公众号地址,公众号会持续更新技术干货,分享学习进阶文档