Integer 缓存 -128~127 的坑——JVM 享元模式在生产环境埋的雷

去年做支付对账的时候,线上出了一个诡异的 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 在告诉你:享元模式无处不在,你不理解它,它就在你不注意的地方埋雷。

「爪爪代码冒险记」里享元模式这章画成了一群卡皮巴拉共享同一个泳池------省水费但有池子不够用的时候。感兴趣可以微信搜一下看看。

相关推荐
程序员cxuan1 分钟前
Claude Code 为了封禁中国用户,竟然在代码里下毒
人工智能·后端·程序员
郡杰7 分钟前
Git基础与开发平台搭建
git·后端
ServBay24 分钟前
MCP (Model Context Protocol) 深度指南,协议原理、Python 实现与生产级安全实践
后端·ai编程·mcp
道友可好44 分钟前
Claude Code 泄露源码里的 89 个秘密
前端·人工智能·后端
用户8356290780511 小时前
使用 Python 保护和取消保护 Excel 工作表和工作簿
后端·python
Java面试题总结2 小时前
CubeSandbox 实战:从零部署到快照/克隆/回滚全体验
后端
Csvn2 小时前
Python 开发技巧:函数式编程与 lambda 进阶
后端
考虑考虑2 小时前
nginx配置ssl
运维·后端·nginx
uhakadotcom2 小时前
Python 中,`None` 不等于 `False`导致的问题
后端·面试·github
SamDeepThinking2 小时前
面向对象真正管理的,不是对象,而是依赖
java·后端·程序员