Java集合-HashMap

1、HashMap 1.7与1.8区别

2、HashMap结构

JDK1.7采用的是数组+链表的形式,而JDK1.8采用的是数组+链表+红黑树在数组容量大于64且链表长度大于8的情况下会使用红黑树,当长度小于6后又将从红黑树转化为链表。

  因为在链表的查询操作都是O(N)的时间复杂度,而且hashMap中查询操作也是占了很大比例的,如果当节点数量多,转换为红黑树结构,那么将会提高很大的效率,因为红黑树结构中,增删改查都是O(log n)。

HashMap一些关键常量:

static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; //默认初始容量
static final int MAXIMUM_CAPACITY = 1 << 30;  //最大容量2^30
static final float DEFAULT_LOAD_FACTOR = 0.75f; //默认加载因子 也就是0.75F
static final int TREEIFY_THRESHOLD = 8;  // 链表长度大于8时树化,数据8是由hashmap作者计算而出
static final int UNTREEIFY_THRESHOLD = 6;  //还原成链表的阈值
static final int MIN_TREEIFY_CAPACITY = 64;//最小树化容量

1、默认加载因子0.75

  • 如果负载因子为0.5甚至更低的话,最后得到的临时阈值明显会很小,这样会造成内存的浪费,也满足不了哈希表均匀分布的情况。
  • 如果负载因子达到了1的情况,也就是Node数组存满了才发生扩容,这样会出现大量的哈希冲突的情况,出现链表过长,因此造成get查询数据的效率很低。
  • 因此选择了0.5~1的折中数也就是0.75,均衡解决了上面出现的情况。

作为一个常规的规则,0.75是时间和空间之间的一种平衡。更高的值减少了空间开销,但增加了查找成本(反映在HashMap类的大多数操作中,包括get和put)。

0.75的数学依据:

假设一个bucket空和非空的概率为0.5,我们用s表示容量,n表示已添加元素个数。

用s表示添加的键的大小和n个键的数目。根据二项式定理,桶为空的概率为:

P(0) = C(n, 0) * (1/s)^0 * (1 - 1/s)^(n - 0)

因此,如果桶中元素个数小于以下数值,则桶可能是空的:

log(2)/log(s/(s - 1))

当s趋于无穷大时,如果增加的键的数量使P(0) = 0.5,那么n/s很快趋近于log(2):

log(2) ~ 0.693...

所以,合理值大概在0.7左右。

根据HashMap的扩容机制,他会保证capacity的值永远都是2的幂。

为了保证负载因子(loadFactor) * 容量(capacity)的结果是一个整数,这个值是0.75(3/4)比较合理,因为这个数和任何2的幂乘积结果都是整数。

3、HashMap是否线程安全

线程安全集合线程不安全集合
Vector(比Arraylist多了个同步化机制)、Stack(栈,继承于Vector)Arraylist
Hashtable(比Hashmap多了个线程安全)、ConcurrentHashMap(一种高效但是线程安全的集合)Hashmap
-LinkedList
-HashSet
-TreeSet
-TreeMap

我们都知道HashMap是线程不安全的,线程不安全,现象表现为:

  • 并发put碰撞导致数据丢失:多线程同时put时,如果计算出来的hashcode相同就会放在数组的同一位置,可能造成数据丢失。
  • 并发put扩容导致数据丢失:多线程同时put发现同时需要扩容,扩容动作涉及到把数组拷贝到新数组中,扩容完成后只会有一个数组被保留下来,也就可能造成数据丢失。
  • 并发put导致死循环CPU100%:多线程并发扩容时导致循环链表。

分析JDK7源码

扩容时新建一个更大尺寸的hash表,然后把数据从老的Hash表中迁移到新的Hash表中

void resize(int newCapacity)
{
    Entry[] oldTable = table;
    int oldCapacity = oldTable.length;
    ......
    //创建一个新的Hash Table
    Entry[] newTable = new Entry[newCapacity];
    //将Old Hash Table上的数据迁移到New Hash Table上
    transfer(newTable);
    table = newTable;
    threshold = (int)(newCapacity * loadFactor);
}

下面是迁移的代码:

void transfer(Entry[] newTable)
{
    Entry[] src = table;
    int newCapacity = newTable.length;
    //下面这段代码的意思是:
    //  从OldTable里摘一个元素出来,然后放到NewTable中
    for (int j = 0; j < src.length; j++) {
        Entry<K,V> e = src[j];
        if (e != null) {
            src[j] = null;
            do {
                Entry<K,V> next = e.next;
                int i = indexFor(e.hash, newCapacity);
                e.next = newTable[i];
                newTable[i] = e;
                e = next;
            } while (e != null);
        }
    }
}

我们都知道HashMap是非线程安全的,上面这段代码在多线程的情况下会导致HashMap死循环,我们来演示下这个过程,最上面的是old hash 表,其中的Hash表的size=2, key = 3, 7, 5,现在要进行扩容了,假设我们有两个线程,线程二执行完成了,但是未退出while循环:

线程一现在开始执行,先是执行 newTalbe[i] = e,然后是e = next,导致了e指向了key(7),而下一次循环的next = e.next导致了next指向了key(3),再看下一步 

环形链接出现,线程一中e.next = newTable[i] 导致 key(3).next 指向了 key(7)
注意:此时线程二中的key(7).next 已经指向了key(3), 环形链表就这样出现了。

1.8解决死循环方案

  • JDK8对HashMap死循环的解决方法是:扩容后,新数组中的链表顺序依然与旧数组中的链表顺序保持一致。
  • JDK8虽然修复了死循环的bug,但是HashMap 还是非线程安全类,仍然会产生数据丢失等问题,线程安全类还是使用ConcurrentHashMap

4、HashMap扩容机制

扩容的时机

存在二种情况:

  • 设定threshold, 当threshold = 默认容量(16) * 加载因子(0.75)的时候,进行resize()
  • 如上文所说,treeifyBin首先判断当前hashMap的长度,如果不足64,只进行resize,扩容table,同时最小树形化容量阈值:即 当哈希表中的容量 > 该值时,才允许树形化链表

扩容原理

扩容一般是把长度扩为原来2倍,所以,元素的位置要么是在原位置,要么是在原位置再移动2次幂的位置。

这里有一个tableSizeFor方法,主要作用是会return一个大于给定整数的2的幂次方树,例如给定10,就会返回16,通过位运算可以验证

在这里插入图片描述
至于为什么一定要是2的幂次方呢?

static int indexFor(int h, int length) {
    // assert Integer.bitCount(length) == 1 : "length must be a non-zero power of 2";
    return h & (length-1);
}

看下图,左边两组是数组长度为16(2的4次方),右边两组是数组长度为15。两组的hashcode均为8和9,但是很明显,当它们和1110“与”的时候,产生了相同的结果,也就是说它们会定位到数组中的同一个位置上去,这就产生了碰撞,8和9会被放到同一个链表上,那么查询的时候就需要遍历这个链表,得到8或者9,这样就降低了查询的效率。同时,我们也可以发现,当数组长度为15的时候,hashcode的值会与14(1110)进行“与”,那么最后一位永远是0,而0001,0011,0101,1001,1011,0111,1101这几个位置永远都不能存放元素了,因为hashmap在计算存放位置时,会发现最后一位一直是0,自然最后一位是0的位置就无法再放入元素,空间浪费会相当大,更糟的是这种情况中,数组可以使用的位置比数组长度小了很多,这意味着进一步增加了碰撞的几率,减慢了查询的效率!

 所以说,当数组长度为2的n次幂的时候,不同的key算得得index相同的几率较小,那么数据在数组上分布就比较均匀,也就是说碰撞的几率小,相对的,查询的时候就不用遍历某个位置上的链表,这样查询效率也就较高了。
这也是为什么默认值选为16,在小数据量的时候,16作为2的4次方,更能减少key之间的碰撞,提高查询的效率。

同时在Jdk 1.8中,在扩容HashMap的时候,不需要像1.7中去重新计算元素的hash,只需要看看原来的hash值新增的哪个二进制数是1还是0就好了,如果是0的话表示索引没有变,是1的话表示索引变成“oldCap+原索引”,这样即省去了重新计算hash值的时间,并且扩容后链表元素位置不会倒置。这也是扩容为2的幂次方的好处。配合源码理解:

final Node<K,V>[] resize() {
        Node<K,V>[] oldTab = table;
        int oldCap = (oldTab == null) ? 0 : oldTab.length;//获取当前数组大小oldCap
        int oldThr = threshold;//获取阈值
        int newCap, newThr = 0;
        //判断是初始化还是扩容
        if (oldCap > 0) {//说明已经初始化
            if (oldCap >= MAXIMUM_CAPACITY) {//说明底层数组长度已经达到最大容量
                threshold = Integer.MAX_VALUE;
                return oldTab;
            }
            else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                     oldCap >= DEFAULT_INITIAL_CAPACITY)//数组和threshold扩大为原来的两倍
                newThr = oldThr << 1; // double threshold
        }
        else if (oldThr > 0) // initial capacity was placed in threshold
            newCap = oldThr;//说明调用了hashmap的有参构造函数,因为无参构造函数并没有对threshold进行初始化
        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);
        }
        /*以上代码总结:
          1.如果已经对底层数组初始化就进行扩容
          2.如果数组长度已经是最大整数值了,最大值赋给threshold,不会在进行扩容
          3.如果没有达到,数组长度扩展两倍,threshold扩招为原来的两倍
        */
        threshold = newThr;//把上面计算来的newThr赋值给threshold
        @SuppressWarnings({"rawtypes","unchecked"})
            Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
        table = newTab; //扩容后新数组给底层table
        if (oldTab != null) {//若是扩容,执行以下方法,不是扩容则revise()结束
            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;
                            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;
    }

注释感觉写的不便于阅读,这里对后半段代码关于三种情况存储 做一个解释:

  • 当e.next == null的时候,也就是只有一个元素时最简单,直接通过(newCap-1)&hash找到需要放入的新下标,然后直接放入即可。
  • if(e instanceof TreeNode)也就是e是红黑树结构时,这个方法并没有直接操作,而是调用了红黑树的split方法对此条件进行处理,源码从2133行开始是红黑树的相关操作,后面再进行红黑树的学习。
  • 当指定下标的一个链表的时候。
Node<K,V> loHead = null, loTail = null;//lohead用户存储低位(位置不变)的key链表头,loTail用于存储链表尾
Node<K,V> hiHead = null, hiTail = null;//高位存储
Node<K,V> next;
do {
    next = e.next;
    //与原数组长度相与之后,得到结果为0,意味着在新数组中下标不变,组成新的链表
    if ((e.hash & oldCap) == 0) {
       if (loTail == null)
          loHead = e;
       else
          loTail.next = e;
          loTail = e;
       }
    else {
    //结果非0的时候,需要存储在新增的数组中的一个新的位置,形成一个链表
       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;//将位置加上原数组的长度,就是新的位置坐标
 }

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值