对HashMap的一些总结和思考

关于HashMap的一些重要总结

hash数组的容量必须是2的n次幂,默认的初始容量是16

默认填充因子为0.75

扩容的临界值=容量*填充因子,默认大小为16*0.75=12,元素超过12则默认扩容2倍

线程不安全,CHM可以保证线程安全

数据无序

key唯一,可以为null

底层数据结构:
JDK7:数组+链表
JDK8:数组+链表+红黑树
链表转红黑树的阈值:
hash表容量,默认大小为64
桶中链表长度,默认大小为8
为什么链表转红黑树?
因为链表的平均查找时间复杂度O(n),而红黑树为logn,
当n过大时红黑树查找效率比链表高

为什么hash数组的容量是2的n次幂?

put元素时,计算元素放入的位置需要用散列函数计算。
我们通常的散列函数采用除留余数法也就是取模%,但是 & 运算取得hash位置的性能比 %取模高,所以jdk采用的 & 运算
但又要保证&运算和取模的结果一致(h^h>>>16& (n- 1) = hash % n),所以需要保证n为2的幂次方

那如果程序员设置的n不是2次幂怎么办呢?
自动调整n为一个大于初始值的最小的2的幂次数,例如n=10,最终得到的n=16

为什么扩容为原来的2倍?

因为要保证数组大小为2的n次幂
那为什么不是4倍8倍?
一下子扩容太多会浪费内存空间

为什么数组长度64链表长度8时转成红黑树

长度为8时
红黑树的平均查找长度为log(8)=3
链表的平均查找长度为8/2=4
此时红黑树的效率超过链表,这才有转换成树的必要;
长度小于8时,红黑树的查找长度不一定优于链表,且数据更新之后,红黑树保持平衡需要进行左旋,右旋,变色等操作,性能其实没有链表高。

什么时候退回链表?
当元素个数小于等于6时退回成链表。
为什么是6呢?
因为需要比8小一点,不然由于HashMap不停的插入、删除元素,链表个数在8左右徘徊,就会频繁的发生红黑树和链表的转换,这个转换过程也是很消耗性能的。

装载因子为什么是0.75,初始容量为什么是16

装载因子

  • 装载因子值大了,可以减少扩容的次数,但会导致发生散列冲突的可能性变大
  • 装载因子值小了,可以减小散列冲突的可能性,但同时扩容的次数可能就会变多!

初始容量

  • 初始容量过大,浪费内存空间且遍历速度慢
  • 初始容量过小,散列表扩容的次数变多,扩容非常耗费性能

所以装载因子0.75和初始容量16是jdk官方通过大量数据的测算之后,在扩容次数和冲突概率之间取最优解的结果

jdk8和jdk7的区别

  1. 7只是数组+链表,8会链表转红黑树
  2. 7是初始化之后就马上创建hash数组,jdk8是延迟创建hash数组的,new HashMap()时底层还没创建一个长度为16的数组, 直到首次调用put()方法时,底层才创建长度为16的数组

HashMap线程不安全的表现

1、多个线程同时更新同一数据时导致数据不一致

2、扩容死循环
jdk7扩容存在死循环问题,当 链表+头插法+多线程并发+扩容 这四者叠加时就可能导致死循环
jdk8将头插法改为尾插法(七上八下)解决了死循环
参考

读下源码

put添加元素

1、首先计算key的插入位置

2、put:
没有元素——添加,
有元素但hash值不同——添加,
hash值相同equals不同——添加,
equals相同——失败

3、put之后:
如果元素个数大于阈值threshold,则进行扩容;
或者超过转树阈值就转换成红黑树

计算key的插入位置公式:

(n-1&  h^ (h >>> 16)
h为key的hashCode
n默认=16

采用&取代%,因为&的效率更高。

计算key的插入位置举例:
在这里插入图片描述

h^ (h >>> 16):
高16 bit 不变,低16 bit 和高16 bit 做了一个异或,目的就是将hashcode 转化为32位二进制

为什么要这样操作呢?直接(n-1)&hashcode不行吗?——减少hash冲突
如果当n即数组长度很小,假设是16的话,那么n-1即为 —》1111 ,这样的值和hashCode()直接做按位与操作,实际上只使用了哈希值的后4位。如果当哈希值的高位变化很大,低位变化很小,这样就很容易造成哈希冲突了,所以这里把高低位都利用起来,可以减少hash冲突。

put源码:
put调用putVal,方法源代码如下所示

    final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
                       boolean evict) {
/** hash key的hash值
- key 原始Key
- value 要存放的值
- onlyIfAbsent 如果true代表不更改现有的值
- evict 如果为false表示table为创建状态
**/
        Node<K,V>[] tab; Node<K,V> p; int n, i;
        /*
        	1)transient Node<K,V>[] table; 表示存储Map集合中元素的数组。
        	2)(tab = table) == null 表示将空的table赋值给tab,然后判断tab是否等于null,第一次肯定是			null
        	3)(n = tab.length) == 0 表示将数组的长度0赋值给n,然后判断n是否等于0,n等于0
        	由于if判断使用双或,满足一个即可,则执行代码 n = (tab = resize()).length; 进行数组初始化。
        	并将初始化好的数组长度赋值给n.
        	4)执行完n = (tab = resize()).length,数组tab每个空间都是null
        */
        if ((tab = table) == null || (n = tab.length) == 0)
            n = (tab = resize()).length;
        /*
        	1)i = (n - 1) & hash 表示计算数组的索引赋值给i,即确定元素存放在哪个桶中
        	2)p = tab[i = (n - 1) & hash]表示获取计算出的位置的数据赋值给节点p
        	3) (p = tab[i = (n - 1) & hash]) == null 判断节点位置是否等于null,如果为null,则执行代			码:tab[i] = newNode(hash, key, value, null);根据键值对创建新的节点放入该位置的桶中
            小结:如果当前桶没有哈希碰撞冲突,则直接把键值对插入空间位置
        */ 
        if ((p = tab[i = (n - 1) & hash]) == null)
            //创建一个新的节点存入到桶中
            tab[i] = newNode(hash, key, value, null);
        else {
             // 执行else说明tab[i]不等于null,表示这个位置已经有值了。
            Node<K,V> e; K k;
            /*
            	比较桶中第一个元素(数组中的结点)的hash值和key是否相等
            	1)p.hash == hash :p.hash表示原来存在数据的hash值  hash表示后添加数据的hash值 比较两个				 hash值是否相等
                     说明:p表示tab[i],即 newNode(hash, key, value, null)方法返回的Node对象。
                        Node<K,V> newNode(int hash, K key, V value, Node<K,V> next) 
                        {
                            return new Node<>(hash, key, value, next);
                        }
                        而在Node类中具有成员变量hash用来记录着之前数据的hash值的
                 2)(k = p.key) == key :p.key获取原来数据的key赋值给k  key 表示后添加数据的key 比较两					个key的地址值是否相等
                 3)key != null && key.equals(k):能够执行到这里说明两个key的地址值不相等,那么先判断后				添加的key是否等于null,如果不等于null再调用equals方法判断两个key的内容是否相等
            */
            if (p.hash == hash &&
                ((k = p.key) == key || (key != null && key.equals(k))))
                    /*
                    	说明:两个元素哈希值相等,并且key的值也相等
                    	将旧的元素整体对象赋值给e,用e来记录
                    */ 
                    e = p;
            // hash值不相等或者key不相等;判断p是否为红黑树结点
            else if (p instanceof TreeNode)
                // 放入树中
                e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
            // 说明是链表节点
            else {
                /*
                	1)如果是链表的话需要遍历到最后节点然后插入
                	2)采用循环遍历的方式,判断链表中是否有重复的key
                */
                for (int binCount = 0; ; ++binCount) {
                    /*
                    	1)e = p.next 获取p的下一个元素赋值给e
                    	2)(e = p.next) == null 判断p.next是否等于null,等于null,说明p没有下一个元					素,那么此时到达了链表的尾部,还没有找到重复的key,则说明HashMap没有包含该键
                    	将该键值对插入链表中
                    */
                    if ((e = p.next) == null) {
                        /*
                        	1)创建一个新的节点插入到尾部
                        	 p.next = newNode(hash, key, value, null);
                        	 Node<K,V> newNode(int hash, K key, V value, Node<K,V> next) 
                        	 {
                                    return new Node<>(hash, key, value, next);
                             }
                             注意第四个参数next是null,因为当前元素插入到链表末尾了,那么下一个节点肯定是								null
                             2)这种添加方式也满足链表数据结构的特点,每次向后添加新的元素
                        */
                        p.next = newNode(hash, key, value, null);
                        /*
                        	1)节点添加完成之后判断此时节点个数是否大于TREEIFY_THRESHOLD临界值8,如果大于
                        	则将链表转换为红黑树
                        	2)int binCount = 0 :表示for循环的初始化值。从0开始计数。记录着遍历节点的个						数。值是0表示第一个节点,1表示第二个节点。。。。7表示第八个节点,加上数组中的的一						个元素,元素个数是9
                        	TREEIFY_THRESHOLD - 1 --》8 - 1 ---》7
                        	如果binCount的值是7(加上数组中的的一个元素,元素个数是9)
                        	TREEIFY_THRESHOLD - 1也是7,此时转换红黑树
                        */
                        if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                            //转换为红黑树
                            treeifyBin(tab, hash);
                        // 跳出循环
                        break;
                    }
                     
                    /*
                    	执行到这里说明e = p.next 不是null,不是最后一个元素。继续判断链表中结点的key值与插					  入的元素的key值是否相等
                    */
                    if (e.hash == hash &&
                        ((k = e.key) == key || (key != null && key.equals(k))))
                        // 相等,跳出循环
                        /*
                    		要添加的元素和链表中的存在的元素的key相等了,则跳出for循环。不用再继续比较了
                    		直接执行下面的if语句去替换去 if (e != null) 
                    	*/
                        break;
                    /*
                    	说明新添加的元素和当前节点不相等,继续查找下一个节点。
                    	用于遍历桶中的链表,与前面的e = p.next组合,可以遍历链表
                    */
                    p = e;
                }
            }
            /*
            	表示在桶中找到key值、hash值与插入元素相等的结点
            	也就是说通过上面的操作找到了重复的键,所以这里就是把该键的值变为新的值,并返回旧值
            	这里完成了put方法的修改功能
            */
            if (e != null) { 
                // 记录e的value
                V oldValue = e.value;
                // onlyIfAbsent为false或者旧值为null
                if (!onlyIfAbsent || oldValue == null)
                    //用新值替换旧值
                    //e.value 表示旧值  value表示新值 
                    e.value = value;
                // 访问后回调
                afterNodeAccess(e);
                // 返回旧值
                return oldValue;
            }
        }
        //修改记录次数
        ++modCount;
        // 判断实际大小是否大于threshold阈值,如果超过则扩容
        if (++size > threshold)
            resize();
        // 插入后回调
        afterNodeInsertion(evict);
        return null;
    } 

resize扩容

创建一个2倍大小的新数组,计算每个元素在新数组中的位置。这个计算很耗性能的,jdk实现的非常巧妙大大减少了消耗

具体做法如下:因为每次扩容都是翻倍,与原来计算的 (n-1)&h^(h>>16)的结果相比,只是多了一个bit位,所以节点要么就在原来的位置,要么就被分配到"原位置+旧容量"的位置。

怎么理解呢?例如我们从16扩展为32时,具体的变化如下所示:
在这里插入图片描述
上面计算总结如下
在这里插入图片描述

扩容之后所有节点要么就在原来的位置,要么就被分配到"原位置+旧容量"这个位置。

原来的hash值新增的那个bit是0的话索引没变,是1的话索引变成原位置+旧容量。

resize源码:

final Node<K,V>[] resize() {
    //得到当前数组
    Node<K,V>[] oldTab = table;
    //如果当前数组等于null长度返回0,否则返回当前数组的长度
    int oldCap = (oldTab == null) ? 0 : oldTab.length;
    //当前阀值点 默认是12(16*0.75)
    int oldThr = threshold;
    int newCap, newThr = 0;
    //如果老的数组长度大于0
    //开始计算扩容后的大小
    if (oldCap > 0) {
        // 超过最大值就不再扩充了,就只好随你碰撞去吧
        if (oldCap >= MAXIMUM_CAPACITY) {
            //修改阈值为int的最大值
            threshold = Integer.MAX_VALUE;
            return oldTab;
        }
        /*
        	没超过最大值,就扩充为原来的2倍
        	1)(newCap = oldCap << 1) < MAXIMUM_CAPACITY 扩大到2倍之后容量要小于最大容量
        	2)oldCap >= DEFAULT_INITIAL_CAPACITY 原数组长度大于等于数组初始化长度16
        */
        else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                 oldCap >= DEFAULT_INITIAL_CAPACITY)
            //阈值扩大一倍
            newThr = oldThr << 1; // double threshold
    }
    //老阈值点大于0 直接赋值
    else if (oldThr > 0) // 老阈值赋值给新的数组长度
        newCap = oldThr;
    else {// 直接使用默认值
        newCap = DEFAULT_INITIAL_CAPACITY;//16
        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);
    }
    //新的阀值 默认原来是12 乘以2之后变为24
    threshold = newThr;
    //创建新的哈希表
    @SuppressWarnings({"rawtypes","unchecked"})
    //newCap是新的数组长度--》32
    Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
    table = newTab;
    //判断旧数组是否等于空
    if (oldTab != null) {
        // 把每个bucket都移动到新的buckets中
        //遍历旧的哈希表的每个桶,重新计算桶里元素的新位置
        for (int j = 0; j < oldCap; ++j) {
            Node<K,V> e;
            if ((e = oldTab[j]) != null) {
                //原来的数据赋值为null 便于GC回收
                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;
                     	//这里来判断如果等于true e这个节点在resize之后不需要移动位置
                        if ((e.hash & oldCap) == 0) {
                            if (loTail == null)
                                loHead = e;
                            else
                                loTail.next = e;
                            loTail = e;
                        }
                        // 原索引+oldCap
                        else {
                            if (hiTail == null)
                                hiHead = e;
                            else
                                hiTail.next = e;
                            hiTail = e;
                        }
                    } while ((e = next) != null);
                    // 原索引放到bucket里
                    if (loTail != null) {
                        loTail.next = null;
                        newTab[j] = loHead;
                    }
                    // 原索引+oldCap放到bucket里
                    if (hiTail != null) {
                        hiTail.next = null;
                        newTab[j + oldCap] = hiHead;
                    }
                }
            }
        }
    }
    return newTab;
}

其他几种Map

LinkedHashMap(双向链表保证按插入顺序有序)

特点:

  • LinkedHashMap继承自HashMap,在HashMap的基础之上将数组改为双向链表,即:由双向链表+链表/红黑树组成
  • 双向链表保证插入数据是有序的,从而实现顺序访问
  • 值允许为null
  • 线程不安全
  • 其他和hashMap一样

TreeMap(红黑树保证按key大小排序)

  • 底层是红黑树,每个<key,val>对应一个红黑树节点
  • 红黑树的有序性保证元素会按key大小排序
  • 元素不重复

Hashtable(synchronized锁保证线程安全)

Hashtable可以理解为线程安全的hashMap
Hashtable是个过时的集合类,不建议在新代码中使用,
不需要线程安全的场合可以用HashMap
需要线程安全的场合可以用ConcurrentHashMap

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值