Skip to content
Go back

享元模式——IntegerCache与String常量池的共享之道

享元模式:IntegerCache 与 String 常量池的共享之道

一句话结论(30s)

享元模式的本质是「不变的共享、变的外传」——通过共享大量相似对象减少内存占用,因为内部状态(可共享)保存在享元对象中、外部状态(随环境变化)由调用方传入。权衡:分离状态会增加复杂度、缓存越多内存占用越大,所以只在对象大量且创建成本高时才值得用。

核心原理(2min)

主流程:工厂创建对象前先查缓存 → 命中返回共享对象 → 未命中才新建并缓存。关键机制在 JDK 的 IntegerCacheInteger 静态内部类在类加载时预创建 -128~127 共 256 个对象存入数组,Integer.valueOf(i) 在范围内直接返回缓存对象——所以自动装箱的 Integer a=100; a==b 为 true,而 200 走 new Integer(200) 为 false,这正是阿里规范要求包装类比较必须用 equals 的原因。String 常量池同理,字面量与 intern() 返回池中共享引用,底层由 JVM 的 StringTable 维护。源码落地:Integer i = 100String s = "hello" 背后都是享元模式在节省内存。

底层深入(5-10min)

一次”诡异”的 Integer 比较

Integer a = 100;
Integer b = 100;
System.out.println(a == b);   // true  ← ??

Integer c = 200;
Integer d = 200;
System.out.println(c == d);   // false ← ??!

同样是 Integer,100 用 == 比较是 true,200 却是 false。这不是 bug,这是享元模式(Flyweight Pattern)在 JDK 源码中最精妙的应用——IntegerCache

享元模式的核心思想

享元模式的本质是:当系统中需要大量相似对象时,通过共享来减少内存占用。 它把对象的状态分为两类:

工厂在创建对象时,先查缓存——如果有可共享的对象就直接返回,没有才创建新的。

思考穿插:享元到底能省多少内存? 假设一个系统里有 100 万个 Integer(100)——不共享的话是 100 万个对象,每个 Integer 对象头部 + 值字段约 16 字节,就是约 16MB;共享后 -128~127 范围内的 100 只需要一个缓存对象,100 万次引用全指向同一个,内存从 16MB 降到几乎可以忽略的一个对象 + 100 万个引用指针(引用本身也要占位,但比对象小得多)。省内存的前提是「大量对象的内容重复且不可变」——如果每个对象值都不同,缓存命中率为零,享元反而白搭一层查缓存的复杂度。

IntegerCache:享元模式在 JDK 中的标准实现

源码解析

Integer.valueOf(int i) 是享元工厂的入口,JDK 源码(Integer.java)逐字如下:

@IntrinsicCandidate
public static Integer valueOf(int i) {
    if (i >= IntegerCache.low && i <= IntegerCache.high)
        return IntegerCache.cache[i + (-IntegerCache.low)];
    return new Integer(i);
}

判断 i 是否落在缓存区间 [low, high],命中就直接返回数组下标 i + 128 处的共享对象,未命中才 new Integer(i)。这一条 if 就是”先查缓存、命中复用”的享元工厂逻辑。

而那个缓存池,正是 Integer 内部真正的 IntegerCache 静态内部类,源码逐字如下:

@AOTSafeClassInitializer
private static final class IntegerCache {
    static final int low = -128;
    @Stable static int high;

    @Stable static Integer[] cache;
    static Integer[] archivedCache;

    static {
        runtimeSetup();
    }

    @AOTRuntimeSetup
    private static void runtimeSetup() {
        // high value may be configured by property
        int h = 127;
        String integerCacheHighPropValue =
            VM.getSavedProperty("java.lang.Integer.IntegerCache.high");
        if (integerCacheHighPropValue != null) {
            try {
                h = Math.max(parseInt(integerCacheHighPropValue), 127);
                // Maximum array size is Integer.MAX_VALUE
                h = Math.min(h, Integer.MAX_VALUE - (-low) -1);
            } catch( NumberFormatException nfe) {
                // If the property cannot be parsed into an int, ignore it.
            }
        }
        high = h;

        Integer[] precomputed;
        if (cache != null) {
            // IntegerCache has been AOT-initialized.
            precomputed = cache;
        } else {
            // Legacy CDS archive support (to be deprecated):
            // Load IntegerCache.archivedCache from archive, if possible
            CDS.initializeFromArchive(IntegerCache.class);
            precomputed = archivedCache;
        }

        cache = loadOrInitializeCache(precomputed);
        archivedCache = cache; // Legacy CDS archive support (to be deprecated)
        // range [-128, 127] must be interned (JLS7 5.1.7)
        assert IntegerCache.high >= 127;
    }

    private static Integer[] loadOrInitializeCache(Integer[] precomputed) {
        int size = (high - low) + 1;

        // Use the precomputed cache if it exists and is large enough
        if (precomputed != null && size <= precomputed.length) {
            return precomputed;
        }

        Integer[] c = newCacheArray(size);
        int j = low;
        // If we loading a precomputed cache (from AOT cache or CDS archive),
        // we must use all instances from it.
        // Otherwise, the Integers from the AOT cache (or CDS archive) will not
        // have the same object identity as items in IntegerCache.cache[].
        int precomputedSize = (precomputed == null) ? 0 : precomputed.length;
        for (int i = 0; i < precomputedSize; i++) {
            c[i] = precomputed[i];
            assert j == precomputed[i];
            j++;
        }
        // Fill the rest of the cache.
        for (int i = precomputedSize; i < size; i++) {
            c[i] = new Integer(j++);
        }
        return c;
    }

    private static Integer[] newCacheArray(int size) {
        // ValueClass.newReferenceArray requires a value class component.
        if (PreviewFeatures.isEnabled()) {
            return (Integer[]) ValueClass.newReferenceArray(Integer.class, size);
        }
        return new Integer[size];
    }

    private IntegerCache() {}
}

享元池的本质在这里看得一清二楚:cache 是一个 Integer[] 数组,类加载时一次性 new Integer(j++) 把 -128~127 共 256 个对象预建好并固定住(@Stable 修饰,初始化后内容不再改变)。此后 valueOf 只是在 cache[i + 128] 上做一次下标寻址,返回的是同一个不可变对象——共享不可变对象、省下重复创建的堆内存,正是享元模式的根基。其余 AOT/CDS 归档逻辑只是把这些享元对象提前存档、加速 JVM 启动,属于优化外壳,不影响享元的核心语义。

Integer a = Integer.valueOf(100);  // 命中缓存,返回 cache[100 + 128] = cache[228]
Integer b = Integer.valueOf(100);  // 再次命中同一个 cache[228] → 同一对象
a == b  // true

为什么是 -128 到 127?

这是”用一个字节表示的有符号整数范围”——符合大多数业务场景中小整数的分布(循环计数、索引、状态码大多在这个范围)。高频使用的值被复用,低频的大整数还是正常 new。

你可以通过 JVM 参数 -XX:AutoBoxCacheMax=1000 调高上限,但要权衡——缓存越多对象,内存占用越大,而命中率增长有限。

思考穿插:为什么偏偏是 -128 到 127? 因为这是一个字节的有符号整数范围,而程序里绝大多数小整数(循环计数、数组索引、状态码)都落在这个区间,命中率最高。范围不是拍脑袋定的,是「用最小的缓存成本覆盖最高频的取值」——调大上限能提升命中率,但边际收益递减,缓存对象本身也占内存,所以默认停在 256 个是工程上的平衡。

自动装箱的陷阱

Integer a = 100;     // 编译后:Integer.valueOf(100) → 走缓存
Integer b = 100;
a == b;              // true

Integer c = 200;     // 编译后:Integer.valueOf(200) → new Integer(200)
Integer d = 200;
c == d;              // false!两个不同的对象

这就是阿里规范要求”所有包装类对象之间值的比较,全部使用 equals 方法”的原因。 == 在 -128~127 范围内碰巧走缓存返回 true,超出范围就 false——行为不一致,极易埋坑。

String 常量池:享元模式的极致应用

String s1 = "hello";       // 字面量 → 放入常量池
String s2 = "hello";       // 发现常量池已有 → 直接返回引用
System.out.println(s1 == s2);  // true!同一个对象

String s3 = new String("hello"); // 强制在堆上新建 → 不走常量池
System.out.println(s1 == s3);    // false

JVM 维护一个 String 常量池(在堆中,JDK 7+),所有字符串字面量和 intern() 返回的字符串都存放在其中。编译期就能确定的字符串常量直接共享。

intern():手动入池

String 常量池的享元入口是 intern(),JDK 源码(String.java)逐字如下:

/**
 * Returns a canonical representation for the string object.
 * <p>
 * A pool of strings, initially empty, is maintained privately by the
 * class {@code String}.
 * <p>
 * When the intern method is invoked, if the pool already contains a
 * string equal to this {@code String} object as determined by
 * the {@link #equals(Object)} method, then the string from the pool is
 * returned. Otherwise, this {@code String} object is added to the
 * pool and a reference to this {@code String} object is returned.
 * <p>
 * It follows that for any two strings {@code s} and {@code t},
 * {@code s.intern() == t.intern()} is {@code true}
 * if and only if {@code s.equals(t)} is {@code true}.
 * <p>
 * All literal strings and string-valued constant expressions are
 * interned. String literals are defined in section {@jls 3.10.5} of the
 * <cite>The Java Language Specification</cite>.
 *
 * @return  a string that has the same contents as this string, but is
 *          guaranteed to be from a pool of unique strings.
 */
public native String intern();

Javadoc 把享元语义写成了契约:常量池由 String 类私有维护,intern() 先按 equals 判断池里是否已有相等字符串,有则返回池中引用,没有才入池再返回。s.intern() == t.intern() 当且仅当 s.equals(t)——这条恒等式说明池内每个内容只保留一个共享对象,正是”共享不可变字符串、减少内存”的极致体现。方法本身是 native,底层由 JVM 的 StringTable(类 HashMap 结构)维护。

String s4 = new String("hello").intern();  // intern() 返回常量池中的引用
System.out.println(s1 == s4);              // true

内部状态 vs 外部状态:享元模式的边界

以文本编辑器中”显示字符”为例:

// 内部状态(可共享):字符的 Unicode 码点
// 外部状态(不可共享):位置、颜色、大小

class CharacterFlyweight {
    private final char unicode;  // 内部状态 → 不可变,可共享

    public void display(int x, int y, Color color) {
        // x, y, color 是外部状态 → 调用时传入,不保存在享元中
    }
}

享元对象本身是不可变的(内部状态不可修改),所有可变信息都由调用方作为参数传入。这样同一个 'A' 享元对象可以被 1000 个位置同时引用,内存只占一个对象的大小。

思考穿插:内部状态和外部状态的边界怎么划? 一句话——「会不会随场景变化」:不会变的(字符码点、数值本身)放内部、可共享;会变的(位置、颜色、大小)放外部、由调用方传入。如果某个状态既是共享的一部分、又会被改,那它就不能进享元,否则一个改了所有引用全被污染。所以享元对象必须不可变,这是「共享」安全的根基。

适用场景判断

用享元模式前先问三个问题:

  1. 系统中是否有大量相似对象? 没有的话徒增复杂度。
  2. 对象创建成本是否很高? 不高的话直接 new 更简单。
  3. 是否可以分离内部/外部状态? 不能分离就不要强行用。

典型适用场景:连接池(数据库连接、线程)、字符渲染(游戏中大量重复字符)、缓存常量(枚举值、字典数据)。

总结

享元模式是 JDK 源码中最”润物细无声”的模式——你每天在写 Integer i = 100String s = "hello",背后都是享元模式在节省内存。核心心法只有一句:不变的共享,变的外传。 用空间换时间不难,用共享换空间才是高手。

章末提问

Q1:为什么 Integer a=100; Integer b=100; a==b 为 true,而 200 为 false? 结论先行:因为 100 落在 IntegerCache 的 -128~127 缓存区间,自动装箱走 valueOf 命中同一个缓存对象;200 超出区间,valueOfnew Integer(200) 各建一个新对象。所以 == 比较的是引用,一个共享一个新建,结果自然相反——这也是阿里规范要求包装类比较必须用 equals 的根因。

Q2:享元模式能省多少内存?为什么能省? 结论先行:省的量级取决于「重复对象数量 × 单对象大小」,重复越多省得越多。因为享元让大量内容相同、不可变的对象共享同一份实例,内存占用从「对象数 × 对象大小」降为「享元池 + 引用指针」;但前提是对象内容高度重复,否则命中率为零、徒增查缓存复杂度。

Q3:内部状态和外部状态怎么区分? 结论先行:看「会不会随使用场景变化」——不变的进内部、可共享;会变的放外部、由调用方传入。因为内部状态一旦被共享,改了就会污染所有引用,所以享元对象必须不可变;可变信息只能作为方法参数(x、y、color)每次传入。

Q4:享元模式和单例模式的区别? 结论先行:享元是「共享一类对象里的多个实例」,单例是「全局只有一个实例」。因为享元池里可以有多个不同的享元对象(IntegerCache 里 256 个),按内容复用;单例则强调整个 JVM 唯一。一个是「按需复用相似对象」,一个是「严格唯一」。

Q5:享元模式有什么代价 / 什么时候不该用? 结论先行:代价是分离内外状态增加复杂度、缓存本身也占内存,所以「对象不大量、创建不昂贵、状态不可分」时别硬上。因为享元的价值建立在「大量相似对象 + 高创建成本 + 状态可分离」三者同时成立的基础上,缺一个都会变成负优化。


Share this post on:

Previous Post
Java反射——Inflation机制让反射接近直接调用
Next Post
hashCode与equals的契约——为什么重写equals必须重写hashCode?