尧图精选

【DIY系列:Java虚拟机】第60篇:基础类型包装类与自动装箱——Integer/Long 的秘密

🕒 发布时间:2026/10/1 6:34:44 📁 来源:尧图网络
上一篇【第59篇】反射实现——Class.forName 的 Go 版下一篇【第61篇】String.intern() 与 Object 基础方法——JDK 的三大件摘要第 9 章的 9.7 节主题是自动装箱和拆箱——Java 5 引入的语法糖。这一篇有个反高潮的发现自动装箱在 JVM 层面完全不存在。Integeri100;// 装箱intji;// 拆箱编译后0: bipush 100 2: invokestatic Integer.valueOf:(I)Ljava/lang/Integer; ← 普通静态方法调用 5: astore_1 6: aload_1 7: invokevirtual Integer.intValue:()I ← 普通实例方法调用 10: istore_2全是普通的方法调用JVM 不需要任何特殊支持。所以这一篇的主要内容是为什么需要包装类基本类型不是ObjectInteger.valueOf()的缓存机制与-128~127的来历Integer类在 jvmgo 中的加载链路那个经典的128 陷阱面试題本篇不新增任何 jvmgo 代码——但会让你彻底搞懂这套机制。一、为什么需要包装类1.1 基本类型不是对象Java 有 8 种基本类型int/long/float/double/byte/short/char/boolean它们不是对象intx42;x.toString();// ❌ 编译错误int 没有方法Objectox;// Java 5 之前编译错误但 Java 的很多基础设施是为对象设计的场景为什么需要对象集合类Listint不合法必须ListInteger泛型不支持基本类型泛型T必须是引用类型反射Method.invoke()的参数/返回值是Object[]null语义数据库里的NULL无法用int表示Object方法equals()/hashCode()/toString()所以需要一组**包装类wrapper class**把基本类型包成对象基本类型包装类父类byteByteNumbershortShortNumberintIntegerNumberlongLongNumberfloatFloatNumberdoubleDoubleNumbercharCharacter—booleanBoolean—voidVoid—注意名字的两个例外int→Integer不是Intchar→Character不是Char。这是历史遗留。1.2 包装类的典型结构以Integer为例JDK 8publicfinalclassIntegerextendsNumberimplementsComparableInteger{// ① 内部值不可变privatefinalintvalue;// ② 类对象第 059 篇讲过int.class 就是访问这个字段publicstaticfinalClassIntegerTYPE(ClassInteger)Class.getPrimitiveClass(int);// ③ 构造器publicInteger(intvalue){this.valuevalue;}// ④ 装箱方法带缓存publicstaticIntegervalueOf(inti){if(iIntegerCache.lowiIntegerCache.high)returnIntegerCache.cache[i(-IntegerCache.low)];returnnewInteger(i);}// ⑤ 拆箱方法publicintintValue(){returnvalue;}// ⑥ 缓存内部类privatestaticclassIntegerCache{staticfinalintlow-128;staticfinalinthigh;staticfinalIntegercache[];static{inth127;// 可以通过 -XX:AutoBoxCacheMax 调整上界StringintegerCacheHighPropValuesun.misc.VM.getSavedProperty(java.lang.Integer.IntegerCache.high);if(integerCacheHighPropValue!null){intiparseInt(integerCacheHighPropValue);iMath.max(i,127);hMath.min(i,Integer.MAX_VALUE-(-low)-1);}highh;cachenewInteger[(high-low)1];intjlow;for(intk0;kcache.length;k)cache[k]newInteger(j);}}}六个组成部分其中 ②⑥ 是包装类特有的其余都是普通的不可变类模式。二、自动装箱纯粹的语法糖2.1 javac 做了什么Integera100;// 装箱boxingintba;// 拆箱unboxingjavap -c// Integer a 100; 0: bipush 100 2: invokestatic #2 // Method java/lang/Integer.valueOf:(I)Ljava/lang/Integer; 5: astore_1 // int b a; 6: aload_1 7: invokevirtual #3 // Method java/lang/Integer.intValue:()I 10: istore_2这就是全部——两条普通的方法调用指令没有任何新东西。关键结论自动装箱/拆箱是 javac 的语法糖JVM 完全不知道它的存在。规范里也没有box/unbox指令。JVM 看到的只是invokestatic Integer.valueOf:(I)Ljava/lang/Integer; invokevirtual Integer.intValue:()I这意味着 jvmgo 不需要为它写一行代码——只要能正常执行invokestatic/invokevirtual、能加载Integer类自动装箱就自动可用。但有个前提Integer类要能正常加载和初始化。而它的clinit会调用Class.getPrimitiveClass()第 059 篇实现的和IntegerCache的静态块。2.2 装箱的完整链路Integera100;在 jvmgo 里的执行过程① bipush 100 操作数栈: [100] ② invokestatic Integer.valueOf:(I)Ljava/lang/Integer; → 解析方法符号引用 → Integer.valueOf 是静态方法 → CheckInit: Integer 类还没初始化 → InitClass(Integer) → StartInit() → scheduleClinit: Integer.clinit() ← 有静态字段所以要执行 → initSuperClass: Number → Object → Integer.clinit 执行 ├─ ldc int Go string ├─ invokestatic Class.getPrimitiveClass ← 本地方法第059篇 │ → heap.GoString(int) → int │ → loader.LoadClass(int) ← loadPrimitiveClasses 生成的 │ → .JClass() ← 类对象 ├─ putstatic Integer.TYPE 编译期常量不是是运行时调用 └─ return → 重新执行 invokestaticRevertNextPC 机制第052篇 → InvokeMethod(Integer.valueOf) ├─ argSlotCount 1static一个 int 参数 ├─ 从栈弹出 100 填入新帧 LocalVars[0] └─ 执行 Integer.valueOf 的字节码 iload_0 invokestatic Integer.IntegerCache... 或者走缓存分支 ... areturn ③ astore_1 局部变量表 [1] Integer 对象涉及第 6~9 章的全部机制——类加载、初始化、方法调用、本地方法、参数传递。2.3 拆箱intba;aload_1 // a invokevirtual Integer.intValue:()I // 普通实例方法 istore_2intValue()的实现就一行publicintintValue(){returnvalue;}字节码0: aload_0 1: getfield #4 // Field value:I 4: ireturn用到第 6 章的getfield 第 7 章的ireturn。NPE 隐患如果a是nullint b a;会在invokevirtual处抛NullPointerException——因为intValue()是实例方法this为 null。Integeranull;intba;// NullPointerException这是自动拆箱最经典的坑。三、IntegerCache为什么是 -128~1273.1 缓存的目的Integera100;Integerb100;System.out.println(ab);// true ← 同一个对象Integerc200;Integerd200;System.out.println(cd);// false ← 不同对象这是 Java 最经典的面试题之一人称128 陷阱或Integer 缓存陷阱。原因就在Integer.valueOf()publicstaticIntegervalueOf(inti){if(iIntegerCache.lowiIntegerCache.high)// -128 ~ 127returnIntegerCache.cache[i(-IntegerCache.low)];// 返回缓存对象returnnewInteger(i);// 新建对象}在缓存范围内的装箱走缓存返回同一个对象范围外走new返回新对象。3.2 为什么下界是 -128因为它是 byte 的范围-128 ~ 127。更准确地说上界 127Java 语言规范JLS 5.1.7强制要求-128 ~ 127必须被缓存。这是规范保证的行为不是实现细节。If the valuepbeing boxed istrue,false, abyte, or acharin the range\u0000to\u007f, or anintorshortnumber between-128and127, then letr1andr2be the results of any two boxing conversions ofp. It is always the case thatr1 r2.下界 -128因为byte的范围是-128 ~ 127正好对称。为什么选这个范围因为小整数在程序里出现频率极高循环计数器 i 0; i 100; i 数组下标 错误码 状态码 月份、星期、小时如果一个循环跑 100 万次每次装箱都new Integer(i)会产生 100 万个短命对象——GC 压力巨大。缓存 256 个对象1KB 左右内存就能彻底解决。这是典型的空间换时间。3.3 上界可以调static{inth127;StringintegerCacheHighPropValuesun.misc.VM.getSavedProperty(java.lang.Integer.IntegerCache.high);if(integerCacheHighPropValue!null){intiparseInt(integerCacheHighPropValue);iMath.max(i,127);hMath.min(i,Integer.MAX_VALUE-(-low)-1);}highh;// ...}可以通过 JVM 参数调大上界java-XX:AutoBoxCacheMax1000MyApp这样-128 ~ 1000都会被缓存。但下界 -128 是写死的(static final int low -128)不能调。3.4 其他包装类的缓存包装类缓存范围说明Booleantrue/false全部只有两个值全缓存Byte全部 256 个范围就是 -128~127全缓存Short-128 ~ 127同 IntegerInteger-128 ~ 127上界可调Long-128 ~ 127同 IntegerCharacter\u0000~\u007F0~127ASCII 范围Float无缓存Double无缓存为什么 Float / Double 没有缓存因为浮点数的常用值是连续分布且无限的0.1、0.2、3.14…没有一组明显更常用的值值得缓存。整数有小整数浮点数没有。3.5 一个必须记住的准则Integera100,b100;ab// true缓存但这是碰巧Integerc200,d200;cd// false// 正确的做法a.equals(b)// true永远正确Objects.equals(a,b)// null 安全永远用equals()比较包装类永远不要用。对包装类比较的是引用结果取决于缓存范围——这是个隐藏的定时炸弹。四、Integer 类在 jvmgo 中的加载4.1 加载链路Integer类的加载会触发一连串的本地方法和类初始化LoadClass(java/lang/Integer) │ ├── 加载超类 java/lang/Number │ └── 加载超类 java/lang/Object │ └── Object.clinit: │ └── registerNatives() → emptyNativeMethod空实现 │ ├── 加载接口 java/lang/Comparable │ ├── prepare(): 分配静态变量空间 │ ├── MIN_VALUE -2147483648 ← ConstantValue编译期常量 │ ├── MAX_VALUE 2147483647 ← ConstantValue │ ├── TYPE null ← 不是编译期常量等 clinit │ └── ... digits / size 等静态数组 │ └── clinit: ├── 初始化 IntegerCache 内部类 │ └── IntegerCache.clinit: │ ├── low -128 │ ├── sun.misc.VM.getSavedProperty(...) ← 本地方法 │ │ 如果没实现 → UnsatisfiedLinkError │ ├── cache new Integer[256] │ └── for 循环cache[k] new Integer(j) │ └── invokespecial Integer.init │ └── TYPE Class.getPrimitiveClass(int) ← 本地方法第059篇注意sun.misc.VM.getSavedProperty()—— 这是个本地方法第 9 章没有实现。那Integer类怎么还能加载因为IntegerCache的静态块里只有在getSavedProperty返回非 null 时才会调整上界。而实际上如果没设-XX:AutoBoxCacheMaxsun.misc.VM类的静态初始化会失败或者返回 null。更关键的是jvmgo 第 9 章的测试程序没有用到Integer.valueOf()StringTest只用了字符串所以没触发这条路径。这是个真实的局限jvmgo 能跑的 Java 程序范围有限一旦用到没实现本地方法的 JDK 类就会UnsatisfiedLinkError。4.2 哪些包装类能用类能在 jvmgo 跑吗拦路的本地方法Boolean✅ 可能无只有两个静态常量Byte✅ 可能Class.getPrimitiveClass已实现Integer⚠️ 看情况sun.misc.VM.getSavedProperty未实现Long⚠️ 看情况同上Character✅ 可能Class.desiredAssertionStatus0已实现第 059 篇就是为了它Float✅floatToRawIntBits第 061 篇实现Double✅doubleToRawLongBits第 061 篇实现有意思的是第 059 篇实现desiredAssertionStatus0的理由就是Character 类初始化时会调它。这说明Character是能被 jvmgo 加载的——而Character也是个包装类。五、自动装箱的其他坑5.1 三元运算符的拆箱Integera100;Integerb200;Integerctrue?a:b;// 没问题booleanflagtrue;Integerdflag?a:100;// ⚠️ 这里 100 会被拆箱为什么因为三元运算符要求两个分支类型一致。a是Integer100是int编译器会把a拆箱成int让两边都是int然后结果再装箱。如果a是nullIntegeranull;Integerdflag?a:100;// NullPointerException5.2 集合的增删ListIntegerlistnewArrayList();list.add(1);// 装箱list.remove(1);// ⚠️ 调用的是 remove(int index)不是 remove(Object)list.remove(Integer.valueOf(1));// ✓ 这样才是按值删除List有两个remove重载remove(int index)和remove(Object o)。传入int会优先匹配remove(int)。5.3 混合运算Integera100;Integerb200;Integercab;// 拆箱 → int 相加 → 装箱编译后6: aload_1 7: invokevirtual Integer.intValue:()I ← 拆箱 10: aload_2 11: invokevirtual Integer.intValue:()I ← 拆箱 14: iadd ← int 相加 15: invokestatic Integer.valueOf:(I)Ljava/lang/Integer; ← 装箱 18: astore_3两次拆箱 一次装箱。在循环里这么做会有明显性能开销。六、包装类和 JVM 的关系把这一篇的发现总结成一句话包装类是纯粹的 Java 类库自动装箱是纯粹的语法糖。JVM 只需要提供普通的方法调用能力其余全是 javac 和标准库的事。对照表特性JVM 需要支持吗谁负责包装类本身❌JDK 类库普通 Java 类自动装箱/拆箱❌javac生成valueOf/intValue调用Integer.valueOf缓存❌JDK 类库实现int.class/Integer.TYPE✅JVM第 059 篇的getPrimitiveClass基本类型的类✅JVM第 059 篇的loadPrimitiveClassesFloat.floatToRawIntBits✅JVM第 061 篇需要访问浮点位模式只有三项需要 JVM 支持而且都是因为Java 语言表达不了int.class—— 需要访问 JVM 内部的类元数据floatToRawIntBits—— 需要直接访问 IEEE 754 位模式Java 语言层面无法做位级转换因为Float到int的转换会做数值转换而非位拷贝doubleToRawLongBits—— 同上这正是本地方法存在的理由的完美例证只有语言做不到的事才需要 JVM 出手。本篇小结第 9 章 9.7 节自动装箱与拆箱为什么需要包装类——基本类型不是Object进不了泛型和集合。8 种包装类Integer/Character名字不规则是历史遗留。自动装箱是纯语法糖——Integer a 100编译成invokestatic Integer.valueOfint b a编译成invokevirtual Integer.intValue。JVM 里没有 box/unbox 指令完全不需要特殊支持。jvmgo 不用为它写一行代码。IntegerCache与 128 陷阱——valueOf()对-128~127返回缓存对象同一个实例范围外new新对象。-128 是byte下界写死127 是 JLS 强制要求的下界上界可用-XX:AutoBoxCacheMax调大。永远用equals()比较包装类。缓存范围总表——Boolean全缓存、Byte全缓存256 个、Short/Integer/Long都是 -128~127、Character是 0~127ASCII、Float/Double无缓存浮点没有明显的常用值。只有三件事需要 JVM 支持——int.class访问类元数据、floatToRawIntBits/doubleToRawLongBits访问 IEEE 754 位模式。其余全是 JDK 类库和 javac 的事。这完美印证了第 058 篇的结论本地方法只用于Java 做不到的事。下一篇第 061 篇回到 jvmgo 的代码实现第 9 章剩下的本地方法System.arraycopy()、Float.floatToRawIntBits()/Double.doubleToRawLongBits()、String.intern()、Object.hashCode()/clone()。这些终于能让字符串拼接跑起来了。上一篇【第59篇】反射实现——Class.forName 的 Go 版下一篇【第61篇】String.intern() 与 Object 基础方法——JDK 的三大件
上一篇/下一篇内容由系统自动关联 返回资讯列表 →