HashMap源码put、get、resize操作、为什么HashMap不安全

什么是哈希表?

什么是哈希表参考博客
在讨论哈希表之前,先大概了解下数组和链表结构在新增、查找操作执行性能

数组:采用一段连续的存储单元来存储数据,用于储存多个相同类型数据的集合

  • 指定下标查找,时间复杂度为O(1)
  • 指定值查找,需要遍历数组,逐一比对给定关键字和数组元素,时间复杂度为O(n)
  • 对于有序数组,指定值查找,则可采用二分查找,插值查找,斐波那契查找等方式,可将查找复杂度提高为O(logn)
  • 对于插入删除操作,涉及到数组元素的移动,其平均复杂度也为O(n)

链表:在内存中通过节点记录内存地址而相互链接形成一条链的储存方式

  • 对于链表的新增、删除操作(在找到指定操作位置后),仅需处理结点间的引用即可,时间复杂度为O(1)
  • 查找操作需要遍历链表逐一进行比对,复杂度为O(n)

哈希表:通过数组+链表+红黑树的组成的存储结构

  • 相比数组和链表,哈希表中在进行添加、删除、查找等操作时,不考虑哈希冲突的情况下,仅需一次定位即可完成,时间复杂度为O(1)
  • 在有哈希冲突的情况下,根据查找操作需要遍历链表逐一对比

时间复杂度解释

时间复杂度参考博客

  • O后面的括号中有一个函数,指明某个算法的耗时/耗空间与数据增长量之间的关系,其中的n代表输入数据的量。
  • O(n)代表数据量增大几倍,耗时也增大几倍,比如常见的遍历算法
  • O(n^2),代表数据量增大n倍时,耗时增大n的平方倍,比如冒泡排序
  • O(logn),当数据增大n倍时,耗时增大logn倍
    • 这里的log是以2为底的,比如,当数据增大256倍时,耗时只增大8倍,是比线性还要低的时间复杂度
    • 二分查找就是O(logn)的算法,每找一次排除一半的可能,256个数据中查找只要找8次就可以找到目标
  • O(nlogn)同理,就是n乘以logn,当数据增大256倍时,耗时增大256*8=2048倍。这个复杂度高于线性低于平方。归并排序就是O(nlogn)的时间复杂度。
  • O(1)就是最低的时空复杂度了,也就是耗时/耗空间与输入数据大小无关,无论输入数据增大多少倍,耗时/耗空间都不变。 哈希算法就是典型的O(1)时间复杂度,无论数据规模多大,都可以在一次计算后找到目标(不考虑冲突的话)

HashMap底层结构

HashMap在jdk1.8之后是数组+链表+红黑树组成的数据结构(在jdk1.8之前是数组+链表)

数组结构: HashMap里面定义了一个内部类Node(jdk1.8之前叫做Entry,jdk1.8之后叫做Node),这个Node就是HashMap中数组结构的数据类型,意思就是说我们的key和value是存储在HashMap里的内部类Node中,而Node就存储在HashMap里的数组结构中,先看看Node类的源码,主要看里面定义的属性

    /**
     * Basic hash bin node, used for most entries.  (See below for
     * TreeNode subclass, and in LinkedHashMap for its Entry subclass.)
     */
    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) {
            V oldValue = value;
            value = newValue;
            return oldValue;
        }

        public final boolean equals(Object o) {
            if (o == this)
                return true;
            if (o instanceof Map.Entry) {
                Map.Entry<?,?> e = (Map.Entry<?,?>)o;
                if (Objects.equals(key, e.getKey()) &&
                    Objects.equals(value, e.getValue()))
                    return true;
            }
            return false;
        }
    }

可以看到Node类里不仅定义了Key和Value,它里面还定义了一个属性hash,还有指定下一个Node类的节点,所以Node节点实现了链表机构,每一个Node节点都是一个链表,当链表长度大于8且数组长度大于64的时候会转化为红黑树,转化为红黑树之后,长度小于6之后,又会转换为链表

再看看源码里定义的HashMap里的Node数组,也就是HashMap的数组结构

transient Node<K,V>[] table;

再看看HashMap里的主要参数

  1. 容量(capacity)
    必须是2的幂
//默认容量16
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;
//最大容量2的3次方
static final int MAXIMUM_CAPACITY = 1 << 30; 
  1. 加载因子(Load factor):HashMap在其容量自动增加前可达到多满的一种尺度
   // 实际加载因子,可以在初始化HashMap是自定义
  final float loadFactor; 
  // 默认加载因子 = 0.75
  static final float DEFAULT_LOAD_FACTOR = 0.75f; 
  1. 扩容阈值(threshold):值等于容量 x 加载因子,当哈希表的大小 ≥ 扩容阈值时,就会扩容哈希表,进行resize操作
int threshold;
  1. 数组相关
  //Node类型数组,长度 = 2的幂
  transient Node<K,V>[] table;  
  // HashMap的大小,即 HashMap中存储的键值对的数量
  transient int size;
  1. 桶的树化阈值,即当链表长度超过这个值,会将链表转换为红黑树
 static final int TREEIFY_THRESHOLD = 8; 
  1. 红黑树还原为链表的阈值
static final int UNTREEIFY_THRESHOLD = 6;
  1. 最小树形化容量阈值:即当哈希表中的容量 大于该值时,才允许将链表转换成红黑树,如果桶内元素达到,而没有哈希表的容量没有达到这个阈值的话,不转换成红黑树,而是直接扩容,
 static final int MIN_TREEIFY_CAPACITY = 64;
  1. 红黑树的实现类
static final class TreeNode<K,V> extends LinkedHashMap.Entry<K,V> {
	//代码太长,省略
}

put方法原理

初始化一个HashMap时,Node数组里的每一个元素的初始值都会Null,所以数据结构图如下:
注: 数组默认长度是16,这里方便演示,只画了8个
在这里插入图片描述
假设执行一个put(“a”,“1”)的操作
put方法源码:

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

可知当调用put方法时,会调用putVal方式,并且会把key进行hash操作,再把值传入到putVal中
hash源码:

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

putVal源码:

    final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
                   boolean evict) {
        Node<K,V>[] tab; Node<K,V> p; int n, i;
        //首先判断数组是否为空,为空则用resize方法初始化
        if ((tab = table) == null || (n = tab.length) == 0)
            n = (tab = resize()).length;
        /**通过i = (n - 1) & hash]计算出数组的下标,如果为空,即不存在hash冲突
        则直接newNode创建Node并赋值给tab[i]
        从这里也可以看到数组的下标位置还需要(n - 1) & hash]运算*/
        if ((p = tab[i = (n - 1) & hash]) == null)
            tab[i] = newNode(hash, key, value, null);
        else {
            Node<K,V> e; K k;
            //判断table[i]的key是否与需要插入的key相等,若相等,则直接覆盖旧值
            if (p.hash == hash &&
                ((k = p.key) == key || (key != null && key.equals(k))))
                e = p;
            //判断当前的数据是否是红黑树,若是,则直接在树中插入或者更新键值对
            else if (p instanceof TreeNode)
                e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
            //若是链表,则在链表中插入或者更新键值对
            else {
            	/**死循环遍历Node类的next节点,直至找出Node的下一个节点为Null的时候打破循环
            	这里的binCount设计得精妙,可以在元素插入之后,判断链表长度是否大于树化阈值
            	来决定是否要将当前链表转化成红黑树*/
                for (int binCount = 0; ; ++binCount) {
                	//判断当前节点的下一个Node引用为空,说明链表在这里到头了,则直接插入
                    if ((e = p.next) == null) {
                        p.next = newNode(hash, key, value, null);
                        //插入之后判断链表长度是否大于树化阈值,这里就是binCount定义得精妙之处
                        if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                            treeifyBin(tab, hash);
                        break;
                    }
                    //判断要插入的key是否和当前节点的key相等,相等则在这里打破循环,在下面的操作中会覆盖
                    if (e.hash == hash &&
                        ((k = e.key) == key || (key != null && key.equals(k))))
                        break;
                    //把e的值赋值给p,下一次遍历继续遍历p,相当于更新遍历了e
                    p = e;
                }
            }
            //若key相同直接用新值覆盖旧值
            if (e != null) { // existing mapping for key
                V oldValue = e.value;
                if (!onlyIfAbsent || oldValue == null)
                    e.value = value;
                afterNodeAccess(e);
                return oldValue;
            }
        }
        ++modCount;
        //插入成功后,判断实际存在的键值对数量是否大于最大容量threshold,若是,则需要扩容
        if (++size > threshold)
            resize();
        afterNodeInsertion(evict);
        return null;
    }

假定hash(“a”)的结果是2,那么就会将存储key为“a”,value为“1”的Node存储到数组中,结构图如下:
在这里插入图片描述

当插入越来越多的时候,总有可能会发生hash冲突,发生hash冲突之后,会判断当前节点是红黑树还是链表,是链表的话会调用equals方法对比key的值,如果相等就覆盖,如果不相等就会在链表的尾部插入进去,jdk1.8之后采用是尾插法,在jdk1.8之前采用的是头插法,稍后会有解释

假定现在执行put(“b”,2)操作时,发生了hash冲突,现在结构图如下:
在这里插入图片描述
继续执行put(“b”,3)操作,因为一之前执行了一个put(“b”,2),所以执行put(“b”,3)会覆盖以前的值,执行之后结构图如下:
在这里插入图片描述
流程图:
在这里插入图片描述

get方法原理

get方法源码:

   
   public V get(Object key) {
        Node<K,V> e;
        /**通过getNode方法去查找数据,三目运算符判断是否为null
        为null则返回null,不为null,则返回key的value属性值*/
        return (e = getNode(hash(key), key)) == null ? null : e.value;
    }

同样会对key进行hash运算,再把值传入getNode方法中,返回的值再进行三目运算,如果是null就返回null,否则返回他的值,再看看getNode源码:

    final Node<K,V> getNode(int hash, Object key) {
        Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
        //判断数据不为null的话,在根据hash&数组长度-1的值计算出数组下标
        if ((tab = table) != null && (n = tab.length) > 0 &&
            (first = tab[(n - 1) & hash]) != null) {
            //如果当前数组下标的hash值相等,并且equals对比也相等,则直接返回当前下标的值
            if (first.hash == hash && // always check first node
                ((k = first.key) == key || (key != null && key.equals(k))))
                return first;
            //判断当前节点的next值是否为空
            if ((e = first.next) != null) {
            	//判断数组结构是否为树化结构,是则在红黑树中找
                if (first instanceof TreeNode)
                    return ((TreeNode<K,V>)first).getTreeNode(hash, key);
                //遍历链表,在链表里面找
                do {
                    if (e.hash == hash &&
                        ((k = e.key) == key || (key != null && key.equals(k))))
                        return e;
                } while ((e = e.next) != null);
            }
        }
        return null;
    }

流程图 :
在这里插入图片描述

resize方法原理

jdk1.8源码

//两种情况会使用这个方法:1.当前数组容量过小 2.初始化hashmap时,即在put操作时,首先会判断table数组是否为null
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;
            }
            //若元数组长度增大一倍之后的值仍然小于最大阈值,则扩充为原来的2倍
            else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                     oldCap >= DEFAULT_INITIAL_CAPACITY)
                newThr = oldThr << 1; // double threshold
        }
        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);
        }
        //计算新的resize上限
        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"})
        //创建新的数组,长度为newCap
        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;
                        /**确认当前为链表结构,循环给取出链表的值并赋值给newTab
                        */
                        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;
    }

jdk1.7源码

/**
   * 源码分析:resize(2 * table.length)
   * 作用:当容量不足时(容量 > 阈值),则扩容(扩到2倍)
   */ 
   void resize(int newCapacity) {  
    
    /**将table的值赋值给oldTable, table为旧数组的值
    下面都是操作oldTable,即也是操作旧数组*/
    Entry[] oldTable = table;  

    // 保存旧数组长度的值
        int oldCapacity = oldTable.length; 

    // 若旧容量已经是系统默认最大容量了,那么将阈值设置成整型的最大值,退出    
    if (oldCapacity == MAXIMUM_CAPACITY) {  
        threshold = Integer.MAX_VALUE;  
        return;  
    }  
  
    // 根据新的数组长度值newCapacity,创建新数组newTable 
    Entry[] newTable = new Entry[newCapacity];  

    // 将扩容后的新数组,传给transfer方法,在transfer方法里面完成对newTable的重新赋值
    transfer(newTable); 

    // 将新数组newTable,也是resize完成后的数组的值赋值给table,这里即完全完成了hashmap的resize过程
    table = newTable;  

    // 重新设置阈值  
    threshold = (int)(newCapacity * loadFactor); 
} 

 /**
   * 分析1.1:transfer(newTable); 
   * 作用:将旧数组上的数据(键值对)转移到新table中,从而完成扩容
   * 过程:按旧链表的正序遍历链表、在新链表的头部依次插入
   */ 
void transfer(Entry[] newTable) {
      // src引用了旧数组
      Entry[] src = table; 

      // 获取新数组的容量大小                 
      int newCapacity = newTable.length;

      // 遍历旧数组,将旧数组上的数据(键值对)转移到新数组中
      for (int j = 0; j < src.length; j++) { 
          // 取得旧数组坐标为j的元素,赋值给e 
          Entry<K,V> e = src[j];
          //判断e不等于null,即开始转移当前Entry数据到新数组newTable         
          if (e != null) {
              // 释放旧数组的对象引用(for循环后,旧数组不再引用任何对象)
              src[j] = null; 
			  //jdk1.7数据结构没有红黑树,所以这里直接循环遍历节点,再保存数据到新数组
              do {                 
                  /**保存下个结点的到next,在本次循环结束的时候赋值给e
                  相当于会再给e的下一个节点重新插入到新数组,以此往复*/
                  Entry<K,V> next = e.next; 
                 // 重新计算每个元素的存储位置
                 int i = indexFor(e.hash, newCapacity); 
              
                 /**这里最难理解,这里是标准的头插法实现方式
                 e.next = newTable[i]是将当前节点e的next属性指向newTable[i],
                 newTable[i]如果为null,那么当前e就是第一个插入当前数组下标的元素,他不会指向别的的Entry节点
                 newTable[i]不为null的话,当前e的next属性就是指向当前数组下标的元素,这时链表结构就完成了
                 接着马上执行newTable[i] = e,即把当前数组下标的引用指向了当前e
                 那么旧的newTable[i]就不在数组结构里了,会以链表的形式和当前e链接在一起*/
                 e.next = newTable[i];
                 newTable[i] = e;  
           		 //将刚刚e的next属性赋值给e,下面又会重新对e进行操作
                 e = next;     
              //循环遍历链表,直至当前e的为null,即上面的next属性为null,即链表遍历到头了       
             } while (e != null);
         }
     }
 }

为什么HashMap线程不安全?

  • 在jdk1.7中,因为是采用的头插法,多个线程对HashMap进行put操作并且需要扩容时,可能会导致链表成环状态
  1. 假设A线程、B线程都put成功后的结构图如下,且两个线程都走到了resize方法,A线程走到了newTable[i] = e; 这一步,此时e=Entry3,next=Entry2,A线程cpu时间片耗尽,B线程开始执行,并且直接完成了resize过程
    在这里插入图片描述

  2. B线程完成resize过程后的结构图
    在这里插入图片描述

  3. 此时A线程继续执行,这时A线程是以B线程resize过后的新数组为原数组进行遍历了,此时e=Entry3,next=Entry2,A线程执行一步后的结构如下
    在这里插入图片描述

  4. 相当于Entry3和Entry2换了一个位置,因为是头插法,这时因为在旧数组里面Entry2的next是指向Entry3的,所以这里又会操作一遍Entry3,所以当把Entry2用头插法移到上面去的时候,在新数组里面本身Entry3的next就是指向Entry2的,这时Entry2又指向了Entry3,所以就形成了环状
    在这里插入图片描述

  5. 所以这时环状出现了,如果执行get操作时,根据key得到的数组下标是当前数组下标,而且这个key在链表里面不存在的话,就会出现死循环

  • jdk1.8中,如果A、B线程同时进行put操作,并且两个线程的key计算出来的数组下标是一样的,而且这个数组下标里没有元素,两个线程同时走到了判断当前数组下标是否有元素的判断,此时A线程停下来了,B线程直接把元素插入进去了,之后A线程继续走的时候,直接就把自己元素插入进入了,会导致了B线程插入的值被覆盖了。
    在这里插入图片描述

key为null存放到哪?

jdk1.8

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

jdk1.7

if (key == null)
             // key为null调用putForNullKey(value)
             return putForNullKey(value);
  private V putForNullKey(V value) {
        for (Entry<K,V> e = table[0]; e != null; e = e.next) {
            if (e.key == null) {
                V oldValue = e.value;
                e.value = value;
                e.recordAccess(this);
                return oldValue;
            }
        }
        modCount++;
        addEntry(0, null, value, 0);
        return null;

所以当key为null的时候,数组下标都是0,再进行操作

HashMap的长度为什么要是2的n次方

计算数组下标的方法是:hash&(length-1),实际上和取模运算hash%length的结果是一样,这里使用&运算是因为计算机底层本身就是二进制,所以使用&运算的快一些,前提也就是长度需要为2的n次方,另外也是为了减少hash冲突

  • 1
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值