HashMap源码学习

1、HashMap继承结构

HashMap的UML图如下所示:

image

以 Map 键——值映射为基础,java.util 提供了 HashMap(最常用)、 TreeMap、Hashtble、LinkedHashMap 等数据结构。衍生的几种 Map 的主要特点:

  • HashMap它根据键的hashCode值存储数据,大多数情况下可以直接定位到它的值,因而具有很快的访问速度,但遍历顺序却是不确定的。HashMap 最多允许一条记录的键为null,允许多条记录的值为null。HashMap 是非线程安全的,即任一时刻有多个线程同时写 HashMap ,可能会导致数据不一致。如果要满足线程安全,可以使用 Collections 的 SynchronizedMap 方法 或者使用 ConcurrentHashMap。
  • HashTable:HashTable 是遗留类,很多常用的功能与 HashMap 类似,不同的是它继承 Dictionary,并且是线程安全的,任一时刻只能有一个线程写 HashTable,并发性不如 ConcurrentHashMap,因为 ConcurrentHashMap 引入了分段锁。HashTable 不建议在新代码中使用,不需要线程安全的场合使用 HashMap,需要线程安全的场合使用 ConcurrentHashMap。
  • LinkedHashMapLinkedHashMap 是 HashMap 的一个子类,保存了记录的插入顺序,在用 iterator 遍历 LinkedHashMap 时,先得到的记录肯定是先插入的,也可以在构造时带参数,按照访问次序排序。
  • TreeMap:它实现了 SortedMap 接口,能够把保存的记录根据键(key)排序,默认是按照键值的升序排序,也可以指定排序的比较器,当用 iterator 遍历时得到的记录是排过序的。如果使用排序的映射,建议使用 TreeMap,在使用 TreeMap 时,key 必须实现 Comparable 接口或者在构造 TreeMap 传入自定义的 Comparator,否则会在运行时抛出 java.lang.ClassCastException 类型的异常。

2、HashMap底层存储结构

在进行源码解析之前,先从总体上对HashMap的数据存储结构进行一个大体上的说明。存储结构如下图所示。

image

HashMap采用Entry数组来存储key-value对,每一个键值对组成了一个Entry实体,Entry类实际上是一个单向的链表结构,它具有Next指针,可以连接下一个Entry实体,依次来解决Hash冲突的问题。

​ 从上图我们可以发现数据结构由数组+链表组成,每个元素存储的是一个链表的头结点。那么这些元素是按照什么样的规则存储到数组中呢。一般情况是通过hash(key.hashCode())%len获得,也就是元素的key的哈希值对数组长度取模得到。比如上述哈希表中,12%16=12,28%16=12,108%16=12,140%16=12。所以12、28、108以及140都存储在数组下标为12的位置。

HashMap里面用到链式数据结构的一个概念。上面我们提到过Entry类里面有一个next属性,作用是指向下一个Entry。打个比方, 第一个键值对A进来,通过计算其key的hash得到的index=0,记做:Entry[0] = A。一会后又进来一个键值对B,通过计算其index也等于0,现在怎么办?HashMap会这样做:B.next = A,Entry[0] = B,如果又进来C,index也等于0,那么C.next = B,Entry[0] = C;这样我们发现index=0的地方其实存取了A,B,C三个键值对,他们通过next这个属性链接在一起。所以疑问不用担心。也就是说数组中存储的是最后插入的元素(jdk1.7是头插法,1.8是尾插法)。到这里为止,

3、HashMap源码分析

3.1、HashMap基本属性

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

//最大的容量,容量的值必须是2的幂并且小于最大的容量,最大值为2的30次方
static final int MAXIMUM_CAPACITY = 1 << 30;

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

//计数阈值,超过这个值将会使用树形结构替代链表结构
static final int TREEIFY_THRESHOLD = 8;

//由树形结构转换成链表的阈值
static final int UNTREEIFY_THRESHOLD = 6;

//树形结构最小的容量为64
static final int MIN_TREEIFY_CAPACITY = 64;

//链表数组
transient Node[] table;

依次解释以上常量:

DEFAULT_INITIAL_CAPACITY: 初始容量,也就是默认会创建 16 个箱子,箱子的个数不能太多或太少。如果太少,很容易触发扩容,如果太多,遍历哈希表会比较慢。
MAXIMUM_CAPACITY: 哈希表最大容量,一般情况下只要内存够用,哈希表不会出现问题。
DEFAULT_LOAD_FACTOR: 默认的负载因子。因此初始情况下,当键值对的数量大于 16 * 0.75 = 12 时,就会触发扩容。
TREEIFY_THRESHOLD: 上文说过,如果哈希函数不合理,即使扩容也无法减少箱子中链表的长度,因此 Java 的处理方案是当链表太长时,转换成红黑树。这个值表示当某个箱子中,链表长度大于 8 时,有可能会转化成树。
UNTREEIFY_THRESHOLD: 在哈希表扩容时,如果发现链表长度小于 6,则会由树重新退化为链表。
MIN_TREEIFY_CAPACITY: 在转变成树之前,还会有一次判断,只有键值对数量大于 64 才会发生转换。这是为了避免在哈希表建立初期,多个键值对恰好被放入了同一个链表中而导致不必要的转化。

3.2、构造方法

HashMap提供了4个构造方法,如下所示

//需要传入自定义的initialCapacity(初始化容量),loadFactor(加载因子)
public HashMap(int initialCapacity, float loadFactor)
//需要传入自定义的initialCapacity(初始化容量),实际在平时的使用过程中如果可以大概知道数据量,建议使用这种构造方法,原因是指定了HashMap的容量之后,可以避免没必要的扩容操作,从而减少了浪费。
public HashMap(int initialCapacity)
//默认的构造方法,按照初始值创建HashMap
public HashMap()
//需要传入一个Map集合
public HashMap(Map m)

3.3、插入操作

 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) {   //hash根据key算出来的hash值  onlyIfAbsent  如果当前位置已经有一个值 是否替换  false是替换 true是不替换
        Node[] tab; Node p; int n, i;  //evict 表示是否在进行表的模式的创建 false则表是在创建模式
        //如果主干上的table为空或者 长度为0 则进行resize(),来调整table的长度
        if ((tab = table) == null || (n = tab.length) == 0)
            n = (tab = resize()).length;   //resize()既对数组进行了初始化,又可以进行扩容   这里是第一次put时,对数组进行初始化
        if ((p = tab[i = (n - 1) & hash]) == null)         //将计算得到的hash值与数组长度进行比较 (n - 1) & hash操作等价于取模运算 不过前者效率更高
            tab[i] = newNode(hash, key, value, null); //位置为空时      将i位置上赋值一个Node对象
        else { //位置不为空时
            Node e; K k;
            if (p.hash == hash &&      //如果这个位置的old节点与new节点的key完全相同  old节点p = tab[i = (n - 1) & hash]
                ((k = p.key) == key || (key != null && key.equals(k))))
                e = p;                               //则e=p
            else if (p instanceof TreeNode)   //判断是p是不是一个红黑树节点 ,是的话就走红黑树的逻辑
                e = ((TreeNode)p).putTreeVal(this, tab, hash, key, value);
            else {  //p与新节点既不完全相同,p也不是treeNode的实例对象
                for (int binCount = 0; ; ++binCount) { //遍历链表
                    if ((e = p.next) == null) {
                        p.next = newNode(hash, key, value, null);  //树化转换的此时虽然binCount==7了 但新节点仍然插入了链表 该时刻有了第九个元素     JDK1.8使用的是尾插法
                        if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st  链表长度>=8 进行树化
                            treeifyBin(tab, hash);  //binCount==7 就会进行树化  进行时树化的时刻已经有了9个节点
                        break;
                    }
                    if (e.hash == hash &&  //如果遍历过程中链表中的元素与新添加的元素完全相同,则跳出循环
                        ((k = e.key) == key || (key != null && key.equals(k))))
                        break;
                    p = e;
                }
            }
            if (e != null) { // existing mapping for key    当前的key已经存在了,发生了hash冲突,那么进行put方法时,会将他在链表中的他的上一个元素的值返回
                V oldValue = e.value;
                if (!onlyIfAbsent || oldValue == null)   //条件成立则,将oldvalue的值替换成newvalue,返回oldvalue;否则不替换,然后返回oldvalue
                    e.value = value;
                afterNodeAccess(e);
                return oldValue;
            }
        }
        ++modCount;   //记录修改次数  用来做快速失败
        if (++size > threshold)  //如果元素数量大于阈值 则进行扩容
            resize();
        afterNodeInsertion(evict);
        return null;
    }

putVal方法执行过程可以通过下图来理解:

image

①.判断键值对数组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,如果超过,进行扩容。

3.4、查找操作

public V get(Object key) {
    Node e;
    // 同样需要经过扰动函数计算哈希值
    return (e = getNode(hash(key), key)) == null ? null : e.value;
}

final Node getNode(int hash, Object key) {
    Node[] tab; Node first, e; int n; K k;
    // 判断桶数组的是否为空和长度值
    if ((tab = table) != null && (n = tab.length) > 0 &&
        // 计算下标,哈希值与数组长度-1
        (first = tab[(n - 1) & hash]) != null) {
        if (first.hash == hash && // always check first node
            ((k = first.key) == key || (key != null && key.equals(k))))
            return first;
        if ((e = first.next) != null) {
            // TreeNode 节点直接调用红黑树的查找方法,时间复杂度O(logn)
            if (first instanceof TreeNode)
                return ((TreeNode)first).getTreeNode(hash, key);
            // 如果是链表就依次遍历查找
            do {
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    return e;
            } while ((e = e.next) != null);
        }
    }
    return null;
}

3.5、删除操作

public V remove(Object key) {
     Node e;
     return (e = removeNode(hash(key), key, null, false, true)) == null ?
         null : e.value;
 }
 
final Node removeNode(int hash, Object key, Object value,
                           boolean matchValue, boolean movable) {
    Node[] tab; Node p; int n, index;
    // 定位桶数组中的下标位置,index = (n - 1) & hash
    if ((tab = table) != null && (n = tab.length) > 0 &&
        (p = tab[index = (n - 1) & hash]) != null) {
        Node node = null, e; K k; V v;
        // 如果键的值与链表第一个节点相等,则将 node 指向该节点
        if (p.hash == hash &&
            ((k = p.key) == key || (key != null && key.equals(k))))
            node = p;
        else if ((e = p.next) != null) {
            // 树节点,调用红黑树的查找方法,定位节点。
            if (p instanceof TreeNode)
                node = ((TreeNode)p).getTreeNode(hash, key);
            else {
                // 遍历链表,找到待删除节点
                do {
                    if (e.hash == hash &&
                        ((k = e.key) == key ||
                         (key != null && key.equals(k)))) {
                        node = e;
                        break;
                    }
                    p = e;
                } while ((e = e.next) != null);
            }
        }
        
        // 删除节点,以及红黑树需要修复,因为删除后会破坏平衡性。链表的删除更加简单。
        if (node != null && (!matchValue || (v = node.value) == value ||
                             (value != null && value.equals(v)))) {
            if (node instanceof TreeNode)
                ((TreeNode)node).removeTreeNode(this, tab, movable);
            else if (node == p)
                tab[index] = node.next;
            else
                p.next = node.next;
            ++modCount;
            --size;
            afterNodeRemoval(node);
            return node;
        }
    }
    return null;
} 

3.6、扩容操作

resize用于以下两种情况之一

  • 初始化table
  • 当键值对的数量大于 table.size * 加载因子 时,就会触发扩容。
    final Node[] resize() {
        Node[] oldTab = table;//首次初始化后table为Null
        int oldCap = (oldTab == null) ? 0 : oldTab.length;
        int oldThr = threshold;//默认构造器的情况下为0
        int newCap, newThr = 0;
        if (oldCap > 0) {//table扩容过
             //当前table容量大于最大值得时候返回当前table
             if (oldCap >= MAXIMUM_CAPACITY) {
                threshold = Integer.MAX_VALUE;
                return oldTab;
            }
            else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                     oldCap >= DEFAULT_INITIAL_CAPACITY)
            //table的容量乘以2,threshold的值也乘以2           
            newThr = oldThr << 1; // double threshold
        }
        else if (oldThr > 0) // initial capacity was placed in threshold
        //使用带有初始容量的构造器时,table容量为初始化得到的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];
        table = newTab;
        if (oldTab != null) {
            for (int j = 0; j < oldCap; ++j) {
                HashMap.Node e;
                if ((e = oldTab[j]) != null) {
                    // help gc
                    oldTab[j] = null;
                    if (e.next == null)
                        // 当前index没有发生hash冲突,直接对2取模,即移位运算hash &(2^n -1)
                        // 扩容都是按照2的幂次方扩容,因此newCap = 2^n
                        newTab[e.hash & (newCap - 1)] = e;
                    else if (e instanceof HashMap.TreeNode)
                        // 当前index对应的节点为红黑树,这里篇幅比较长且需要了解其数据结构跟算法,因此不进行详解,当树的个数小于等于UNTREEIFY_THRESHOLD则转成链表
                        ((HashMap.TreeNode)e).split(this, newTab, j, oldCap);
                    else { // preserve order
                        // 把当前index对应的链表分成两个链表,减少扩容的迁移量
                        HashMap.Node loHead = null, loTail = null;
                        HashMap.Node hiHead = null, hiTail = null;
                        HashMap.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) {
                            // help gc
                            loTail.next = null;
                            newTab[j] = loHead;
                        }
                        if (hiTail != null) {
                            // help gc
                            hiTail.next = null;
                            // 扩容长度为当前index位置+旧的容量
                            newTab[j + oldCap] = hiHead;
                        }
                    }
                }
            }
        }
        return newTab;
    }

综上我们知道, 这段代码的意义就是将原来的链表拆分成两个链表, 并将这两个链表分别放到新的table的 j 位置和 j+oldCap 上, j位置就是原链表在原table中的位置, 拆分的标准就是:

(e.hash & oldCap) == 0

为了帮助大家理解,我画了个示意图:


image

关于 (e.hash & oldCap) == 0 j 以及 j+oldCap
上面我们已经弄懂了链表拆分的代码, 但是这个拆分条件看上去很奇怪, 这里我们来稍微解释一下:

首先我们要明确三点:

  • oldCap一定是2的整数次幂, 这里假设是2^m
  • newCap是oldCap的两倍, 则会是2^(m+1)
  • hash对数组大小取模(n - 1) & hash 其实就是取hash的低m位

例如:
我们假设 oldCap = 16, 即 2^4,
16 - 1 = 15, 二进制表示为 0000 0000 0000 0000 0000 0000 0000 1111
可见除了低4位, 其他位置都是0(简洁起见,高位的0后面就不写了), 则 (16-1) & hash 自然就是取hash值的低4位,我们假设它为 abcd.

以此类推, 当我们将oldCap扩大两倍后, 新的index的位置就变成了 (32-1) & hash, 其实就是取 hash值的低5位. 那么对于同一个Node, 低5位的值无外乎下面两种情况:

0abcd
1abcd

其中, 0abcd与原来的index值一致, 而1abcd = 0abcd + 10000 = 0abcd + oldCap

故虽然数组大小扩大了一倍,但是同一个key在新旧table中对应的index却存在一定联系: 要么一致,要么相差一个 oldCap

而新旧index是否一致就体现在hash值的第4位(我们把最低为称作第0位), 怎么拿到这一位的值呢, 只要:

hash & 0000 0000 0000 0000 0000 0000 0001 0000

上式就等效于

hash & oldCap

故得出结论:

  1. 如果 (e.hash & oldCap) == 0 则该节点在新表的下标位置与旧表一致都为 j
  2. 如果 (e.hash & oldCap) == 1 则该节点在新表的下标位置 j + oldCap

3.7、链表树化

HashMap这种散列表的数据结构,最大的性能在于可以O(1)时间复杂度定位到元素,但因为哈希碰撞不得已在一个下标里存放多组数据,那么jdk1.8之前的设计只是采用链表的方式进行存放,如果需要从链表中定位到数据时间复杂度就是O(n),链表越长性能越差。因为在jdk1.8中把过长的链表也就是8个,优化为自平衡的红黑树结构,以此让定位元素的时间复杂度优化近似于O(logn),这样来提升元素查找的效率。但也不是完全抛弃链表,因为在元素相对不多的情况下,链表的插入速度更快,所以综合考虑下设定阈值为8才进行红黑树转换操作。

链表转红黑树,如下图:

[图片上传失败...(image-1abca2-1610979495851)]以上就是一组链表转换为红黑树的情况,接下来阅读下对应的源码。

final void treeifyBin(Node[] tab, int hash) {
    int n, index; Node e;
    // 这块就是我们上面提到的,不一定树化还可能只是扩容。主要桶数组容量是否小于64 MIN_TREEIFY_CAPACITY 
    if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
        resize();
    else if ((e = tab[index = (n - 1) & hash]) != null) {
        // 又是单词缩写;hd = head (头部),tl = tile (结尾)
        TreeNode hd = null, tl = null;
        do {
            // 将普通节点转换为树节点,但此时还不是红黑树,也就是说还不一定平衡
            TreeNode 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);
    }
}

以上源码主要包括的知识点如下:

  1. 链表树化的条件有两点;链表长度大于等于8、桶容量大于64,否则只是扩容,不会树化。
  2. 链表树化的过程中是先由链表转换为树节点,此时的树可能不是一颗平衡树。同时在树转换过程中会记录链表的顺序,tl.next = p,这主要方便后续树转链表和拆分更方便。
  3. 链表转换成树完成后,在进行红黑树的转换。先简单介绍下,红黑树的转换需要染色和旋转,以及比对大小。在比较元素的大小中,有一个比较有意思的方法,tieBreakOrder加时赛,这主要是因为HashMap没有像TreeMap那样本身就有Comparator的实现。

3.8、红黑树转链

在链表转红黑树中我们重点介绍了一句,在转换树的过程中,记录了原有链表的顺序。

那么,这就简单了,红黑树转链表时候,直接把TreeNode转换为Node即可,源码如下:

final Node untreeify(HashMap map) {
    Node hd = null, tl = null;
    // 遍历TreeNode
    for (Node q = this; q != null; q = q.next) {
        // TreeNode替换Node
        Node p = map.replacementNode(q, null);
        if (tl == null)
            hd = p;
        else
            tl.next = p;
        tl = p;
    }
    return hd;
}

// 替换方法
Node replacementNode(Node p, Node next) {
    return new Node<>(p.hash, p.key, p.value, next);
}

因为记录了链表关系,所以替换过程很容易。所以好的数据结构可以让操作变得更加容易。

你可能感兴趣的:(HashMap源码学习)