2.集合(Map)

  在我们的代码开发中,Map键值对集合是我们经常使用的数据存储结构,他用着O(1)的查询时间复杂度,为我们的查询操作提供了优质的效率。

1.Map

1.1 HashMap与HashTable的区别

  1. 线程是否安全HashMap 是非线程安全的Hashtable 是线程安全的,因为 Hashtable 内部的方法基本都经过synchronized 修饰。(如果要保证线程安全最好使用 ConcurrentHashMap )
  2. 效率:因为线程安全的问题HashMap 要比 Hashtable 效率高一点。另外,Hashtable 基本被淘汰,不要在代码中使用它
  3. 对NULL key和NULLvalue的支持HashMap 可以存储 null 的 key 和 value,但 null 作为键只能有一个null 作为值可以有多个Hashtable 不允许有 null 键和 null 值否则会抛出 NullPointerException
  4. 初始容量大小和每次扩容大小的不同:① 创建时如果不指定容量初始值,Hashtable 默认的初始大小为 11每次扩充容量变为原来的 2n+1HashMap 默认的初始化大小为 16每次扩充容量变为原来的 2 倍。② 创建时若给定容量初始值,那么 Hashtable 会直接使用你给定的大小,而 HashMap 会将其扩充为 2 的幂次方大小HashMap 中的tableSizeFor()方法保证,下面给出了源代码)。也就是说 HashMap 总是使用 2 的幂作为哈希表的大小,后面会介绍到为什么是 2 的幂次方。
  5. 底层数据结构: JDK1.8 以后的 HashMap 在解决哈希冲突时有了较大的变化,当链表长度大于阈值(默认为 8)时,将链表转化为红黑树(将链表转换成红黑树前会判断,如果当前数组的长度小于 64,那么会选择先进行数组扩容,而不是转换为红黑树),以减少搜索时间(后文中我会结合源码对这一过程进行分析)。Hashtable 没有这样的机制。

HashMap中带有初始容量的构造函数

    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);
    }
     public HashMap(int initialCapacity) {
        this(initialCapacity, DEFAULT_LOAD_FACTOR);
    }

tableSizeFor()方法保证了HashMap总是使用2的幂作为其大小。

static int tableSizeFor(int cap){
    int n = cap -1;
    n |= n >>>1;
    n |= n >>>2;
    n |= n >>>4;
    n |= n >>>8;
    n |= n >>>16;
    return (n < 0) ? 1 : (n >= MAXIMUN_CAPACITY) ? MAXIMUN_CAPCITY : n+1;
}

  当我给定cap值为16时,最后输出位14。并没有值为2的次幂。这个后续再理解。

1.2 HashMap与HashSet的区别

  HashSet 底层就是基于 HashMap 实现的。(HashSet 的源码非常非常少,因为除了 clone()writeObject()readObject()HashSet 自己不得不实现之外,其他方法都是直接调用 HashMap 中的方法。

HashMapHashSet
实现了 Map 接口 实现 Set 接口
存储键值对 仅存储对象
调用 put()向 map 中添加元素 调用 add()方法向 Set 中添加元素
HashMap 使用键(Key)计算 hashcode HashSet 使用成员对象来计算 hashcode 值,对于两个对象来说 hashcode 可能相同,所以equals()方法用来判断对象的相等性

   JDK1.8HashMap的hash方法源码:

static final int hash(Object key){
      int h;
      // key.hashCode():返回散列值也就是hashcode
      // ^:按位异或
      // >>>:无符号右移,忽略符号位,空位都以0补齐
      return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

JDK1.7 的 HashMap 的 hash 方法源码

static int hash(int h) {
    h ^= (h >>> 20) ^ (h >>> 12);
    return h ^ (h >>> 7) ^ (h >>> 4);
}

  JDK1.8相对于JDK1.7少扰动了4次,因此效率好一点。

  “拉链法” 就是:将链表和数组相结合。创建一个链表数组数组中每一格就是一个链表若遇到哈希冲突,则将冲突的值加到链表中。

这里贴上JavaGuide图:

   在JDK1.8之后,解决哈希冲突有了较大的变化,当链表长度大于阈值(默认为 8)(将链表转换成红黑树前会判断,如果当前数组的长度小于 64,那么会选择先进行数组扩容,而不是转换为红黑树)时,将链表转化为红黑树,以减少搜索时间。

1、 putVal 方法中执行链表转红黑树的判断逻辑

链表的长度大于 8 的时候,就执行 treeifyBin (转换红黑树)的逻辑。

// 遍历链表
for (int binCount = 0; ; ++binCount) {
    // 遍历到链表最后一个节点
    if ((e = p.next) == null) {
        p.next = newNode(hash, key, value, null);
        // 如果链表元素个数大于等于TREEIFY_THRESHOLD(8)
        if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
            // 红黑树转换(并不会直接转换成红黑树)
            treeifyBin(tab, hash);
        break;
    }
    if (e.hash == hash &&
        ((k = e.key) == key || (key != null && key.equals(k))))
        break;
    p = e;
}

2、treeifyBin 方法中判断是否真的转换为红黑树:

final void treeifyBin(Node<K,V>[] tab, int hash) {
    int n, index; Node<K,V> e;
    // 判断当前数组的长度是否小于 64
    if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
        // 如果当前数组的长度小于 64,那么会选择先进行数组扩容
        resize();
    else if ((e = tab[index = (n - 1) & hash]) != null) {
        // 否则才将列表转换为红黑树

        TreeNode<K,V> hd = null, tl = null;
        do {
            TreeNode<K,V> p = replacementTreeNode(e, null);
            if (tl == null)
                hd = p;
            else {
                p.prev = tl;
                tl.next = p;
            }
            tl = p;
        } while ((e = e.next) != null);
        if ((tab[index] = hd) != null)
            hd.treeify(tab);
    }
}

1.6 HashMap的长度为什么是2的幂次方?

  为使 HashMap 存取高效,尽量较少碰撞,也就是要尽量把数据分配均匀。取余(%)操作中若除数是 2 的幂次则等价于与其除数减一的与(&)操作(也就是说 hash%length==hash&(length-1)的前提是 length 是 2 的 n 次方;)。采用二进制位操作 &,相对于%能够提高运算效率

  • 底层数据结构: JDK1.7ConcurrentHashMap 底层采用 分段的数组+链表 实现,JDK1.8 采用的数据结构跟 HashMap1.8 的结构一样,数组+链表/红黑二叉树Hashtable 和 JDK1.8 之前的 HashMap 的底层数据结构类似都是采用 数组+链表 的形式,数组是 HashMap 的主体,链表则是主要为了解决哈希冲突而存在的;
  • 实现线程安全的方式(重要):
  • JDK1.7 时,ConcurrentHashMap 对整个桶数组进行了分割分段(Segment,分段锁)每一把锁只锁容器其中一部分数据(下面有示意图),多线程访问容器里不同数据段的数据,就不会存在锁竞争,提高并发访问率
    • 在 JDK1.8 时ConcurrentHashMap 直接用 Node 数组+链表+红黑树的数据结构来实现并发控制使用 synchronized 和 CAS 来操作
    • Hashtable(同一把锁) :使用 synchronized 来保证线程安全,效率非常低下。当一个线程访问同步方法时,其他线程也访问同步方法,可能会进入阻塞或轮询状态,如使用 put 添加元素,另一个线程不能使用 put 添加元素,也不能使用 get,竞争会越来越激烈效率越低。

参考链接

Java集合常见面试题总结(下) | JavaGuide(Java面试 + 学习指南)

posted @ 2023-12-08 11:22  求知律己  阅读(29)  评论(0)    收藏  举报