基于Hash的集合
代码版本jdk1.8.0_101
HashMap顾名思义就是利用哈希机制构建的Map
应该是Java最常用的Map结构
底层存储结构
HashMap的基础结构是Node数组,Node是Entry的子类,一个node表示一条数据
数组容量必须是2的幂次,默认初始容量16, 最大容量2的30次幂
HashMap.java 1 transient Node<K,V>[] table;
由数据映射到底层
每当新加入一条数据,需要决定数据的存放位置
首先,对key求哈希值,将hash作为存放位置的依据
HashMap.java 1 2 3 public V put (K key, V value) { return putVal(hash(key), key, value, false , true ); }
hash的范围是整个int空间,还需要将hash映射到当前的数组大小
由于整数n的大小始终为2的幂次,那么n-1的二进制表示为末尾全1的形式,相当于一个掩码,把hash值截断,相当于取模运算,比%效率更高
HashMap.java
截断的hash值损失了高位信息,因此采用了一种弥补方式。计算哈希时并没有直接采用key的hashCode,而是与高16位进行异或的结果作为hash值
对null值key进行特殊处理,哈希值为0
HashMap.java 1 2 3 4 static final int hash (Object key) { int h; return (key == null ) ? 0 : (h = key.hashCode()) ^ (h >>> 16 ); }
哈希冲突解决
Hash表冲突有两大解决方案
Open addressing
:一个位置由一条数据独占,如果发现目标位置被占用,就按规则选其他位置
Separate chaining
:一个位置被多个数据共享,这些数据链接起来存储
Java的HashMap冲突解决策略采用后者。
Java7中,所有冲突都用链表解决。哈希全部冲突的极端情况下,退化成链表,查询效率变成O(n)。
Java8中,采用两种方案。如果同一位置的数据比较少,线性查找尚可接受,那么直接链表存储,如果比较多(默认是超过8)则转化成一颗红黑树来优化查询效率。这种转化是双向的,如果删除数据,那么树可能重新退化成链表。 因为采用了双结构,因而实现上有两种形式的Node,一种为普通的链表Node,除了包含hash,key,value这三个基础元素,还包括next引用。另一种为树节点TreeNode,它在Node基础上增加了parent,left,right,prev引用以及红黑树用到的颜色信息。进行树化操作可以缓解哈希碰撞攻击,故意构造哈希值一样的数据会使得链表形式性能大幅下降。
HashMap.java 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 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; if ((tab = table) == null || (n = tab.length) == 0 ) n = (tab = resize()).length; if ((p = tab[i = (n - 1 ) & hash]) == null ) tab[i] = newNode(hash, key, value, null ); else { Node<K,V> e; K k; 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 ) treeifyBin(tab, hash); break ; } if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k)))) break ; p = e; } } if (e != null ) { V oldValue = e.value; if (!onlyIfAbsent || oldValue == null ) e.value = value; afterNodeAccess(e); return oldValue; } } ++modCount; if (++size > threshold) resize(); afterNodeInsertion(evict); return null ; }
转树结构策略
如果容量超过64,启用树管理,否则直接扩容来缩减链表长度
如果链表长度超过8,变成树,如果树少于6个节点,变成链表
1 2 3 static final int TREEIFY_THRESHOLD = 8 ;static final int UNTREEIFY_THRESHOLD = 6 ;static final int MIN_TREEIFY_CAPACITY = 64 ;
数据检索
由key的hash值定位到底层Node数组
判断当前Node数组上节点是否命中
不是的话继续查找,根据Node类型进行链表查找或树查找
判断命中有两层条件,首要条件是hash值相等,hash值判定了大致范围,进而精确判断同一key或key相等
这就是为什么自定义对象用作key时要重写hashcode
和equals
方法
HashMap.java 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 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 ; }
扩容
初始时HashMap容量为0,无参构造并不会先进行存储分配,因此只要不插入数据就没有数据存储空间
HashMap.java 1 2 3 public HashMap () { this .loadFactor = DEFAULT_LOAD_FACTOR; }
用户设置的初始容量值可能不是2的幂次,会根据容量计算出一个下次扩容的threshold值,他是可以满足需求的最小的2的幂次值。之所以要求2的幂次是有利于扩容、取模等操作。即使指定容量,也不会立刻分配存储,而是仅仅用于设置threshold
HashMap.java 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 public HashMap (int initialCapacity) { this (initialCapacity, DEFAULT_LOAD_FACTOR); } public HashMap (int initialCapacity, float loadFactor) { this .loadFactor = loadFactor; this .threshold = tableSizeFor(initialCapacity); } 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 ; }
put和putAll操作会导致容量扩充,扩容的标准是判断是否超过threshold
扩容的基本标准是倍增,新阈值threshold的基本标准是容量和loadFactor的乘积。默认的loadFactor为0.75。可见并不是满了才扩容,而是快满了就会扩容。0.75是空间和查询时间的tradeoff。如果很满,说明大概率哈希冲突,可能同一位置数据过多,从而降低查询效率
Java6中扩容阈值始终采用因子乘积
Java7后第一次扩容阈值采用初始容量,之后采用因子乘积
由于底层容量改变,计算存储位置时会发生变化,因此扩容之后需要对数据进行重新hash。
Java7全量重哈希+头插入
扩容采用全部重新hash的方式,并且采用头部插入,考虑到新插入数据能较快命中。扩容时遍历链表依次重新哈希,每个节点头插入新位置,效果是进行了逆序,并发访问可能造成环死循环。如果HashMap被用到高并发环境下,同时扩容,可能出现链表环,从而get时死循环。Java7扩容后同一位置数据顺序可能更改。
Java8链表分离+尾插入
优化了扩容,无需全体调整位置。由于是倍增方式扩容,相当于掩码增加了一位,相当于在高位多了0或1。因此只有两种情况,要么保持不动,要么移动到新增容量的同一位置。而且java8尾插入,保持相对顺序,扩容之前和之后Node链表内数据顺序保持不变
建立两个链表loHead和hiHead分别表示低位和高位,将原链表按照高位值不同划分到两个子链表,loHead保持原链表位置,hiHead挂到新增区域的对应位置
HashMap.java 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 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 ) { 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" }) Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap]; table = newTab; 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; }
遍历
Map可以按照三种方式遍历:keySet,values,entrySet
这三个对象都是无状态的,不存储任何信息,遍历直接遍历底层Node数组
值得注意的是modCount变量,它记录了HashMap变动数,每次操作都会增加,可以作为fail-fast
机制的标志变量
一旦在遍历前后modCount发生变化,直接抛异常
HashMap.java 1 2 3 4 5 6 7 8 9 if (size > 0 && (tab = table) != null ) { int mc = modCount; for (int i = 0 ; i < tab.length; ++i) { for (Node<K,V> e = tab[i]; e != null ; e = e.next) action.accept(e); } if (modCount != mc) throw new ConcurrentModificationException(); }
使用技巧
建HashMap时传入初始容量参数,因为扩容代价很大,如果有预期容量上限设为目标容量/负载因子+1
能避免扩容
不能再并发环境下使用,否则可能会产生环链表死循环或者数据覆盖丢失
HashSet
有了HashMap之后,HashSet的实现变得极其简单
HashSet和HashMap的区别是没有Key-Value结构
HashSet.java 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 public class HashSet <E > extends AbstractSet <E > implements Set <E >, Cloneable , java .io .Serializable { private transient HashMap<E,Object> map; private static final Object PRESENT = new Object(); public HashSet () { map = new HashMap<>(); } public boolean add (E e) { return map.put(e, PRESENT)==null ; } public boolean remove (Object o) { return map.remove(o)==PRESENT; } }
实际上内部就是个HashMap,Map所有的value都存成一个用于占位的同一Object
另外map的put和remove返回旧值,使用占位的Object而不是null可以用于区分原来是否存在