近日无事看看源码,昨天看了ArrayList,今天看下HashMap。HashMap有多重要?只要去面试,Hashtable和Hashmap的区别基本是必问的知识点,HashMap是否线程安全以及并发情况下的concurrentHashMap也基本是必问的。跟看ArrayList一样,对于Hashmap的各种操作过程我也是通过一个小例子debug来查看每一步的运行情况。
package com.sgx.source;
import java.util.HashMap;
public class Test {
public static void main(String[] args) {
HashMap map = new HashMap();
map.put("1","sgx");
map.put("2", "value");
map.remove("1");
}
}
1.数据结构
紫色是table,每一个table都是一个链表的头(table[0],也是entry[0]),绿色是一个一个的Entry如果hash值计算的一样的话,entry会连接在一起,addEntry方法中会看到这是个单链表的插入,新的元素插在table[i]。图片出处:http://blog.youkuaiyun.com/sinat_32873711/article/details/54097992
Ctrl+左键跟入HashMap.class内部,我们发现HashMap定义如下:
public class HashMap<K,V>
extends AbstractMap<K,V>
implements Map<K,V>, Cloneable, Serializable
{
/**
* The default initial capacity - MUST be a power of two.
*/
static final int DEFAULT_INITIAL_CAPACITY = 16;
/**
* The maximum capacity, used if a higher value is implicitly specified
* by either of the constructors with arguments.
* MUST be a power of two <= 1<<30.
*/
static final int MAXIMUM_CAPACITY = 1 << 30;
/**
* The load factor used when none specified in constructor.
*/
static final float DEFAULT_LOAD_FACTOR = 0.75f;
/**
* The table, resized as necessary. Length MUST Always be a power of two.
*/
transient Entry[] table;
/**
* The number of key-value mappings contained in this map.
*/
transient int size;
/**
* The next size value at which to resize (capacity * load factor).
* @serial
*/
int threshold;
/**
* The load factor for the hash table.
*
* @serial
*/
final float loadFactor;
/**
* The number of times this HashMap has been structurally modified
* Structural modifications are those that change the number of mappings in
* the HashMap or otherwise modify its internal structure (e.g.,
* rehash). This field is used to make iterators on Collection-views of
* the HashMap fail-fast. (See ConcurrentModificationException).
*/
transient volatile int modCount;
实现了Map接口,可克隆,可序列化,有一个默认初始容量(16,必须是2得N次幂),最大容量(1<<30=2^30),装载因子0.75,存储数据的是一个Entry类型的数组(长度为2^n),HashMap的size即存储得key-value对得数目。注意到table定义是transient,参考上一篇中的内容,可以知道这个Entry数组不能被直接序列化。threshold是重新分配空间时的大小,计算公式为容量(capacity*load factor);装载因子是一个为了重新分配空间大小设置的系数,默认是0.75,0.75是对时间和空间都相对优化的一个选择,装载因子过小会导致这个散列表很稀疏,空间造成浪费,如果装载因子过大,会resize扩容,每次扩充后的大小为原来的两倍,而且涉及到数组的复制,这样的话空间虽然够了但是查找效率会降低。
,modCount在jdk源码注释中这样解释:modCount是这个Hashmap已经被结构化修改的次数,这个参数主要是为了迭代器在遍历集合fail-fast的时候抛出ConcurrentModificationEception。
fail-fast(快速失败机制):是Java集合的一种错误检测机制。当多个线程操作同一个集合的时候,假如有AB两个Thread,Thread-A用迭代器遍历集合,Thread-B在这个过程中修改了集合的结构(是修改结构,不是增删元素),此时会抛出ConcurrentModificationEception异常,这就是快速失败机制。
下面我们看到实际HashMap存储数据的结构是一个Entry数组,Entry又是什么东西呢?点进去,如下:
static class Entry<K,V> implements Map.Entry<K, V>{
final K key;
V value;
Entry<K,V>next;
final int hash;
}
看到这个很容易联想到一个数据结构,单链表,不过是域不同罢了,这个Entry就是个单链表。
看完结构,我们现在开始测试上面的程序为了加深印象,下面的源码我都会手敲下。
2.初始化
我们来看下new HashMap的时候发生了什么吧。调用了下面的构造方法:
public HashMap() {
this.loadFactor = DEFAULT_LOAD_FACTOR;
threshold = (int)(DEFAULT_INITIAL_CAPACITY * DEFAULT_LOAD_FACTOR);
table = new Entry[DEFAULT_INITIAL_CAPACITY];
init();
}
给装载因子,threshold以及table赋值,init()是个空方法。table在new的时候如下:
static class Entry<K,V> implements Map.Entry<K, V>{
final K key;
V value;
Entry<K,V>next;
final int hash;
Entry(int h,K k,V v,Entry<K,V>n){
value = v;
next = n;
key = k;
hash = h;
}
}
就是给Entry中的几个元素赋值,key,value,下一个节点,以及hash值。
3.添加
执行map.put(),我们跟进方法中查看put的源码如下:
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 (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;
}
先会判断key是否为空,为空的话会把空值放在table的第0个位置上。这也是Hashtable和HashMap第一点不同,Hashtable不允许为空,Hashmap是允许存空值的。
不为空的话会去检查table[i]上面有没有相同key的元素,如果有的话直接更新值返回即可。没有的话进入addEntry方法。
然后第二步是获取Hash值,这一步是为了让带插入的Entry均匀分布,以Key(是个Object)的hashCode为参数,执行下面的方法:
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);
}
得到一个hash结果,然后为这个结果来确定在table中的位置;
static int indexFor(int h, int length) {
return h & (length-1);
}
得到结果为i,那么当前元素Entry就插在table[i]头部的位置。下面是addEntry:
void addEntry(int hash, K key, V value, int bucketIndex) {
Entry<K,V> e = table[bucketIndex];
table[bucketIndex] = new Entry<K,V>(hash, key, value, e);
if (size++ >= threshold)
resize(2 * table.length);
}
先拿到第i个元素e,然后再第i个位置new一个Entry,赋值,并且让这个Entry指向e,就完成了一次插入操作,跟单链表的操作是一样的。值得注意的是,size比较大的时候
size>capacity*loadfactor(16*0.75=12)的时候会扩容。扩容涉及到复制片段,类似ArrayList,比较影响效率的。
两次插入完成后得到的结果为:
[null,2=value,1=sgx,null,,null,null,null,null,null,null,null,null,null,null,null,null]
4.删除
下面执行删除操作,remove的时候我们继续debug调试,执行如下方法:
public V remove(Object key) {
Entry<K,V> e = removeEntryForKey(key);
return (e == null ? null : e.value);
}
final Entry<K,V> removeEntryForKey(Object key) {
int hash = (key == null) ? 0 : hash(key.hashCode());
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;
}
我们执行了map.remove("1");此时key不为空,还是会先对key本身的hashcode做一次hash,然后把hash的结果利用indexFor求出下标,下面就是单链表的删除操作;
两个节点prev和e均指向table[i],从e开始往后遍历,如果e的key和要删除的key一致(注意比较的过程:先比较整数哈希值,在比较key,因为有短路表达式有&&运算符,一个不成立直接跳过)那么就让当前的table[i]指向他的下一个节点,不一致的话,就让e的前一个Entry也就是prev,prev的next指向e的后一个节点即可,相当于把pre往后移动一个Entry继续循环比较。
5.get方法
public V get(Object key) {
if (key == null)
return getForNullKey();
int hash = hash(key.hashCode());
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.equals(k)))
return e.value;
}
return null;
}
可以看到,传入参数Object K,返回value
(1)key==null,getForNullKey()即可;
(2)key!=null的时候,先是hash求取下标,然后遍历table[i]开头的这一条链表,在这一片链表上找到key相同的Entry返回value就行了。所有的操作都跟hash离不开。效率很高,不管查询还是插入。
5.Hashtable和HashMap区别
(1)HashMap允许空值,Hashtable会报空指针异常;
(2)Hashmap是非线程安全的,因为方法均没有同步,并发情况下用到Java.util.concurrent包下面的concurrentHashmap;
Hashtable是线程安全的。
6.Hash算法(以下内容转自点击打开链接)
我们一般对哈希表的散列很自然地会想到用hash值对length取模(即除法散列法),Hashtable中也是这样实现的,这种方法基本能保证元素在哈希表中散列的比较均匀,但取模会用到除法运算,效率很低,HashMap中则通过h&(length-1)的方法来代替取模,同样实现了均匀的散列,但效率要高很多,这也是HashMap对Hashtable的一个改进。
接下来,我们分析下为什么哈希表的容量一定要是2的整数次幂。首先,length为2的整数次幂的话,h&(length-1)就相当于对length取模,这样便保证了散列的均匀,同时也提升了效率;其次,length为2的整数次幂的话,为偶数,这样length-1为奇数,奇数的最后一位是1,这样便保证了h&(length-1)的最后一位可能为0,也可能为1(这取决于h的值),即与后的结果可能为偶数,也可能为奇数,这样便可以保证散列的均匀性,而如果length为奇数的话,很明显length-1为偶数,它的最后一位是0,这样h&(length-1)的最后一位肯定为0,即只能为偶数,这样任何hash值都只会被散列到数组的偶数下标位置上,这便浪费了近一半的空间,因此,length取2的整数次幂,是为了使不同hash值发生碰撞的概率较小,这样就能使元素在哈希表中均匀地散列。
这看上去很简单,其实比较有玄机的,我们举个例子来说明:
假设数组长度分别为15和16,优化后的hash码分别为8和9,那么&运算后的结果如下:
h & (table.length-1) hash table.length-1
8 & (15-1): 0100 & 1110 = 0100
9 & (15-1): 0101 & 1110 = 0100
-----------------------------------------------------------------------------------------------------------------------
8 & (16-1): 0100 & 1111 = 0100
9 & (16-1): 0101 & 1111 = 0101
从上面的例子中可以看出:当它们和15-1(1110)“与”的时候,产生了相同的结果,也就是说它们会定位到数组中的同一个位置上去,这就产生了碰撞,8和9会被放到数组中的同一个位置上形成链表,那么查询的时候就需要遍历这个链 表,得到8或者9,这样就降低了查询的效率。同时,我们也可以发现,当数组长度为15的时候,hash值会与15-1(1110)进行“与”,那么 最后一位永远是0,而0001,0011,0101,1001,1011,0111,1101这几个位置永远都不能存放元素了,空间浪费相当大,更糟的是这种情况中,数组可以使用的位置比数组长度小了很多,这意味着进一步增加了碰撞的几率,减慢了查询的效率!而当数组长度为16时,即为2的n次方时,2n-1得到的二进制数的每个位上的值都为1,这使得在低位上&时,得到的和原hash的低位相同,加之hash(int h)方法对key的hashCode的进一步优化,加入了高位计算,就使得只有相同的hash值的两个值才会被放到数组中的同一个位置上形成链表。
所以说,当数组长度为2的n次幂的时候,不同的key算得得index相同的几率较小,那么数据在数组上分布就比较均匀,也就是说碰撞的几率小,相对的,查询的时候就不用遍历某个位置上的链表,这样查询效率也就较高了。