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 在告诉你:享元模式无处不在,你不理解它,它就在你不注意的地方埋雷。

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

相关推荐
tonydf1 小时前
DotNet项目接入Copilot SDK简单案例
后端·.net·github copilot
何以解忧,唯有..1 小时前
Go 语言运算符详解:从基础到实战
开发语言·后端·golang
XovH1 小时前
MySQL 系列:第2篇 库和表,一切的容器
后端
笨鸟飞不快1 小时前
当定时任务涨到 180+,我们为什么从 Elastic Job 迁到了 XXL-JOB
后端
Kir1to1 小时前
分布式锁基础与三种实现方式对比
后端
MariaH1 小时前
Web服务器开发
后端
程序边界1 小时前
凌晨三点批量掉授权,我花了四小时才搞明白LAC心跳链路是怎么算的
后端
叫我:松哥1 小时前
基于Flask的在线考试刷题系统设计与实现,集智能练习、过程追踪、深度分析与个性化引导
数据库·人工智能·后端·python·flask·boostrap
AI人工智能_电脑小能手1 小时前
【大白话说Java面试题 第106题】【并发篇】第6题:synchronized 锁的锁对象可以是什么?
java·后端·面试