Java集合
概述
Java集合分为 Collection
接口,主要用于存放单一元素;另一个是 Map
接口,主要用于存放键值对。
Collection
接口又有三个主要的子接口:List
、Set
、 Queue
。
List
集合
集合遍历的方法有哪些?
1,普通 for 循环
2,增强 for 循环(for-each循环): 用于循环访问数组或集合中的元素
3,Iterator 迭代器: 可以使用迭代器来遍历集合,特别适用于需要删除元素的情况。
**4,ListIterator 列表迭代器 :**ListIterator是迭代器的子类,可以双向访问列表并在迭代过程中修改元素。
**5,使用 forEach 方法:**Java 8引入了 forEach 方法,可以对集合进行快速遍历。
**6,Stream API:**Java 8的Stream API提供了丰富的功能,可以对集合进行函数式操作,如过滤、映射等。
List vs 数组
数组是固定长度的数据结构,一旦创建长度就无法改变,而集合是动态长度的数据结构,可以根据需要动态增加或减少元素。
数组可以包含基本数据类型和对象,而集合只能包含对象。
数组可以直接访问元素,而集合需要通过迭代器或其他方法访问元素。
list可以一边遍历一边修改元素吗?
决于遍历方式和具体的List
实现类
1,使用普通for循环遍历。可以在遍历过程中修改元素,只要修改的索引不超出List
的范围即可。
2,使用foreach循环遍历。不可以,因为这可能会导致意外的结果或ConcurrentModificationException
异常。因为foreach
循环底层是基于迭代器实现的,在foreach
循环中修改元素可能会破坏迭代器的内部状态。
3,使用迭代器遍历。可以使用迭代器的remove
方法来删除元素,通过迭代器的set
方法修改元素。不能通过List
的set
方法,否则也可能发生ConcurrentModificationException
异常。
4,对于线程安全的List
,如CopyOnWriteArrayList
由于其采用了写时复制的机制,在遍历的同时可以进行修改操作,不会抛出ConcurrentModificationException
异常,但是可能会读取到旧的数据,因为修改操作是在新的副本上进行的。
Collections.synchronizedList()
Collections.synchronizedList()
是 Java 中的一个工具方法,用于将一个普通的 List
包装成一个线程安全的 List
。它是通过对所有的 List
操作加锁来保证线程安全的。
ArrayList
ArrayList和数组的区别
数组是 Java 最基本的数据结构,特点是长度固定、访问效率高。它适合用在对性能要求比较高、元素数量明确的场景,比如底层算法、缓存之类的。
ArrayList 是 Java 集合框架中的一个类,它的底层其实就是数组,但它在上层做了封装,支持动态扩容。还提供了很多方便的 API,比如 add、remove、contains 等。
数组可以存基本类型,比如 int[],但是 ArrayList 只能存引用类型,这是因为泛型不支持基本类型,要通过自动装箱来处理。
机制
ArrayList
是基于数组实现的可变长度的列表。它实现了RadomAccess
标记接口,支持索引快速访问。底层就是一个Object[]数组。线程不安全。
懒加载:如果你没有指定大小那它是懒加载的,创建了一个ArrayList
的时候并不会立刻分配数组,而是把内部的elementDate
指向一个共享的空数组EMPTY_ELEMENTDATA
,真正的初始化发生在第一次添加元素的时候,这样的目的是为了节省内存。只有在第一次添加元素时,才会初始化为默认容量 10 的数组。
扩容:当你调用add()会进行元素个数和容量的判断,判断是否达到数组容量,如果是就会触发扩容,这里的扩容是一个懒触发的过程,只有在满了的那一刻才扩容,会触发grow()方法,把原数组的容量扩大到1.5倍,实际上是创建了一个新数组然后Arrays.copyOf()
方法复制的。
至于为什么是1.5倍,从源码上看是因为oldCapacity + (oldCapacity >> 1);
,>>右移一位就是除以2^1,从设计上讲,这是一个性能和空间的权衡折中方案,因为频繁的扩容会导致性能浪费,而扩太多就会导致空间浪费。并且1.5可以通过位运算减少浮点数或者运算时间和运算次数。
但是删除元素不会自动缩容,逻辑大小会减少,但是底层容量仍然保留原扩容后的大小。这种设计的初衷是为了提高性能,减少频繁的内存复制操作,适用于大多数场景。然而,这也带来了内存浪费的问题。为了避免浪费,如果元素减少得较多,可以手动调用 trimToSize()
来缩容。
复杂度:至于插入删除的复杂度,如果是头部和指定位置都是O(n),如果是尾部插入,又分为需要扩容和不需要扩容的情况,如果需要扩容,那么会新建数组然后copyOf()
,复杂度是O(n),如果不需要扩容就是O(1)。
为什么ArrayList线程不安全
ArrayList
是基于 动态数组 实现的集合类,在设计上是为单线程场景优化的,内部操作都没有做任何同步控制,因此在多线程环境下是不安全的。
具体来说,它的线程不安全体现在以下几个方面:
- 添加元素时可能发生数据覆盖或数组越界: 如果多个线程同时调用
add()
方法,而此时底层数组正好需要扩容,那么可能会多个线程同时进入扩容逻辑,导致扩容结果不一致、数据被覆盖,甚至抛出ArrayIndexOutOfBoundsException
。 - 删除或修改元素时可能引发结构错乱: 多个线程同时调用
remove()
或set()
,可能会导致元素错位或数据异常,破坏ArrayList
的内部结构。 - 迭代过程中可能抛出
ConcurrentModificationException
:ArrayList
的迭代器是快速失败(fail-fast)机制,在遍历过程中如果有其他线程修改了集合结构,会抛出ConcurrentModificationException
异常。
这些问题的根本原因在于:ArrayList
的核心操作(如 add
、remove
、ensureCapacity
、modCount
相关)没有使用锁或其他并发控制机制,在多线程环境下会发生竞态条件,最终导致不可预测的问题。
把ArrayList变成线程安全
ArrayList不是线程安全的
1,使用Collections类的synchronizedList方法将ArrayList包装成线程安全的List
2,使用CopyOnWriteArrayList类代替ArrayList,它是一个线程安全的List实现
3,使用Vector类代替ArrayList,Vector是线程安全的List实现
LinkedList
LinkedList
是基于双向链表实现的(JDK1.6 之前为循环链表,JDK1.7 改为普通双向链表),底层是由多个节点(Node
)连接成的链表,内存地址不连续,只能通过指针来定位,不支持随机快速访问。包括前后节点的引用和存储的元素。线程不安全。
复杂度:元素的添加都是O(1),删除头尾也都是O(1),但是删除中间节点的时候需要定位到节点,所以是O(n)。
并且它实现了 List
、Deque
和 Queue
接口,所以既能当列表用,也能当队列、双端队列、栈来用,功能还是很丰富的。
CopyOnWriteArrayList
CopyOnWriteArrayList
是 Java 并发包 (java.util.concurrent
) 提供的线程安全 List
实现。它采用 "写时复制" 机制,适用于 读多写少 的高并发场景。
底层结构是内部维护了一个 volatile
数组(Object[] array
),保证多线程可见性。
它的核心实现原理是读写分离。所有读操作直接访问当前数组的快照,完全不需要加锁,因此读性能极高。而写操作会通过互斥锁ReentrantLock 加锁保护,在修改数据时先复制一份新的底层数组,在新数组上完成修改后,再原子性地替换旧数组。这种设计保证了读操作永远不会阻塞,也避免了读写冲突。
这种实现方式带来了几个关键特性:首先,由于读操作不加锁且访问的是不可变数组快照,因此可以支持高并发的读取。其次,写操作虽然需要复制数组,但由于加锁保证了同一时刻只有一个写操作在进行,避免了数据竞争。最后,迭代器基于创建时的数组快照工作,不会反映后续修改,这种弱一致性特性避免了 ConcurrentModificationException。
应用场景:CopyOnWriteArrayList
最适合监听器列表、配置信息等读多写少的场景。它的主要优势在于极高的读并发性能和无锁读取,但代价是写操作的成本较高,因为每次修改都需要复制整个数组。同时,由于迭代器的弱一致性,它不适合需要实时看到最新数据的场景。
对比
ArrayList vs LinkedList
相同点:
- 都实现了 Java 的 List 接口:它们都支持相同的基本操作,例如添加元素、删除元素、查找元素、获取元素等
- 都是有序的集合:都保证按顺序存储元素
- 都允许存储重复元素:可以有多个相同的元素,并且都允许存储 null 元素。
- 都不线程安全
不同点:
- 底层数据结构:ArrayList的底层是动态数组,有扩容机制;LinkedList的底层是双向链表。
- 操作复杂度:ArrayList的插入删除的复杂度,如果是头部和指定位置都是O(n),如果是尾部插入,又分为需要扩容和不需要扩容的情况,如果需要扩容,那么会新建数组然后
copyOf()
,复杂度是O(n),如果不需要扩容就是O(1); LinkedList元素的添加都是O(1),删除头尾也都是O(1),但是删除中间节点的时候需要定位到节点,所以是O(n)。 - 快速随机访问:
LinkedList
不支持高效的随机元素访问,而ArrayList
(实现了RandomAccess
接口) 支持。 - 内存空间占用:
ArrayList
的空间浪费主要体现在在 list 列表的结尾会预留一定的容量空间,而 LinkedList 的空间花费则体现在它的每一个元素都需要消耗比 ArrayList 更多的空间(因为要存放直接后继和直接前驱以及数据)。 - 应用场景:ArrayList 适合查询多,修改少的场景;LinkedList 适合插入和删除操作多的场景。
Map
图
如何对map进行快速遍历?
1,使用for-each循环和entrySet()方法:较为常见和简洁的遍历方式,可以同时获取Map
中的键和值。
2,使用for-each循环和keySet()方法:只遍历键
3,使用迭代器:这种方式在需要删除元素等操作时比较有用。
4,使用 Lambda 表达式和forEach()方法
5,使用Stream API:可以将Map
转换为流
哈希冲突的解决方法
1,链接法:使用链表或其他数据结构来存储冲突的键值对,将它们链接在同一个哈希桶中。
2,开放寻址法:在哈希表中找到另一个可用的位置来存储冲突的键值对。常见的开放寻址方法包括线性探测、二次探测和双重散列。
3,再哈希法:使用另一个哈希函数再次计算键的哈希值,直到找到一个空槽来存储键值对。
4,哈希桶扩容:可以动态地扩大哈希桶的数量,重新分配键值对,以减少冲突的概率。
HashMap
TreeMap和HashMap都继承自AbstractMap
HashMap的底层是数组+链表/红黑树,是非线程安全的,核心原理是通过 key 的 hash 值快速定位元素在数组中的位置,然后通过 equals()
判断是否为同一个 key,实现快速查找和插入;当put插入数据的时候,首先调用hashcode方法计算哈希值,然后hash方法又会对这个哈希值进行扰动处理(本质是添加高位信息的计算,降低低位重复的风险),目的是为了让哈希值更均匀,减少碰撞。然后会对数组长度取模得到数组下标,也就是桶的索引,如果这个位置是空的,就直接在这个位置放一个Node节点(键值对的封装对象),如果已经有数据了,说明发生了哈希冲突,这个时候会遍历桶的链表/红黑树,先用hash筛选可能的key,然后使用equals方法判断是不是同一个key,如果key已经存在,就更新value,反之,就添加这个节点,走链表(8之前头插,之后尾插,性能稍慢但是并发扩容不会造成循环链表)或红黑树的添加逻辑。如果某个桶的链表长度超过8并且总容量大于64,就会转化成红黑树,效率是O(logn);当查找元素的时候,首先计算key的哈希值,再去桶遍历链表/红黑树,找hash值相等的节点,然后用equals判断是不是目标key,如果找到了就返回value;扩容:HashMap的默认初始容量是16,负载因子是0.75,当元素数量超过16*0.75=12的时候,发生扩容,扩容会把原数组容量扩大为原来的2倍,然后把原来的元素再次hash放到新位置,这一步的开销较大,因为涉及所有元素的重新计算和移动。
优先扩容而不是优先转化为红黑树,因为数组扩容能减少哈希冲突的概率,并且红黑树需要维持自平衡,维护成本较高,并且红黑树会增加复杂度(节点占用内存更大)。
为什么扩容是8和64:因为泊松分布表明,链表长度到达8的情况非常非常少。设为8可以保证性能和空间的平衡。64也是实践验证的经验值,优先扩容而不是优先转化红黑树,当数组大小达到64的时候,冲突概率较高,转化成红黑树的性能优势就明显了。
为什么扩容*2:目的是为了让hash计算更加高效;首先,HashMap的容量是2的幂,hash取模可以使用位运算代替,(n-1)& hash;扩容后的再次hash更容易判断元素是否发生位置移动,重新hash后我们只需要看新增的哪一位是0还是1就能判断出来是否需要后移oldCapacity(是1就移);
**为什么负载因子是0.75?**默认负载因子为 0.75,是因为它提供了空间和时间复杂度之间的良好平衡。负载因子太低会导致大量的空桶浪费空间,负载因子太高会导致大量的碰撞,降低性能。
**为什么HashMap线程不安全?**1.7之前头插法导致在多线程环境下扩容操作可能存在死循环问题;另外无论是7还是8都存在一个数据丢失的问题,实际上是数据覆盖,一种情况是线程1发现hash冲突并且equals满足,即将覆盖数据的时候,CPU执行权被线程2抢走,数据2覆盖了数据,等到线程1再次操作的时候就会把线程2的数据覆盖。还有一种情况是两个线程同时 put
操作导致 size
的值不正确,进而导致数据覆盖的问题。
死循环问题:JDK1.7 及之前版本的 HashMap
在多线程环境下扩容操作可能存在死循环问题,这是由于当一个桶位中有多个元素需要进行扩容时,多个线程同时对链表进行操作,头插法可能会导致链表中的节点指向错误的位置,从而形成一个环形链表,进而使得查询元素的操作陷入死循环无法结束。
为什么红黑树而不是平衡二叉树(AVL树):平衡二叉树追求的是一种 ”完全平衡“ 状态,导致每次进行插入/删除节点的时候都需要左旋右旋来维持平衡;红黑树追求 ”弱平衡“,虽然牺牲了一些查找的效率,但是维护成本低。
Key可以为null:null作为Key只能有一个,但是null作为value可以有多个。当key为空时,直接另key的哈希值为0,不走key.hashCode()方法。
HashMap vs HashSet
HashMap
是一个键值对集合,用来存储 key 和 value 的映射关系;而 HashSet
是一个不重复的集合,只存储元素本身,没有键值对的概念。
从实现上来看,HashSet
内部其实就是通过一个 HashMap
来实现的。我们每往 HashSet
加一个元素,底层就是把这个元素作为 HashMap
的 key,value 用一个固定的对象(通常是 PRESENT
)。也就是说,HashSet
借用了 HashMap
的 key 去实现唯一性约束。
此外,在元素唯一性判断上,两者都靠 key 的 hashCode 和 equals 来判断唯一性,但 HashMap 还需要关注和操作与 key 相关联的 value,如get(key)
,putIfAbsent()
等方法,而 HashSet 根本不涉及 value 的变化。
TreeMap vs HashMap
首先,排序方式是最显著的区别。HashMap
不保证任何顺序,它是无序的,元素的顺序是由哈希函数决定的。而 TreeMap
是基于红黑树实现的,它会根据键的自然顺序(即 Comparable
)或者自定义的 Comparator
对键进行排序。因此,TreeMap
保证键的顺序是有序的。
第二,底层数据结构不同。HashMap
底层是基于哈希表实现的,键通过哈希值定位到桶(bucket)中,这使得它的查找、插入和删除操作平均时间复杂度是 O(1),但是它的顺序是不可预测的。而 TreeMap
底层是基于红黑树的,操作时间复杂度是 O(log N),但是它能够提供有序的键值对。
第三,线程安全性,HashMap
和 TreeMap
都是非线程安全的。如果在多线程环境下需要使用线程安全的 Map
,需要使用 ConcurrentHashMap
或者通过显式加锁来实现。
最后,性能上的差异。由于 TreeMap
需要维护键的顺序,插入、删除、查找等操作的时间复杂度是 O(log N),相较于 HashMap
的 O(1) 要慢。如果不需要排序,HashMap
会更快
TreeMap
TreeMap
是 Java 中 Map
接口的一个实现类,最大的特点就是它可以对键进行自动排序。这个排序要么是按照键的自然顺序,也就是键实现了 Comparable
接口,要么是通过我们在构造时传入的 Comparator
进行自定义排序。
它的底层是基于红黑树实现的,也就是一种自平衡的二叉搜索树,所以它在插入、删除、查找时都能保证 logN 的时间复杂度。相比于 HashMap
来说,虽然性能略低一些,但它能保持顺序,这在需要范围查找、按序遍历时非常有用。
值得注意的是,TreeMap
不允许键为 null,因为排序时会涉及键的比较,而 null 不能参与比较操作。值是可以为 null 的。
另外,TreeMap
不是线程安全的,在并发场景下我们需要自己加锁或者使用工具方法进行同步包装。
总的来说,如果我们在业务中需要一个按照键排序的 Map,比如做排行榜、区间查找、时间线数据等,TreeMap
是一个很合适的选择。
ConcurreHashMap
ConcurreHashMap是线程安全版的hashmap。
1.8之前
在 JDK1.7 及之前,ConcurrentHashMap
的线程安全是通过**分段锁(Segment Lock)**实现的。它将整个哈希表分为若干个段(Segment),每个 Segment 其实是一个小的哈希表,同时每个 Segment 都维护一个独立的锁。
当我们对 ConcurrentHashMap
进行 put、get 或 remove 操作时,只会锁住对应的某个 Segment,而不是整个 map。这样多个线程只要访问的 Segment 不同,就可以并发执行操作,避免了 Hashtable
那种全表锁带来的性能瓶颈。
Segment 的数量一般是 2 的幂次方,比如默认是 16 个,也就是说最多可以有 16 个线程并发写入而不阻塞,从而提升了并发性能。
不过它的缺点也很明显:Segment 的数量是固定的,粒度不能再细,某个 Segment 上仍然可能成为热点,限制了更高的并发性。
JDK 1.7 ConcurrentHashMap中的分段锁是用了 ReentrantLock,是一个可重入的锁。
1.8之后
到了 JDK1.8,ConcurrentHashMap
的结构完全重写,不再使用 Segment,而是用数组 + 链表 + 红黑树的结构,同时结合了 CAS(无锁操作) 和 synchronized(细粒度同步)。
插入数据时,先通过 CAS 操作尝试将节点插入到桶中,如果该位置是空的,直接写入,无需加锁;如果该位置已经有元素(比如链表或红黑树),才会用 synchronized 加锁,但只锁当前桶的头节点,锁的粒度更细。
扩容时,多个线程可以协同迁移数据,称为协助扩容,避免了单线程扩容造成的阻塞。
get 操作是无锁的,通过 volatile 保证可见性,读取效率非常高。
相比 JDK1.7,1.8 的 ConcurrentHashMap
在并发性能、内存占用、锁竞争等方面都做了很大优化,彻底解决了分段锁机制下的扩展瓶颈。
已经用了synchronized,为什么还要用CAS
ConcurrentHashMap
之所以同时使用 synchronized
和 CAS
,是为了在保证线程安全的同时最大程度提高并发性能,两者各有侧重,互相配合使用。
首先,CAS
(Compare-And-Swap)是一种无锁原子操作,适用于一些简单、局部的并发修改场景,比如在插入元素之前先尝试占位节点(initTable
初始化时就使用了 CAS)。它可以在不加锁的前提下完成并发修改,从而提高并发效率,减少上下文切换。
但 CAS
也有局限,它只能保证单点原子性,不适用于需要多个步骤或者修改多个共享变量的复合操作。而对于某些复杂逻辑(如链表转红黑树、链表删除元素、扩容等),就必须使用传统的 synchronized
来保证原子性和一致性。
所以:
- CAS 适用于轻量级、简单、可乐观执行的操作,优先使用,提高性能。
synchronized
适用于需要完整互斥控制的复杂操作,确保线程安全。
这种组合方式体现了 ConcurrentHashMap
的设计思想:在保证线程安全的前提下,尽可能细化锁粒度、提升并发度和执行效率。
不允许为 null
HashMap
的 key 和 value 都能为 null,但 key 最多只能有一个为 null;而 ConcurrentHashMap
两者都不允许为 null,因为二义性,你没办法是不存在才返回null还是因为值本身就是null。多线程下无法正确判定键值对是否存在(存在其他线程修改的情况),单线程是可以的(不存在其他线程修改的情况)。
ConcurrentHashMap
是否支持复合操作的原子性时
第一,它本身只保证单个方法的线程安全,比如 get
、put
、remove
等,内部都有并发控制机制,比如 CAS 或 synchronized。但如果我组合多个方法,比如 if (!map.containsKey(k)) map.put(k, v)
,这就不是原子的,中间可能被其他线程修改,导致逻辑错误。
第二,JDK 1.8 开始提供了一些专门应对复合操作的原子方法,比如 putIfAbsent
、compute
、computeIfAbsent
、computeIfPresent
和 merge
,这些方法内部已经做了同步控制,可以安全地执行复合操作,所以我们要优先使用这些方法来避免并发问题。
第三,如果遇到更加复杂的逻辑,ConcurrentHashMap
没有提供直接支持的原子操作,那就需要 开发者自己加锁 来保证原子性,通常可以使用外部 ReentrantLock
或 synchronized
。
Hashtable
Hashtable
是 Java 中最早期提供的哈希表实现,已经过时,更推荐使用 ConcurrentHashMap
来替代它。
最大的特点是线程安全。Hashtable
的所有方法几乎都通过 synchronized
实现了同步控制,保证了多线程并发访问时的数据一致性。但也正因为如此,它的性能在高并发环境下会受到一定影响,因为每次访问都需要加锁,容易成为性能瓶颈。
从底层结构上看,Hashtable
和早期的 HashMap
类似,都是基于数组加链表的结构实现的哈希表。通过键的 hashCode()
方法计算哈希值,再定位到数组的某个槽位,发生哈希冲突时通过拉链法解决。
值得注意的是,Hashtable
不允许键或值为 null
。这和 HashMap
不同,HashMap
允许一个 null
键和多个 null
值。
ConcurrentHashMap vs Hashtable
首先,在实现线程安全的方式上,Hashtable
是通过对整个方法加上 synchronized
来实现同步的,也就是说每次读写操作都需要加锁,锁的粒度非常大,线程只能串行访问,性能比较低。而 ConcurrentHashMap
则是通过分段锁或者更细粒度的同步机制来实现并发控制的。在 JDK1.7 中,它使用的是分段锁结构,把整个桶数组分成多个 Segment,每个 Segment 有自己的锁,可以支持更高并发;到了 JDK1.8,它优化为使用 CAS 和 synchronized
结合的方式,锁粒度更小,性能进一步提升。
其次,Hashtable
在多线程下容易成为性能瓶颈,而 ConcurrentHashMap
在保证线程安全的同时兼顾了并发性能,是并发场景下更推荐的选择。
还有一点比较重要的是,Hashtable
不允许键或值为 null
,ConcurrentHashMap
也不允许。这个设计是为了避免在并发场景下使用 null
带来的歧义,比如不能判断是没有映射还是映射为 null。
从实际开发角度来说,Hashtable
已经属于过时的类,几乎不会在新项目中使用了;而 ConcurrentHashMap
是目前在并发环境下使用最广泛的线程安全 Map 实现。
ConcurrentSkipListMap
ConcurrentSkipListMap
是 Java 并发包中提供的一种线程安全的、可排序的 Map 实现,底层基于跳表(Skip List)结构。它可以保证键值对按照 key 的自然顺序或自定义 Comparator 排序,并支持高并发读写操作。
与 ConcurrentHashMap
相比,它最大的特点是有序;与 TreeMap
相比,它则具备线程安全性,并发性能更好。内部是通过 CAS 和分段锁来实现并发控制的,支持无锁读、局部加锁写,整体性能优于传统的全锁方案。
由于其有序性和线程安全特性,ConcurrentSkipListMap
特别适用于并发场景下需要排序功能的需求,比如实现排行榜、区间查找、按时间排序的数据存储等。
Set
HashSet,LinedHashSet,TreeSet,都是Set接口的实现类,都能保证元素唯一,并且都不是线程安全的。
HashSet是基于HasmMap的Key唯一的特性实现的,用来实现HashSet的HasmMap的value是一个固定常量,一般是PRESENT。
这个
PRESENT
是HashSet
内部的一个private static final Object
,只用来占位,没有实际意义。每当我们往HashSet
添加一个元素,底层就是执行map.put(element, PRESENT)
。不能是Null
如果用
null
作为 value,那在调用map.get(key)
的时候,永远返回的是null
,我们就无法区分到底这个 key 是真的存在,只是值是 null,还是这个 key 根本不存在。
LinkedHashSet
LinkedHashSet
是 Set
接口的一个实现类,它继承自 HashSet
。
- 保持插入顺序:
LinkedHashSet
通过双向链表来维护元素的插入顺序,这意味着在遍历时,它会按照元素添加的顺序进行输出。与HashSet
不同,HashSet
是无序的,无法保证元素的顺序。 - 底层实现:
LinkedHashSet
是基于HashMap
实现的,每个元素作为HashMap
的 key,value 使用一个固定的对象(通常是PRESENT
)。同时,它通过双向链表来保持插入顺序。 - 性能:
- 和
HashSet
一样,LinkedHashSet
的add
、remove
、contains
等操作的时间复杂度都是 O(1)。 - 由于需要维护插入顺序,它相较于
HashSet
会稍微多一些内存开销,因为它要额外存储双向链表的信息。
- 和
- 适用场景:
LinkedHashSet
适用于那些需要保证元素顺序的场景,且不需要重复元素的集合。如果你需要元素的唯一性且保持插入顺序,就可以使用LinkedHashSet
。
TreeSet
TreeSet
是 Set
接口的一个实现类,底层基于 TreeMap
实现。
- 元素有序:
TreeSet
会根据元素的自然顺序(即元素实现了Comparable
接口)或者自定义的排序规则(通过构造函数传入Comparator
)对元素进行排序,遍历时是有序的。 - 不允许重复元素:
TreeSet
和其他 Set 一样,不能存储重复元素。它是通过比较元素的大小来判断是否重复的,而不是equals()
,所以自定义比较器或compareTo
的逻辑要和equals
保持一致,否则会出现逻辑冲突。 - 底层实现:
TreeSet
底层是基于TreeMap
实现的,具体是将元素作为TreeMap
的 key,value 是一个固定的常量。它本质上是一个红黑树结构,因此所有操作的时间复杂度是 O(log N)。 - 不允许存储
null
元素: 因为比较时会抛出空指针异常,所以插入null
会直接抛出NullPointerException
。 - 适用场景: 当我们需要一个自动排序且不重复的集合时,就可以使用
TreeSet
,比如处理排行榜、自动去重并排序的一些数据集合等。
ConcurrentSkipListSet
CopyOnWriteArraySet
Queue
PriorityQueue
PriorityQueue
是 Java 提供的一个基于优先级的队列实现,它实现了 Queue
接口,用来存储一组按照优先级自动排序的元素。
和普通队列不同,普通队列是先进先出,而 PriorityQueue
中的元素并不是按照插入顺序来出队的,而是按照优先级最小的元素优先出队,也就是默认是一个小顶堆结构。
它的底层是基于最小堆(binary heap)实现的数组结构,插入和删除操作的时间复杂度都是 O(logN)。元素入队后会自动按照优先级进行调整。
默认情况下,队列中的元素必须实现 Comparable
接口,使用元素的自然顺序进行排序;如果我们需要自定义排序方式,也可以在构造方法中传入一个 Comparator
,从而按照指定的优先级进行排序。
值得注意的是,PriorityQueue
允许重复元素,但不允许插入 null,因为 null 会与内部比较操作冲突。此外,它不是线程安全的,在多线程环境下需要通过外部同步或使用并发版本的队列,比如 PriorityBlockingQueue
。
典型应用场景包括:任务调度器、带权重的消息处理系统、Dijkstra 最短路径算法、Top-K 问题等。
BlockingQueue
BlockingQueue
是 Java 中用于线程间通信和任务调度的一个接口,广泛用于生产者-消费者模型的实现。它的特点是:当队列为空时,消费者线程会被阻塞,直到有新元素加入;而当队列满时,生产者线程会被阻塞,直到队列有空间可以插入新元素。
BlockingQueue
主要有两个特性:
- 阻塞:如果队列为空,调用
take()
方法的线程会被阻塞,直到队列中有元素可用。如果队列已满,调用put()
方法的线程会被阻塞,直到队列有空位。 - 线程安全:通过内部的锁机制(如使用 ReentrantLock 或其他同步机制)来确保多个线程同时访问时,不会产生数据不一致的问题,保证在高并发情况下的正确性和一致性。
BlockingQueue
接口有几个常用实现类:
ArrayBlockingQueue
:基于数组实现的有界阻塞队列,队列的容量是固定的。LinkedBlockingQueue
:基于链表实现的有界阻塞队列,可以设置最大容量,也可以不设置,默认是无界队列。PriorityBlockingQueue
:基于优先级队列实现的阻塞队列,元素会按照优先级顺序排序。DelayQueue
:一个支持延迟获取元素的队列,元素必须实现Delayed
接口,适用于定时任务的场景。SynchronousQueue
:没有任何容量的阻塞队列,每一个put
操作必须等待另一个take
操作才能完成,常用于任务传递。
在实际应用中,BlockingQueue
被广泛应用于多线程的任务调度、线程池的实现、生产者-消费者模型等场景。
ArrayBlockingQueue
和LinkedBlockingQueue
区别
ArrayBlockingQueue
和LinkedBlockingQueue
都是常用的阻塞队列,具备线程安全和阻塞特性,但它们在底层结构、容量控制和性能表现上有明显区别。首先,底层实现不同。
ArrayBlockingQueue
是基于数组实现的,内部使用一个固定长度的数组作为存储结构,因此它的容量必须在创建时指定,无法动态扩容。而LinkedBlockingQueue
是基于链表实现的,每个元素是一个独立的节点,通过链表串联起来,它支持有界和无界两种模式。如果构造时没有指定容量,默认容量是 Integer.MAX_VALUE,相当于一个理论上的无界队列。其次,在性能和资源使用上也有区别。由于数组是连续内存,
ArrayBlockingQueue
的内存利用更紧凑,内存开销相对较小,适合对内存敏感的场景。而LinkedBlockingQueue
每个元素都需要额外的节点对象,内存开销相对更大,但在高并发场景下,它的插入和移除操作更加平稳,适合大规模数据流动。此外,
LinkedBlockingQueue
的 put 和 take 操作内部使用的是两把锁(分别用于读和写),而ArrayBlockingQueue
只使用一把锁来控制整个队列,因此在某些高并发情况下,LinkedBlockingQueue
的吞吐量可能会更好一些。应用场景上,如果我清楚队列的容量范围,比如用于线程池中的任务提交队列,我更倾向于使用
ArrayBlockingQueue
,因为它更简单、内存开销更小。但如果任务量无法预估,或者系统允许更大的弹性空间,那么LinkedBlockingQueue
会更适合一些。
ConcurrentLinkedQueue
Queue和Deque
Queue 是一个单端队列,遵循的是典型的“先进先出”(FIFO)原则。我们通常使用 offer()
添加元素,用 poll()
或 remove()
移除元素,从队首出,从队尾入。常见的实现类有 LinkedList
和 PriorityQueue
,像任务调度、消息队列等场景都是使用 Queue。
而 Deque 是“双端队列”,全称是 Double Ended Queue,它既可以从队头添加或移除元素,也可以从队尾进行相同的操作。也就是说,Deque
同时支持 FIFO 和 LIFO(后进先出),因此它既可以当队列用,也可以当栈用。常用方法比如 addFirst()
、addLast()
、removeFirst()
、removeLast()
,而实现类像 ArrayDeque
和 LinkedList
都支持 Deque 接口。
所以总结来说,Queue 只能一端进、一端出,Deque 两端都能进出,更灵活,适合处理双向数据结构或实现栈这种结构。
LinkedBlockingDeque
ConcurrentLinkedDeque
ArrayDeque
和 LinkedList
ArrayDeque
是基于动态数组实现的,它内部用一个循环数组来存储元素。因为是数组结构,它在两端插入和删除元素时效率很高,几乎都是 O(1) 时间,并且由于没有指针开销,它的内存利用率也更好。
相比之下,LinkedList
是基于双向链表实现的,每个节点都有前后指针。它的优势是插入和删除不会涉及数组扩容或元素移动,也支持插入 null 元素,但缺点是内存开销更大,随机访问效率低,性能波动也可能更明显。
此外,ArrayDeque
不允许插入 null 元素,而 LinkedList
是允许的;另外在栈结构(比如替代 Stack
类)或队列结构中,ArrayDeque
通常比 LinkedList
表现更优,也更推荐使用。
ArrayDeque
和 ArrayList
虽然 ArrayList
和 ArrayDeque
的名字都带有 "Array",而且底层都是用数组(Object[])来存储数据,但它们的设计目的、使用方式以及性能特点有很大的不同。
首先,ArrayList
实现了 List
接口,主要用于按顺序存储元素,强调的是随机访问和按索引管理数据。它的底层是一个线性动态数组,支持快速的索引访问,比如 get(index)
或 set(index)
的时间复杂度是 O(1)。但是如果在中间插入或删除元素,就会涉及到大量元素的移动,性能相对较差。
而 ArrayDeque
实现了 Deque
接口,也就是双端队列,它的重点是支持从队头和队尾进行高效的插入和删除操作。它底层虽然也是数组,但用的是循环数组结构。当数组尾部空间不足时,它会自动绕回到数组头部去使用未占用的位置,这种环形结构可以避免频繁的数据移动,使得两端操作的性能非常稳定,都是 O(1) 的时间复杂度。
还有一些细节上的差异,比如:
ArrayList
允许插入null
元素,而ArrayDeque
明确禁止null
,因为它用null
表示队列为空,这样更容易区分是否出队成功。- 在扩容机制上,两者都支持动态扩容,但
ArrayList
是线性扩容(一般是原来容量的 1.5 倍),ArrayDeque
的容量总是 2 的幂次方,这有助于在循环数组中通过位运算快速定位元素位置。
使用场景上也有差别:
- 如果我们要频繁地访问元素,尤其是按下标访问,那肯定首选
ArrayList
。 - 如果我们实现的是栈(LIFO)或队列(FIFO),尤其需要在两端进行频繁插入/删除,
ArrayDeque
的性能和空间利用率都比LinkedList
更好,更推荐使用。
其他
双向链表和双向循环链表
在 JDK 1.6 之前,LinkedList
的实现是循环链表。而从 JDK 1.7 开始,Java 的 LinkedList
底层实现就不再使用循环链表,改用了普通的双向链表。
双向链表: 包含两个指针,一个 prev 指向前一个节点,一个 next 指向后一个节点。
双向循环链表: 最后一个节点的 next 指向 head,而 head 的 prev 指向最后一个节点,构成一个环。
为什么这么改?这个变化的主要原因是循环链表并不带来明显的性能优势,而反而可能带来一些复杂性和潜在的错误。例如,处理尾节点指向头节点的引用可能引发无限循环的问题,尤其是在垃圾回收时。改成普通双向链表后,结构更简单,代码也更加直观。
RandomAccess
RandomAccess
接口不过是一个标识接口。标识实现这个接口的类具有随机访问功能。
ArrayList
实现了 RandomAccess
接口, 而 LinkedList
没有实现。究其原因还是和底层数据结构有关,数组天然支持随机访问,而链表需要遍历到特定位置才能访问特定位置的元素。
public interface RandomAccess {
}
fail-fast
和fail-safe
Fail-fast 是一种设计策略,它会在集合被修改的情况下立即抛出异常。通常,当你在迭代集合时,如果集合被修改了,迭代器就会抛出 ConcurrentModificationException
异常。
Fail-safe 是另一种设计策略,它允许在遍历集合的同时修改集合的结构,并且不会抛出异常。即使在迭代过程中发生修改,迭代器也能继续正常工作。
fail-fast 更侧重于提前发现错误,fail-safe 更侧重于保证并发环境下的安全操作。
ArrayList
和 HashMap
的迭代器就是 fail-fast 类型的。
CopyOnWriteArrayList
和 ConcurrentHashMap
,它们采用了 fail-safe 策略
Comparable
和Comparator
Comparable
接口和 Comparator
接口都是 Java 中用于排序的接口
首先,Comparable
是一个 内部比较器,它是由类自己实现的排序规则。也就是说,如果一个类实现了 Comparable
接口,它就必须重写 compareTo()
方法,并在方法中定义自己的“自然排序规则”。比如像 String
、Integer
、Date
这些类本身就实现了 Comparable
,可以直接进行排序。
而 Comparator
是一个 外部比较器,它通常在我们无法修改目标类源码,或者需要灵活定义多种排序规则时使用。通过实现 Comparator
接口中的 compare()
方法,我们可以在类的外部定义排序逻辑。像我们用 Collections.sort(list, comparator)
或 TreeMap
传入比较器时,都是在使用 Comparator
。
简单理解的话,Comparable
是让“对象自己可比较”,而 Comparator
是“通过外部工具比较对象”。另外值得注意的是,Comparator
支持 Lambda 表达式,所以在 Java 8 之后我们可以用非常简洁的方式写出比较逻辑。
Collections和Collection的区别
Collection是Java集合框架中的一个接口,它是所有集合类的基础接口。它定义了一组通用的操作和方法,如添加、删除、遍历等,用于操作和管理一组对象。Collection接口有许多实现类,如List、Set和Queue等。
Collections(注意有一个s)是Java提供的一个工具类,位于java.util包中。它提供了一系列静态方法,用于对集合进行操作和算法。Collections类中的方法包括排序、查找、替换、反转、随机化等等。这些方法可以对实现了Collection接口的集合进行操作,如List和Set。