秋招Day5 - Java集合(下) - Map

HashMap vs HashTable

  • 线程安全:HashMap不是线程安全的(如果想要线程安全就使用ConcurrentHashMap;HashTable内部方法由synchronized修饰,线程安全
  • 效率:HashMap由于没有加同步锁,所以相比HashTable效率更高一点
  • 对NULL Key和NULL value的支持:HashMap支持一个NULL key,多个NULL value;HashTable不支持NULL key和value,否则抛出NullPointerException
  • 初始容量大小和每次扩容大小:HashMap默认初始容量为16,之后每次扩充为2倍;HashTable默认初始容量为11,之后每次扩充为2n+1倍;如果指定了初始容量,HashMap会将其扩充为2^n大小;HashTable会直接使用指定的容量
  • 底层数据结构:初始都是数组+链表,但HashMap中如果链表长度大于8转换为红黑树(如果数组长度小于64则优先扩容数组),HashTable则没有这样的机制
  • 哈希函数实现:HashMap使用了高位和低位的混合扰动以减少哈希冲突;HashTable没有这样的机制

 HashMap vs HashSet

秋招Day5 - Java集合(下) - Map_第1张图片

HashSet 底层就是基于 HashMap 实现的

HashMap vs TreeMap

都继承自AbstractMap,但是TreeMap是基于红黑树,比HashMap多了根据键排序的能力以及对集合内元素的搜索能力(定向搜索、子集操作、逆序视图、边界操作)。TreeMap保证了搜索操作的时间复杂度为 O(log n)

HashMap底层实现

Before JDK1.8

数组+链表。先计算要插入元素的hashCode,然后经过扰动函数(优化哈希值分布减少碰撞)计算得出hash值,然后通过(n - 1) & hash得出索引,如果当前索引存在元素,则调用equals判断是否真的相等,如果相等,则覆盖,如果不相等,则加入链表。

After JDK1.8

数组+链表(红黑树),当链表长度大于8时会转化为红黑树(要先检查数组长度是否大于64,否则优先扩容数组)。链表查询效率为O(n),红黑树查询效率为O(logn)。

为什么优先扩容数组而不是直接转化为红黑树?

因为数组扩容能减少哈希冲突的几率(将元素重新散列到更大的数组),元素较少的情况下比转化为红黑树更有效,因为当链表较短时,O(n) 和 O(log n) 的性能差异不明显。红黑树需要保持自平衡,维护成本较高。过早引入红黑树会增加复杂度。

为什么选择阈值8和64?

  • 链表长度为8意味着至少发生了八次哈希冲突,从概率学上讲这种情况的概率极低。阈值为8可以保证性能和空间效率的平衡
  • 数组长度阈值为64是因为在小数组中扩容成本低,优先扩容而避免过早引入红黑树。数组长度达到64时哈希冲突概率变高,此时红黑树的性能优势开始显现

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

hashCode算出来并不能直接用,还需要做取余运算,而普通的取余运算效率不高

  • 位运算效率更高:当length的长度为2的幂次方时,hashCode % length 等价于hashCode & length - 1
  • 更好地保证哈希值均匀分布:如果旧数组中就均匀分配了(得益于扰动函数),新数组中的分配也会均匀(要么位置不变,要么移动到新数组扩容之后的那一部分)
  • 扩容机制变得简单高效:length按照2的幂次方扩容后,只需要检查哈希值最高位的取值0(位置不变)或1(移动到新数组扩容之后的那一部分)来判断新位置

HashMap多线程操作导致死循环

HashMap发生扩容时,需要移动旧桶的链表中的元素到新位置,JDK1.7之前使用的是头插法,如果此时有多个线程同时进行操作,头插法可能会导致链表中的节点指向错误的位置,从而形成循环链表,导致查询元素的操作陷入死循环而结束。

为了解决这个问题,JDK1.8之后改为尾插法避免链表倒置导致死循环。但并发环境下还是推荐使用ConcurrentHashMap。

HashMap为什么线程不安全?

因为如果多个线程对HashMap做put操作,容易造成数据丢失/覆盖。

比如线程A和线程B同时进行put操作(假设hash值相同),线程A先获得CPU时间片,此时判断对应的哈希桶中没有元素,准备直接插入数组的对应索引位置,若此时线程A的时间片用完,线程B同样执行put操作并直接插入到了数组中。然后线程A重新获得时间片,由于之前已经判断过没有造成哈希冲突,会直接覆盖线程B插入的值,导致数据丢失。

HashMap的7种遍历方式

1. 使用迭代器(Iterator)EntrySet 的方式进行遍历;  

    Iterator> iterator = map.entrySet().iterator();
    while (iterator.hasNext()) {
        Map.Entry entry = iterator.next();
        System.out.println(entry.getKey());
        System.out.println(entry.getValue());
    }

2. 使用迭代器(Iterator)KeySet 的方式进行遍历;

        // 遍历
        Iterator iterator = map.keySet().iterator();
        while (iterator.hasNext()) {
            Integer key = iterator.next();
            System.out.println(key);
            System.out.println(map.get(key));
        }

3. 使用 For Each EntrySet 的方式进行遍历;

        for (Map.Entry entry : map.entrySet()) {
            System.out.println(entry.getKey());
            System.out.println(entry.getValue());
        }

4. 使用 For Each KeySet 的方式进行遍历;

        for (Integer key : map.keySet()) {
            System.out.println(key);
            System.out.println(map.get(key));
        }

5. 使用 Lambda 表达式的方式进行遍历;

        // 遍历
        map.forEach((key, value) -> {
            System.out.println(key);
            System.out.println(value);
        });

6. 使用 Streams API 单线程的方式进行遍历;

        map.entrySet().stream().forEach((entry) -> {
            System.out.println(entry.getKey());
            System.out.println(entry.getValue());
        });

7. 使用 Streams API 多线程的方式进行遍历。

        // 遍历
        map.entrySet().parallelStream().forEach((entry) -> {
            System.out.println(entry.getKey());
            System.out.println(entry.getValue());
        });

存在阻塞时 parallelStream 性能最高, 非阻塞时 parallelStream 性能最低 。

ConcurrentHashMap vs HashTable

  • 底层数据结构:JDK1.7的ConcurrentHashMap使用的是分段数组+链表JDK1.8之后使用数组+链表/红黑二叉树。HashTable使用的是简单的数组+链表
  • 实现线程安全的方式:
  1. 对于ConcurrentHashMap,在JDK1.7之前,实现线程安全的方式是为每一段数组分配不同的锁;JDK1.8之后完全摒弃了分段数组,而是数组+链表/红黑树。并发控制采用synchronized 和 CAS 机制,就是优化过且线程安全的HashMap。
  2. 而HashTable整个数组使用同一把锁。

ConcurrentHashMap线程安全的底层实现

 - JDK1.7之前:

分段数组+链表:每个Segment段(一个HashEntry 数组)配一把可重入锁,当一个线程占用锁访问其中一个段数据时,其他段的数据也能被其他线程访问。Segment默认有16个

- JDK1.8及之后:

Node数组+链表/红黑树

秋招Day5 - Java集合(下) - Map_第2张图片

Node + CAS(改 - 值变化) + synchronized(增/删 - 结构性变化) 来保证并发安全。

JDK1.8之后锁粒度更细,synchronized 只锁定当前链表或红黑二叉树的首节点,这样只要hash不冲突就不会产生并发,最大并发度就是 Node 数组的大小。

ConcurrentHashMap为什么key和value都不能为NULL?

是为了防止并发环境下的二义性问题(值/键没有在集合中 or 值/键本身为NULL)

HashMap一般用在单线程场景下,可以先通过map.containsKey(key)来判断键值对是否存在,然后再调用get方法,这个过程不会和其他线程产生并发问题。

而ConcurrentHashMap,即使事先通过map.containsKey(key)来判断某个键值对是否存在,别的线程也有可能执行map.remove(key),这个时候调用get也会返回null,你无法确定是由于别的线程同时删掉了这个键值对导致的null,还是因为其本来就不存在,所以存在二义性问题。

多线程下无法正确判定键值对是否存在(存在其他线程修改的情况),单线程是可以的(不存在其他线程修改的情况)。

From ConcurrentHashMap author Doug Lea

‘The main reason that nulls aren't allowed in ConcurrentMaps (ConcurrentHashMaps, ConcurrentSkipListMaps) is that ambiguities that may be just barely tolerable in non-concurrent maps can't be accommodated. The main one is that if map.get(key) returns null, you can't detect whether the key explicitly maps to null vs the key isn't mapped. In a non-concurrent map, you can check this via map.contains(key), but in a concurrent one, the map might have changed between calls.’

ConcurrentHashMap能保证复合操作的原子性吗?

如果分开调用putgetremovecontainsKey,则不能。例如先判断某个键是否存在containsKey(key),然后根据结果进行插入或更新put(key, value)。这种操作在执行过程中可能会被其他线程打断,导致结果不符合预期。

那该如何保证ConcurrentHashMap的原子性?

使用putIfAbsentcomputecomputeIfAbsent 、computeIfPresentmerge

你可能感兴趣的:(八股,#,集合,java)