ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Java集合框架全解析:List、Set、Map、Queue选型与底层原理

Java集合框架全解析:List、Set、Map、Queue选型与底层原理 做Java做了快十年集合框架几乎是从我第一次写代码到现在每天都在碰的东西。不管是写业务逻辑、做数据过滤还是准备面试翻来覆去就是这些集合类来回选。前两天我特意让DeepSeek把Java集合框架里所有集合的异同点从头到尾梳理了一遍又结合我自己这些年踩过的坑和实际项目的选型经验整理出了这篇内容。如果你是刚学Java、想在集合这块打下扎实基础或者正在背面试题准备跳槽这篇都值得从头到尾认真看一遍。我不光会告诉你每个集合是什么还会讲清楚它们底层怎么设计的、什么时候该用谁、用错了会有什么后果。1. 集合框架整体认知为什么每个Java程序员都绕不开它1.1 数组的局限性与集合框架的诞生想彻底理解集合框架得先搞清楚它到底解决了什么问题。很多新手第一反应是不是有数组吗存个东西用数组不就行了但你在真实项目里写代码就会发现数组做业务存储简直处处是坑。数组最大的问题就是定长。你创建int[10]那它就永远是10存满了就得自己写扩容逻辑——新建一个更大的数组把旧数据拷贝过去再丢掉旧数组。这个操作又啰嗦又容易出bug。另外数组只能按顺序存储你要查一个元素得自己写循环遍历要删除一个中间元素还得自己挪位置现实中业务数据的查找、插入、删除频率远比你想象得高。集合框架把这些脏活累活全部封装好了。你只管往里面放数据、取数据扩容、缩减、索引维护这些底层逻辑全由集合类内部处理。更重要的是集合框架把数据结构做成了体系化的接口和实现类List、Set、Queue、Map四大体系各管一摊每种都有自己的适用场景和性能特点。你只要选对了集合类很多代码性能问题在源头就避免了。1.2 两大体系Collection与MapJava集合框架的整体结构其实就两大分支一个是Collection接口体系存的是单个元素另一个是Map体系存的是键值对。你打开JDK源码所有集合类最终都归到这俩分支下面。Collection下面又分出三个子接口List有序可重复、Set无序不可重复、Queue队列主要用于先进先出或者按优先级出队。图我就不画了你在脑子里面想象一棵树就行。Map体系则是独立的一棵HashMap、TreeMap、LinkedHashMap、ConcurrentHashMap这些全是它的后代。这里有个很容易让人困惑的点Set和Map看似完全不相干底层却是千丝万缕。HashSet内部其实就是一个HashMap只是它只用key不关心valueTreeSet内部也是一个TreeMap。理解了这条线索你就能把两大体系串起来记忆。我一会儿讲具体集合的时候会再展开。1.3 泛型与迭代器集合框架的地基在你深入看每个集合之前有两个基础概念必须先整明白否则后面代码会写得很痛苦。第一个是泛型。Java从1.5开始支持泛型你可以把集合声明成ListString、MapString, Integer这种形式让编译器帮你检查放进来的数据类型。不用泛型的话List里能放Object类型运行时做强制类型转换极其容易踩ClassCastException的坑。扁平化管理时代写集合代码不加泛型基本等于给自己埋雷。第二个是迭代器Iterator。集合类统一通过迭代器暴露遍历能力不管是ArrayList还是TreeSet都能用同一个Iterator接口遍历。这里有个面试常问的机制叫fail-fast简单说就是集合内部维护了一个modCount字段用来记录结构性修改次数。迭代器遍历时会记录初始modCount每次next()都检查一遍发现被其他线程或者代码修改了就立刻抛ConcurrentModificationException。这个机制不是为了绝对安全而是尽早暴露并发修改问题。2. List体系详解有序可重复的三个主力2.1 ArrayList动态数组的扩容哲学ArrayList是日常开发里用得最多的集合类没有之一。它底层就是一个Object数组所有操作本质上都在操作这个数组。你初始化一个new ArrayList()它内部是空数组元素首次add的时候才给你初始化为容量10的数组。这个设计很聪明避免了无数只创建不使用的空列表浪费内存。扩容时机和倍数也有讲究每次容量不够就扩容新容量是旧容量的1.5倍也就是int newCapacity oldCapacity (oldCapacity 1)。为什么是1.5倍而不是2倍因为1.5倍兼顾了空间利用率和扩容频率的平衡——2倍扩容空间浪费太大1.5倍整体更经济。由于底层是连续数组ArrayList的get(int index)是纯内存寻址时间复杂度O(1)特别适合随机访问场景。但它的弱点也在这里在列表中间插入或者删除元素得把后面的所有元素整体往前挪或者往后挪时间复杂度O(n)。业务上如果你频繁在头部插入用ArrayList会非常难受性能迅速劣化。2.2 LinkedList双向链表的双面性LinkedList底层是双向链表结构每个节点是一个Node对象包含前驱指针prev、后继指针next和元素本身item。因为不需要连续内存所以它没有扩容的概念存多少就分配多少节点。从性能角度看LinkedList在头尾两端操作是O(1)addFirst()、addLast()、removeFirst()都非常快。但如果你按照索引取中间元素比如get(size/2)它不能直接跳到那个位置只能从链表头或者尾一步步遍历过去实际开销是O(n/2)。这个特性决定了它不适合随机访问。还有一个很多人没意识到的点LinkedList不仅实现了List接口还实现了Deque接口所以它本身就可以当双端队列用。进队出队、栈操作它都能干。但我的建议是——日常开发中LinkedList能不用就不用。为什么我先卖个关子后面讲ArrayDeque的时候再给你算这笔账。2.3 Vector与Stack遗留类的历史包袱Vector和Stack是JDK 1.0时代就存在的集合类属于爷爷辈的组件。Vector的设计思路和ArrayList几乎一样底层也是Object数组主要区别在于它的方法都用synchronized修饰了也就是线程安全版本。但这里有个典型的性能陷阱Vector的方法级别的同步粒度太粗整个方法加锁。在多线程环境下即使只是读取操作也会被锁阻塞。实际项目中并发场景大家早就不用Vector了而是用Collections工具类包装出来的Collections.synchronizedList(new ArrayList())或者直接上CopyOnWriteArrayList。Stack就更复古了它直接继承Vector提供了push、pop、peek等方法。Stack的问题在于它的语义设计得并不纯粹继承了Vector的全部能力你既可以当栈用也可以当列表用这在工程上其实是坏事。Java官方自己也承认Stack是个遗留设计推荐使用ArrayDeque作为栈的替代品。2.4 三个List到底怎么选一个表格解决我直接给你一张我自己整理的对照表平时选型对着看就够了维度ArrayListLinkedListVector底层结构Object数组双向链表Object数组随机访问O(1)极快O(n/2)慢O(1)极快尾部插入O(1)摊销O(1)O(1)摊销中间插入/删除O(n)需要移动元素O(n)查找O(1)改指针O(n)头部插入/删除O(n)很差O(1)优秀O(n)线程安全否否synchronized方法内存占用连续数组额外空间少每个节点多两个指针约8字节额外开销连续数组选型建议非常明确99%的线性表场景直接用ArrayList。包括很多人以为的我经常在头部插入是不是该用LinkedList——实际业务里这种场景通常应该改成ArrayDeque而不是LinkedList。LinkedList只有在确实需要同时利用List和Deque双接口特性时才算有优势这种场景在真实项目中少之又少。3. Set体系详解不重复背后的三种策略3.1 HashSetHashMap的套壳设计Set的核心语义就一句话不包含重复元素。三个常见实现各用了一种策略来保证这一点。HashSet是使用频率最高的它的底层实现就是HashMap实例化时创建了一个HashMapE, Object每次add(e)本质上执行的是map.put(e, PRESENT)其中PRESENT是一个静态的Object占位对象。换句话说HashSet把元素当作key存进HashMapvalue永远是一个固定的假对象。利用HashMap天然的key不重复特性add重复元素时put会返回旧valueHashSet据此判断插入失败。这也带出一个极其重要的推论HashSet去重依赖元素的hashCode()和equals()。如果元素是一个自定义类的对象你必须在类里同时重写这两个方法而且重写规则是equals相等的对象hashCode必须相等。很多人只重写equals忘了重写hashCode结果set里面两个内容相同的对象都存进去了bug查半天查不出来。因为底层是哈希表HashSet的add、remove、contains操作平均时间复杂度都是O(1)非常快。但它有一个代价无序。它不保证元素的迭代顺序存进去的顺序和取出来很可能是两码事。如果你打印一个HashSet的内容顺序颠三倒四很正常。3.2 LinkedHashSet有序与去重的平衡LinkedHashSet继承自HashSet在哈希表的基础上加了一条双向链表来维护元素的插入顺序。它本质是LinkedHashMap实现的每个节点除了哈希桶的next指针还有before和after两个指针串成一条链。这个设计精妙在哪里它既保留了HashSet键值对操作的O(1)复杂度又让迭代顺序变得可预测按插入顺序遍历。这个特性在需要保持插入顺序同时又需要去重的场景非常有用比如记录用户操作路径去重后还要按照操作顺序展示。不过LinkedHashSet的代价是内存开销更大每个元素都多了前后指针数据量大时额外内存不可忽略。另外一个容易记混的点LinkedHashSet是插入顺序不是访问顺序。你要是想实现访问后移动到末尾这种LRU效果得用LinkedHashMap并设置accessOrder为trueLinkedHashSet没有这个选项。3.3 TreeSet红黑树支撑的排序集合TreeSet底层是一个TreeMap内部基于红黑树数据结构存储元素。它的核心特点是有序元素存储进去就会自动排好序迭代时按排序顺序输出。排序规则有两种来源。第一种是元素本身实现了Comparable接口比如String、Integer这些JDK自带类都有自然排序。第二种是创建TreeSet的时候传一个Comparator对象进去自定义排序逻辑。这两种同时存在时Comparator优先级更高。因为基于红黑树TreeSet的增删改查时间复杂度都是O(log n)比HashSet的O(1)慢但它换来的是排序能力和范围查询能力。比如subSet(from, to)、headSet(to)、tailSet(from)这些范围截断操作其他Set根本做不到。判断元素是否存在也同样走的是红黑树查找路径不需要遍历。需要注意两个坑第一TreeSet不允许存null除非Comparator特殊处理因为null没法比较大小第二元素排序属性修改后不会自动触发重新排序你得先remove再重新add否则位置就是错的。3.4 三种Set的异同对比与去重实战注意事项我把三个Set的关键异同整理成表格方便你记忆维度HashSetLinkedHashSetTreeSet底层结构HashMapLinkedHashMapTreeMap红黑树迭代顺序无序插入顺序排序顺序自然或Comparator增删查复杂度O(1)平均O(1)平均O(log n)是否允许null允许一个null允许一个null不允许null适用范围常规去重、判重需要保持插入顺序的去重需要排序、范围查询实操中我吃过亏的一个点就是这个自定义对象放进Set之前一定确保hashCode和equals两个方法都重写了且行为一致。你用IDE自动生成的版本就行但要注意如果用到了Lombok的EqualsAndHashCode它在类继承层次复杂时会生成包含父类字段的实现这块逻辑不一致也可能导致去重失效。更隐蔽的是对象放进Set之后再修改了参与hashCode计算的字段会导致对象在哈希桶中的位置错乱你以为contains能查到它实际查不到。这类bug极难排查建议设计上把参与hashCode的字段都设为不可变。4. Map体系详解键值对存储的完整图谱4.1 HashMap数组链表红黑树的进化史HashMap是整个Java集合框架里最核心、面试问得最多的类没有之一。JDK 1.8之后它的底层结构是数组加链表加红黑树的三位一体。核心设计是这样的HashMap内部维护一个Node数组也就是哈希桶数组。put一个键值对时先通过(h key.hashCode()) ^ (h 16)这个扰动函数把key的哈希值做一次高低位异或尽量让高位信息也参与散列。然后通过tab[(n - 1) hash]计算出这个键值对落在哪个桶。这里的n是数组长度使用n-1的按位与运算相当于取模但比取模快得多前提是n必须是2的幂。如果多个key的哈希值落到了同一个桶就形成链表挂在数组节点后面。链表长度超过8且数组长度大于等于64时链表会转成红黑树把查询复杂度从O(n)降为O(log n)。为什么阈值设成8这是依据泊松分布计算的负载因子0.75情况下链表长度达到8的概率约是千万分之六树化其实是为了抵御极端哈希冲突的场景。扩容是HashMap里最值得讲明白的点。当map元素数量超过容量 * 0.75时触发resize数组容量翻倍。所以为什么加载因子默认选0.75这是时间成本和空间成本的折中太低会频繁扩容浪费空间太高则冲突增多降低效率。JDK 1.8对rehash做了个经典优化扩容后每个元素的新位置要么留在原索引要么移动到原索引加上旧容量的位置。判断依据就是(e.hash oldCap)等于0留在原地等于1移到高区。头插法改尾插法也解决了1.7版本并发扩容可能产生循环链表的问题。顺便说一句HashMap允许一个null key和任意多个null valuenull key会放到数组第0个桶。这个细节和Hashtable、ConcurrentHashMap都不一样后面要对比着记。4.2 LinkedHashMap与TreeMap两种有序性HashMap有一个明显痛点遍历顺序不可控。LinkedHashMap就是针对这个痛点设计的它继承自HashMap内部加了双向链表维护节点顺序。LinkedHashMap支持两种排序模式插入顺序和访问顺序。默认是插入顺序就是你put的顺序。如果把构造参数accessOrder设为true每当get或put访问一个节点这个节点就会被移动到链表末尾从而实现最近最少使用的淘汰语义。这个特性让它成了实现LRU缓存的绝佳底座。我做过一个项目需要限制内存缓存上限直接继承LinkedHashMap、重写removeEldestEntry方法判断size超过阈值就返回true几十行代码就搞定了一个正经的LRU缓存简单可靠。LinkedHashMap因为要维护双向链表put和get的常数项开销比HashMap略高内存占用也更大。这属于可接受的代价换来了稳定的迭代顺序。TreeMap走的是另一种有序路线基于红黑树对key进行排序。它和TreeSet的关系就像HashMap和HashSet的关系一样TreeSet底层就是TreeMap。TreeMap的key必须要么实现Comparable要么在构造时传入Comparator。它支持各种范围操作subMap、headMap、tailMap、firstKey、lastKey等都是红黑树上的高效操作时间复杂度O(log n)。核心场景是需要按键的自然顺序或自定义顺序遍历以及处理区间查询的业务。比如按时间戳排序存储一批数据然后查最近一小时到最近十分钟的数据TreeMap干这个非常顺手。4.3 Hashtable与ConcurrentHashMap线程安全的两种思路先说Hashtable。这个类名字里的t是小写的很多人写代码敲成HashTable直接编译报错。Hashtable是JDK 1.0时代的老类它的线程安全手段极其原始所有公开方法都用synchronized加锁等于同一时间只允许一个线程访问整个表结构。这种全表锁在并发量稍高的情况下性能非常差而且它连get操作也要抢锁读取都不能并发执行。Hashtable还有一个严格限制不允许null key和null value会直接抛NullPointerException。为什么因为它设计时认为null有歧义——查不到key的null和值为null的null无法区分。ConcurrentHashMap是真正的并发之王。JDK 1.8之后它的实现逻辑是数组节点用volatile修饰保证可见性空桶插入时用CAS操作无锁完成出现哈希冲突后在链表头节点或者红黑树根节点上使用synchronized加锁。这种细粒度锁的效果是多线程可以并发操作不同桶只有操作同一个桶时才需要竞争。它的读操作完全无锁性能极其出色。ConcurrentHashMap同样不允许null key和null value官方设计的理由是为了避免并发场景下二义性如果用null表示查无此key在多线程环境下get返回null时你无法区分是key不存在还是key对应的value本身就是null。这个设计取舍你在面试中如果能讲出来会加分不少。4.4 Map家族横向对比维度HashMapLinkedHashMapTreeMapHashtableConcurrentHashMap底层结构数组链表红黑树HashMap双向链表红黑树数组链表数组链表红黑树迭代顺序无序插入顺序或访问顺序key排序无序无序线程安全否否否是全表锁是CASsynchronizednull key/value允许允许key不允许null都不允许都不允许常用场景通用键值存储LRU缓存、有序迭代排序、范围查询基本已被淘汰高并发共享存储这里我要特别强调一个很多老开发都会踩的坑多线程环境直接使用HashMap不出事是运气出事是必然。哪怕你只是并发读写在扩容期间就可能出现数据错乱、丢数据甚至CPU跑满的问题。并发场景统一上ConcurrentHashMap不是可选项是必选项。5. Queue体系详解被忽视的双端与优先级队列5.1 ArrayDeque循环数组实现的双端队列说完List和Map队列体系经常被很多人忽视但我觉得这部分在真实场景里价值极高。Deque这个接口提供了双端队列语义两端都能进能出既能当普通FIFO队列用也能当栈用。ArrayDeque是Deque最推荐的实现。它的底层是循环数组通过头尾两个指针来标记数据区间。之所以叫循环数组是因为当tail指针到达数组末尾时如果头部还有空位tail会绕回数组头部继续使用不需要扩容。只有当整个数组真的满了才翻倍扩容。相比LinkedListArrayDeque在同样支持双端操作的前提下底层是连续数组CPU缓存友好度更高节点不需要额外存前后指针内存更省。实测下来同样规模的入队出队操作ArrayDeque明显比LinkedList快。前面我留的问题现在可以回答了如果只是当栈或者队列用Stack类和LinkedList都不如ArrayDeque。Java官方文档也直接建议优先使用ArrayDeque来替代Stack。ArrayDeque唯一需要注意的是不允许插入null元素。这其实和ConcurrentHashMap的限制逻辑类似null在队列语义里太含糊了栈和队列都不太需要null这种哨兵值。5.2 PriorityQueue二叉堆的优先级世界PriorityQueue是一个基于二叉堆实现的优先级队列。它的核心逻辑元素不是按入队顺序出队而是按优先级出队优先级最高的元素最先出队。默认情况下PriorityQueue是一个小顶堆堆顶元素始终是队列里最小的那个。如果你想要大顶堆最大值先出队可以通过Comparator实现比如new PriorityQueue(Comparator.reverseOrder())。每次offer入队时新元素会从堆的末尾加入然后不断上滤siftUp和父节点比较如果比父节点小就交换位置直到满足堆的性质。每次poll出队时堆顶元素被拿走最后一个元素放到堆顶位置然后不断下滤siftDown和两个子节点中较小的那个交换恢复堆序。这两个操作都是O(log n)而peek只看堆顶是O(1)。PriorityQueue在业务里最典型的应用是任务调度有一批带优先级的任务每次都处理优先级最高的那个。它还有两个限制一是不能存null二是元素必须可比较否则你创建队列的时候必须显式传入Comparator。5.3 队列体系各实现怎么选维度ArrayDequeLinkedListPriorityQueue底层结构循环数组双向链表二叉堆出队顺序FIFOFIFO按优先级入队/出队复杂度O(1)摊销O(1)O(log n)是否可当栈用可以可以不可以是否允许null不允许允许不允许适用场景队列、栈通用容器需要列表与队列双接口任务调度、TopK这里补充一个实际项目里的经验需要找N个元素里最大的K个或者最小的K个这类TopK问题用一个容量为K的小顶堆或大顶堆解决遍历一遍数据堆满后每来一个新元素和堆顶比一下该替换就替换。整体复杂度O(n log K)比全量排序高效得多我接过的很多数据统计需求都是这么干的。6. 集合工具类与整体选型速查6.1 Collections工具类包装、排序与同步Java为集合框架配了两个工具类一个叫Collections注意是复数另一个叫Arrays。它们是被低估的瑞士军刀很多场景用好它们能省下大量手写逻辑。Collections里有几个高频操作sort(List)对List排序底层实际是调用了Arrays.sort非常高效binarySearch(List, key)对有序List做二分查找reverse(List)反转列表shuffle(List)随机打乱frequency(Collection, obj)统计元素出现次数min/max(Collection)直接取极值。更实用的是它的包装方法unmodifiableList/Set/Map(...)返回只读视图任何尝试修改的调用都会抛UnsupportedOperationException。这个在做接口返回数据保护时特别有用防止外层代码意外修改内部数据。synchronizedList/Set/Map(...)则返回同步包装版给需要快速兼容并发场景的遗留代码用。还有一个冷门好用的emptyList()、singletonList(e)避免频繁new空集合产生不必要的对象开销。6.2 Arrays工具类数组与集合的桥梁Arrays的用途集中在数组操作和数组与集合的转换上。Arrays.asList(T... a)是把数组包装成List的经典入口但它有三个你必须知道的坑。第一asList返回的List是Arrays内部类ArrayList不是java.util.ArrayList。你不能对它调用add、remove等结构性修改方法否则直接抛UnsupportedOperationException。第二asList得到的列表和原数组共享底层数据通过list修改元素会同步到数组上。第三如果你传的是int[]这类基本类型数组asList得到的List元素类型是整个int数组而不是Integer泛型会把int[]当成一个整体对象放进去输出长度永远是1。如果你想要一个真正独立、可增删的ArrayList正确姿势是new ArrayList(Arrays.asList(arr))相当于做一次拷贝复制。另外Arrays.copyOf、Arrays.sort、Arrays.binarySearch这些方法在性能敏感代码里的出场率极高建议熟悉。6.3 面对业务场景的最终选型清单如果你看完整篇还不太确定怎么选我给你一份最直白的选型清单照着套就行需要线性存储、频繁随机访问、按下标取数据选ArrayList需要栈或者队列不做随机访问选ArrayDeque需要按优先级处理任务选PriorityQueue需要去重不在乎顺序选HashSet需要去重同时保持插入顺序选LinkedHashSet需要去重并且元素要有排序选TreeSet需要键值对映射无特殊要求选HashMap并发场景换ConcurrentHashMap需要键值对且迭代顺序必须是插入顺序或做LRU选LinkedHashMap需要按键排序、范围查询选TreeMap需要给接口返回只读数据防止外部修改用Collections.unmodifiableXXX包装这个清单覆盖了日常开发里九成以上的场景。别小看选型这步一个HashMap和一个TreeMap在数据量五万以上的时候性能差距是数量级的。常量级和O(log n的差异在小数据量下感觉不到数据量一大立刻原形毕露。6.4 面试高频考察点清单结合这些年当面试官和参加面试的经验我把Java集合这块问得最多的点也一并列出来你可以对照自查HashMap的put流程、扩容时机、为什么容量是2的幂答案为了n-1按位与运算代替取模加载因子为什么默认0.75、树化阈值为什么是8答案泊松分布空间时间折中HashMap在JDK 1.7和1.8的区别头插法改尾插法、引入红黑树、扩容不用重新计算indexHashSet怎么实现去重的答案复用HashMap元素当key存重写equals为什么必须同时重写hashCode答案哈希集合的存储规则要求相等对象哈希值必须一致ArrayList和LinkedList的区别及各自适用场景这个最多人问fail-fast机制和ConcurrentModificationException的触发条件ConcurrentHashMap为什么读操作无锁还能保证线程安全答案volatile保证可见性CAS加锁粒度的设计如何实现一个线程安全的HashMap老生常谈Collections.synchronizedMap或ConcurrentHashMap这些考点你如果能结合底层原理讲清楚而不是背概念基本就稳了。面试官想听的永远是你真正理解了数据结构在你手里的行为而不是你记住了这个类的功能描述。我个人在实际项目里最深刻的体会是集合选型这种看似基础的选择往往决定了代码在线上真实数据下的表现上限。曾经有一个导出服务最初用LinkedList做中间存储数据量到几十万条时耗时翻了几倍换成ArrayList之后耗时直接降了一个数量级一行业务逻辑都没改。还有一次在多线程环境里图省事用了HashMap存统计数据线上偶发数据对不上排查了两天才定位到是并发扩容写丢了数据。从那以后我给自己立了个规矩涉及共享可变数据一律ConcurrentHashMap选择集合类之前先想清楚数据规模、读写比例和是否需要有序这三个问题。这些基架层面的注意事项越早形成习惯后面省的事就越多。
返回列表