前言
HashMap是Java集合框架中很重要的一个数据结构,掌握了它对于其它与之相关的数据结构就会迎刃而解。因为在JDK1.8对HashMap做了数据结构和扩容的优化,本文会对JDK1.7和JDK1.8的HashMap的实现原理和源码进行对比分析。
Map的集合框架
首先上一张图来直观感受下java.util.Map集合框架,Map接口有几个常用的实现类,有HashMap,HashTable,LinkedHashMap,还有TreeMap,我们对每一个做一些说明:
- HashMap: 它通过Key的hashCode()做相关的位运算得到数组中的位置索引,如果hash散列算法以及数组长度合适的话,效率很高。它可以存入键为null的键值对,JDK1.7是将其存在数组的第一个索引位置。因为它不是线程安全的,并且在高并发的情况下HashMap容易出现死循环,所以多线程环境下可以使用Collections.synchronizedMap()或者HashTable,它们两个是线程安全的,但是因为HashTable是对方法整体加锁,而Collections.synchronizedMap()是对代码块加锁,所以效率不够高,在高并发的环境下可以使用ConcurrentHashMap,它采用的是分段加锁机制。
- LinkedHashMap: 它是HashMap的子类,它和HashMap的区别是在HashMap的基础上,增加了一个双向链表来记录存储数据的先后顺序,能够保证遍历数据的时候,首先得到的是先插入的数据。
- HashTable: 它是一个面试中经常被问到的类,它不允许存入键值对为null(Key和Value都不能为null,否则会报NullPointerException)。它是线程安全的,但是因为是对方法整体加锁,所以并发性不太好。一般不使用这个类,在不要求线程安全的时候可以使用HashMap, 对线程安全有要求的时候可以使用ConcurrentHashMap。
- TreeMap: 它实现了SortedMap接口,查看put源码可以看到,key必须实现Comparable接口或者在构造TreeMap传入自定义的Comparator,否则会在运行时抛出java.lang.ClassCastException类型的异常。它能够把保存的记录根据键排序,默认是按键值的升序排序,可以在构造方法中指定排序的比较器,当用Iterator遍历TreeMap时,得到的记录是排过序的。如果使用排序的映射,建议使用TreeMap。
HashMap的数据结构
-
HashMap采用的是数组加链表的数据结构(JDK1.8中增加了红黑树),首先我们要知道HashMap中存储的是什么,我们通过查看JDK1.7中和JDK1.8中的源码得知,在1.7中存储的是HashMapEntry,在1.8中存储的是Node(当链表长度大于8时候转换为红黑树), 源码如下:
// JDK 1.7 中存储的元素 static class HashMapEntry<K,V> implements Map.Entry<K,V> { final K key; V value; HashMapEntry<K,V> next; int hash; HashMapEntry(int h, K k, V v, HashMapEntry<K,V> n) { value = v; next = n; key = k; hash = h; } } // JDK 1.8 中存储的元素 static class Node<K,V> implements Map.Entry<K,V> { final int hash; final K key; V value; Node<K,V> next; Node(int hash, K key, V value, Node<K,V> next) { this.hash = hash; this.key = key; this.value = value; this.next = next; } }
-
由上可知,基本上就是换了个名字而已,他们是HashMap中的一个静态类,实现Entry接口。
-
HashMap是一个数组加链表的数据结构(在JDK1.8中当链表长度大于8时候会转换为红黑树),它的结构图如下:
HashMap简介
-
HashMap内部采用哈希表来存储,因为有hash冲突,采用的是链地址法,就是数组加链表的结构。每一个数组位置上都可能是一个链表,当有冲突的时候,就将新的值要么覆盖原来的位置,要么放在对应索引的链表的末尾。所以想要有高的存取效率就得设计好的hash散列算法,散列的越均匀,碰撞的概率就越小,存取效率就会越高。
-
HashMap内部有几个重要的成员变量:
table: HashMap内部维护的一个一维数组,它的长度必须2的幂,后面会详细讲解原因。
size:数组中真实存储的键值对(Entry)的数量。
capacity: 数组的容量,等于table.length()。
loadFactor:加载因子,默认是0.75,此数值用来衡量Hashmap存储的疏散程度。这个值尽量不要修改,它肯定是官方经过很多研究才确定下来的。这个值是对时间和空间的一个权衡,如果内存多并且对效率要求比较好,可以降低这个因子的值,这样数组中的元素较少的时候就会扩容,减少了hash碰撞的几率,效率就会提升。反之如果内存少并且对效率不是很高的时候,可以增加这个值,使得数组长度一定,链表长度变长,就增加了hash碰撞的可能性,效率就会降低。
threshold: 扩容阀值,它等于capacity * loadFactor ,当Hashmap的size大于或者等于 threshold 时,Hashmap将进行扩容(resize)。
-
无论多么完美的设计都避免不了hash碰撞的可能性,在JDK1.8中,如果链表长度太长的话(默认是超过8)就会转换为红黑树结构,可以点击教你初步了解红黑树了解。
HashMap实现原理与源码分析
- 按照我们使用首先会使用
new
关键字实例化一个HashMap对象,那首先我们看它的构造方法(JDK1.7):static final HashMapEntry<?,?>[] EMPTY_TABLE = {}; transient HashMapEntry<K,V>[] table = (HashMapEntry<K,V>[]) EMPTY_TABLE; public HashMap(int initialCapacity, float loadFactor) { if (initialCapacity < 0) throw new IllegalArgumentException("Illegal initial capacity: " + initialCapacity); if (initialCapacity > MAXIMUM_CAPACITY) { initialCapacity = MAXIMUM_CAPACITY; } else if (initialCapacity < DEFAULT_INITIAL_CAPACITY) { initialCapacity = DEFAULT_INITIAL_CAPACITY; } if (loadFactor <= 0 || Float.isNaN(loadFactor)) throw new IllegalArgumentException("Illegal load factor: " + loadFactor); threshold = initialCapacity; init(); }
- 构造方法中并没有对
table
数组进行初始化的操作,实际上是在进行put操作的时候进行的,构造中有一个init
方法,在它的子类LinkedHashMap
中有具体的实现。接下来我们看put
方法: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 (HashMapEntry<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; }
- put方法内容就比较多了,我们一步一步来分析,首先判断
table == EMPTY_TABLE
为true,就会执行inflateTable(threshold)
函数,源码如下:private void inflateTable(int toSize) { // Find a power of 2 >= toSize int capacity = roundUpToPowerOf2(toSize); float thresholdFloat = capacity * loadFactor; if (thresholdFloat > MAXIMUM_CAPACITY + 1) { thresholdFloat = MAXIMUM_CAPACITY + 1; } threshold = (int) thresholdFloat; table = new HashMapEntry[capacity]; }
inflateTable(threshold)
函数首先执行roundUpToPowerOf2
方法,roundUpToPowerOf2
的源码和图解如下:private static int roundUpToPowerOf2(int number) { // assert number >= 0 : "number must be non-negative"; int rounded = number >= MAXIMUM_CAPACITY ? MAXIMUM_CAPACITY : (rounded = Integer.highestOneBit(number)) != 0 ? (Integer.bitCount(number) > 1) ? rounded << 1 : rounded : 1; return rounded; }
- roundUpToPowerOf2图解
roundUpToPowerOf2
返回一个大于等于toSize
的2次幂的容量值(capacity),然后将获取的新的容量capacity * loadFactor
赋值给threshold,再然后就初始化了table
这个数组了,所以table
的初始化是在put
方法中,而不是构造方法中。这里roundUpToPowerOf2
为什么一定要返回一个2次幂的值呢?我们在后面真正put的时候会详细解释。- 接着上面
put
中的源码分析,inflateTable
之后检查key==null
,如果为true的话就直接执行putForNullKey(value)
方法,说明HashMap是可以放入<Key,Value>为null的值的,但是HashTable
是不可以的,会报空指针异常的错误。查看源码知道,这里把key为null的值存储在了数组的第一个位置。 - 再然后根据key值计算在数组中的索引位置,先执行
hashCode
,再执行hash
算法,再执行indexFor(hash, table.length)
,我们来比较一下JDK1.7和1.8中取索引位置的操作::static final int hash(Object key) { int h; return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16); } //JDK1.8中没有这个单独的函数,它是在put方法内部实现的这个逻辑,原理一样 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); }
- hash函数在JDK1.7和JDK1.8的源码中都是有的,这里先求取key的hashCode()值等于h,再对h取无符号向右移动16位,再对这两个值取异或运算,这样高位和地位都参与了运算,使得散列更加的均匀分布。
indexFor
这一步是用来获取实际在数组table
中存储的位置,返回的是数组的下标。这里获取数组下标的方式采用的是位运算,而不是index = HashCode(Key) % Length
的取模运算是因为位运算更加的高效。这里就可以解释为什么要使用2次幂作为数组的长度了,我们举例说明,如果这里以cat
为key举例说明h & (length-1)
的值。- cat的hash值为98263,二进制为
10111111111010111
,假定数组长度为16,那么这里indexFor
函数就是执行10111111111010111 & 1111 = 0111
,等于7。 - 我们看到其实不管我们put的是什么值,其实只和它的hash值的末尾几位相关。这样不但结果上完全和取模一样,而且采用位运算很高效。
- 如果这里数组的长度不是16,而是9的话,执行
10111111111010111 & 1000 = 0000
,等于0。如果末尾0010 & 1000 = 0000
或者0100 & 1000 = 0000
,我们看到结果都是0,这样就在一定的程度上,有的数组位置可能一直处于空置状态,就大大增加了其它位置的hash碰撞的可能性,那就不是一个好的数据结构了,所以这里要将数组长度设置为2次幂。
- cat的hash值为98263,二进制为
- 获取完数组的下标索引之后就可以执行put操作了,再继续之前,我们先看一下
HashMapEntry
的数据结构,源码如下:static class HashMapEntry<K,V> implements Map.Entry<K,V> { final K key; V value; HashMapEntry<K,V> next; int hash; /** * Creates new entry. */ HashMapEntry(int h, K k, V v, HashMapEntry<K,V> n) { value = v; next = n; key = k; hash = h; } public final K getKey() { return key; } public final V getValue() { return value; } }
HashMapEntry
属于HashMap的一个静态内部类,next
存储指向下一个Entry的引用,是一个单链表结构,hash是对key的hashcode值运算后得到的值,存储在Entry,避免重复计算。
Put操作
- 接着进行put的操作,这里因为举例前面太远,就再贴下源码:
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 (HashMapEntry<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; }
- 我们看到根据索引值在数组中找到了对应的位置的对象,执行for循环:
- 如果这个位置的
HashMapEntry
不为null的话,并且key的hash值和equal值都相等的话,说明是要覆盖此处之前的值,并且返回之前的旧Value值。 - 如果这个地方的
HashMapEntry
不为null,并且key的hash值不相等,说明产生了冲突,就需要处理,接着执行addEntry
方法,源码如下:
void addEntry(int hash, K key, V value, int bucketIndex) { if ((size >= threshold) && (null != table[bucketIndex])) { resize(2 * table.length); hash = (null != key) ? sun.misc.Hashing.singleWordWangJenkinsHash(key) : 0; bucketIndex = indexFor(hash, table.length); } createEntry(hash, key, value, bucketIndex); }
- 如果这个位置的
addEntry
方法中,首先会判断容量是否超过阈值,如果是的话就要使用resize()
方法扩容,扩容后的数组还是2次幂大小,resize源码如下:void resize(int newCapacity) { HashMapEntry[] oldTable = table; int oldCapacity = oldTable.length; if (oldCapacity == MAXIMUM_CAPACITY) { threshold = Integer.MAX_VALUE; return; } HashMapEntry[] newTable = new HashMapEntry[newCapacity]; transfer(newTable); table = newTable; threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1); }
- 我们看到了将数组长度扩大了一倍,并且为
table
和threshold
重新赋值。接着看transfer(newTable)
方法:void transfer(HashMapEntry[] newTable) { int newCapacity = newTable.length; for (HashMapEntry<K,V> e : table) { while(null != e) { HashMapEntry<K,V> next = e.next; int i = indexFor(e.hash, newCapacity); e.next = newTable[i]; newTable[i] = e; e = next; } } }
- 其实
resize
扩容的真正逻辑都在transfer
这里了,这里会打破原来的位置信息,对每一个元素重新根据indexFor
函数求位置索引,然后将其放入新的数组中(PS:众所周知,扩容在高并发的时候会出现链表环形的情况,当获取值的时候会陷入死循环,详细信息可以查看高并发下的HashMap)。 - 扩容完之后执行
addEntry
方法,此方法源码如下,由源码可知,将旧的HashMapEntry
值放在了链表的尾端,将新的值放在了链表的头部,之所以这样做是基于新值被使用到的可能性更大,在链表的头部,会便于更早点获得
void createEntry(int hash, K key, V value, int bucketIndex) { HashMapEntry<K,V> e = table[bucketIndex]; table[bucketIndex] = new HashMapEntry<>(hash, key, value, e); size++; }
Get操作
- 接下来我们看下get方法的源码:
public V get(Object key) { if (key == null) return getForNullKey(); Entry<K,V> entry = getEntry(key); return null == entry ? null : entry.getValue(); }
- 如果key为null的话,就直接调用
getForNullKey
方法,根据前面分析知道,这里其实是在第一个索引位置查找。如果key不为null的话就调用getEntry
方法获取Entry对象,源码如下:final Entry<K,V> getEntry(Object key) { if (size == 0) { return null; } int hash = (key == null) ? 0 : sun.misc.Hashing.singleWordWangJenkinsHash(key); for (HashMapEntry<K,V> e = table[indexFor(hash, table.length)]; e != null; e = e.next) { Object k; if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k)))) return e; } return null; }
- 如果知道了put的原理的话,这里get的原理就很简单了,根据
indexFor
函数求取在数组中的索引位置,然后调用table[indexFor(hash, table.length)]
得到此处的值,使用for循环,查找此位置的链表中和要查询的key值匹配的元素,返回即可。
总结
本文基于JDK1.7源码讲述了HashMap的实现原理,并对JDK1.8源码做了进了对比分析,希望对大家有多帮助。