来,进来的小伙伴们,我们认识一下。

我是俗世游子,在外流浪多年的Java程序猿

前言

前面两篇我们聊了聊关于集合容器中的List集合,其中包含两个子类:

  • ArrayList
  • LinkedList

如果还没有看过的小伙伴,可以先返回去看看

我在LinkedList中留下了几个思考问题,不知道有没有想到的,我们在评论区里探讨探讨啊

这篇我们聊一聊Map,我们再回顾一下,前面介绍的时候,我们说过,MapList很大的一个区别在于:

  • List存储数据是单一元素,而Map存储数据是K-V形式存储的

为什么要有K,V形式来存储数据?

在传统的系统中,我们的数据大概10W,百W的存储就足够了,但是在一些特殊的应用或者大数据平台中,涉及到千万甚至更多的数据,

本人在上家公司开发的广告投放系统中,每天至少会产生2000W的数据

这时如果我们想从其中查找到某一条数据就非常麻烦,涉及到性能等的问题,而通过K,V形式存储,我们就相当于对某一个值添加了索引,通过这个索引我们就能很快定位到数据,提高系统的性能。

关于K,V形式的存储,我们在工作中还会用到如:

  • Session
  • Redis
  • HBase
  • ...

好,了解到这一点之后,我们继续往后看。

前面我们讲到,集合中所有的父类是Collection,但是Map是单独的一套接口,这里不能混在一起,下面我们来看看Map的实现子类:

  • HashMap
  • LinkedHashMap
  • TreeMap

了解ArrayList中我也给出了一张思维导图

我们就一个一个来看

最重要的一点:面试出场率贼高了【9月份面试一个月,80%的公司都有问到(当时不懂啊-_-||)】

这里,在聊今天的主角:HashMap之前,我们先来简单的认识一下什么是哈希表

HashMap很长,文采略烂,大家要有耐心哦

哈希表

什么是哈希表?

也叫散列表,是根据关键码值(Key value)而直接进行访问的一种数据结构。也就是说,它通过把关键码值映射到表中一个位置来访问记录,以加快查找的速度

来源:百度百科

PS:概念都不是人话,不用记他,直接看结构

哈希表分为多种类型,下面我们来看到的是在HashMap底层实现的结构:

HashMap中哈希表结构

上面是一个数组,在内存中是一块连续的内存空间,Key值取到hashCode码然后再对数组长度做取模操作,得到对应的下标位置,然后将Value值放到对应下标位置的地方;如果在对应下标位置的地方存在元素,那么就已链表的形式追加

这种方式在散列函数中称为:除法散列法

除法散列法

取值也是一样:通过关键key的哈希取到对应下标之后,如果对应位置只有一个数据,那么就直接取出,否则就在链表中进行比对然后再取出对应的数据

简单对哈希表认识一下,我们继续聊HashMap

HashMap

背景说明:JDK1.8

老规矩,我们来先来看一看HashMap的一个类分布图

HashMap

操作方法

属性值介绍

我们都是这样来构造HashMap的:

Map<String, String> hashMap = new HashMap<>();
Map<String, String> hashMap = new HashMap<>(20);

这样一个无参的构造方式,我们来看看具体做了什么?

/**MUST be a power of two.*/
// 默认初始化长度
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;
// 负载因子,负责判定数据存储到什么地步的时候进行扩容
static final float DEFAULT_LOAD_FACTOR = 0.75f;

public HashMap() {
    this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
}

按照我们之前的经验,存储数据需要在内存中开辟空间,但是在HashMap的构造方法中并没有这么做,包括其他有参的构造方法:

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

// 除外,传递参数不同,
public HashMap(Map<? extends K, ? extends V> m) {
    this.loadFactor = DEFAULT_LOAD_FACTOR;
    putMapEntries(m, false);
}

这里值得我们借鉴:如果直接在定义构造方法的时候就在内存中开辟空间的话,如果不存储数据的话,那么这块内存不就被浪费了么

前面我们都知道,在定义数组的时候需要指定数组的长度,而哈希表中有采用数组的结构,那么如果不定义指定长度的话,默认的一个长度就是属性值:DEFAULT_INITIAL_CAPACITY,等于 16

我们重点还要关注它的注释:MUST be a power of two WHY?

Hashtable中,是按照除法散列法中的规范来做的:也就是上面说的不太接近2的整数幂的素数,但是为什么在HashMap中就没有采用这种规范,而是要采用2的N次幂呢?我们后面再具体说

同样,我们还要在关注一个点:

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

通过计算,得到离传递参数最近的2的N次幂的数,所以说,哪怕传递的参数不满足规定,在代码中也会帮我们进行调整

同样在HashMap中还存在一个属性loadFactor:表示负载因子,简单来说就是该属性决定了HashMap容器空间什么时候该扩容,默认是0.75

比如:初始长度为16,负载因子是0.75,那么当容器中存储了> (16 * 0.75 = 12)的时候,就会进行扩容操作

下面我们再来看两个属性

static final int TREEIFY_THRESHOLD = 8;
static final int UNTREEIFY_THRESHOLD = 6;

这两个属性值是在JDK1.8之后加进来的,简单来说就是:当链表长度>8的时候,链表会转成红黑树的结构存储数据,当红黑树的节点<6个的时候,会转成链表的形式

  • 也就是说,在JDK1.8中,HashMap的底层结构采用的是哈希表+红黑树的形式

也就是下面的结构:

HashMap底层结构

关于为什么是8转红黑树?

在属性值DEFAULT_INITIAL_CAPACITY上面有一段注释,给出了分析:

/*
     * the frequency of
     * nodes in bins follows a Poisson distribution
     * (http://en.wikipedia.org/wiki/Poisson_distribution) with a
     * parameter of about 0.5 on average for the default resizing
     * 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
     *
     */

也就是说,通过计算,在k=8的时候,接近于0,所以定义为8,提升检索的效率,这里涉及到一个叫做《泊松分布》:这是一种统计与概率学里常见到的离散概率分布

介绍的话推荐大家看这一篇:如何通俗理解泊松分布

方法说明

了解完基本的属性值之后,我们来看具体的操作方法,还是一样的,在此之前我们来看两个类:

  • Node
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;
    }
    // ...
}

这个类大家肯定不陌生,前面在聊LinkedList的时候就已经见过了,不过这个是单向链表的方式

  • TreeNode
static final class TreeNode<K,V> extends LinkedHashMap.Entry<K,V> {
    TreeNode<K,V> parent;  // red-black tree links
    TreeNode<K,V> left;
    TreeNode<K,V> right;
    TreeNode<K,V> prev;    // needed to unlink next upon deletion
    boolean red;
    TreeNode(int hash, K key, V val, Node<K,V> next) {
        super(hash, key, val, next);
    }
    //...
}

这是关于红黑树的具体类

下面我们继续,

put

我们是这样调用的:

hashMap.put("key1", "value1");

public V put(K key, V value) {
    return putVal(hash(key), key, value, false, true);
}

前面也说到,确定关键值Key在数组中的位置,那么我们先来看看是如何进行hash运算的:

static final int hash(Object key) {
    int h;
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);   
}

为了能够让计算出来索引位置更分散,所以先(h &gt;&gt;&gt; 16),同样,再通过 ^运算让哈希值的高低位都能参与运算,从而减少哈希碰撞的几率

扰动函数

下面我们看具体的实现:

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;                            // 注释1
    if ((p = tab[i = (n - 1) & hash]) == null)
        tab[i] = newNode(hash, key, value, null);               // 注释2
    else {
        Node<K,V> e; K k;
        if (p.hash == hash &&
            ((k = p.key) == key || (key != null && key.equals(k))))     // 注释4
            e = p;
        else if (p instanceof TreeNode)
            e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);// 注释5.2
        else {
            for (int binCount = 0; ; ++binCount) {              // 链表的插入过程
                if ((e = p.next) == null) {
                    p.next = newNode(hash, key, value, null);
                    if (binCount >= TREEIFY_THRESHOLD - 1)              // 注释5
                        treeifyBin(tab, hash);
                    break;
                }
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    break;
                p = e;
            }
        }
        if (e != null) {                                    // 注释4.2
            V oldValue = e.value;
            if (!onlyIfAbsent || oldValue == null)
                e.value = value;
            afterNodeAccess(e);
            return oldValue;
        }
    }
    ++modCount;
    if (++size > threshold)                                 // 注释3
        resize();   
    afterNodeInsertion(evict);
    return null;
}
开辟空间和扩容

前面说到,在构造方法中什么都没做,只有在实际添加元素的时候才会开辟空间。在上面注释1的地方就是开辟空间的过程,同时在注释3的地方,该方法也是我们扩容的过程,调用的都是同一个方法resize()

一步步来,先剖析putVal()方法,然后我们在来看resize()

在第一次put(),只是创建了一个Node数组,没有其他操作, 也就是在内存中开辟空间,然后我们继续往下看

计算索引下标

第一次put()元素,那么肯定会进入到注释2的位置,通过 & 来计算当前元素所处的下标位置并赋值

同时,如果之前有看过JDK1.7的源码的话,会发现在1.7中有这样一个方式:

static int indexFor(int h, int length) {
    return h & (length - 1);
}

JDK1.8中:

i = (n - 1) & hash

举个例子:

(n-1)之后的二进制: 01111

hash = 18,转成二进制: 10010

&运算之后: 00010

通过计算,i=2

在设置成2的N次幂之后,在计算下标位置的时候可以保证(n-1)的后几位一定是1,方便进行 & 运算,而&的效率要高于%运算。

多用用位运算符,那不是摆设。O(∩_∩)O

继续调用put()添加元素,注释4注释4.2是相辅相成的,走到这里会判断key是否存在,如果存在key,那么e还是会得到从HashMap中数组索引位置上得到的key,在注释4.2的地方将value进行赋值操作,也就是覆盖原先的值并返回旧值。

也就是说:在HashMap,不存在重复元素,如果在同一个key上存储了多个元素,那么只会存储最新的元素

hashMap.put("key1", "value1");
hashMap.put("key1", "value2");
System.out.println(hashMap);

// {key1=value2}
转红黑树存储数据

前面我们也说过,HashMap在JDK1.8的版本中:当链表长度>8的时候会将存储结构转成红黑树来存储,那么在注释5的地方我们就得到了验证,同样,我们来看一下转换过程:

final void treeifyBin(Node<K,V>[] tab, int hash) {
    int n, index; Node<K,V> e;
    if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
        resize();
    else if ((e = tab[index = (n - 1) & hash]) != null) {
        TreeNode<K,V> hd = null, tl = null;
        do {
            TreeNode<K,V> p = replacementTreeNode(e, null);
            if (tl == null)
                hd = p;
            else {
                p.prev = tl;
                tl.next = p;
            }
            tl = p;
        } while ((e = e.next) != null);
        if ((tab[index] = hd) != null)
            hd.treeify(tab);
    }
}

前面我们已经看过TreeNode这个类,这样也就对应到了我们上面的底层结构图

同时我们可以看到注释5.2的地方,如果存储已经转换成红黑树的形式,那么就对红黑树进行插入操作

红黑树后面聊

resize()

接下来我们看resize(),上面知道初始化的时候是创建数组的过程,那么我们看有值的时候做了什么事情

final Node<K,V>[] resize() {
    Node<K,V>[] oldTab = table;                                 
    int oldCap = (oldTab == null) ? 0 : oldTab.length;              // oldCap = 16
    int oldThr = threshold;                                         // oldThr = 12
    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 = 32
                 oldCap >= DEFAULT_INITIAL_CAPACITY)
            newThr = oldThr << 1; // double threshold               // newThr = 24
    }
    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;
}

后面注释举了个小栗子。我们也可以看到

  • 容器长度的扩容是成倍扩容的
  • 判断容器扩容的依据,也就是说在初始通过(容器长度 * 负载因子)得到的扩容依据的数也会成倍增长的,所以这里我们要注意一下
数据迁移

JDK1.8中数据迁移是判断:当前hash & 旧容器长度的结果:

  • 如果结果是0,在新数组中还是在老位置
  • 如果结果不是0,那么在新数组中的位置为:原数组中的位置 + 原数组的容器长度

具体逻辑在这里:

// e.hash = 65 通过计算为0,
if ((e.hash & oldCap) == 0) {
    if (loTail == null)
        loHead = e;
    else
        loTail.next = e;
    loTail = e;
}
// e.hash = 6366 通过计算不为0,走else
else {
    if (hiTail == null)
        hiHead = e;
    else
        hiTail.next = e;
    hiTail = e;
}

// 原数组中的位置和新数组中位置相同
if (loTail != null) {
    loTail.next = null;
    newTab[j] = loHead;
}
// 新数组中的位置改变
if (hiTail != null) {
    hiTail.next = null;
    newTab[j + oldCap] = hiHead;
}

HashMap关键点也就聊完了,下面我们来总结一下

总结

  • HashMap底层采用哈希表+红黑树的结构来存储,无序存储。当哈希表链表长度>8的时候会将链表转成红黑树,判断基准是通过采用泊松分布经过验证之后得出的结论;然后在当红黑树的节点<6的时候将红黑树的结构转回到链表(官方没有明确表示为什么是6)
  • HashMap在默认情况下容器长度为:16,且如果需要扩容的话,那么会扩容当前容器长度的2倍。且达到扩容的条件时当前容器长度*负载因子,在后续扩容过程中,扩容条件为成倍扩容
  • 这里我们在构造的时候也可以传入固定参数,但是这里需要注意:如果需要自定义容器长度的话,最好定义的长度是2的N次幂:
    • 因为在通过key的哈希值查找数组索引的时候会采用&计算,性能对比%更高
  • 同时在数据迁移的过程中,判断key的哈希值与原数组长度通过运算之后是否为0,等于0的话就是在新数组中的下标位置不变,否则的话就等于将当前下标位置 + 原数组的长度即在新数组中的索引位置
  • HashMap是线程不安全的类,如果想要保证线程安全的话:
    • 自己加锁
    • Collections.synchronizedMap()
    • 采用ConcurrentHashMap

文档

更多关于HashMap使用方法推荐查看其文档:

HashMapAPI文档