Java集合之HashMap源码分析

本文详细解析了HashMap的工作原理,包括其存储结构、源码分析、扩容机制等。探讨了HashMap为何选择2的幂次作为数组长度,解释了散列冲突解决方法,并介绍了如何自定义类作为key。

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

1. 引言

HashMap在平时工作中使用非常多,非常重要,之前对HashMap有做过研究,但是再次阅读其源码有不一样的理解。

问题:

  1. HashMap默认初始值和装载因子多少?
  2. HashMap中table的初始化时间?table的最大长度?
  3. HashMap中阈值的作用?
  4. hashMap解决散列冲突的方式?还有那些方式?
  5. 树化的条件 ?
  6. 扩容的过程?
  7. 为什么table的大小为2的幂次?与扩容有何关系?

2. 继承体系

Alt

1.HashMap实现了Serializable接口,可以被序列化

2.HashMap实现了Cloneable接口,可以被克隆

3.HashMap继承自AbstractMap类,实现了Map接口

3. 存储结构

(图片来源于网络)
Alt

HashMap采用了(数组 + 链表 + 红黑树)的实现结构,数组的一个元素称作为

数组中的每一个元素值为链表,此链表为单向链表,jdk1.8之后使用的是红黑树的存储体系

在添加元素时,会根据key的hash值计算出元素在数组中的位置,如果该位置中没有元素,则直接把元素存储在此位置,如果该位置有元素了,则把元素以链表的形式存放在链表的尾部。

4. 源码解析

4.1. HashMap属性

    //默认的初始容量为16
    static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16

    //最大的容量为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;

    //数组,元素称为桶
    transient Node<K,V>[] table;
   
    transient Set<Map.Entry<K,V>> entrySet;
 
    transient int size;


  • Node内部类
    Node是一个单向链表节点
static class Node<K,V> implements Map.Entry<K,V> {
        final int hash;//存储key的hash值
        final K key;
        V value;
        Node<K,V> next;
        ......
}
  1. 数组容量:
    数组的容量默认为16个,最大为2的30次方

  2. 装载因子:
    装载因子用来计算容量达到多少时进行扩容,默认为0.75

  3. 树化:
    当容量达到64个且链表的长度达到8个时才进行树化,当链表小于6个时才进行反树化

4.2. 构造方法

  1. HashMap()构造方法
 public HashMap() {
        this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
    }
  1. HashMap(int initialCapacity)构造方法
public HashMap(int initialCapacity) {
        this(initialCapacity, DEFAULT_LOAD_FACTOR);
    }
  1. HashMap(int initialCapacity, float loadFactor)构造方法
public HashMap(int initialCapacity, float loadFactor) {
    //判断初始容量是否合法
    if (initialCapacity < 0)
        throw new IllegalArgumentException("Illegal initial capacity: " +
                                            initialCapacity);
    //判断初始容量是否大于最大容量,如果大于则初始化为最大容量 1 << 30
    if (initialCapacity > MAXIMUM_CAPACITY)
        initialCapacity = MAXIMUM_CAPACITY;
    //判断装载因子是否合法
    //isNan()方法判断是否为数字
    if (loadFactor <= 0 || Float.isNaN(loadFactor))
        throw new IllegalArgumentException("Illegal load factor: " +
                                            loadFactor);
    
    this.loadFactor = loadFactor;
    //计算扩容门槛
    this.threshold = tableSizeFor(inistialCapacity);
}

数组容量的计算

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

通过位运算达到数组的大小为2的幂次

4.3. put( K key,V value)方法

put(k key, V value)

public V put(K key, V value) {
    //1.hash(key):计算key的hash值
    return putVal(hash(key), key, value, false, true);
}

final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
                   boolean evict) {
        //tab与p的关系:tab为数组,代替table,p为桶,即tab中的元素,也就是key所在的链表
        Node<K,V>[] tab; //创建新的tab,代替table
        Node<K,V> p; //创建p节点,此节点代表的是新添加的元素所在的桶
        int n, i;
        
        //2.如果桶的个数为0或者桶为null则进行桶的初始化
        if ((tab = table) == null || (n = tab.length) == 0)
            n = (tab = resize()).length;//n为桶中的元素个数
        
        //3.如果tab中还没有这个元素,则将这个元素添加在第一个位置
        //(n-1) & hash:计算元素在那个桶中
        if ((p = tab[i = (n - 1) & hash]) == null)
            //新建节点
            tab[i] = newNode(hash, key, value, null);
        
        //如果tab中已经存在元素
        else {
            Node<K,V> e; //用于临时保存key相同的值
            K k;
            //4.如果桶中第一个元素的key值与待插入的元素的key值相同,则保存到e用于后续修改value值
            if (p.hash == hash &&
                ((k = p.key) == key || (key != null && key.equals(k))))
                e = p;
            
            //5.如果第一个元素p是树节点,则调用树节点的putTreeVal方法插入元素,此时链表为红黑树
            else if (p instanceof TreeNode)
                e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
            //6.遍历整个桶对应的链表
            else {
                for (int binCount = 0; ; ++binCount) {
                    //8.如果遍历完整个链表都没有找到相同的key元素,说明key对应的元素不存在,则在链表最后插入一个新的节点
                    if ((e = p.next) == null) {
                        p.next = newNode(hash, key, value, null);
                        //如果插入新节点之后链表长度大于8,则判断是否需要进行树化
                        if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                            treeifyBin(tab, hash);
                        break;
                    }
                    //如果待插入的key在链表中找到了则退出循环
                    if (e.hash == hash &&
                        ((k = e.key) == key || (key != null && key.equals(k))))
                        break;
                    p = e;
                }
            }

            //9.如果找到了对应的key的元素
            if (e != null) { // existing mapping for key
                //记录下旧值
                V oldValue = e.value;
                //判断是否需要替换旧值
                if (!onlyIfAbsent || oldValue == null)
                    e.value = value;
                afterNodeAccess(e);
                return oldValue;
            }
        }

        ++modCount;
        //10.判断是否需要扩容
        if (++size > threshold)
            //扩容函数
            resize();
        afterNodeInsertion(evict);
        //如果没有找到元素则返回null
        return null;
    }

4.4. 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;//声明新的容量和阈值
    //如果就数组的容量大于0
    if (oldCap > 0) {
        //如果旧数组的容量大于等于最大容量
        if (oldCap >= MAXIMUM_CAPACITY) {
            threshold = Integer.MAX_VALUE;//设置阈值为Integer的最大值
            return oldTab;//返回旧的数组
        }
        else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                    oldCap >= DEFAULT_INITIAL_CAPACITY)
            newThr = oldThr << 1; //旧数组阈值左移动一位,2的幂次方,设置为新的数组的阈值
    }
    //如果旧的阈值大于0,更新为
    else if (oldThr > 0) // initial capacity was placed in threshold
        newCap = oldThr;
    else {               // zero initial threshold signifies using defaults
        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) {
        //遍历旧的数组,填充到新数组中
        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 { // preserve order
                    //如果不为红黑树
                    //不需要重新计算的新链表
                    Node<K,V> loHead = null, loTail = null;
                    //需要重新计算的新链表
                    Node<K,V> hiHead = null, hiTail = null;
                    Node<K,V> next;
                    do {
                        next = e.next;
                        /**
                       	oldCap为2的幂次,二进制最高位为1,其他位均为0
                       	则e.hash在oldCap的最高位不为1的元素下标不变
           				*/
                        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;
}
  • 注意

    1、在填充新的数组时,链表与红黑树的重新分配方式不一致
    2、如果为单个数组直接计算
    3、链表的分配与2的幂次有关
    

5.哈希值的获取方式和table下表的计算

//获取哈希值
static final int hash(Object key) {
    int h;
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);// ^ 进行异或运算
}

public native int hashCode();

//获取下标
......
     if ((p = tab[i = (n - 1) & hash]) == null) //n为数组长度
......

HashMap中table的长度为什么是2的幂次?

数组长度为2的幂次,转化为二进制后最高位恒为1,其他位为0;减1后最高位为0,其他为恒为1;当与hashCode进行与运算时不会出现某些位永远取不到

如果数组长度不为2的幂次,二进制减1后必然存在某个位为0,与hashCode进行与运算后此位永远为0,则必然存在hashCode此位为1的下标值永远取不到,造成浪费。

而且在扩容运算时需要重新计算下标值,由于数组长度为2的幂次,通过通过位运算可减少计算次数,提高性能。

总之,不为2的幂次时存在弊端,为2的幂次时使其节点可以均匀分布,并且在扩容时可以提高性能

6. HashMap总结

  1. table的初始化时间?

HashMap在创建对象的时候会计算table的初始值,而在第一次put的时候进行扩容,在扩容的过程中初始化table

  1. table的最大长度?

    2的30次方(并不是Integer.MAX_VALUE,而是一半)

6.1. 如果HashMap的key是一个自定义的类该怎么办?

Object类的equals()方法是根据类的引用值判断两个值是否相同,在具体的使用过程中要根据实际情况进行重写,因为equals()方法与hashCode()方法是相互一致的,当equals()方法判断两个类相同为true时,则他们的散列值必定是相同的,因此要重写hashCode()方法.

key为String的情况:

如果key为String时,在进行存储时会根据String的hashCode()方法获取散列值和equals()方法判断key值是否唯一,可知String的equals()方法被重写了,是根据String的内容进行判断是否相同

Key为自定义类的情况:

如果key为自定义的类,则要重写自定义类的hashCode()方法和equals()方法,来达到key的唯一性,在HashSet中也是如此,重写类的hashCode()和equals()方法来保证HashSet类中的元素无重复.

7. 散列冲突解决方式

  1. 链地址法
    jdk1.8中HashMap的散列冲突解决方式就是使用链地址法

  2. 再哈希法
    再哈希法就是当出现散列冲突时使用另一个哈希函数计算哈希值,一般有多个哈希函数以供备用,直到找到不冲突的哈希地址.

  3. 建立公共溢出区
    思想是将哈希表分为基本表和溢出表,当出现散列冲突时,将出现冲突的元素存入到溢出表中.

  4. 开方地址法
    开放地址法思想是在出现散列冲突时根据一个计算公式再次计算哈希地址,这里声明哈希函数只有一个,改变的是key的值

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值