高性能集合与实现细节(快速导览)
开篇语
哈喽,各位小伙伴们,你们好呀,我是喵手。运营社区:C站/掘金/腾讯云/阿里云/华为云/51CTO;欢迎大家常来逛逛
今天我要给大家分享一些自己日常学习到的一些知识点,并以文字的形式跟大家一起交流,互相学习,一个人虽可以走的更快,但一群人可以走的更远。
我是一名后端开发爱好者,工作日常接触到最多的就是Java语言啦,所以我都尽量抽业余时间把自己所学到所会的,通过文章的形式进行输出,希望以这种方式帮助到更多的初学者或者想入门的小伙伴们,同时也能对自己的技术进行沉淀,加以复盘,查缺补漏。
小伙伴们在批阅的过程中,如果觉得文章不错,欢迎点赞、收藏、关注哦。三连即是对作者我写作道路上最好的鼓励与支持!
正文
HashMap(Java 8+)
-
数据结构:内部是
Node<K,V>[] table(数组) + 每个槽位链表,链表长度超过TREEIFY_THRESHOLD(默认 8)会 treeify 成TreeNode(红黑树)。 -
索引计算:用
hash = key == null ? 0 : spread(key.hashCode()),index = (table.length - 1) & hash(所以 table 长度为 2 的幂可使位运算映射均匀)。- hash spread 通常为
h ^ (h >>> 16),把高位扰动到低位,减少低位碰撞。
- hash spread 通常为
-
扩容策略:初始容量
capacity(默认 16),负载因子loadFactor(默认 0.75)。扩容时 newCapacity = oldCapacity << 1(翻倍);threshold = capacity * loadFactor。 -
复杂度:期望情况下
put/get为 O(1),最坏情况 O(n)(链表转成树后最坏 O(log n))。 -
常见误用:
- 使用非常小的初始容量或错误负载因子导致频繁 resize(开销大)。
- 用不可变性差的
hashCode()导致大量碰撞。
ConcurrentHashMap(Java 8 实现要点)
-
设计要点:
- Java 8 从分段(Segment)转向基于 CAS + synchronized 的 bin-level 并发控制(更细粒度)。
table仍是Node<K,V>[]。在插入时使用 CAS 初始化槽位;写入大多数时候使用synchronized在某个 bin 上(或对 treeBin)保护;读操作使用 volatile / CAS /无锁读取,通常不加锁。- resize 使用
ForwardingNode标记并由多个线程协作推进(transfer)。
-
并发语义:
- 不支持对整个 map 的原子复合操作(除非使用
compute/computeIfAbsent等原子方法)。 computeIfAbsent的 mappingFunction 在并发情况下可能被多次计算(取决于并发冲突),需保证幂等或轻量。
- 不支持对整个 map 的原子复合操作(除非使用
-
优势/适用场景:高并发读多写少场景;比
Collections.synchronizedMap更高吞吐。
LongAdder / LongAccumulator(高并发计数)
- 原理:用“分片计数”(cells 数组 + base)来减少 CAS 竞争,线程先尝试更新 base,失败则退到 cells 的某个槽(依线程哈希)再 CAS 更新;最终读取时把各个 cell 值与 base 合并。
- 适用:高并发下的计数器(例如 QPS、命中计数等),比
AtomicLong性能好得多,但读取时稍贵(合并多个分片)。 - 注意:不适合需要严格精确即时一致性的场景(合并时是一致性的近似快照)。
ArrayList / ArrayDeque / ArrayBlocking
-
ArrayList
- 底层
Object[] elementData,动态扩容策略newCapacity = old + (old >> 1)(即 1.5 倍)。 - 随机访问 O(1),在尾部添加 amortized O(1),中间插入/删除 O(n)(需要移动数组)。
- 初始为空数组优化(
DEFAULTCAPACITY_EMPTY_ELEMENTDATA)。
- 底层
-
ArrayDeque
- 用循环数组实现双端队列(head/tail 索引),容量为 2 的幂,操作不使用边界检查(性能高)。
- 不允许
null元素;非常适合作为栈/队列的高性能替代(比 LinkedList 更快)。
-
何时用链表? LinkedList 除非需要频繁在中间节点插入并已持有节点引用,否则通常不推荐(内存和缓存友好性差)。
内存布局与缓存友好优化(高级)
-
对象头 + 引用开销:Java 对象有对象头(Mark Word + Klass pointer)和字段,引用(4/8 字节,取决于 compressed OOPs)导致“指针跳转”缓存不友好。
-
优化建议:
- 使用
Arrayof primitives(int[], long[])而不是List<Integer>` 等会避免装箱。 - 使用
long[]、byte[]或ByteBuffer做结构化存储以减少对象数量。 - 对内存布局敏感时,将常用字段放在对象前面(热字段靠近),减少填充与对齐浪费。
- 必要时考虑 off-heap(DirectByteBuffer、Unsafe / VarHandle 或第三方 off-heap 库)以减少 GC 压力,但引入复杂性。
- 使用 primitive-specialized collections(fastutil、Eclipse Collections、Trove)避免频繁装箱——能显著降低内存与 CPU。
- 使用
并发集合的扩展策略(设计思想)
-
减少争用:
- 分段或分片(sharding):把 key-space 划分成多个子-map(
ConcurrentHashMap已内建),或手工用ConcurrentHashMap数组来进一步分片。 - 读优先无锁:读操作尽可能无锁(volatile、CAS),写操作在小范围同步。
- 批量/异步处理:把写入或日志上报异步化,减少临界区时长。
- 分段或分片(sharding):把 key-space 划分成多个子-map(
-
幂等与重试:并发下数据操作需考虑幂等与重复执行(例如
computeIfAbsentmapping 需可安全重复执行)。 -
可伸缩 eviction:缓存驱逐最好不要把整个结构上大锁(单点瓶颈),用近似算法或后台异步清理来保证吞吐。
实战练习:实现高性能缓存(ConcurrentHashMap + 近似 LRU 驱逐)
下面给出一个简单、并发友好的近似 LRU 缓存实现(适合高并发读取/写入、对精确 LRU 要求不是极端严格的场景)。要点:使用 ConcurrentHashMap 存储条目,使用 ConcurrentLinkedDeque 跟踪访问顺序(近似 LRU),并用 AtomicInteger 跟踪大小;当超过最大容量时异步或同步驱逐最老键。实现简单、性能好但允许访问队列出现重复条目(会在后续轮次清理),这是常见的折衷。
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicInteger;
/**
* ApproximateConcurrentLRUCache:近似 LRU,适用于高并发场景(非严格 LRU)。
* 优点:读取/写入路径无全局锁;驱逐为近似,代价低。
* 缺点:访问队列允许重复 key(需要定期清理),不是严格 LRU。
*/
public class ApproximateConcurrentLRUCache<K, V> {
private final ConcurrentHashMap<K, V> map;
private final ConcurrentLinkedDeque<K> accessDeque;
private final int maxSize;
private final AtomicInteger size;
private final ScheduledExecutorService cleaner; // 可选后台清理
public ApproximateConcurrentLRUCache(int maxSize) {
this.maxSize = maxSize;
this.map = new ConcurrentHashMap<>(Math.min(16, maxSize));
this.accessDeque = new ConcurrentLinkedDeque<>();
this.size = new AtomicInteger(0);
this.cleaner = Executors.newSingleThreadScheduledExecutor(r -> {
Thread t = new Thread(r, "cache-cleaner");
t.setDaemon(true);
return t;
});
// 定期清理重复的陈旧 key(避免队列无限膨胀)
cleaner.scheduleAtFixedRate(this::trimToSize, 1, 1, TimeUnit.SECONDS);
}
public V get(K key) {
V val = map.get(key);
if (val != null) {
accessDeque.addLast(key); // 记录一次访问(允许重复)
}
return val;
}
public void put(K key, V value) {
V prev = map.put(key, value);
if (prev == null) {
int cur = size.incrementAndGet();
accessDeque.addLast(key);
if (cur > maxSize) {
// 触发清理(可以选择异步)
trimToSize();
}
} else {
// 覆盖旧值:也把 key 推到访问队列末尾
accessDeque.addLast(key);
}
}
private void trimToSize() {
while (size.get() > maxSize) {
K oldest = accessDeque.pollFirst();
if (oldest == null) break;
V removed = map.remove(oldest);
if (removed != null) {
size.decrementAndGet();
}
// 若 removed == null,说明队列中是重复 key(已被其他线程更新/移除),继续循环
}
}
public V remove(K key) {
V removed = map.remove(key);
if (removed != null) size.decrementAndGet();
return removed;
}
public void shutdown() {
cleaner.shutdownNow();
}
// 额外方法:size(), containsKey(), clear() 等根据需要添加
}
说明 & 优化点
ConcurrentLinkedDeque的addLast/pollFirst是无锁并发操作;但队列允许重复 key,因此我们在驱逐时检查map.remove(oldest)是否真正移除条目。- 这是常见的“近似 LRU”实现(低开销)。若需要严格 LRU(精确且线程安全),推荐使用
LinkedHashMap+ 单锁(在低并发下可行)或直接使用优秀现成库:Caffeine(目前生产级高性能缓存库)——它提供多种驱逐策略、异步加载、统计、权重等。 - 驱逐策略也可用 TTL(定时过期)或基于权重的策略代替纯 LRU。
常见陷阱与性能评测建议
- 误用 synchronized/map 的全表锁:
Collections.synchronizedMap(new HashMap())在高并发写场景下是瓶颈(单点锁)。用ConcurrentHashMap或分片替代。 - 装箱/拆箱导致的性能问题:避免
List<Integer>、Map<Long, X>在热点场景频繁装箱,改用 primitive-specialized collections 或 long/Int arrays。 - 错误的初始容量/负载因子设置:如果已知大致元素数量,提前
new HashMap(expectedSize)可以避免扩容开销(或ConcurrentHashMap指定并发级别/容量)。 - 过多的 GC pressure:大量短寿命对象(例如临时 wrapper)会触发频繁 GC。在高性能场景优先考虑对象复用、池化(小心内存泄漏)或 off-heap。
- 没有基准测试就调优:使用 JMH 做微基准,且要在接近真实负载的环境下做对比(不同 GC、不同堆大小与 JIT 优化可能产生巨大差别)。
延伸阅读(源码 & 工具推荐)
- 阅读 OpenJDK 中
HashMap,ConcurrentHashMap,ArrayList,ArrayDeque的源代码(带注释)能极大提升理解。 - JMH(Java Microbenchmark Harness)用于微基准测试。
- fastutil / Eclipse Collections / Trove:primitive-specialized 集合库。
- Caffeine:高性能缓存实现(推荐在生产中使用,胜过自造轮子)。
… …
文末
好啦,以上就是我这期的全部内容,如果有任何疑问,欢迎下方留言哦,咱们下期见。
… …
学习不分先后,知识不分多少;事无巨细,当以虚心求教;三人行,必有我师焉!!!
wished for you successed !!!
⭐️若喜欢我,就请关注我叭。
⭐️若对您有用,就请点赞叭。
⭐️若有疑问,就请评论留言告诉我叭。
版权声明:本文由作者原创,转载请注明出处,谢谢支持!
- 点赞
- 收藏
- 关注作者
评论(0)