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 接口是所有集合框架的父接口:
- Collection接口的子接口包括:Set、List、Queue
- List是有序的允许有重复元素的Collection,实现类主要有:ArrayList、LinkedList、Stack以及Vector等
- Set是一种不包含重复元素且无序的Collection,实现类主要有:HashSet、TreeSet、LinkedHashSet等
- 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
- ArrayList 采用数组存储,所以插入和删除元素的时间复杂度受元素位置的影响。 比如:执行
- 是否支持快速随机访问: 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 之前,先要了解的概念
-
initialCapacity:初始容量。指的是 HashMap 集合初始化的时候自身的容量。可以在构造方法中指定;如果不指定的话,总容量默认值是 16 。需要注意的是初始容量必须是 2 的幂次方。(1.7中,已知HashMap中将要存放的KV个数的时候,设置一个合理的初始化容量可以有效的提高性能)
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
-
size:当前 HashMap 中已经存储着的键值对数量,即
HashMap.size()
。 -
loadFactor:加载因子。所谓的加载因子就是 HashMap (当前的容量/总容量) 到达一定值的时候,HashMap 会实施扩容。加载因子也可以通过构造方法中指定,默认的值是 0.75 。举个例子,假设有一个 HashMap 的初始容量为 16 ,那么扩容的阀值就是 0.75 * 16 = 12 。也就是说,在你打算存入第 13 个值的时候,HashMap 会先执行扩容。
-
threshold:扩容阀值。即 扩容阀值 = HashMap 总容量 * 加载因子。当前 HashMap 的容量大于或等于扩容阀值的时候就会去执行扩容。扩容的容量为当前 HashMap 总容量的两倍。比如,当前 HashMap 的总容量为 16 ,那么扩容之后为 32 。
-
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 集合面试,你肯定也会被问到这些的主要内容,如果未能解决你的问题,请参考以下文章