java读源码 之 map源码分析(HashMap)二

简介: java读源码 之 map源码分析(HashMap)二

字段分析:

// 默认容量16
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; 
// 最大容量2的31次方
 static final int MAXIMUM_CAPACITY = 1 << 30;
// 默认负载因子0.75
static final float DEFAULT_LOAD_FACTOR = 0.75f;
// 链表中元素超过8就进行树化
static final int TREEIFY_THRESHOLD = 8;
// 在哈希表扩容时,如果发现链表长度小于 6,则会由树重新退化为链表。
static final int UNTREEIFY_THRESHOLD = 6;
//在转变成树之前,还会有一次判断,只有键值对数量大于 64 才会发生转换。这是为了避免在哈希表建立初期,多个键 //值对恰好被放入了同一个链表中而导致不必要的转化。
static final int MIN_TREEIFY_CAPACITY = 64;

构造函数分析:

/**
 * 用指定的初始容量跟负载因子构造一个空的HashMap
 */
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);
}

构造函数中调用了一个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;
}

有没有一种很眼熟的感觉?这个算法我们之前在ArrayDeque已经分析过啦,其实就是求一个比cap大的最小的2的n次方的数(在这个方法里,进行了减1,就是要找一个大于等于当前数的2的n次方的数),具体的分析大家可以看看这篇文章:https://blog.csdn.net/qq_41907991/article/details/94724829

// 构造一个指定初始容量的HashMap,采用的是默认的负载因子0.75
public HashMap(int initialCapacity) {
    this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
// 容量跟负载因子均采用默认的值,初始容量为16,负载因子为0.75
public HashMap() {
    this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
}
// 构建一个新的HashMap,用于保存原有map中的映射
// 在构建时会采用默认的负载因子0.75,同时会计算一个初始容量来保存这些映射
public HashMap(Map<? extends K, ? extends V> m) {
    this.loadFactor = DEFAULT_LOAD_FACTOR;
    putMapEntries(m, false);
}
// 将集合中元素放入到新构建的map中
final void putMapEntries(Map<? extends K, ? extends V> m, boolean evict) {
    // 可以看到,不能放入一个null的map
    int s = m.size();
    if (s > 0) {
        // 如果当前的HashMap还没经过初始化的话,现进行一次初始化
        if (table == null) { 
            // 计算当前map需要的最小的容量=元素数量/负载因子,加1方便强转
            float ft = ((float)s / loadFactor) + 1.0F;
            int t = ((ft < (float)MAXIMUM_CAPACITY) ?
                     (int)ft : MAXIMUM_CAPACITY);
            // 如果容器中元素的数量已经大于了阈值,进行一次容量的初始化
            if (t > threshold)
                threshold = tableSizeFor(t);
        }
        // 如果当前的hashMap已经经过的初始化,判断元素数量是否大于阈值
        else if (s > threshold)
            // 进行一次扩容
            resize();
        for (Map.Entry<? extends K, ? extends V> e : m.entrySet()) {
            // 循环添加
            K key = e.getKey();
            V value = e.getValue();
            putVal(hash(key), key, value, false, evict);
        }
    }
}

构造函数就到这里啦~,接下来就要进入我们今天的重头戏,hashMap的扩容机制:

扩容机制:

final Node<K,V>[] resize() {
    // 旧容器底层数组
    Node<K,V>[] oldTab = table;
    // 旧容器容量
    int oldCap = (oldTab == null) ? 0 : oldTab.length;
    // 旧容器的阈值
    int oldThr = threshold;
    int newCap, newThr = 0;
    // oldCap > 0说明容器已经经过了初始化
    if (oldCap > 0) {
        // 扩容前进行判断,如果oldCap>2的31次方
        if (oldCap >= MAXIMUM_CAPACITY) {
            // 直接将阈值设置为Integer.MAX_VALUE
            threshold = Integer.MAX_VALUE;
            return oldTab;
        }
        // 这里可以看出容量每次被扩容为2倍
        else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                 oldCap >= DEFAULT_INITIAL_CAPACITY)
            // 阈值也变为原来2倍
            newThr = oldThr << 1; 
    }
    else if (oldThr > 0) 
        // 当我们调用HashMap(Map<? extends K, ? extends V> m)这个构造函数时,就会进入这个判断
        newCap = oldThr;
    else { 
        // oldCap=0 oldThr=0,说明还没经过初始化,直接给默认值
        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) {
        // 原容器的数组不为null,说明曾经放入过元素
        for (int j = 0; j < oldCap; ++j) {
            // 遍历其中每一个节点
            Node<K,V> e;
            if ((e = oldTab[j]) != null) {
                // 将原数组中的引用置为null,方便垃圾回收
                oldTab[j] = null;
                if (e.next == null)
                    // 根据key得到hash值跟新容器容量进行模运算,并将这个位置上的元素置为e
                    // 在arrayDeque的文章中已经详细分析过原理了
                    newTab[e.hash & (newCap - 1)] = e;
                // TreeNode的相关东西我们还是单独做一章进行分析
                else if (e instanceof TreeNode)
                    ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
                else { 
                    // 说明在当前数组位置上,下挂了一个链表
                    // 需要将这个链表进行移动
                    Node<K,V> loHead = null, loTail = null;
                    Node<K,V> hiHead = null, hiTail = null;
                    Node<K,V> next;
                    do {
                        next = e.next;
                        // e.hash & oldCap 很关键的一个判断
                        // 主要用来判断链表下挂的节点是否发生改变
                        // 后文会对这个判断进行详细分析
                        if ((e.hash & oldCap) == 0) {
                            // 解决jdk循环链表跟rehash后链表顺序颠倒的问题
                            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;
    }

到这里,我们已经对resize有了一个大致的了解,但是现在最大的问题就是上面这段代码最好那个循环了,它到底是干什么的?我们好不容易搞懂了e.hash & (newCap - 1)是一个模运算,现在这个(e.hash & oldCap) == 0又是什么鬼?不要急,跟着我一步步分析,包懂~~~~,要说清楚这个问题,我们需要将其与jdk7进行比较


jdk7中扩容导致的问题分析:

参考链接:https://www.jianshu.com/p/619a8efcf589


具体的源码我就不分析,相对于jdk8而言,代码还是很好理解的,这里主要说一下过程,以及它导致的死循环问题:(请注意,以下为jdk7的流程,不是jdk8,不要搞混了哦)

// jdk7中的resize方法
void resize(int newCapacity) {
    Entry[] oldTable = table;
    int oldCapacity = oldTable.length;
    ......
    // 创建一个新的 Hash Table
    Entry[] newTable = new Entry[newCapacity];
    // 将 Old Hash Table 上的数据迁移到 New Hash Table 上
    transfer(newTable);
    table = newTable;
    threshold = (int)(newCapacity * loadFactor);
}
// 迁移链表的方法
void transfer(Entry[] newTable) {
    Entry[] src = table;
    int newCapacity = newTable.length;
    //下面这段代码的意思是:
    //  从OldTable里摘一个元素出来,然后放到NewTable中
    for (int j = 0; j < src.length; j++) {
        Entry<K,V> e = src[j];
        if (e != null) {
            src[j] = null;
            do {
                Entry<K,V> next = e.next;
                int i = indexFor(e.hash, newCapacity);
                e.next = newTable[i];
                newTable[i] = e;
                e = next;
            } while (e != null);
        }
    }
}
  • 假设我们的 hash 算法就是简单的用 key mod 一下表的大小(也就是数组的长度)
  • 最上面的是 old hash 表,其中的 Hash 表的 size = 2,所以 key = 3, 7, 5,在 mod 2 以后都冲突在 table[1] 这里了
  • 接下来的三个步骤是 Hash 表 resize 成 4,然后所有的 <key, value> 重新 rehash 的过程

image.png

并发下的Rehash:

1)假设有两个线程

do {
    Entry<K,V> next = e.next; //  假设线程一执行到这里就被调度挂起了
    int i = indexFor(e.hash, newCapacity);
    e.next = newTable[i];
    newTable[i] = e;
    e = next;
} while (e != null);

而线程二执行完成了。于是有下面的这个样子

image.png

注意,线程二执行完成之后,key(7)已经指向了Key(3)


2)线程一被调度回来执行

先是执行 newTalbe[i] = e;

然后是 e = next,导致了 e 指向了 key(7)

而下一次循环的 next = e.next 导致了 next 指向了 key(3)


3)线程一接着工作。把 key(7) 摘下来,放到 newTable[i] 的第一个,然后把 e 和 next 往下移

image.png

4)环形链接出现

e.next = newTable[i] 导致 key(3).next 指向了 key(7)

此时的 key(7).next 已经指向了 key(3), 环形链表就这样出现了

image.png

在对jdk中hashmap存在的问题分析了之后,我们来看看jdk8是怎么解决的。

jdk8的改进方案:

我们先解答之前留下来的一个疑惑,if ((e.hash & oldCap) == 0),oldCap一定是2的n次方,用二进制表示可以是下面这个样子

image.png

看到这种数,并且在进行与运算,我们就要知道,它其实就是为了确定e.hash的第n-1位上的到底是0还是1。


那么现在问题就成了,如果key的hash值对应的二进制第n-1位为0又意味着什么呢?为1又意味着什么呢?


我们分两种情况分析:

1.key的hash值对应的二进制第n-1位为0

当我们确定了其n-1位为0的时候,这个时候,key.hash & 2的n次方一定等于key.hash & 2的n+1次方(也就是我们扩容后的容量),图解如下:

image.png

我们可以看到,当我们进行key.hash&(容量-1)的时候,因为新旧容量对应二进制数唯一的区别就是最高位上一个为0,一个为1,而当我们确定key的hash值在这个位置上的值为0后,就可以忽略这个差异性,因为0与1=0与0=0


这个时候说明了这个这个key在数组中的位置不需要被移动,还是在原来的角标位置上


2.key的hash值对应的二进制第n-1位为1

image.png

从图中可以很明显的看出来,key.hash & 2的n次方-1 跟 key.hash & 2的n+1次方-1,这两个结果相差什么呢?相差一个2的n次方嘛~,也就是oldCap。分析完了不得不感叹一句,真他娘的巧妙!


OK,解决了我们遗留的一个问题后,继续分析为什么jdk8能解决添加元素的时候的死循环问题,回到之前的代码:

// 位置为发生改变的节点对应链表的头尾节点
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)
            // 将头节点置为e
            loHead = e;
        else
            // 否则将当前链表的最后一个节点指向e
            loTail.next = e;
        // loTail始终为加入的最后一个元素
        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;
    // 改变的位置为原位置+oldCap
    newTab[j + oldCap] = hiHead;
}

可以看出,相比于jdk7,jdk8在移动元素时不会该变其顺序,而是保持原来的顺序,这样就解决了jdk7中的死循环问题

到这里,扩容机制我们就介绍完啦希望你能从中学习到知识

相关文章
|
8天前
|
监控 Java 应用服务中间件
高级java面试---spring.factories文件的解析源码API机制
【11月更文挑战第20天】Spring Boot是一个用于快速构建基于Spring框架的应用程序的开源框架。它通过自动配置、起步依赖和内嵌服务器等特性,极大地简化了Spring应用的开发和部署过程。本文将深入探讨Spring Boot的背景历史、业务场景、功能点以及底层原理,并通过Java代码手写模拟Spring Boot的启动过程,特别是spring.factories文件的解析源码API机制。
31 2
|
2天前
|
数据采集 人工智能 Java
Java产科专科电子病历系统源码
产科专科电子病历系统,全结构化设计,实现产科专科电子病历与院内HIS、LIS、PACS信息系统、区域妇幼信息平台的三级互联互通,系统由门诊系统、住院系统、数据统计模块三部分组成,它管理了孕妇从怀孕开始到生产结束42天一系列医院保健服务信息。
13 4
|
13天前
|
人工智能 监控 数据可视化
Java智慧工地信息管理平台源码 智慧工地信息化解决方案SaaS源码 支持二次开发
智慧工地系统是依托物联网、互联网、AI、可视化建立的大数据管理平台,是一种全新的管理模式,能够实现劳务管理、安全施工、绿色施工的智能化和互联网化。围绕施工现场管理的人、机、料、法、环五大维度,以及施工过程管理的进度、质量、安全三大体系为基础应用,实现全面高效的工程管理需求,满足工地多角色、多视角的有效监管,实现工程建设管理的降本增效,为监管平台提供数据支撑。
30 3
|
18天前
|
运维 自然语言处理 供应链
Java云HIS医院管理系统源码 病案管理、医保业务、门诊、住院、电子病历编辑器
通过门诊的申请,或者直接住院登记,通过”护士工作站“分配患者,完成后,进入医生患者列表,医生对应开具”长期医嘱“和”临时医嘱“,并在电子病历中,记录病情。病人出院时,停止长期医嘱,开具出院医嘱。进入出院审核,审核医嘱与住院通过后,病人结清缴费,完成出院。
55 3
|
21天前
|
存储 Java API
Java交换map的key和value值
通过本文介绍的几种方法,可以在Java中实现Map键值对的交换。每种方法都有其优缺点,具体选择哪种方法应根据实际需求和场景决定。对于简单的键值对交换,可以使用简单遍历法或Java 8的Stream API;对于需要处理值不唯一的情况,可以使用集合存储或Guava的Multimap。希望本文对您理解和实现Java中的Map键值对交换有所帮助。
27 1
|
23天前
|
JavaScript Java 项目管理
Java毕设学习 基于SpringBoot + Vue 的医院管理系统 持续给大家寻找Java毕设学习项目(附源码)
基于SpringBoot + Vue的医院管理系统,涵盖医院、患者、挂号、药物、检查、病床、排班管理和数据分析等功能。开发工具为IDEA和HBuilder X,环境需配置jdk8、Node.js14、MySQL8。文末提供源码下载链接。
|
26天前
|
移动开发 前端开发 JavaScript
java家政系统成品源码的关键特点和技术应用
家政系统成品源码是已开发完成的家政服务管理软件,支持用户注册、登录、管理个人资料,家政人员信息管理,服务项目分类,订单与预约管理,支付集成,评价与反馈,地图定位等功能。适用于各种规模的家政服务公司,采用uniapp、SpringBoot、MySQL等技术栈,确保高效管理和优质用户体验。
|
28天前
|
存储 Java API
优雅地使用Java Map,通过掌握其高级特性和技巧,让代码更简洁。
【10月更文挑战第19天】本文介绍了如何优雅地使用Java Map,通过掌握其高级特性和技巧,让代码更简洁。内容包括Map的初始化、使用Stream API处理Map、利用merge方法、使用ComputeIfAbsent和ComputeIfPresent,以及Map的默认方法。这些技巧不仅提高了代码的可读性和维护性,还提升了开发效率。
54 3
|
28天前
|
存储 缓存 安全
在Java的Map家族中,HashMap和TreeMap各具特色
【10月更文挑战第19天】在Java的Map家族中,HashMap和TreeMap各具特色。HashMap基于哈希表实现,提供O(1)时间复杂度的高效操作,适合性能要求高的场景;TreeMap基于红黑树,提供O(log n)时间复杂度的有序操作,适合需要排序和范围查询的场景。两者在不同需求下各有优势,选择时需根据具体应用场景权衡。
30 2
|
3月前
|
存储 安全 Java
一天十道Java面试题----第二天(HashMap和hashTable的区别--------》sleep、wait、join)
这篇文章是关于Java面试的第二天笔记,涵盖了HashMap与HashTable的区别、ConcurrentHashMap的实现原理、IOC容器的实现方法、字节码的概念和作用、Java类加载器的类型、双亲委派模型、Java异常体系、GC如何判断对象可回收、线程的生命周期及状态,以及sleep、wait、join、yield的区别等十道面试题。
一天十道Java面试题----第二天(HashMap和hashTable的区别--------》sleep、wait、join)