HashMap底层实现、线程安全问题

一、HashCode原理

1、HashCode特性

  • HashCode的存在主要是用于查找的快捷性,主要用于HashMap、Hashtable中,用来确定对象的存储地址
  • 如果两个对象相同,equals()一定返回true,并且两个对象的HashCode一定相同
  • 两个对象的HashCode相同,并不一定表示两个对象就相同,只能说明这两个对象在一个散列存储结构中
  • equals()重写也需要同时重写HashCode()

2、Hash算法(散列算法)

  • Hash常用于java集合中,Set作为一种无序且无重复元素的集合,如果每次添加元素从头equals遍历,则效率很低,因此采用Hash表存储元素
  • Hash算法可以将一个元素直接映射到一个地址上,这样集合要添加新的元素时,通过HashCode可以知道这个元素的物理地址,进行存储:
    • 如果这个物理地址没有元素,则直接存储
    • 如果已经有元素,就调用equals方法,如果相等就不存储
    • 如果equals也不想等,就会产生Hash冲突,即两个不同的元素得到了同一个存储地址
  • Hash冲突的解决方法
    • 开放定址法
      • 当冲突发生时,使用某种探查技术在散列表中形成一个探查序列。沿此序列逐个单元地查找,直到找到给定的关键字,或者碰到一个开放的地址(即该地址单元为空)为止(若要插入,在探查到开放的地址,则可将待插入的新结点存入该地址单元)。
      • 哈希表越来越满时数据插入非常耗时,因此设计Hash表确保元素数不超过容量的一半,最多不超过2/3
      • 开放定址方法按照寻址方法可以分为:
        - 1)线性探测:在原来值的基础上往后加一个单位,直至不发生哈希冲突
        - 2)在平方探测:在原来的值上先加1的平方个单位,若仍然冲突则减1平方个单位,随之是2的平法,3的平方
        - 3)伪随机探测:在原来值基础上加上一个随机函数生成的数,直至不发生哈希冲突
    • 链地址法(拉链法),也是目前HashMap使用的方法
      在这里插入图片描述
      • 将具有相同HashCode的值根据插入顺序,形成链表,链表的头节点地址放在Hash表中
      • 生成链表需要额外的空间,需要花费精力和时间维护链表,扩容的时候需要reHash

二、HashMap原理

  • HashMap采用拉链式存储实现,即使用链表处理Hash冲突,但是当同一个Hash值的链表数量过多时,通过key值查询的效率较低,因此JDK1.8中,当链表数量超过8个时,将链表转换为红黑树

1、HashMap的相关属性和构造函数

static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // 默认的初始化容量16
static final int MAXIMUM_CAPACITY = 1 << 30;//最大的容量2^29
static final float DEFAULT_LOAD_FACTOR = 0.75f;//默认的加载因子(填充比)
static final int TREEIFY_THRESHOLD = 8;//链的长度大于8则转换为红黑树
static final int UNTREEIFY_THRESHOLD = 6;//链的长度小于6转换为链表
static final int MIN_TREEIFY_CAPACITY = 64;//当集合中容量大于64时,才会转为树,否则只需要resize

        //构造函数1:(initialCapacity:初始容量,loadFactor:填充比)
        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);//容量阈值=填充比*初始化容量
    }

    //构造函数2:(初始化容量)
    public HashMap(int initialCapacity) {
        this(initialCapacity, DEFAULT_LOAD_FACTOR);//填充比使用默认填充比然后调用构造函数1
    }

    //构造函数3:无参构造器
    public HashMap() {
        this.loadFactor = DEFAULT_LOAD_FACTOR; // 填充比赋值,其他也均使用默认值
    }

    //构造函数4:(使用Map m的元素初始化)
    public HashMap(Map<? extends K, ? extends V> m) {
        this.loadFactor = DEFAULT_LOAD_FACTOR;
        putMapEntries(m, false);
    }

2、HashMap的实现原理

1)HashMap添加的元素是一个Entry(Key-Value对),因此首先需要有一个的元素类型的Hash表

在这里插入图片描述

2)添加元素时,HashMap会根据Key的值,计算hash值,进一步计算出该元素在Hash表中对应的索引:

    public V put(K key, V value) {
        return putVal(hash(key), key, value, false, true);
    }

    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)
            n = (tab = resize()).length;
        if ((p = tab[i = (n - 1) & hash]) == null)//索引
            tab[i] = newNode(hash, key, value, null);
        else {
            ………………
        }
        ++modCount;
        if (++size > threshold)
            resize();
        afterNodeInsertion(evict);
        return null;
    }
  • 从源码中可以看出,HashMap对于一个Key的索引计算是通过tab[i = (n - 1) & hash]进行的,也就是说该元素的索引是i = (n - 1) & hash,其中n为hash表长度,传递进putVal()中的hash值是在put()方法中的hash(key)得到,hash()方法的源码如下:
static final int hash(Object key) {
        int h;
        return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
    }
  • 同时, key.hashCode()调用的是Object类下的hashCode()方法:
public native int hashCode();
  • 其返回值是一个int类型的整型数,32位
  • 因此若添加的一个元素中Key通过Object类hashCode()方法得到的hash值为h,则该元素在HashMap中的hash表索引为:i = (n - 1) & (h^(h>>>16)),其中n为hash表长度
  • 那么,为什么HaspMap中hash值需要重新进行这么多的计算呢?
    • 首先分析i = (n - 1) & hash,在一般的hash算法实现中,通常通过对hash表长度取余实现,即hash%n,而对于HashMap,其长度n是2的n次幂,两种计算方式是等同的,并且位运算更快:

假设a=10,b=8=23
a%b:二进制中右移等同于/2,因此对2n取余,就是将原来的二进制数右移n位,剩下的数是做除法后的值,移位出来的值是余数,10%8=1010>>3移位丢弃的数=010
a&(2n-1): 2n-1比 2n少一位,并且全为1,并且一个二进制数&全为1的结果是其本身,因此这个&运算结果就是 a的位于2n-1的位数的二进制数,与取余过程中右移个数也相等, 10&7=10&(2n-1)=1010&0111=010

  • 但是,取余和与运算均存在一种缺陷,就是计算结果对高位是无效的,只对低位有效,当i = (n - 1) & hash中的hash只有高位变化时,取余结果不变,如:

0111 0101和0101 0101 的结果是一样的
0111 0101&1111=101
0101 0101&1111=101

  • 这种缺陷会导致当所有的key值中,只要后四位一致,其hash索引均是一致的,会带来很大的hash冲突,而这种现象就说明了HashMap为什么要重新计算Object中hashCode()得到的hash:
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
  • 前面已经说明key.hashCode()返回的是一个int值,具有32位,本段代码中需要把int值右移16位,将高16位变为低16位,这里使用8位int,右移4位来说明其原理:

假设有两个后四位相同的较大值0111 0101和0101 0101,以及一个较小的值0110来计算hash
右移4位: 0111 0101>>4 -> 0111,0101 0101>>4 -> 0101,0110>>4 -> 0000
与原来的值作异或:0111 0101 ^ 0111 -> 0111 0010,0101 0101 ^ 0101 -> 0101 0000,0110 ^ 0000 -> 0110

  • 因此,通过HashMap的重新计算hash,能够将高16位的变化影响到低16位的变化,减少了大量的hash冲突

3)如果hash数组中,该元素的索引位置没有元素,就把该元素放在hash数组中,如果存在元素,则把已存在的元素作为链表的头节点,添加元素依次往后放,这么一条链表上的元素,其hash值是相同的

4)当一个链表长度过长,其检索性能也会降低,新的版本中会把链表长度大于8的链表转换为红黑树,除此之外也会通过将hash数组扩容的方法来提高效率

  • hash数组会有一个初始容量,及一个系数(默认是0.75),当hash数组容量超过初始容量的0.75时,会将hash数组扩大两倍(resize()),然后将之前存的元素rehash()到新的hash数组中,这个过程中会改变索引,会把以往较长的链表重新打散存储在新的hash数组中

3、HashMap的resize()

final Node<K,V>[] resize() {
        //扩容前的参数复制
        Node<K,V>[] oldTab = table;
        int oldCap = (oldTab == null) ? 0 : oldTab.length;
        int oldThr = threshold;
        int newCap, newThr = 0;
        //如果旧表不为空
        if (oldCap > 0) {
            //旧表长度超出最大容量
            if (oldCap >= MAXIMUM_CAPACITY) {
                threshold = Integer.MAX_VALUE;
                return oldTab;
            }
            //新表赋值长度是旧表的两倍(newCap = oldCap << 1)
            else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                     oldCap >= DEFAULT_INITIAL_CAPACITY)
                newThr = oldThr << 1; // 容量阈值也同步放大两倍
        }
        //如果旧表为空,就开始第一次初始化表
        else if (oldThr > 0) 
            newCap = oldThr;
        else {               
            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;

        //构造新表,并把旧表中的所有元素rehash
        @SuppressWarnings({"rawtypes","unchecked"})
        Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
        table = newTab;
        if (oldTab != null) {
            //hash数组的遍历
            for (int j = 0; j < oldCap; ++j) {
                Node<K,V> e;
                if ((e = oldTab[j]) != null) {
                    oldTab[j] = null;//旧表中删去元素
                    //如果旧表中该索引只有一个元素,即链表长度为1
                    if (e.next == null)
                        //在新表中重新计算该元素的hash索引
                        newTab[e.hash & (newCap - 1)] = e;
                    //如果该hash索引下是否包含多个元素
                    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) == 0为一队,!=0为一队
                            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;
    }

三、HashMap与多线程

1、HashMap的线程不安全场景

1)多线程的put()

  • HashMap中put一个(K,V)元素的过程是先根据K的hash值,计算得到在hash数组中的索引位置,然后判断该位置是否已经有元素,如果没有,就放置在该位置,如果有,则放置在该链表的尾部
  • 如果两个线程同时put两个元素,这两个元素具有相同的Hash索引,就会产生线程不安全问题
  • 假如该Hash对应索引处有一个元素A,并且A.next=null,则线程安全的结果应该是将两个元素均放置在A的链表上,顺序不影响
  • 但是如果线程T1和线程T2同时得到了一个信息:这个索引处A.next=null,那么两个线程都会执行A.next=(K,V)这么一步,会导致一个元素的丢失,具体如下图:
    在这里插入图片描述
    在这里插入图片描述

2)put和get并发时,可能导致get为null

  • 如果线程T1put一个元素,发现需要resize,会在以下代码处产生线程不安全
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
table = newTab;
  • table重新创建时,是一个空的数组,此时如果其他线程使用get()时,会得到null

3)JDK7中HashMap并发put会造成循环链表,导致get时出现死循环

  • JDK8中resize后的rehash是先把旧的链表分为两个单链表,然后把链表头放在对应的hash数组索引位置,避免了这个线程不安全问题
  • 但是JDK之前的rehash是通过对整个HashMap的遍历,依次将每个元素rehash到新的hash数组中,会带来这个线程不安全问题
  • JDK8以前的rehash方法源码:
    void transfer(Entry[] newTable, boolean rehash) {
        int newCapacity = newTable.length;
        for (Entry<K, V> e : table) { // table变量即为旧hash表
            while (null != e) {
                Entry<K, V> next = e.next;
                if (rehash) {
                    e.hash = null == e.key ? 0 : hash(e.key);
                }
                int i = indexFor(e.hash, newCapacity);
                e.next = newTable[I];
                newTable[i] = e;
                e = next;
            }
        }
    }

关于JDK8以前多线程put的线程安全问题可以看这里

2、线程安全的HashMap==>ConcurrentHashMap

先埋个坑,脑子有点不够用了

  • 0
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值