Java源码解析之———HashMap

Java为数据结构中的映射定义了一个接口java.util.Map,此接口主要有四个常用实现类,HashMap,Hashtable,LinkedHashMap,TreeMap.如图:

HashMap简介:

    它根据键的hashCode值存储数据,大多数情况下可以直接定位到它的值,因为具有很快的访问速度,但遍历顺序是不固定的。HashMap最多只允许一个键为null,允许多个值为null。HashMap非线程安全,即任一时刻可以有多个线程同时写HashMap,可能会导致数据的不一致。如果需要满足线程安全,可以用Collections的synchronizedMap方法使HashMap具有线程安全的能力,或者使用ConcurrentHashMap。

内部实现

    搞清楚HashNMap,首先需要知道它是什么,即它的存储结构-字段,还要弄明白它能干什么,即它的功能实现-方法。

存储结构-字段

    结构实现讲,HashMap是数组加链表+红黑树(jdk1.8新加的)实现的,如图

    

每一个黑圈圈就是一个Node<K,V> (1.8之前是Entry<K,V>)。

HashMap有一个非常重要的字段就是Node[] table 就是哈希桶数组,是一个Node数组。

这是个什么东西呢,我们查看HashMap源码可知  有一个内部类也就是Node<K,V>

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;
    }

    public final K getKey()        { return key; }
    public final V getValue()      { return value; }
    public final String toString() { return key + "=" + value; }

    public final int hashCode() {
        return Objects.hashCode(key) ^ Objects.hashCode(value);
    }

    public final V setValue(V newValue) {
        V oldValue = value;
        value = newValue;
        return oldValue;
    }

    public final boolean equals(Object o) {
        if (o == this)
            return true;
        if (o instanceof Map.Entry) {
            Map.Entry<?,?> e = (Map.Entry<?,?>)o;
            if (Objects.equals(key, e.getKey()) &&
                Objects.equals(value, e.getValue()))
                return true;
        }
        return false;
    }
}

该类实现了Entry<K,V>接口,本质上也就是一个映射,也就是我们所说的键值对。

HashMap就是使用哈希表来存储的。哈希表为解决冲突,可以采用开放地址法和链地址法,HashMap采用的是链地址法。简单来说就是数组加链表,在每个数组元素上都加一个链表结构,当我们存储元素时,先调用key的hashCode()方法,然后通过Hash算法的后两步运算(后面会说到)来定位该键值对的存储位置,如果该位置已经有了某键值对,那么就发生了hash碰撞。如果哈希桶数组很大,即使交叉的Hash算法也会比较分散,如果哈希桶数组很小,即使好的Hash算法也会出现较多的碰撞,所以就需要在空间成本和时间成本之间做权衡。下面看一下HashMap是通过什么来控制这些的。

先看一下HashMap的几个字段

int threshold;                                //所能容纳的键值对,也叫做阀值   通过负载因子 * 容量得来

final float loadFactor;                   //负载因子  默认0.75  可以超过1

int modCount;                              

int size;

首先table的默认初始化长度是16,负载因子是0.75,默认允许存储的元素数量就是0.75 * 16 。也就是说,在数组定义好长度之后,负载因子越大 ,能存储元素的数量就越多。如果超过了这个容量,那么就要进行resize(扩容),扩容后的容量是之前的两倍。默认的负载因子0.75 是一个权衡的选择 ,尽量不要修改,当然特殊情况下除外。如果内存很充足而对效率的要求很高,可以降低负载因子的值。如果内存很紧张,而对效率要求不是很高的情况下 ,可以提高负载因子的值,前面说过  这个值是可以大于1的。

size 就很好理解了,就是HashMap中实际存储的元素数量。注意table的长度length和容纳最大键值对数量threshold是有区别的。

modCount字段主要记录了HashMap内部结构发生变化的次数,比如put新的键值对,但是如果有key相同value被覆盖的情况 则不计数。

这里就有一个问题 即使负载因子和Hash算法在合理,也会出现Hash碰撞多次的情况 也就是链表过长,这里在jdk1.8进行了优化,当链表长度大于8的时候 就转换为红黑树,利用红黑树crud比较快的特点提高了HashMap的性能。想了解红黑树的同学们可以另查一下哈。

功能实现-方法:

不管是增删改查,快速定位到元素在哪个哈希桶是很重要的。我们看一下源码可知

static final int hash(Object key) {
    int h;return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
//第一步取hashCode值  第二步 高位参与运算>>>16
 
 
static int indexFor(int h, int length) {  //偷来的 jdk1.7的源码,jdk1.8没有这个方法,但是实现原理一样的
    return h & (length-1);  //第三步 取模运算
}

对于给定的对象,如果它的hashCode值是相同的,那么程序调用第一个方法得到的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的计算中,同时不会有太大的开销。


HashMap的put方法

1     public V put(K key, V value) {
 2     // 对key的hashCode()做hash
 3     return putVal(hash(key), key, value, false, true);
 4 }
 5 
 6 final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
 7                boolean evict) {
 8     Node<K,V>[] tab; Node<K,V> p; int n, i;
 9     //tab为空则创建新的
10     if ((tab = table) == null || (n = tab.length) == 0)
11         n = (tab = resize()).length;
12     // 计算index,并对null做处理 
13     if ((p = tab[i = (n - 1) & hash]) == null) 
14         tab[i] = newNode(hash, key, value, null);
15     else {
16         Node<K,V> e; K k;
17         // 如果key已经存在那么将value值覆盖
18         if (p.hash == hash &&
19             ((k = p.key) == key || (key != null && key.equals(k))))
20             e = p;
21         // 判断该链是否为红黑树
22         else if (p instanceof TreeNode)
23             e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
24         // 该链为链表
25         else {
26             for (int binCount = 0; ; ++binCount) {
27                 if ((e = p.next) == null) {
28                     p.next = newNode(hash, key,value,null);
                        //链表长度大于8转换为红黑树进行处理
29                     if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st  
30                         treeifyBin(tab, hash);
31                     break;
32                 }
                    // key已经存在直接覆盖value
33                 if (e.hash == hash &&
34                     ((k = e.key) == key || (key != null && key.equals(k))))                                            break;
36                 p = e;
37             }
38         }
39         
40         if (e != null) { // existing mapping for key
41             V oldValue = e.value;
42             if (!onlyIfAbsent || oldValue == null)
43                 e.value = value;
44             afterNodeAccess(e);
45             return oldValue;
46         }
47     }

48     ++modCount;
49     //超过最大容量 就扩容
50     if (++size > threshold)
51         resize();
52     afterNodeInsertion(evict);
53     return null;
54 }

扩容机制:

    扩容(resize) 就是重新计算容量,向HashMap对象里不停的添加元素,而HashMap对象内部的数组无法装载更多的元素时,对象就需要扩大数组的长度,以便能装入更多的元素。当然Java里的数组是无法自动扩容的,方法是使用一个新的数组代替已有的容量小的数组。

    看一下resize的源码,jdk1.8加入了红黑树的比较复杂,我们先看一下1.7的。

 1 void resize(int newCapacity) {   //传入新的容量
 2     Entry[] oldTable = table;    //引用扩容前的老的Entry数组
 3     int oldCapacity = oldTable.length;         
 4     if (oldCapacity == MAXIMUM_CAPACITY) {  //扩容前的数组大小如果已经达到最大(2^30)了
 5         threshold = Integer.MAX_VALUE; //修改阈值为int的最大值(2^31-1),这样以后就不会扩容了
 6         return;
 7     }
 8  
 9     Entry[] newTable = new Entry[newCapacity];  //初始化一个新的Entry数组
10     transfer(newTable);                         //将数据转移到新的Entry数组里
11     table = newTable;                           //HashMap的table属性引用新的Entry数组
12     threshold = (int)(newCapacity * loadFactor);//计算新的阀值
13 }

大概就是用一个新的容量大的数组代替了老的容量小的数组,transfer()方法将原有Entry数组的元素拷贝到新的数组中

1 void transfer(Entry[] newTable) {
 2     Entry[] src = table;                   //src引用了旧的Entry数组
 3     int newCapacity = newTable.length;
 4     for (int j = 0; j < src.length; j++) { //遍历旧的Entry数组
 5         Entry<K,V> e = src[j];             //取得旧Entry数组的每个元素
 6         if (e != null) {
 7             src[j] = null;//将就的数组对象制空(for循环后,旧的Entry数组不再引用任何对象)
 8             do {
 9                 Entry<K,V> next = e.next;
10                 int i = indexFor(e.hash, newCapacity); //重新计算每个元素在数组中的位置
11                 e.next = newTable[i]; //标记
12                 newTable[i] = e;      //将元素放在数组上
13                 e = next;             //访问下一个Entry链上的元素
14             } while (e != null);
15         }
16     }
17 }

这里我个人理解的也不是特别完善,jdk1.8这里也有一些改动  我会继续研究并加上来的

小结:

1、扩容是一个特别耗性能的操作,所以在使用HashMap的时候,估算map的大小,初始化的时候给一个大致的数值,避免map进行频繁的扩容

2、负载因子是可以修改的,也可以大于1,但是建议不要轻易修改

3、HashMap是线程不安全的,不要在并发的环境中同时操作HashMap,建议使用ConcurrentHashMap

4、jdk1.8引入红黑树大程度优化了HashMap的性能







猜你喜欢

转载自blog.csdn.net/java_ying/article/details/79978148