去年做支付对账的时候,线上出了一个诡异的 Bug:两个金额字段明明都是 128 元,比较结果却是 false。查了半小时日志,发现是 Integer 的 == 比较出了问题。
java
Integer a = 127;
Integer b = 127;
System.out.println(a == b); // true
Integer c = 128;
Integer d = 128;
System.out.println(c == d); // false ------ 为什么?!
周会上我讲了这个 Bug,团队 12 个人,有 7 个不知道原因。不是他们 Java 学得差,是 JVM 把享元模式藏得太深了。
IntegerCache:JVM 里最隐蔽的享元实现
打开 Integer.java 源码,第 780 行左右:
java
private static class IntegerCache {
static final int low = -128;
static final int high;
static final Integer cache[];
static {
int h = 127;
String integerCacheHighPropValue =
sun.misc.VM.getSavedProperty("java.lang.Integer.IntegerCache.high");
if (integerCacheHighPropValue != null) {
h = Math.min(Math.max(parseInt(integerCacheHighPropValue), 127),
Integer.MAX_VALUE - (-low) - 1);
}
high = h;
cache = new Integer[(high - low) + 1];
int j = low;
for (int k = 0; k < cache.length; k++)
cache[k] = new Integer(j++);
}
}
Integer.valueOf(127) 直接从缓存数组里拿同一个对象,Integer.valueOf(128) 每次 new 一个新对象。这就是享元模式:把不可变对象的创建成本摊平,重复使用同一个实例。
== 比较的是引用地址,128 超出了缓存范围,每次装箱都是不同对象,所以 false。
不只是 Integer,JVM 里到处都是享元
Short 缓存 -128~127:
java
Short s1 = 100;
Short s2 = 100;
System.out.println(s1 == s2); // true ------ 享元起作用了
Long 缓存 -128~127:
java
Long l1 = 100L;
Long l2 = 100L;
System.out.println(l1 == l2); // true ------ 享元又起作用了
String 的 intern 池:
java
String s1 = "hello";
String s2 = "hello";
System.out.println(s1 == s2); // true ------ 字符串常量池就是享元
String s3 = new String("hello");
String s4 = new String("hello");
System.out.println(s3 == s4); // false ------ 绕过了享元池
JVM 在包装类型和字符串上大量使用享元模式,节省了几十 MB 甚至上百 MB 的堆内存。但代价是------你写 == 而不是 .equals() 的时候,它不会报错,不会警告,只会在 128 这个边界上悄悄爆炸。
这个 Bug 为什么在生产环境更难发现
测试环境的数据量小,金额、数量、状态码往往在 -128 到 127 之间,== 比较不出问题。上了生产,数据膨胀,ID 超过 127、金额超过 127 元、数量超过 127 件------突然就炸了。
我见过一个工单系统,ticket.getStatus() 返回 Integer,有人用 == 比较状态码。业务前期状态码只有 0-5,完美运行半年。后来产品加了「待审核(200)」「已归档(201)」两个状态,== 比较全挂了,几百个工单被错误归档。
更隐蔽的是 Long 的缓存:
java
// 订单 ID 超过 127 之后,这个 equals 判断就废了
if (order.getId() == cachedOrder.getId()) { // Bug!
// 走缓存
}
代码 Review 的时候,order.getId() == cachedOrder.getId() 这段看着太正常了------谁能想到 Long 的 == 在超过 127 后会失效?
数据库连接池也是享元模式
JVM 玩享元不止在包装类型上。你每天都在用的数据库连接池,本质就是享元模式:
java
// HikariCP 内部:享元池
public class ConcurrentBag<T extends IConcurrentBagEntry> {
private final CopyOnWriteArrayList<T> sharedList; // 所有连接
private final ThreadLocal<List<Object>> threadList; // 线程本地缓存
private final SynchronousQueue<T> handoffQueue; // 等待队列
public T borrow(long timeout, TimeUnit timeUnit) {
// 1. 先从 threadList 拿(本地享元缓存)
// 2. 再从 sharedList 拿(全局享元池)
// 3. 拿不到就等
}
}
连接池的核心思想:创建连接很贵,复用它。 这和享元模式「共享不可变/可复用对象」的思路一模一样。区别是连接不是不可变的------它复用完要重置状态(cleanup()),这是享元模式的工程扩展。
享元模式你写对了吗------内部状态 vs 外部状态
享元模式的教科书定义里有个关键区分:内部状态 (可共享的)和外部状态(不可共享的)。
在 IntegerCache 里,value 就是内部状态------所有 Integer.valueOf(100) 共享同一个 int 值。外部状态不存在,因为 Integer 不可变。
但你在业务里用享元的时候,很容易搞混。比如缓存参数配置:
java
// 错误:把外部状态写进了享元
public class DataSourceConfig {
private String url; // 内部状态 ✓
private String username; // 内部状态 ✓
private Connection conn; // 这是外部状态!不应该在享元里
}
正确的做法:配置对象只存不可变的参数(url、username、password),连接对象由连接池管理。这就是为什么 HikariCP 把配置和连接拆开了------配置是享元,连接是享元的产物。
我踩过的最贵的坑:String.intern() 引发的 OOM
Java 6 的时候,String.intern() 把字符串放进 PermGen(永久代),而 PermGen 默认只有 64MB。有个同事写了一个爬虫,把 URL 全部 intern 了:
java
public String processUrl(String url) {
return url.intern(); // 每个 URL 都扔进永久代
}
跑了两天线上 OOM。几百个 URL 字符串占了 60 多 MB 永久代空间。Java 7 以后 intern 字符串移到了堆里,这个 Bug 才没那么致命。
string intern 的享元是 JVM 级别的,你不能控制它的生命周期。除非你明确知道自己在复用大量重复字符串(比如字典、枚举值),否则不要手动 intern。
什么时候自己实现享元
java
public class FlyweightFactory<K, V> {
private final ConcurrentHashMap<K, V> pool = new ConcurrentHashMap<>();
public V get(K key, Supplier<V> creator) {
return pool.computeIfAbsent(key, k -> creator.get());
}
}
适用场景:
- 大量不可变小对象,重复创建成本高
- 对象的状态可以分为「频繁共享」和「少数不共享」两部分
- 你确定
ConcurrentHashMap的内存开销小于你节省的对象内存
不适用场景:
- 对象生命周期短、类型多------HashMap 本身的内存开销会吃掉收益
- 对象有可变状态------共享可变对象是并发 Bug 的温床
判断标准很简单:用 JMH 跑一下 new vs cache.get() 的吞吐量对比。低于 30% 提升就别折腾了,代码复杂度不值这个收益。
Integer 缓存这个坑,其实就是 JVM 在告诉你:享元模式无处不在,你不理解它,它就在你不注意的地方埋雷。
「爪爪代码冒险记」里享元模式这章画成了一群卡皮巴拉共享同一个泳池------省水费但有池子不够用的时候。感兴趣可以微信搜一下看看。