개요
자바 프로그래머의 HashMap가 가장 자주 데이터 유형에 대한 매핑 (키 - 값 쌍)에 사용됩니다. 업데이트의 JDK (자바 Developmet 키트) 버전으로, JDK1.8 부하의 HashMap은 예를 들어, 데이터 구조, 최적화 달성하고 확장의 최적화는 레드 - 블랙 트리를 소개했다. 본 논문에서는 JDK1.7과 JDK1.8, 구조와 기능의 구현 원리 HashMap의 깊이 연구의 차이.
간략한 소개
자바 데이터 구조 매핑하는 인터페이스를되고있는 java.util.Map를 정의하고,이 인터페이스는 다음과 같이 네 개의 일반적인 구현 클래스의 HashMap, 해시 테이블, 및 트리 맵의 LinkedHashMap 클래스 계층 구조가있다 :
다음 기능은 각 구현 클래스에 대한 몇 가지 메모를합니다
(1)의 HashMap : 이것은 데이터 값 저장된 해시 코드 결합에 기반 직접 값 대부분 위치, 따라서 빠른 접근 속도를 갖지만, 순회 순서는 불투명 할 수있다. HashMap의 키는 하나 개의 레코드의 최대 하나 개의 레코드가 null보다 더 허용, 널 수 있습니다. 언제 당신이 일치하지 않는 데이터가 발생할 수 있습니다, 여러 스레드가 동시에 HashMap에 쓰기 수, 즉, 비 스레드 안전 해시 맵. 당신은 보안 실을 충족해야하는 경우, 방법은 HashMap의 스레드 안전 기능의 synchronizedMap 컬렉션을 사용하거나, ConcurrentHashMap의를 사용할 수 있습니다.
(2) 해시 테이블은 : 해시 테이블은 기존 클래스, 일반적인 기능지도의 많은과의 HashMap은 해시 테이블을 작성할 수 있습니다 한 번에 하나 개의 스레드에서 스레드 안전이 사전 클래스에서 상속 된 것을 제외하고, 유사이며, 동시성이 더 ConcurrentHashMap의가 있기 때문이다 ConcurrentHashMap의 잠금 세그먼트가 도입된다. 해시 테이블의 HashMap을 대체하는 데 사용할 수있는 스레드 안전 응용 프로그램을 필요로하지 않는, 새로운 코드의 사용을 권장 스레드 안전 응용 프로그램 ConcurrentHashMap의로 대체 할 수 필요하지 않습니다.
(3)의 LinkedHashMap :의 LinkedHashMap은 HashMap의 서브 클래스이며, 순차적으로 기록되어 삽입 한 보유 및 반복자 구성 중에 매개 변수 일 수 있고, 기록을 먼저 삽입해야 수득한다의 LinkedHashMap을 통과 할 때, 액세스 순서를 정렬.
(4) 트리 맵 : 트리 맵 키 순서에 기초하여 레코드를 저장할 수의 SortedMap 인터페이스를 구현하고, 기본 오름차순 정렬 키의 값이 또한 지정 될 수 정렬 비교기 사용 반복자가 트리 맵을 통과 얻어 레코드 시퀀스를 통해 방전 인 가. 당신이지도의 종류를 사용하는 경우는 트리 맵을 사용하는 것이 좋습니다. 트리 맵을 사용하는 경우, 키 그렇지 않으면 런타임에 예외 java.lang.ClassCastException가 유형을 던질 것이다, Comparable 인터페이스 또는 비교기 생성자 트리 맵 들어오는 사용자 정의를 구현해야합니다.
클래스의 위의 네 가지 유형에 대한지도, 핵심 요구 사항지도 불변의 객체입니다. 불변 오브젝트가 생성 된 후에 그 해시 값의 객체가 변경되지이다. 객체 변경의 해시 값은 객체의 위치를 찾는지도는지도에 비해 가능성이 작은 경우.
위의 비교함으로써, 우리는 대부분의 시나리오의 관점에서 사용 조건을 충족하기 위해, 일반 회원의 자바 가족의 HashMap의지도를 알고, 그것을 사용의 가장 높은 주파수 중 하나입니다. 우리는 주로 소스 코드와 함께, 저장 구조, 일반적으로 사용되는 방법, 확장 및 보안과 작동 원리 HashMap에 너무 깊이있는 설명에서 분석 아래.
저장 구조 - 필드
아래와 같이 달성하기 구조의 관점에서, 해시 MAP 레드 - 블랙 트리 배열 + + 쇄 (JDK1.8는 레드 - 블랙 트리 부분 증가) 달성이다. 아래와 같이 달성하기 구조의 관점에서, 해시 MAP 레드 - 블랙 트리 배열 + + 쇄 (JDK1.8는 레드 - 블랙 트리 부분 증가) 달성이다.
우리는 두 가지 질문을 이해하는 말을해야합니다 특정 기본 데이터 저장소는 무엇입니까? 이러한 저장 그것의 장점은 무엇입니까?
(1) 광원으로부터 본 상기 HashMap의 클래스는 중요한 필드 노드 [] 테이블, 즉, 해시 버킷 배열은, 그것이 명백하게 노드의 배열이다 갖는다. 우리는 노드가 [JDK1.8]를 일이 보인다.
static class Node<K,V> implements Map.Entry<K,V> { final int hash; //用来定位数组索引位置 final K key; V value; Node<K,V> next; //链表的下一个node Node(int hash, K key, V value, Node<K,V> next) { ... } public final K getKey(){ ... } public final V getValue() { ... } public final String toString() { ... } public final int hashCode() { ... } public final V setValue(V newValue) { ... } public final boolean equals(Object o) { ... } }
Node是HashMap的一个内部类,实现了Map.Entry接口,本质是就是一个映射(键值对)。上图中的每个黑色圆点就是一个Node对象。
(2) HashMap就是使用哈希表来存储的。哈希表为解决冲突,可以采用开放地址法和链地址法等来解决问题,Java中HashMap采用了链地址法。链地址法,简单来说,就是数组加链表的结合。在每个数组元素上都一个链表结构,当数据被Hash后,得到数组下标,把数据放在对应下标元素的链表上。例如程序执行下面代码:
map.put("美团","小美");
系统将调用"美团"这个key的hashCode()方法得到其hashCode 值(该方法适用于每个Java对象),然后再通过Hash算法的后两步运算(高位运算和取模运算,下文有介绍)来定位该键值对的存储位置,有时两个key会定位到相同的位置,表示发生了Hash碰撞。当然Hash算法计算结果越分散均匀,Hash碰撞的概率就越小,map的存取效率就会越高。
如果哈希桶数组很大,即使较差的Hash算法也会比较分散,如果哈希桶数组数组很小,即使好的Hash算法也会出现较多碰撞,所以就需要在空间成本和时间成本之间权衡,其实就是在根据实际情况确定哈希桶数组的大小,并在此基础上设计好的hash算法减少Hash碰撞。那么通过什么方式来控制map使得Hash碰撞的概率又小,哈希桶数组(Node[] table)占用空间又少呢?答案就是好的Hash算法和扩容机制。
在理解Hash和扩容流程之前,我们得先了解下HashMap的几个字段。从HashMap的默认构造函数源码可知,构造函数就是对下面几个字段进行初始化,源码如下:
int threshold; // 所能容纳的key-value对极限 final float loadFactor; // 负载因子 int modCount; int size;
首先,Node[] table的初始化长度length(默认值是16),Load factor为负载因子(默认值是0.75),threshold是HashMap所能容纳的最大数据量的Node(键值对)个数。threshold = length * Load factor。也就是说,在数组定义好长度之后,负载因子越大,所能容纳的键值对个数越多。
结合负载因子的定义公式可知,threshold就是在此Load factor和length(数组长度)对应下允许的最大元素数目,超过这个数目就重新resize(扩容),扩容后的HashMap容量是之前容量的两倍。默认的负载因子0.75是对空间和时间效率的一个平衡选择,建议大家不要修改,除非在时间和空间比较特殊的情况下,如果内存空间很多而又对时间效率要求很高,可以降低负载因子Load factor的值;相反,如果内存空间紧张而对时间效率要求不高,可以增加负载因子loadFactor的值,这个值可以大于1。
size这个字段其实很好理解,就是HashMap中实际存在的键值对数量。注意和table的长度length、容纳最大键值对数量threshold的区别。而modCount字段主要用来记录HashMap内部结构发生变化的次数,主要用于迭代的快速失败。强调一点,内部结构发生变化指的是结构发生变化,例如put新键值对,但是某个key对应的value值被覆盖不属于结构变化。
在HashMap中,哈希桶数组table的长度length大小必须为2的n次方(一定是合数),这是一种非常规的设计,常规的设计是把桶的大小设计为素数。相对来说素数导致冲突的概率要小于合数,具体证明可以参考http://blog.csdn.net/liuqiyao_01/article/details/14475159,Hashtable初始化桶大小为11,就是桶大小设计为素数的应用(Hashtable扩容后不能保证还是素数)。HashMap采用这种非常规设计,主要是为了在取模和扩容时做优化,同时为了减少冲突,HashMap定位哈希桶索引位置时,也加入了高位参与运算的过程。
这里存在一个问题,即使负载因子和Hash算法设计的再合理,也免不了会出现拉链过长的情况,一旦出现拉链过长,则会严重影响HashMap的性能。于是,在JDK1.8版本中,对数据结构做了进一步的优化,引入了红黑树。而当链表长度太长(默认超过8)时,链表就转换为红黑树,利用红黑树快速增删改查的特点提高HashMap的性能,其中会用到红黑树的插入、删除、查找等算法。
功能实现-方法
HashMap的内部功能实现很多,本文主要从根据key获取哈希桶数组索引位置、put方法的详细执行、扩容过程三个具有代表性的点深入展开讲解。
1. 确定哈希桶数组索引位置
不管增加、删除、查找键值对,定位到哈希桶数组的位置都是很关键的第一步。前面说过HashMap的数据结构是数组和链表的结合,所以我们当然希望这个HashMap里面的元素位置尽量分布均匀些,尽量使得每个位置上的元素数量只有一个,那么当我们用hash算法求得这个位置的时候,马上就可以知道对应位置的元素就是我们要的,不用遍历链表,大大优化了查询的效率。HashMap定位数组索引位置,直接决定了hash方法的离散性能。先看看源码的实现(方法一+方法二):
方法一: static final int hash(Object key) { //jdk1.8 & jdk1.7 int h; // h = key.hashCode() 为第一步 取hashCode值 // h ^ (h >>> 16) 为第二步 高位参与运算 return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16); } 方法二: static int indexFor(int h, int length) { //jdk1.7的源码,jdk1.8没有这个方法,但是实现原理一样的 return h & (length-1); //第三步 取模运算 }
这里的Hash算法本质上就是三步:取key的hashCode值、高位运算、取模运算。
对于任意给定的对象,只要它的hashCode()返回值相同,那么程序调用方法一所计算得到的Hash码值总是相同的。我们首先想到的就是把hash值对数组长度取模运算,这样一来,元素的分布相对来说是比较均匀的。但是,模运算的消耗还是比较大的,在HashMap中是这样做的:调用方法二来计算该对象应该保存在table数组的哪个索引处。
这个方法非常巧妙,它通过h & (table.length -1)来得到该对象的保存位,而HashMap底层数组的长度总是2的n次方,这是HashMap在速度上的优化。当length总是2的n次方时,h& (length-1)运算等价于对length取模,也就是h%length,但是&比%具有更高的效率。
在JDK1.8的实现中,优化了高位运算的算法,通过hashCode()的高16位异或低16位实现的:(h = k.hashCode()) ^ (h >>> 16),主要是从速度、功效、质量来考虑的,这么做可以在数组table的length比较小的时候,也能保证考虑到高低Bit都参与到Hash的计算中,同时不会有太大的开销。
下面举例说明下,n为table的长度。
2. 分析HashMap的put方法
HashMap的put方法执行过程可以通过下图来理解,自己有兴趣可以去对比源码更清楚地研究学习。
①.判断键值对数组table[i]是否为空或为null,否则执行resize()进行扩容;
②.根据键值key计算hash值得到插入的数组索引i,如果table[i]==null,直接新建节点添加,转向⑥,如果table[i]不为空,转向③;
③.判断table[i]的首个元素是否和key一样,如果相同直接覆盖value,否则转向④,这里的相同指的是hashCode以及equals;
④.判断table[i] 是否为treeNode,即table[i] 是否是红黑树,如果是红黑树,则直接在树中插入键值对,否则转向⑤;
⑤.遍历table[i],判断链表长度是否大于8,大于8的话把链表转换为红黑树,在红黑树中执行插入操作,否则进行链表的插入操作;遍历过程中若发现key已经存在直接覆盖value即可;
⑥.插入成功后,判断实际存在的键值对数量size是否超多了最大容量threshold,如果超过,进行扩容。
JDK1.8HashMap的put方法源码如下:
public V put(K key, V value) { // 对key的hashCode()做hash 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; // 步骤①:tab为空则创建 if ((tab = table) == null || (n = tab.length) == 0) n = (tab = resize()).length; // 步骤②:计算index,并对null做处理 if ((p = tab[i = (n - 1) & hash]) == null) tab[i] = newNode(hash, key, value, null); else { Node<K,V> e; K k; // 步骤③:节点key存在,直接覆盖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) { 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; } // key已经存在直接覆盖value 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; afterNodeAccess(e); return oldValue; } } ++modCount; // 步骤⑥:超过最大容量 就扩容 if (++size > threshold) resize(); afterNodeInsertion(evict); return null; }
3. 扩容机制
扩容(resize)就是重新计算容量,向HashMap对象里不停的添加元素,而HashMap对象内部的数组无法装载更多的元素时,对象就需要扩大数组的长度,以便能装入更多的元素。当然Java里的数组是无法自动扩容的,方法是使用一个新的数组代替已有的容量小的数组,就像我们用一个小桶装水,如果想装更多的水,就得换大水桶。
线程安全性
在多线程使用场景中,应该尽量避免使用线程不安全的HashMap,而使用线程安全的ConcurrentHashMap。那么为什么说HashMap是线程不安全的,下面举例子说明在并发的多线程使用场景中使用HashMap可能造成死循环。代码例子如下(便于理解,仍然使用JDK1.7的环境):
public class HashMapInfiniteLoop { private static HashMap<Integer,String> map = new HashMap<Integer,String>(2,0.75f); public static void main(String[] args) { map.put(5, "C"); new Thread("Thread1") { public void run() { map.put(7, "B"); System.out.println(map); }; }.start(); new Thread("Thread2") { public void run() { map.put(3, "A); System.out.println(map); }; }.start(); } }
其中,map初始化为一个长度为2的数组,loadFactor=0.75,threshold=2*0.75=1,也就是说当put第二个key的时候,map就需要进行resize。
通过设置断点让线程1和线程2同时debug到transfer方法(3.3小节代码块)的首行。注意此时两个线程已经成功添加数据。放开thread1的断点至transfer方法的“Entry next = e.next;” 这一行;然后放开线程2的的断点,让线程2进行resize。结果如下图。
注意,Thread1的 e 指向了key(3),而next指向了key(7),其在线程二rehash后,指向了线程二重组后的链表。
线程一被调度回来执行,先是执行 newTalbe[i] = e, 然后是e = next,导致了e指向了key(7),而下一次循环的next = e.next导致了next指向了key(3)。
e.next = newTable[i] 导致 key(3).next 指向了 key(7)。注意:此时的key(7).next 已经指向了key(3), 环形链表就这样出现了。
小结
(1) 扩容是一个特别耗性能的操作,所以当程序员在使用HashMap的时候,估算map的大小,初始化的时候给一个大致的数值,避免map进行频繁的扩容。
(2) 负载因子是可以修改的,也可以大于1,但是建议不要轻易修改,除非情况非常特殊。
(3) HashMap是线程不安全的,不要在并发的环境中同时操作HashMap,建议使用ConcurrentHashMap。
(4) JDK1.8引入红黑树大程度优化了HashMap的性能。