JDK1.8版本HashMap源码原理分析

HashMap是一个KV的容器对象,也是日常在开发当中非常常用的对象,比如我们可能经常做一些内存缓存的时候,很多时候就选用HashMap这种KV的数据结构。在实现原理上,是基于哈希表的Map接口实现,是常用的java集合之一,非线程安全的。

HashMap可以存储null的key和value,但是null作为键值只能有一个,做为值的话,可以是多个。这和Map的键要保持唯一性并不冲突。

一、HashMap的类图结构

二、概念、原理概述

Jdk1.8之前的HashMap是由数组+链表作为底层数据结构实现的,数组是hashMap的主体,链表则是为了解决哈希冲突而存在内部解决方案(拉链法)。

jdk1.8以后的HashMap在解决哈希冲突时有了较大的变化,引入了红黑树,以减少搜索时间。

先明确几个概念:

哈希表:指的就是hashMap;

哈希桶:HashMap的底层数据结构,即数组;

链表:Hash桶的下标装的是链表(或树型结构体);

节点:链表上的节点就是哈希表上的元素

哈希表元素容量:元素的总个数

哈希桶的容量:数组数组个数。

哈希桶的默认容量是16

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

对于拉链式的散列算法,其数据结构是由数组和链表(或树形结构)组成。

上图初步呈现了hashMap的实现结构,在进行增删改查操作的时候,首先要定位到元素所在桶的位置,桶的位置指的就是table数组元素的位置,之后再从桶元素所对应的链表中定位该元素。

比如我们要查找35的元素,先定位到数组中3元素的位置,然后通过链表定位到第三个元素,确定了35元素的位置。

HashMap的底层结构原理概述就如上所示,HashMap的基本操作就是对拉链式散列算法的一层包装,无论1.8版本后引入的红黑树,虽底层数据结构由【数组+链表】变成【数组+链表+红黑树】,核心的原理设计没变。

在jdk1.8中引入的红黑树,在链表的长度大于8并且哈希桶的长度大于等于64的时候【TODO】,会将链表进行树化。红黑树是一个自平衡的二叉查找树,查找效率会从链表O(N)降低为o(logn),大大提升查找效率。

详细分析,看接下来的源码分析

三、HashMap源码原理分析

3.1构造函数分析

/**
 * 根据初始化容量、加载因子初始化一个空元素的Map
 * @param  initialCapacity 初始化容量
 * @param  loadFactor  负载因子
 * @throws IllegalArgumentException 负数抛异常
 */
public HashMap(int initialCapacity, float loadFactor) {
   
    ...
    //初始化容量超过了最大容量1 << 30(2的30次幂),则使用最大容量
    if (initialCapacity > MAXIMUM_CAPACITY)
        initialCapacity = MAXIMUM_CAPACITY;
    ...
    //负载因子初始化:负载因子或者叫做扩容因子
    this.loadFactor = loadFactor;
    //HashMap进行扩容的阈值,实际上就是数组的长度
    this.threshold = tableSizeFor(initialCapacity);
}

public HashMap(int initialCapacity) {
   
    this(initialCapacity, DEFAULT_LOAD_FACTOR);
}

/**
 * 以16的数组容量和0.75的负载因子,进行默认初始化
 */
public HashMap() {
   
    this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
}

public HashMap(Map<? extends K, ? extends V> m) {
   
    this.loadFactor = DEFAULT_LOAD_FACTOR;
    putMapEntries(m, false);
}

上述4个构造函数,最常用的是第三个,就是以默认的方式进行初始化一个HashMap,最终会调用都构造函数1,构造函数构造的过程,就是对几个核心的成员变量做了初始化。

3.2 hashMap中桶的长度设计(数组的长度是如何计算的)?

this.threshold = tableSizeFor(initialCapacity);看一下这个方法:

/**
 * Returns a power of two size for the given target capacity.
 */
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;
}

5步或等和移位运算,最终会找到大于或等于 cap 的最小2的正数幂的值,举例说明一下:

比如构造过程中传入的是15,最终tableSizeFor后的值是16,如果是28,最终tableSizeFor的值是32,如果是64,则是64.

tableSizeFor()这个函数就是计算hashMap的长度,那么从设计层面为什么HashMap的长度设计成2的整数幂次方呢?

1、为了加快哈希计算

查找一个KEY在哈希表的那个桶中,需要计算hash(key)%桶的长度,%属于算术运算,算术运算的效率要低于&位运算符的效率,恰好,当被取余的数是2的n次幂的时候,可以用位&替代取余来提升效率。

如下,当b为2的n次方时,有如下替换公式(公式可自行验证):
a % b = a & (b-1) (b=2^n)
即:a % 2^n = a & (2^n-1)

2、2次幂必然是偶数,这种偶数设计能使得散列结果均匀,从而减少Hash冲突的可能性。

假设数组的长度length是奇数,length-1为偶数,最后一位是0,通过hash函数hash&(length-1)的结果最后一位肯定是0,即只能为偶数,这样任何hash值经过hash函数计算后的结果都是偶数,元素就只能被散列在偶数的下标位置上,这样既浪费了空间,同时可能带来2倍的hash冲突的可能性。

关于tableSizeFor()方法运算中大量的使用了位运算和逻辑运算的详细说明可以参考https://segmentfault.com/a/1190000039392972。

3.2 HashMap源码中的关键常量变量的声明部分

/**
 * 最大的容量值
 * MUST be a power of two(必须2的幂) <= 1<<30.
 */
static final int MAXIMUM_CAPACITY = 1 << 30;

/**
 * 默认的负载因子
 */
static final float DEFAULT_LOAD_FACTOR = 0.75f;

/**
 * 树化的链表数量阈值
 */
static final int TREEIFY_THRESHOLD = 8;

/**
 * 树拆分的阈值
 */
static final int UNTREEIFY_THRESHOLD = 6;

/**
 * 最小的树化桶容量
 */
static final int MIN_TREEIFY_CAPACITY = 64;

以上都是简单的描述,在后续的章节分析中会得到这些常量的使用和设计。

3.3 hashMap的桶的的数据结构和源码实现

/**
 * table数组, 第一次使用的时候初始化,必要的时候会进行扩容,扩容一般都是原来的2倍
 */
transient Node<K,V>[] table;
/**
 * 基本的hash存储节点, 用于存储大量的entries. 
 */
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;
    }

    public final K getKey()        {
    return key; }
    public final V getValue()      {
    return value; }
    public final String toString() {
    return key + "=" + value; }

    public final int hashCode() {
   
        return Objects.hashCode(key) ^ Objects.hashCode(value);
    }

    public final V setValue(V newValue) {
   
       ...
    }

    public final boolean equals(Object o) {
   
        ...
    }
}

Node是HashMap的的一个静态内部类,实现了Map.Entry接口,本质上就是一个映射。final K key;V value; 类的成员变量中定义了key和value,是泛型化的,但是注意key被关键字final修饰了,虽然无论创建出来什么样的节点的key数据,程序不会出现问题,但是如果Key对应的对象本身应该也是不可变对象。

3.4.1HashMap的元素插入逻辑和源码实现分析

元素的插入流程是首先要定位需要插入的键值元素属于哪个通,定位到桶过后,判断当前的桶中是否已经有元素,如果桶为空,则直接将键值对存入即可。如果不为空,则需要将键值对接在链表的最后一个位置,或者更新键值对。

以上就是元素插入的核心流程,但由于hashMap是一个变长的集合,实际在插入的时候还有扩容机制,在1.8版本jdk中,还有树化过程和树拆过程。看一下源码,源码分析后,会画一个流程帮助理解:

/**
 * 将键值对元素插入的桶中。
 * 如果同种存在了相同key的键值对,则替换
 *
 * @param key 
 * @param value 
 */
public V put(
  • 1
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 2
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

hymKing

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值