JDK1.8 hashmap源码解析

一.HashMap的数据结构

jdk1.8版本是数组+ 链表/红黑树

二.元素属性

public class HashMap<K,V> extends AbstractMap<K,V> implements Map<K,V>, Cloneable, Serializable {
// 序列号
private static final long serialVersionUID = 362498820763181265L;    
// 默认的初始容量是16
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;   
// 最大容量
static final int MAXIMUM_CAPACITY = 1 << 30; 
// 默认的填充因子
static final float DEFAULT_LOAD_FACTOR = 0.75f;
// 当桶(bucket)上的结点数大于这个值时会转成红黑树
static final int TREEIFY_THRESHOLD = 8; 
// 当桶(bucket)上的结点数小于这个值时树转链表
static final int UNTREEIFY_THRESHOLD = 6;
// 桶中结构转化为红黑树对应的table的最小大小
static final int MIN_TREEIFY_CAPACITY = 64;
// 存储元素的数组,总是2的幂次倍
transient Node<k,v>[] table; 
// 存放具体元素的集
transient Set<map.entry<k,v>> entrySet;
// 存放元素的个数,注意这个不等于数组的长度。
transient int size;
// 每次扩容和更改map结构的计数器
transient int modCount;   
// 临界值 当实际大小(容量*填充因子)超过临界值时,会进行扩容
int threshold;
// 填充因子
final float loadFactor;
}

HashMap的容量控制在2的幂次方,这主要便于定位key在table中的位置

index = (table.length - 1) & hash(key);

table.length - 1相当于一个低位的掩码,和哈希值取与,可以得到最终的index,因为这其中运用到了位运算,所有效率较高

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

hash()函数对hashCode高半区和低半区做异或,可以混合原始哈希码的高位和低位,以此来加大低位的随机性。

HashMap的基础操作:

put插入、get获取、remove移除、entrySet遍历

三.put方法

put插入:

1.先获取table长度,必要时进行扩容

2.根据key的hash定位key应在table数组的索引位i和首节点p

3.如果首节点位空,直接初始化一个新节点

4.判断key如果应为首节点,则记录为e

5.如果p instanceof TreeNode,则调用putTreeVal转换为树节点插入

6.否则p instanceof Node,遍历链表尝试插入key
遍历如果到了尾节点,则在尾部插入节点,并判断节点数是否达到TREEIFY_THRESHOLD阈值,如果达到,调用treeifyBin将链表转换为红黑树

7.如果在遍历过程找到替换节点,记录为e,并退出循环

8.如果e存在,则进行替换操作,并返回旧值

9.如果e不存在,说明进行了结构性变更(新增节点或红黑树化),则记录modCount,同时判定如果实际大小大于阈值则扩容。

final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
               boolean evict) {
// tab是table的本地副本,p是根据hash计算的key在table中的所属桶的首节点
// n是table长度,i是key在table的索引位置
Node<K,V>[] tab; Node<K,V> p; int n, i;
// table未初始化或者长度为0,通过resize进行初始化
if ((tab = table) == null || (n = tab.length) == 0)
    n = (tab = resize()).length;
// (n - 1) & hash 确定元素存放在哪个桶中
if ((p = tab[i = (n - 1) & hash]) == null)
    // 桶为空,新生成结点放入桶中(此时,这个结点是放在数组中)
    tab[i] = newNode(hash, key, value, null);
else {
    // 桶中已经存在元素
    Node<K,V> e; K k;
    // 比较桶中第一个元素(数组中的结点)和传入key的hash值、引用地址或equals方法是否相等
    if (p.hash == hash &&
        ((k = p.key) == key || (key != null && key.equals(k))))
            // 将第一个元素赋值给e,用e来记录
            e = p;
    // hash值不相等,即key不相等;
    else if (p instanceof TreeNode)
        // 为为红黑树结点,放入树中
        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);
                // 结点数量达到阈值,转化为红黑树
                if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                    treeifyBin(tab, hash);
                // 跳出循环
                break;
            }
            // 判断链表中结点的key值与插入的元素的key值是否相等
            if (e.hash == hash &&
                ((k = e.key) == key || (key != null && key.equals(k))))
                // 相等,跳出循环
                break;
            // 用于遍历桶中的链表,与前面的e = p.next组合,可以遍历链表
            p = e;
        }
    }
    // 表示在桶中找到key值、hash值与插入元素相等的结点
    if (e != null) { 
        // 记录e的value
        V oldValue = e.value;
        // onlyIfAbsent为false或者旧值为null
        if (!onlyIfAbsent || oldValue == null)
            //用新值替换旧值
            e.value = value;
        // 访问后回调
        afterNodeAccess(e);
        // 返回旧值
        return oldValue;
    }
}
// 结构性修改
++modCount;
// 实际大小大于阈值则扩容
if (++size > threshold)
    resize();
// 插入后回调
afterNodeInsertion(evict);
return null;
}


中间涉及到节点创建替换实现非常简单:

 // Create a regular (non-tree) node
 Node<K,V> newNode(int hash, K key, V value, Node<K,V> next) {
return new Node<>(hash, key, value, next);
}

 // Create a tree bin node
 TreeNode<K,V> newTreeNode(int hash, K key, V value, Node<K,V> next) {
return new TreeNode<>(hash, key, value, next);
}

四.resize获取或调整Map容量

在第一次触发初始化HashMap或扩容时都会触发resize函数调整。在进行扩容前,会根据不同触发条件,计算扩容后的阈值和容量,初始化新table,而后将旧table的元素迁移到新table中。整个扩容的核心算法在元素迁移部分。

迁移算法原理

迁移算法是:

1.遍历table,过滤出不为null的桶首节点。

2.如果没有后续节点,直接计算新的索引位置存放在新的table中

3.否则如果是树节点,则拆分树节点再进行重新映射:

4.最后是普通链表节点,则先分组再映射,具体实现是根据(e.hash & oldCap)是否为0分成两条链表,

如果为0则直接存入新链表相应位置,不为0需要重新计算索引,这里考虑以下实例:
oldCap=1000,newCap=10000,
hash1=11101,hash2=10101

1. 不满足hash1&oldCap==0:A=hash1&(oldCap-1)=00101,B=hash1&(newCap-1)=01101,A!=B,需要迁移到新table的其他索引位
2. 满足hash2&oldCap==0:A=hash2&(oldCap-1)=00101,B=hash2&(newCap-1)=00101,A=B,直接迁移到新table的相同索引位
从以上发现,每次扩容,会将同一个桶内的元素,根据(e.hash & oldCap)是否为0分到新表的两个桶中

从以上发现,每次扩容,会将同一个桶内的元素,根据(e.hash & oldCap)是否为0分到新表的两个桶中

 final Node<K,V>[] resize() {
// 当前table保存
Node<K,V>[] oldTab = table;
// 保存table大小
int oldCap = (oldTab == null) ? 0 : oldTab.length;
// 保存当前阈值 
int oldThr = threshold;
// 阈值=容量*loadFactor
int newCap, newThr = 0;
// 如果旧容量大于0,在没有超过最大容量,会对旧容量oldCab和阈值oldThr进行翻倍,存储在newCap和newThr中
if (oldCap > 0) {
    // 如果超过最大容量,规整为最大容量
    if (oldCap >= MAXIMUM_CAPACITY) {
        threshold = Integer.MAX_VALUE;
        return oldTab;
    }
    // 容量翻倍,使用左移,效率更高
    else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
        oldCap >= DEFAULT_INITIAL_CAPACITY)
        // 阈值翻倍,如果能进来证明此map是扩容而不是初始化
        newThr = oldThr << 1; // double threshold
}
// 旧容量为0,阈值大于0
else if (oldThr > 0)
    //创建map时用的带参构造:public HashMap(int initialCapacity)或 public HashMap(int initialCapacity, float loadFactor) 进入此if
    //注:带参的构造中initialCapacity(初始容量值)不管是输入几都会通过 “this.threshold = tableSizeFor(initialCapacity);”此方法计算出接近initialCapacity参数的2^n来作为初始化容量(初始化容量==oldThr)
    newCap = oldThr;
// oldCap = 0并且oldThr = 0
else {           
    // 创建map时用的无参构造进入此if:
    // 使用缺省值(如使用HashMap()构造函数,之后再插入一个元素会调用resize函数,会进入这一步)
    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"})
// 初始化table
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
table = newTab;
// 之前的table已经初始化过
if (oldTab != null) {
    // 复制元素,重新进行hash,遍历旧table
    for (int j = 0; j < oldCap; ++j) {
        Node<K,V> e;
        // 数组元素首节点不为空才继续操作
        if ((e = oldTab[j]) != null) {
            // 先设为空
            oldTab[j] = null;
            // 每个桶仅有首节点,没有后续节点
            if (e.next == null)
                // 根据hash计算在新表的索引位置,存入新表中
                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;
                // 将同一桶中的元素根据(e.hash & oldCap)是否为0进行分割,分成两个不同的链表,完成rehash
                // 为0放在lo链表,不为0放在hi链表
                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;
}

从代码中我们可以看到,对于每个桶:

如果是链表节点,在经过扩容后,会将同一个桶内的元素,根据(e.hash & oldCap)是否为0分到新表的两个桶中,但新表中两个桶的节点顺序并未发生改变。
如果是树节点,会对树进行拆分,再重新映射到新table中,具体实现在下一节分析
每次进行扩容,会伴随着一次重新hash分配,并且会遍历hash表中所有的元素,是非常耗时的,但经过一次扩容处理后,元素会更加均匀的分布在各个桶中,会提升访问效率。

五.链表树化、红黑树链化与拆分

在putVal()和resize()函数中,都有一些相关的红黑树操作,包括:putTreeVal,treeifyBin,split,untreeify。

treeifyBin树化

在扩容的过程中,满足树化的要求

1.链表长度大于等于 TREEIFY_THRESHOLD

2.桶数组容量大于等于 MIN_TREEIFY_CAPACITY

加入第二个条件的原因在于:

1.当桶数组容量比较小时,键值对节点 hash 的碰撞率可能会比较高,进而导致链表长度较长。这个时候应该优先扩容,而不是立马树化。毕竟高碰撞率是因为桶数组容量较小引起的,这个是主因。

2.容量小时,优先扩容可以避免一些列的不必要的树化过程。同时,桶容量较小时,扩容会比较频繁,扩容时需要拆分红黑树并重新映射。所以在桶容量比较小的情况下,将长链表转成红黑树是一件吃力不讨好的事。

这里值得讨论的是树化后,如果决定每个key-value的存储顺序,源码中的实现如下:

1.比较键与键之间 hash 的大小,如果 hash 相同,继续往下比较

2.检测键类是否实现了 Comparable 接口,如果实现调用 compareTo 方法进行比较

3.如果仍未比较出大小,就需要进行仲裁了,仲裁方法为 tieBreakOrder,实现是先比较类名,再比较System.identityHashCode:

在源码中,链表的pre/next顺序是保留的,这也是TreeNode继承自Node类的必要性,也方便后续红黑树转化回链表结构。

split 红黑树拆分
final void split(HashMap<K,V> map, Node<K,V>[] tab, int index, int bit) {
TreeNode<K,V> b = this;
// Relink into lo and hi lists, preserving order
TreeNode<K,V> loHead = null, loTail = null;
TreeNode<K,V> hiHead = null, hiTail = null;
int lc = 0, hc = 0;
/* 
 * 红黑树节点仍然保留了 next 引用,故仍可以按链表方式遍历红黑树。
 * 下面的循环是对红黑树节点进行分组,与上面类似
 */
for (TreeNode<K,V> e = b, next; e != null; e = next) {
    next = (TreeNode<K,V>)e.next;
    e.next = null;
    // bit是旧table容量,这里同样根据是否(e.hash & bit) == 0切分到两个桶,切分同时对链表长度进行计数
    if ((e.hash & bit) == 0) {
        if ((e.prev = loTail) == null)
            loHead = e;
        else
            loTail.next = e;
        loTail = e;
        ++lc;
    }
    else {
        if ((e.prev = hiTail) == null)
            hiHead = e;
        else
            hiTail.next = e;
        hiTail = e;
        ++hc;
    }
}

if (loHead != null) {
    // 如果 loHead 不为空,且链表长度小于等于 6,则将红黑树转成链表
    if (lc <= UNTREEIFY_THRESHOLD)
        tab[index] = loHead.untreeify(map);
    else {
        tab[index] = loHead;
        /* 
         * hiHead == null 时,表明扩容后,
         * 所有节点仍在原位置,树结构不变,无需重新树化
         */
        if (hiHead != null) 
            loHead.treeify(tab);
    }
}
// 与上面类似
if (hiHead != null) {
    if (hc <= UNTREEIFY_THRESHOLD)
        tab[index + bit] = hiHead.untreeify(map);
    else {
        tab[index + bit] = hiHead;
        if (loHead != null)
            hiHead.treeify(tab);
    }
}
}

从源码上可以看得出,重新映射红黑树的逻辑和重新映射链表的逻辑基本一致。

不同的地方在于,重新映射后,会将红黑树拆分成两条由 TreeNode 组成的链表。如果链表长度小于 UNTREEIFY_THRESHOLD,则将链表转换成普通链表。否则根据条件重新将 TreeNode 链表树化。

untreeify 红黑树链化
final Node<K,V> untreeify(HashMap<K,V> map) {
Node<K,V> hd = null, tl = null;
// 遍历 TreeNode 链表,并用 Node 替换
for (Node<K,V> q = this; q != null; q = q.next) {
    // 替换节点类型
    Node<K,V> p = map.replacementNode(q, null);
    if (tl == null)
        hd = p;
    else
        tl.next = p;
    tl = p;
}
return hd;
}

  Node<K,V> replacementNode(Node<K,V> p, Node<K,V> next) {
return new Node<>(p.hash, p.key, p.value, next);
}

再将红黑树转成链表就简单多了,仅需将 TreeNode 链表转成 Node 类型的链表

六.remove移除Map元素

public V remove(Object key) {
Node<K,V> e;
return (e = removeNode(hash(key), key, null, false, true)) == null ?
    null : e.value;
}

1.定位桶位置

2.遍历链表并找到键相等的节点

3.判断若matchValue为true,需要比对值相等才进行下一步操作
删除节点,删除时有3种情况:
i.如果待删除节点父节点为树节点,调用树删除算法,同时对红黑树进行必要调整

ii.如果删除的是首节点,直接下一节点作为首节点

iii.删除的是普通链表非首节点,假设p是待删除节点的上一节点,设置p下一节点为待删除节点的下一节点

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;
if ((tab = table) != null && (n = tab.length) > 0 &&
    // 1. 定位桶位置
    (p = tab[index = (n - 1) & hash]) != null) {
    Node<K,V> 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) {  
        // 如果是 TreeNode 类型,调用红黑树的查找逻辑定位待删除节点
        if (p instanceof TreeNode)
            node = ((TreeNode<K,V>)p).getTreeNode(hash, key);
        else {
            // 2. 遍历链表,找到待删除节点
            do {
                if (e.hash == hash &&
                    ((k = e.key) == key ||
                     (key != null && key.equals(k)))) {
                    node = e;
                    break;
                }
                p = e;
            } while ((e = e.next) != null);
        }
    }
    
    // 3. 删除节点,并修复链表或红黑树
    if (node != null && (!matchValue || (v = node.value) == value ||
                         (value != null && value.equals(v)))) {
        if (node instanceof TreeNode)
            // 调用树删除算法,同时对红黑树进行必要调整
            ((TreeNode<K,V>)node).removeTreeNode(this, tab, movable);
        else if (node == p)
            // 如果删除的是首节点,直接下一节点作为首节点
            tab[index] = node.next;
        else
            // 根据上面的算法,p是待删除节点的上一节点,设置p下一节点为待删除节点的下一节点。
            p.next = node.next;
        // 记录结构修改次数
        ++modCount;
        --size;
        // 删除回调
        afterNodeRemoval(node);
        return node;
    }
}
return null;
}

七.entrySet遍历Map元素与并发更新fail fast原理

遍历除了使用entrySet()方法外,还可以使用ketSet()或values(),这里以entrySet作为代表分析。如果HashMap结构未发生变更,每次遍历的顺序都是一致,但都不是插入的顺序。下面来看遍历的相关核心实现:

public final void forEach(Consumer<? super Map.Entry<K,V>> action) {
Node<K,V>[] tab;
if (action == null)
    throw new NullPointerException();
if (size > 0 && (tab = table) != null) {
    // 缓存modCount
    int mc = modCount;
    // 遍历tbale
    for (int i = 0; i < tab.length; ++i) {
        // 对每个桶根据next顺序进行遍历
        for (Node<K,V> e = tab[i]; e != null; e = e.next)
            action.accept(e);
    }
    // 如果遍历过程,Map被修改过,则抛ConcurrentModificationException
    if (modCount != mc)
        throw new ConcurrentModificationException();
}
}

如果我们需要在遍历过程删除元素,需要使用Map的迭代器来进行遍历,否则在遍历过程检测到modCount发生变化,会抛出异常。

final class EntrySet extends AbstractSet<Map.Entry<K,V>> {
// ……省略其他代码
public final Iterator<Map.Entry<K,V>> iterator() {
    return new EntryIterator();
}
// ……省略其他代码
}  

final class EntryIterator extends HashIterator implements Iterator<Map.Entry<K,V>> {
public final Map.Entry<K,V> next() { return nextNode(); }
}

八.并发操作死循环问题

在jdk1.8之前,resize操作时,当两个线程同时触发resize操作,基于头插法可能会导致链表节点的循环引用,在下次调用get操作查找一个不存在的key时,会在循环链表中出现死循环。

在jdk1.8中,从上述分析可以看到,在插入时,声明两对指针,维护两个连链表,依次在末端添加新的元素。(在多线程操作的情况下,无非是第二个线程重复第一个线程一模一样的操作),因而不再有多线程put导致死循环,但是依然有其他的弊端,比如数据丢失(与内存可见性有关)或size不准确。因此多线程情况下还是建议使用concurrenthashmap。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值