内容简介:HashMap是一个散列表,它存储的内容是键值对(key-value)映射,它是基于哈希表的 Map 接口的非同步实现。此实现提供所有可选的映射操作,并允许使用 null 值和 null 键。作为一名java开发者,我们平常使用过HashMap应该是比较多的,有没有想过HashMap到底是怎么实现的呢?我们使用HashMap的时候需要注意什么吗?怎么使用才能使得HashMap的效率最大化呢?接下来,我们带着这些疑问,去读HashMap的源码,来揭开HashMap的神秘面纱,注意,本次阅读的jdk源码版本为
一、概述
HashMap是一个散列表,它存储的内容是键值对(key-value)映射,它是基于哈希表的 Map 接口的非同步实现。此实现提供所有可选的映射操作,并允许使用 null 值和 null 键。
作为一名java开发者,我们平常使用过HashMap应该是比较多的,有没有想过HashMap到底是怎么实现的呢?我们使用HashMap的时候需要注意什么吗?怎么使用才能使得HashMap的效率最大化呢?接下来,我们带着这些疑问,去读HashMap的源码,来揭开HashMap的神秘面纱,注意,本次阅读的jdk源码版本为1.8。
二、窥探HashMap数据结构
我们先看看HashMap的属性以及构造方法,对HashMap有个初步的了解,了解其是怎么样的数据结构实现。
/** /**hashMap默认容量,1<<4=16**/ static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16 //默认最大的容量 static final int MAXIMUM_CAPACITY = 1 << 30; //默认的负载因子0.75,后续用来扩容的判断条件 static final float DEFAULT_LOAD_FACTOR = 0.75f; //链表转换为红黑树的阈值,默认是8 static final int TREEIFY_THRESHOLD = 8; //红黑树转换链表的阀值,默认是6 static final int UNTREEIFY_THRESHOLD = 6; //进行链表转换最少需要的数组长度,如果没有达到这个数字,只能进行扩容 static final int MIN_TREEIFY_CAPACITY = 64; //节点数组 transient Node<K,V>[] table; //map的Entry缓存 transient Set<Map.Entry<K,V>> entrySet; //map中存放的键值对数目 transient int size; //记录这个map数据结构发生改变的次数,用于快速失败机制 transient int modCount; //实际的负载因子 final float loadFactor; //内部类,节点类 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; } public final int hashCode() { return Objects.hashCode(key) ^ Objects.hashCode(value); } 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; } } //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); } //指定容量的构造方法 public HashMap(int initialCapacity) { this(initialCapacity, DEFAULT_LOAD_FACTOR); } //默认的构造方法 public HashMap() { this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted } //直接给定Map的构造方法 public HashMap(Map<? extends K, ? extends V> m) { this.loadFactor = DEFAULT_LOAD_FACTOR; putMapEntries(m, false); } 复制代码
我们阅读完上面HashMap的构造方法以及属性后,我们知道HashMap最关键的元素有3个,第一个是容量,第二个是加载因子,第三个是Node节点的构造。HashMap默认的容量是16,也可以指定容量进行创建,加载因子默认是0.75,当HashMap容量使用的比例达到总比例的0.75后,就进行扩容。HashMap声明了一个Node节点的数组,同时,Node节点可以指向下一个Node,所以暂时HashMap的内部数据结构大概是这样(后续还会变化,后面会细说):
知道HashMap大概数据结构后,我们来了解下HashMap常用的方法,看看HashMap是如何添加,查找,删除,扩容等操作的。
三、HashMap常用方法源码
- put(K key, V value)
public V put(K key, V value) { return putVal(hash(key), key, value, false, true); } /**真正的put方法逻辑*/ 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还没初始化就进行扩容。 if ((tab = table) == null || (n = tab.length) == 0) n = (tab = resize()).length; /**如果计算出来table数组索引[i]为空,就直接构造新节点赋值*/ if ((p = tab[i = (n - 1) & hash]) == null) tab[i] = newNode(hash, key, value, null); else { Node<K,V> e; K k; /**如果计算出的Hash值索引一样,同时key也一样,如果onlyIfAbsent为true就忽略,否则覆盖原来的value*/ if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k)))) e = p; /**判断是否为树节点,如果是就调用树节点添加方法*/ else if (p instanceof TreeNode) e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value); else { for (int binCount = 0; ; ++binCount) { /**如果当前key对应的hash索引是最后一个的话*/ if ((e = p.next) == null) { /**构造新的节点添加到尾部*/ p.next = newNode(hash, key, value, null); /**如果该节点链表数大于等于8*/ 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; } } if (e != null) { // existing mapping for key V oldValue = e.value; /**判断是否需要覆盖相同key的value**/ if (!onlyIfAbsent || oldValue == null) e.value = value; /***用于支持LinkedHashMap的方法*/ afterNodeAccess(e); return oldValue; } } /**记录修改次数*/ ++modCount; if (++size > threshold) //扩容 resize(); /***用于支持LinkedHashMap的方法*/ afterNodeInsertion(evict); return null; } 复制代码
HashMap调用put方法,首先会通过Hash(key)&(table.length-1)计算出table数组的索引值,然后如果该位置如果为null,直接new一个node,赋值即可;如果当前位置已经有元素,就判断key是否相等,如果相等并且同时设置了onlyIfAbsent为true,那么就会忽略新元素(默认设置为false)。如果key不相等,那么构造新的node节点,放在最后一个节点的尾部,同时,如果node链表个数大于等于8,会进行链表转红黑树转换。最后如果map的size大于总size的0.75倍,就进行扩容。
- get(Object key)
public V get(Object key) { Node<K,V> e; return (e = getNode(hash(key), key)) == null ? null : e.value; } //真正执行HashMap的get()方法 final Node<K,V> getNode(int hash, Object key) { Node<K,V>[] tab; Node<K,V> first, e; int n; K k; //判断如果key的索引位置不为空 if ((tab = table) != null && (n = tab.length) > 0 && (first = tab[(n - 1) & hash]) != null) { //判断该位置第一个元素的key和hash值是否一样,一样就返回该元素 if (first.hash == hash && // always check first node ((k = first.key) == key || (key != null && key.equals(k)))) return first; //如果第一个元素不是查找的key,那么判断链表是否还有元素 if ((e = first.next) != null) { //如果是树节点,执行树节点查找方法 if (first instanceof TreeNode) return ((TreeNode<K,V>)first).getTreeNode(hash, key); do { //不是树节点,循环遍历查找,直到查找到对应的key或者最后一个元素为止 if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k)))) return e; } while ((e = e.next) != null); } } return null; } 复制代码
HashMap的get方法相对put方法来说简单些,首先判断索引第一个是不是查找的key,如果不是就循环遍历链表。
以上源码引出来了新的内容,hash计算,红黑树转换,扩容。我们接下来分析下这三部分的内容,看看HashMap究竟是如何计算索引,红黑树转换和扩容的。
- 计算hash值
static final int hash(Object key) { int h; return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16); } 复制代码
这里用计算hash值并不是直接求key的hashCode,而是求出HashCode值后再进行了一次操作hashcode无符号右移16位,然后与原值进行异或操作,为什么要这么操作呢?直接解析hashcode方法计算不就可以了吗?这么设计是有原因的,我们知道,HashMap最终计算索引位置是通过 (n - 1) & hash
来计算的,n就是数组的长度,该方法实际上也是对n求余的操作,我们知道位运算要比求余运算要快,所以,这里也算是一个优化。那么为啥求Hash值的时候需要进行右移呢?因为我们的n,也就是table数组的长度,比较小,当进行位运算时,只有低位参与了运算,高位并没有参与运算,就比如默认的n=16,换算成32位2进制为 00000000000000000000000000010000
,所以,由于n的高位全部是0,相当于做位运算没有意义,所以,为了让高位也参与运算,先自己右移16位,然后和自己进行异或运算,这样做可以增加hash的随机性,减少碰撞几率。我们通过图示来解析下: 我们假设hash值二进制为: 00101010101010101010101011111101
,n为默认的16,此时计算过程如下:
最后结果换算成10进制为:7,这就计算出来此时key可以缓存的位置为数组索引等于7的位置下。
- 红黑树转换
final void treeifyBin(Node<K,V>[] tab, int hash) { int n, index; Node<K,V> e; //判断table数组长度是否小于64,如果小于就进行扩容 if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY) resize(); else if ((e = tab[index = (n - 1) & hash]) != null) { TreeNode<K,V> hd = null, tl = null; do { //把普通节点转换成红黑树节点 TreeNode<K,V> p = replacementTreeNode(e, null); //如果尾部节点为空,那么说明没有确定头部节点,设置该节点为头部节点 if (tl == null) hd = p; else { //如果已经存在尾部节点,那么把刚刚转换的p节点设置为尾部节点 p.prev = tl; tl.next = p; } tl = p; } while ((e = e.next) != null); //节点转换完成后,确定了头部节点,开始进行红黑树转换 if ((tab[index] = hd) != null) //把普通treeNode列表转换成为红黑树 hd.treeify(tab); } } 复制代码
这里的主要操作只是把普通的node节点转换成treeNode节点,此时,还是最开始的链表形式,最后的红黑树转换依靠 hd.treeify(tab)
方法进行转换。
final void treeify(Node<K,V>[] tab) { TreeNode<K,V> root = null; for (TreeNode<K,V> x = this, next; x != null; x = next) { next = (TreeNode<K,V>)x.next; x.left = x.right = null; //确定root节点,如果root为空,就设置当前节点为root节点,并设置是黑节点。 if (root == null) { x.parent = null; x.red = false; root = x; } //如果root节点已经确定,就开始构造红黑树,下面是左节点和右节点的确定,涉及到排序。 else { K k = x.key; int h = x.hash; Class<?> kc = null; //遍历root,把节点x插入到红黑树中,执行先插入,后修正 for (TreeNode<K,V> p = root;;) { int dir, ph; K pk = p.key; if ((ph = p.hash) > h) dir = -1; else if (ph < h) dir = 1; else if ((kc == null && (kc = comparableClassFor(k)) == null) || (dir = compareComparables(kc, k, pk)) == 0) //比较k和pk的值,用于判断是遍历左子树还是右子树 dir = tieBreakOrder(k, pk); TreeNode<K,V> xp = p; if ((p = (dir <= 0) ? p.left : p.right) == null) { x.parent = xp; if (dir <= 0) xp.left = x; else xp.right = x; root = balanceInsertion(root, x); break; } } } } moveRootToFront(tab, root); } 复制代码
以上就是一个红黑树树化的一个过程,由于篇幅原因,后面的红黑树是如何旋转等操作,这些涉及到基本的数据结构知识,就不在本文的讨论之中。HashMap进行树化后,此时真正的结构如下图:
- 扩容
final Node<K,V>[] resize() { // 当前table保存 Node<K,V>[] oldTab = table; //保存table的容量 int oldCap = (oldTab == null) ? 0 : oldTab.length; //保存当前阈值 int oldThr = threshold; int newCap, newThr = 0; //如果当前table的长度大于0 if (oldCap > 0) { //当前table已经是最大长度了,无法扩容了 if (oldCap >= MAXIMUM_CAPACITY) { threshold = Integer.MAX_VALUE; return oldTab; } //否则扩容为原来的2倍 else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY && oldCap >= DEFAULT_INITIAL_CAPACITY) newThr = oldThr << 1; // double threshold } //如果当前table大容量等于0,并且阈值大于0 else if (oldThr > 0) // initial capacity was placed in threshold //设定新容量就是阈值 newCap = oldThr; else { // zero initial threshold signifies using defaults //容量和阈值都给默认值 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"}) //初始化新table数组。 Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap]; table = newTab; //旧table数组不为null,说明已经初始化过了。 if (oldTab != null) { //循环遍历旧table数组的元素 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 { // preserve order Node<K,V> loHead = null, loTail = null; Node<K,V> hiHead = null, hiTail = null; Node<K,V> next; do { next = e.next; // 将table中的元素根据(e.hash & oldCap)是否为0进行分割,分成两个不同的链表,完成重新计算hash操作 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; } 复制代码
这里比较难理解的就是根据 (e.hash & oldCap) == 0
来创建两个链表,然后分别赋值在扩容后的table原来位置或者“原位置+oldCap”,这里怎么理解呢?因为我们扩容使用的是2次幂扩展(也就是原来的2倍),所以扩容后的元素,重新计算hash值,元素要么就是在原来的位置,要么就是原来的位置再移动2次幂。我们用一张图来说明下这个设计机制:
图(a)表示扩容前的key1和key2两种key确定索引位置的示例,图(b)表示扩容后key1和key2两种key确定索引位置的示例,其中hash1是key1对应的哈希与高位运算结果。 元素在重新计算hash之后,因为n变为2倍,那么n-1的mask范围在高位多1bit(红色),因此新的index就会发生这样的变化:
所以,扩容后只需要判断(e.hash & oldCap)
是否等于0就可以知道元素再新table的位置,不需要重新计算每一个元素的hash值,这里是jdk1.8的扩容优化。上面源码涉及红黑树的分割,原理和链表的重新分配是一样的,同样判断
(e.hash & oldCap)
是否为0,来分割为2个树,唯一的区别就是涉及到红黑树的旋转变色等操作,有兴趣的同学可以自行阅读,本次鉴于篇幅原因就不分析了。
以上就是本文的全部内容,希望本文的内容对大家的学习或者工作能带来一定的帮助,也希望大家多多支持 码农网
猜你喜欢:本站部分资源来源于网络,本站转载出于传递更多信息之目的,版权归原作者或者来源机构所有,如转载稿涉及版权问题,请联系我们。
Practical Django Projects, Second Edition
James Bennett / Apress / 2009 / 44.99
Build a django content management system, blog, and social networking site with James Bennett as he introduces version 1.1 of the popular Django framework. You’ll work through the development of ea......一起来看看 《Practical Django Projects, Second Edition》 这本书的介绍吧!