Java集合高级(一)HashMap

目录

  • HashMap容器简介
  • HashMap源码及数据结构深入分析
  • 注意问题及性能优化

HashMap容器简介
        HashMap以K/V形式来存储数据,基于哈希表结构,本质上是一个数组+链表的结构,提供了高效率的添加和检索。影响HashMap性能的主要有两个因素,一个是桶的数量,另外一个就是加载因子,桶数*加载因子就是HashMap扩容的临界值。如果扩容临界值设置过小,实际存储数据又过多,扩容次数就会很频繁,从时间成本上就会影响性能。相反如果临界值设置过大,get和迭代操作性能就会降低,这是由空间成本引起的。 与Hashtable相比,HashMap允许使用 null 值和 null 键,除了非同步和允许使用 null 之外,它与 Hashtable 大致相同,此外HashMap的迭代器也是快速失败的,因为迭代过程中不会检测对集体结构上的修改(比如put一个新k/v,或remove已有值,但不包括替换),从而会出并发修改异常ConcurrentModificationException。

HashMap源码及数据结构分析(版本JDK1.8.0_131)
初始化:基本状态及构造函数部分源码

    static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16,默认容量
   
    static final int MAXIMUM_CAPACITY = 1 << 30;        // 最大容量

    static final float DEFAULT_LOAD_FACTOR = 0.75f;     //默认加载因子

    transient Node[] table;                        //Map.Entry数组(桶数组) 

    transient int modCount;                             //结构被修改的次数

    int threshold;                                      //下次扩容临界值
  
    final float loadFactor;                             //加载因子(不允许修改)

public HashMap(int initialCapacity, float loadFactor) {
        if (initialCapacity < 0)
            throw new IllegalArgumentException("Illegal initial capacity: " +
                                               initialCapacity);
        if (initialCapacity > MAXIMUM_CAPACITY)
            initialCapacity = MAXIMUM_CAPACITY;
        if (loadFactor <= 0 || Float.isNaN(loadFactor))
            throw new IllegalArgumentException("Illegal load factor: " +
                                               loadFactor);
        this.loadFactor = loadFactor;    
        this.threshold = tableSizeFor(initialCapacity);    //首次扩容临界值=大于给定cap的第一个满足2的N次幂的值(如15则首次扩容为临界值为2的4次方=16)
    }
   
    public HashMap(int initialCapacity) {
        this(initialCapacity, DEFAULT_LOAD_FACTOR);
    }
    
    public HashMap() {
        this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
    }

从源码可以看出,在JDK1.8中HashMap默认的初始容量仍然是16,加载因子是0.75,扩容临界值threshold仍然等于【桶数*加载因子 =(int)8*0.75 = 6】虽然在1.8的构造器中把threshold设置成了取大于capacity的第一个等于2的N次幂【8】,但第一次put后又会把threshold变成桶数*加载因子,所以目前来看构造器中的设置并没有意义)。此外在JDK1.8中,new HashMap<>()时不会创建Node[] table桶数组,而是在第一次put()的时候去创建,也就是说首次扩容操作(resize())发生在put()时候


put()/get()核心源码

  final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
                   boolean evict) {
        Node[] tab; Node p; int n, i;
        if ((tab = table) == null || (n = tab.length) == 0)
            n = (tab = resize()).length;    //构造时没有创建table,所以首次扩容发生在第一次put。
        if ((p = tab[i = (n - 1) & hash]) == null)    //校验table[i]位置是否被占用,对于key==null的键,其hash==0,永远处于第一个元素)
            tab[i] = newNode(hash, key, value, null);
        else {
            Node 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)p).putTreeVal(this, tab, hash, key, value);
            else {
                for (int binCount = 0; ; ++binCount) { //迭代,如果在迭代过程中发现相同Node,则记录该Node。否则把新的Node添加到链接末尾
                    if ((e = p.next) == null) {
                        p.next = newNode(hash, key, value, null);
                        if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                            treeifyBin(tab, hash);    //链表长度达到8个即转为红黑树结构
                        break;
                    }
                    if (e.hash == hash &&
                        ((k = e.key) == key || (key != null && key.equals(k))))
                        break;
                    p = e;
                }
            }
            if (e != null) { // existing mapping for key  (如果存在相同Node,则进行值的替换)
                V oldValue = e.value;
                if (!onlyIfAbsent || oldValue == null)
                    e.value = value;
                afterNodeAccess(e);
                return oldValue;
            }
        }
        ++modCount;
        if (++size > threshold)
            resize();                //重调Map
        afterNodeInsertion(evict);
        return null;
    }
    
    /**
     * 重调map
     */
    final Node[] resize() {
        Node[] 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 &&    //newCap调整为oldCap的两倍
                     oldCap >= DEFAULT_INITIAL_CAPACITY)     
                newThr = oldThr << 1; // double threshold    //大于=16时,调整为oldThr两倍,其实等价于newCap * 加载因子,比如newThr(8*0.75) = oldThr(4*0.75)*2 = 3 * 2 
        }
        else if (oldThr > 0) // initial capacity was placed in threshold
            newCap = oldThr;
        else {               // zero initial threshold signifies using defaults
            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[] newTab = (Node[])new Node[newCap]; //创建新Node数组
        table = newTab;
        if (oldTab != null) {
            for (int j = 0; j < oldCap; ++j) {        //复制元素,并删除旧Node数组元素
                Node 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)e).split(this, newTab, j, oldCap);
                    else { // preserve order  保证原链表顺序,由于扩容后table长度会发生变化,所以需要重新计算每个Entry的存储位置, 这里拆分Entry链。
                        Node loHead = null, loTail = null;
                        Node hiHead = null, hiTail = null;
                        Node 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;
    }

从源码可以看出HashMap的存储结构与存储过程:HashMap内部维护了一个存储数据的Entry数组(并保证该数组不管是初始化还是扩容后其大小永远是2^{n}),Entry本质上是一个单向链表,HashMap采用该链表来解决key冲突的情况key为null的键值对永远都放在以table[0]为头结点的链表中。当put一个key-value对时,首先通过hash(key)计算得到key的hash值,然后结合数组长度通过计算(table.length - 1) & hash得到存储位置table[n](2^{n}-1使得所有二进制位都为1),如果table[n]位置未被占用则创建一个Entry并插入到位置n;否则产生碰撞,迭代Entry链依次比较新Key与每个Entry.K,如果key.equals(Entry.K)==true则替换旧的value,否则创建新Entry并插入到链表的末尾(1.7中在头部),默认情况下当HashMap大小达到容量的75%时,会执行扩容操作创建一个新的Entry数组,长度是原来的2倍,在1.8中当某个Entry链长度>= 8 时,还会把该Entry链转换为红黑树结构。在扩容过程中由于Node table数组容量发生的变化,所以需要重新计算所有元素的存储位置,如有必要拆分Entry链。
下面代码虽然简单但却非常全面的测试了map初始化、碰撞及扩容过程

public static void main(String[] args) throws Exception { 
		HashMap map = new HashMap<>(1);      
		map.put(1, 1);			
		map.put(3, 3);			
	}

从源码角度分析代码:
(1) new HashMap<>(1):最终结果capacity == 1、threshold == 2的0次 == 1、Node[] table == null、size == 0
(2) map.put(1,1);这里执行了两次resize()操作。

 

  •         Entry[] table==null时执行第一次resize(),结果capacity == 1、threshold == (int)1*0.75 == 0、table = new Node[1])
  •         put(1,1)之后,由于++size(++0) > threshold(0),需要扩容,所以此时执行第二次resize()操作,扩容结果:capacity = 1*2、 threshold = (int)2*0.75 = 1、table = new Node[2]所以Entry<1,1>位于(tab.length - 1 & 1) == 1 & 1 == tab[1] 

(3) map.put(3,3) 同样由于(tab.length - 1 & 3) == 1 & 3 == 1, 所以Entry<3,3>同样应处于tab[1]位于并且作为Entry<1,1>的next元素而存在。但此时由于++size(++1)又大于了threshold(1),将再次执行resize(),最终capacity == 4、threshold==-3,由于Entry[] table的容量发生了变化,所以此时需要迭代每个Entry链,对Entry链中的每个元素进行重新计算,得出新的存储位置。所以这里tab[1]处的Entry链(Entry<1,1>-->Entry<3,3>)将会被拆分,因为(tab.length  - 1) & 3 = 3 & 3 = 3,不再是1,所以最终会把Entry<3,3>存储在tab[3]。而Entry<1,1>仍然处于tab[1]处。最终结果及对应变化可以用下图表示


数据结构
上方的源码其实已经说明了HashMap底层的数据结构,图解的话大致如下

注意问题及性能优化
     HashMap并不是线程安全的,如果需要线程安全的话可以考虑通过Collections.synchronizeMap(hmap)来包装,但这种方式本质上和HashTable没什么区别,都是以map本身为锁对象,效率并不高。并发情况下应该使用ConcurrentHashMap
     HashMap扩容时,由于Node[] table长度发生变化,所以会重新计算每个元素的位置,在这个过程中可能会拆分Entry 链。另外随着table数组的增长,每次扩容的时间和空间成本都会增加。所以初始化时应尽可能设置合理的大小,如果计划存储20个元素,在加载因子不变的情况下,容量设为 2^{5}=32最为合理(20 / 0.75 = 26)。
     不考虑冲突的情况下,HashMap的复杂度为O(1),不管数据量多大,一次计算就可以找到目标;当然实际应用中随着Key的增加,冲突的可能性越大, 如果请求大量key不同,但是hashCode相同的数据甚至可以造成Hash攻击,让HashMap不断发生碰撞,硬生生的变成一个单链表,这样put/get性能就从O(1)变成了O(N)。从这点出发应尽量减少碰撞的机率,使用比较高率的hashCode,比如可以采用Integer、String等final变量作为Key,这种类型的hash值产生冲突的可能性很少,比如1的hashCode就是1,2的就是2。
   另外一种常见的问题就是HashMap的扩容时死循环问题


    void transfer(Entry[] newTable) {
        Entry[] src = table;
        int newCapacity = newTable.length;
        for (int j = 0; j < src.length; j++) {
            Entry e = src[j];
            if (e != null) {
                src[j] = null;
                do {
                    Entry next = e.next;    //若线程A执行此行被挂起,线程B整个更新链表。线程A继续运行,则很可能产生死循环或者put丢失。
                    int i = indexFor(e.hash, newCapacity);
                    e.next = newTable[i];
                    newTable[i] = e;
                    e = next;
                } while (e != null);
            }
        }
    }

死循环问题在1.8中已经被解决,1.8中在扩容时声明了两对指针,维护两个链表,依次在末端添加新的元素,在多线程操作的情况下,不会出现交叉的情况,顶多也就是每个线程重复同样操作。

 else { // preserve order
                        Node loHead = null, loTail = null;
                        Node hiHead = null, hiTail = null;
                        Node 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;
                        }
                    }

    HashMap迭代过程中不会检测对map结构的修改,所以并发访问情况下(或单线程下在迭代中修改)很容易抛出ConcurrentModificationException。
 

你可能感兴趣的:(Java框架,Java高级,Java,EE)