三、通勤路上搞定 Java 集合面试

漫谈面试系列

前言

从这章开始,我们正式进入Java代码的相关面试,部分较为基础的、带坑的面试题我将放在《漫聊系列》里面,而体系相对较为庞大的内容我将单独分为一个章节放在本系列当中进行讲解。

我们先来看Java中的集合,集合不仅是面试问的多,日常使用也多,只不过有许多细节在我们使用的过程没有留意,而这些细节便是面试经常问的内容。下面我们通过几道常问的面试题进行相关知识点的学习:

1. Map,List 和 Set 的区别。
2. ArrayList随机增删效率真的比LinkedList低吗?
3. HashMap 的扩容过程是怎么样的?
4. HashMap 是线程安全的吗,为什么不是线程安全的(JDK7,8)?
5. 编写一个LRU缓存工具类

本系列不会讲解过多的源码,只会以结论的形式给读者直接提供面试题的答案,如果读者需要往更深层次发展,可以去阅览相应的博客进行深度学习。

在进入具体讲解之前,我们先看看java的集合家族:
Collection

Map

通过上面两张图,我们可以基本了解到集合分为两大类,分布是Collection和Map,接下来,我们会根据面试题进一步讲解这两大集合类的点点滴滴。


1. Map,List 和 Set 的区别

Map 存储的是映射关系即 key-value 键值对,而Collection存储的是单列的集合,其中List子类可以有序的存储重复的值,而 Set 子类存储无序并且不可重复的值。

一般来说,大部分人都能回答以上两点,但是回答完这些,只能说是基本知识掌握了,更深层次的问题会问:

  1. Map 没有继承 Iterable 接口,他是如何实现循环的?
  2. Set 是利用什么来实现不可重复的?

以上问题就算面试官不问,但是也尽量回答一下,基本上能回答这两个问题说明你对Java的集合有更深层次的了解。

    1. Map 没有继承 Iterable 接口,他是如何实现foreach循环的?
      第一个问题,Map 类通过使用一个 Set 类来实现一种类似于视图的功能,并利用这个 Set 实现 foreach (因为 Set 顶层是 Iterable 接口),但是实际上这个 Set 类的方法在实现上都是通过调用 Map 自身的方法或字段,即该 Set 类只能算是一种工具类,本身不存储数据,下面我们通过 HashMap 来简单了解下 Map 如何通过 EntrySet 类实现相关功能。
      EntrySet与Map的关系.png

可能会有人问为什么 Map 不直接继承 Iterable 或 Collection 接口从而获取对应的循环功能,这里涉及到一个面向对象的接口分离原则,由于 Map 主要是存储一系列的键值对操作,有针对键值对的操作方式,而 Collection 是存储一个个符合某个特征对象的集合,操作方式与 Map 不一样,再细一点讲,Map并不算集合的一员,如果想对 Map 进行遍历操作,则可以利用组合的方式,“借用” Collection 的特性来实现 Map 的遍历,而无需添加 Map 类的负担。

    1. Set 是利用什么来实现不可重复的?
      先说结论,Set 本质上是只有 key 的 Map,因为 Map 的 key 是不允许重复的,重复的话就无法指定一个 key 搜索正确的 value 了。
      同 Map 不直接继承 Collection 或 Iterable 接口的理由一样,既然已经有接口实现了对某个值的重复判定,那我就利用组合的形式借用一下这种特性,因此在 HashSet 中,里面会有个 HashMap 字段,当我们 new HashSet() 的时候,构造函数里面代码其实是 new HashMap() ;我们调用 Set 的 add() 方法实际上就是将对象存入 HashMap当中,只不过 value 为 同一个Object对象罢了。
      下面我们通过一张图来了解下 HashSet 如何组合 HashMap 实现其基本的方法。
      HashSet

    经过上面两个问题,不知道读者有没有看出一些共同点。 Set 这个类本身都不存数据,一直是一个“工具人” 的身份,真正存数据的类最终还是归属于 Map 来存,即便是 TreeSet,内部也是引用了一个 TreeMap 类的字段,只不过这些 Map 引用的 value 可能为 null 也可能为同一个 Object。Set 只不过在对数据操作的时候可能有一些额外的操作,即 Set 可以说是 Map 的视图。

2. ArrayList随机增删效率真的比LinkedList低吗

很多时候,面试都会问 ArrayList 和 LinkedList 的区别,大部分人都能回答出

ArrayList 基于数组 , LinkedList 基于链表,前者的随机访问效率高,后者的随机增删效率高。

大体上,这应该是90%面试人都会回答的内容,但是,在执行效率上,如果不区分业务场景直接这样回答,就无法体现出你在日常开发中有没有根据业务进行技术选型。
接下来让我们了解下两者在一些操作中的性能开销情况:

操作ArrayListLinkedList
随机查询由于是数组,几乎无开销需要从链表头进行遍历
随机插入1.插入位置后的数组后移
2.可能出现数组扩容带来的开销
1.需要从链表头进行遍历
2.对node节点进行new
随机删除数组迁移需要从链表头进行遍历

从上表可以看出,

  1. ArrayList 的开销主要体现在数组的部分迁移以及数组扩容。

  2. LinkedList 的开销体现在链表的遍历以及新增时候对 node 节点的 new 操作。

    如果业务场景是:

    1. 预先知道数组大小区间范围
    2. 需要大量的随机访问
    3. 只需要在数组靠中后的位置进行随机增删
      那我们就选用 ArrayList

如果业务场景是:
1.  需要经常在链表靠前的位置进行数据随机的新增(例如队列)或删除
2.  数组长度不可预知
那么我们就选用 LinkedList

因此,回答题目这个问题,主要还是体现在需要增删的“位置”上,如果是在靠前位置的增删,那么 LinkedList 效率高,反之,ArrayList 的效率高。不过整体的随机增删,ArrayList 的效率整体也是相较于 LinkedList 高,因此除非某些上述特定的场景,大部分时候用 ArrayList 即可。想了解更多信息可以参考以下博客内容:ArrayList和LinkedList插入效率对比

3. HashMap 的扩容过程是怎么样的?

目前 HashMap 在笔试或者技术面中几乎是100%考的一个知识点,因为 HashMap 涉及了数据结构、功能设计、算法等大量的知识点,基本上能搞懂 HashMap 的底层,可以侧面说明你个人编程能力方面,以及技术方面有钻研的精神,因此,目前对 HashMap 底层了解只能算是 Java 开发人员的基本功。
由于网上关于 HashMap 底层原理的介绍已经非常多了(需要补基础的点击这里HashMap源码解析),接下来我会简单通过动图的形式介绍下jdk1.7和1.8下 HashMap 的扩容过程。HashMap 本身是一个是一个数组+链表/红黑树(jdk1.8)的数据结构,而存储的每个数据元素都是一个Entry/Node(jdk1.8) 类,每次扩容主要是针对数组进行扩容,然后将元素的hash进行一系列的操作获取其在新数组的下标值,然后存放到对应的新数组位置上。因此 HashMap 扩容的核心主要是元素数据的迁移,以下代码为jdk1.7下 HashMap 元素迁移的核心代码:

void transfer(Entry[] newTable) {
        Entry[] src = table;
        int newCapacity = newTable.length;
        for (int j = 0; j < src.length; j++) {
            //循环数组
            Entry<K,V> e = src[j];
            if (e != null) {
                src[j] = null;
                 //循环链表
                do {
                    Entry<K,V> next = e.next;
                    int i = indexFor(e.hash, newCapacity);
                    e.next = newTable[i];
                    newTable[i] = e;
                    e = next;
                } while (e != null);
            }
        }
    }

接下来让我们看个动图来了解下jdk1.7下 HashMap 在扩容的时候是如何将一个链表上的元素放入到新的数组里面:

jdk1.7 Hashmap同一链表下的元素迁移过程

而在jdk1.8下,HashMap 的数据迁移则进行了一些优化,具体操作是不再进行rehash,而直接将旧数组的长度与元素的hashCode进行与’&'操作(这里用的不是key的hash,而是Node的hash),然后为0的元素存放到原本的下标位置,而为1的则存到原本的下标+旧数组长度的位置,下面通过扩容源码以及几张图简单了解下jdk1.8下的 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;
        //.....判断目前数组的长度或长度*2是否大于最大值,不是的话才进行扩容
        Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
        //数据迁移前便将table指向新数组
        table = newTab;
        //对老数据进行迁移操作
        if (oldTab != null) {
            for (int j = 0; j < oldCap; ++j) {
                Node<K,V> e;
                if ((e = oldTab[j]) != null) {
                    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 { // preserve order
                        //最核心的点,如果该链表有多个元素,进行循环迁移
                        //这里将链表内的元素分成了两个链表,一个是高位链表一个是低位链表
                        Node<K,V> loHead = null, loTail = null;
                        Node<K,V> hiHead = null, hiTail = null;
                        Node<K,V> next;
                        do {
                            next = e.next;
                            //通过旧数组长度与元素的hash进行,如果为0则算入低位链表
                            if ((e.hash & oldCap) == 0) {
                                if (loTail == null)
                                    loHead = e;
                                else
                                    loTail.next = e;
                                loTail = e;
                            }
                            else {
                                //与操作结果为1,设置高位链表元素
                                if (hiTail == null)
                                    hiHead = e;
                                else
                                    hiTail.next = e;
                                hiTail = e;
                            }
                        } while ((e = next) != null);
                        //将低位链表放入新数组的原下标中
                        if (loTail != null) {
                            loTail.next = null;
                            newTab[j] = loHead;
                        }
                        //将高位链表放入新数组的原下标+旧数组长度的位置中
                        if (hiTail != null) {
                            hiTail.next = null;
                            newTab[j + oldCap] = hiHead;
                        }
                    }
                }
            }
        }
        return newTab;
}

结合上面的代码以及下面的动图,读者应该很容易理解jdk1.8下HashMap元素的迁移过程
jdk1.8下HashMap元素的迁移过程.gif

了解完 HashMap 的扩容过程,在下一节我们一起看看 HashMap 在高并发情况下多个线程进行扩容的时候会发生什么。

jdk1.7下,HashMap 在扩容以及数据迁移结束后,才会将 table字段指向新的数组,而jdk1.8下,在数据迁移前便会将 table字段指向新的数组。

4. HashMap 是线程安全的吗,为什么不是线程安全的?

介绍完基本的扩容过程,接下来就提一提 HashMap 高并发时候产生的线程安全问题,因为这个问题也是非常不好理解的(特别jdk1.7),下面让我们一起看看如何回答这个面试题。
首先出现线程不安全主要体现在两个点:

  1. 多个线程同时进行 put 操作的时候,可能出现添加的数据被覆盖。
  2. 多个线程同时进行扩容的操作时,则容易出现环链表,导致下次有线程搜索相应的 key 的时候进入死循环 ;而在 jdk1.8 以后由于使用了尾插法,基本不会出现环形链表,但会出现数据丢失。

接下来让我们看看,高并发扩容的情况下,如何导致查询死循环的:
环形链表产生过程
可以看出,导致环形链表的原因就是“头插法”,因为在每次hashmap进行数据迁移的时候,会将链表进行倒置(如果刚好这个链表上面的数据rehash后都处于同一个新链表的话),而对挂起的线程来说他并不知道链表已经被倒置了,因此在他进行新一轮的迁移的时候,又会进行一次倒置,最终导致相关的节点next指针指向了他前面的元素。

网上找了许多有关于高并发下HashMap导致线程死循环的博客,他们线程A的条件是处于 'Entry next = e.next;' 这个语句的时候进行挂起,但是根据实际流程走下来,这种情况不会导致查询的时候进入死循环,而是在线程A进行扩容的时候就已经进入死循环了,因为后续的一个语句 'e.next = newTable[i];'会将当前e的next指针指向一个非null值,因此这个线程会一直在while语句块里面,有兴趣的读者可以自己一步一步走一遍流程。

在jdk1.8中,HashMap 的数据迁移改为“尾插法”,即链表的元素顺序不会改变,因此jdk1.8中的高并发下使用 HashMap 几乎不会出现死循环的问题,但是仍然会出现数据的丢失,具体丢失原因还是由于多个线程同时写的时候出现了数据的覆盖,无论是否处于扩容状态。

5. 编写一个LRU缓存工具类

可能不少人在笔试或面试的时候都会遇到这个问题,那就是如何快速编写一个LRU缓存的工具类,有的读者不明白为什么在集合这章节会出现LRU缓存,其实我们目前可以通过一个 Map 实现类来迅速编写一个 LRU 缓存工具类,这个 Map 类便是 LinkedHashMap。LinkedHashMap 继承于 HashMap ,只不过重写了 HashMap 中的Node 类,改为带有前后指针的 Entry 类。
可能许多人只以为 LinkedHashMap 只是一个有序的 HashMap ,但这个有序是有两个场景应用的,在他的构造函数里面是表明了你这个有序到底是插入顺序还是访问顺序,如果我们要实现LRU缓存,那么我们就得选择访问顺序。
下面代码中,参数 accessOrder 便是用来设置你是插入顺序还是访问顺序,如果为true则为访问顺序,在其他构造函数中,该成员变量都会默认设置为false。

LinkedHashMap 当中,如果 accessOrder 为 true,那么头节点便属于最少使用的节点,而尾节点属于最近使用过的节点

public LinkedHashMap(int initialCapacity,
                         float loadFactor,
                         boolean accessOrder) {
        super(initialCapacity, loadFactor);
        this.accessOrder = accessOrder;
    }

同时,在 HashMap 里面,就已经提前准备了相应的回调方法:获取节点后的回调、插入节点后的回调以及删除节点后的回调:

// Callbacks to allow LinkedHashMap post-actions
void afterNodeAccess(Node<K,V> p) { }
void afterNodeInsertion(boolean evict) { }
void afterNodeRemoval(Node<K,V> p) { }

而这些方法,LinkedHashMap 都进行了重写,接下来我们主要讲解下获取回调以及插入回调。

  1. 获取回调
    获取回调主要是将get的节点放入链表的尾部,即将当前节点赋值给 tail 成员变量,如果该节点存在前后关联的节点,那么便将前后关联的节点进行连接。
  2. 插入回调
    插入回调最主要的是判断是否要删除头节点,从而实现 LRU 缓存,这个方法会调用一个 removeEldestEntry() 方法来判断是否删除头节点,而开发者便可以通过重写这个方法来实现一些自定义的判断逻辑,最终实现业务相关的 LRU 缓存
void afterNodeInsertion(boolean evict) { // possibly remove eldest
      LinkedHashMap.Entry<K,V> first;
      if (evict && (first = head) != null && removeEldestEntry(first)) {
          K key = first.key;
          removeNode(hash(key), key, null, false, true);
      }
}
protected boolean removeEldestEntry(Map.Entry<K,V> eldest) {
      return false;
}

所以,如果我们要实现自己的 LRU 缓存工具类,只需要继承 LinkedHashMap ,并重写其 removeEldestEntry 方法即可,如果返回true,则代表要删除节点。最后,我们看个简单的例子,来了解如何实现一个基本的LRU 缓存工具类。

public class LRUCache extends LinkedHashMap<String,Object> {
    private static final int maxSize = 32;

    public LRUCache(int initialCapacity, float loadFactor){
        super(initialCapacity,loadFactor,true);
    }

    @Override
    protected boolean removeEldestEntry(Map.Entry<String, Object> eldest) {
        return this.size() > maxSize;
    }
}

总结

本章节主要是介绍了集合中的一些平时经常被忽略的细节,同时这些细节也是面试中常问的一些点。集合作为一个开发人员经常使用的技术点,频繁的出现在我们的项目当中,要说不会用,那是不可能的,但是里面的一些细节点如功能设计、底层原理等,确实值得我们留意和思考。

接下来,我们回到最初的五个问题

1. Map,List 和 Set 的区别。
2. ArrayList随机增删效率真的比LinkedList低吗?
3. HashMap 的扩容过程是怎么样的?
4. HashMap 是线程安全的吗,为什么不是线程安全的(JDK7,8)?
5. 编写一个LRU缓存工具类

如果你们可以很流畅的回答这些问题,那么恭喜你,该章节的内容已经全部掌握,如果不行,希望可以回到对应问题讲解的地方,或者对某个不了解的点进行额外的知识搜索,尽量用自己组织的语言回答这些问题。

  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值