HashMap的结构以及核心源码分析
摘要
对于Java开发人员来说,能够熟练地掌握java的集合类是必须的,本节想要跟大家共同学习一下JDK1.8中HashMap的底层实现与源码分析。HashMap是开发中使用频率最高的用于映射(键值对)处理的数据结构,而在JDK1.8中HashMap采用位桶数组+链表+红黑树实现的,现在我们深入探究一下HashMap的结构实现
一、HashMap简介
1、特点
- HashMap根据键的hashcode值存储数据,大多数情况可以直接定位到它的值,因而具有很快的访问速度,但遍历顺序是不确定的
想要使得遍历的顺序就是插入的顺序,可以使用LinkedHashMap,LinkedHashMap是HashMap的一个子类,保存了记录的插入顺序,在用Iterator遍历LinkedHashMap时,先得到的记录肯定是先插入的,也可以在构造时带参数,按照访问次序排序。
public class HashMapTest { public static void main(String[] args) { HashMap hashMap = new HashMap(); hashMap.put(2,"bbb"); hashMap.put(3,"ccc"); hashMap.put(1,"aaa"); System.out.println("HashMap的遍历顺序:"+hashMap); LinkedHashMap linkedHashMap = new LinkedHashMap(); linkedHashMap.put(2,"bbb"); linkedHashMap.put(3,"ccc"); linkedHashMap.put(1,"aaa"); System.out.println("LinkedHashMap的遍历顺序:"+linkedHashMap); } } Console输出 HashMap的遍历顺序:{1=aaa, 2=bbb, 3=ccc} LinkedHashMap的遍历顺序:{2=bbb, 3=ccc, 1=aaa}
- HashMap最多只允许一条记录的键为null,允许多条记录的值为null
- HashMap非线程安全,如果需要满足线程安全,可以一个Collections的synchronizedMap方法使HashMap具有线程安全能力,或者使用ConcurrentHashMap。
HashTable容器在竞争激烈的并发环境下表现出效率低下的原因,是因为所有访问HashTable的线程都必须竞争同一把锁,那假如容器里有多把锁,每一把锁用于锁容器其中一部分数据,那么当多线程访问容器里不同数据段的数据时,线程间就不会存在锁竞争,从而可以有效的提高并发访问效率,这就是ConcurrentHashMap所使用的锁分段技术,首先将数据分成一段一段的存储,然后给每一段数据配一把锁,当一个线程占用锁访问其中一个段数据的时候,其他段的数据也能被其他线程访问。
顺便说一下Hashtable,Hashtable是遗留类,很多映射的常用功能与HashMap类似,不同的是它承自Dictionary类,并且是线程安全的,任一时间只有一个线程能写Hashtable,并发性不如ConcurrentHashMap,因为ConcurrentHashMap引入了分段锁。Hashtable不建议在新代码中使用,不需要线程安全的场合可以用HashMap替换,需要线程安全的场合可以用ConcurrentHashMap替换。
2、结构
从实现结构上看,HashMap是数组+链表+红黑树(JDK1.8增加了红黑树部分)实现的,如上图所示,当链表长度超过阙值(8)时,将链表转化成红黑树,这样大大减少了查找时间
实现原理
首先每一个元素都是链表的数组,当添加一个元素(key-value)时, 就首先计算元素key的hash值,以此确定插入数组的位置,但是可能存在同一hash值的元素已经被放到数组的同一位置,这是就添加到同一hash值的元素的后面,他们在数组的同一位置形成链表,同一链表上的Hash值是相同的,所以说数组存放的是链表,而当链表长度太长时,链表就转换为红黑树这样大大提高了查找效率。
当链表数组的容量超过初始容量的0.75时,再散列将链表数组扩大2倍,把原链表数组的元素搬移到新的数组中
二、HashMap源码分析
1、核心成员变量
transient Node<K,V>[] table; //HashMap的哈希桶数组,非常重要的存储结构,用于存放表示键值对数据的Node元素。 transient Set<Map.Entry<K,V>> entrySet; //HashMap将数据转换成set的另一种存储形式,这个变量主要用于迭代功能。 transient int size; //HashMap中实际存在的Node数量,注意这个数量不等于table的长度,甚至可能大于它,因为在table的每个节点上是一个链表(或RBT)结构,可能不止有一个Node元素存在。 transient int modCount;
//HashMap的数据被修改的次数,这个变量用于迭代过程中的Fail-Fast机制,其存在的意义在于保证发生了线程安全问题时,能及时的发现(操作前备份的count和当前modCount不相等)并抛出异常终止操作。 int threshold; //HashMap的扩容阈值,在HashMap中存储的Node键值对超过这个数量时,自动扩容容量为原来的二倍。 final float loadFactor; //HashMap的负载因子,可计算出当前table长度下的扩容阈值:threshold = loadFactor * table.length。
2、HashMap常量
//默认的初始容量为16,必须是2的幂次 static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; //最大容量即2的30次方 static final int MAXIMUM_CAPACITY = 1 << 30; //默认加载因子 static final float DEFAULT_LOAD_FACTOR = 0.75f; //当put一个元素时,其链表长度达到8时将链表转换为红黑树 static final int TREEIFY_THRESHOLD = 8; //链表长度小于6时,解散红黑树 static final int UNTREEIFY_THRESHOLD = 6; //默认的最小的扩容量64,为避免重新扩容冲突,至少为4 * TREEIFY_THRESHOLD=32,即默认初始容量的2倍 static final int MIN_TREEIFY_CAPACITY = 64;
3、构造函数
//构造函数1 指定初始容量以及负载因子 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); } //构造函数2 指定初始容量 public HashMap(int initialCapacity) { this(initialCapacity, DEFAULT_LOAD_FACTOR); } //构造函数3 什么都不指定 public HashMap() { this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted } //构造函数4 指定一个map用来初始化 public HashMap(Map<? extends K, ? extends V> m) { this.loadFactor = DEFAULT_LOAD_FACTOR; putMapEntries(m, false); }
4、设计到的数据结构
(1)数组元素Node<k,v>实现了Entry接口
//Node是单向链表,它实现了Map.Entry接口 static class Node<k,v> implements Map.Entry<k,v> { final int hash; final K key; V value; Node<k,v> next; //构造函数Hash值 键 值 下一个节点 Node(int hash, K key, V value, Node<k,v> next) { this.hash = hash; this.key = key; this.value = value; this.next = next; } public final K getKey() { return key; } public final V getValue() { return value; } public final String toString() { return key + = + value; } public final int hashCode() { return Objects.hashCode(key) ^ Objects.hashCode(value); } public final V setValue(V newValue) { V oldValue = value; value = newValue; return oldValue; } //判断两个node是否相等,若key和value都相等,返回true。可以与自身比较为true public final boolean equals(Object o) { if (o == this) return true; if (o instanceof Map.Entry) { Map.Entry<!--?,?--> e = (Map.Entry<!--?,?-->)o; if (Objects.equals(key, e.getKey()) && Objects.equals(value, e.getValue())) return true; } return false; }
从这个Node<k,v>内部类可知,它实现了Map.Entry接口。内部定义的变量 有hash值、key/value键值对和实现链表和红黑树所需要的指针索引
(2)红黑树
//红黑树 static final class TreeNode<k,v> extends LinkedHashMap.Entry<k,v> { TreeNode<k,v> parent; // 父节点 TreeNode<k,v> left; //左子树 TreeNode<k,v> right;//右子树 TreeNode<k,v> prev; // needed to unlink next upon deletion boolean red; //颜色属性 TreeNode(int hash, K key, V val, Node<k,v> next) { super(hash, key, val, next); } //返回当前节点的根节点 final TreeNode<k,v> root() { for (TreeNode<k,v> r = this, p;;) { if ((p = r.parent) == null) return r; r = p; } }
5、HashMap的常用方法(put、get)
(1)put方法
public V put(K key, V value) { return putVal(hash(key), key, value, false, true); } final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) { Node<K,V>[] tab; Node<K,V> p; int n, i;
//判断键值对数组table[i]是否为空或为null,否则执行resize()进行扩容 if ((tab = table) == null || (n = tab.length) == 0) n = (tab = resize()).length;
//根据键值key计算hash值得到插入的数组索引i,如果table[i]==null,直接新建节点添加,否则如果table[i]不为null,看下面注释 if ((p = tab[i = (n - 1) & hash]) == null) tab[i] = newNode(hash, key, value, null); else { Node<K,V> e; K k;
//如果table[i]不为null,则判断table[i]的首个元素是否和key一样,如果相同直接覆盖value,否则如果不一样,则看下面注释 if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k)))) e = p;
//判断table[i]是否为treeNode,即table[i]是否是红黑树,如果是红黑树,则直接在树中插入键值对,否则,看下面注释 else if (p instanceof TreeNode) e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value); else {
//遍历table[i],判断链表长度是否大于8,大于8的话把链表转换为红黑树,在红黑树中执行插入操作,否则进行链表的插入操作,遍历过程中若发现key已经存在直接覆盖value即可。 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; } }
//key已经存在,将新value替换旧value值具体操作 if (e != null) { // existing mapping for key V oldValue = e.value; if (!onlyIfAbsent || oldValue == null) e.value = value; afterNodeAccess(e); return oldValue; } } ++modCount;
//插入成功之后,判断实际存在的键值对数量size是否超过了最大容量threshold,如果超过,进行扩容 if (++size > threshold) resize(); afterNodeInsertion(evict); return null; }
为了更好的理解hashmap如何进行过put操作,可以看下图
重点理解(求元素在node数组的下标)
主要分为三个阶段:计算hashcode、高位运算与取模运算
i = (n - 1) & hash
· 首先上面的hash是由put方法中的hash(key)产生的,源码为:
static final int hash(Object key) { int h; return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16); }
这里通过key.hashCode()计算出key的哈希值,然后将哈希值h右移16位,再与原来的h做异或^运算——这一步是高位运算。设想一下,如果没有高位运算,那么hash值将是一个int型的32位数。而从2的-31次幂到2的31次幂之间,有将近几十亿的空间,如果我们的HashMap的table有这么长,内存早就爆了。所以这个散列值不能直接用来最终的取模运算,而需要先加入高位运算,将高16位和低16位的信息"融合"到一起,也称为"扰动函数"。这样才能保证hash值所有位的数值特征都保存下来而没有遗漏,从而使映射结果尽可能的松散。最后,根据 n-1 做与操作的取模运算。这里也能看出为什么HashMap要限制table的长度为2的n次幂,因为这样,n-1可以保证二进制展示形式是(以16为例)0000 0000 0000 0000 0000 0000 0000 1111。在做"与"操作时,就等同于截取hash二进制值得后四位数据作为下标。这里也可以看出"扰动函数"的重要性了,如果高位不参与运算,那么高16位的hash特征几乎永远得不到展现,发生hash碰撞的几率就会增大,从而影响性能。
(2)get方法
public V get(Object key) { Node<K,V> e; return (e = getNode(hash(key), key)) == null ? null : e.value; //根据key及其hash值查询node节点,如果存在,则返回该节点的value值。 } final Node<K,V> getNode(int hash, Object key) { //根据key搜索节点的方法。记住判断key相等的条件:hash值相同 并且 符合equals方法。 Node<K,V>[] tab; Node<K,V> first, e; int n; K k; if ((tab = table) != null && (n = tab.length) > 0 && //根据输入的hash值,可以直接计算出对应的下标(n - 1)& hash,缩小查询范围,如果存在结果,则必定在table的这个位置上。 (first = tab[(n - 1) & hash]) != null) { if (first.hash == hash && // always check first node ((k = first.key) == key || (key != null && key.equals(k)))) //判断第一个存在的节点的key是否和查询的key相等。如果相等,直接返回该节点。 return first; if ((e = first.next) != null) { //遍历该链表/红黑树直到next为null。 if (first instanceof TreeNode) //当这个table节点上存储的是红黑树结构时,在根节点first上调用getTreeNode方法,在内部遍历红黑树节点,查看是否有匹配的TreeNode。 return ((TreeNode<K,V>)first).getTreeNode(hash, key); do { if (e.hash == hash && //当这个table节点上存储的是链表结构时,用跟第11行同样的方式去判断key是否相同。 ((k = e.key) == key || (key != null && key.equals(k)))) return e; } while ((e = e.next) != null); //如果key不同,一直遍历下去直到链表尽头,e.next == null。 } } return null; }
因为查询过程不涉及到HashMap的结构变动,所以get方法的源码显得很简洁。核心逻辑就是遍历table某特定位置上的所有节点,分别与key进行比较看是否相等。
(3)resize方法(扩容机制)
扩容时机
- 在jdk1.8中,resize方法是在hashmap中的键值对大于阙值时,
- 初始化时,
- 链表转红黑树时,
- putAll时,就会调用resize()方法进行扩容
final Node<K,V>[] resize() { //保存旧的 Hash 数组 Node<K,V>[] oldTab = table; int oldCap = (oldTab == null) ? 0 : oldTab.length; int oldThr = threshold; int newCap, newThr = 0; if (oldCap > 0) { //超过最大容量,不再进行扩充 if (oldCap >= MAXIMUM_CAPACITY) { threshold = Integer.MAX_VALUE; return oldTab; } //容量没有超过最大值,容量变为原来的两倍 else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY && oldCap >= DEFAULT_INITIAL_CAPACITY) //阀值变为原来的两倍 newThr = oldThr << 1; } else if (oldThr > 0) newCap = oldThr; else { //阀值和容量使用默认值 newCap = DEFAULT_INITIAL_CAPACITY; newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY); } if (newThr == 0) { //计算新的阀值 float ft = (float)newCap * loadFactor; //阀值没有超过最大阀值,设置新的阀值 newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ? (int)ft : Integer.MAX_VALUE); } threshold = newThr; @SuppressWarnings({"rawtypes","unchecked"}) //创建新的 Hash 表 Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap]; table = newTab; //遍历旧的 Hash 表 if (oldTab != null) { for (int j = 0; j < oldCap; ++j) { Node<K,V> e; if ((e = oldTab[j]) != null) { //释放空间 oldTab[j] = null; //当前节点不是以链表的形式存在 if (e.next == null) newTab[e.hash & (newCap - 1)] = e; //红黑树的形式,略过 else if (e instanceof TreeNode) ((TreeNode<K,V>)e).split(this, newTab, j, oldCap); else { //以链表形式存在的节点; //这一段就是新优化的地方,见下面分析 Node<K,V> loHead = null, loTail = null; Node<K,V> hiHead = null, hiTail = null; Node<K,V> next; do { next = e.next; if ((e.hash & oldCap) == 0) { if (loTail == null) loHead = e; else loTail.next = e; loTail = e; } else { if (hiTail == null) hiHead = e; else hiTail.next = e; hiTail = e; } } while ((e = next) != null); if (loTail != null) { //最后一个节点的下一个节点做空 loTail.next = null; newTab[j] = loHead; } if (hiTail != null) { //最后一个节点的下一个节点做空 hiTail.next = null; newTab[j + oldCap] = hiHead; } } } } } return newTab; }
由代码可以看出,其实就是通过复制,将table数据保存到旧的hash数组oldTab,然后循环遍历oldTab,根据oldTab.next判断有没有值,没有值就意味着就是桶数组元素,直接复制到新建的newTab,然后有值的话则判断是否是红黑树,若是红黑树的话调用split修剪方法进行拆分放置,若是链表的话,根据一定的规则分两种情况,一种是留在旧链表,一种是去新链表(do-while循环)
链表情况详解
假如现在容量为初始容量16,再假如5,21,37,53的hash自己(二进制),
所以在oldTab中的存储位置就都是 hash & (16 - 1)【16-1就是二进制1111,就是取最后四位】,
5 :00000101
21:00010101
37:00100101
53:00110101
四个数与(16-1)相与后都是0101
即原始链为:5--->21--->37--->53---->null
此时进入代码中 do-while 循环,对链表节点进行遍历,判断是留下还是去新的链表:
lo就是扩容后仍然在原地的元素链表
hi就是扩容后下标为 原位置+原容量 的元素链表,从而不需要重新计算hash。
因为扩容后计算存储位置就是 hash & (32 - 1)【取后5位】,但是并不需要再计算一次位置,
此处只需要判断左边新增的那一位(右数第5位)是否为1即可判断此节点是留在原地lo还是移动去高位hi:(e.hash & oldCap) == 0 (oldCap是16也就是10000,相与即取新的那一位)
5 :00000101——————》0留在原地 lo链表
21:00010101——————》1移向高位 hi链表
37:00100101——————》0留在原地 lo链表
53:00110101——————》1移向高位 hi链表
退出循环后只需要判断lo,hi是否为空,然后把各自链表头结点直接放到对应位置上即可完成整个链表的移动。
(4)remove(Object key)方法0
public V remove(Object key) { Node<K,V> e; return (e = removeNode(hash(key), key, null, false, true)) == null ? null : e.value; } final Node<K,V> removeNode(int hash, Object key, Object value, boolean matchValue, boolean movable) { Node<K,V>[] tab; Node<K,V> p; int n, index; if ((tab = table) != null && (n = tab.length) > 0 && (p = tab[index = (n - 1) & hash]) != null) { Node<K,V> node = null, e; K k; V v; if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k)))) node = p; // 待删除元素在桶中,但不是桶中首元素 else if ((e = p.next) != null) { // 待删除元素在红黑树结构的桶中 if (p instanceof TreeNode) // 查找红黑树 node = ((TreeNode<K,V>)p).getTreeNode(hash, key); else { // 遍历链表,查找待删除元素 do { if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k)))) { node = e; break; } // p保存待删除节点的前一个节点,用于链表删除操作 p = e; } while ((e = e.next) != null); } } /** * matchValue为true:表示必须value相等才进行删除操作 * matchValue为false:表示无须判断value,直接根据key进行删除操作 */ if (node != null && (!matchValue || (v = node.value) == value || (value != null && value.equals(v)))) { // 桶为红黑数结构,删除节点 if (node instanceof TreeNode) // movable参数用于红黑树操作 ((TreeNode<K,V>)node).removeTreeNode(this, tab, movable); // 待删除节点是桶链表表头,将子节点放进桶位 else if (node == p) tab[index] = node.next; // 待删除节点在桶链表中间 else p.next = node.next; ++modCount; --size; afterNodeRemoval(node); return node; } } // 待删除元素不存在,返回null return null; }
(5)还有size()、isEmpty()、clear()、containsValue(Object value)、values()等等方法,在这就不一一列举了,大家可以查看JDK1.8 HashMap源码
三、HashMap为什么要改进使用红黑树
在jdk1.7中,HashMap处理“碰撞”的时候,都是采用链表来存储的,当碰撞的结点很多的时候(也就是hash值相同、key不同的元素很多时),查询时间是O(n)(最坏的情况)。查询时间从O(1)到O(n)。
而在jdk1.8中,HashMap处理“碰撞”增加了红黑树这种数据结构,当碰撞结点少时,采用链表存储,当较大的时候(>8),采用红黑树存储,查询时间是O(log n)。
到这里,我们一起学习了HashMap的结构实现以及核心源码,HashMap还有一些重要的知识要了解,比如说并发安全问题、内部红黑树的实现、与其他Map子类、其他集合类的联系等等,之后会陆续剖析,大家一起学习,共同进步吧!