Java 集合——HashMap
2017-05-02 20:13
316 查看
Java 集合——HashMap
介绍
先前介绍了 List 集合的三种子类实现原理,今天我们来讲讲另一种数据结构——Map 集合,Map 是一种用来存储 key-value 的数据结构,每一个 key 对应了一个 value,并且同一个集合里面不允许存在相同的 key。在 Java 中,常见的 Map 实现是 HashMap、HashTable、LinkedHashMap、TreeMap 和 ConcurentHashMap,下面从源码的层面上,给大家讲解 HashMap 的实现原理。
HashMap 定义
从 HashMap 名字中我们可以看出这是一个用 hash 表这种数据结构实现 Map 集合,涉及到 hash 表这种数据结构就需要考虑两个问题:1. 使用什么算法来计算 key 的 hash 值
2. 出现冲突时,使用什么机制去解决冲突
这两个是 hash 表要考虑也是最核心的两个问题,只要搞懂了这两个问题,其实你就能够理解 HashMap 的实现原理了。
首先先来看看 HashMap 类的定义:
public class HashMap<K,V> extends AbstractMap<K,V> implements Map<K,V>, Cloneable, Serializable { static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; static final int MAXIMUM_CAPACITY = 1 << 30; static final float DEFAULT_LOAD_FACTOR = 0.75f; static final int TREEIFY_THRESHOLD = 8; static final int UNTREEIFY_THRESHOLD = 6; static final int MIN_TREEIFY_CAPACITY = 64; 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; } } transient Node<K,V>[] table; transient int size; transient int modCount; }
上面列举了 HashMap 定义的一些静态常量,这些静态常量的具体用途在下面讲解具体操作的时候会再介绍,首先需先理解 Node 的定义和其他变量的用途。
Node 是集合中结点的抽象,它内部封装了该结点的 key-value,同时还保存了 hash 值,key 和 hash 都是 final 域,这代表加入集合中的 key 是不可变的,要求其 hashcode 值不会发生改变,否则将可能会出现内存泄露问题。另外该结点定义中还有一个 next 域,用于指向下一个结点,这是用于解决冲突所定义的结构(其实从这个定义就可以猜想到 HashMap 是采用拉链法来解决冲突的)。
table 是 HashMap 的用于存储结点元素的数据结构,其实就是 hash 表,通过 size 来记录当前 hash 表存放了多少个元素。其实从这里定义可知,HashMap 的底层数据结构是数组 + 链表,所有操作都是通过操作这两个数据结构来进行的。
HashMap 的基本操作
map 是一种集合的数据结构,当然就需要实现集合的基本操作,那么现在就从最最基本的添加操作来讲解,通过结合源码和例子来让大家更加清晰地了解它的设计思想put 操作
put 操作用于在集合添加一个 key-value 的键值对,在 HashMap 中采用数组+链表的形式来存储数据,每一个数组元素都是一个链表,该链表存储相同 hash 值的 key 结点(拉链法)。它的主要工作流程:
1. 先通过 hash 算法计算 key 的 hash 值,再通过 hash & (n-1) 计算该 key 应该存放在数组哪一个位置
2. 若当前 index 位置上不存在元素,则代表未发生冲突,直接创建新结点存放,并作为该位置链表的头结点,结束;
3. 若当前 index 位置上存在元素,则代表发生冲突,则采用拉链法解决冲突,遍历链表查看是否存在相同的 key 元素(比较方法采用 equals 方法,null 元素特别处理),存在,则替换旧值;若不存在,则插入到链表的末尾
4. 更新当前的元素个数,若超过负载因子,则进行扩容操作
JDK1.8 的源码实现:
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 为null,则当前 hash 表未初始化,则进行初 始化操作 */ if ((tab = table) == null || (n = tab.length) == 0) n = (tab = resize()).length; /* 如果当前 hash 后的数组位置不存在结点,即代表还未没有发生冲突, 则创建新结点保存 */ if ((p = tab[i = (n - 1) & hash]) == null) tab[i] = newNode(hash, key, value, null); else { /* 当前位置已经有元素,则代表出现冲突,采用拉链法解决冲突,遍历 链表查看是否存在相同的 key ,若存在,则将新值替换旧值,返回 旧值;若不存在,则在链表的末尾插入元素。 注:比较 key 是否相同,是采用 equals 方法来进行的,除 key 为 null 的情况,HashMap 是可以存放 null 元素的 */ Node<K,V> e; K k; if (p.hash == hash && ((k = p.key) == key || (key != null && 4000 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; } } // 上面的过程查询相同的 key 的结点,若找到,则执行值替换 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; } static final int hash(Object key) { int h; return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16); }
从上述的过程和源码,相信大家对 put 操作有一个大致的了解,那么现在再具体地来说一下一些内部的细节是如何实现,增加了哪些优化操作,以及在 jdk 1.8 为什么要这样实现。
hash 算法实现
从源码可以看出,hash 值是通过公式 hashcode ^ (hashcode >>> 16) 计算所得,在 JDK 1.8 前都是直接采用 hashcode,并未经过计算,该种方式保证了高 16 位全为 1,具体的优化目的其实我也不太清楚,可能可增大命中高位位置的概率。计算 index 位置
要计算 index 位置,先通过 hash 算法计算 key 的 hash 值,再通过公式 hash & (n-1) 来算的,这也是 1.8 后引入的优化操作。在之前,都是通过 hash % n 来计算 index,但在 1.8 时,HashMap 的容量都要求是 2 的整数倍,并且根据公式 hash & (n-1) = hash % n,当 n = 2^i 时,& 运算比 % 运算的效率要高,因此采用 & 运算代替 % 运算计算 index 。红黑树的引入
从源码部分其实可以看到增加了一些 TreeNode 的操作,这部分也是 JDK 1.8 新引入的红黑树机制。考虑当未引入红黑树时,出现冲突时采用拉链法解决冲突,那么当查找元素时则需要遍历链表找到匹配的 key,最优时查找次数为 1,最差时查找次数为 n,那么总得平均查找次数为 n/2,因此当出现较多碰撞时,HashMap 的查询性能则会下降,时间复杂度可能会去到 O(n)。因为为解决决该种情况时的性能下降,JDK 1.8 引入了红黑树,红黑树也是一种较平衡的二叉树,它的插入、查找和删除的时间复杂度都为 O(logn),具体的红黑树结构可以自行去 google 了解。因此,在 JDK 1.8 中,当添加元素时,链表元素个数大于 8 个时,则会触发将链表转换成红黑树,从而提高查询性能。
扩容操作
前面也提到,当当前负载大于负载因子,则会触发扩容操作。那么问题就来了:为什么需要扩容?负载因子是什么?
首先负载因子 = 当前元素个数 / 总容量,即当前元素所占的比例。我们知道 HashMap 底层采用的数组这种结构,在有限的存储空间存放元素,当存放 n + 1 个元素时,那么至少有两个元素会存放在同一个位置,那就是说当存放的元素个数越多,发生冲突的概率就会越高,那么就会影响查询性能。因此为了降低的冲突的概率,当负载超过 75% 时,则将数组扩容,增大容量。
具体的扩容工作流程:
1. 先将当前容量增大一倍(因为都是 2 的倍数,采用 << 1 操作),创建一个新容量的新数组
2. 遍历旧数组每一个元素,对链表上的元素或红黑树上的元素进行重定位,存放到新数组中(这里面会涉及到红黑树的退化以及链表的拆分)
重定位操作是通过公式 hash & n 来进行,由于重定位的结果只有两种,要么放在原 index 位置,要么放在 index + n 位置,那么现在假设当前 n 为 2,在 1 位置上 存放了 key 为 1 和 key 为 3 的元素,即如下图:
经过扩容后,则转化为:
从图上可以看出 3 去到新数组的 3 位置,而 1 仍在 1 位置。那么现在来看 1 的二进制表示为 0001,3 的二进制表示为 0011 ,扩容后容量为 4(0100), 即计算位置的公式为 hash & 0011,那么只要当 hash 的最高位为 1,则新位置为 index + n,否则为 index。(与旧容量的对齐的最高位,当前为第二位)
扩容源码实现:
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; // double threshold } 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); // 这里进行链表的分裂,将链表根据最高位是否为1,来分配位置 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; }
通过 put 操作的分析其实已经能够了解 HashMap 的具体实现原理,删除和获取操作实际上也是对数组+链表+红黑树这个数据结构进行操作而已,只要理解了添加操作,对于其他操作应该也不难理解。另外的话,HashMap 的迭代器也是属于 fast-fail 类型,在遍历时,若其他线程修改了 map(导致 modCOunt 被修改),会抛出 ConcurentModifyException 。
总结
在 Java 中,Map 的实现子类有HashMap、HashTable、LinkedHashMap、TreeMap 和 ConcurentHashMap,HashMap 在该文章已经讲解过,LinkedHashMap 则是在 HashMap 的基础上,增加了一个链表机制来维护元素添加的顺序;TreeMap 则是可排序的 Map 集合,内部是采用红黑树来实现的。而 HashTable 是一个比较古老的容器,它与 HashMap 的实现机制类似,都是采用 数组+链表的形式来实现的,不过 HashMap 增加了红黑树的实现。两者还有一个重要的区别是 HashMap 是线程不安全的,而 HashTable 是线程安全的,因为后者每一个方法都使用了 synchronize 关键字来定义。另外的话,HashTable 不支持 key 为 null 的元素,且扩容容量为原来的 1.5 倍。虽然 HashTable 是线程安全的,但同样不适用于并发环境编程,因为 synchronize 会导致性能下降,限制了并发量。因此,若要用于多线程开发,可使用 ConcurentHashMap ,它采用了分段锁和 volatile 变量来提高并发性能,关于 ConcurrentHasnMap 的实现原理可看我的这篇文章Java 集合——ConcurrentHashMap
相关文章推荐
- Java HashMap集合深度分析
- 关于java的集合类,以及HashMap中Set的用法!
- 深入Java集合学习系列:HashMap的实现原理
- 深入Java集合学习系列:HashMap的实现原理
- Java 集合(ArrayLsit,LinkedList,Vector,HashMap--HashTable)
- 关于java的集合类,以及HashMap中Set的用法!
- Java:集合,Array、Collection(List/Set/Queue)、Map的遍历,比如:ArrayList,LinkedList,HashSet,HashMap
- Java集合HashSet-ArrayList-HashMap的线程同步控制方法和区别
- Java基础之集合框架(三)--Map、HashMap、TreeMap
- 深入Java集合学习系列:HashMap的实现原理
- Java对集合的遍历 List ArryList HashMap LinkedMap JSON 数组等
- 深入Java集合学习系列:HashMap的实现原理
- java集合框架学习—HashMap的实现原理
- 深入Java集合学习系列:HashMap的实现原理
- 深入Java集合学习系列:HashMap的实现原理
- Java HashMap集合深度分析
- 关于java的集合类,以及HashMap中Set的用法!
- 深入Java集合学习系列:HashMap的实现原理
- Java集合HashSet-ArrayList-HashMap的线程同步控制方法和区别
- 深入Java集合学习系列:HashMap的实现原理