引言

上一篇文章我们讲解了java中类型、值和变量的概念以及简单类型与引用数据类型的区别。简单类型没有对象的概念,简单类型变量持有的是简单值本身,引用类型变量持有的是对对象的引用,最能概括这两种类型的区别。而java作为面向对象的语言,很多功能是离不开对象的,例如容器(List、Map、Set)都需要引用类型的对象作为元素,简单类型无法使用这些功能,所以就出现了对应的包装类型。

基本类型与包装类型的对应

所有的包装类都在java.lang包下面,其中大部分都是Number类的子类(除了Character和Boolean)。下表列出了简单类型和包装类的对应关系:

简单类型 包装类 是否是Number的子类
boolean Boolean
byte Byte
char Character
short Short
int Integer
long Long
float Float
double Double

这些类不仅能表示对应基本数据类型的值,还提供了一些属性和方法,丰富了对这些值的操作。

Number类

Number类的定义如下:

  1. public abstract class Number implements java.io.Serializable {}

这是一个抽象类,实现了Serializable接口,提供了以下几个方法:
number.png
分别用来获得int、long、float、double、byte和short值,为什么要提供这些方法呢?因为基本数据类型之间是可以相互转换的,例如从int到long或者从long到int,转换分为拓宽转换(Widening Primitive Conversions)和窄化转换(Narrowing Primitive Conversions),这些转换可能会导致精度损失(例如从long到int的转换),具体请参考《java语言规范》第五章的内容,这里不做详细介绍。
所以Number的子类,根据可以转换到的类型,可以选择性地实现这几个方法,这些类除了继承Number之外,还都实现了Comparable接口。如下:

  1. public final class Integer extends Number implements Comparable<Integer> {}
  2. public final class Float extends Number implements Comparable<Float> {}

意味着每个包装类的对象都是可以比较的。

自动装箱和自动拆箱

装箱转换和拆箱转换是包装类型的一大特性。装箱转换将简单类型的表达式转换为相应的引用类型表达式,拆箱转换将引用类型的表达式转换为相应的简单类型的表达式。我们以Integer类为例来分析一下这两种转换。
在解释自动装箱和自动拆箱之前,我们先来看一下Integer的构造方法:

通过构造方法显式创建对象

Integer提供了两个构造方法:

  1. public Integer(int value) {
  2. this.value = value;
  3. }
  4. public Integer(String s) throws NumberFormatException {
  5. this.value = parseInt(s, 10);
  6. }

前者的实现逻辑很简单,就是将value赋值为参数值,后者的参数是一个字符串,首先将字符串解析为int,然后赋值给value,是通过调用静态方法parseInt来实现的,对这两个构造方法的每次调用都会创建新的Integer对象,看下面的例子:

  1. public static void main(String[] args) {
  2. Integer i1 = new Integer(1);
  3. Integer i2 = new Integer(1);
  4. Integer i3 = new Integer("1");
  5. Integer i4 = new Integer("1");
  6. System.out.println(i1==i2);
  7. System.out.println(i2==i3);
  8. System.out.println(i3==i4);
  9. }

这三个的输出结果都是false,所以当我们用new关键字显式地创建Integer对象时,生成的对象总是新的,而不论他们的原始值是否相等。

装箱和拆箱的实现

除了使用new关键字来声明Integer的对象,我们还经常会这样操作:

  1. public static void main(String[] args) {
  2. Integer i = 1;
  3. int i1 = i;
  4. }

这两行代码分别演示了装箱转换和拆箱转换,第一行将简单类型值赋给引用类型Integer,第二行反过来将引用类型Integer赋给简单类型,那自动装箱和拆箱是怎么实现的呢?这个需要我们将class文件反编译出来,看下面反编译后的代码:

  1. public class IntegerTest
  2. {
  3. public static void main(String args[])
  4. {
  5. Integer i = Integer.valueOf(1);
  6. int i1 = i.intValue();
  7. }
  8. }

编译器自动修改了我们的代码,用Integer i = Integer.valueOf(1)替换掉了Integer i = 1,用int i1 = i.intValue()替换掉了int i1 = i,如果将八种包装类型类似的代码都反编译过来,你会发现装箱转换都是通过对应包装类的valueOf方法实现的,拆箱转换都是通过xxxValue方法实现的,前面介绍Number类是我们已经知道类似intValue这些方法是Number提供的,没有继承Number类的Boolean和Character自己提供了对应的boolValue(
)方法和charValue()方法,看下面的例子(第二个main方法是反编译之后的):

  1. public static void main(String[] args) {
  2. Boolean b = false;
  3. boolean b1 = b;
  4. Character c = 'a';
  5. char c1 = c;
  6. }
  7. public static void main(String args[])
  8. {
  9. Boolean b = Boolean.valueOf(false);
  10. boolean b1 = b.booleanValue();
  11. Character c = Character.valueOf('a');
  12. char c1 = c.charValue();
  13. }

所以八种包装类型都是通过同样的方式来实现装箱转换和拆箱转换的。

装箱和拆箱的场景

除了上述简单类型与对应包装类型之间相互赋值会产生装箱和拆箱操作之外,以下几种场景也会产生装箱和拆箱:

将基本数据类型放入集合类

  1. public static void main(String[] args) {
  2. List<Integer> integers = new ArrayList<Integer>(3);
  3. integers.add(1);
  4. integers.add(2);
  5. integers.add(3);
  6. }
  7. public static void main(String args[])
  8. {
  9. List integers = new ArrayList(3);
  10. integers.add(Integer.valueOf(1));
  11. integers.add(Integer.valueOf(2));
  12. integers.add(Integer.valueOf(3));
  13. }

编译器默认为我们做了自动装箱操作。

包装类型与基本类型的大小比较

  1. public static void main(String[] args) {
  2. Integer a=1;
  3. System.out.println(a==1?"dengyu":"budengyu");
  4. Boolean bool=false;
  5. System.out.println(bool?"zhen":"jia");
  6. }
  7. public static void main(String args[])
  8. {
  9. Integer a = Integer.valueOf(1);
  10. System.out.println(a.intValue() != 1 ? "budengyu" : "dengyu");
  11. Boolean bool = Boolean.valueOf(false);
  12. System.out.println(bool.booleanValue() ? "zhen" : "jia");
  13. }

可以看到,当包装类型和基本数据类型进行比较时,通过xxxValue方法做了拆箱,实际上比较的是简单值的大小,这也是符合我们认知的。

包装类型的运算

当我们对包装类型进行加减乘除四则运算时,同样会发生拆箱操作:

  1. public static void main(String[] args) {
  2. Integer i=1;
  3. Integer i1=1;
  4. System.out.println(i+i1);
  5. }
  6. public static void main(String args[])
  7. {
  8. Integer i = Integer.valueOf(1);
  9. Integer i1 = Integer.valueOf(1);
  10. System.out.println(i.intValue() + i1.intValue());
  11. }

这个也是符合我们认知的,对于包装类的四则运算应该反映到对应的简单值上面。

方法的参数和返回值

看下面的例子:

  1. public static void main(String[] args) {
  2. test(new Integer(3));
  3. }
  4. public static Integer test(int i){
  5. return i;
  6. }

反编译后的内容:

  1. public static void main(String args[])
  2. {
  3. test((new Integer(3)).intValue());
  4. }
  5. public static Integer test(int i)
  6. {
  7. return Integer.valueOf(i);
  8. }

test方法的参数是简单类型,返回值是包装类型,所以return i被装箱为Integer.valueOf,在调用时,我们传入的是包装类型,被intValue()拆箱了。

三目运算符

  1. public static void main(String[] args) {
  2. boolean flag = true;
  3. Integer i = 0;
  4. int j = 1;
  5. int k = flag ? i : j;
  6. }
  7. public static void main(String args[])
  8. {
  9. boolean flag = true;
  10. Integer i = Integer.valueOf(0);
  11. int j = 1;
  12. int k = flag ? i.intValue() : j;
  13. }

再看下面的例子:

  1. public static void main(String[] args) {
  2. boolean flag = true;
  3. Integer i = 0;
  4. int j = 1;
  5. Integer integer = flag ? i : j;
  6. }
  7. public static void main(String args[])
  8. {
  9. boolean flag = true;
  10. Integer i = Integer.valueOf(0);
  11. int j = 1;
  12. Integer integer = Integer.valueOf(flag ? i.intValue() : j);
  13. }

这是三目表达式的一个原则:当两个操作数,这里是i和j,一个是简单类型T,并且另一个是在T上应用装箱转换得到的结果,那么该表达式的类型就是T。所以第一个例子中,因为flag ? i.intValue() : j这个表达式的类型应该是int,所以要将i拆箱,而第二个例子,同样因为flag ? i.intValue() : j的类型是int,而我们将它赋给Integer,所以要在表达式的基础上在进行一次装箱。
三目表达式还有其他原则,可以在《java语言规范》15.25中找到描述。

装箱转换与缓存

Integer.valueOf()方法可以完成装箱转换,但是并不是每次调用Integer.valueOf方法都会返回新的Integer对象,看valueOf方法的源码:

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

它判断了i的值,如果在IntegerCache.low和IntegerCache.high之间,就会返回IntegerCache.cache这个数组里的某个Integer,否则才会创建并返回新的Integer对象。我们看一下IntegerCache的实现:

  1. private static class IntegerCache {
  2. static final int low = -128;
  3. static final int high;
  4. static final Integer cache[];
  5. }

low是固定的值-128,high没有给定初始值,cache就是一个Integer数组,我们看high和cache是怎么赋值的:

  1. static {
  2. // high value may be configured by property
  3. int h = 127;
  4. String integerCacheHighPropValue =
  5. sun.misc.VM.getSavedProperty("java.lang.Integer.IntegerCache.high");
  6. if (integerCacheHighPropValue != null) {
  7. try {
  8. int i = parseInt(integerCacheHighPropValue);
  9. i = Math.max(i, 127);
  10. // Maximum array size is Integer.MAX_VALUE
  11. h = Math.min(i, Integer.MAX_VALUE - (-low) -1);
  12. } catch( NumberFormatException nfe) {
  13. // If the property cannot be parsed into an int, ignore it.
  14. }
  15. }
  16. high = h;
  17. cache = new Integer[(high - low) + 1];
  18. int j = low;
  19. for(int k = 0; k < cache.length; k++)
  20. cache[k] = new Integer(j++);
  21. // range [-128, 127] must be interned (JLS7 5.1.7)
  22. assert IntegerCache.high >= 127;
  23. }

h和cache的初始化都是在static块中进行的。首先,去获取jvm参数java.lang.Integer.IntegerCache.high的值,如果没有指定这个参数的值,就设置high为127,然后cache这个数组的长度就是127-(-128)+1=256,然后循环cache并赋值,第一个元素的值是-128,第二个元素的值是-127,最后一个元素的值是127,也就是说在没有指定java.lang.Integer.IntegerCache.high的值时,会默认缓存[-128,127]这个区间内的值,这样我们使用Integer.valueOf时,如果给定的参数值在这个区间内,会返回cache数组中缓存的Integer对象而不是新创建一个对象,看下面的例子:

  1. public static void main(String[] args) {
  2. Integer i1 = 10;
  3. Integer i2 = 10;
  4. Integer i3 = Integer.valueOf(10);
  5. Integer i4 = Integer.valueOf("10");
  6. System.out.println(i1==i2);
  7. System.out.println(i2==i3);
  8. System.out.println(i3==i4);
  9. Integer i5 = 128;
  10. Integer i6 = 128;
  11. Integer i7 = Integer.valueOf(128);
  12. Integer i8 = Integer.valueOf("128");
  13. System.out.println(i5==i6);
  14. System.out.println(i6==i7);
  15. System.out.println(i7==i8);
  16. }

输出结果:

  1. true true true false false false

10在[-128,127]这个区间内,而128不在,所以前者每次使用valueOf方法都会返回cache数组中固定的对象,后者每次都会创建新的对象。但是需要注意的一点是,当我们显式的用new来创建Integer对象时,是不会走缓存判断的流程的,每次都会生成新的Integer对象,看下面的例子:

  1. public static void main(String[] args) {
  2. Integer i1 = 10;
  3. Integer i2 = 10;
  4. Integer i3 = Integer.valueOf(10);
  5. Integer i4 = new Integer(10);
  6. System.out.println(i1==i2);
  7. System.out.println(i2==i3);
  8. System.out.println(i3==i4);
  9. }

输出结果为:

  1. true true false

i4虽然和前三个有相同的简单类型值,但是因为使用new关键字来显式创建的,所以总是会生成新的对象。
我们继续分析设置了java.lang.Integer.IntegerCache.high的情况:

  1. int i = parseInt(integerCacheHighPropValue);
  2. i = Math.max(i, 127);
  3. // Maximum array size is Integer.MAX_VALUE
  4. h = Math.min(i, Integer.MAX_VALUE - (-low) -1);

首先,通过parseInt方法解析java.lang.Integer.IntegerCache.high参数的值,得到一个int,然后取这个值和127两个的最大值,得到最大值之后,取这个最大值和Integer.MAX_VALUE - (-low) -1之间的最小值,这样做的含义是什么:首先,取参数值和127两个的最大值,保证了要缓存的值不能小于127,也就是[-128,127]是必须被缓存的,然后,取上面获得的最大值与Integer.MAX_VALUE - (-low) -1的最小值,是因为cache数组的最大长度为Integer.MAX_VALUE,缓存的数值的个数不能超过这个值,而最小值已经是-128了,所以最大值不能超过Integer.MAX_VALUE - (-low) -1也就是Integer.MAX_VALUE-128-1,为什么要再减一呢,因为0也会被缓存。
看下面的例子:

  1. -Djava.lang.Integer.IntegerCache.high=200
  2. public static void main(String[] args) {
  3. Integer i5 = 128;
  4. Integer i6 = 128;
  5. Integer i7 = Integer.valueOf(128);
  6. Integer i8 = Integer.valueOf("128");
  7. System.out.println(i5==i6);
  8. System.out.println(i6==i7);
  9. System.out.println(i7==i8);
  10. }

我设置了-Djava.lang.Integer.IntegerCache.high=200,再次运行的结果就是三个true了。
思考一下,此时cache的长度是多少?应该是[-128,200]这个区间的长度即329。

不同包装类的缓存策略

除了Integer外,Byte、Short、Long都有类似地缓存策略并且这几个包装类的默认缓存范围均为[-128,127],但是只有Integer的缓存最大值可以通过参数java.lang.Integer.IntegerCache.high来设置,其他的几个均不能,可以看一下Short的cache实现:

  1. private static class ShortCache {
  2. private ShortCache(){}
  3. static final Short cache[] = new Short[-(-128) + 127 + 1];
  4. static {
  5. for(int i = 0; i < cache.length; i++)
  6. cache[i] = new Short((short)(i - 128));
  7. }
  8. }

就是很简单的初始化数组,没有取参数值的逻辑。
另外Double和Float是没有缓存的。

缓存带来的问题

虽然自动装箱和拆箱能在代码编写阶段节省精力,但是随之带来的问题也需要我们注意。

自动拆箱的空指针

第一个问题就是自动拆箱的空指针问题,在自动拆箱时,如果对象为null,就会出现这个问题:

  1. public static void main(String[] args) {
  2. Integer integer = null;
  3. int i = integer;
  4. }
  1. Exception in thread "main" java.lang.NullPointerException
  2. at person.andy.concurrency.simpletype.IntegerTest.main(IntegerTest.java:8)

示例代码中会执行自动拆箱,但是integer为null。

包装对象的比较

我们知道,==作用于引用类型的对象时,比较的是引用值,包装类也是如此,而要想比较包装类的简单值,就需要使用equals方法:

  1. public boolean equals(Object obj) {
  2. if (obj instanceof Integer) {
  3. return value == ((Integer)obj).intValue();
  4. }
  5. return false;
  6. }

看下面的例子:

  1. public static void main(String[] args) {
  2. Integer integer1 = new Integer(10);
  3. Integer integer2 = new Integer(10);
  4. System.out.println(integer1 == integer2);
  5. System.out.println(integer1.equals(integer2));
  6. }

输出结果是false、true,所以在进行包装类型内的简单类型的相等性判断时,需要用equals方法。
但是由于缓存策略,就会出现下面的情况:

  1. public static void main(String[] args) {
  2. Integer integer1 = 10;
  3. Integer integer2 = 10;
  4. System.out.println(integer1 == integer2);
  5. System.out.println(integer1.equals(integer2));
  6. }

输出的是两个true。10处于Integer的默认缓存区间,两个integer1和integer2实际上指向一个对象,所以对于[-128,127]区间内的通过装箱转换得到的Integer对象,==操作符会返回true,不过这个true的意义是说这两个引用指向的是同一个对象。
你也应该能理解下面的例子:

  1. public static void main(String[] args) {
  2. Integer integer1 = 10;
  3. Integer integer2 = new Integer(10);
  4. System.out.println(integer1 == integer2);
  5. System.out.println(integer1.equals(integer2));
  6. }

结果是false、true。
所以,==对于包装对象来说总是比较引用地址而不是内部简单数据类型的值,最保险的方法就是想比较包装类型内部的简单值时总是使用equals方法。

Character缓存字符常量

Character在java中表示的是字符,它默认缓存的是’\u0000’到’\u007f’(包含)之间的字符常量值而不是数字值:

  1. private static class CharacterCache {
  2. private CharacterCache(){}
  3. static final Character cache[] = new Character[127 + 1];
  4. static {
  5. for (int i = 0; i < cache.length; i++)
  6. cache[i] = new Character((char)i);
  7. }
  8. }

注意cache[i] = new Character((char)i);这行代码进行了int到char的类型转换。
‘\u0000’到’\u007f’这个区间的字符常量其实就是ASCII表中的前127个字符(32个非打印控制字符+95个可打印字符+delete命令),看下面的例子:

  1. public static void main(String[] args) {
  2. Character backSpace1 = ' ';
  3. Character backSpace2 = ' ';
  4. System.out.println(backSpace1 == backSpace2);
  5. }

空格字符是ASCII表中的第32个(从0开始),也是可打印字符的第一个,所以是被缓存的,这个的输出结果是true。

Boolean的缓存

Boolean类型没有类似IntegerCache的内部类来实现缓存,因为它只需要缓存两个值:

  1. public static final Boolean TRUE = new Boolean(true);
  2. public static final Boolean FALSE = new Boolean(false);

直接通过static final常量给出,当我们调用Boolean的valueOf方法时,就会对应返回这两个值:

  1. public static Boolean valueOf(boolean b) {
  2. return (b ? TRUE : FALSE);
  3. }

同样,对于new关键字,每次还是都会创建新的对象,并且==对于Boolean对象同样比较的是引用值而不是内部的原始值,要想比较原始值,也需要使用equals方法,这里不再赘述。

小结

这篇文章主要讲解了包装类型的装箱转换、拆箱转换和缓存策略,并分析了由此可能产生的需要我们在开发过程中注意的问题,完整理解了包装类的这些特性和背后的机制,才能避免出现上面我们说的各种问题,或者在出现问题时快速解决。