ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

Java集合框架深度解析:从HashMap到ConcurrentHashMap的进阶指南

Java集合框架深度解析:从HashMap到ConcurrentHashMap的进阶指南 直接上结论如果你正在准备Java面试或者写了两年代码还在靠new ArrayList()闯天下那这篇就是你需要的。Java核心容器也就是我们常说的集合框架是整个Java生态里使用频率最高、面试问得最细、日常开发最容易踩坑的一块。它本质上解决的是“怎么存数据、怎么取数据、怎么高效管理数据”的问题而Collection和Map就是这片江湖的两大门派。这篇文章我会从设计思路、核心实现、实操选型到高频面试题逐一拆开讲并结合我自己实际写代码时踩过的坑来补充细节。不是教科书照搬是照着“面试官在想什么”和“线上环境需要什么”两条线来讲。适合正在准备校招、社招的Java候选人也适合工作几年想回头把基础补扎实的后端开发。1. Collection与Map的整体设计为什么要分两大体系先说一个很多人没认真想过的问题为什么Java集合要分成Collection和Map两个独立的顶层接口而不是统一成一个答案很简单因为数据组织方式根本不同。Collection管的是“一个个独立的元素”而Map管的是“一组组键值对”。你往ArrayList里放字符串、对象、数字它们之间是平等的没有主次关系。但往HashMap里放东西必须成双成对地放——put(name, 张三)一个key对应一个valuekey是用来定位的value才是你真正要用的数据。这种差异直接决定了接口方法的设计// Collection 核心操作 boolean add(E e); boolean remove(Object o); boolean contains(Object o); int size(); IteratorE iterator(); // Map 核心操作 V put(K key, V value); V get(Object key); V remove(Object key); boolean containsKey(Object key); boolean containsValue(Object value); SetK keySet(); CollectionV values();看到了吧Map里压根没有add方法它只有put。因为Map的语义是“映射”不是“添加”。如果你往同一个key上连续put两次第二次会覆盖第一次的值——这是完全合理的设计因为一个key只能对应一个value但是在Collection里你往一个ArrayList连续add两个相同对象它是完全允许的。再往深一层看整个集合框架的设计还有一个核心理念面向接口编程。你写方法参数时声明ListString而不是ArrayListString声明MapString, Object而不是HashMapString, Object这样底层实现可以随时替换——从ArrayList换成LinkedList从HashMap换成ConcurrentHashMap业务代码一行不用改。这就是为什么面试官特别爱问“ArrayList和LinkedList有什么区别”这类问题因为选型本身就是架构能力的一部分。从类图结构来看顶层有两个接口Collection和Map。Collection下面又分List、Set、Queue三大子接口Map下面则直接跟着HashMap、TreeMap、ConcurrentHashMap等实现类。每条分支都有自己的性格List有序可重复、Set不可重复、Queue偏向队列操作、Map讲究键值映射。理解了这层设计后面所有实现类的行为都说得通了。2. Collection体系深度拆解List、Set、Queue三条分支2.1 List接口有序、可重复、有索引List最大的特征是有顺序你add进去的元素会按插入顺序存放而且可以通过索引直接访问。这个特性决定了它的使用场景非常广——榜单数据、消息列表、批量参数集合凡是需要保持先后顺序的地方基本都是List的菜。ArrayList是最常用的实现底层是Object数组默认容量10满时扩容为原来的1.5倍。扩容的代价是数组复制所以如果你能预估数据量最好在构造时就指定初始容量// 预估1000条数据直接指定容量避免多次扩容 ListString list new ArrayList(1000);LinkedList底层是双向链表增删首尾元素很快但随机访问是O(n)——因为链表没有索引要从头往后找。所以“查多改少用ArrayList首尾增删多用LinkedList”这个结论没问题但实际开发中 LinkedList 的使用频率远低于ArrayList。很多场景下差别几乎感知不到真正要注意的是千万别在LinkedList上做get(i)循环——那性能是灾难级的。Vector是JDK1.0就有的老古董方法加了synchronized所以线程安全但性能不如ArrayList。现在除非面试官问到“Vector和ArrayList的区别”否则在代码里几乎不需要动它。要线程安全的话用Collections.synchronizedList()包装或者在并发场景直接用CopyOnWriteArrayList都比Vector更符合现代开发习惯。2.2 Set接口不可重复去重利器Set的核心语义是“没有重复元素”但它并不保证顺序LinkedHashSet和TreeSet除外。去重逻辑依赖两个方法hashCode()和equals()。先比较hashCode如果相同再比较equals两层都过才算重复。所以存自定义对象进HashSet一定要重写这两个方法否则两个字段完全相同的对象因为继承自Object的默认实现会被当成两个不同对象去重直接失效。HashSet底层其实就是HashMap的key部分——你add的元素会作为key放进HashMapvalue统一是同一个占位Object。所以HashSet的查找、添加时间复杂度平均O(1)但无法保证遍历顺序。LinkedHashSet在HashSet基础上加了双向链表维护插入顺序既能去重又能保持插入顺序适合做“有序去重”场景。TreeSet底层是红黑树元素会按自然顺序或传入的Comparator排序但add、remove、contains都是O(log n)比HashSet慢。一个很多人忽略的细节如果把可变对象放入HashSet后修改了对象内容导致hashCode变化这个对象在Set里的位置就“失效”了——contains会返回falseremove也删不掉。解决办法是放进HashSet的对象业务上不要修改会影响hashCode的字段或者用不可变对象。2.3 Queue接口队列语义先进先出Queue是后起之秀JDK5才正式加入集合框架。它的核心是FIFO先进先出操作上比List更收敛——不允许随机访问只能从队尾入队、队头出队。常用方法有offer()入队、poll()出队、peek()查看队头不删除。实现类里LinkedList同时实现了Queue接口可以直接当队列用。ArrayDeque是循环数组实现的双端队列既能当普通队列用也能当栈用性能上比Stack好Stack是同步的有锁开销。PriorityQueue是优先级队列元素出队顺序不按插入顺序而是按优先级——默认自然排序最小堆实现也可以传入Comparator自定义优先级。这个结构在做任务调度、TopK问题时非常好用我自己的经验是写“从N个元素里取前K个最小/最大”问题时用PriorityQueue只需要十几行代码。// 取数组里最大的3个数 PriorityQueueInteger minHeap new PriorityQueue(3); for (int num : nums) { minHeap.offer(num); if (minHeap.size() 3) { minHeap.poll(); // 超过3个就把最小的弹出 } }这个思路面试时很加分实际开发里做排行榜、任务优先级队列也特别实用。3. Map体系全解析从HashMap到ConcurrentHashMap3.1 HashMap现代Java开发最常碰的容器HashMap的底层结构在JDK1.8之后做了大改动数组链表红黑树。简单说先有一个数组默认长度16容量一定是2的幂元素进桶时通过hash(key) (n - 1)算出桶下标如果发生哈希碰撞往链表后面挂当某个桶的链表长度超过8且数组长度超过64链表就转成红黑树把查询复杂度从O(n)降到O(log n)。很多人问为什么默认长度是16而不是别的数核心原因是2的幂配合hash (n-1)计算下标可以保证散列均匀且效率极高——位运算比取模运算快得多。扩容时也是翻倍扩容新容量是原来的2倍仍然是2的幂。JDK1.8优化后扩容不需要重新rehash每个元素而是看原hash值新增的那一位是0还是1——0留在原位1移到“原位置原容量”的新位置这个设计非常精妙。负载因子默认0.75意思是元素个数达到容量*0.75就开始扩容。为什么是0.75官方给出的考虑是时间和空间的折中太高比如1能节省空间但碰撞概率变大、查询变慢太低比如0.5查询快但空间浪费严重。0.75这个值在大多数场景下表现均衡日常开发直接用默认值就行不用特殊调整。关于hash(key)JDK1.8对key的hashCode做了“扰动处理”——h key.hashCode() ^ (h 16)把高16位和低16位做异或目的是让高16位的信息也参与低位的计算减少碰撞概率。这个细节面试时经常被问到回答出来会很加分。写代码时要特别注意HashMap的key必须是不可变的、equals和hashCode必须正确重写。最常见的坑是用可变对象做key。我线上踩过一次用某个实体对象做key存在Map里后来这个对象的字段变了再get直接拿不到值——因为hashCode变了桶都找错了。后来所有Map的key统一用String、Long这类不可变类型问题彻底消失。3.2 LinkedHashMap能记住顺序的HashMapLinkedHashMap继承自HashMap额外维护了双向链表来记录条目的顺序。默认是插入顺序遍历时会按put的顺序输出构造时传入accessOrdertrue则变成访问顺序——每次get或put一个已存在的key这个条目会移动到链表尾部。访问顺序这特性最经典的应用是实现LRU缓存最近最少使用淘汰。LinkedHashMap还特意设计了removeEldestEntry方法默认返回false表示不淘汰重写为“超过容量就返回true”就能自动删除最久没访问的那条数据。class LRUCacheK, V extends LinkedHashMapK, V { private final int maxSize; public LRUCache(int maxSize) { super(16, 0.75f, true); this.maxSize maxSize; } Override protected boolean removeEldestEntry(Map.EntryK, V eldest) { return size() maxSize; } }这段代码没有引入任何额外依赖只需要十几行就实现了一个线程不安全的LRU缓存。线程安全的版本可以用Collections.synchronizedMap()包装或者直接用Caffeine这类成熟缓存库——但理解LinkedHashMap的实现原理对你理解Caffeine底层的淘汰策略也有帮助。3.3 TreeMap有序Map红黑树的经典应用TreeMap底层是红黑树key会按自然顺序或构造时传入的Comparator排序。它跟HashMap最大的区别是TreeMap是有序的可以用firstKey()取最小key、lastKey()取最大key、subMap(fromKey, toKey)截取key区间。这个特性在做区间查询、范围统计时特别好用。比如做价格区间查询、时间段聚合或者需要按key排序输出的场景TreeMap是天然合适的选择。但代价是操作复杂度从HashMap的O(1)变成O(log n)——所以没必要排序的场合别用TreeMap。使用TreeMap的另一个要求是key必须可比较。如果key是自定义对象要么实现Comparable接口要么在构造TreeMap时传入Comparator否则运行时会抛ClassCastExceptionMapStudent, String map new TreeMap((s1, s2) - s1.getScore() - s2.getScore());3.4 ConcurrentHashMap并发场景下当之无愧的主力面试必问、线上必用的并发MapJDK1.8之后实现方式有了质的改变放弃JDK1.7的Segment分段锁改成CASsynchronized锁桶头节点。put流程大体是计算桶下标后如果桶为空用CAS直接插入无锁操作如果桶非空synchronized锁住桶头节点再执行插入如果链表长度达到阈值就转红黑树。这种做法的优势是并发度更高——JDK1.7的锁粒度是Segment一个Segment管多个桶JDK1.8把锁粒度降到单个桶不同桶之间可以完全并行写入。读操作通过volatile保证可见性大部分读不需要加锁所以吞吐量表现很好。我自己的建议是并发场景一律直接上ConcurrentHashMap不要去用Hashtable或者Collections.synchronizedMap()。Hashtable是全表锁并发越高性能越差synchronizedMap也只是对每个方法加锁粒度粗得多。而ConcurrentHashMap不光性能好还提供了一些原子操作方法// 不存在才插入返回旧值 V putIfAbsent(K key, V value); // 存在且值等于期望值才删除 boolean remove(K key, V value); // 存在且值等于期望值才替换 boolean replace(K key, V oldValue, V newValue);但要注意ConcurrentHashMap的每个方法是线程安全的多个方法的组合操作却不是原子的。比如经典的map.containsKey(key)再map.put(key, value)两步之间其他线程可能已经插入了同个key。这种场景要么用computeIfAbsent这种原子方法要么加外部锁。这是并发编程里最常见的“复合操作非原子”陷阱写过并发代码的人基本都掉进去过。3.5 其他Map实现速览Hashtable是JDK1.0的老类所有方法都加了synchronized线程安全但性能差现在基本被ConcurrentHashMap取代。面试时记住区别就行Hashtable不允许key或value为nullConcurrentHashMap同样不允许HashMap允许key或value为null。WeakHashMap的key是弱引用当key对象不再被强引用引用时垃圾回收时会自动清除对应的条目。这个类在做缓存、需要及时释放内存的场景有点用比如某些框架的元数据缓存。但实际开发中用到频率很低知道原理就够了。IdentityHashMap使用而不是equals()来判断key是否相等也就是说两个内容相同但引用不同的key会被视为两个不同的key。这个类在序列化框架、做对象引用计数等特殊场景才会用到一般业务开发完全碰不到。4. 实操核心环节集合的创建、遍历、排序与线程安全4.1 初始化集合的便捷姿势JDK9开始提供了List.of()、Set.of()、Map.of()这组静态工厂方法可以快速创建不可变集合ListString list List.of(a, b, c); SetInteger set Set.of(1, 2, 3); MapString, Integer map Map.of(a, 1, b, 2);这三个方法创建出来的集合是不可变的——不能add、remove、put只能读。优点是代码简洁、节省内存生成的集合专门做了优化。缺点是如果试图修改会抛UnsupportedOperationException。所以用之前要清楚自己的集合会不会被修改如果不确定就老老实实写到ArrayList或HashMap里。JDK8里也可以用双括号初始化但它是个坑外部类会持有内部类引用容易造成内存泄漏而且每次创建都会生成一个新的内部类文件。我看到很多老代码这么写新代码千万别学了。正确姿势是先创建集合再逐个put或者用Stream收集MapString, Integer map Stream.of( new AbstractMap.SimpleEntry(a, 1), new AbstractMap.SimpleEntry(b, 2) ).collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue));4.2 遍历方式怎么选遍历集合看起来人人都会但写得好不好、能不能在面试里讲清楚是另一回事。List遍历for循环配合索引、增强for底层是迭代器、forEach方法、Stream这几种都行。如果遍历过程中要删除元素用增强for会抛ConcurrentModificationException——快速失败机制迭代器modCount和预期值对不上就抛。要安全删除用Iterator的remove方法或者JDK8的removeIflist.removeIf(s - s.startsWith(test));这里的原理值得一提removeIf内部也是用迭代器遍历但在删除时会同步更新迭代器的期望modCount所以不会抛异常。这也是为什么常规的“for循环list.remove”会踩坑而removeIf不会。Map遍历有四种姿势。keySet()拿key然后get value——效率最低因为多一次get的哈希查找values()只遍历值拿不到keyentrySet()拿Entrykey和value都能直接拿到效率最好forEach((k, v) - ...)是JDK8的方法内部也是Entry遍历代码最简洁。平时最推荐用最后两种map.forEach((key, value) - System.out.println(key value)); for (Map.EntryString, Integer entry : map.entrySet()) { System.out.println(entry.getKey() entry.getValue()); }4.3 排序List和Map的排序姿势List排序用Collections.sort()或者list自带的sort()方法传入Comparator即可list.sort(Comparator.comparing(User::getAge)); // 按年龄升序 list.sort(Comparator.comparing(User::getAge).reversed()); // 按年龄降序 list.sort(Comparator.comparing(User::getAge) .thenComparing(User::getName)); // 年龄相同再按姓名Map排序要分情况。TreeMap天然有序构造时传Comparator就行。如果拿到了一个HashMap想按key排序最直接的方式是转成List再排然后放进LinkedHashMap保持顺序MapString, Integer sortedMap map.entrySet().stream() .sorted(Map.Entry.comparingByKey()) .collect(Collectors.toMap( Map.Entry::getKey, Map.Entry::getValue, (oldValue, newValue) - oldValue, LinkedHashMap::new ));注意Collectors.toMap有三个重载默认的版本如果出现重复key会抛IllegalStateException。上面的第四参数LinkedHashMap才是保持顺序的关键第三个参数是合并函数——面试官问“Stream collect的toMap参数是什么”考察的就是这个细节。4.4 线程安全的集合选型面试里最常见的追问是“HashMap线程安全吗不安全体现在哪”。不安全体现在三处多个线程同时put可能丢失数据——两个线程同时扩容一个线程的结果覆盖另一个JDK1.7还有并发put时可能形成环形链表导致get死循环这个经典问题让无数人面试翻车值得重点记忆另一个是put时size不是原子操作多线程环境下size统计不准确。解决方案选型表我整理了一下实际开发直接照这个选场景推荐实现理由单线程无并发需求HashMap / ArrayList性能最高无额外开销多线程读多写少CopyOnWriteArrayList / ConcurrentHashMap读无锁写时复制快照多线程写多读也多ConcurrentHashMap / ConcurrentLinkedQueue细粒度锁或CAS吞吐高需要全量加锁的复合操作外置锁 HashMapsynchronizedMap粒度太粗不如自主控制锁需要同步且能容忍性能损失Collections.synchronizedList/map简单但性能差仅对旧代码兼容特别提醒CopyOnWriteArrayList的“写时复制”意味着每次add/set都会复制整个数组写成本非常高。所以它只适合读多写极少的场景比如监听器列表、配置缓存这种。如果写操作频繁用它就是灾难。5. 高频面试题与避坑技巧速查5.1 面试官最爱追问的六个细节第一问HashMap的put流程是什么完整回答要覆盖这几步计算key的hash并进行扰动处理通过(n - 1) hash计算桶下标桶为空直接new Node放入桶非空则比较hash和equals相同则覆盖value并返回旧值否则在链表尾部插入JDK1.8尾插法JDK1.7头插法链表长度超过8且数组长度超过64转红黑树添加完成后size加1超过阈值容量*负载因子就扩容。第二问扩容为什么会导致线程不安全1.7头插法在并发扩容时可能反转链表产生环1.8改成尾插法修复了这个问题但并发put还是会丢数据。所以底层虽然大量优化HashMap仍然不是线程安全的容器只有ConcurrentHashMap才是正道。第三问为什么链表转红黑树的阈值是8官方注释给出的是泊松分布计算在负载因子0.75、随机hash函数条件下一个桶链表长度达到8的概率只有千万分之六说明正常情况链表根本长不到8。达到8说明hash函数分布严重失衡这时候用红黑树来“补救”查询性能就比较值了。树化也附属一个条件数组长度必须达到64否则优先扩容而不是树化。第四问equals()和hashCode()为什么要一起重写因为HashMap查找key的流程是“先比hashCode定位桶再比equals确认对象”。如果只重写equals不重写hashCode两个逻辑相等的对象hashCode不同会散落到不同桶equals比对根本不会发生——哈希表里永远找不到。如果只重写hashCode不重写equals同一个桶内比对时两个对象equals返回false也不会被识别成同一个key。第五问ConcurrentHashMap为什么不能放null这个有解释也有争议官方没有给出明确理由。常见的说法是如果允许null在并发环境下通过get(key)返回null时无法区分“key不存在”还是“key的value是null”。HashMap是单线程的可以用containsKey再来一次确认但ConcurrentHashMap在并发场景下这种做法不原子容易产生歧义。所以干脆不允许null从源头上杜绝这种模糊性。第六问fail-fast和fail-safe有什么区别fail-fast是指迭代遍历时结构被修改增删元素立即抛出ConcurrentModificationExceptionArrayList、HashMap都是这种机制原理是遍历前记录expectedModCount每次遍历对比实际modCount。fail-safe则是像CopyOnWriteArrayList、ConcurrentHashMap这类的迭代器遍历的是快照所以遍历时修改不会抛异常代价是遍历期间看不到最新数据。5.2 日常开发最容易踩的五个坑第一个坑用可变对象做Map或Set的key。前面已经说过了放进Map后如果对象的hashCode发生改变get、remove都会失效。这是线上最常见的内存泄漏和bug来源。解决方案key一律用String、Integer、Long这些不可变类型。第二个坑遍历集合时直接删除。for循环里调用list.remove()会抛ConcurrentModificationException增强for也一样。正确姿势是迭代器的remove方法、removeIf、或者先用Stream过滤再collect成新List。第三个坑Arrays.asList()创建的List不支持add/remove。它返回的是Arrays内部的ArrayList实现虽然类名也是ArrayList但这个类的数组是定长的只支持改不支持增删——调add会抛UnsupportedOperationException。想得到一个真正的可变List要new ArrayList(Arrays.asList(...))。第四个坑Map.getOrDefault不会把默认值写回Map。这个很多人搞混map.getOrDefault(key, 0)只是返回默认值Map本身不会多出“key”这个条目。如果希望“拿不到就放一个默认值进去”要用computeIfAbsentmap.computeIfAbsent(key, k - 0);第五个坑大量使用contains做去重却用List。List.contains()的时间复杂度是O(n)数据量大时非常慢。如果只是去重用HashSetO(1)判断是否存在性能天差地别。有一次我排查线上接口慢发现就是在一个上千元素的List上反复做contains判断改成HashSet后接口耗时从几百毫秒降到十几毫秒。5.3 集合工具类的隐藏技能Collections这个工具类有很多人忽略的技能。Collections.reverse(list)逆序、Collections.shuffle(list)打乱顺序、Collections.rotate(list, distance)旋转、Collections.min/max(collection)找极值、Collections.frequency(collection, obj)统计出现次数。这些方法虽然逻辑不复杂但自己写还要先想起循环结构用工具类直接一行解决代码干净很多。Collections.unmodifiableList(list)可以返回一个只读视图任何修改操作都会抛异常。适合把内部集合暴露给外部调用方时防止外部乱改内部数据。注意这里的“视图”意味着原始List改了只读视图里的内容也会跟着改——它不是快照只是加了“不能改”的限制。Collections.emptyList()返回一个空集合常量不涉及内存分配。方法返回值是空集合时别返回null也别每次创建一个new ArrayList直接返回Collections.emptyList()调用方就不用判空了。6. 一个完整场景从需求到集合选型的实战推演说了这么多原理最后拿一个实际场景串一遍你会更清楚怎么把集合选型落地。场景写一个简单的“在线投票统计”功能需要记录每个候选人的票数支持高并发投票。候选人是固定的几个名字。第一步选型记录票数天然是key-value结构key是候选人名字value是票数。所以第一步确定用Map。第二步看并发线上是高并发投票不能用HashMap直接上ConcurrentHashMap。第三步确定key类型候选人名字用String不可变安全。ConcurrentHashMapString, Integer voteMap new ConcurrentHashMap();第四步发现问题光用put不行因为“读取旧值加1写回”不是原子的。多个线程同时读到一个旧值分别加1再写回票数就少算了。第五步寻找原子方法ConcurrentHashMap的compute方法可以在锁内完成“根据当前值计算新值并写入”的操作是原子的voteMap.compute(candidateName, (key, oldValue) - oldValue null ? 1 : oldValue 1);或者用merge语义更直接——合并函数指定“旧值和新增值怎么合并”voteMap.merge(candidateName, 1, Integer::sum);第六步做统计和排序投票结束了要展示排行榜需要按票数排序。从ConcurrentHashMap拿到entrySet后用Stream排序输出即可不需要改容器本身。如果你用的是HashMap或ConcurrentHashMap排序都是“临时排序”而不是“容器天然有序”——天然有序的TreeMap要按key排序而不是value所以这个场景不适用。这个例子想说明的是集合选型不是“哪个牛逼用哪个”而是结合并发需求、key特性、后续操作排序、遍历频率、是否需要原子更新一起决定的。真正写多了就会有条件反射看到统计需求先想ConcurrentHashMapmerge看到去重先想HashSet看到保持顺序先想LinkedHashMap看到范围查询先想TreeMap。我自己写代码这么多年最深刻的一个体会是Java集合框架不是背出来的知识点而是靠一次一次debug、一次一次线上事故总结出来的经验库。面试官问某个集合的底层原理本质上不是要看你能不能背出源码注释而是要确认你在关键时刻能不能选对容器、写出不会出问题的代码。把这几个核心容器吃透日常开发里再遇到“数据怎么存”的问题你基本都能条件反射地给出合理答案了。最后再提醒一句在你写下一个new ArrayList()之前先花三秒钟想想——它真的该是ArrayList吗
返回列表