Java 语言虽然号称一切都是对象,但原始数据类型例外,Java 提供了 8 个原始数据类型,分别是:boolean、byte 、short、char、int、float、double、long。而 Integer 是 int 对应的包装类,内部维护了一个 int 类型的字段存储数据,并且提供了基本操作,比如数学运算、int 和字符串之间转换等。

自动装箱、拆箱

JDK 5 引入了自动装箱和自动拆箱功能,Java 可以根据上下文自动进行转换,极大简化了相关编程。自动装箱实际上算是一种语法糖。什么是语法糖?可以简单理解为 Java 平台为我们自动进行了一些转换,保证不同的写法在运行时等价,它们发生在编译阶段,也就是生成的字节码是一致的。

像前面提到的整数,javac 替我们自动把装箱转换为 Integer.valueOf(),把拆箱替换为 Integer.intValue(),既然调用的是 Integer.valueOf,自然也能够得到缓存的好处。

下面我们来验证一下:

  1. Integer integer = 1;
  2. int unboxing = integer ++;

反编译输出:

  1. 1: invokestatic #2 // Method
  2. java/lang/Integer.valueOf:(I)Ljava/lang/Integer;
  3. 8: invokevirtual #3 // Method
  4. java/lang/Integer.intValue:()I

这种缓存机制并不是只有 Integer 才有,同样存在于其他的一些包装类,比如:

  • Boolean:缓存了 true、false 对应实例,即只返回两个常量实例 Boolean.TRUE/FALSE。
  • Short:同样是缓存了 -128 到 127 之间的数值。
  • Byte:由于其数值有限,所以全部都被缓存。
  • Character:缓存范围 ‘\u0000’ 到 ‘\u007F’。

自动装箱、自动拆箱似乎很酷,在编程实践中,有什么需要注意的吗?

原则上,建议避免无意中的装箱、拆箱行为,尤其是在性能敏感的场合,创建 10 万个 Java 对象和 10 万个整数的开销可不是一个数量级,不管是内存使用还是处理速度,光对象头的空间占用就已经是数量级的差距了。

我们其实可以把这个观点扩展开,使用原始数据类型、数组甚至本地代码实现等,在性能极度敏感的场景往往具有比较大的优势,用其替换掉包装类、动态数组(如 ArrayList)等可以作为性能优化的备选项。一些追求极致性能的产品或者类库,会极力避免创建过多对象。当然,在大多数产品代码里,并没有必要这么做,还是以开发效率优先。以我们经常会使用到的计数器实现为例,下面是一个常见的线程安全计数器实现。

  1. class Counter {
  2. private final AtomicLong counter = new AtomicLong();
  3. public void increase() {
  4. counter.incrementAndGet();
  5. }
  6. }

如果利用原始数据类型,可以将其修改为:

  1. class CompactCounter {
  2. private volatile long counter;
  3. private static final AtomicLongFieldUpdater<CompactCounter> updater = AtomicLongFieldUpdater.newUpdater(CompactCounter.class, "counter");
  4. public void increase() {
  5. updater.incrementAndGet(this);
  6. }
  7. }

源码分析

整体看一下 Integer 的职责,它主要包括各种基础的常量,比如最大值、最小值、位数等;前面提到的各种静态工厂方法 valueOf();获取环境变量数值的方法;各种转换方法,比如转换为不同进制的字符串,如 8 进制,或者反过来的解析方法等。我们进一步来看一些有意思的地方。

首先,继续深挖缓存,Integer 的缓存范围虽然默认是 -128 到 127,但是在特别的应用场景,比如我们明确知道应用会频繁使用更大的数值,这时候应该怎么办呢?

缓存上限值实际是可以根据需要调整的,JVM 提供了参数设置:

  1. -XX:AutoBoxCacheMax=N

这些实现,都体现在 java.lang.Integer 源码之中,并实现在 IntegerCache 的静态初始化块里:

  1. private static class IntegerCache {
  2. static final int low = -128;
  3. static final int high;
  4. static final Integer cache[];
  5. static {
  6. // high value may be configured by property
  7. int h = 127;
  8. String integerCacheHighPropValue = VM.getSavedProperty("java.lang.Integer.IntegerCache.high");
  9. ...
  10. // range [-128, 127] must be interned (JLS7 5.1.7)
  11. assert IntegerCache.high >= 127;
  12. }
  13. ...
  14. }

第二,我们在分析字符串的设计实现时,提到过字符串是不可变的,保证了基本的信息安全和并发编程中的线程安全。如果你去看包装类里存储数值的成员变量 “value”,你会发现,不管是 Integer 还 Boolean 等,都被声明为 private final ,所以它们同样也是不可变类型。

这种设计是可以理解的,或者说是必须的选择。想象一下这个应用场景,比如 Integer 提供了 getInteger() 方法,用于方便地读取系统属性,我们可以用属性来设置服务器某个服务的端口,如果我可以轻易地把获取到的 Integer 对象改变为其他数值,这会带来产品可靠性方面的严重问题。

第三,Integer 等包装类,定义了类似 SIZE 或者 BYTES 这样的常量,这反映了什么样的设计考虑呢?如果你使用过其他语言,比如 C、C++,类似整数的位数,其实是不确定的,可能在不同的平台,比如 32 位或者 64 位平台,存在非常大的不同。那在 32 位 JDK 或者 64 位 JDK 里,数据位数会有不同吗?或者说,这个问题可以扩展为,我使用 32 位 JDK 开发编译的程序,运行在 64 位 JDK 上,需要做什么特别的移植工作吗?

其实,这种移植对于 Java 来说相对要简单些,因为原始数据类型是不存在差异的,这些明确定义在 Java 语言规范里面,不管是 32 位还是 64 位环境,开发者无需担心数据的位数差异。

对于应用移植,虽然存在一些底层实现的差异,比如 64 位 HotSpot JVM 里的对象要比 32 位 HotSpot JVM 大(具体区别取决于不同 JVM 实现的选择),但是总体来说并没有行为差异,应用移植还是可以做到宣称的“一次书写,到处执行”,应用开发者更多需要考虑的是容量、能力等方面的差异。

Java 原始数据类型和引用类型局限性

对于 Java 应用开发者,设计复杂而灵活的类型系统似乎已经习以为常了。但是坦白说,毕竟这种类型系统的设计是源于很多年前的技术决定,现在已经逐渐暴露出了一些副作用,例如:

  • 原始数据类型和 Java 泛型并不能配合使用

这是因为 Java 的泛型某种程度上可以算作伪泛型,它完全是一种编译期的技巧,Java 编译期会自动将类型转换为对应的特定类型,这就决定了使用泛型,必须保证相应类型可以转换为 Object。

  • 无法高效地表达数据,也不便于表达复杂的数据结构,比如 vector 和 tuple

我们知道 Java 的对象都是引用类型,如果是一个原始数据类型数组,它在内存里是一段连续的内存,而对象数组则不然,数据存储的是引用,对象往往是分散地存储在堆的不同位置。这种设计虽然带来了极大灵活性,但是也导致了数据操作的低效,尤其是无法充分利用现代 CPU 缓存机制。