HashMap原理与理解

本文以 Java 1.8 为基础进行展开。

一、HashMap的基本结构

  HashMap是基于哈希表的Map接口的非同步实现。此实现提供所有可选的映射操作,并允许使用null值和null键。此类不保证映射的顺序,特别是它不保证该顺序恒久不变。

  在java编程语言中,最基本的结构就是两种,一个是数组,另外一个是模拟指针(引用),所有的数据结构都可以用这两个基本结构来构造,比如String、ArrayList等,HashMap也不例外。HashMap实际上是一个“链表散列”的数据结构,即数组、链表、红黑树的结合体(Java 1.8引入了红黑树结构)。

  数组的特点:查询效率高,插入,删除效率低。

  链表的特点:查询效率低,插入删除效率高。

  在HashMap底层使用数组加(链表或红黑树)的结构完美的解决了数组和链表的问题,使得查询和插入,删除的效率都很高。

  其结构模型可参考下图(借用baidu的两张图):

  

二、HashMap的基本属性、方法

  1. HashMap的成员变量

static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; //默认的初始容量为16

static final int MAXIMUM_CAPACITY = 1 << 30; //最大的容量为 2 ^ 30

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

static final int TREEIFY_THRESHOLD = 8; //链表长度大于等于8时转化为红黑树

static final int UNTREEIFY_THRESHOLD = 6; //红黑树长度小于等于6时转化为链表

static final int MIN_TREEIFY_CAPACITY = 64; //链表转为红黑树时要求的table capacity最小容量

transient Node<K,V>[] table; //Node类型的数组(实现了Entry接口),HashMap的基本组成单元,用来存储key-value映射

transient Set<Map.Entry<K,V>> entrySet; //

transient int size; //HashMap包含key-value映射的数量

final float loadFactor; //加载因子

int threshold; //HashMap的扩容临界点(容量和加载因子的乘积)

transient int modCount; // 每次扩容和更改map结构的计数器
View Code

  计算数组index的时候,为什么要用位运算&呢?

  主要是效率问题,位运算(&)效率要比取模运算(%)高很多,主要原因是位运算直接对内存数据进行操作,不需要转成十进制,因此处理速度非常快。在jdk1.8之前的index计算就是用的取模运算%。

  MAXIMUM_CAPACITY 为什么设置成 1 << 30

  i. HashMap在确定数组下标Index的时候,采用的是( length-1) & hash的方式。只有当length为2的指数幂,2的n次-1的二进制表示刚好全为1,这样&运算确定的index才能分布均匀,不然如果有一位是0,那

么与运算结果对应的这一位也永远是0,那对应的数组index处就为空,index分布不均匀了。所以HashMap规定了其容量必须是2的n次方,这样才能较均匀的分布元素,hash%length = hash&(length-1)才能成

立。

  ii. 另外,HashMap内部由Node[](Entry[])数组构成,Java的数组下标是由Integer表示的。所以对于HashMap来说其最大的容量应该是不超过Integer最大值的一个2的指数幂,而最接近Integer最大值的2的

指数幂就是 1 << 30。此时,HashMap也就无法再继续扩容。

  DEFAULT_LOAD_FACTOR = 0.75f 加载因子

  loadFactor加载因子,是用来衡量 HashMap 满的程度,表示HashMap的疏密程度,影响hash操作到同一个数组位置的概率。计算HashMap的实时加载因子的方法为:size/capacity,而不是占用桶的数量去除以capacity。capacity 是桶的数量,也就是 table 的长度length。当Size>=threshold的时候,那么就要考虑对数组的resize(扩容)。当链表的值超过8则会转红黑树(jdk 1.8新增)

  loadFactor太大导致查找元素效率低,太小导致数组的利用率低,存放的数据会很分散。loadFactor的默认值为0.75f是官方给出的一个比较好的临界值。

  当HashMap里面容纳的元素已经达到HashMap数组长度的75%时,表示HashMap太挤了,需要扩容,而扩容这个过程涉及到 rehash、复制数据等操作,非常消耗性能。所以开发中尽量减少扩容的次数,可

以通过创建HashMap集合对象时指定初始容量来尽量避免。

  同时在HashMap的构造器中可以指定loadFactor:

    public HashMap(int initialCapacity) {
        this(initialCapacity, DEFAULT_LOAD_FACTOR);
    }

    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);
    }
View Code

  TREEIFY_THRESHOLD = 8;   UNTREEIFY_THRESHOLD = 6;

  为什么Map桶中节点个数大于等于8转为红黑树?桶中链表元素个数小于等于6时,树结构还原成链表?

  解释1. 参考自:https://www.cnblogs.com/xc-chejj/p/10825676.html

  因为红黑树的平均查找长度是log(n),长度为8的时候,平均查找长度为3,如果继续使用链表,平均查找长度为8/2=4,这才有转换为树的必

要。链表长度如果是小于等于6,6/2=3,虽然速度也很快的,但是转化为树结构和生成树的时间并不会太短。

  还有选择6和8,中间有个差值7可以有效防止链表和树频繁转换。假设一下,如果设计成链表个数超过8则链表转换成树结构,链表个数小于8则树结构转换成链表,如果一个HashMap不停的插入、删除元

素,链表个数在8左右徘徊,就会频繁的发生树转链表、链表转树,效率会很低。

  解释2. 参考自:https://www.cnblogs.com/coding-996/p/12468618.html

  根据官方源码的注释可以看到:

threshold of 0.75, although with a large variance because of
     * resizing granularity. Ignoring variance, the expected
     * occurrences of list size k are (exp(-0.5) * pow(0.5, k) /
     * factorial(k)). The first values are:
     *
     * 0:    0.60653066
     * 1:    0.30326533
     * 2:    0.07581633
     * 3:    0.01263606
     * 4:    0.00157952
     * 5:    0.00015795
     * 6:    0.00001316
     * 7:    0.00000094
     * 8:    0.00000006
     * more: less than 1 in ten million
View Code

  大概意思就是说,在理想情况下,使用随机哈希码,节点出现在hash桶中的频率遵循泊松分布,同时给出了桶中元素个数和概率的对照表。从上面的表中可以看到当桶中元素到达8个的时候,概率已经变得非

常小,也就是说用0.75作为加载因子,每个碰撞位置的链表长度超过8个的概率小于一千万分之一。即加载因子为0.75,同一个桶中出现8个元素然后转化为红黑树的概率小于1000万分之一。

  MIN_TREEIFY_CAPACITY = 64

  当HashMap桶的容量超过这个值时才能进行树形化 ,否则会先选择扩容,而不是树形化。为了避免进行扩容、树形化选择的冲突,这个值不能小于 4 * TREEIFY_THRESHOLD。

  Node<K,V>[] table

  在jdk1.7中,Entry数组是HashMap的基本组成单元:

View Code

  jdk1.8中,HashMap基本单元由Node数组组成,Node数组实现了Entry接口:

View Code

  当然,还有jdk1.8 引入的红TreeNode静态内部类:

View Code

  Set<Map.Entry<K,V>> entrySet

  HashMap中所有key-value映射的集合,保存了所有的key-value键值对。可通过entrySet() 方法获取:

 public Set<Map.Entry<K,V>> entrySet() {
        Set<Map.Entry<K,V>> es;
        return (es = entrySet) == null ? (entrySet = new EntrySet()) : es;
 }
View Code

  粗略一看,发现HashMap中并没有主动地去维护entrySet,比如put的时候去存值或者调用entrySet()去维护值,那entryset的值从哪而来呢?具体在后面的entrySet()方法中说明。

  2. HashMap的常用方法

  (1)HashMap的主要构造器

  i. HashMap():构建一个初始容量为 16,负载因子为 0.75 的 HashMap

  View Code

  ii. HashMap(int initialCapacity):设置了初始容量initialCapacity,但方法内部会基于initialCapacity重新计算,得到一个不小于initialCapacity的最小的2的指数幂,并将其作为threshold(具体计算逻辑见下面的tableSizeFor() 方法和 put() 方法),同时设置负载因子为 0.75 

    public HashMap(int initialCapacity) {
        this(initialCapacity, DEFAULT_LOAD_FACTOR);
    }

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

    static final int tableSizeFor(int cap) {
        int n = cap - 1;
        n |= n >>> 1;
        n |= n >>> 2;
        n |= n >>> 4;
        n |= n >>> 8;
        n |= n >>> 16;
        return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
    }
View Code

  iii. HashMap(int initialCapacity, float loadFactor):以指定初始容量、指定的负载因子创建一个 HashMap。同样,方法内部也基于initialCapacity重新计算了threshold

  源码见ii 中所述方法。

  (2)tableSizeFor() 方法

  源码见上述ii 中代码。该方法对给定初始容量initialCapacity进行初始化,将其修改为不小于initialCapacity的、最小的2的n次幂,以此来保证HashMap的容量只会是2的幂。也就是说如果传入的参数为x,那么调用该方法应该返回一个大于等于x的最小的2的幂。

                                // 10000000 00000000 00000000 00000000
        n |= n >>> 1;       // 11000000 00000000 00000000 00000000
        n |= n >>> 2;       // 11110000 00000000 00000000 00000000
        n |= n >>> 4;       // 11111111 00000000 00000000 00000000
        n |= n >>> 8;       // 11111111 11111111 00000000 00000000
        n |= n >>> 16;      // 11111111 11111111 11111111 11111111
    
View Code

  其实,我们只需要关心从左边数第一个不为零的位,其他的位是1是0都不重要。n |= n >>> 1之后可以确保,从左边数第一个不为零的位开始前两位都是1,n |= n >>> 2之后可以确保前四位都是1。以此类推,最后得到的就是从第一个不为零的位开始全为1的数,再将这个数+1就可以得到大于等于cap这个变量的最小的2的幂了。

  移位5次就停止是因为HashMap最大容量是1<<30 (2的30次方),是一个int型数据。在java中int型是4个字节也就是32位,除去符号位就是31位,进行5次移位之后已经足以保证31位全为1了。

  参考自:https://blog.csdn.net/qq_41046325/article/details/88626353 

  值得注意的是,上面的源码中,是将tableForSize的值赋值给了threshold, 那为何说是我们初始化容量(capacity)的大小为该值呢?可以下面的put()方法。

  (3)put() 方法

  先解释一下上面刚刚提出的问题。在第一次向map添加数据时,调用:

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;
        if ((tab = table) == null || (n = tab.length) == 0)
            n = (tab = resize()).length;   //注意这一行代码
        if ((p = tab[i = (n - 1) & hash]) == null)
            tab[i] = newNode(hash, key, value, null);
        else {
           ...
        }
        ++modCount;
        if (++size > threshold)
            resize();
        afterNodeInsertion(evict);
        return null;
    }
View Code

   注意上面,putVal会判断table是否为null

  if ((tab = table) == null || (n = tab.length) == 0)

  如果为null,则调用resize方法:

  n = (tab = resize()).length;

  而resize()方法(源码见下文)实际上就是将之前设置的threshold作为了初始化的容量大小。

  参考自:https://www.cnblogs.com/shuhe-nd/p/12011269.html

  为了逻辑清晰、简洁易懂,在此参考另一篇博客中的一张图,put() 方法的逻辑可以用下图来表示(图中对链表转化为红黑树等条件判断进行了简化):

  

  上图参考自:https://www.cnblogs.com/one-apple-pie/p/10473682.html  

  (4)resize()方法

  当hashmap中的元素越来越多的时候,碰撞的几率也就越来越高(因为数组的长度是固定的),所以为了提高查询的效率,就要对hashmap的数组进行扩容,数组扩容这个操作也会出现在ArrayList中,所以这是一个通用的操作,很多人对它的性能表示过怀疑,不过想想我们的“均摊”原理,就释然了,而在hashmap数组扩容之后,最消耗性能的点就出现了:原数组中的数据必须重新计算其在新数组中的位置,并放进去,这就是resize。

    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) // 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<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);
                    else { // preserve order
                        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;
    }
View Code

  resize的大致逻辑:

  i. 校验和扩容:校验capacity 和 threshold,重新计算这两个参数,并新建一个Entry空数组,长度是原来的两倍。

  ii. reHash :遍历原来的Entry数组,把所有的数据重新Hash到新的数组。

  那么hashmap什么时候进行扩容呢?当hashmap中的元素个数超过数组大小*loadFactor时,就会进行数组扩容,loadFactor的默认值为0.75,也就是说,默认情况下,数组大小为16,那么当hashmap中元素个数超过16*0.75=12的时候,就把数组的大小扩展为2*16=32,即扩大一倍,然后重新计算每个元素在数组中的位置,而这是一个非常消耗性能的操作,所以如果我们已经预知hashmap中元素的个数,那么预设元素的个数能够有效的提高hashmap的性能。比如说,我们有1000个元素new HashMap(1000), 但是理论上来讲new HashMap(1024)更合适,不过即使是1000,hashmap也自动会将其设置为1024。 但是new HashMap(1024)还不是更合适的,因为0.75*1000 < 1000, 也就是说为了让0.75 * size > 1000, 我们必须这样new HashMap(2048)才最合适,既考虑了&的问题,也避免了resize的问题。

  上面参考自:https://www.cnblogs.com/yuanblog/p/4441017.html

  为什么要重新Hash呢,直接复制过去不是更快捷方便吗?

  • 因为扩容以后的数组长度变了,index = HashCode(key)&(length - 1),扩容后的length和之前不一样了,变为原来的2倍,重新Hash算出来的index值肯定也不一样,而且重新计算后,会使元素更加均匀的分布在HashMap表中,如果直接复制的话,那么数据肯定都堆在一起了,扩容的意义就削弱了。

  (5)entrySet()方法

  i. 先看一段常用的遍历代码:

View Code

  上面的遍历方式可以遍历HashMap中所有的key-value键值对,为什么entrySet()会返回值呢?继续看源码:

View Code
View Code
    final class EntryIterator extends HashIterator
        implements Iterator<Map.Entry<K,V>> {
        public final Map.Entry<K,V> next() { return nextNode(); }
    }
View Code
    abstract class HashIterator {
        Node<K,V> next;        // next entry to return
        Node<K,V> current;     // current entry
        int expectedModCount;  // for fast-fail
        int index;             // current slot

        HashIterator() {
            expectedModCount = modCount;
            Node<K,V>[] t = table;
            current = next = null;
            index = 0;
            if (t != null && size > 0) { // advance to first entry
                do {} while (index < t.length && (next = t[index++]) == null);
            }
        }

        public final boolean hasNext() {
            return next != null;
        }

        final Node<K,V> nextNode() {
            Node<K,V>[] t;
            Node<K,V> e = next;
            if (modCount != expectedModCount)
                throw new ConcurrentModificationException();
            if (e == null)
                throw new NoSuchElementException();
            if ((next = (current = e).next) == null && (t = table) != null) {
                do {} while (index < t.length && (next = t[index++]) == null);
            }
            return e;
        }

        public final void remove() {
            Node<K,V> p = current;
            if (p == null)
                throw new IllegalStateException();
            if (modCount != expectedModCount)
                throw new ConcurrentModificationException();
            current = null;
            K key = p.key;
            removeNode(hash(key), key, null, false, false);
            expectedModCount = modCount;
        }
    }
View Code

  可以看到,上面entrySet() 方法中在entrySet为空时,会初始化一个EntrySet类。而该类中重写了一个方法iterator(),并且在方法内部调用了EntryIterator 的构造方法。EntryIterator 类又继承了HashIterator类,所以会默认调用该类的构造方法。在HashIterator 的构造方法中,会初始化一个next变量,构造初期会从0开始找有值(不为空)的索引位置,找到后将这个Node赋值给next;然后要遍历的时候调用了EntryIterator的 next() 方法,即调用了HashIterator的 nextNode() 方法,这个方法会一直遍历找到下一个有值(不为空)的索引位置。如此反复。

  所以,HashMap在put 的时候维护了Node<K,V>[] table,然后在entrySet() 的时候会遍历这个table,从而获得所有的key-value键值对。

  参考自:https://www.cnblogs.com/javammc/p/7631597.html

  另外,关于entrySet 值初始化的问题,可能跟默认调用toString()方法有关,可以参考下面的讨论:

  https://www.cnblogs.com/allmignt/p/12353732.htmlhttps://segmentfault.com/q/1010000012304833

  ii. 遍历方法对比

  (i) 通过HashMap.entrySet()获得键值对的Set集合,如上面 i 中所述方式。

  (ii) 通过HashMap.keySet()获得键的Set集合(iterator或foreach)

        Map<String, String> map = new HashMap<String, String>();
        map.put("1", "11");
        map.put("2", "22");
        map.put("3", "33");
        // 键和值
        String key = null;
        String value = null;
        // 获取键集合的迭代器
        Iterator it = map.keySet().iterator();
        while (it.hasNext()) {
            key = (String) it.next();
            value = (String) map.get(key);
            System.out.println("key:" + key + "---" + "value:" + value);
        }
View Code

  (iii) 通过HashMap.values()得到“值”的集合

        Map<String, String> map = new HashMap<String, String>();
        map.put("1", "11");
        map.put("2", "22");
        map.put("3", "33");
        //
        String value = null;
        // 获取值集合的迭代器
        Iterator it = map.values().iterator();
        while (it.hasNext()) {
            value = (String) it.next();
            System.out.println("value:" + value);
        }
View Code

  上述(i)与(ii)的不同之处在于获取到相应集合之后,在遍历的时候时间复杂度不同:(i)在遍历时通过iterator获取下一个键值对,时间复杂度为O(1),而(ii)调用get()方法则又会进行一次遍历。因此方式(i)的性能要更优于方式(ii),尤其是在map容量比较大的时候。  

  (iiii) Lambda表达式

  (iiiii) stream API

  链接的文中对HashMap的遍历方式列举比较全面(包括Lambda表达式、stream API),可以进行学习参考:https://blog.csdn.net/sufu1065/article/details/105852634?utm_medium=distribute.pc_relevant_t0.none-task-blog-BlogCommendFromMachineLearnPai2-1.channel_param&depth_1-utm_source=distribute.pc_relevant_t0.none-task-blog-BlogCommendFromMachineLearnPai2-1.channel_param

  三、线程安全 

  1. 链表头插法、尾插法

  举个例子,现在采用多线程往一个容量大小为2的HashMap中put 值:A = 1 ,B = 2,C = 3 ,负载因子是0.75。

  正常单线程的情况下,在put第二个值的时候就会进行resize。对于多线程而言,假如各自都安全地完成了数据的插入(但还没有触发扩容),此时A->B->C,状态如下所示:

​   扩容前

​  现在进行扩容。如果使用单链表的头插法,同一index位置上的新元素总会被放到链表的头部,假如某一线程这时刚好把 B 重新hash到了A原来的位置,如图:

  

​ 由于是多线程同时操作,当所有线程都执行完毕以后,就可能会出现这样的情况:

​ 

​ 此时居然出现了环状的链表结构,如果这个时候去取值,就会出错——InfiniteLoop(死循环)。

  Java7在多线程操作HashMap时,采用了头插法,在转移过程中修改了原链表中节点的引用关系(互相颠倒),很有可能引起死循环;Java8采用尾插法,就不会引起死循环,原因是扩容前后(如果)仍然位于同一链表上的元素,他们的相对引用顺序不会颠倒。

  总之,Java7如果多个线程同时触发扩容,在移动节点时可能会导致一个链表中的2个节点相互引用,从而生成环链表。

  四、与Jdk1.7对比

  1. HashMap基本构成

  参考上面的:Node<K,V>[] table部分

  2. 数据插入链表时,JDK8以前是头插法,JDK8是尾插法

  参考三、线程安全

  3. JDK8引入了红黑树(相对平衡的二叉搜索树),提高了查询效率。

  红黑树可以参考:https://www.cnblogs.com/LiaHon/p/11203229.html

  五、小结:

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

  2. 负载因子是可以修改的,也可以大于1,但是建议不要轻易修改,除非情况非常特殊。

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

 

 
posted @ 2020-08-11 22:18  科哒科哒  阅读(460)  评论(0)    收藏  举报