JavaSE - 集合【1】详解HashMap

HashMap作为Java中最常用的集合类之一,也是各种面试必问的考点,因此很有必要深入了解HashMap源码,剖析它的实现原理和具体的实现细节。

常量

首先看一些HashMap类的源码中定义的常量及其基本含义。

//默认初始化容量:该容量指`table`数组的默认大小,默认值为16。
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; 

//最大容量:`table`数组的最大长度为 2^30=1073741824
static final int MAXIMUM_CAPACITY = 1 << 30;


//默认负载因子:负载因子用于控制Entry数组的扩容时机,默认当数组实际长度大于容量的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;

内部类

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;
        }
    //... 
    //hashCode方法被重写,key与value唯一确定
    public final int hashCode() {
        return Objects.hashCode(key) ^ Objects.hashCode(value);
    }
}

该类是构成HashMap的基本类型,当链表没有升级成红黑树前,HashMap中的每个元素都以Node对象的形式存在。

TreeNode

该类用与HashMap中作为红黑树的具体实现类,它是LinkedHashMap.Entry类的子类,也是Node类的子类。

类内部定义的许方法用于红黑树的插入、删除、平衡、左右旋等操作,比较复杂,本文就不一一进行讲解了,后续有时间深入研究再进行补充。

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

HashIterator

HashMap中的迭代器类,是 KeyIterator、ValueIterator、EntryIterator 类的父类。

从源码中可以看出,类中定义了了一个 expectedModCount 变量,并初始化为 HashMap 中的 modCount。

在迭代过程中,如果 modCount 发生过改变,即 modCount != expectedModCount ,会抛出异常并终止迭代过程。

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

HashMapSpliterator

该类 HashIterator 的增强,让迭代器能被多个线程调用从而提高迭代的效率。

实例域

实例域是每个HashMap对象独有的属性,对应了每个具体的HashMap对象相对应。

//第一次被使用时(lazyload)将被初始化,初始化长度只能为2的幂次
    transient Node<K,V>[] table;

//用于存放键值对
    transient Set<Map.Entry<K,V>> entrySet;

//用于记录HashMap对象中实际存放的键值对数量
    transient int size;

//记录HashMap结构被修改的次数,用于Iterator遍历
    transient int modCount;

//存放table数组被初始化时的长度,用tableSizeFor方法得到
    int threshold;

//实际负载因子
    final float loadFactor;

静态方法

HashMap中的几个静态方法属于类中比较重要的辅助方法,对于它们的理解有助于我们了解HashMap的实现原理。

hash()

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

HashMap对我们输入的key进行hash运算,得到一个hashcode,但它没有直接使用hashcode,而是将其高16位和低16位进行一次异或运算。目的是降低hash冲突的几率,这样即使两个key的低16位hashcode相等,仍然可以通过该运算一定程度地避免哈希碰撞。

更多关于hash方法的细节和jdk1.7以及jdk1.8中该方法的区别可以参考该文

comparableClassFor()

static Class<?> comparableClassFor(Object x) {
    if (x instanceof Comparable) {
        Class<?> c; Type[] ts, as; ParameterizedType p;
        if ((c = x.getClass()) == String.class) // bypass checks
            return c;
        if ((ts = c.getGenericInterfaces()) != null) {
            for (Type t : ts) {
                if ((t instanceof ParameterizedType) &&
                    ((p = (ParameterizedType) t).getRawType() ==
                     Comparable.class) &&
                    (as = p.getActualTypeArguments()) != null &&
                    as.length == 1 && as[0] == c) // type arg is c
                    return c;
            }
        }
    }
    return null;
}

该方法用于红黑树中的查找和树化方法,判断传入的对象x是否实现了Comparable接口,用于判断是基于hashcode排序还是使用其compareTo方法进行排序。

compareComparables()

@SuppressWarnings({"rawtypes","unchecked"}) // for cast to Comparable
    static int compareComparables(Class<?> kc, Object k, Object x) {
    return (x == null || x.getClass() != kc ? 0 :
    ((Comparable)k).compareTo(x));
}

与上一个方法配合使用,返回两个对象的比较结果,用于红黑树中的查找或者结构调整方法。

tableSizeFor()

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;
    }CITY) ? MAXIMUM_CAPACITY : n + 1;
}

该方法用于对HashMap初始化时传入的自定义数组长度 capacity 进行二次加工,保证其为2的幂次数。

实现原理

HashMap存储结构

基本存储结构如下图所示,由 数组+链表+红黑树 构成。

当数组中所有的 Node对象中的key值生成的hashcode都不存在哈希碰撞时,table对象已最简单的数组形式存在,此时get()方法的时间复杂度为O(1)。

当发生哈希碰撞时,同一个存储桶(bin)中的Node对象以链表形式存放,此时get()方法时间复杂度为O(n)。

当一个bin中存放的Node对象大于等于前文所说的 TREEIFY_THRESHOLD 时,会执行树化方法将链表转化为红黑树,此时get()方法时间复杂度为O(log(n))。

在这里插入图片描述

初始化

public HashMap(int initialCapacity, float loadFactor)
public HashMap(int initialCapacity)
public HashMap()
public HashMap(Map<? extends K, ? extends V> m)

HashMap共有四个构造方法,其中除了最后一个方法是将Map类型的对象封装为HashMap外,其他都是用于初始化一个新的HashMap对象,区别只在于是否有自定义的初始化变量。

当不指定初始化变量时,将使用默认的变量来对table数组进行初始化,自定义的 initialCapacity 变量将会被传入到 tableSizeFor() 方法中进行处理。

前三个个构造器中都没有table数组被初始化的代码,因此当我们使用new HashMap() 来创建对象时,table数组并未被初始化,而是直到我们第一次调用put方法时它才被初始化。

put方法

put方法用于将(key-value)键值对放入HashMap实例中。对应到内部实现,应该是将 key-value 构造成一个Node对象并放入table数组对应的位置中。

putval方法的基本执行过程在代码的解释中以给出,

//put方法时putval方法的封装
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;
    //如果table数组为null或长度为0,对其进行初始化
    if ((tab = table) == null || (n = tab.length) == 0)
        //调用resize()方法初始化table数组
        n = (tab = resize()).length;
    //如果对应位置的存储桶中没有元素,生成新的Node对象并将其放置在桶中
    if ((p = tab[i = (n - 1) & hash]) == null)
        tab[i] = newNode(hash, key, value, null);
    else {
        //如果存储桶已经有元素,往下执行
        Node<K,V> e; K k;
        if (p.hash == hash &&
            ((k = p.key) == key || (key != null && key.equals(k))))
            //若key值已存在,将该节点存放在e中待后续替换value值使用
            e = p;
        //若key不存再,往下执行
        else if (p instanceof TreeNode)
            //若p为红黑树节点对象,调用putTreeVal方法
            e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
        else {
            //遍历链表
            for (int binCount = 0; ; ++binCount) {
                //到达链表尾部
                if ((e = p.next) == null) {
                    //插入新节点
                    p.next = newNode(hash, key, value, null);
                    //若链表中节点数量>=7,将链表转换为红黑树
                    if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                        treeifyBin(tab, hash);
                    break;
                }
                //判断e的hash值与待插入节点的hash值是否相同,相同则跳出循环
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    break;
                //用于向后遍历链表
                p = e;
            }
        }
        //替换已存在的key值对应的value值,并返回原来的value值
        if (e != null) { // existing mapping for 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;
}

(n - 1) & hash:该运算用于根据hashcode算出这个key在table数组中对应的下标,由于采用了&运算,因此n(table数组的长度)必须为2的幂次数,以保证运算结果的唯一性以降低哈希碰撞的发生概率。该运算在putval() 方法和 getNode() 方法中都可以看到。

get方法

get方法用于根据传入的key值获取与之映射的value值。

//对getNode方法的封装
public V get(Object key) {
    Node<K,V> e;
    return (e = getNode(hash(key), key)) == null ? null : e.value;
}

final Node<K,V> getNode(int hash, Object key) {
    Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
    //判断table中是否有元素以及对应存储桶中是否有元素
    if ((tab = table) != null && (n = tab.length) > 0 &&
        (first = tab[(n - 1) & hash]) != null) {
        //若存储桶中第一个Node对象的hash值和key值都与擦传入参数相等
        if (first.hash == hash && 
            ((k = first.key) == key || (key != null && key.equals(k))))
            //返回该对象
            return first;
        if ((e = first.next) != null) {
            //如果存储桶中存放的是红黑树,调用getTreeNode方法
            if (first instanceof TreeNode)
                return ((TreeNode<K,V>)first).getTreeNode(hash, key);
            do {
                //向后遍历链表直到找到key值相等的Node对象并返回
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    return e;
            } while ((e = e.next) != null);
        }
    }
    return null;
}

resize方法

扩容方法,每次执行resize方法都会对table中所有Node对象进行遍历并重新计算hash值,非常耗时,因此在明确HashMap容量时应给出自定义的容量。

final Node<K,V>[] resize() {
    Node<K,V>[] oldTab = table;
   	//扩容前的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
    }
    //如果table还没有被初始化,容量在构造方法中被存放到threshold中
    else if (oldThr > 0) // initial capacity was placed in threshold
        newCap = oldThr;
    //给容量和阈值赋初值,当使用无参构造器初始化HashMap时,第一次调用resize会到这一步
    else {               // zero initial threshold signifies using defaults
        newCap = DEFAULT_INITIAL_CAPACITY;
        newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
    }
    //如果阈值为0,则用数组容量和负载因子计算出来
    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;
                //如果对应存储桶中只有一个元素,直接rehash后放入到新数组中
                if (e.next == null)
                    newTab[e.hash & (newCap - 1)] = e;
                //如果存储桶中节点为红黑树形式,调用split方法
                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;
                        //对就数组一个bin中的链表进行拆分,按照e.hash & oldCap==0进行拆分
                        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;
}

treeifyBin方法

该方法用于将bin中以链表形式存储的Node对象构造成红黑树,实际上是对TreeNode类的treeify方法的封装。

final void treeifyBin(Node<K,V>[] tab, int hash) {
    int n, index; Node<K,V> e;
    //数组长度没达到要求时,不进行树化,而是通过扩容减少每个bin中的Node对象数量
    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 {
            //将链表中所有Node对象转化为TreeNode对象
            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)
            //调用链表头结点的treeify方法将链表转化为红黑树
            hd.treeify(tab);
    }
}

remove方法

用于删除key-value对,返回被删除对象的value值。

public V remove(Object key) {
    Node<K,V> e;
    //调用removeNode方法删除key对应的Node对象
    return (e = removeNode(hash(key), key, null, false, true)) == null ?
        null : e.value;
}

final Node<K,V> removeNode(int hash, Object key, Object value,
                           boolean matchValue, boolean movable) {
    Node<K,V>[] tab; Node<K,V> p; int n, index;
    //判断bin中是否存在Node对象
    if ((tab = table) != null && (n = tab.length) > 0 &&
        (p = tab[index = (n - 1) & hash]) != null) {
        Node<K,V> node = null, e; K k; V v;
        //找到key值对应的Node对象
        if (p.hash == hash &&
            ((k = p.key) == key || (key != null && key.equals(k))))
            node = p;
        else if ((e = p.next) != null) {
            //如果bin中存储的是红黑树,调用getTreeNode方法获取key值对应的TreeNode对象
            if (p instanceof TreeNode)
                node = ((TreeNode<K,V>)p).getTreeNode(hash, key);
            else {
                //遍历链表寻找对应的Node对象
                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)
                //如果是红黑树,调用removeTreeNode方法
                ((TreeNode<K,V>)node).removeTreeNode(this, tab, movable);
            else if (node == p)
                //如果待删除对象是头结点,将其下一节点放置在bin的起始位
                tab[index] = node.next;
            else
                //删除node节点
                p.next = node.next;
            ++modCount;
            --size;
            afterNodeRemoval(node);
            return node;
        }
    }
    return null;
}

多线程

HashMap类在多线程下是无法保证数据安全的。

死循环

下面一段代码时JDK1.7的源码,从代码10~12行可以看出在扩容后将旧数据拷贝到新数组时,对于链表的拷贝是使用头插法的。JDK1.8修改了此处的代码,链表的拷贝是直接将头结点放入新数组对应的bin中,不存在逐个节点遍历的过程。那头插法会给多线程下的使用带来什么问题呢?

 1    void transfer(Entry[] newTable, boolean rehash) {
 2         int newCapacity = newTable.length;
 3         for (Entry<K,V> e : table) {
 4             while(null != e) {
 5                 Entry<K,V> next = e.next;
 6                 if (rehash) {
 7                     e.hash = null == e.key ? 0 : hash(e.key);
 8                 }
 9                 int i = indexFor(e.hash, newCapacity);
10                 e.next = newTable[i];
11                 newTable[i] = e;
12                 e = next;
13             }
14         }
15     }

我们假设有如下图左侧所示的HashMap,进行单线程的扩容时,产生的newTable如右侧所示,由于采用头插法,链表中元素的顺序被翻转了。


当我们采用两个线程同时进行扩容时,可能会发生如下情况:

线程A执行完 e.next = newTable[i]; 被挂起,线程B执行resize操作并完成了扩容,从而得到如下newTable;

在这里插入图片描述

根据JMM模型,主内存中的数据已更新为线程B扩容后的newTable中的数据,此后线程A从主内存读取的数据以此为准。

线程A继续从阻塞处往后执行,第一次循环结束后得到下图右上的结果;继续第二次循环,得到右下的结果;继续第三次循环,当第三次循环执行到 e.next = newTable[i]; 时,e表示的 Entry 对象将指向 newTable[i] 的首个 Entry 对象,在此产生了一个环形链表,从而造成死循环。

在这里插入图片描述

JDK1.8后对扩容代码进行了优化,不再会出现环形链表,但HashMap仍然是线程不安全的。

数据丢失

对于JDK1.8以后的版本,主要的线程不安全发生在put方法被调用时。

HashMap的 putVal 方法中有如下一段代码,当两个线程同时调用put方法插入一个hash值相同的Node对象时,可能存在A线程执行完如下if判断语句后被阻塞,然后B线程往该hash值对应的存储桶中放入一个Node对象。A线程获取CPU资源后继续往下执行代码,此时A线程不会重新执行判断,而是直接覆盖线程B放入的Node对象,造成了线程B放入的对象被丢失的现象。

//如果对应位置的存储桶中没有元素,生成新的Node对象并将其放置在桶中
if ((p = tab[i = (n - 1) & hash]) == null)
    tab[i] = newNode(hash, key, value, null);

此外,putVal 方法还有如下代码,插入一个数据后需要对数组的实际大小执行+1,但由于++操作在JVM中不是原子操作,在多线程情况下可能两个线程都执行了putVal方法后,数组的 size 实际执行了一次++(一个线程的++操作被覆盖了),此时size值记录的大小与table的实际大小不符,造成了线程不安全。

//判断插入后是否需要扩容
if (++size > threshold)
    resize();

HashMap与HashTable的区别

HashTable是JDK1.0便被引入的集合类,也是线程安全的,关于HashTable的源码分析可以参考《【Java集合源码剖析】Hashtable源码剖析》 。本文直接给出二者比较明显的区别:

  1. 多线程:HashTable中的方法基本都使用了 synchronized 关键字,因此它是线程安全的,而HashMap是线程不安全的;
  2. 存储结构:HashTable使用的是数组+链表的存储方式,当数据量较大时,显然没有HashMap中使用的红黑树结构效率高;
  3. hash值:HashTable直接使用了key对象的hashCode,而HashMap对此进行了优化,降低哈希碰撞的概率;
  4. 数组下标的计算:HashTable在计算hash值对应的数组下标时,代码为: (hash & 0x7FFFFFFF) % tab.length ;而HashMap中对应的代码为: (n - 1) & hash 。因此HashMap要求n(数组长度)一定为2的幂次数,同时HashMap该运算的效率会快于HashTable;
  5. HashMap中Key和Value中的值都允许为null,而HashTable中不允许,如果调用了put(null, null),JVM在运行时会抛出 nullpointerException 。

参考:

https://www.cnblogs.com/jiang–nan/p/9014779.html

https://www.cnblogs.com/chengxiao/p/6059914.html

https://blog.csdn.net/ns_code/article/details/36191279

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值