关于作者
程序猿周周
⌨️ 短视频小厂BUG攻城狮
如果文章对你有帮助,记得关注、点赞、收藏,一键三连哦,你的支持将成为我最大的动力
本文是《后端面试小册子》系列的第 8️⃣ 篇文章,该系列将整理和梳理笔者作为 Java 后端程序猿在日常工作以及面试中遇到的实际问题,通过这些问题的系统学习,也帮助笔者顺利拿到阿里、字节、华为、快手等多个大厂 Offer,也祝愿大家能够早日斩获自己心仪的 Offer。
PS:《后端面试小册子》已整理成册,目前共十三章节,总计约二十万字,欢迎关注公众号【程序猿周周】获取电子版和更多学习资料(最新系列文章也会在此陆续更新)。公众号后台可以回复关键词「电⼦书」可获得这份面试小册子。文中所有内容都会在 Github 开源,项目地址 csnotes,如文中存在错误,欢迎指出。如果觉得文章还对你有所帮助,赶紧点个免费的 star 支持一下吧!
标题 | 地址 |
---|---|
MySQL数据库面试题总结(2022版) | https://blog.csdn.net/adminpd/article/details/122910606 |
Redis面试题总结(2022版) | https://blog.csdn.net/adminpd/article/details/122934938 |
计算机网络面试题总结(2022版) | https://blog.csdn.net/adminpd/article/details/122973684 |
操作系统面试题总结(2022版) | https://blog.csdn.net/adminpd/article/details/122994599 |
Linux面试题总结(2022版) | https://blog.csdn.net/adminpd/article/details/122994862 |
Spring面试题总结(2022版) | https://blog.csdn.net/adminpd/article/details/123016872 |
Java基础面试题总结(2022版) | https://blog.csdn.net/adminpd/article/details/123080189 |
Java集合面试题总结(2022版) | https://blog.csdn.net/adminpd/article/details/123171501 |
Java并发面试题总结(2022版) | https://blog.csdn.net/adminpd/article/details/123266624 |
Java虚拟机面试题总结(2022版) | https://blog.csdn.net/adminpd/article/details/123412605 |
Java异常面试题总结(2022版) | https://blog.csdn.net/adminpd/article/details/123462676 |
设计模式面试题总结(2022版) | https://blog.csdn.net/adminpd/article/details/123490442 |
Dubbo面试题总结(2022版) | https://blog.csdn.net/adminpd/article/details/123538243 |
Netty面试题总结(2022版) | https://blog.csdn.net/adminpd/article/details/123564362 |
集合是用于存放对象的容器,而集合类是 Java 的一种数据结构,常用的集合类定义在 java.util 包中。
需要注意的是,集合类只能存放对象,不能存放基本数据类型,且是对象的引用,而非对象本身。
好处
Java 中的容器集合类主要包括 Collection(集合) 和 Map(映射) 两大类,而 Collection 接口又包括 Set(无序不重复) 和 List(有序可重复) 两个子接口。
1)List 接口的实现类主要有:ArrayList、LinkedList、Stack 以及 Vector 等;
2)Set 接口的实现类主要有:HashSet、TreeSet、LinkedHashSet等;
3)Map 接口的实现类主要有:HashMap、TreeMap、Hashtable、ConcurrentHashMap 以及 Properties 等。
数组固定长度,集合成都可变;
数组可以存放基本数据类型,集合只能存储引用数据类型;
数组存储的元素必须是同一个数据类型,集合支持存储不同数据类型。
1)AList 接口 ArrayList、LinkedList 以及 Vector 等都可以存储多个 null;
2)Set 接口中 HashSet、LinkedSet 可以存储一个 null,TreeSet 不能存储 null;
3)Map 接口中 HashMap、LinkedHashMa 的 key 与 value 均可以为 null。Treemap 的 key 不可以为 null,value 可以为 null。HashTable、ConcurrentHashMap 的 key 与 value 均不能为 null。
二者都是 List 接口的实现,顾名思义,从数据结构上看,它们底层一个是数组,一个是链表的数据结构。前者是数组队列,相当于动态数组;后者为双向链表结构,也可当作堆栈、队列、双端队列。
1)访问效率的不同
2)利用效率的不同
从利用效率来看,ArrayList 自由性较低,因为它需要有手动或默认一个固定大小的容量,但是它的使用比较方便,只需要创建,然后添加数据,通过调用下标进行使用;而 LinkedList 自由性较高,能够动态的随数据量的变化而变化,但是它不便于使用。
3)控件开销的不同
ArrayList 控件开销在于底层数组中预留了一定空间;而LinkList主要控件开销在于需要存储结点信息以及结点指针信息。
Vector 是线程安全的,它的方法之间都加了 synchronized 关键字修饰,而 ArrayList 是非线程安全的。因此在不考虑线程安全情况下使用 ArrayList 的效率更高。
ArrayList 和 Vector 都有一个初始的容量大小,并在必要时对数组进行扩容。但 Vector 每次增长两倍,而 ArrayList 增长 1.5 倍。
唯一正确的做法是使用迭代器 Iterator.remove() 方法。
Iterator<Integer> it=list.iterator();
while(it.hasNext()){
it.remove();
}
如果直接使用 foreach 循环遍历时删除时,该语法糖会自动生成一个 Iterator 进行遍历集合,但 Iterator 不允许遍历中的元素被修改,故会抛出 ConcurrentModificationException 异常。
for(Integer i : list){
list.remove(I);
}
ListIterator 是一个继承于 Iterator 接口,功能更加强大的迭代器,但只能用于各种 List 类型的访问。
故二者的主要区别:
RandomAccess 接口是 Java 集合框架中为了标记 List 集合是否支持随机访问而设计。
即只要 List 集合实现这个接口,就能支持快速随机访问,按位置读取元素的平均时间复杂度是 o(1),如 ArrayList。而没有实现这个接口,表示不支持随机访问,如 LinkedList。
通过源码和测试,我们可以发现:支持 RandomAccess 接口的集合使用 for 循环比使用迭代器 Iterator 更快,反之,不支持 RandomAccess 接口的集合使用 Iterator 或 foreach 更优。
在 Collection 集合类中,有线程安全和线程不安全这两大类型。对于线程不安全的类,并发情况下可能会出现快速失败(fail-fast) 情况,而线程安全的类,可能出现安全失败(fail-safe) 的情况。
这里需要注意的是,以上情况都是针对并发而言。
当遍历一个集合对象时,如果集合对象的结构被修改了,就会抛出 ConcurrentModificationExcetion 异常。但需要注意的是,迭代器的快速失败行为无法得到保证,因为一般来说,不可能对是否出现不同步并发修改做出任何硬性保证。快速失败迭代器则会尽最大努力抛出 ConcurrentModificationException。
我们以 ArrayList 为例,ArrayList 继承自 AbstractList 类,AbstractList 内部有一个 modCount 属性,代表修改的次数。且每一次 add、remove 操作都会使得 modCount 自增。
public boolean add(E e) {
modCount++; // here
add(e, elementData, size);
return true;
}
迭代器对象有一个属性 expectedModCount 属性,它被赋值为该方法调用时 modCount 值。这也就意味着 expectedModCount 是 modCount 在这个时间点的快照值,且这个值在 iterator 内部是不会再发送变化的。
private class Itr implements Iterator<E> {
int cursor = 0; // Index of next elemnet to be returned.
int lastRet = -1; // Index of last elemnet returned.
int expectedModCount = modCount;
}
我们在使用迭代器调用 next() 方法时,第一步就是检查 modCount 和 expectedModCount 是否相等,如果不相等就立即抛出 ConcurrentModificationException。到这就很好理解了,如果我们使用迭代器的同时使用 add、remove 操作了集合,则会导致 modCount 发生变化,从而导致抛出异常。
public E next() {
checkForComodification();
// ......
}
final void checkForComodification() {
if (modCount != expectedModCount)
throw new ConcurrentModificationException();
}
至于为什么快速失败行为无法得到保证也就不难理解,因为虽然集合发生了变化,但如果没有调用 next() 方法,则也不会抛出异常。
即这个机制的主要目的是告诉你,当前迭代器要进行操作是有问题的,因为集合对象现在的状态发生了改变。
那没为何使用迭代器 remove() 方法不会触发该机制?
通过源码不难看出,remove() 方法没有进行 modCount 的检查,并手动把 expectedModCount 值修改成了 modCount,这又保证了下一次迭代的正确。
ref
Fail-Safe 机制主要是针对线程安全的集合类(如 ConcurrentHashMap),其的出现的目的是为了解决 fail-fast 抛出异常处理起来不方便的问题。
并发容器的 iterator() 方法返回的迭代器内部都是保存了该集合对象的一个快照副本,并且没有 modCount 等数值做检查。这也造成了并发容器迭代器读取的数据是某个时间点的快照版本。你可以并发读取,不会抛出异常,但无法不保证遍历读取的值和当前集合对象的状态是一致的。 同时创建集合拷贝还需要时间和空间上的额外开销。
private int newCapacity(int minCapacity) {
// overflow-conscious code
int oldCapacity = elementData.length;
int newCapacity = oldCapacity + (oldCapacity >> 1);
if (newCapacity - minCapacity <= 0) {
if (elementData == DEFAULTCAPACITY_EMPTY_ELEMENTDATA)
return Math.max(DEFAULT_CAPACITY, minCapacity);
if (minCapacity < 0) // overflow
throw new OutOfMemoryError();
return minCapacity;
}
return (newCapacity - MAX_ARRAY_SIZE <= 0)
? newCapacity : hugeCapacity(minCapacity);
}
上面是 ArrayList 扩容时计算新容量的方法,其中 minCapacity 是当前 size + 1,通过 oldCapacity + (oldCapacity >> 1)
我们可以知道新容量是老容量的 1.5 倍。
同时通过 MAX_ARRAY_SIZE 常量可知 ArrayList 的最大长度是 Integer.MAX_VALUE。
ref
在 JDK 1.7 中,HashMap 底层使用数组+链表的形式进行存储,在 JDK 1.8 之后,增加了红黑树,即数组+链表+红黑树的形式存储元素。
其中桶数组是用来存储元素,链表是用来解决哈希冲突,而红黑树是用来提高查询效率的。
PS 可能会被问到解决 Hash 冲突的常见方法?
作者在源码中注释到:TreeNodes 占用空间是普通 Nodes 的两倍, 所以只有当普通桶包含足够多的节点时才会转成 TreeNodes。
当 hashCode 离散性足够好时,树型桶用到的概率非常小,因为数据均匀分布在每个桶中,几乎不会有链表长度会达到阈值。但是在随机 hashCode 情况下,离散性可能会变差,然而又不能阻止用户使用这种不好的 hash 算法,因此就可能导致不均匀的数据分布。不过理想情况下随机 hashCode 算法下所有桶中节点的分布频率会遵循泊松分布,作者还计算出相关概率,即当链表长度达到 8 个元素的概率为 0.00000006,几乎是不可能事件。
因此,源码定义了在 binCount>=7(从 0 开始共 8 个数),即该槽插入第 9 个节点(超过 8 个)时转为红黑树。
至于为何树转链表的阈值选为 6?主要还是为了避免红黑树和链表之间频繁转换带来的性能损耗。
相比普通二叉树,红黑树是一棵平衡树,它的添加、删除和查找操作最差时间复杂度为 o(logn),避免了普通二叉树最差情况下 o(n) 的复杂度。
平衡二叉树是比红黑树更加严格的平衡树,为了达到平衡需要进行更多的旋转次数,所以红黑树插入删除操作效率更高。
ref
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
else {
Node<K,V> e; K k;
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
else if (p instanceof TreeNode)
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
else {
for (int binCount = 0; ; ++binCount) {
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
break;
}
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
++modCount;
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
从上边的源码我们可以总结出 HashMap 在进行 put 操作时的一个大体流程:
1)首先通过 hash() 函数方法计算出 key 的 hash 值;
2)判断桶数组是否为空或长度为 0,是则使用 resize() 方法进行初始化;
3)根据 tab[i = (n - 1) & hash]
(n 是数组长度)计算 key 在数组中对应的下标值,如果该位置没有存放数据,则使用 newNode() 方法直接插入当前数据;
4)如果对应下标已存在数据,则需要分三种情况处理碰撞:
5)最后在所有元素处理完毕后,还需判断当前元素大小是否超过扩容阈值,超过则调用 resize() 方法扩容。
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
当 key 为 null 时 hash 值为 0,否则,hash 值就是 key 的 hashCode 值异或 key 的 hashCode 无符号右移 16 位的结果。即 hash 值是 hashCode 值的高 16 位和低 16 位的异或结果。通过异或处理,避免了只靠低位数据计算 hash 值时导致的冲突,计算结果由高低位共同决定,可以使元素分布更均匀。
至于为什么扰动函数可以降低哈希碰撞发生的概率,这是因为 hash 值是一个 int 类型整数,拥有 40 量级的映射空间。由于 HasMap 桶数组初始长度才 16,就需要使用 hash 值对数组长度进行取模计算,通过前文源码可知下标计算方式是 (n-1) & hash
,该运算的意义是保留 hash 值二进制中的最后 logn 位,高位直接清零。当只保留 hash 值最后几位的信息时,如果遇到 hash 函数不太理想时,如等差数列等漏洞,导致最后几位成规律重复,则将面临严重的碰撞问题。
故 HashMap 通过 hashCode 的高低 16 位进行异或处理,增大最终 hash 值的随机性,以解决上述问题。
也是 HashMap 中使用的方法,计算公式为 H(key) = key % p (p<=n)
,关键字除以一个不大于哈希表长度的正整数 p,所得余数为地址。
直接根据 key 映射到对应哈希表位置,如 i 就直接放到下标 i 的位置。
根据 key 的某些数字(如十位或者百位)作为映射的位置。
根据 key 平方的中间几位作为映射的位置。
把 key 分割成位数相同的几段,然后将其叠加和作为映射的位置。
主要有两个原因:
1)为了方便哈希取余,因为当除数是 2 的倍数时,可以将 %
取模运算将换成 &
位运算,以达到更高的运算效率;
2)扩容后容量也是 2 的倍数,则每个桶中的节点要么保留在当前位置,要么移动 2 次幂的位置(即 原位置+原数组容量),这样就可以快速简洁的完成数据迁移。
为了减少哈希冲突发生的概率,当 HashMap 中的元素个数达到一个阈值 threshold 的时候就会触发扩容,把所有元素 rehash 之后再放在扩容后的容器中,这是一个相当耗时的操作。
这个阈值 threshold 又与当前元素个数(newCap)以及负载因子(loadFactor)正相关。至于为何选址 0.75 作为负载因子,简单来讲这是出于对时间和空间成本综合考量。
1)如果我们设的负载因子比较大,元素比较多时,扩容时桶数组没有发生碰撞的位置较少,查找的时间成本就增加了。
2)如果设置的比较小,元素又比较少时,数组还有足够空位的时候就发生了扩容,发生哈希碰撞的概率就降低了,查找时间成本降低,但是就需要更多的空间去存储元素,空间成本就增加了。
我们在 HashMap 是如何进行 put 操作中讲到,map 在插入新元素之后还会进行容量检查,当超过阈值时,就是调用 resize()
进行扩容处理。其主要作用:初始化桶数组或对数组进行 2 倍的扩容。
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
// 分为 lo、hi 两个新链表
Node<K,V> loHead = null, loTail = null;
Node<K,V> hiHead = null, hiTail = null;
Node<K,V> next;
do {
// 遍历节点,放到对应的新链表中
next = e.next;
if ((e.hash & oldCap) == 0) {
if (loTail == null)
loHead = e;
else
loTail.next = e;
loTail = e;
}
else {
if (hiTail == null)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
}
} while ((e = next) != null);
// lo 链表放到新数组现有位置
if (loTail != null) {
loTail.next = null;
newTab[j] = loHead;
}
// hi 链表放到 +oldCap 的位置
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
}
由上面一段扩容方法中的核心代码可知,由于数组扩容前后长度均是 2 的次幂,则看节点 hash 值新增的那一位是 0 还是 1,若是 0 则在新数组索引下标不变,是 1 则变成当前下标 j + oldCap
。
如此巧妙的设计不仅能把冲突节点随机分配到数组不同位置上,又省略了重新计算节点位置下标的时间,该设计思路也常用于数据库的分库分表中。
将原来数组+链表
改成了数组+链表或红黑树
的结构,用于降低查找操作的时间复杂度。
put 操作链表的插入方式从头插法改成了尾插法,简单说法就是 1.7 将新元素直接放到链表头部,而 1.8 插入到链表尾部。这是因为头插法扩容时会使链表发生反转,多线程环境下会产生环。
ref 并发下HashMap头插会造成死循环情况说明
扩容时 1.7 需要对数组中所有节点重新 hash 计算在新数组中的位置,而 1.8 采用了更简单的位运算方式,提高扩容的效率。
1.7 在插入时会先判断是否需要扩容,再插入,而 1.8 会先进行插入,再判断是否需要扩容。
1.7 做了四次移位和四次异或,而 1.8 只需要一次。因为 4 次的边际效用也不大,改为一次可提升效率。
HashMap 是非线程安全的,可能会发生这些问题:
如果两个线程计算出来的索引位置是相同的,那会造成前一个 key 被后一个 key 覆盖,从而导致前一个元素的丢失。
线程 1 执行 put 时,因为元素个数超出 threshold 而导致 rehash,线程 2 此时执行 get,有可能导致这个问题。这个问题在 JDK 1.7 和 JDK 1.8 中都存在。
如何解决 HashMap 非线程安全问题
我们可以使用 Java 中线程安全的 Map,如 HashTable、Collections.SynchronizedMap 以及 ConcurrentHashMap 等。
无需慌张,这题本质是考察 Hash 冲突的处理。
首先 ConcurrentHashMap 在 JDK 1.7 和 1.8 中实现方式是完全不同的。其中 1.7 四基于分段锁实现,而 1.8 是基于CAS+Synchronized实现。
在 JDK1.7 中,ConcurrentHashMap 是由 Segment 数组结构和 HashEntry 数组结构组成(Segment 继承了 ReentrantLock,是一种可重入锁。HashEntry 则用于存储键值对数据)。一个 ConcurrentHashMap 里包含一个 Segment 数组,一个 Segment 里包含一个 HashEntry 数组 ,每个 HashEntry 是一个链表结构的元素,因此 JDK1.7 的 ConcurrentHashMap 依旧是一种数组+链表结构。
当对 HashEntry 数组的数据进行修改时,整个流程和 HashMap 非常类似,只不过是先定位到具体的 Segment,然后通过 ReentrantLock 去操作而已,后面的流程,就和 HashMap 基本一致。这样只要保证每个 Segment 是线程安全的,也就实现了全局的线程安全(分段锁)。
JDK 1.8 中实现线程安全不是在数据结构上下功夫,它的数据结构和 HashMap 一样的是数组 + 链表 + 红黑树。它实现线程安全的关键点在于 put 流程。
在锁的实现上,采用 CAS 操作和 Synchronized 锁实现更加低粒度的锁,将锁的级别控制在了更细粒度的 table 元素级别,也就是说只需要锁住这个链表的头节点,并不会影响其他的 table 元素的读写,大大提高了并发度。
Java 开发者从未放弃过 Synchronized 关键字的优化,在 JDK1.8 Synchronized 锁的性能得到了很大的提高,并且 Synchronized 有多种锁状态,会从无锁 -> 偏向锁 -> 轻量级锁 -> 重量级锁一步步转换,因此并非是我们认为的重量级锁。
减少内存开销 。假设使用可重入锁来获得同步支持,那么每个节点都需要通过继承 AQS 来获得同步支持。但并不是每个节点都需要获得同步支持的,只有链表的头节点(红黑树的根节点)需要同步,这无疑带来了巨大内存浪费。
这里主要讲讲 JDK 1.8 中的实现。
不需要加锁。因为 Node 的元素 value 和指针 next 是用 volatile 修饰的,在多线程环境下线程A修改节点的 value 或者新增节点的时候是对线程B可见的。
这也是比其它并发集合比如 Hashtable、Collections.synchronizedMap() 效率高的原因之一。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-ROOGzLcn-1645970391210)(https://segmentfault.com/img/bVcOaHA)]
我们先说 为何 value 不能为 null。因为 ConcurrentHashMap 是用于多线程的,如果 get(key) 方法到了 null,就无法判断存在的 value 是 null,还是没有找到对应的 key 而为 null,就有了二义性。
虽然 count 变量是被 volatile 修饰的,但是并不是简单的把所有 Segment 的 count 值相加。因为有可能在累加过程中 count 值发生了改变,那么此时结果就不正确了。但是也不能直接锁住,这样效率太低。因此在 JDK1.7 中的做法是先尝试 2 次通过不锁住 Segment 的方式来统计各个 Segment 大小,如果统计的过程中,容器的 count 发生了变化,则再采用加锁的方式来统计所有Segment 的大小。
那么 ConcurrentHashMap 是如何判断在统计的时候容器是否发生了变化呢?
使用 modCount 变量,在 put、remove 方法里操作元素前都会将变量 modCount++,那么在统计大小前后比较 modCount 是否发生变化,从而得知容器的大小是否发生变化。
由于没有 segment 的概念,所以只需要用一个 baseCount 变量来记录 ConcurrentHashMap 当前节点的个数。
1)先尝试通过CAS 更新 baseCount 计数;
2)如果多线程竞争激烈,某些线程 CAS 失败,那就 CAS 尝试将 cellsBusy 置 1,成功则可以把 baseCount 变化的次数暂存到一个数组 counterCells 里,后续数组 counterCells 的值会加到 baseCount 中;
3)如果 cellsBusy 置 1 失败又会反复进行 CAS baseCount 和 CAS counterCells 数组。
HashMap 是无序的,根据 hash 值随机插入。如果想使用有序的 Map,可以使用 LinkedHashMap 或者 TreeMap。
LinkedHashMap 维护了一个双向链表以及头尾两节点,同时 LinkedHashMap 节点 Entry 内部除了继承 HashMap 的 Node 属性,还有 before 和 after 用于标识前置节点和后置节点。
TreeMap 是按照 Key 的自然顺序或者 Comprator 的顺序进行排序,内部是通过红黑树来实现。所以要么 key 所属的类实现 Comparable 接口,或者自定义一个实现了 Comparator 接口的比较器,传给 TreeMap 用于 key 的比较。