面试官:说下你最熟悉的一种数据结构(os:HashMap?)

136 阅读14分钟

认识 HashMap

HashMap 的通常是基于“数组+链表”实现的,这种方式被称为“拉链法”,是线程不安全的,而 ConcurrentHashMap 的出现为其提供了解决方案并且支持高并发场景的使用。

HashMap 中的链表大小超过 8 个的时候会自动转化为红黑树,当链表的节点个数删除到小于 6 时,重新转换为链表。为什么呢?

根据泊松分布,在负载因子默认为 0.75 时,单个 Hash 槽内元素个数小于 8 的概率小于百万分之一,所以将 7 作为一个分水岭,等于的 7 时候不做转换,大于等于 8 的时候才进行转换,当删减至小于等于 6 时就转化为链表。

HashMap 在多线程的情况下存在线程安全问题,那你一般是怎么处理的呢?

一般在多线程的场景下,我会使用几下几种方式去处理:

  1. 使用 Collections.synchronizedMap(Map) 创建线程安全的 map 集合

  2. 使用 Hashtable

  3. 使用 ConcurrentHashMap

不过由于线程并发度的原因,我都会使用后面的 ConcurrentHashMap,他在保证线程安全的前提下,性能和效率比前两者高。

Collections.synchronizedMap 是怎么实现线程安全的,你有了解过吗?

在 synchronizedMap 内部维护了一个普通对象的 Map,还有排斥锁 mutex,我们在调用这个方法的时候需要传入一个 Map,它里面由两个构造器,如果你传入了 mutex 参数,则会将对象排斥锁赋值给传入的 Map 对象,如果没有的话,它会将排斥所赋值给 this,即调用 synchronizedMap 的对象(就是上面的 ),创建出 synchronizedMap 之后,再操作 Map 的时候会对方法加上 synchronized 锁。

Hashtable

能跟我聊下 Hashtable 吗

跟 HashMap 比 Hashtable 是线程安全的,适合在多线程的情况下使用,但是效率不高。

说下 Hashtable 效率低的原因

看过它的源码,它在对数据操作的时候,都会加上 synchronized 锁。

除了这个你还能说出一些 Hashtable 和 HashMap 的区别吗

Hashtable 是不允许键或值为 null 的,而 HashMap 的键值都可以为 null。

为啥 Hashtable 不允许 KEY 和 VALUE 为 null,而 HashMap 可以

Hashtable 使用了安全失败机制(fail-safe) ,这种机制会使你此次读到的数据不一定是最新的数据,如果你使用了 null 值,就会使得它无法判断对应的 key 是不存在还是为空,因为你无法再调用 containsKey(key) 来对 KEY 的存在进行判断。ConcurrentHashMap 同理。

1. 实现方式不同

Hashtable 继承了 Dictionary 类,而 HashMap 继承的是 AbstractMap 类。

2. 初始化容量不同

HashMap 的初始容量是 16,而 Hashtable 初始容量为 11,两者的负载因子都是 0.75。

3. 扩容机制不同

当现有容量大于总容量 * 负载因子的值时,HashMap 扩容规则为当前容量的一倍,而 Hashtable 扩容为当前容量的一倍 + 1。

4. 迭代器不同

HashMap 中的 Iterator 迭代器是 fail-fast 的,而 Hashtable 的迭代器是 Enumerator 不是 fail-fast 的,所以,当其它线程改变了 HashMap 的结构(比如:增加、删除元素)将会抛出 ConcurrentModificationException 以此,但是 Hashtable 不会。

什么是 fail-fast

fail-fast 是 java 集合中的一种机制,字面上的意思是快速失败。在用迭代器遍历一个集合的时候,如果在遍历过程中对集合对象的内容进行了修改,则会抛出 Concurrent Modification Exception。

fail-fast 的原理是啥

迭代器在遍历的时候会直接访问集合中的内容,并且在遍历的时候使用一个 modCount 变量。集合在遍历期间如果内容发生了变化,就会改变 modCount 的值。每当迭代器使用 hashNext()/next() 时也就是在遍历下一个元素之前,都会检测 modCount 变量是否为 expectedmodCount 的值,如果是的话就返回遍历,否则就会抛出异常,终止遍历。

这里异常的抛出条件是检测到 modCount!=expectedmodCount 这个条件。
如果集合发生变化时修改 modCount值刚好又设置为了expectedmodCount值,则不会抛出异常

说下 fail-fast 的使用场景

JUC 包下的集合类都是快速失败的,不能在多线程下发生并发修改,算是一种安全机制吧。

那你说说其它数据结构吧,以及为啥的它的并发度那么高

ConcurrentHashMap 底层是基于 数组+链表 组成的,不过在 jdk1.7 和 jdk 1.8 中具体的实现稍有不同。我们先说下他在 jdk 1.7 中的数据结构吧。

jdk 1.7 中的 ConcurrentHashMap

jdk 1.7 下的 ConcurrentHashMap 是由 Segment 数组和 HashEntry 组成的,其实也是数组 + 链表

Segment 是 ConcurrentHashMap 中的一个内部类,结构如下(辅助理解):

static final class Segment<K,V> extends ReentrantLock implements Serializable {
	private static final long serialVersionUID = 2249069246763182397L;
  // 和 HashMap 中的 HashEntry 作用一样,真正存放数据的桶
  transient volatile HashEntry<K,V>[] table;
  transient int count;
  // fail-fast 快速失败机制
  transient int modCount;
  // 大小
  transient int threshold;
  // 负载因子
  final float loadFactor;
}

HashEntry 的结构和 HashMap 的结构差不多,不同的是 HashEntry 使用了 volatile 关键字去修饰了它的 value 和 下一个节点 next。

volatile 的特性是啥

volatile 的特性有以下几点:

  1. 保证了不同线程对这个变量进行操作时的可见性,就是说当一个线程修改了某个变量的值时,这个更新后的值对其它线程是立即可见的。(实现可见性

  2. 禁止进行指令重排序。(实现有序性

  3. volatile 只能保证对单词读或写的原子性。i ++ 这种操作不能保证原子性。

www.yuque.com/solider/rmn…

你说下 ConcurrentHashMap 并发度高的原因

原理上来说,ConcurrentHashMap 采用了分段锁技术,其中 Segment 继承了 ReentrantLock 类。它不像 Hashtable 那样,不管是 get 操作还是 put 操作都做了同步处理。理论上 ConcurrentHashMap 支持的并发度就是 Segment 数组的 size 值(CurrencyLevel = segment.size() )。每当一个线程占用锁访问一个 Segment 时,不会影响到其它线程对其它 Segment 的操作。也就是说如果 Segment 数组的容量大小为 16,那么它可以同时允许 16 个线程操作 16 个 Segment 而且还是线程安全的。

首先它会先定位到要访问哪个 Segment,然后再进行 put 操作。可以看下 put 操作的源码(辅助理解)。

put 操作的流程

首先第一步的时候会尝试获取锁,如果锁获取失败,则肯定有其它线程存在竞争,此时会利用 scanAndLockForPut() 自旋获取锁。

  1. 会尝试通过自旋获取锁
  2. 如果重试的次数达到了 MAX_SCAN_RETRIES则会改为阻塞锁获取,为了保证操作能成功

它的 get 逻辑呢?

get() 操作比较简单,只需要将 KEY 通过 Hash 之后定位到具体的 Segment ,再通过一次 Hash 定位到具体元素上。由于 HashEntry 的 value 属性是用了 volitale 关键字修饰的,从而保证了内存的可见性,所以每次获取的时候 value 都是最新值(get 操作是非常高效的,所以整个过程不需要加锁)。

你有没有发现 jdk 1.7 虽然可以支持每个 Segment 并发访问,但是还是会存在一些问题?

是的,因为基本上还是数组 + 链表的方式,我们去查询的时候,需要遍历链表,会导致效率很低,所以在 jdk 1.8 的时候做了优化。

jdk 1.8 中的 ConcurrentHashMap

那你跟我说说 jdk 1.8 中它的数据结构

jdk 1.8 中抛弃了原有的 Segment 分段锁技术,而是采用了 CAS + synchronized 来保证了并发的安全性。跟 HashMap 很像,也将之前的 HashEntry 改成了 Node,但是作用不变,然后把 value 和 next 都用了 volatile 关键字进行了修饰。 同时也引入了红黑树,在链表的长度大于某个阈值的时候会将链表转换为红黑树(默认为 8)。

你能跟我说下它的存取操作吗,如何保证线程安全的?

ConcurrentHashMap 的 put 操作还是比较复杂的,大致可以分为以下几个步骤:

put 操作的流程

  1. 首先根据 KEY 计算得到 hashcode

  2. 判断是否需要进行初始化操作

  3. 为当前的 KEY 定位出 Node,如果为空表示当前位置可以写入数据,利用 CAS 尝试写入,失败的时候通过自旋保证成功

  4. 如果当前位置的 hashcode == MOVED == -1,则表示需要进行扩容

  5. 如果都不满足,则会利用 synchronized 锁写入数据

  6. 如果数量大于 TREEIFY_THRESHOLD(默认为 8 ),则会触发将链表转换为红黑树的操作。

下面是他的源码(辅助理解):

/** Implementation for put and putIfAbsent */
final V putVal(K key, V value, boolean onlyIfAbsent) {
    if (key == null || value == null) throw new NullPointerException();
    int hash = spread(key.hashCode());
    int binCount = 0;
    for (Node<K,V>[] tab = table;;) {
        Node<K,V> f; int n, i, fh; K fk; V fv;
        if (tab == null || (n = tab.length) == 0)
            tab = initTable();
        else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
            if (casTabAt(tab, i, null, new Node<K,V>(hash, key, value)))
                break;                   // no lock when adding to empty bin
        }
        else if ((fh = f.hash) == MOVED)
            tab = helpTransfer(tab, f);
        else if (onlyIfAbsent // check first node without acquiring lock
                 && fh == hash
                 && ((fk = f.key) == key || (fk != null && key.equals(fk)))
                 && (fv = f.val) != null)
            return fv;
        else {
            V oldVal = null;
            synchronized (f) {
                if (tabAt(tab, i) == f) {
                    if (fh >= 0) {
                        binCount = 1;
                        for (Node<K,V> e = f;; ++binCount) {
                            K ek;
                            if (e.hash == hash &&
                                ((ek = e.key) == key ||
                                 (ek != null && key.equals(ek)))) {
                                oldVal = e.val;
                                if (!onlyIfAbsent)
                                    e.val = value;
                                break;
                            }
                            Node<K,V> pred = e;
                       

你刚才提到了 CAS,它是什么?自旋又是什么?

CAS 是乐观锁的一种实现方式,属于轻量级锁,JUC 包中很多工具类的实现就是基于 CAS 的。

CAS 的操作流程

线程在读取数据时不进行加锁,在准备写回数据的时候,会比较原值是否修改,如果未被其它线程修改就会进行写回;如果已被修改,则会重新执行读取流程(乐观策略,默认并发操作不会发生)。

CAS 就一定能保证数据没被别的线程修改过吗?

并不是的,比如很经典的 ABA 问题,CAS 就无法判断了。

什么 ABA 问题?

ABA 问题就是一个线程把值改成了 B ,又来一个线程把 B 又改回了 A,对于这个时候其它线程就会发现它的值还是 A,所以它就不会知道这个值到底有没有被人修改过(其实很多场景如果只追求最后结果正确的话就没有关系,但是如果实际过程中需要记录修改过程的,比如资金修改什么的,你每次修改的操作都应该有记录,方便回溯)。

那么如何处理 ABA 问题的?

可以用版本号去保证就好了,比如说我们在修改前去查询它原来的值的时候再带一个版本号,每次判断的时候连预期值和版本号一起判断,判断成功就给版本号加 1。

还有其它方式吗?

加时间戳也可以,查询的时候把时间戳一起查出来,对的上才修改并且更新值得时候一起修改更新时间。

CAS 性能很高,但是我知道 synchronzied 性能可不咋地,为啥 jdk 1.8 后,反而多了 synchronized?

synchronzied 之前一直都是重量级锁,但是后来 java 官方对他进行过升级,现在采用得是锁升级的方式去做的。

针对 synchronized 获取锁的方式,JVM 使用了锁升级的优化方式。先使用偏向锁优先供同一线程使用(这里的了解下对象头的结构www.yuque.com/solider/rmn…)获取锁,如果失败(表示此时有锁竞争),就升级为轻量级锁,如果失败了就进行短暂的自旋(适应式自旋) ,防止线程被系统挂起。最后如果以上操作都失败的话就升级为重量级锁

所以是一步步升级上去的,最初也是通过很多轻量级的方式去锁定的。

那我们回归正题,ConcurrentHashMap 的 get 操作又是怎么做的?

get 操作流程

  1. 首先根据 KEY 计算出 hashcode,进行寻址,如果村子会直接返回值

  2. 如果是红黑树那就按照树的方式进行取值

  3. 如果是链表就按照链表的方式遍历取值

下面是源码(辅助理解):

public V get(Object key) {
    Node<K,V>[] tab; Node<K,V> e, p; int n, eh; K ek;
    int h = spread(key.hashCode());
    if ((tab = table) != null && (n = tab.length) > 0 &&
        (e = tabAt(tab, (n - 1) & h)) != null) {
        if ((eh = e.hash) == h) {
            if ((ek = e.key) == key || (ek != null && key.equals(ek)))
                return e.val;
        }
        else if (eh < 0)
            return (p = e.find(h, key)) != null ? p.val : null;
        while ((e = e.next) != null) {
            if (e.hash == h &&
                ((ek = e.key) == key || (ek != null && key.equals(ek))))
                return e.val;
        }
    }
    return null;
}

说下它的扩容操作流程

/**
     * Replaces all linked nodes in bin at given index unless table is
     * too small, in which case resizes instead.
     */
private final void treeifyBin(Node<K,V>[] tab, int index) {
    Node<K,V> b; int n;
    if (tab != null) {
        // 如果数组长度小于阈值64,不做红黑树的转换,直接扩容
        if ((n = tab.length) < MIN_TREEIFY_CAPACITY)
            tryPresize(n << 1);
        else if ((b = tabAt(tab, index)) != null && b.hash >= 0) {
            // 链表转换为红黑树
            synchronized (b) {
                if (tabAt(tab, index) == b) {
                    TreeNode<K,V> hd = null, tl = null;
                    // 遍历链表,初始化红黑树
                    for (Node<K,V> e = b; e != null; e = e.next) {
                        TreeNode<K,V> p =
                            new TreeNode<K,V>(e.hash, e.key, e.val,
                                              null, null);
                        if ((p.prev = tl) == null)
                            hd = p;
                        else
                            tl.next = p;
                        tl = p;
                    }
                    setTabAt(tab, index, new TreeBin<K,V>(hd));
                }
            }
        }
    }
}

MIN_TREEIFY_CAPACITY=64,意味着当数组的长度没有超过 64 的时候,数组的每个节点里都是链表,只会扩容。不会转换成红黑树,只有当数组长度大于或等于 64 时,才考虑把链表转换成红黑树。

我门看下扩容的方法tryPresize(int size),其内部调用了一个核心方法transfer(Node<K,V>[] tab, Node<K,V>[] nextTab),下面我分析下这个方法:

/**
     * Tries to presize table to accommodate the given number of elements.
     *
     * @param size number of elements (doesn't need to be perfectly accurate)
     */
private final void tryPresize(int size) {
    int c = (size >= (MAXIMUM_CAPACITY >>> 1)) ? MAXIMUM_CAPACITY :
    tableSizeFor(size + (size >>> 1) + 1);
    int sc;
    while ((sc = sizeCtl) >= 0) {
        Node<K,V>[] tab = table; int n;
        if (tab == null || (n = tab.length) == 0) {
            n = (sc > c) ? sc : c;
            if (U.compareAndSetInt(this, SIZECTL, sc, -1)) {
                try {
                    if (table == tab) {
                        @SuppressWarnings("unchecked")
                        Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n];
                        table = nt;
                        sc = n - (n >>> 2);
                    }
                } finally {
                    sizeCtl = sc;
                }
            }
        }
        else if (c <= sc || n >= MAXIMUM_CAPACITY)
            break;
        else if (tab == table) {
            int rs = resizeStamp(n);
            if (U.compareAndSetInt(this, SIZECTL, sc,
                                   (rs << RESIZE_STAMP_SHIFT) + 2))
                transfer(tab, null);
        }
    }
}

/**
     * Moves and/or copies the nodes in each bin to new table. See
     * above for explanation.
     */
private final void transfer(Node<K,V>[] tab, Node<K,V>[] nextTab) {
    int n = tab.length, stride;
    if ((stride = (NCPU > 1) ? (n >>> 3) / NCPU : n) < MIN_TRANSFER_STRIDE)
        // 计算步长
        stride = MIN_TRANSFER_STRIDE; // subdivide range
    if (nextTab == null) {            // initiating
        try {
            // 初始化新的HashMap
            @SuppressWarnings("unchecked")
            Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n << 1];// 扩容两倍
            nextTab = nt;
        } catch (Throwable ex) {      // try to cope with OOME
            sizeCtl = Integer.MAX_VALUE;
            return;
        }
        nextTable = nextTab;
        // 初始化的transferIndex为HashMap的数组长度
        transferIndex = n;
    }
    int nextn = nextTab.length;
    ForwardingNode<K,V> fwd = new ForwardingNode<K,V>(nextTab);
    boolean advance = true;
    boolean finishing = false; // to ensure sweep before committing nextTab
    for (int i = 0, bound = 0;;) {
        Node<K,V> f; int fh;
        while (advance) {
            int nextIndex, nextBound;
            if (--i >= bound || finishing)
                advance = false;
            else if ((nextIndex = transferIndex) <= 0) {
                i = -1;
                advance = false;
            }
            else if (U.compareAndSetInt
                     (this, TRANSFERINDEX, nextIndex,
                      nextBound = (nextIndex > stride ?
                                   nextIndex - stride : 0))) {
                bound = nextBound;
                i = nextIndex - 1;
                advance = false;
            }
        }
        if (i < 0 || i >= n || i + n >= nextn) {
            int sc;
            if (finishing) {
                nextTable = null;
                table = nextTab;
                sizeCtl = (n << 1) - (n >>> 1);
                return;
            }
            if (U.compareAndSetInt(this, SIZECTL, sc = sizeCtl, sc - 1)) {
                if ((sc - 2) != resizeStamp(n) << RESIZE_STAMP_SHIFT)
                    return;
                finishing = advance = true;
                i = n; // recheck before commit
            }
        }
        else if ((f = tabAt(tab, i)) == null)
            advance = casTabAt(tab, i, null, fwd);
        else if ((fh = f.hash) == MOVED)
            advance = true; // already processed
        else {
            synchronized (f) {
                if (tabAt(tab, i) == f) {
                    Node<K,V> ln, hn;
                    if (fh >= 0) {
                        int runBit = fh & n;
                        Node<K,V> lastRun = f;
                        for (Node<K,V> p = f.next; p != null; p = p.next) {
                            int b = p.hash & n;
                            if (b != runBit) {
                                runBit = b;
                                lastRun = p;
                            }
                        }
                        if (runBit == 0) {
                            ln = lastRun;
                            hn = null;
                        }
                        else {
                            hn = lastRun;
                            ln = null;
                        }
                        for (Node<K,V> p = f; p != lastRun; p = p.next) {
                            int ph = p.hash; K pk = p.key; V pv = p.val;
                            if ((ph & n) == 0)
                                ln = new Node<K,V>(ph, pk, pv, ln);
                            else
                                hn = new Node<K,V>(ph, pk, pv, hn);
                        }
                        setTabAt(nextTab, i, ln);
                        setTabAt(nextTab, i + n, hn);
                        setTabAt(tab, i, fwd);
                        advance = true;
                    }
                    else if (f instanceof TreeBin) {
                        TreeBin<K,V> t = (TreeBin<K,V>)f;
                        TreeNode<K,V> lo = null, loTail = null;
                        TreeNode<K,V> hi = null, hiTail = null;
                        int lc = 0, hc = 0;
                        for (Node<K,V> e = t.first; e != null; e = e.next) {
                            int h = e.hash;
                            TreeNode<K,V> p = new TreeNode<K,V>
                                (h, e.key, e.val, null, null);
                            if ((h & n) == 0) {
                                if ((p.prev = loTail) == null)
                                    lo = p;
                                else
                                    loTail.next = p;
                                loTail = p;
                                ++lc;
                            }
                            else {
                                if ((p.prev = hiTail) == null)
                                    hi = p;
                                else
                                    hiTail.next = p;
                                hiTail = p;
                                ++hc;
                            }
                        }
                        ln = (lc <= UNTREEIFY_THRESHOLD) ? untreeify(lo) :
                        (hc != 0) ? new TreeBin<K,V>(lo) : t;
                        hn = (hc <= UNTREEIFY_THRESHOLD) ? untreeify(hi) :
                        (lc != 0) ? new TreeBin<K,V>(hi) : t;
                        setTabAt(nextTab, i, ln);
                        setTabAt(nextTab, i + n, hn);
                        setTabAt(tab, i, fwd);
                        advance = true;
                    }
                }
            }
        }
    }
}

为什么要使用红黑树呢?

二叉查找树(BST)会随着树的深度增多退化成链表,查找的效率会大幅下降。

红黑树是一种自平衡的二叉查找树。除了具有二叉查找树(BST)的特征外,还是有自己的一些特征:

  1. 每个节点要么是黑色,要么是红色

  2. 根节点是黑色的

  3. 每隔叶子节点都是黑色的空节点(NIL 节点

  4. 如果一个节点是红色的,则它的子节点必须是黑色的(父子节点不能同时为红色

  1. 从任意一节点到其每个叶子节点的所有路径都包含相同数目的黑色节点(这是平衡的关键
  2. 新插入的节点默认为红色,插入后需要校验红黑树是否符合规则,不符合规则的话需要进行自平衡操作(自平衡操作包含:自旋(又分为左旋、右旋)、颜色反转

AVL 树也是平衡二叉树,为什么不选择 AVL 树而选择红黑树呢?

二叉查找树(BST)会随着树的深度增多退化成链表,查找的效率会大幅下降。并且红黑树不是严格意义上的二叉平衡树,对其中的数据进行修改操作的效率高于 AVL。

说下红黑树的自旋过程(os....握草)

后面持续更新。。。希望能帮到你 原文是这位大佬的,觉得大佬的思路挺好,后期我会按照自己的思路对数据结构这块做些延伸的补充:juejin.cn/post/684490…