hashmap 源码分析

本文详细分析了HashMap的底层实现,包括数组+链表/红黑树的数据结构,为何选择2的幂次方作为数组长度,以及如何通过hash值计算下标以减少冲突。在JDK1.8中,当链表长度达到8时转换为红黑树,以提高查找效率。同时,文章探讨了扩容策略和线程不安全问题,指出扩容时新旧数组的关系以及线程安全问题可能导致的并发修改异常。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

hashmap 源码分析

1、底层原理

  • jdk 1.7 :数组+链表

  • jdk 1.8 :数组+链表+红黑树

引入红黑树的原因:

如果链表长度过长,那么遍历查找的时间复杂度为O(n),效率较慢;引入红黑树,查找效率为O(logn), 效率提升。在jdk 1.8中如果链表的个数大于等于8个且数组长度不小于64,就会转化为红黑树;当个数小于等于6时,又会转化为链表。

1、为什么不一开始就是红黑树,即基于数组+红黑树即可,红黑树相对于链表维护成本大,插入新数据之后可能会通过左旋、右旋、变色来维持平衡,当链路较短时,不适合用红黑树。
2、红黑树转化为链表
TreeNodes(红黑树)占用空间是普通Nodes(链表)的两倍,为了时间和空间的权衡。当hashCode离散性很好的时候,用到红黑树的概率非常小,因为数据均匀分布在数组每个位置中,几乎不会有位置的链表长度达到阈值。但是在随机hashCode下,离散性可能会变差,就可能导致不均匀的数据分布。理想情况下随机hashCode算法下,所有bin中节点的分布频率会遵循泊松分布,一个bin中链表长度达到8个元素的概率为0.00000006,几乎是不可能事件。
3、为什么转化为红黑树的阈值8和转化为链表的阈值6不一样 && 为什么转化为链表的阈值不是7
为了避免频繁来回转化。
这是遵循泊松分布,结点数达到6-8的概率,笔者认为基于时间与空间的权衡以及转化所消耗的资源,减少频繁的转化次数等考虑因素不选择7,如果是7,假设不断的增加和删除某个元素,那么就得不停的来回转化
6: 0.00001316
7: 0.00000094
8: 0.00000006

//有关参数说明

/**
* 默认长度 16
*/
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;

/**
* 最大长度 2的30次方
*/
static final int MAXIMUM_CAPACITY = 1 << 30;

/**
* 负载因子
*/
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;

2、构造方法

1、无参构造方法

public HashMap() {
   this.loadFactor = DEFAULT_LOAD_FACTOR; // 默认为0.75
}

2、带参传入数组长度,实际调用另一种构造方法

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

3、带参构造方法,传入数组长度及负载因子

public HashMap(int initialCapacity, float loadFactor) {
   if (initialCapacity < 0)//传入参数不符规则
       throw new IllegalArgumentException("Illegal initial capacity: " +
                                          initialCapacity);
   if (initialCapacity > MAXIMUM_CAPACITY)//传入参数超过最大容量限制,最大容量为2的30次方
       initialCapacity = MAXIMUM_CAPACITY;
   if (loadFactor <= 0 || Float.isNaN(loadFactor)) //传入参数不符规则
       throw new IllegalArgumentException("Illegal load factor: " +
                                          loadFactor);
   this.loadFactor = loadFactor;
   this.threshold = tableSizeFor(initialCapacity);//为数组初始化时服务,确保数组最终长度是2的幂次方,详情看扩容
}

4、传入一组数据

public HashMap(Map<? extends K, ? extends V> m) {
   this.loadFactor = DEFAULT_LOAD_FACTOR;
   putMapEntries(m, false);
}
final void putMapEntries(Map<? extends K, ? extends V> m, boolean evict) {
       int s = m.size();//元素个数赋值
       if (s > 0) {
           if (table == null) { // 数组为空
               float ft = ((float)s / loadFactor) + 1.0F;
               int t = ((ft < (float)MAXIMUM_CAPACITY) ?//判断是否小于最大容量
                        (int)ft : MAXIMUM_CAPACITY);//大于等于则设置为最大容量
               if (t > threshold)
                   threshold = tableSizeFor(t);//为数组初始化时服务,确保数组最终长度是2的幂次方,详情看扩容
           }
           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);
           }
       }
   }

数组默认长度DEFAULT_INITIAL_CAPACITY为16,也可以通过传参initialCapacity的方式规定数组长度。负载因子loadFactor 默认为0.75,threshold =initialCapacity x loadFactor即数组最多能承载的键值对数量,若超过这个数量会进行扩容。数组table真正初始化在第一次插入数据的时候进行判断与扩容

数组长度规定必须是2的幂次方,若传入的参数不是2的幂次方,会通过tableSizeFor方法转化为 大于传入参数的2幂次方。

如 若传进来的长度为10,则最终长度为 16;若传进来的长度为17,则最终长度为 32。

static final int tableSizeFor(int cap) { //假设cap为17 00010001
    int n = cap - 1;//保证了n的最右一位与cap的最右一位一定不同 n为16 00010000
    
    // n = 00010000 | 00001000 = 00011000
    n |= n >>> 1;
    
    //n = 00011000 | 00000110 = 00011110
    n |= n >>> 2;
    
    //n = 00011110 | 00000001 = 00011111
    n |= n >>> 4;
    
    //n = 00011111 | 00000000 = 00011111
    n |= n >>> 8;
    
    //n = 00011111 | 00000000 = 00011111 = 31
    n |= n >>> 16;
    
    //如果n < 0则数组长度为1,否则判断n是否大于最大容量MAXIMUM_CAPACITY,MAXIMUM_CAPACITY等于2的30次方;
    //大于等于则设置为等于最大容量,保证数组长度不会超过最大容量;小于则设置为n+1。
    //样例最终返回32
    return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}
为什么是2的幂次方

为了使得 得到的索引下标 在数组长度范围内,可以通过模运算,用 hash % 数组长度length,那么就可以保证下标在 0 - (length-1)范围内。计算机底层运算都是通过转化为二进制进行计算,因此如果通过二进制数与运算的效率会比十进制数模运算更快。

为了得到十进制数模运算相同的结果,length -1 与hash值进行与运算操作是能够保证得到的下标索引在数组长度范围内。

假设hash值为17,数组长度为16,十进制模运算 17 % 16 = 1;

二进制数 00010001 & 00001111 = 1;

结合二进制数的性质,hash值若超过16,那么16的倍数所在的位置一定在低四位左方,余数就必在低四位,因此通过 hash & length -1即可得到余数。

如果是其他数 如14 --> 二进制 00001110,这个数值与hash值与运算,那么1、3、5等数据是无法得到的,导致这些位置空缺,造成了空间浪费。

3、hash值

  • jdk 1.7 中 下标索引是直接计算 hashcode & length -1 得到结果

    这样会导致只有hashcode值的低位进行了运算,那么低位相同但高位不同的数据得到的索引相同,即哈希冲突。

  • 为了减少哈希冲突。jdk 1.8 在进行与运算之前,将hashcode值 与 右移16位的hashcode值进行异或运算,使高位也参与到这个环节。

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

假设 两个hashcode值 分别为 0000 0001 0000 1010,0000 0000 0000 1010;数组长度为16

使用jdk 1.7 的方法 分别得到的索引为 10 ,10

  • 0000 0001 0000 1010 & 0000 0000 0000 1111 = 0000 0000 0000 1010 = 10
  • 0000 0001 0000 1010 & 0000 0000 0000 1111 = 0000 0000 0000 1010 = 10

使用jdk 1.8 的方法 :
异或运算

  • 0000 0001 0000 1010 ^ 0000 0000 0000 0001 = 0000 0001 0000 1011
  • 0000 0000 0000 1010 ^ 0000 0000 0000 0000 = 0000 0000 0000 1010

再分别与运算

  • 0000 0001 0000 1011 & 0000 0000 0000 1111 = 0000 0000 0000 1011 = 11
  • 0000 0001 0000 1010 & 0000 0000 0000 1111 = 0000 0000 0000 1010 = 10

得到的索引下标不同

4、put方法

hashmap 通过 put 方法存储数据,以下是对 jdk 1.8版本 put 方法源码的解读及注释

  1. 判断数组是否为null 或 长度 是否为0 ,满足其一条件,进行扩容(数组在第一次put数据的时候进行初始化
  2. 将hash值 与运算 数组长度(length -1)得到索引下标,判断该下标的数组位置是否为null,null 则直接插入
  3. 不为空,则判断 该下标的结点与新结点的key值是否相同,相同则更新value值,并返回旧value
  4. 不相同则判断是否为树结构,是则放入树结构
  5. 不为树结构,则说明为链表结构,遍历链表判断是否有相同key值的结点,有则覆盖,并返回旧value;无则到达链表结尾,插入链表尾部。插入完毕,判断结点数是否大于等于8,转为红黑树结构
  6. 最终判断当前数组结点数,如果超过数组最多能承载的键值对数量(threshold),进行扩容。
public V put(K key, V value) {//调用putVal方法
        return putVal(hash(key), key, value, false, true);
}

//onlyIfAbsent 为true ,则若新插入的结点key值已存在,不改变其原有的value值 ,这里为false,即会更新value值
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
               boolean evict) {
    Node<K,V>[] tab; Node<K,V> p; int n, i;
    if ((tab = table) == null || (n = tab.length) == 0)//若数组长度为0则扩容
        n = (tab = resize()).length;
    if ((p = tab[i = (n - 1) & hash]) == null)//若得到的下标所在的空间为null,即没有数据在该位置
        tab[i] = newNode(hash, key, value, null);//直接赋值
    else {//该数组下标tab[i]有数据
        Node<K,V> e; K k;
        if (p.hash == hash &&
            ((k = p.key) == key || (key != null && key.equals(k))))//新结点与tab[i]节点的key值相同
            e = p;
        else if (p instanceof TreeNode)//新结点与tab[i]节点的key不相同 且 现在是红黑树结构
            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) // 若binCount大于等于7,即链表结点数大于等于8
                        treeifyBin(tab, hash);//该方法会判断数组长度是否小于64,是才会进行转化为红黑树
                    break;
                }
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))//在链表中存有相同key值的结点
                    break;
                p = e;
            }
        }
        if (e != null) { // 即没有到达链表结尾就停止了,即在链表中存有相同key值的结点
            V oldValue = e.value;
            if (!onlyIfAbsent || oldValue == null)
                e.value = value;
            afterNodeAccess(e);
            return oldValue;//返回旧结点的value值
        }
    }
    ++modCount;
    if (++size > threshold)//threshold = 数组长度x负载因子,整个数组结点数超过该数
        resize();//扩容
    afterNodeInsertion(evict);
    return null;
}

5、扩容

扩容 :

  • 数组初始化操作
  • 真正的扩容:一般来说,将数组大小扩大为原来的两倍,新建一个两倍大的数组,将原数组的存储数据复制到新数组;如果数组长度已经超过了最大长度 - - 2的30次方,则通过改变数组最多能承载的结点数threshold来进行扩容。

扩容的时候

  • jdk 1.7需要对原数组中的元素进行重新hash定位在新数组的位置

  • 1.8采用更简单的判断逻辑,位置不变或原索引+旧容量大小;

    元素在重新计算hash之后,因为n变为2倍,那么2n-1的二进制在高位比n-1多1,只需要看看原来的hash值新增的那个bit是1还是0就好了,是0的话索引没变,是1的话索引变成“原索引+n”

jdk 1.8 假设原数组大小为16 ,length -1 --> 00001111;

新数组大小为 32, length -1 --> 00011111

若原来的hash值为 00001010 ,原索引为 10,新索引为 10;

若原来的hash值为 00011010 ,原索引为 10,新索引为 10 + 16 = 26;

final Node<K,V>[] resize() {
    Node<K,V>[] oldTab = table;
    int oldCap = (oldTab == null) ? 0 : oldTab.length;//为null则赋值为0,否则赋值为原数组长度
    int oldThr = threshold;//数组最多能承载的结点数
    int newCap, newThr = 0;
    if (oldCap > 0) {
        if (oldCap >= MAXIMUM_CAPACITY) {//如果原数组大小已经超过最大容量-2的30次方
            threshold = Integer.MAX_VALUE; //将threshold更改,即通过改变数组最多能承载的结点数来进行扩容
            return oldTab;
        }
        else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                 oldCap >= DEFAULT_INITIAL_CAPACITY)//原数组的两倍小于最大容量且原数组长度大于等于16
            newThr = oldThr << 1; // 新threshold为两倍原来的threshold,数组新长度也为旧数组两倍
    }
    //以下else if 与 else语句是对数组进行初始化操作
    else if (oldThr > 0) // 说明构造方法中用户传入参数数组长度 最终对threshold赋值
        newCap = oldThr;//这是在对数组初始化时,将数组长度赋值为(处理过后的用户传入参数)
    else {               // 说明构造方法未对数组长度说明 ,即数组长度初始化为默认长度
        newCap = DEFAULT_INITIAL_CAPACITY;//16
        newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);// 16 x 0.75 = 12
    }
    
    if (newThr == 0) {//原数组长度小于16
        float ft = (float)newCap * loadFactor; 
        newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
                  (int)ft : Integer.MAX_VALUE);
    }
    threshold = newThr;//两倍原数组长度x负载因子
    
    @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;//将原数组的置为空
                if (e.next == null)//只有一个结点
                    newTab[e.hash & (newCap - 1)] = e;
                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;
                        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;
}

6、线程不安全

以put方法为例(在判断该下标的数组位置为null后,新结点直接插入)

线程1 在判断该下标索引的位置为null,此时线程2 也执行至此,同样判断为空,将新结点插入数组,线程2执行完毕;线程1 也将带着的新结点插入数组,最终导致的结果就是线程2所带的结点被覆盖。

hashmap 在对于线程不安全方面采取的措施是抛出异常 throw new ConcurrentModificationException()modCount会记录操作次数,如果操作前后该数值不符合期待值,则会抛出异常。

public final void forEach(Consumer<? super K> action) {
    Node<K,V>[] tab;
    if (action == null)
        throw new NullPointerException();
    if (size > 0 && (tab = table) != null) {
        int mc = modCount;//记录原数值
        for (int i = 0; i < tab.length; ++i) {
            for (Node<K,V> e = tab[i]; e != null; e = e.next)
                action.accept(e.key);
        }
        if (modCount != mc) //若期间有其他线程执行了增删等操作 致使modCount改变,即抛出并发修改异常
            throw new ConcurrentModificationException();
    }
}

Hashtable 线程安全

使用synchronized关键字来实现线程安全:即线程执行操作过程中,其他线程阻塞

  • 如果两个线程间的操作并没有冲突时(如插入的数据在不同的索引),线程1必须等线程2操作方法完毕才能进行操作,效率低。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值