16000+字!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。
