Java集合:Map总结

1. 概述

Map是java.util下的接口,与Collections接口呈并列关系,其提供的是键到值的映射。Map不能包含相同的键,每个键只能映射一个值。键还决定了储存对象在映射中的储存位置。
在这里插入图片描述

  • Map接口的实现类:HashMap、LinkedHashMap、TreeMap
    • HashMap是数组+链表+红黑树实现的数据结构;
    • LinkedHashMap继承于HashMap,同时实现了链表的结构,维护元素进入的顺序;
    • TreeMap底层的数据结构是红黑树,利用红黑树的性质,实现了对Key的排序;

2.HashMap

2.1 基本性质

  • HashMap是由数组+链表+红黑树实现的,如下图所示,当链表的长度大于8时,且数组大于64时,链表会转换为红黑树,红黑树的节点数小于8时,红黑树会转为链表;
  • HashMap是线程不安全的,我们可以自己在外部加锁,或者通过 Collections#synchronizedMap 来实现线程安全,Collections#synchronizedMap 的实现是在每个方法上加上了 synchronized 锁;
  • HashMap允许Key和Value为null
  • 如果有很多数据需要储存到 HashMap 中,建议 HashMap 的容量一开始就设置成足够的大小,这样可以防止在其过程中不断的扩容,影响性能;

2.2 HashMap存储结构

HashMap采用Entry数组来存储key-value对,每一个键值对组成了一个Entry实体,Entry类实际上是一个单向的链表结构,它具有Next指针,可以连接下一个Entry实体。

 //存放数据的数组
 transient Node<K,V>[] table;

 //链表的节点
 static class Node<K,V> implements Map.Entry<K,V> {
 
 //红黑树的节点
 static final class TreeNode<K,V> extends LinkedHashMap.Entry<K,V>

在这里插入图片描述

相关问题:

1). 为什么用数组+链表?

数组是用来确定桶的位置,利用元素的key的hash值对数组长度取模得到. 链表是用来解决hash冲突问题,当出现hash值一样的情形,就在数组上的对应位置形成一条链表。

2). hash算法是干嘛的?

Hash函数是指把一个大范围映射到一个小范围。把大范围映射到一个小范围的目的往往是为了节省空间,使得数据容易保存。

3). hash冲突你还知道哪些解决办法?

比较出名的有四种 (1)开放定址法 (2)链地址法 (3)再哈希法 (4)公共溢出区域法;
HashMap中使用的是链地址法来解决hash冲突。

2.3 HashMap的put操作

2.3.1put操作的基本流程:

对key的hashCode()做hash运算,计算index; 如果没碰撞直接放到bucket里; 如果碰撞了,以链表的形式存在buckets后; 如果碰撞导致链表过长(大于等于TREEIFY_THRESHOLD),就把链表转换成红黑树(JDK1.8中的改动); 如果节点已经存在就替换old value(保证key的唯一性) 如果bucket满了(超过load factor*current capacity),就要resize。在这里插入图片描述

2.3.2. hash算法

在上面概念中讲到,hash算法是计算key值对应哈希桶的位置即索引值。我们都知道数组在获取元素会比链表快,所以我们应该尽量让每个哈希桶只有一个元素,这样在查询时就只需要通过索引值找到对应的哈希桶内的值,而不需要再通过桶内的链表一个一个去查。所以hash算法的作用是为了让元素分散均匀,从而提高查询效率。那接下来通过代码来一步一步分析时如何让元素分布均匀的。

//Hash算法如下
static final int hash(Object key) {
    int h;
    //第一步:先获取key中的hashCode值
    //第二步 再与hashcode向左移16位的值进行抑或 
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);  
}

在这里插入图片描述

h ^ h >>> 16的意义:

  • h >>> 16:为了使计算出的 hash 值更分散,所以选择先将 h 无符号右移 16 位,然后再于 h 异或时,就能达到 h 的高 16 位和低 16 位都能参与计算,减少了碰撞的可能性
  • h ^ h >>> 16取余运算::hash 值算出来之后,要计算当前 key 在数组中的索引下标位置时,可以采用取模的方式,就是索引下标位置 = hash 值 % 数组大小,这样做的好处,就是可以保证计算出来的索引下标值可以均匀的分布在数组的各个索引位置上,但取模操作对于处理器的计算是比较慢的,数学上有个公式,当 b 是 2 的幂次方时,a % b = a &(b-1),所以此处索引位置的计算公式我们可以更换为: (n-1) & hash,故是为了提高处理器处理的速度。
  • 为什么提倡数组大小是 2 的幂次方?答:因为只有大小是 2 的幂次方时,才能使 hash 值 % n(数组大小) == (n-1) & hash 公式成立。
2.3.3. 扩容操作

扩容是Hashmap重点中的重点。也是最耗性能的操作。扩容的步骤是先对size扩大两倍,再对原先的节点重新经过hash算法得到新的索引值即复制到新的哈希桶里。最后得到新的table。其中jdk8对扩容进行了优化,提高了扩容的效率。在平常运用中尽量要避免让hashmap进行扩容,若已知hashmap中的元素数量,则一开始初始化hashmap时指定容量,这样就减少了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;
        if (oldCap > 0) {
            //如果容量大于了最大容量时,直接返回旧的table
            if (oldCap >= MAXIMUM_CAPACITY) {
                threshold = Integer.MAX_VALUE;
                return oldTab;
            }
            //同时满足扩容两倍后小于最大容量和原先容量大于默认初始化的容量,对阈值增大两倍
            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);
        }
        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;
                //如果哈希桶为null,则不需任何操作
                if ((e = oldTab[j]) != null) {
                    //将桶内的第一个节点赋值给e
                    //将原哈希桶置为null,让gc回收
                    oldTab[j] = null;
                    if (e.next == null)
                        //如果e的下个节点(即第二个节点)为null,则只需要将e进行转移到新的哈希桶中
                        newTab[e.hash & (newCap - 1)] = e;
                    else if (e instanceof TreeNode)
                        //如果哈希桶内的节点为红黑树,则交给TreeNode进行转移
                        ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
                    else { // preserve order
                        //将桶内的转移到新的哈希桶内
                        //JDK1.8后将新的节点插在最后面
                        
                        //下面就是1.8后的优化
                        //1.7是将哈希桶的所有元素进行hash算法后转移到新的哈希桶中
                        //而1.8后,则是利用哈希桶长度在扩容前后的区别,将桶内元素分为原先索引值和新的索引值(即原先索引值+原先容量)。这里不懂为什么,可以看下一段图文讲解。
                        
                        //loHead记录低位链表的头部节点
                        //loTail是低位链表临时变量,记录上个节点并且让next指向当前节点
                        Node<K,V> loHead = null, loTail = null;
                        //hiHead,hiTail与上面的一样,区别在于这个是高位链表
                        Node<K,V> hiHead = null, hiTail = null;
                        Node<K,V> next;
                        do {
                            //用于临时记录当前节点的next节点
                            next = e.next;
                            //e.hash & oldCap==0表示扩容前后对当前节点的索引值没有发生改变
                            if ((e.hash & oldCap) == 0) {
                                //loTail为null时,代表低位桶内无元素则记录头节点
                                if (loTail == null)
                                    loHead = e;
                                else
                                    //将上个节点next指向当前节点
                                    //即新的节点是插在链表的后面
                                    loTail.next = e;
                                //将当前节点赋值给loTail
                                loTail = e;
                            }
                            else {
                                //跟上面的步骤是一样的。
                                if (hiTail == null)
                                    hiHead = e;
                                else
                                    hiTail.next = e;
                                hiTail = e;
                            }
                            //当next节点为null则退出循环
                        } while ((e = next) != null);
                        //如果低位链表记录不为null,则低位链表放到原index中
                        if (loTail != null) {
                            //将最后一个节点的next属性赋值为null
                            loTail.next = null;
                            newTab[j] = loHead;
                        }
                        //如果高位链表记录不为null,则高位链表放到新index中
                        if (hiTail != null) {
                            hiTail.next = null;
                            newTab[j + oldCap] = hiHead;
                        }
                    }
                }
            }
        }
        return newTab;
    }

相关问题:

1). HashMap在什么条件下扩容?

如果bucket满了(超过load factor*current capacity),就要resize。 load factor为0.75,为了最大程度避免哈希冲突 current capacity为当前数组大小。

2). 为什么扩容是2的次幂?

HashMap为了存取高效,要尽量较少碰撞,就是要尽量把数据分配均匀,每个链表长度大致相同,这个实现就在把数据存到哪个链表中的算法
这个算法实际就是取模,hash%length。 但是,大家都知道这种运算不如位移运算快。
因此,源码中做了优化hash&(length-1)。 也就是说hash%length==hash&(length-1)

2.3 HashMap的get操作

hashmap中get元素的过程是先对key的hashCode()做hash运算,计算index; 如果在bucket里的第一个节点里直接命中,则直接返回; 如果有冲突,则通过key.equals(k)去查找对应的Entry;
• 若为树,则在树中通过key.equals(k)查找,O(logn);
• 若为链表,则在链表中通过key.equals(k)查找,O(n)。

链表查找的源码:

// 采用自旋方式从链表中查找 key,e 初始为为链表的头节点
do {
    // 如果当前节点 hash 等于 key 的 hash,并且 equals 相等,当前节点就是我们要找的节点
    // 当 hash 冲突时,同一个 hash 值上是一个链表的时候,我们是通过 equals 方法来比较 key 是否相等的
    if (e.hash == hash &&
        ((k = e.key) == key || (key != null && key.equals(k))))
        return e;
    // 否则,把当前节点的下一个节点拿出来继续寻找
} while ((e = e.next) != null);

红黑树查找的代码很多,实际步骤比较复杂:

  • 从根节点递归查找;
  • 根据 hashcode,比较查找节点,左边节点,右边节点之间的大小,根本红黑树左小右大的特性进行判断;
  • 判断查找节点在第 2 步有无定位节点位置,有的话返回,没有的话重复 2,3 两步;
  • 一直自旋到定位到节点位置为止。

3. 对比总结

1). HashMap 和 Hashtable 的区别
  • 线程安全 :HashMap是线程不安全的,而HashTable是线程安全的,每个方法通过修饰synchronized来控制线程安全。
  • 效率: HashMap比HashTable效率高,原因在于HashTable的方法通过synchronized修饰后,并发的效率会降低。
    允不允许null : HashMap运行只有一个key为null,可以有多个null的value。而HashTable不允许key,value为null。
2). HashMap在并发编程环境下有什么问题?如何解决这些问题?

多线程put的时候可能导致元素丢失;put非null元素后get出来的却是null;
解决这些问题:使用ConcurrentHashmap,Hashtable等线程安全集合类。

3). 一般用什么作为HashMap的key?用可变类当HashMap的key有什么问题?

一般用Integer、String这种不可变类当HashMap当key,而且String最为常用。
(1) 因为字符串是不可变的,所以在它创建的时候hashcode就被缓存了,不需要重新计算。这就使得字符串很适合作为Map中的键,字符串的处理速度要快过其它的键对象。这就是HashMap中的键往往都使用字符串。
(2) 因为获取对象的时候要用到equals()和hashCode()方法,那么键对象正确的重写这两个方法是非常重要的,这些类已经很规范的覆写了hashCode()以及equals()方法。

用可变类当HashMap的key,其hashcode可能发生改变,导致put进去的值,无法get出,如下所示

HashMap<List<String>, Object> changeMap = new HashMap<>();
List<String> list = new ArrayList<>();
list.add("hello");
Object objectValue = new Object();
changeMap.put(list, objectValue);
System.out.println(changeMap.get(list));
list.add("hello world");   // hashcode发生了改变
System.out.println(changeMap.get(list));

输出结果如下:

java.lang.Object@33909752
null
3).LinkedHashMap性质:

LinkedHashMap 本身是继承 HashMap 的,所以它拥有 HashMap 的所有特性,再此基础上,还提供了两大特性:

  • 按照插入顺序进行访问;
  • 实现了访问最少最先删除功能,其目的是把很久都没有访问的 key 自动删除。

引用
http://www.liuhaihua.cn/archives/625510.html
https://blog.csdn.net/Yoga0301/article/details/84452104
https://www.cnblogs.com/flyuz/p/11378491.html

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值