读HashMap源码所思所得
特性
-
1、HashTable与HashMap相差无几,仅有的区别在于:HashMap是异步且允许加入的元素为空,如此可知Hashtable是同步的且不允许加入的元素为空。如果需要用到同步的HashMap,可以使用synchronizedMap进行封装:Map m = Collections.synchronizedMap(new HashMap(…));。原文为:The HashMapclass is roughly equivalent to Hashtable, except that it is unsynchronized and permits nulls.
在电路逻辑中,同步和异步的区别在于是否在同一个脉冲输入下同时运行,而在程序中同步和异步的区别主要在于数据的一致性:在多个线程操作同一个数据之后,该数据的数值对于多个线程而言是否都是一致的。 -
2、HashMap中的元素排列是无序的且序列不是持久的,因此,在高频率迭代时,不应使HashMap初始的体积(bucket的数量)和大小(键值对的数量)过高(或者负载因子过低);
-
3、体积(capacity)和负载因子(load factor)是影响HashMap效率的两大因素;体积指的是HashMap中的HashTable所包含的bucket的数量。负载因子则是用来规定HashTable中元素数量的,而一旦元素数量超出负载因子规定的数量,HashTable就会重新打乱(rehashed)以使初始体积翻倍,可知负载因子的根本目的是用来平衡时间和空间支出的,如果HashTable的初始体积大于负载因子规定的元素数量则HashTable重新打乱的操作永远不会发生,而其默认的.75提供了一个良好的平衡点。
问题:HashTable与HashMap的关系?负载因子如何规定HashTable的元素数量
初始体积=数量 / 负载因子 + 1.0F
键对数量阙值=初始体积对应包含该数值的2次幂最小数值[参考tableSizeFor方法] -
4、HashMap的迭代使用快速失效(fail-fast)策略:在迭代过程中,一旦HashMap在迭代器生成之后发生结构性改变(包括对键值对的增删操作,但不包括对键值对的修改操作),迭代器会立刻失效,并抛出ConcurrentModificationException异常。但事实上,快速失效(fail-fast)策略是不可靠的,它无法在异常出现时立刻产生作用,因此,该策略最好只用于测试,不能用于程序逻辑设计。
-
5、HashMap 通常使用普通箱节点(Bin node),但在普通节点太大时会转换成树状箱节点(tree bin);树状箱节点一般像普通箱节点一样使用,但在节点密集的情况下支持快速查找。树状箱节点排序时通常用基于hashcode的compareTo方法进行比较,但在两个元素都是继承自一个接口的类的实例对象,那么会根据类的不同进行比较。相比较普通箱节点,更加复杂的树状箱节点在应对排序最差情况(对有序的节点进行打乱再排序)时体现了它的价值。另外,树状箱节点使用频率服从泊松分布。
-
6、不论节点列表是否属于树状结构还是被分裂,节点的顺序会尽量被保留来使删除操作更加容易进行。
-
7、普通模式和树模式的转换可以再LinkedHashMap的子列势力中找到
-
8、Tie问题
即两个对象的类继承实现相同Comparable接口的父类,因此用基于hashcode的CompareTo方法会失效(case of ties, twoelements are of the same “class C implements Comparable”),因此需要从别的方法来实现CompareTo,解决参考方法tieBreakOrder() 和 comparableClassFor()[下文介绍红黑树结构会出现]static int tieBreakOrder(Object a, Object b) { int d; if (a == null || b == null || (d = a.getClass().getName(). compareTo(b.getClass().getName())) == 0) d = (System.identityHashCode(a) <= System.identityHashCode(b) ? -1 : 1); return d; }
Map中的算法
- ①计算某个自然数在哪个2次幂的范围内,如1在2的零次幂中,2在2的一次幂中,3、4在2的二次幂范围中,5、6、7、8在2的三次幂范围中
/*
* Returns a power of two size for the given target capacity.
*/
static final int tableSizeFor(int cap) {
int n = cap - 1;
n |= n >>> 1;
n |= n >>> 2;
n |= n >>> 4;
n |= n >>> 8;
n |= n >>> 16;
return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}
-
- 演算:
cap=0 n= -1
0 -1(1000 0001)->1000 0001|0=1000 0001->1000 0001|0=1000 0001->1000 0001|0=1000 0001->1000 0001|0=1000 0001
1 0 0000 0000
2 1 (0000 0001)->0000 0001|0=0000 0001->0000 0001|0=0000 0001->0000 0001|0=0000 0001->0000 0001|0=0000 0001
3 2 (0000 0010)->0000 0010|0000 0001=0000 0011->0000 0011|0=0000 0011->0000 0011|0=0000 0011->0000 0011|0=0000 0011
4 3 (0000 0011)->0000 0011|0000 0001=0000 0011->0000 0011|0=0000 0011->0000 0011|0=0000 0011->0000 0011|0=0000 0011
5 4 (0000 0100)->0000 0100|0000 0010=0000 0110->0000 0110|0000 0001=0000 0111->0000 0111|0=0000 0111->0000 0111|0=0000 0111
6 5 (0000 0101)->0000 0101|0000 0010=0000 0111->0000 0111|0000 0001=0000 0111->0000 0111|0=0000 0111->0000 0111|0=0000 0111
7 6 (0000 0110)->0000 0110|0000 0011=0000 0111->0000 0111|0000 0001=0000 0111->0000 0111|0=0000 0111->0000 0111|0=0000 0111
8 7 (0000 1111)->0000 1111|0000 0111=0000 1111->0000 1111|0000 0011=0000 1111->0000 1111|0=0000 1111->0000 1111|0=0000 1111
- 演算:
-
②对hashcode再进行一次运行,降低碰撞率
static final int hash(Object key) { int h; return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16); }
问题:为什么会经常碰撞?
-
③翻倍体积sesize
首先要确定新table的初始体积和键对数量阙值,而这个又取决于旧table的体积,- 如果旧table的初始体积等于0,新table的初始体积为0,键对数量阙值为1
- 如果旧table的初始体积等于容许的最大体积,新table初始体积为最大体积而键对数量阙值为Integer.MAX_VALUE
- 如果旧table的初始体积大于0且两倍初始体积大于容许的最大体积,新table初始体积等于旧table初始体积的两倍,键对数量阙值等于Integer.MAX_VALUE
- 如果旧table的初始体积大于0且两倍初始体积小于容许的最大体积,新table初始体积等于旧table初始体积的两倍,键对数量阙值用负载因子公式计算
接着根据新table的初始体积来重新放置旧table的元素(e = oldTab[j];newTab[e.hash & (newCap - 1)] = e;),这样可以是原来的元素在新table中打散放置,而newCap-1是为了防止出现由于与类似1XXXXXX的2次幂数相与而出现序号碰撞的情况
问题:如果hashcode分别为0011和0101的元素与cap-1=0001相与,不是会碰撞吗?而treenode中,(n - 1) & root.hash为什么是第一个节点索引
HashMap中的数据结构
node<K,V> 简单的链表结构和treenode<k,v> 红黑树
- I 一个树节点的左子节点在某个属性上小于右节点,可以是hashcode也可以是其他的属性;对于hashMap中的红黑树,这个节点还可以是key或者value的某个属性
源代码节选清单
其中的comparableClassFor()方法用来找到k继承了什么类或者是实现了什么接口,继而通过这些类或者接口的实现类所实现的compareTo方法来判断k对象与x对象之间的大小关系(compareComparables方法的实现)。当所有属性都比较之后都无法比较出大小则会沿着直接按照先右后左的顺序继续遍历//find方法用来查找本节点的子节点中中有没有节点符合hash=h,或者key=k的,抑或key关于kc类型的某个属性等于k的某个属性 final TreeNode<K,V> find(int h, Object k, Class<?> kc) { TreeNode<K,V> p = this; do { int ph, dir; K pk; TreeNode<K,V> pl = p.left, pr = p.right, q; if ((ph = p.hash) > h) p = pl; else if (ph < h) p = pr; else if ((pk = p.key) == k || (k != null && k.equals(pk))) return p; else if (pl == null) p = pr; else if (pr == null) p = pl; else if ((kc != null || (kc = comparableClassFor(k)) != null) && (dir = compareComparables(kc, k, pk)) != 0) p = (dir < 0) ? pl : pr; else if ((q = pr.find(h, k, kc)) != null) return q; else p = pl; } while (p != null); return null; }
而接着的getTreeNode()是对find()的改进,加入了对根节点的验证,一旦查找的是根节点会节约不必要的性能消耗static Class<?> comparableClassFor(Object x) { if (x instanceof Comparable) { Class<?> c; Type[] ts, as; Type t; ParameterizedType p; if ((c = x.getClass()) == String.class) // bypass checks return c; if ((ts = c.getGenericInterfaces()) != null) { for (int i = 0; i < ts.length; ++i) { if (((t = ts[i]) instanceof ParameterizedType) && ((p = (ParameterizedType)t).getRawType() == Comparable.class) && (as = p.getActualTypeArguments()) != null && as.length == 1 && as[0] == c) // type arg is c return c; } } } return null; } @SuppressWarnings({"rawtypes","unchecked"}) // for cast to Comparable static int compareComparables(Class<?> kc, Object k, Object x) { return (x == null || x.getClass() != kc ? 0 : ((Comparable)k).compareTo(x)); }
final TreeNode<K,V> getTreeNode(int h, Object k) { return ((parent != null) ? root() : this).find(h, k, null); }
HashMap树化和去树化
- 树化指的是:保持map中链表结构不变的同时,遍历指定节点之后的继承节点并按照红黑树结构进行排序,最后返回根节点
- 去树化指的是:遍历指定节点的后代树节点,将每个后代节点重构成普通节点即链表结构的节点,最后返回新的map
//把当前节点当作树的根节点并把在链表中该节点之后的节点变成该根节点的树状子节点,此过程中链表结构不变 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; if (root == null) { x.parent = null; x.red = false; root = x; } else { K k = x.key; int h = x.hash; Class<?> kc = null; 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) 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的分割操作
分割指的是:在某一个节点处按照将map分割成两个指定大小的map(给出的参数是位,即当该参数位8时);分割最主要考虑的是分割后map的大小对数据结构的影响:如果分割后map中节点数目小于阙值,树结构的优势不复存在——遍历速度比不上链表结构,因此必须去树化。而即便低位map中的节点数目大于阙值,如果高位map为空,低位map也不应树化,因为此时分割操作实际上是无效的,应保持原先的数据结构不变;而如果低位map为空也是同样的道理。
##HashMap插入操作
当插入一个新的Entry时,先判断table是否为空,如果为空则该Entry为第一节点,否则会根据key在table中进行遍历比较,如果找到相当的key则停止遍历,并根据onlyIfAbsent参数来确定是否替代原来的Value;如果找不到相当的key则考虑开始插入。如果该Entry为树节点结构,则直接用红黑树插入操作进行插入;如果Entry是普通节点,当该节点插入后table中的节点数目超过树化阙值则必须从该节点开始对整个table进行树化,并将该节点作为根节点和第一节点;未超过树化阙值则遍历找到链表的最后一个节点进行链表插入即可
##HashMap替换Value操作:有两种替换方式:一般替换和计算后替换
-
一般替换方式有两个替换方法:replace(key,value) 和replace(key,newValue,oldValue),参数少的替换方法只是简单判断是否存在Key为key的Entry,是则直接替换;而参数多的替换方法加入了对oldValue的验证,只有在原value为oldValue的情况下才执行替换操作,并返回验证结果
-
计算后替换指的是由传入的Function类型参数的apply方法计算出新的value,由两类计算后替换:根据key计算后替换和根据value计算后替换。
根据key计算后替换有三个替换方法:compute(key,BiFunction),computeIfAbsent(key,BiFunction)和computeIfPresent(key,BiFunction)。
-
compute()方法规定在计算出的新value不为空的情况下,如果key对应的Entry不为空则进行替换,若对应的Entry为空则进行插入操作;若新value为空且Entry存在则删除该Entry;
-
computeIfAbsent()在compute()的基础上加入了oldValue存在判断,如果oldValue存在则不进行替换,如果oldValue不存在则进行compute()方法中的替换操作;
-
computeIfPresent()方法在compute()的基础上也加入了oldValue存在判断,如果oldValue存在则进行compute()方法中的替换操作,如果oldValue不存在则不进行替换。
根据value计算后替换只有一个替换方法:merge(key,value,BiFunction),除了用旧value和传入的参数value计算出新value之外,其他操作与compute()的操作相似
-
HashMap中的集合视图
KeySet、ValueSet、EntrySet用于映射对应的key、value和Entry,一旦map中key、value或Entyr发生变化,集合视图也会发生相对于的变化,而集合视图存在的意义在于:获得对map中的子集合并进行操作。
HashMap对象克隆
HashMap对象克隆相当于创建一个HashMap副本,清空了keySet、valueSet和EntrySet,之后再将当前Map的节点及其结构放入副本
HashMap对象的序列化
除了需要将Entry中的key和value序列化,还需要将初始体积和负载因子也一并序列化,这样在反序列化时才可以指定table的各种指标。
另外,在反序列化过程中还需检验读取出来的是否为Entry类型的对象,源码如下:
// Check Map.Entry[].class since it's the nearest public type to
// what we're actually creating.
SharedSecrets.getJavaOISAccess().checkArray(s, Map.Entry[].class, cap);
HashMap对象的hashcode:HashMap中看似找不到有关HashMap对象的hashcode,但实际上HashMap的hashcode已经由其父类AbstractMap定义了
public int hashCode() {
int h = 0;
Iterator<Entry<K,V>> i = entrySet().iterator();
while (i.hasNext())
h += i.next().hashCode();
return h;
}
而且实际上,所有继承AbstractMap的Map子类对象的HashCode都由AbstractMap定义了
- 问题:源码中afterNodeAccess()、afterNodeRemoval()、afterNodeInsertion()方法与回调LinkedMap的post-action有关?
总结
HashMap最主要的特点在于它的数据结构:链表结构+红黑树结构,实际上,我们可以把它看成时LinkedMap和TreeMap的结合。