文章目录
- Pre
- 1. 引言
- 2. String 的 substring 方法内存泄漏案例
- 2.1 JDK6 的实现与内存泄漏
- 2.2 JDK7+ 的修复
- 2.3 优化启示
- 3. 集合扩容的隐形成本
- 3.1 StringBuilder 扩容机制
- 3.2 ArrayList 扩容机制
- 3.3 HashMap 扩容机制
- 3.4 优化建议
- 4. 结构维度:优化大对象的粒度与存储方式
- 4.1 合理划分业务数据粒度
- 4.2 使用 Bitmap 压缩布尔值
- 4.2.1 Java 本地 `BitSet`
- 4.2.2 Redis Bitmap
- 4.3 布隆过滤器(Bloom Filter)原理
- 5. 时间维度:冷热分离
- 5.1 冷热分离常见方案
- 5.1.1 数据双写(同步双写)
- 5.1.2 写入 MQ 分发(异步双写)
- 5.1.3 Binlog 同步(CDC)
- 5.2 三种方案的对比与选型
- 6. 思维发散:结果缓存池与其他复用技术
- 6.1 数据库索引(B+ 树)
- 6.2 序列化协议:JSON vs Protobuf
- 6.2.1 性能比较
- 7. 小结
Pre
性能优化 - 理论篇:常见指标及切入点
性能优化 - 理论篇:性能优化的七类技术手段
性能优化 - 理论篇:CPU、内存、I/O诊断手段
性能优化 - 工具篇:常用的性能测试工具
性能优化 - 工具篇:基准测试 JMH
性能优化 - 案例篇:缓冲区
性能优化 - 案例篇:缓存
性能优化 - 案例篇:数据一致性
性能优化 - 案例篇:池化对象_Commons Pool 2.0通用对象池框架
- 引言:说明“大对象”对性能的影响与优化动机;
- String substring 漏洞案例:回顾 JDK6 与 JDK7+ 的差异及注意事项;
- 集合扩容开销:展示常见集合(StringBuilder、ArrayList、HashMap)的扩容机制及潜在性能损耗;
- 对大对象的结构化优化:
4.1 合理设计数据粒度——以用户信息为例,用 Hash 取代 JSON;
4.2 Bitmap 压缩布尔数据——介绍 BitSet 与 Redis Bitmap 用法;
4.3 布隆过滤器原理:减少内存占用、避免缓存穿透; - 对大对象的时间维度优化:冷热分离三种常见方案(双写、MQ 同步、Binlog 同步);
- 结果缓存池思维发散:索引(B+ 树)、序列化(JSON vs Protobuf)示例;
- 小结:回顾上述优化策略与适用场景;
1. 引言
“大对象”是一个泛化概念,可以指:
- 堆内大对象(例如,几 MB 的 String、长列表、复杂的 JSON 对象);
- 网络传输大对象(如多媒体文件、长 JSON 串);
- 数据库中的大数据行(包含大量列或大文本字段)。
之所以要对大对象进行优化,主要基于三点考量:
- 占用资源多:GC 需要更多时间来标记和整理大对象,若堆中存在大量短期大对象,可能导致频繁的 Full GC;
- 传输成本高:大对象跨网络传输时,占用带宽、增加 I/O 等待,延迟显著;
- 解析耗时:对大对象进行序列化、反序列化或部分解析时,需要更多 CPU 周期。
因此,在保证功能正确性的前提下,让“大对象变小”或“避免不必要的大对象操作”就成为了提高性能的重要手段。
2. String 的 substring 方法内存泄漏案例
Java 中 String
是不可变对象,当我们需要截取子串时,最常用的方法就是 substring(int beginIndex, int endIndex)
。
2.1 JDK6 的实现与内存泄漏
在 JDK6 时代,String
底层维护了一个 char[] value
数组,substring()
并不会复制需要的字符,而是通过:
public String substring(int begin, int end) {// 直接引用原来的字符数组,并保存 offset 和 countreturn new String(value, begin, end - begin);
}
这样会导致:
- 原始
String
占有较大char[]
,其引用一直存在于子串中; - 即使只保留子串,整个大数组也无法被 GC 回收,造成“内存泄漏”。
举例:
String content = dao.getLargeArticle(id); // content 长达数 MB
String summary = content.substring(0, 100);
articles.put(id, summary); // 仅保留 summary,但 large array 仍被引用
由于 summary
的 value
成员仍指向 content.value
,JVM 无法回收那整个几 MB 的字符数组,导致堆内存持续膨胀。
2.2 JDK7+ 的修复
从 JDK7.0u6 开始,String
在截取时会复制所需字符片段:
public String substring(int beginIndex, int endIndex) {char[] newValue = Arrays.copyOfRange(value, beginIndex, endIndex);return new String(newValue);
}
- 这样
summary
内部的新char[]
只有 100 个字符大小,消除了对原大数组的引用; - 原始的
content
数组只要不被任何变量引用,便可被 GC 回收。
2.3 优化启示
- 注意 JDK 版本差异:当我们在老项目中使用
substring()
或其他大对象“分片”操作,要先确认运行时 JDK 版本; - 手动拷贝:若不确定
String
底层是否复制,或者要兼容多版本,可显式new String(content.substring(...))
强制拷贝一次; - 及时切断引用:如果只需要保留子串,建议将原始大
String
置为null
或尽快脱离作用域,帮助 GC 回收。
3. 集合扩容的隐形成本
Java 常用集合(如 StringBuilder
、ArrayList
、HashMap
)都采用“动态扩容”的策略,随着元素不断增加,底层数组会不断翻倍或按系数扩展。虽然这保证了“无上限”的灵活性,但也带来性能开销:
3.1 StringBuilder 扩容机制
源码片段(JDK8+):
void ensureCapacityInternal(int minCapacity) {// 默认容量为 16,第一次 expandCapacity 时会被替换int newCapacity = (value.length << 1) + 2;if (newCapacity - minCapacity < 0)newCapacity = minCapacity;value = Arrays.copyOf(value, newCapacity);
}
- 每次扩容时按 “当前长度 × 2 + 2” 计算新容量;
Arrays.copyOf
会分配一个新的char[]
并复制原数据,CPU 与内存带宽开销显著;
3.2 ArrayList 扩容机制
源码片段:
private void grow(int minCapacity) {int oldCapacity = elementData.length;// 扩容到 1.5 倍int newCapacity = oldCapacity + (oldCapacity >> 1);if (newCapacity - minCapacity < 0)newCapacity = minCapacity;elementData = Arrays.copyOf(elementData, newCapacity);
}
- 扩容系数固定为 1.5(或 3/2),在渐进的元素添加场景下减少扩容次数;
- 但同样会经历“新数组分配 + 原数据复制”,尤其是数据量上万时,复制开销非常明显;
3.3 HashMap 扩容机制
源码片段(简化自 JDK8):
void resize() {int oldCap = table.length;int newCap = oldCap << 1; // 总是翻倍Node<K,V>[] newTable = (Node<K,V>[]) new Node[newCap];// 重新散列:遍历所有旧桶,将节点链移到新桶for (Node<K,V> e : table)while (e != null) {Node<K,V> next = e.next;int idx = indexFor(e.hash, newCap);e.next = newTable[idx];newTable[idx] = e;e = next;}table = newTable;threshold = newCap * loadFactor;
}
- 除了新数组分配,还需要 “逐节点重新散列”,会对每个 entry 重新计算
(hash & (newCap - 1))
并链接到新桶,CPU 开销更大; - 扩容时无锁的情况下,若并发访问,可能导致竞争或元素丢失;若加锁则会阻塞其他线程。
3.4 优化建议
-
预估容量:若能提前知道大致元素数量,尽量
new ArrayList<>(expectedSize)
或new HashMap<>(expectedSize)
,避免扩容;// 例如需要存储1000个元素,loadFactor=0.75 int initialCapacity = (int) (1000 / 0.75f) + 1; List<String> list = new ArrayList<>(initialCapacity);
-
Sparse vs Dense 场景选择合适集合:若元素稀疏且索引较大,可考虑
LinkedHashMap
、SparseArray
(Android)等替代方案; -
批量操作合并:若需要一次插入大量元素,尽量
addAll(Collection)
或putAll(Map)
,避免多次触发扩容。
4. 结构维度:优化大对象的粒度与存储方式
4.1 合理划分业务数据粒度
以用户基本信息为例:
-
原始设计:
# Redis 中以 String 方式存储 JSON key: user_{userId} value: {"id":1001,"name":"Alice","age":30,"sex":"female","email":"a@x.com", ...}
- 缺点:每次只需“性别(sex)”时,都要
GET user_1001
→ 得到完整 JSON 再在应用端解析; - 更新单个字段
sex
时,也需要先拿到整串 JSON,修改后再SET user_1001
,I/O 与带宽开销高;
- 缺点:每次只需“性别(sex)”时,都要
-
优化方案:使用 Redis Hash 存储
# 主键不变,value 变为 Hash 结构 key: user_{userId} fields: { "id":1001, "name":"Alice", "age":"30", "sex":"female", "email":"a@x.com", ... }
- 读取单个字段:
HGET user_1001 sex
→ 只返回"female"
; - 更新单个字段:
HSET user_1001 sex male
→ 只修改 Hash 槽而不改动其他字段; - 节省了 JSON 序列化/反序列化和完整字符串传输的开销。
- 读取单个字段:
4.2 使用 Bitmap 压缩布尔值
假设系统频繁需要存取用户“小规模 Boolean 属性”(如性别、签到状态、活跃状态等),全量存成 String 或 Hash 都显得太浪费。可以考虑:
4.2.1 Java 本地 BitSet
// 创建可存储 10 亿位的 BitSet,占用约 10e9 / 8 ≈ 125MB
static BitSet missSet = new BitSet(1_000_000_000);
static BitSet sexSet = new BitSet(1_000_000_000);// 示例:按需加载用户性别
String getSex(int userId) {if (!missSet.get(userId)) { // 首次访问String actualSex = dao.getSex(userId); // 从数据库查询missSet.set(userId, true); // 标记已加载if ("female".equals(actualSex)) {sexSet.set(userId, true);} else {sexSet.set(userId, false);}}return sexSet.get(userId) ? "female" : "male";
}
- 优势:一个
BitSet
内部用long[]
存储,最小单位 64 位;10 亿位只需10e9/64 ≈ 1.56e7
个long
→1.56e7 × 8B ≈ 125MB
; - 缺点:全部保存在 JVM 堆中仍然占用大量内存;若机器内存不足,应考虑将这类稀疏布尔映射存到 Redis Bitmap。
4.2.2 Redis Bitmap
// 设定 Redis key: user:sex_bitmap
// 用户性别:0 表示 male,1 表示 female// 在 Redis 中标记 userId 的性别
redisTemplate.opsForValue().setBit("user:sex_bitmap", userId, trueOrFalse);// 获取 userId 性别
boolean isFemale = redisTemplate.opsForValue().getBit("user:sex_bitmap", userId);
- 存储压缩:Bitmap 在 Redis 中以紧凑二进制存储,不受稀疏索引过大影响;
- 缺点:一次只能存一个布尔数组,若还要记录“是否签到”、“是否在线”等其他布尔值,需要多个 Bitmap key;
4.3 布隆过滤器(Bloom Filter)原理
当集合中存在 N 个元素,需要判断新元素是否在集合中时,直接遍历或使用 HashSet 可能占用大量内存或遍历时间。布隆过滤器能在“可接受的误判概率”范围内大幅降低内存占用:
- 结构:使用一个长度为 M 的 Bitmap + k 个哈希函数;
- 插入:对某元素计算 k 个哈希,分别对应的 Bitmap 位设为 1;
- 查询:对某元素计算 k 个哈希,如果出现任何一位为 0,则该元素一定不在集合;如果所有位都为 1,则该元素“可能在集合”——存在“误判”概率;
- 内存效率:若想存储 1 亿个元素,控制误判率在 1%,所需内存约数百 MB ;
应用示例:缓存穿透防御
// 初始化布隆过滤器,预计插入数据量 n=1e7,误判率 p=0.01
BloomFilter<String> bloom = BloomFilter.create(Funnels.stringFunnel(Charset.defaultCharset()), 10_000_000, 0.01);// 将所有合法用户ID 放入布隆过滤器
for (String userId : userIdListFromDB) {bloom.put(userId);
}// 访问时先查询布隆
if (!bloom.mightContain(requestedUserId)) {// 直接返回 404 或提示“用户不存在”,避免落库
} else {// 再走缓存/数据库查询
}
- 优点:内存占用远低于
HashSet
、TreeSet
,仅有“一定概率误判存在、绝不会漏判不存在”; - 缺点:误判率不可避免,若误判导致“真正不存在”却被判为存在,需要后续缓存或 DB 依旧做二次校验。
5. 时间维度:冷热分离
除了从“结构纬度”(切分或压缩大对象)来优化,“时间纬度”上的冷热分离也是常见手段,将“热数据”保持在高性能存储,“冷数据”迁移到低成本存储:
5.1 冷热分离常见方案
5.1.1 数据双写(同步双写)
- 思路:在同一业务事务中同时写入“热库”(如 MySQL)和“冷库”(如 HBase、Elasticsearch);
- 优点:同步完成,两边数据可保持一致;
- 缺点:需要“分布式事务”保障原子性,技术实现代价大;遗留系统代码分散、业务逻辑复杂时,很难统一改造。
5.1.2 写入 MQ 分发(异步双写)
- 思路:业务写操作 → 发消息到 MQ(如 Kafka、RocketMQ) → 多个消费者订阅消息,分别写入热库与冷库;
- 优点:业务与双写解耦,消费者各自独立,可并行/批量处理;
- 缺点:引入 MQ 增加系统复杂度;消息顺序、消息丢失、重复消费需额外处理;
5.1.3 Binlog 同步(CDC)
- 思路:利用 MySQL 的 Binlog,借助 Canal、Maxwell 等组件实时解析 Binlog → 将写操作同步到冷库或搜索引擎;
- 优点:无需在业务代码层插入双写逻辑,靠数据库自身日志机制;延迟通常在毫秒级别;
- 缺点:只能对关系型数据库适用;要处理 Binlog 顺序与数据一致性;冷库写入可能落后于热库。
5.2 三种方案的对比与选型
特性 | 同步双写 | 写入 MQ 分发 | Binlog 同步 (CDC) |
---|---|---|---|
实现难度 | 高,需要分布式事务 | 中,需要 MQ 组件与消息消费逻辑 | 中,需要部署 Canal 等组件 |
数据一致性保证 | 最强(事务级别原子) | 较强,但需处理消息顺序与重试 | 较强,但需保证 Binlog 顺序 |
编码侵入性 | 大,需改造所有写逻辑 | 小,写操作统一发 MQ,读写分离 | 最小,几乎无需改写业务代码 |
延迟 | 几毫秒到几十毫秒 | 几十毫秒到几百毫秒 | 几十毫秒到几百毫秒 |
组件依赖 | 依赖分布式事务管理 | 依赖 MQ(Kafka/RocketMQ 等) | 依赖 Canal/Maxwell 等工具 |
-
推荐场景
- 小规模新项目:若对一致性要求极高,可考虑同步双写;
- 成熟项目迁移:若无法改造业务代码,偏好无侵入式方案,可优先采用 Binlog 同步;
- 复杂业务多订阅:若冷库的使用场景很多(搜索、统计、离线计算),可使用 MQ 分发 → 多消费者并行处理。
6. 思维发散:结果缓存池与其他复用技术
除了上述结构与时间维度的“变小”策略,还有诸多“优化思想”,可将大对象或复杂操作“预先计算/预先组织”,减少运行时开销。
6.1 数据库索引(B+ 树)
- 场景:表中亿级别数据时,若无索引,每次
WHERE
条件查询都需全表扫描; - 优化:创建 B+ 树索引,将热点列或联合列组织成树状结构,对磁盘页预读更友好;查询时只需 O(logN) 查找到对应行所在页,再读取该页;
- 效果:大幅减少磁盘 I/O,真实查询只需读取少量页。
6.2 序列化协议:JSON vs Protobuf
-
JSON/XML:可读性好,但体积与解析开销大。
-
Protobuf(Protocol Buffers):
-
二进制格式,轻量紧凑:序列化后的字节数只有 JSON 的约 1/10;
-
解析速度快:相比 Jackson/Gson 解析 JSON,Protobuf 解析更高效;
-
使用示例:
message User {int32 id = 1;string name = 2;int32 age = 3;string email = 4; }
-
Java 端使用自动生成的
UserProto.User.parseFrom(byte[])
快速解析;
-
6.2.1 性能比较
- 体积:若同样的数据,Protobuf 序列化后大小为 JSON 的 1/10~1/20;
- 反序列化速度:Protobuf 可以提升 5~100 倍的解码性能;
- 应用场景:RPC 通信、海量日志传输、缓存存储(如 Redis 存二进制)。
7. 小结
从“大对象优化”角度,系统梳理了多种有效策略:
-
String substring 内存泄漏案例
- JDK6
substring()
会引用原始大char[]
,导致堆内存泄漏; - JDK7+ 已修复为复制子串,避免大数组被长期引用;
- JDK6
-
集合扩容的隐形成本
StringBuilder
、ArrayList
、HashMap
扩容都会触发“新数组分配 + 数据复制 + (若为 HashMap)重新散列”;- 提前预估容量、使用带容量构造参数或批量插入可显著减少扩容次数,提升性能;
-
结构维度优化
- 合理粒度:以用户信息为例,用 Redis Hash 代替 JSON 字符串,针对性地查询/更新单字段;
- Bitmap 压缩:将大规模布尔标志(如性别、签到状态、活跃标识)转换为紧凑的 Bitmap,显著节省内存;
- 布隆过滤器:在判断元素是否存在场景下,用少量内存做好“判断一定不存在” → 减少缓存/数据库访问;
-
时间维度优化(冷热分离)
- 同步双写(分布式事务):最严谨但改造成本高;
- MQ 异步分发:解耦写逻辑,适合多订阅、批量写入场景;
- Binlog 同步(CDC):低侵入、可实时同步事务日志到冷库;
-
结果缓存池思维
- 缓存与池化的共通本质:“预先做高成本操作并保留结果,下次复用”;
- 进一步延伸至数据库索引(B+ 树)、Protobuf 序列化等优化,大幅减少运行时 I/O 与解析开销。
通过以上多维度优化手段,我们能够:
- 减少大对象在堆内的驻留,降低 GC 压力;
- 避免不必要的网络/磁盘传输,节省带宽与 I/O 等待;
- 聚焦操作于必要子集,降低 CPU 解析成本;
- 做到“热数据”快速命中,“冷数据”按需访问,兼顾性能与成本。