Java 集合面试,你肯定也会被问到这些

Posted π大新

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Java 集合面试,你肯定也会被问到这些相关的知识,希望对你有一定的参考价值。

文章收录在 GitHub JavaKeeper ,N线互联网开发必备技能兵器谱

作为一位小菜 ”一面面试官“,面试过程中,我肯定会问 Java 集合的内容,同时作为求职者,也肯定会被问到集合,所以整理下 Java 集合面试题

说说常见的集合有哪些吧?

HashMap说一下,其中的Key需要重写hashCode()和equals()吗?

HashMap中key和value可以为null吗?允许几个为null呀?

HashMap线程安全吗?ConcurrentHashMap和hashTable有什么区别?

List和Set说一下,现在有一个ArrayList,对其中的所有元素按照某一属性大小排序,应该怎么做?

ArrayList 和 Vector 的区别

list 可以删除吗,遍历的时候可以删除吗,为什么

面向对象语言对事物的体现都是以对象的形式,所以为了方便对多个对象的操作,需要将对象进行存储,集合就是存储对象最常用的一种方式,也叫容器。

从上面的集合框架图可以看到,Java 集合框架主要包括两种类型的容器

  • 一种是集合(Collection),存储一个元素集合
  • 另一种是图(Map),存储键/值对映射。

Collection 接口又有 3 种子类型,List、Set 和 Queue,再下面是一些抽象类,最后是具体实现类,常用的有 ArrayList、LinkedList、HashSet、LinkedHashSet、HashMap、LinkedHashMap 等等。

集合框架是一个用来代表和操纵集合的统一架构。所有的集合框架都包含如下内容:

  • 接口:是代表集合的抽象数据类型。例如 Collection、List、Set、Map 等。之所以定义多个接口,是为了以不同的方式操作集合对象

  • 实现(类):是集合接口的具体实现。从本质上讲,它们是可重复使用的数据结构,例如:ArrayList、LinkedList、HashSet、HashMap。

  • 算法:是实现集合接口的对象里的方法执行的一些有用的计算,例如:搜索和排序。这些算法被称为多态,那是因为相同的方法可以在相似的接口上有着不同的实现。


说说常用的集合有哪些吧?

Map 接口和 Collection 接口是所有集合框架的父接口:

  1. Collection接口的子接口包括:Set、List、Queue
  2. List是有序的允许有重复元素的Collection,实现类主要有:ArrayList、LinkedList、Stack以及Vector等
  3. Set是一种不包含重复元素且无序的Collection,实现类主要有:HashSet、TreeSet、LinkedHashSet等
  4. Map没有继承Collection接口,Map提供key到value的映射。实现类主要有:HashMap、TreeMap、Hashtable、ConcurrentHashMap 以及 Properties 等

ArrayList 和 Vector 的区别

相同点:

  • ArrayList 和 Vector 都是继承了相同的父类和实现了相同的接口(都实现了List,有序、允许重复和null)

    extends AbstractList<E>
            implements List<E>, RandomAccess, Cloneable, java.io.Serializable
    
  • 底层都是数组(Object[])实现的

  • 初始默认长度都为10

不同点:

  • 同步性:Vector 中的 public 方法多数添加了 synchronized 关键字、以确保方法同步、也即是 Vector 线程安全、ArrayList 线程不安全

  • 性能:Vector 存在 synchronized 的锁等待情况、需要等待释放锁这个过程、所以性能相对较差

  • 扩容大小:ArrayList在底层数组不够用时在原来的基础上扩展 0.5 倍,Vector默认是扩展 1 倍

    扩容机制,扩容方法其实就是新创建一个数组,然后将旧数组的元素都复制到新数组里面。其底层的扩容方法都在 grow() 中(基于JDK8)

    • ArrayList 的 grow(),在满足扩容条件时、ArrayList以1.5 倍的方式在扩容(oldCapacity >> 1 ,右移运算,相当于除以 2,结果为二分之一的 oldCapacity)

      private void grow(int minCapacity) 
          // overflow-conscious code
          int oldCapacity = elementData.length;
          //newCapacity = oldCapacity + O.5*oldCapacity,此处扩容0.5倍
          int newCapacity = oldCapacity + (oldCapacity >> 1); 
          if (newCapacity - minCapacity < 0)
              newCapacity = minCapacity;
          if (newCapacity - MAX_ARRAY_SIZE > 0)
              newCapacity = hugeCapacity(minCapacity);
          // minCapacity is usually close to size, so this is a win:
          elementData = Arrays.copyOf(elementData, newCapacity);
      
      
    • Vector 的 grow(),Vector 比 ArrayList多一个属性,扩展因子capacityIncrement,可以扩容大小。当扩容容量增量大于0时、新数组长度为原数组长度**+扩容容量增量、否则新数组长度为原数组长度的2**倍

      private void grow(int minCapacity) 
          // overflow-conscious code
          int oldCapacity = elementData.length;
          //
          int newCapacity = oldCapacity + ((capacityIncrement > 0) ?
                                           capacityIncrement : oldCapacity);
          if (newCapacity - minCapacity < 0)
              newCapacity = minCapacity;
          if (newCapacity - MAX_ARRAY_SIZE > 0)
              newCapacity = hugeCapacity(minCapacity);
          elementData = Arrays.copyOf(elementData, newCapacity);
      
      

ArrayList 与 LinkedList 区别

  • 是否保证线程安全: ArrayList 和 LinkedList 都是不同步的,也就是不保证线程安全;
  • 底层数据结构: Arraylist 底层使用的是 Object 数组;LinkedList 底层使用的是双向循环链表数据结构;
  • 插入和删除是否受元素位置的影响:
    • ArrayList 采用数组存储,所以插入和删除元素的时间复杂度受元素位置的影响。 比如:执行 add(E e)方法的时候, ArrayList 会默认在将指定的元素追加到此列表的末尾,这种情况时间复杂度就是O(1)。但是如果要在指定位置 i 插入和删除元素的话( add(intindex,E element))时间复杂度就为 O(n-i)。因为在进行上述操作的时候集合中第 i 和第 i 个元素之后的(n-i)个元素都要执行向后位/向前移一位的操作。
    • LinkedList 采用链表存储,所以插入,删除元素时间复杂度不受元素位置的影响,都是近似 O ( 1 ) O(1) O(1),而数组为近似 O ( n ) O(n) O(n)
    • ArrayList 一般应用于查询较多但插入以及删除较少情况,如果插入以及删除较多则建议使用 LinkedList
  • 是否支持快速随机访问: LinkedList 不支持高效的随机元素访问,而 ArrayList 实现了 RandomAccess 接口,所以有随机访问功能。快速随机访问就是通过元素的序号快速获取元素对象(对应于 get(intindex)方法)。
  • 内存空间占用: ArrayList 的空间浪费主要体现在在 list 列表的结尾会预留一定的容量空间,而 LinkedList 的空间花费则体现在它的每一个元素都需要消耗比 ArrayList 更多的空间(因为要存放直接后继和直接前驱以及数据)。

高级工程师的我,可不得看看源码,具体分析下:

  • ArrayList工作原理其实很简单,底层是动态数组,每次创建一个 ArrayList 实例时会分配一个初始容量(没有指定初始容量的话,默认是 10),以add方法为例,如果没有指定初始容量,当执行add方法,先判断当前数组是否为空,如果为空则给保存对象的数组分配一个最小容量,默认为10。当添加大容量元素时,会先增加数组的大小,以提高添加的效率;

  • LinkedList 是有序并且支持元素重复的集合,底层是基于双向链表的,即每个节点既包含指向其后继的引用也包括指向其前驱的引用。链表无容量限制,但双向链表本身使用了更多空间,也需要额外的链表指针操作。按下标访问元素 get(i)/set(i,e) 要悲剧的遍历链表将指针移动到位(如果i>数组大小的一半,会从末尾移起)。插入、删除元素时修改前后节点的指针即可,但还是要遍历部分链表的指针才能移动到下标所指的位置,只有在链表两头的操作add()addFirst()removeLast()或用 iterator() 上的 remove() 能省掉指针的移动。此外 LinkedList 还实现了 Deque(继承自Queue接口)接口,可以当做队列使用。

不会囊括所有方法,只是为了学习,记录思想。

ArrayList 和 LinkedList 两者都实现了 List 接口

public class ArrayList<E> extends AbstractList<E>
        implements List<E>, RandomAccess, Cloneable, java.io.Serializable
public class LinkedList<E>
    extends AbstractSequentialList<E>
    implements List<E>, Deque<E>, Cloneable, java.io.Serializable

构造器

ArrayList 提供了 3 个构造器,①无参构造器 ②带初始容量构造器 ③参数为集合构造器

public class ArrayList<E> extends AbstractList<E>
        implements List<E>, RandomAccess, Cloneable, java.io.Serializable
   
public ArrayList(int initialCapacity) 
   if (initialCapacity > 0) 
       // 创建初始容量的数组
     this.elementData = new Object[initialCapacity];
     else if (initialCapacity == 0) 
     this.elementData = EMPTY_ELEMENTDATA;
     else 
   throw new IllegalArgumentException("Illegal Capacity: "+initialCapacity);
  

public ArrayList() 
  // 默认为空数组
  this.elementData = DEFAULTCAPACITY_EMPTY_ELEMENTDATA;

    
public ArrayList(Collection<? extends E> c)  //...       

LinkedList 提供了 2 个构造器,因为基于链表,所以也就没有初始化大小,也没有扩容的机制,就是一直在前面或者后面插插插~~

public LinkedList() 


public LinkedList(Collection<? extends E> c) 
    this();
    addAll(c);

// LinkedList 既然作为链表,那么肯定会有节点
private static class Node<E> 
    E item;
    Node<E> next;
    Node<E> prev;

    Node(Node<E> prev, E element, Node<E> next) 
        this.item = element;
        this.next = next;
        this.prev = prev;
    

插入

ArrayList:

public boolean add(E e) 
    // 确保数组的容量,保证可以添加该元素
    ensureCapacityInternal(size + 1);  // Increments modCount!!
    // 将该元素放入数组中
    elementData[size++] = e;
    return true;

private void ensureCapacityInternal(int minCapacity) 
    // 如果数组是空的,那么会初始化该数组
    if (elementData == DEFAULTCAPACITY_EMPTY_ELEMENTDATA) 
        // DEFAULT_CAPACITY 为 10,所以调用无参默认 ArrayList 构造方法初始化的话,默认的数组容量为 10
        minCapacity = Math.max(DEFAULT_CAPACITY, minCapacity);
    

    ensureExplicitCapacity(minCapacity);


private void ensureExplicitCapacity(int minCapacity) 
    modCount++;

    // 确保数组的容量,如果不够的话,调用 grow 方法扩容
    if (minCapacity - elementData.length > 0)
        grow(minCapacity);

//扩容具体的方法
private void grow(int minCapacity) 
    // 当前数组的容量
    int oldCapacity = elementData.length;
    // 新数组扩容为原来容量的 1.5 倍
    int newCapacity = oldCapacity + (oldCapacity >> 1);
    // 如果新数组扩容容量还是比最少需要的容量还要小的话,就设置扩充容量为最小需要的容量
    if (newCapacity - minCapacity < 0)
        newCapacity = minCapacity;
    //判断新数组容量是否已经超出最大数组范围,MAX_ARRAY_SIZE = Integer.MAX_VALUE - 8
    if (newCapacity - MAX_ARRAY_SIZE > 0)
        newCapacity = hugeCapacity(minCapacity);
    // minCapacity is usually close to size, so this is a win:
    // 复制元素到新的数组中
    elementData = Arrays.copyOf(elementData, newCapacity);

当然也可以插入指定位置,还有一个重载的方法 add(int index, E element)

public void add(int index, E element) 
    // 判断 index 有没有超出索引的范围
    rangeCheckForAdd(index);
    // 和之前的操作是一样的,都是保证数组的容量足够
    ensureCapacityInternal(size + 1);  // Increments modCount!!
    // 将指定位置及其后面数据向后移动一位
    System.arraycopy(elementData, index, elementData, index + 1,
                     size - index);
    // 将该元素添加到指定的数组位置
    elementData[index] = element;
    // ArrayList 的大小改变
    size++;

可以看到每次插入指定位置都要移动元素,效率较低。

再来看 LinkedList 的插入,也有插入末尾,插入指定位置两种,由于基于链表,肯定得先有个 Node

private static class Node<E> 
    E item;
    Node<E> next;
    Node<E> prev;

    Node(Node<E> prev, E element, Node<E> next) 
        this.item = element;
        this.next = next;
        this.prev = prev;
    

public boolean add(E e) 
    // 直接往队尾加元素
    linkLast(e);
    return true;


void linkLast(E e) 
    // 保存原来链表尾部节点,last 是全局变量,用来表示队尾元素
    final Node<E> l = last;
    // 为该元素 e 新建一个节点
    final Node<E> newNode = new Node<>(l, e, null);
    // 将新节点设为队尾
    last = newNode;
    // 如果原来的队尾元素为空,那么说明原来的整个列表是空的,就把新节点赋值给头结点
    if (l == null)
        first = newNode;
    else
    // 原来尾结点的后面为新生成的结点
        l.next = newNode;
    // 节点数 +1
    size++;
    modCount++;


public void add(int index, E element) 
    // 检查 index 有没有超出索引范围
    checkPositionIndex(index);
    // 如果追加到尾部,那么就跟 add(E e) 一样了
    if (index == size)
        linkLast(element);
    else
    // 否则就是插在其他位置
     linkBefore(element, node(index));


//linkBefore方法中调用了这个node方法,类似二分查找的优化
Node<E> node(int index) 
    // assert isElementIndex(index);
    // 如果 index 在前半段,从前往后遍历获取 node
    if (index < (size >> 1)) 
        Node<E> x = first;
        for (int i = 0; i < index; i++)
            x = x.next;
        return x;
     else 
        // 如果 index 在后半段,从后往前遍历获取 node
        Node<E> x = last;
        for (int i = size - 1; i > index; i--)
            x = x.prev;
        return x;
    


void linkBefore(E e, Node<E> succ) 
    // assert succ != null;
    // 保存 index 节点的前节点
    final Node<E> pred = succ.prev;
    // 新建一个目标节点
    final Node<E> newNode = new Node<>(pred, e, succ);
    succ.prev = newNode;
    // 如果是在开头处插入的话
    if (pred == null)
        first = newNode;
    else
        pred.next = newNode;
    size++;
    modCount++;

获取

ArrayList 的 get() 方法很简单,就是在数组中返回指定位置的元素即可,所以效率很高

public E get(int index) 
    // 检查 index 有没有超出索引的范围
    rangeCheck(index);
    // 返回指定位置的元素
    return elementData(index);

LinkedList 的 get() 方法,就是在内部调用了上边看到的 node() 方法,判断在前半段还是在后半段,然后遍历得到即可。

public E get(int index) 
    checkElementIndex(index);
    return node(index).item;


HashMap的底层实现

什么时候会使用HashMap?他有什么特点?

你知道HashMap的工作原理吗?

你知道get和put的原理吗?equals()和hashCode()的都有什么作用?

你知道hash的实现吗?为什么要这样实现?

如果HashMap的大小超过了负载因子(load factor)定义的容量,怎么办?

HashMap 在 JDK 7 和 JDK8 中的实现方式略有不同。分开记录。

深入 HahsMap 之前,先要了解的概念

  1. initialCapacity:初始容量。指的是 HashMap 集合初始化的时候自身的容量。可以在构造方法中指定;如果不指定的话,总容量默认值是 16 。需要注意的是初始容量必须是 2 的幂次方。(1.7中,已知HashMap中将要存放的KV个数的时候,设置一个合理的初始化容量可以有效的提高性能

    static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
    
  2. size:当前 HashMap 中已经存储着的键值对数量,即 HashMap.size()

  3. loadFactor:加载因子。所谓的加载因子就是 HashMap (当前的容量/总容量) 到达一定值的时候,HashMap 会实施扩容。加载因子也可以通过构造方法中指定,默认的值是 0.75 。举个例子,假设有一个 HashMap 的初始容量为 16 ,那么扩容的阀值就是 0.75 * 16 = 12 。也就是说,在你打算存入第 13 个值的时候,HashMap 会先执行扩容。

  4. threshold:扩容阀值。即 扩容阀值 = HashMap 总容量 * 加载因子。当前 HashMap 的容量大于或等于扩容阀值的时候就会去执行扩容。扩容的容量为当前 HashMap 总容量的两倍。比如,当前 HashMap 的总容量为 16 ,那么扩容之后为 32 。

  5. table:Entry 数组。我们都知道 HashMap 内部存储 key/value 是通过 Entry 这个介质来实现的。而 table 就是 Entry 数组。

JDK1.7 实现

JDK1.7 中 HashMap 由 数组+链表 组成(“链表散列” 即数组和链表的结合体),数组是 HashMap 的主体,链表则是主要为了解决哈希冲突而存在的(HashMap 采用 “拉链法也就是链地址法” 解决冲突),如果定位到的数组位置不含链表(当前 entry 的 next 指向 null ),那么对于查找,添加等操作很快,仅需一次寻址即可;如果定位到的数组包含链表,对于添加操作,其时间复杂度依然为 O(1),因为最新的 Entry 会插入链表头部,即需要简单改变引用链即可,而对于查找操作来讲,此时就需要遍历链表,然后通过 key 对象的 equals 方法逐一比对查找。

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

源码解析

构造方法

《阿里巴巴 Java 开发手册》推荐集合初始化时,指定集合初始值大小。(说明:HashMap 使用HashMap(int initialCapacity) 初始化)建议原因: https://www.zhihu.com/question/314006228/answer/611170521

// 默认的构造方法使用的都是默认的初始容量和加载因子
// DEFAULT_INITIAL_CAPACITY = 16,DEFAULT_LOAD_FACTOR = 0.75f
public HashMap() 
    this(DEFAULT_INITIAL_CAPACITY, DEFAULT_LOAD_FACTOR);


// 可以指定初始容量,并且使用默认的加载因子
public HashMap(int initialCapacity) 
    this(initialCapacity, DEFAULT_LOAD_FACTOR);


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;
    threshold = initialCapacity;
    // 空方法
    init();


public HashMap(Map<? extends K, ? extends V> m) 
  this(Math.max((int) (m.size() / DEFAULT_LOAD_FACTOR) + 1,
                DEFAULT_INITIAL_CAPACITY), DEFAULT_LOAD_FACTOR);
  inflateTable(threshold);
  putAllForCreate(m);

HashMap 的前 3 个构造方法最后都会去调用 HashMap(int initialCapacity, float loadFactor) 。在其内部去设置初始容量和加载因子。而最后的 init() 是空方法,主要给子类实现,比如LinkedHashMap。

put() 方法
public V put(K key, V value) 
    // 如果 table 数组为空时先创建数组,并且设置扩容阀值
    if (table == EMPTY_TABLE) 
        inflateTable(threshold);
    
    // 如果 key 为空时,调用 putForNullKey 方法特殊处理
    if (key == null)
        return putForNullKey(value);
    // 计算 key 的哈希值
    int hash = hash(key);
    // 根据计算出来的哈希值和当前数组的长度计算在数组中的索引
    int i = indexFor(hash, table.length);
    // 先遍历该数组索引下的整条链表
    // 如果该 key 之前已经在 HashMap 中存储了的话,直接替换对应的 value 值即可
    for (Entry<K,V> e = table[i]; e != null; e = e.next) 
        Object k;
       //先判断hash值是否一样,如果一样,再判断key是否一样,不同对象的hash值可能一样
        if (e.hash == hash && ((k = e.key) == key || key.equals(k))) 
            V oldValue = e.value;
            e.value = value;
            e.recordAccess(this);
            return 

以上是关于Java 集合面试,你肯定也会被问到这些的主要内容,如果未能解决你的问题,请参考以下文章

Java集合面试题汇总篇

面试阿里,字节跳动90%会被问到的微服务,你确定不进来看看吗?

软件测试面试一定会被问到的10个技术问题(附答案)

面试大厂,90%会被问到的Java面试题(附答案)

面试大厂,90%会被问到的Java面试题(附答案)

面试阿里,腾讯90%会被问到的25个问题,附答案!