文章目录
- 1 集合概述
- 2 List
- 3 Set
- 4 Queue
- 5 Map
- 5.1 HashMap 和 Hashtable 的区别
- 5.2 HashMap 的底层实现🔥
- 5.3 哈希冲突解决方法有哪些?🔥
- 5.4 HashMap 的 put 方法的具体流程🔥
- 5.5 HashMap 的扩容机制🔥
- 5.6 HashMap 的长度为什么是 2 的幂次方🔥
- 5.7 HashMap 为什么线程不安全?🔥
- 5.8 HashMap一般用什么做Key?为啥String适合做Key呢?
- 5.9 为什么HashMap要用红黑树而不是平衡二叉树?
- 5.10 ConcurrentHashMap 和 Hashtable 的区别
- 5.11 ConcurrentHashMap 线程安全的底层具体实现🔥
- 5.12 ConcurrentHashMap已经用了synchronized,为什么还要用CAS呢?🔥
- 5.13 ConcurrentHashMap 为什么 key 和 value 不能为 null?
- 5.14 HashSet 与 HashMap 的区别?
1 集合概述
1.1 Java 集合概览🔥
Java 集合,也叫作容器,主要是由两大接口派生而来:一个是 Collection 接口,主要用于存放单一元素;另一个是 Map 接口,主要用于存放键值对。对于 Collection 接口,下面又有三个主要的子接口:List、Set 和 Queue。
Java 集合框架如下图所示:

List:存储的元素是有序的、可重复的。常用的实现List的类有LinkedList,ArrayListSet:存储的元素不可重复的。常用的实现有HashSet,LinkedHashSet和TreeSet。Queue:按特定的排队规则来确定先后顺序,存储的元素是有序的、可重复的。常用的实现有 PriorityQueue.Map:使用键值对(key-value)存储,key 是无序的、不可重复的,value 是无序的、可重复的。主要实现有TreeMap、HashMap、LinkedHashMap、ConcurrentHashMap。
1.2 数组与集合区别
- 数组是固定长度的数据结构,一旦创建长度就无法改变,而集合是动态长度的数据结构,可以根据需要动态增加或减少元素。
- 数组可以包含基本数据类型和对象,而集合只能包含对象。
- 数组可以直接访问元素,而集合需要通过迭代器或其他方法访问元素。
2 List
2.1 ArrayList底层实现原理🔥
- 底层数据结构:ArrayList底层是用动态的数组实现的
- 初始容量:ArrayList初始容量为0,当第一次添加数据的时候才会初始化容量为10
- 扩容逻辑:ArrayList在进行扩容的时候是原来容量的1.5倍,每次扩容都需要拷贝数组
- 添加逻辑
- 确保数组已使用长度(size)加1之后足够存下下一个数据
- 计算数组的容量,如果当前数组已使用长度+1后的大于当前的数组长度,则调用grow方法扩容(原来的1.5倍)
- 确保新增的数据有地方存储之后,则将新元素添加到位于size的位置上。
- 返回添加成功布尔值。
添加数据的流程如下

2.2 如何实现数组和List之间的转换
- 数组转List ,使用JDK中java.util.Arrays工具类的asList方法
- List转数组,使用List的toArray方法。无参toArray方法返回 Object数组,传入初始化长度的数组对象,返回该对象数组
List<String> myList = Arrays.asList(myArray); // 数组转list
str=myList.toArray(new String[0]); // list转数组
继续追问:用Arrays.asList转List后,如果修改了数组内容,list受影响吗?List用toArray转数组后,如果修改了List内容,数组受影响吗?
- Arrays.asList转换list之后,如果修改了数组的内容,list会受影响,因为它的底层使用的Arrays类中的一个内部类ArrayList来构造的集合,在这个集合的构造器中,把我们传入的这个集合进行了包装而已,最终指向的都是同一个内存地址
- list用了toArray转数组后,如果修改了list内容,数组不会影响,当调用了toArray以后,在底层是它是进行了数组的拷贝,跟原来的元素就没啥关系了,所以即使list修改了以后,数组也不受影响
2.3 ArrayList 与 LinkedList 区别?🔥
视频讲解:常见集合篇-08-ArrayList和LinkedList的区别是什么?_哔哩哔哩_bilibili
- 底层数据结构:
ArrayList底层使用的是动态数组;LinkedList底层使用的是 双向链表 - 插入和删除效率不同: ArrayList在尾部的插入和删除操作效率较高,但在中间或开头的插入和删除操作效率较低,需要移动元素。LinkedList在任意位置的插入和删除操作效率都比较高,因为只需要调整节点之间的指针。
- 随机访问的效率不同:ArrayList支持通过索引进行快速随机访问,时间复杂度为O(1)。LinkedList需要从头或尾开始遍历链表,时间复杂度为O(n)。
- 空间占用:
ArrayList在创建时需要分配一段连续的内存空间,列表的结尾会预留一定的容量空间。而 LinkedList 的每个节点不光需要存储元素还要存储指针。 - 使用场景: ArrayList适用于频繁随机访问和尾部的插入删除操作,而LinkedList适用于频繁的中间插入删除操作和不需要随机访问的场景。
- 是否保证线程安全:
ArrayList和LinkedList都不是线程安全的;
2.4 把ArrayList变成线程安全有哪些方法?
- 使用Collections类的synchronizedList方法将ArrayList包装成线程安全的List:
- 使用CopyOnWriteArrayList类代替ArrayList,它是一个线程安全的List实现:
- 使用Vector类代替ArrayList,Vector是线程安全的List实现:
2.5 ArrayList list=new ArrayList(10)中的 list 扩容几次
在 ArrayList 的源码中提供了一个带参数的构造方法,这个参数就是指定的集合初始长度,所以给了一个10的参数,就是指定了集合的初始长度是10,这里面并没有扩容。
2.6 ArrayList 可以添加 null 值吗?
ArrayList 中可以存储任何类型的对象,包括 null 值。不过,不建议向 ArrayList 中添加 null 值, null 值无意义,会让代码难以维护比如忘记做判空处理就会导致空指针异常。
2.7 LinkedList 为什么不能实现 RandomAccess 接口?
RandomAccess 是一个标记接口,用来表明实现该接口的类支持随机访问(即可以通过索引快速访问元素)。由于 LinkedList 底层数据结构是链表,内存地址不连续,只能通过指针来定位,不支持随机快速访问,所以不能实现 RandomAccess 接口。
3 Set
3.1 Comparable 和 Comparator 的区别
Comparable 接口和 Comparator 接口都是 Java 中用于排序的接口
Comparator接口出自java.util包,它有一个compare(Object obj1, Object obj2)方法用来排序
// void sort(List list),按自然排序的升序排序
Collections.sort(arrayList);
// 定制排序的用法
Collections.sort(arrayList, new Comparator<Integer>() {
@Override
public int compare(Integer o1, Integer o2) {
return o2.compareTo(o1);
}
});
Comparable接口出自java.lang包,它有一个compareTo(Object obj)方法用来排序
// person对象没有实现Comparable接口,所以必须实现
public class Person implements Comparable<Person> {
// 省略属性定义部分
@Override
public int compareTo(Person o) {
if (this.age > o.getAge()) {
return 1;
}
if (this.age < o.getAge()) {
return -1;
}
return 0;
}
}
3.2 比较 HashSet、LinkedHashSet 和 TreeSet 三者的异同
HashSet、LinkedHashSet和TreeSet都是Set接口的实现类,都能保证元素唯一,并且都不是线程安全的。HashSet、LinkedHashSet和TreeSet的主要区别在于底层数据结构不同。HashSet的底层数据结构是哈希表(基于HashMap实现)。LinkedHashSet的底层数据结构是链表和哈希表,元素的插入和取出顺序满足 FIFO。TreeSet底层数据结构是红黑树,元素是有序的,排序的方式有自然排序和定制排序。- 底层数据结构不同又导致这三者的应用场景不同。
HashSet用于不需要保证元素插入和取出顺序的场景,LinkedHashSet用于保证元素的插入和取出顺序满足 FIFO 的场景,TreeSet用于支持对元素自定义排序规则的场景。
4 Queue
4.1 Queue 与 Deque 的区别
Queue 是单端队列,只能从一端插入元素,另一端删除元素,实现上一般遵循 先进先出(FIFO) 规则。
Queue 扩展了 Collection 的接口,根据 因为容量问题而导致操作失败后处理方式的不同 可以分为两类方法: 一种在操作失败后会抛出异常,另一种则会返回特殊值。
Queue 接口 | 抛出异常 | 返回特殊值 |
|---|---|---|
| 插入队尾 | add(E e) | offer(E e) |
| 删除队首 | remove() | poll() |
| 查询队首元素 | element() | peek() |
Deque 是双端队列,在队列的两端均可以插入或删除元素。
Deque 扩展了 Queue 的接口, 增加了在队首和队尾进行插入和删除的方法,同样根据失败后处理方式的不同分为两类:
Deque 接口 | 抛出异常 | 返回特殊值 |
|---|---|---|
| 插入队首 | addFirst(E e) | offerFirst(E e) |
| 插入队尾 | addLast(E e) | offerLast(E e) |
| 删除队首 | removeFirst() | pollFirst() |
| 删除队尾 | removeLast() | pollLast() |
| 查询队首元素 | getFirst() | peekFirst() |
| 查询队尾元素 | getLast() | peekLast() |
事实上,Deque 还提供有 push() 和 pop() 等其他方法,可用于模拟栈。
4.2 ArrayDeque 与 LinkedList 的区别
ArrayDeque 和 LinkedList 都实现了 Deque 接口,两者都具有队列的功能,但两者有什么区别呢?
ArrayDeque是基于可变长的数组和双指针来实现,而LinkedList则通过链表来实现。ArrayDeque不支持存储NULL数据,但LinkedList支持。ArrayDeque是在 JDK1.6 才被引入的,而LinkedList早在 JDK1.2 时就已经存在。ArrayDeque插入时可能存在扩容过程, 不过均摊后的插入操作依然为 O(1)。虽然LinkedList不需要扩容,但是每次插入数据时均需要申请新的堆空间,均摊性能相比更慢。
从性能的角度上,选用 ArrayDeque 来实现队列要比 LinkedList 更好。此外,ArrayDeque 也可以用于实现栈。
4.3 说一说 PriorityQueue
PriorityQueue 是在 JDK1.5 中被引入的,其与 Queue 的区别在于元素出队顺序是与优先级相关的,即总是优先级最高的元素先出队。
PriorityQueue利用了二叉堆的数据结构来实现的,底层使用可变长的数组来存储数据PriorityQueue通过堆元素的上浮和下沉,实现了在 O(logn) 的时间复杂度内插入元素和删除堆顶元素。PriorityQueue是非线程安全的,且不支持存储NULL和non-comparable的对象。PriorityQueue默认是小顶堆,但可以接收一个Comparator作为构造参数,从而来自定义元素优先级的先后。
4.4 什么是 BlockingQueue?
BlockingQueue (阻塞队列)是一个接口,继承自 Queue。BlockingQueue阻塞的原因是其支持当队列没有元素时一直阻塞,直到有元素;还支持如果队列已满,一直等到队列可以放入新元素时再放入。
BlockingQueue 常用于生产者-消费者模型中,生产者线程会向队列中添加数据,而消费者线程会从队列中取出数据进行处理。

4.5 BlockingQueue 的实现类有哪些?
Java 中常用的阻塞队列实现类有以下几种:
ArrayBlockingQueue:使用数组实现的有界阻塞队列。在创建时需要指定容量大小,并支持公平和非公平两种方式的锁访问机制。LinkedBlockingQueue:使用单向链表实现的可选有界阻塞队列。在创建时可以指定容量大小,如果不指定则默认为Integer.MAX_VALUE。和ArrayBlockingQueue不同的是, 它仅支持非公平的锁访问机制。PriorityBlockingQueue:支持优先级排序的无界阻塞队列。元素必须实现Comparable接口或者在构造函数中传入Comparator对象,并且不能插入 null 元素。SynchronousQueue:同步队列,是一种不存储元素的阻塞队列。每个插入操作都必须等待对应的删除操作,反之删除操作也必须等待插入操作。因此,SynchronousQueue通常用于线程之间的直接传递数据。DelayQueue:延迟队列,其中的元素只有到了其指定的延迟时间,才能够从队列中出队。- ……
日常开发中,这些队列使用的其实都不多,了解即可。
4.6 ArrayBlockingQueue 和 LinkedBlockingQueue 有什么区别?🔥
ArrayBlockingQueue 和 LinkedBlockingQueue 是 Java 并发包中常用的两种阻塞队列实现,它们都是线程安全的。不过,不过它们之间也存在下面这些区别:
- 底层实现:
ArrayBlockingQueue基于数组实现,而LinkedBlockingQueue基于链表实现。 - 是否有界:
ArrayBlockingQueue是有界队列,必须在创建时指定容量大小。LinkedBlockingQueue创建时可以不指定容量大小,默认是Integer.MAX_VALUE,也就是无界的。但也可以指定队列大小,从而成为有界的。 - 锁是否分离:
ArrayBlockingQueue中的锁是没有分离的,即生产和消费用的是同一个锁;LinkedBlockingQueue中的锁是分离的,即生产用的是putLock,消费是takeLock,这样可以防止生产者和消费者线程之间的锁争夺。 - 内存占用:
ArrayBlockingQueue需要提前分配数组内存,而LinkedBlockingQueue则是动态分配链表节点内存。这意味着,ArrayBlockingQueue在创建时就会占用一定的内存空间,且往往申请的内存比实际所用的内存更大,而LinkedBlockingQueue则是根据元素的增加而逐渐占用内存空间。
5 Map
5.1 HashMap 和 Hashtable 的区别
- 底层数据结构:
Hashtable是数组+链表。JDK1.8 以后的HashMap当链表长度大于阈值(默认为 8)时,将链表转化为红黑树,以减少搜索时间。 - 线程是否安全:
HashMap是非线程安全的,Hashtable是线程安全的,因为Hashtable内部的方法基本都经过synchronized修饰。 - 效率: 因为线程安全的问题,
HashMap要比Hashtable效率高一点。另外,Hashtable基本被淘汰,不要在代码中使用它; - 对 Null key 和 Null value 的支持:
HashMap可以存储 null 的 key 和 value,但 null 作为键只能有一个,null 作为值可以有多个;Hashtable 不允许有 null 键和 null 值。 - 初始容量大小和每次扩充容量大小的不同:
Hashtable默认的初始大小为 11,之后每次扩充,容量变为原来的 2n+1。HashMap默认的初始化大小为 16。之后每次扩充,容量变为原来的 2 倍。
5.2 HashMap 的底层实现🔥
JDK1.8 之前 HashMap 底层是 数组和链表 。HashMap 先得到 key 的 hashCode 值,然后和这个 hashCode 值右移16位后的二进制进行按位异或运算得到最终的 hash 值。然后通过 (n - 1) & hash 计算出当前元素存放的位置(这里的 n 指的是数组的长度)。如果当前位置存在元素的话,就判断该元素与要存入的元素的 hash 值以及 key 是否相同,如果相同的话,直接覆盖,不相同就通过拉链法解决冲突。
JDK1.8 在解决哈希冲突时有了较大的变化,当链表长度大于阈值8并且数组长度大于等于64时,将链表转化为红黑树,以减少搜索时间。并且在数量小于6时,会将红黑树转换回链表。如果当前数组的长度小于 64,那么会选择先进行数组扩容,而不是转换为红黑树。每次扩容的时候,都是扩容为之前容量的2倍。
5.3 哈希冲突解决方法有哪些?🔥
- 链接法:使用链表或其他数据结构来存储冲突的键值对,将它们链接在同一个哈希桶中。
- 开放寻址法:在哈希表中找到另一个可用的位置来存储冲突的键值对,而不是存储在链表中。常见的开放寻址方法包括线性探测、二次探测和双重散列。
- 再哈希法(Rehashing):当发生冲突时,使用另一个哈希函数再次计算键的哈希值,直到找到一个空槽来存储键值对。
- 哈希桶扩容:当哈希冲突过多时,可以动态地扩大哈希桶的数量,重新分配键值对,以减少冲突的概率。
5.4 HashMap 的 put 方法的具体流程🔥
- 判断键值对数组是否为空或为 null,是的话执行
resize()初始化容量为 16 - 根据键值 key 计算 hash 值,得到数组索引
- 检查该位置是否为空,若为空则直接插入
- 如果该位置不为空
- 判断首个元素的 key是否和待插入的 key 一样,如果相同直接覆盖
- 判断插入的是否是红黑树节点,如果是,则直接在树中插入键值对
- 遍历链表,遍历过程中若发现存在相同的 key ,则直接覆盖。否则在链表的尾部插入数据。
- 然后判断链表长度是否大于8,数组长度是否大于等于64,是的话则把链表转换为红黑树。
- 插入成功后,判断实际存在的键值对数量
size是否超过了最大容量threshold(数组长度*0.75),如果超过,进行扩容。

5.5 HashMap 的扩容机制🔥
- 在添加元素或初始化的时候需要调用 resize 方法进行扩容,第一次添加数据初始化数组长度为 16,以后每次都是达到了扩容阈值(数组长度 * 0.75)进行扩容
- 每次扩容的时候,都是扩容之前容量的 2 倍;
- 扩容之后,会新创建一个数组,需要把老数组中的数据挪动到新的数组中
- 没有hash冲突的节点,则直接使用 e.hash & (newCap - 1) 计算新数组的索引位置
- 如果是红黑树,走红黑树的添加
- 如果是链表,则需要遍历链表,可能需要拆分链表,判断(e.hash & oldCap)是否为 0,是的话停留在原始位置,否则移动到原始位置+旧数组大小的位置上

5.6 HashMap 的长度为什么是 2 的幂次方🔥
- 计算索引时效率更高:如果是 2 的 n 次幂可以使用位与运算
(n - 1) & hash代替直接取模 - 如果 n 为 2 次幂,那么 n-1 的低位就全是 1,哈希值进行与操作时可以保证低位的值不变,从而保证分布均匀
5.7 HashMap 为什么线程不安全?🔥
JDK1.7 及之前版本,在多线程环境下,HashMap 扩容时会造成死循环和数据丢失的问题。这是由于当一个桶位中有多个元素需要进行扩容时,多个线程同时对链表进行操作,头插法可能会导致链表中的节点指向错误的位置,从而形成一个环形链表,进而使得查询元素的操作陷入死循环无法结束。
JDK1.8 版本的 HashMap 采用了尾插法而不是头插法,使得插入的节点永远都是放在链表的末尾,解决了 JDK1.7 扩容操作的死循环和数据丢失问题。但多个线程对 HashMap 的 put 操作仍会存在数据覆盖的风险。
- 两个线程 1,2 同时进行 put 操作,并且发生了哈希冲突。
- 当前线程 1 执行完哈希冲突判断后,由于时间片耗尽挂起。线程 2 先完成了插入操作。
- 随后,线程 1 获得时间片,由于之前已经进行过 hash 碰撞的判断,所有此时会直接进行插入,这就导致线程 2 插入的数据被线程 1 覆盖了。
还有一种情况是这两个线程同时 put 操作导致 size 的值不正确,进而导致数据覆盖的问题。
5.8 HashMap一般用什么做Key?为啥String适合做Key呢?
用 string 做 key,因为 String对象是不可变的,一旦创建就不能被修改,这确保了Key的稳定性。如果Key是可变的,可能会导致hashCode和equals方法的不一致,进而影响HashMap的正确性。
5.9 为什么HashMap要用红黑树而不是平衡二叉树?
- 平衡二叉树追求的是一种 “完全平衡” 状态:任何结点的左右子树的高度差不会超过 1,优势是树的结点是很平均分配的。这个要求实在是太严了,导致每次进行插入/删除节点的时候,几乎都会破坏平衡树的第二个规则,进而我们都需要通过左旋和右旋来进行调整,使之再次成为一颗符合要求的平衡树。
- 红黑树不追求这种完全平衡状态,而是追求一种 “弱平衡” 状态:整个树最长路径不会超过最短路径的 2 倍。优势是虽然牺牲了一部分查找的性能效率,但是能够换取一部分维持树平衡状态的成本。与平衡树不同的是,红黑树在插入、删除等操作,不会像平衡树那样,频繁着破坏红黑树的规则,所以不需要频繁着调整,这也是我们为什么大多数情况下使用红黑树的原因。
5.10 ConcurrentHashMap 和 Hashtable 的区别
- 底层数据结构: JDK1.7 的
ConcurrentHashMap底层采用 分段的数组+链表 实现,JDK1.8 采用的数据结构跟HashMap1.8的结构一样,数组+链表/红黑二叉树;Hashtable采用 数组+链表 的形式,数组是主体,链表则是主要为了解决哈希冲突而存在的; - 实现线程安全的方式(重要):
- 在 JDK1.7 的时候,
ConcurrentHashMap对整个数组分成了多个Segment,每一把锁只锁一个Segment,多线程访问容器里不同数据段的数据,就不会存在锁竞争,提高并发访问率。 - 到了 JDK1.8 的时候,
ConcurrentHashMap摒弃了Segment的概念,而是直接用数组+链表+红黑树的数据结构,并发控制使用synchronized和CAS操作。 Hashtable(同一把锁) :所有的方法都加了锁来保证线程安全,但是效率非常的低下,当一个线程访问同步方法,另一个线程也访问的时候,就会陷入阻塞或者轮询的状态。
- 在 JDK1.7 的时候,
5.11 ConcurrentHashMap 线程安全的底层具体实现🔥
在 jdk1.7中, ConcurrentHashMap 底层使用的是分段的数组+链表。ConcurrentHashMap 里包含一个 Segment 数组, Segment 的个数一旦初始化就不能改变。 Segment 数组的大小默认是 16,也就是说默认可以同时支持 16 个线程并发写。Segment 是一种数组和链表结构,一个 Segment 包含一个 HashEntry 数组,每个 HashEntry 是一个链表结构的元素。Segment 是一种可重入的锁,当对 HashEntry 数组的数据进行修改时,必须首先获得对应的 Segment 锁。
JDK1.8 采用的数据结构跟 HashMap1.8的结构一样,数组+链表/红黑树。在 jdk1.8中的 ConcurrentHashMap 做了较大的优化,放弃了 Segment 臃肿的设计,取而代之的是采用 Node + CAS + Synchronized 来保证并发安全,synchronized 只锁定当前链表或红黑树的首节点,锁粒度更细,并发度更大,性能更好。
5.12 ConcurrentHashMap已经用了synchronized,为什么还要用CAS呢?🔥
ConcurrentHashMap使用这两种手段来保证线程安全主要是一种权衡的考虑,在某些操作中使用synchronized(悲观锁),还是使用CAS(乐观锁),主要是根据锁竞争程度来判断的。
比如:在putVal中,如果计算出来的hash槽没有存放元素,那么就可以直接使用CAS来进行设置值,这是因为在设置元素的时候,因为hash值经过了扰动处理后,造成hash碰撞的几率较低,那么我们可以预测使用较少的自旋来完成具体的hash落槽操作。
当发生了hash碰撞的时候说明容量不够用了或者已经有大量线程访问了,因此这时候使用synchronized来处理hash碰撞比CAS效率要高,因为发生了hash碰撞大概率来说是线程竞争比较强烈。
5.13 ConcurrentHashMap 为什么 key 和 value 不能为 null?
多线程下无法正确判定键值对是否存在(存在其他线程修改的情况),单线程是可以的(不存在其他线程修改的情况)。
5.14 HashSet 与 HashMap 的区别?
HashSet 底层其实是用 HashMap 实现存储的,HashSet 封装了一系列 HashMap 的方法。依靠 HashMap 的 key 存储元素,而 value 值默认为 Object 对象。所以 HashSet 不允许出现重复值,判断标准和 HashMap 判断标准相同,两个元素的 hashCode 相等并且通过 equals()方法返回 true。

1133

被折叠的 条评论
为什么被折叠?



