一.集合概述
Java 集合概览
Java 集合, 也叫作容器,主要是由两大接口派生而来:一个是 Collection 接口,主要用于存放单一元素;另一个是 Map 接口,主要用于存放键值对。对于 Collection 接口,下面又有三个主要的子接口:List、Set 和 Queue。
Java 集合框架如下图所示:
图中只列举了主要的继承派生关系,并没有列举所有关系。比方省略了 AbstractList、
NavigableSet 等抽象类以及其他的一些辅助类,如想深入了解,可自行查看源码。
1.说说 List、Set、Queue、Map 四者的区别?
-
List:存储的元素是有序的、可重复的。
-
Set:存储的元素无序的、不可重复的。
-
Queue:按特定的排队规则来确定先后顺序,存储的元素是有序的、可重复的。
-
Map:使用键值对存储,key 是无序的、不可重复的,value 是无序的、可重复的。
2.集合的底层数据结构
先来看一下 Collection 接口下面集合。
List
-
ArrayList:Object 数组
-
Vector:Object 数组
-
linkedList:双向链表,JDK1.6 及之前为循环链表,JDK1.7 取消了循环
Set
-
HashSet:基于 HashMap 实现的,底层数据结构是数组和链表或红黑树。
-
LinkedHashSet:基于 LinkedHashMap 实现的,底层数据结构是数组和双向链表或红黑树。
-
TreeSet:底层数据结构是红黑树
Queue
-
PriorityQueue: Object 数组来实现二叉堆
-
ArrayQueue: Object 数组 + 双指针
Map
-
HashMap:JDK1.8 之前 HashMap 由数组 + 链表组成。JDK1.8 以后,由数组 + 链表或者红黑树组成。
-
LinkedHashMap:LinkedHashMap的底层数据结构是数组和双向链表或红黑树。
-
Hashtable:底层数据结构是数组+链表。
-
TreeMap:红黑树。
Note:
- HashSet 内部维护了一个 HashMap 对象,所有元素都作为 HashMap 的 key 存储,value 则统一使用一个常量对象。
- HashMap底层的哈希表和链表存储的元素是键值对,不仅仅是一个值。
- LinkedHashMap 在 HashMap 结构的基础上,链表由单向链表变为双向链表,使得LinkedHashMap 可以保持键值对的插入顺序。同时通过对链表进行相应的操作,实现了访问顺序相关逻辑。
二.List
1.说一下你对 ArrayList 的理解?
ArrayList 底层是基于动态数组实现的,它具有自动扩容的机制。
-
首先,ArrayList 在创建时会分配一个初始容量,默认为 10,也可以通过构造函数改变这个初始容量大小。
-
其次,当 ArrayList 的容量不足以容纳新的元素时,会触发扩容操作,扩容会创建一个更大的新数组,并将原数组中的元素复制到新数组中。扩容的策略是通过增加原数组容量的一半来计算新数组的大小。例如,当前容量为 n,则新的容量为 n + n/2。
-
最后,新数组会代替原有的数组,成为ArrayList的内部数组。
值得注意的是,ArrayList 的扩容机制会导致内存重新分配和数组复制的开销,因此频繁进行大量元素的插入操作可能会影响性能。为了避免频繁的扩容操作,可以在创建 ArrayList 时,估计所需的元素数量,并尽可能地预设一个合适的初始容量。
2.ArrayList 和数组的区别?
ArrayList 内部是基于动态数组实现的,比静态数组使用起来更加灵活:
-
大小可变性:ArrayList 创建时,无需指定大小,并且会根据实际存储的元素数量动态地扩容或缩容。而数组创建时需要指定大小,并且之后数组大小无法再改变。
-
泛型:ArrayList 可以使用泛型来确保类型安全,数组则不可以。
-
数据类型:ArrayList 中只能存储引用数据类型的数据。数组既可以存储基本数据类型的数据,也可以存储引用数据类型的数据。
-
灵活性:ArrayList 提供了丰富的 API 对元素进行增删改查,比如 add
()
、remove()
等。数组只能通过下标访问其中的元素。
Note:数组定义时的类型限制不会像泛型一样在编译期对数组内的元素进行检查,只会在运行期来判断。
下面是二者使用的简单对比:
Array:
// 初始化一个 String 类型的数组
String[] stringArr = new String[]{"hello", "world", "!"};
// 修改数组元素的值
stringArr[0] = "goodbye";
System.out.println(Arrays.toString(stringArr));// [goodbye, world, !]
// 删除数组中的元素,需要手动移动后面的元素
for (int i = 0; i < stringArr.length - 1; i++) {
stringArr[i] = stringArr[i + 1];
}
stringArr[stringArr.length - 1] = null;
System.out.println(Arrays.toString(stringArr));// [world, !, null]
//创建一个数组,并向数组内添加不同的元素
int[] array = new int[5];
array[0] = 1; // 正确
array[1] = "Hello"; // 编译通过,但在运行时会抛出异常:java.lang.ArrayStoreException
ArrayList:
// 初始化一个 String 类型的 ArrayList
ArrayList<String> stringList = new ArrayList<>(Arrays.asList("hello", "world", "!"));
// 添加元素到 ArrayList 中
stringList.add("goodbye");
System.out.println(stringList);// [hello, world, !, goodbye]
// 修改 ArrayList 中的元素
stringList.set(0, "hi");
System.out.println(stringList);// [hi, world, !, goodbye]
// 删除 ArrayList 中的元素
stringList.remove(0);
System.out.println(stringList); // [world, !, goodbye]
ArrayList 可以添加 null 值吗?
ArrayList 中可以存储任何类型的对象,包括 null 值。不过,不建议向 ArrayList 中添加 null 值, null 值无意义,会让代码难以维护比如忘记做判空处理就会导致空指针异常。
示例代码:
ArrayList<String> listOfStrings = new ArrayList<>();
listOfStrings.add(null);
listOfStrings.add("java");
System.out.println(listOfStrings);
输出:
[null, java]
3.ArrayList 插入和删除元素的时间复杂度?
对于插入:
-
头部插入:当 ArrayList 的容量未达到极限时,由于需要将所有元素都依次向后移动一个位置,因此时间复杂度是 O(n);当容量已达到极限需要扩容时,则需要执行一次 O(n) 的操作将原数组复制到新的更大的数组中,然后再执行 O(n) 的操作添加元素,时间复杂度仍为O(n)。
-
尾部插入:当 ArrayList 的容量未达到极限时,往列表末尾插入元素的时间复杂度是 O(1);当容量已达到极限需要扩容时,则需要执行一次 O(n) 的操作将原数组复制到新的更大的数组中,然后再执行 O(1) 的操作添加元素。
-
指定位置插入:需要将目标位置之后的所有元素都向后移动一个位置,然后再把新元素放入指定位置。这个过程需要移动平均 n/2 个元素,因此时间复杂度为 O(n)。
对于删除:
-
头部删除:由于需要将所有元素依次向前移动一个位置,因此时间复杂度是 O(n)。
-
尾部删除:当删除的元素位于列表末尾时,时间复杂度为 O(1)。
-
指定位置删除:需要将目标元素之后的所有元素向前移动一个位置以填补被删除的空白位置,因此需要移动平均 n/2 个元素,时间复杂度为 O(n)。
这里简单列举一个例子:
// ArrayList的底层数组大小为10,此时存储了7个元素
+---+---+---+---+---+---+---+---+---+---+
| 1 | 2 | 3 | 4 | 5 | 6 | 7 | | | |
+---+---+---+---+---+---+---+---+---+---+
0 1 2 3 4 5 6 7 8 9
// 在索引为1的位置插入一个元素8,该元素后面的所有元素都要向右移动一位
+---+---+---+---+---+---+---+---+---+---+
| 1 | 8 | 2 | 3 | 4 | 5 | 6 | 7 | | |
+---+---+---+---+---+---+---+---+---+---+
0 1 2 3 4 5 6 7 8 9
// 删除索引为1的位置的元素,该元素后面的所有元素都要向左移动一位
+---+---+---+---+---+---+---+---+---+---+
| 1 | 2 | 3 | 4 | 5 | 6 | 7 | | | |
+---+---+---+---+---+---+---+---+---+---+
0 1 2 3 4 5 6 7 8 9
4.LinkedList 插入和删除元素的时间复杂度?
-
头部插入/删除:只需要修改头结点的指针即可完成插入/删除操作,因此时间复杂度为 O(1)。
-
尾部插入/删除:只需要修改尾结点的指针即可完成插入/删除操作,因此时间复杂度为 O(1)。
-
指定位置插入/删除:需要先移动到指定位置,再修改指定节点的指针完成插入/删除,需要移动平均 n/2 个元素,时间复杂度为 O(n)。
简单列举一个例子:假如我们要删除节点 9 的话,需要先遍历链表找到该节点。然后,再执行相应节点指针指向的更改。
5.为什么 LinkedList 没有实现 RandomAccess 接口?
RandomAccess 是一个标记接口,用来表明实现该接口的类是否可以通过索引快速访问元素。由于 LinkedList 底层数据结构是链表,内存地址不连续,不支持通过索引快速访问元素,所以不能实现 RandomAccess 接口。
Note:虽然 LinkedList 不支持随机访问,但它提供了许多其他方法来支持按索引顺序访问。例如,可以使用 get(int index) 方法按照索引顺序获取元素,但需要进行遍历操作;
6.ArrayList 与 LinkedList 区别?
-
底层数据结构: ArrayList 底层使用的是 Object 数组;LinkedList 底层使用的是 双向链表。
-
是否支持按索引访问: ArrayList 支持,LinkedList 不支持。
-
内存空间占用:LinkedList 的节点相比于 ArrayList 除了存放数据之外,还要存放指针,会更占内存空间一些。
-
插入和删除是否受元素位置的影响:ArrayList 采用数组存储,所以插入和删除元素受元素位置的影响。LinkedList 采用双向链表存储,所以在头尾插入或者删除元素不受元素位置的影响,但是如果是要在指定位置插入和删除元素的话,会受元素位置的影响 ,因为需要先找到到指定位置再插入和删除。
Note:
- ArrayList 除了在数组中存放数据之外,还会在数组的结尾预留一定的内存空间。
- 我们在项目中一般是不会使用到 LinkedList 的,需要用到 LinkedList 的场景几乎都可以使用 ArrayList 来代替,并且,性能通常会更好!就连 LinkedList 的作者 Josh Bloch 自己都说从来不会使用 LinkedList。
三.Set
1.比较 HashSet、LinkedHashSet 和 TreeSet 三者的异同
相同点
-
HashSet、LinkedHashSet 和 TreeSet 都是 Set 接口的实现类,都能保证元素唯一,并且都是线程不安全的。
区别
-
三者的底层数据结构是不同的。HashSet 的是基于 HashMap 实现的,因此其底层数据结构是哈希表加链表或红黑树。LinkedHashSet 的底层数据结构是双向链表和哈希表。TreeSet 底层数据结构是红黑树。
-
底层数据结构的不同也导致这三者的特点不一样。HashSet 的特点是无序、不可重复的;LinkedHashSet 的特点是有序、不可重复的;TreeSet存储的元素是无序,不可重复,但是是可排序的。
2.HashSet 如何检查重复?
当把对象加入到 HashSet 时,HashSet 首先会计算对象的 hashcode 值,然后将 hashcode&n-1 来判断对象加入的位置,如果要存放的位置存在元素的话,就将此对象和已经加入的对象的 hashcode 值作比较,如果都不相等,该对象会被加入到 HashSet 中。如果发现有 hashcode 值相同的对象,HashSet 会调用 equals() 方法来检查 hashcode 值相等的对象是否真的相同,如果相同,则加入失败,如果不相等,则将其加入。
四.Map
1.HashMap 的底层实现
JDK1.8 之前
JDK1.8 之前, HashMap 底层是 数组和链表 结合在一起使用。当把元素加入到 HashMap 时,HashMap 首先利用自己的哈希函数得到 key 的哈希码,然后通过(n-1)& hashcode 判断当前元素存放的位置,如果要存放的位置存在元素的话,就通过equals方法判断已存在的元素与要存入的元素是否真的相同,如果不相同就加入,如果相同就加入失败。此外,当哈希表中的元素数量超过负载因子(默认0.75)与数组长度的乘积时,就会触发扩容操作,扩容会将数组长度扩大为原来的2倍,并重新计算每个元素在新表中的位置。
Note:
(n-1)& hash 是位运算,这里的 n 指不的是数组的长度,用于将两个操作数对应位上的位进行逻辑与操作。通过执行(n-1)& hash ,可以实现将哈希码限制在 0 到 n-1 的范围内,相当于对哈希码取模 n。
HashMap 其实是以 key 的哈希码为输入到自己的哈希函数得到新的哈希码,然后再将哈希码和(n-1)做与运算,得到元素要存放的位置。
JDK 1.8 HashMap 的 hash 方法源码:
JDK 1.8 的 hash 方法 相比于 JDK 1.7 hash 方法更加简化,但是原理不变。
static final int hash(Object key) {
int h;
// key.hashCode():返回散列值也就是hashcode
// ^:按位异或
// >>>:无符号右移,忽略符号位,空位都以0补齐
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
对比一下 JDK1.7 的 HashMap 的 hash 方法源码.
static int hash(int h) {
// This function ensures that hashCodes that differ only by
// constant multiples at each bit position have a bounded
// number of collisions (approximately 8 at default load factor).
h ^= (h >>> 20) ^ (h >>> 12);
return h ^ (h >>> 7) ^ (h >>> 4);
}
相比于 JDK1.8 的 hash 方法 ,JDK 1.7 的 hash 方法的性能会稍差一点点,因为毕竟扰动了 4 次。
所谓 “拉链法” 就是:将链表和数组相结合。也就是说创建一个链表数组,数组中每一格就是一个链表。若遇到哈希冲突,则将冲突的值加到链表中即可。
JDK1.8 之后
JDK1.8 之后, HashMap 的底层数据结构是 数组、链表、红黑树 结合在一起使用。当把元素加入到 HashMap 时,HashMap 首先利用自己的哈希函数得到 key 的哈希码,然后通过(n-1)& hashcode 判断当前元素存放的位置,如果要存放的位置存在元素的话,就通过 equals 方法判断已存在的元素与要存入的元素是否真的相同,如果相同就加入失败,如果不相同就继续判断要加入的位置是红黑树还是链表,是红黑树就直接加入,是链表,就判断加入后链表的长度有没有超过默认阈值 8,没有超过就直接加入,超过了就将链表转为红黑树。此外,当哈希表中的元素数量超过负载因子(默认0.75)与数组长度的乘积时,就会触发扩容操作,扩容会将数组长度扩大为原来的2倍,并重新计算每个元素在新表中的位置。
2.HashMap 中的数组长度为什么是 2 的幂次方
为了能让 HashMap 存取高效,尽量较少碰撞,也就是要尽量把数据分配均匀。我们上面也讲到了过了,Hash 值的范围值 -2147483648 到 2147483647,前后加起来大概 40 亿的映射空间,只要哈希函数映射得比较均匀松散,一般应用是很难出现碰撞的。但问题是一个 40 亿长度的数组,内存是放不下的。所以这个散列值是不能直接拿来用的。用之前还要先做对数组的长度取模运算,得到的余数才能用来要存放的位置也就是对应的数组下标。这个数组下标的计算方法是(n-1)& hash ,n 代表数组长度。如果取模运算的除数是 2 的幂次时,等价于除数减一和被除数的按位与(&)操作,而位运算比起直接取模其性能更高,HashMap的数组长度设为 2 的幂次方也会比其他数更容易进行二进制位运算,因为它们在二进制下只有一个 1,其他位都是 0,这也就解释了 HashMap 的长度为什么是 2 的幂次方。
3.HashMap 常见的遍历方式?
参考下面这篇博客:
4.HashMap 和 HashSet 区别
HashMap 和 HashSet 的主要区别如下
-
HashMap 存储的元素都是键值对、HashSet 存储的元素都是对象。
-
HashMap 使用键计算 hashcode,HashSet 使用对象来计算 hashcode。
-
HashSet 的底层是基于 HashMap 实现的。
5.HashMap 和 Hashtable 的区别
-
线程是否安全: HashMap 是非线程安全的,Hashtable 是线程安全的,因为 Hashtable 内部的方法基本都经过 synchronizeed 修饰。
-
效率: 因为线程安全的问题,HashMap 要比 Hashtable 效率高一点。另外,Hashtable 基本被淘汰,最好不要在代码中使用了。
-
对 Null key 和 Null value 的支持: HashMap 可以存储 null 的 key 和 value,但 null 作为键只能有一个,null 作为值可以有多个;Hashtable 不允许有 null 键和 null 值,否则会抛出NullPointerException。
-
初始容量大小和每次扩充容量大小的不同: ① 创建时如果不指定容量初始值,Hashtable 默认的初始大小为 11,之后每次扩充,容量变为原来的 2n+1。HashMap 默认的初始化大小为 16。之后每次扩充,容量变为原来的 2 倍。② 创建时如果给定了容量初始值,那么 Hashtable 会直接使用你给定的大小,而 HashMap 会将其扩充为 2 的幂次方大小。
-
底层数据结构: JDK1.8 之前 HashMap 由数组 + 链表组成,数组是 HashMap 的主体,链表则是为了解决哈希冲突而存在的。JDK1.8 以后,当链表长度大于默认阈值 8 时,会对当前数组的长度进行判断,如果当前数组的长度小于 64,那么会选择先进行数组扩容,如果当前数组的长度大于等于64,则将链表转化为红黑树。Hashtable 没有这样的机制。
6.HashMap 和 TreeMap 区别
TreeMap 和 HashMap 都继承自 AbstractMap,但是需要注意的是 TreeMap 它还实现了NavigableMap 接口和 SortedMap 接口。
实现 NavigableMap 接口让 TreeMap 有了对集合内元素的搜索的能力。
实现 SortedMap 接口让 TreeMap 有了对集合中的元素根据键排序的能力。默认是按 key 的升序排序,不过我们也可以指定排序的比较器。示例代码如下:
/**
* @author shuang.kou
* @createTime 2020年06月15日 17:02:00
*/
public class Person {
private Integer age;
public Person(Integer age) {
this.age = age;
}
public Integer getAge() {
return age;
}
public static void main(String[] args) {
TreeMap<Person, String> treeMap = new TreeMap<>(new Comparator<Person>() {
@Override
public int compare(Person person1, Person person2) {
int num = person1.getAge() - person2.getAge();
return Integer.compare(num, 0);
}
});
treeMap.put(new Person(3), "person1");
treeMap.put(new Person(18), "person2");
treeMap.put(new Person(35), "person3");
treeMap.put(new Person(16), "person4");
treeMap.entrySet().stream().forEach(personStringEntry -> {
System.out.println(personStringEntry.getValue());
});
}
}
输出:
person1
person4
person2
person3
可以看出,TreeMap 中的元素已经是按照 Person 的 age 字段的升序来排列了。
上面,我们是通过传入匿名内部类的方式实现的,也可以将匿名内部类的代码替换成 Lambda 表达式实现的方式:
TreeMap<Person, String> treeMap = new TreeMap<>((person1, person2) -> {
int num = person1.getAge() - person2.getAge();
return Integer.compare(num, 0);
});
综上,相比于 HashMap 来说 TreeMap 主要多了对集合中的元素根据键排序的能力以及对集合内元素的搜索的能力。
7.HashMap 多线程操作导致死循环问题
JDK1.7 及之前版本的 HashMap 在多线程环境下扩容操作可能存在死循环问题,这是由于当一个桶位中有多个元素需要进行扩容时,多个线程同时对链表进行操作,头插法可能会导致链表中的节点指向错误的位置,从而形成一个环形链表,进而使得查询元素的操作陷入死循环无法结束。为了解决这个问题,JDK1.8 版本的 HashMap 采用了尾插法而不是头插法来避免链表倒置,使得插入的节点永远都是放在链表的末尾,避免了链表中的环形结构。但是还是不建议在多线程下使用 HashMap ,因为多线程下使用 HashMap 还是会存在数据覆盖的问题。并发环境下,推荐使用 ConcurrentHashMap 。
8.HashMap 为什么线程不安全?
JDK1.7 及之前版本,在多线程环境下,HashMap 扩容时会造成死循环和数据丢失的问题。
数据丢失这个在 JDK1.7 和 JDK1.8 中都存在,这里以 JDK1.8 为例进行介绍。
JDK 1.8 后,在 HashMap 中,多个键值对可能会被分配到同一个桶,并以链表或红黑树的形式存储。多个线程对 HashMap 的 put 操作会导致线程不安全,具体来说会有数据覆盖的风险。
举个例子:
-
两个线程 1、2 同时进行 put 操作,并且发生了哈希冲突。
-
不同的线程可能在不同的时间片获得 CPU 执行的机会,当前线程 1 执行完哈希冲突判断后,由于时间片耗尽挂起。线程 2 先完成了插入操作。
-
随后,线程 1 获得时间片,由于之前已经进行过 hash 碰撞的判断,所有此时会直接进行插入,这就导致线程 2 插入的数据被线程 1 覆盖了。
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
// ...
// 判断是否出现 hash 碰撞
// (n - 1) & hash 确定元素存放在哪个桶中,桶为空,新生成结点放入桶中(此时,这个结点是放在数组中)
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
// 桶中已经存在元素(处理hash冲突)
else {
// ...
}
还有一种情况是这两个线程同时 put 操作导致 size 的值不正确,进而导致数据覆盖的问题:
-
线程 1 执行 if(++size > threshold) 判断时,假设获得 size 的值为 10,由于时间片耗尽挂起。
-
线程 2 也执行 if(++size > threshold) 判断,获得 size 的值也为 10,并将元素插入到该桶位中,并将 size 的值更新为 11。
-
随后,线程 1 获得时间片,它也将元素放入桶位中,并将 size 的值更新为 11。
-
线程 1、2 都执行了一次 put 操作,但是 size 的值只增加了 1,也就导致实际上只有一个元素被添加到了 HashMap 中。
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
// ...
// 实际大小大于阈值则扩容
if (++size > threshold)
resize();
// 插入后回调
afterNodeInsertion(evict);
return null;
}