자바 8 새로운 이해의 HashMap

 

개요

자바 프로그래머의 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次方(一定是合数),这是一种非常规的设计,常规的设计是把桶的大小设计为素数。相对来说素数导致冲突的概率要小于合数,具体证明可以参考,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的性能。

 

参考:https://zhuanlan.zhihu.com/p/21673805

추천

출처www.cnblogs.com/zhangyaotong/p/11245845.html