HashMap源码浅析 Base 1.8

247 阅读6分钟

JDK版本1.8

数据结构
底层实现
初始容量
扩容
负载因子(默认值)
线程安全
链表插入值方法
HashMap
Node数组+链表/红黑树
16
整个map扩容,newsize = oldsize*2,size一定为2的n次幂,区分单项链表和红黑树的扩容
0.75
尾插法

HaspMap

  • 底层Node数组+链表/红黑树实现,可以存储null键和null值,线程不安全
  • 初始size为16,扩容:newsize = oldsize*2,size一定为2的n次幂,最大容量是2的30次方大小
  • 默认装载因子为0.75. 比如,刚开始的大小是16,16*0.75=12. 然后当map的大小超过12,就会进行扩容,变成32个,也就是16的两倍
  • 当链表长度达到这个TREEIFY_THRESHOLD(即8)的时候,将链表转化为红黑树
  • 当红黑树的长度小于这个UNTREEIFY_THRESHOLD(即6)时,把红黑树转换成链表
  • 红黑树的最小的容量,至少是 4 x TREEIFY_THRESHOLD = 32。然后为了避免(resizing 和 treeification thresholds) 设置成64
  • 扩容后的数组下标位置要么和原先的数组下标不变的index,要么就是index+oldTable.length
  • 每次扩容时,针对链表,遍历每个链表节点的hash值和oldCap与操作区分出高低位链表,分别拆开形成两个高低位链表再放到新的数组中
  • 每次扩容时,针对红黑树,遍历红黑树节点hash值计算该节点属于高位链表还是低位链表,然后高低位链表分别判断长度是否小于等于6,满足此条件则将此红黑树的TreeNode类型链表转换成Node类型链表

源码解析

1.初识HashMap实现

static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16

    /**
     * The maximum capacity, used if a higher value is implicitly specified
     * by either of the constructors with arguments.
     * MUST be a power of two <= 1<<30.
     */
    static final int MAXIMUM_CAPACITY = 1 << 30;

    /**
     * The load factor used when none specified in constructor.
     */
    static final float DEFAULT_LOAD_FACTOR = 0.75f;

    static final int TREEIFY_THRESHOLD = 8;

    transient Node[] table;

    /**
     * Holds cached entrySet(). Note that AbstractMap fields are used
     * for keySet() and values().
     */
    transient Set> entrySet;

    /**
     * The number of key-value mappings contained in this map.
     */
    transient int size;
和 1.7 大体上都差不多,还是有几个重要的区别:
  • TREEIFY_THRESHOLD 用于判断是否需要将链表转换为红黑树的阈值
  • HashEntry 修改为 Node
Node 的核心组成其实也是和 1.7 中的 HashEntry 一样,存放的都是 key value hashcode next 等数据。

2.put方法

2-1 put方法一览

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)   //判断当前桶是否为空,空的就需要初始化(resize 中会判断是否进行初始化)
            n = (tab = resize()).length;
        if ((p = tab[i = (n - 1) & hash]) == null)   //根据当前 key 的 hashcode 定位到具体的桶中并判断是否为空,为空表明没有 Hash 冲突就直接在当前位置创建一个新桶即可
            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))))   //如果当前桶有值( Hash 冲突),那么就要比较当前桶中的 key、key 的 hashcode 与写入的 key 是否相等,相等就赋值给 e,在第 8 步的时候会统一进行赋值及返回
                e = p;
            else if (p instanceof TreeNode)   //如果当前桶为红黑树,那就要按照红黑树的方式写入数据
                e = ((TreeNode)p).putTreeVal(this, tab, hash, key, value);
            else {
                for (int binCount = 0; ; ++binCount) {   //如果是个链表,就需要将当前的 key、value 封装成一个新节点写入到当前桶的后面(形成链表)
                    if ((e = p.next) == null) {
                        p.next = newNode(hash, key, value, null);
                        if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st   //接着判断当前链表的大小是否大于预设的阈值,大于时就要转换为红黑树
                            treeifyBin(tab, hash);
                        break;
                    }
                    if (e.hash == hash &&   //如果在遍历过程中找到 key 相同时直接退出遍历
                        ((k = e.key) == key || (key != null && key.equals(k))))
                        break;
                    p = e;
                }
            }
            if (e != null) { // existing mapping for key   //如果 e != null 就相当于存在相同的 key,那就需要将值覆盖
                V oldValue = e.value;
                if (!onlyIfAbsent || oldValue == null)
                    e.value = value;
                afterNodeAccess(e);
                return oldValue;
            }
        }
        ++modCount;
        if (++size > threshold)   //最后判断是否需要进行扩容
            resize();
        afterNodeInsertion(evict);
        return null;
    }

put方法大致过程如下:

  • 判断当前桶是否为空,空的就需要初始化(resize 中会判断是否进行初始化)
  • 根据当前 key 的 hashcode 定位到具体的桶中并判断是否为空,为空表明没有 Hash 冲突就直接在当前位置创建一个新桶即可
  • 如果当前桶有值( Hash 冲突),那么就要比较当前桶中的 key、key 的 hashcode 与写入的 key 是否相等,相等就赋值给 e,在第 8 步的时候会统一进行赋值及返回
  • 如果当前桶为红黑树,那就要按照红黑树的方式写入数据
  • 如果是个链表,就需要将当前的 key、value 封装成一个新节点写入到当前桶的后面(形成链表)
  • 接着判断当前链表的大小是否大于预设的阈值,大于时就要转换为红黑树
  • 如果在遍历过程中找到 key 相同时直接退出遍历
  • 如果 e != null 就相当于存在相同的 key,那就需要将值覆盖
  • 最后判断是否需要进行扩容

3.get方法

3-1 get方法一览

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 &&
            (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) {
                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;
    }

get方法大致过程如下:

  • 首先将 key hash 之后取得所定位的桶
  • 如果桶为空则直接返回 null
  • 否则判断桶的第一个位置(有可能是链表、红黑树)的 key 是否为查询的 key,是就直接返回 value
  • 如果第一个不匹配,则判断它的下一个是红黑树还是链表
  • 红黑树就按照树的查找方式返回值
  • 不然就按照链表的方式遍历匹配返回值
从这两个核心方法(get/put)可以看出 1.8 中对大链表做了优化,修改为红黑树之后查询效率直接提高到了 O(logn)。
但是 HashMap 原有的问题也都存在,比如在并发场景下使用时容易出现死循环。
final HashMap map = new HashMap();
for (int i = 0; i < 1000; i++) {
    new Thread(new Runnable() {
        @Override
        public void run() {
            map.put(UUID.randomUUID().toString(), "");
        }
    }).start();
}
但是为什么呢?简单分析下
看过上文的还记得在 HashMap 扩容的时候会调用 resize() 方法,就是这里的并发操作容易在一个桶上形成环形链表;这样当获取一个不存在的 key 时,计算出的 index 正好是环形链表的下标就会出现死循环。

4.遍历方式

还有一个值得注意的是 HashMap 的遍历方式,通常有以下几种:
Iterator> entryIterator = map.entrySet().iterator();
        while (entryIterator.hasNext()) {
            Map.Entry next = entryIterator.next();
            System.out.println("key=" + next.getKey() + " value=" + next.getValue());
        }

Iterator iterator = map.keySet().iterator();
        while (iterator.hasNext()){
            String key = iterator.next();
            System.out.println("key=" + key + " value=" + map.get(key));

        }
强烈建议使用第一种 EntrySet 进行遍历。
第一种可以把 key value 同时取出,第二种还得需要通过 key 取一次 value,效率较低。
简单总结下 HashMap:无论是 1.7 还是 1.8 其实都能看出 JDK 没有对它做任何的同步操作,所以并发会出问题,甚至出现死循环导致系统不可用。