Hashmap介绍
Hashmap是基于hash表的map的实现,使用key-value形式存储键值对,并允许使用 null 值和 null 键,但是key只能有一个为null. Map不保证映射的顺序,其内部是根据hash值去模运算去排列的。hashmap内部使用entry数组作为存储的介质.
本文是基于Java8版本写的,因为Java8对hashMap有较大的改变,采用数组+链表+红黑树
方式来记录键值对. 而且Java8还重写了resize方法,Java8之前很有可能造成扩容时,链表的循环问题.
源码解读
本文中的代码比较多,大多数的说明都在注释上体现了,所以可能文字上表述的不是很多,本文也尽可能介绍了一些java8中的新方法.需要注意的是其中很多地方都进行了修改和补充,看完本篇文章一定和之前看的Java7 Hashmap有非常多的不同之处,也可以思考一下为什么Java8要做这么多改变.
Node介绍
Node是map的接口中的内部接口Map.Entry的实现类,用于存储Hashmap中键值对的对象,是HashMap中非常重要的一个内部类,随便提一下,HashMap中有非常多的内部类,本文没做过多的介绍,读者可以自己翻看一下源码,因为篇幅实在太长了...在这里就简单的讲一下,大部分的内部类都是用于集合操作的,如keySet
,values
,entrySet
等方法.
内部组成
static class Node<K,V> implements Map.Entry<K,V> { //key是不可变的 final K key; //value V value; //指向下一个entry对象,具体作用后面介绍 Node<K,V> next; //hash值 int hash; }
Hashmap组成
Hashmap主要参数
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; //默认初始容量 static final int MAXIMUM_CAPACITY = 1 << 30; //最大容量 static final float DEFAULT_LOAD_FACTOR = 0.75f; //默认负载因子,相当于只有多少可用 transient Node<K,V>[] table;//要存储值的hash表 transient int size; //实际大小 int threshold; //阈值 final float loadFactor;//负载因子 transient int modCount; //记录操作次数的
相比之前版本的HashMap,Java8更钟爱与位操作. >> 表示逻辑右移 >>> 表示无符号逻辑右移.高位补零
Hashmap构造方法
//为空时候使用默认分配的大小16,负载因子0.75f,默认的容量为12,当size>threshold默认容量时候就会去扩容 public HashMap() { this.loadFactor = DEFAULT_LOAD_FACTOR; } //构造方法 初始化负载因子和阈值 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); }
HashMap方法
本文主要挑选几个比较常用且重要的方法进行分析.因为这写方法已经涵盖了大部分的方法调用关系.
hash方法 使用hashcode 异或
hashcode右移16位 得到hash值,等同于高位都取零 随带提一句,int类型的hashcode就是本身.
static final int hash(Object key) { int h; return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16); }
put方法解析 put方法,是HashMap中非常重要的方法,其中牵涉到了非常多的知识点,需要仔细的阅读.
public V put(K key, V value) { return putVal(hash(key), key, value, false, true); } /** * onlyIfAbsent 是否替换,为true时,如果存在值则替换 * evict 主要用于LinkedHashMap移除最近未使用的节点 */ final V putVal(int hash, K key, V value, boolean onlyIfAbsent,boolean evict) { Node<K,V>[] tab; Node<K,V> p; int n, i; //未初始化则扩容(扩容包括新建和扩容) if ((tab = table) == null || (n = tab.length) == 0) n = (tab = resize()).length; //如果不存在,直接put if ((p = tab[i = (n - 1) & hash]) == null) tab[i] = newNode(hash, key, value, null); else { Node<K,V> e; K k; //根据条件获取不同的node节点,有可能tab[i]就是存在的元素 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) { //到尾都没找到,添加一个节点 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; } } //存在,替换 if (e != null) { // existing mapping for key V oldValue = e.value; if (!onlyIfAbsent || oldValue == null) e.value = value; //用于linkedhashmap,本文不做介绍 afterNodeAccess(e); return oldValue; } } //添加成功,判断是否要扩容 ++modCount; if (++size > threshold) resize(); //用于linkedhashmap afterNodeInsertion(evict); return null; }
下面详细介绍一下扩容的方法.看起来非常庞大.其实逻辑不复杂
/** * 有几种情况 * 1.当为空的时候,也就是没有初始化的时候 * 2.当到达最大值时候 * 3.普通扩容时候 */ final Node<K,V>[] resize() { Node<K,V>[] oldTab = table; int oldCap = (oldTab == null) ? 0 : oldTab.length; int oldThr = threshold; int newCap, newThr = 0; //存在旧值 if (oldCap > 0) { //大于2<<30 最大容量设置为2<<32 - 1 if (oldCap >= MAXIMUM_CAPACITY) { //但是不移动.,没有空间移动 threshold = Integer.MAX_VALUE; return oldTab; } //如果属于正常范围的扩容 容量 x2 else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY && oldCap >= DEFAULT_INITIAL_CAPACITY) newThr = oldThr << 1; // double threshold } //用户自己设定了初始化大小 else if (oldThr > 0) { newCap = oldThr; } //如果没使用,使用默认值初始化 else { newCap = DEFAULT_INITIAL_CAPACITY; newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY); } //用户自定义了map的初始化操作 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"}) Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap]; table = newTab; //如果是扩容操作 if (oldTab != null) { //遍历map的数据 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); //如果没超过8个 是链表 else { // preserve order Node<K,V> loHead = null, loTail = null; Node<K,V> hiHead = null, hiTail = null; Node<K,V> next; //此处的操作是这样子的 因为是扩容一倍的操作,所以与旧的容量进行与操作后只有两个值0 和 1 //如果是0就位置不变,如果是1就移动n+old的位置, //个人认为这么做的好处是: /** * 1.不会像之前1.7发生循环依赖的问题 * 2.从概率的角度上来看可以均匀分配,(一般来说高位和低位比例差不多) * 3.提高效率 */ do { next = e.next; //如果和旧容量位运算后的值是0,记得当前节点和存放在链表的尾部 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); //为0的还是存放在当前位置 if (loTail != null) { loTail.next = null; newTab[j] = loHead; } //为1的就放在扩容的j + oldCap那边去 if (hiTail != null) { hiTail.next = null; newTab[j + oldCap] = hiHead; } } } } } return newTab; }
因为不像Java8之前的HashMap有初始化操作,此处选择将初始化和扩容放在了一起,并且又增加了红黑树的概念,所以导致整个方法的判断次数非常多,也是这个方法比较庞大的主要原因.
值得一体的是,在扩容后重新计算位置的时候,对链表进行优化,有兴趣可以搜索一下hashmap导致cpu百分之百的问题 而在Java中通过巧妙的进行&操作,然后获得高位是为0还是1.最终移动的位置就是低位的链表留在原地,高位的放在index+oldsize的地方就可以了,不用为每一个元素计算hash值,然后移动到对应的位置,再判断是否是链表,是否需要转换成树的操作.如下所示.
hashcode: 1111111111111101212 oldcap: 0000000000000010000
很容易知道这个&操作之后就是为0,因为oldcap都是2的倍数,只有高位为1,所以通过&确认高位要比%取余高效. 此时在看一下上面的扩容操作也许就更清晰了.
下面介绍一些newNode方法.就是新建一个节点.可以思考一下为什么要把newNode抽离出来?文章末尾给答案.
Node<K,V> newNode(int hash, K key, V value, Node<K,V> next) { return new Node<>(hash, key, value, next); }
添加节点到红黑树的方法是Java8中新添加的,需要满足链表的长度到8,才会转换成红黑树,其主要目的是防止某个下标处的链表太长,导致在找到的时候速度很慢,下面看一下实现
//尝试着往树节点添加值 final TreeNode<K,V> putTreeVal(HashMap<K,V> map, Node<K,V>[] tab,int h, K k, V v) { Class<?> kc = null; boolean searched = false; //找到根节点 TreeNode<K,V> root = (parent != null) ? root() : this; for (TreeNode<K,V> p = root;;) { int dir, ph; K pk; if ((ph = p.hash) > h) dir = -1; else if (ph < h) dir = 1; //存在的话直接返回,用于替换 else if ((pk = p.key) == k || (k != null && k.equals(pk))) return p; //判断节点类型是否相同,不相同 else if ((kc == null && (kc = comparableClassFor(k)) == null) || (dir = compareComparables(kc, k, pk)) == 0) { //没有搜索过,搜索子节点,搜过了说明没有就跳过. if (!searched) { TreeNode<K,V> q, ch; searched = true; //去子节点去找 if (((ch = p.left) != null &&(q = ch.find(h, k, kc)) != null) ||((ch = p.right) != null && (q = ch.find(h, k, kc)) != null)) return q; } //对比hash值,决定查找的方向 dir = tieBreakOrder(k, pk); } TreeNode<K,V> xp = p; //找到子节点为空,就可以加进去,设置层级关系 if ((p = (dir <= 0) ? p.left : p.right) == null) { Node<K,V> xpn = xp.next; TreeNode<K,V> x = map.newTreeNode(h, k, v, xpn); if (dir <= 0) xp.left = x; else xp.right = x; xp.next = x; x.parent = x.prev = xp; if (xpn != null) ((TreeNode<K,V>)xpn).prev = x; moveRootToFront(tab, balanceInsertion(root, x)); return null; } } }
这里简单的梳理一下流程. 1.从根节点查找,找到了返回,如果没找到,找字节点 2.判断往哪个方向去查找 3.如果不存在,在子节点末端添加新节点
下面再看一下树的split方法,主要是扩容操作,重新结算位置需要分裂树,之前讲过,扩容会根据和旧map容量进行&操作,移动高位为1的节点.并且验证新的节点列表是否需要重新转换成链表的形式.
final void split(HashMap<K,V> map, Node<K,V>[] tab, int index, int bit) { TreeNode<K,V> b = this; // 设置记录高低位的node,和链表一样都是计算高位是0还是1 TreeNode<K,V> loHead = null, loTail = null; TreeNode<K,V> hiHead = null, hiTail = null; int lc = 0, hc = 0; for (TreeNode<K,V> e = b, next; e != null; e = next) { next = (TreeNode<K,V>)e.next; e.next = null; //还是和旧的容量做位运算,为0的放在lo中 if ((e.hash & bit) == 0) { //判断是否为头部 if ((e.prev = loTail) == null) loHead = e; else loTail.next = e; loTail = e; ++lc; } //获取为1的放在hi中 else { if ((e.prev = hiTail) == null) hiHead = e; else hiTail.next = e; hiTail = e; ++hc; } } //lo链表的处理 if (loHead != null) { //如果小于7,那么当做链表处理 if (lc <= UNTREEIFY_THRESHOLD) tab[index] = loHead.untreeify(map); else { //转换成树 tab[index] = loHead; if (hiHead != null) // (else is already treeified) loHead.treeify(tab); } } //同上 if (hiHead != null) { if (hc <= UNTREEIFY_THRESHOLD) tab[index + bit] = hiHead.untreeify(map); else { tab[index + bit] = hiHead; if (loHead != null) hiHead.treeify(tab); } } } //把树转换成链表 final Node<K,V> untreeify(HashMap<K,V> map) { Node<K,V> hd = null, tl = null; for (Node<K,V> q = this; q != null; q = q.next) { Node<K,V> p = map.replacementNode(q, null); if (tl == null) hd = p; else tl.next = p; tl = p; } return hd; }
转化成红黑树的详情本文就没详细介绍了,我相信很容易看懂. 这里借`美团点评技术团队`的一张图来展示一下put方法的流程
get方法
逻辑其实很简单,就是首先通过hashcode确认位置,然后分数据结构类型继续不同方式的查找
public V get(Object key) { Node<K,V> e; return (e = getNode(hash(key), key)) == null ? null : e.value; } final Node<K,V> getNode(int hash, Object key) { Node<K,V>[] tab; Node<K,V> first, e; int n; K k; //找到位置,并且当且位置存在元素 if ((tab = table) != null && (n = tab.length) > 0 &&(first = tab[(n - 1) & hash]) != null) { if (first.hash == hash &&((k = first.key) == key || (key != null && key.equals(k)))) return first; //链表或者树,遍历子节点 if ((e = first.next) != null) { if (first instanceof TreeNode) return ((TreeNode<K,V>)first).getTreeNode(hash, key); do { if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k)))) return e; } while ((e = e.next) != null); } } return null; }
remove方法
public V remove(Object key) { Node<K,V> e; return (e = removeNode(hash(key), key, null, false, true)) == null ? null : e.value; } /** * 1.寻找是否存在,如果存在分数据类型分别处理 * 2.如果为树,稍微复杂一点,需要判断是否要转换成链表,然后就是树的移动 */ 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 = e; } while ((e = e.next) != null); } } //找到了对应的节点 if (node != null && (!matchValue || (v = node.value) == value || (value != null && value.equals(v)))) { //树节点处理 主要是两点,存在删除,删除了是否需要转换成链表 if (node instanceof TreeNode) ((TreeNode<K,V>)node).removeTreeNode(this, tab, movable); //一个元素 else if (node == p) tab[index] = node.next; else //指向node的下一个 p.next = node.next; ++modCount; --size; afterNodeRemoval(node); return node; } } return null; }
本文就不看树的详细删除过程了,内容太多太长.可以自己想象一下树的删除过程. replace方法
@Override public V replace(K key, V value) { Node<K,V> e; //找到节点替换 if ((e = getNode(hash(key), key)) != null) { V oldValue = e.value; e.value = value; //与linkedhashmap有关 afterNodeAccess(e); return oldValue; } return null; }
从基本的方法中我们可以看出,最复杂的就是put方法,put方法设计非常多的方法,后续的get,replace,remove都是建立在put方法基础之上.
补充方法
上面介绍了几个基本的方法,另外现在介绍一些有用的小方法.都是在Java8新增的.
merge方法 merge方法的主要作用是如果不存在就进行添加,如果存在的话按照自己指定的remappingFunction
进行操作,如果操作之后value为null的话删除元素,否则替换,下面是代码详情
public V merge(K key, V value,BiFunction<? super V, ? super V, ? extends V> remappingFunction) { if (value == null) throw new NullPointerException(); if (remappingFunction == null) throw new NullPointerException(); int hash = hash(key); Node<K,V>[] tab; Node<K,V> first; int n, i; int binCount = 0; TreeNode<K,V> t = null; Node<K,V> old = null; //扩容操作 if (size > threshold || (tab = table) == null ||(n = tab.length) == 0) n = (tab = resize()).length; //通过key找到元素,分为树和链表两种情况 if ((first = tab[i = (n - 1) & hash]) != null) { if (first instanceof TreeNode) old = (t = (TreeNode<K,V>)first).getTreeNode(hash, key); else { Node<K,V> e = first; K k; do { if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k)))) { old = e; break; } ++binCount; } while ((e = e.next) != null); } } //存在旧的结点,进行合并操作 if (old != null) { V v; if (old.value != null) //具体的合并操作 v = remappingFunction.apply(old.value, value); else v = value; //合并成功,执行afterNodeAccess方法,子类linkedHashMap有用 if (v != null) { old.value = v; afterNodeAccess(old); } //合并之后没有值,删除元素 else removeNode(hash, key, null, false, true); return v; } //没有旧结点,直接添加,考虑扩容,链表和树的情况 if (value != null) { if (t != null) t.putTreeVal(this, tab, hash, key, value); else { tab[i] = newNode(hash, key, value, first); if (binCount >= TREEIFY_THRESHOLD - 1) treeifyBin(tab, hash); } ++modCount; ++size; afterNodeInsertion(true); } return value; }
compute方法
public V compute(K key,BiFunction<? super K, ? super V, ? extends V> remappingFunction) { //省略部分基本和merge方法的前半段一直,不重复展示 //获取旧元素 V oldValue = (old == null) ? null : old.value; //计算出新值 V v = remappingFunction.apply(key, oldValue); //存在旧元素,旧赋值到旧元素上 if (old != null) { if (v != null) { old.value = v; afterNodeAccess(old); } //计算结果为空则删除 else removeNode(hash, key, null, false, true); } //r如果没有旧元素,但计算出的值不为空,添加操作,和merge方法相同,省略 return v; }
看到这里我相信大家都有疑问,为什么要放两个几乎相同的两个方法,
我们详细对比一下两个方法,发现会有几点不同:
- 参数不同,merge方法要求value不为null,compute方法没有要求;
- merge方法要求old.value也不为null.compute的方法依然没有要求.
另外compute还有两个扩展方法,简单介绍一下.
computeIfAbsent方法 如果oldvalue不为null就不替换,如果计算后的值不存在直接返回,否则如果old不为null,通过key计算出值替换,否则添加到map中
public V computeIfAbsent(K key,Function<? super K, ? extends V> mappingFunction) { //扩容等操作省略,同上 V oldValue; if (old != null && (oldValue = old.value) != null) { afterNodeAccess(old); return oldValue; } //注意这里 V v = mappingFunction.apply(key); if (v == null) { return null; } else if (old != null) { old.value = v; afterNodeAccess(old); return v; } //添加操作省略 }
computeIfPresent方法 不进行扩容的判断(因为不存在找不到就添加这样的操作).直接通过key查找,如果找到,且计算的结果不为null,替换,否则就直接删除
public V computeIfPresent(K key,BiFunction<? super K, ? super V, ? extends V> remappingFunction) { if (remappingFunction == null) throw new NullPointerException(); Node<K,V> e; V oldValue; int hash = hash(key); if ((e = getNode(hash, key)) != null && (oldValue = e.value) != null) { //注意这里 V v = remappingFunction.apply(key, oldValue); if (v != null) { e.value = v; afterNodeAccess(e); return v; } else removeNode(hash, key, null, false, true); } return null; }
再次总结一下三个compute方法的异同点
- compute方法和computeIfPresent方法都需要oldValue,computeIfAbsent不需要
- compute的remappingFunction.apply方法不限制oldvalue是否为null,但是computeIfPresent限制必须不为null才进行,computeIfAbsent方法必须要old或者oldvalue为null才会进行后续操作
- computeIfPresent只有oldvalue存在则进行apply方法,然后根据条件替换或者删除操作,而compute和computeIfAbsent方法则是如果old不存在,还会根据条件额外进行添加操作
简单点说就是:
- 如果是不存在oldvalue才进行操作,这也可以认为我们在声明了
String a = null
这样的操作,现在需要进行初始化,选择computeIfAbsent方法, - 如果必须存在oldvalue才操作,而且只进行删除或者修改的操作则选择computeIfPresent方法,类似看看还有没有修改的价值,没价值就干掉了.
- 其他选择compute方法
快速失败和安全失败问题
一:快速失败(fail—fast) 在用迭代器遍历一个集合对象时,如果遍历过程中对集合对象的内容进行了修改(增加、删除、修改),则会抛出Concurrent Modification Exception。 原理:迭代器在遍历时直接访问集合中的内容,并且在遍历过程中使用一个 modCount 变量。集合在被遍历期间如果内容发生变化,就会改变modCount的值。每当迭代器使用hashNext()/next()遍历下一个元素之前,都会检测modCount变量是否为expectedmodCount值,是的话就返回遍历;否则抛出异常,终止遍历。 注意:这里异常的抛出条件是检测到 modCount!=expectedmodCount 这个条件。如果集合发生变化时修改modCount值刚好又设置为了expectedmodCount值,则异常不会抛出。因此,不能依赖于这个异常是否抛出而进行并发操作的编程,这个异常只建议用于检测并发修改的bug。 场景:java.util包下的集合类都是快速失败的,不能在多线程下发生并发修改(迭代过程中被修改)。 二:安全失败(fail—safe) 采用安全失败机制的集合容器,在遍历时不是直接在集合内容上访问的,而是先复制原有集合内容,在拷贝的集合上进行遍历。 原理:由于迭代时是对原集合的拷贝进行遍历,所以在遍历过程中对原集合所作的修改并不能被迭代器检测到,所以不会触发Concurrent Modification Exception。 缺点:基于拷贝内容的优点是避免了Concurrent Modification Exception,但同样地,迭代器并不能访问到修改后的内容,即:迭代器遍历的是开始遍历那一刻拿到的集合拷贝,在遍历期间原集合发生的修改迭代器是不知道的。 场景:java.util.concurrent包下的容器都是安全失败,可以在多线程下并发使用,并发修改。
解答
解释一下刚才在文章设置的悬念,为什么要把newNode方法单独提出来,其实这里很大一部分原因是因为Linkedhashnap会需要重写此方法进行额外的操作.具体是什么可以自己查看一下源码,或者看我的另一篇文章,map集合类总结.
总结
改版后的HashMap看起来更加的庞大和神秘了,因为相比之前看起来可能方法更大而且还有红黑树这个数据结构,看到代码就会让人感觉好难受,同时也呼吁每个人都不要写这么庞大的方法.尽可能将方法拆小.变得更加的简洁明了.但是Java8对HashMap的改变,使得HashMap在一定程度上提升了性能,并且还新填了不少的方法.
当然本文只是个人的一些看法,如果存在不足或者错误的地方,欢迎大家指正!!!
结语
与君共勉!!!