Java 为什么需要包装类

在 Java 的世界中,对象是一等公民,但 Java 也还是做出了妥协,出于对性能的考虑而保留了 8 种基础数据类型。 但是在某些场景下,无法直接使用基本数据类型,所以还是需要使用对象,Java 的包装类就是这样出现的。

## 自动装箱和拆箱

看下面的代码:

1
2
3
ArrayList<Integer> list = new ArrayList<Integer>();
int i = 1;
list.add(i); // 装箱;

Java 编译器会自动把基本数据类型转成对象,这个称之为装箱。 到底是怎么做到的呢?看下面的字节码:

1
2
3
4
// ...
12: invokestatic  #3                  // Method java/lang/Integer.valueOf:(I)Ljava/lang/Integer;
15: invokevirtual #10                 // Method java/util/ArrayList.add:(Ljava/lang/Object;)Z
// ...

简单解释一下 invokestatic 和 invokevirtual,这两个都是 JVM 的指令,前者表示调用 Java 的 静态方法,后者表示调用对象方法。

invokestatic 调用了 Integer.valueOf() 方法,所以装箱实际上就是调用了 Integer.valueOf() 方法。

拆箱也很简单,看下面的代码:

1
2
Integer i = 1;
int i2 = 1// 拆箱;

拆箱的字节码如下:

1
2
3
  // ...
  7: invokevirtual #2                  // Method java/lang/Integer.intValue:()I
  // ...

同理,拆箱实际调用的是 Integer 对象方法 i.intValue()

从上文可以看出,Java 中基本类型的装箱和拆箱实际上是编译器提供的语法糖,是在编译器层面进行处理的,编译器会将装箱和拆箱编译成调用方法的字节码。在虚拟机层,通过调用方法来实现包装类的装箱和拆箱。

Byte,Short,Long,Float,Double,Boolean,Character 与 Integer 类似。

但是需要注意,还有一个特殊的包装类 Void。 Void 是 void 的包装类,Void 不能被继承,也不能被实例化,仅仅就是一个占位符。

如果一个方法使用 void 修饰,说明方法没有返回值,如果使用 Void 修饰,则该方法只能返回 null。

1
2
3
public Void nullFunc() {
    return null// 返回其他值会编译不通过
}

Void 常用于反射中,判断一个方法的返回值是不是 void。

1
2
3
4
5
for (Method method : VoidDemo.class.getMethods()) {
    if (method.getReturnType().equals(Void.TYPE)) {
        // ...
    }
}

## 包装类的缓存 先看下面的代码:

1
2
3
4
5
6
7
8
9
Integer i1 = 200;
Integer i2 = 200;
System.out.println(i2 == i1); // false
Integer i3 = new Integer(100);
Integer i4 = new Integer(100);
System.out.println(i3 == i4); // false
Integer i5 = 100;
Integer i6 = 100;
System.out.println(i5 == i6); // true

上面的代码应该算是一道经典的面试题了。通过上文可知,装箱操作使用的是 Integer.valueOf() 方法,源码如下:

1
2
3
4
5
public static Integer valueOf(int i) {
    if (i >= IntegerCache.low && i <= IntegerCache.high)
        return IntegerCache.cache[i + (-IntegerCache.low)];
    return new Integer(i);
}

关键实现在 IntegerCache 中,在某个范围内的数值可以直接使用已经创建好的对象。IntegerCache 是一个静态内部类,而且不能实例化,仅仅用来缓存 Integer 对象:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
private static class IntegerCache {
    static final int low = -128// 缓存对象的最小值,不能配置
    static final int high;
    static final Integer cache[];
    static {
        int h = 127// 缓存对象的最大值可以配置,但是不能超过 Integer的最大值,不能小于 127
        String integerCacheHighPropValue =
            sun.misc.VM.getSavedProperty("java.lang.Integer.IntegerCache.high");
        if (integerCacheHighPropValue != null) {
            try {
                int i = parseInt(integerCacheHighPropValue);
                i = Math.max(i, 127);
                h = Math.min(i, Integer.MAX_VALUE - (-low) -1);
            } catch( NumberFormatException nfe) {
            }
        }
        high = h;
        cache = new Integer[(high - low) + 1];
        int j = low;
        for(int k = 0; k < cache.length; k++)
            cache[k] = new Integer(j++);
        assert IntegerCache.high >= 127;
    }
    private IntegerCache() {}
}

缓存对象的默认大小范围是 -128 ~ 127,正数范围可以根据自己的需要进行调整,负数最小就是 -128,不可以调整。如果不在这个范围内,就会创建新的对象。

上面代码的结果就很清晰了,第一个结果为 false 是因为 200 超出了默认的缓存范围,因此会创建新的对象。第二个结果为 false 是因为直接使用 new 来创建对象,而没有使用缓存对象。第三个结果为 true 是因为刚好在缓存的范围内。

所以在使用 Integer 等包装类生成对象时,不要使用 new 去新建对象,而应该尽可能使用缓存的对象,而且比较两个 Integer 对象时不要使用 ==,而应该使用 equals。

其他的包装类的实现基本类似,只是在对象缓存上的实现有些不同:

- Byte 的范围刚好是 -128~127,所以都可以直接从缓存中获取对象。 - Short 缓存范围也是 -128 ~ 127,而且不可以调整。 - Long 的实现与 Short 一致。 - Character 因为没有负数,所以缓存范围是 0 ~ 127,也不可以调整范围。 - Boolean 的值只有 true 和 false,在类加载的时候直接创建好。 - Float,Double 则没有缓存机制,因为是浮点数,可以表示无穷无尽的数,缓存的意义不大。

## 小心空指针 此外还需要注意的一点就是,使用包装类生成的是对象,是对象就有可能出现空指针异常,在代码中需要进行处理。

1
2
Integer integer = null;
int i = integer; // NPE

@2020 rayjun