Java的自动拆装箱

概念

       在说到拆箱和装箱之前,需要了解Java中有八种基本的数据类型,分别是:byte、short、char、int、long、float、double和boolean。这八种基本类型在Java中都有对应的包装类型:Byte、Short、Character、Integer、Long、Float、Double以及Boolean。

       有了基本类型为什么还需要包装类型呢?这是由Java本身的语言特性决定的,Java是一种面向对象的编程语言,在学习Java之初就被明确灌输了一个概念:OOP,即面向对象编程。一切皆对象。但是基本类型是不具备Java中对象的某些特征,对象内部可以封装一系列属性和行为,但是这些在基本数据类型中都无法满足,所以对应的包装类型就应运而生了。

       这里的装箱和拆箱的概念描述的其实就是Java中这八种基本数据类型和对应的包装类型之间的转换过程。我们把基本数据类型转换成对应的包装类型的过程叫做装箱。反之就是拆箱。在Java中的装箱和拆箱不是人为操作的,是程序在编译的时候编译器帮助我们完成这项任务的,因此说它是自动的。

       需要明确的是自动拆装箱是在JDK1.5以后引入的,对于之前版本的Java,需要格外注意格式的转换。

为何需要自动装箱和拆箱?

方便

       首先就是方便程序员的编码,我们在编码过程中,可以不需要考虑包装类和基本类型之间的转换操作,这一步由编译器自动替我们完成,开发人员可以有更多的精力集中与具体的业务逻辑。否则的话,一个简单的数字赋值给包装类就得写两句代码,即:首先生成包装类型对象,然后将对象转换成基本数据类型。而这种操作是代码中使用频率很高的操作,导致代码书写量增多。

节约空间

       我们在查阅对应包装类的源代码时可以看到,大部分包装类型的valueOf方法都会有缓存的操作,即:将某段范围内的数据缓存起来,创建时如果在这段范围内,直接返回已经缓存的这些对象,这样保证在一定范围内的数据可以直接复用,而不必要重新生成。

       这么设计的目的因为:小数字的使用频率很高,将小数字缓存起来,让其仅有一个对象,可以起到节约存储空间的作用。这里其实采用的是一种叫做享元模式的设计模式。可以去具体了解以下这种设计模式,这里就不再过多赘述。

实现原理

       Java中是怎么实现这个自动装箱和拆箱的过程的呢?这里需要借助与一些反编译工具,例如javap命令或者其他一些反编译的工具,我这里使用的是idea的bytecode插件,如果需要,可以到这里下载。在它的release中直接下载zip压缩包就行,然后作为插件安装在idea中就行,安装完成重启idea后,在需要反编译的java代码中右键,可以找到"Show Bytecode outline-dev"菜单选项,直接点击就可以看到反编译后的代码。

装箱

首先看下面两句代码:

Integer i = 20;
int j = 2;

在进行反编译之后可以得到:

Integer i = Integer.valueOf((int)10);
int j = 2;

       可以看到对于数值类型直接赋值给包装类型,有一个自动装箱的操作,而自动装箱的操作就是利用了Integer中的valueOf方法,这就是前面在节约空间那部分提到的valueOf方法。Integer的valueOf方法中具有缓存的功能,也就是说在数值为-128到127之间的数据,都是被构造成同一个对象,这就是上面提到的享元模式的设计思路:

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

这个概念在刷面试题的时候,都被强调烂了,基本常见的笔试题目就是比较几个integer对象之间==操作:

Integer a = 100;
Integer b = 100;
Integer c = 128;
Integer d = 128;
System.out.println(a == b); //true
System.out.println(c == d); //false

       注意:也可以使用new Integer(num)的方式创建Integer对象,但是在JDK1.9之后,这个构造方法被标记为Deprecated,也就是过时了,所以以后尽量不要使用这种方式创建对象。它的注释中建议使用valueOf进行构建对象。利用构造器构造出来的对象不会经过取缓存操作,所以对于new Integer(100)的操作,得到的Integer对象与a或b进行==比较时,得到的会是false。

       其实其他七种包装类型的valueOf方法大多都是这个享元设计模式的逻辑,但是有两个除外:Float和Double。这个其实也很好理解:因为Integer这种类型的数据,-128到127之间的数据是有限个,总共就256个数字,但是对于Float和Double这种类型,它们之间的数据个数就无法计算了,所以它两个就没有采用这种缓存的方式。下面是其他包装类型中的valueOf方法的源码:

//Short
public static Short valueOf(short s) {
 final int offset = 128;
 int sAsInt = s;
 if (sAsInt >= -128 && sAsInt <= 127) { // must cache
 return ShortCache.cache[sAsInt + offset];
 }
 return new Short(s);
}
//Byte
public static Byte valueOf(byte b) {
 final int offset = 128;
 return ByteCache.cache[(int)b + offset];
}
//Character
public static Character valueOf(char c) {
 if (c <= 127) { // must cache
 return CharacterCache.cache[(int)c];
 }
 return new Character(c);
}
//Long
public static Long valueOf(long l) {
 final int offset = 128;
 if (l >= -128 && l <= 127) { // will cache
 return LongCache.cache[(int)l + offset];
 }
 return new Long(l);
}
//Boolean
public static Boolean valueOf(boolean b) {
 //public static final Boolean TRUE = new Boolean(true);
 //public static final Boolean FALSE = new Boolean(false);
 return (b ? TRUE : FALSE);
}
//Float
public static Float valueOf(float f) {
 return new Float(f);
}
//Double
public static Double valueOf(double d) {
 return new Double(d);
}

       通过上面的代码截图可以看到,对于Float和Double都是直接使用了构造器直接构造对应包装类型的对象。对于Boolean类型,就是固定的两个TRUE和FALSE两个常量,它们不会出现变化,这也属于一种缓存。

       对于Byte类型,它是直接全部缓存了,这里使用了cache数组,它在Byte类中定义和初始化如下:

static final Byte cache[] = new Byte[-(-128) + 127 + 1];
static {
 for(int i = 0; i < cache.length; i++)
 cache[i] = new Byte((byte)(i - 128));
}

       所以cache数组中存储的就是-128到127范围的所有数。在构建时直接定位到具体的数组位置中去,并将该位置上的数值直接返回即可。

       其余数据类型基本逻辑都差不多了,都有一个缓存值范围,如果超过了,就利用构造器直接构造,否则直接返回缓存的对象。

拆箱

上面介绍的valueOf方法是装箱操作的时候使用的,还有一个拆箱操作,看下面这个例子:

Integer a = 100;
int b = 20;
int c = a + b;

上面代码反编译之后就得到:

Integer a = Integer.valueOf((int)100);
int b = 20;
int c = a.intValue() + b;

       可以看到第一步进行了自动装箱操作,在第三行中,基本数据类型和包装类型进行运算,需要将包装类型进行拆箱操作,用到了intValue方法。这个方法其实在源码中很简单,就是一句话,返回value。我们知道任何包装类型,内部都有一个基本数据类型的字段用于存储对应基本类型的值,这个字段就是value。

       相应的其他包装类型在进行拆箱的时候,都会调用对应的xxxValue方法,例如:byteValue、shortValue等等方法。其实内部逻辑都是一样,直接返回存储的value值。

自动装箱和拆箱的时机

直接赋值

       这个情况其实在前面介绍自动装箱的操作的时候,举例代码中就是这种情况,将一个字面量直接赋值给对应包装类型会触发自动装箱操作。

函数参数

//自动拆箱
public int getNum1(Integer num) {
 return num;
}
//自动装箱
public Integer getNum2(int num) {
 return num;
}

集合操作

       在Java的集合中,泛型只能是包装类型,但是我们在存储数据的时候,一般都是直接存储对应的基本类型数据,这里就有一个自动装箱的过程。

运算符运算

       上面在拆箱操作的时候利用的就是这个特性,当基本数据类型和对应的包装类型进行算术运算时,包装类型会首先进行自动拆箱,然后再与基本数据类型的数据进行运算。

       说到运算符,这里对于自动拆箱有一个需要注意的地方:

Integer a = null;
int b = a;// int b = a.intValue();

       这种情况编译是可以通过的,但是在运行的时候会抛出空指针异常,这就是自动拆箱导致的这种错误。因为自动拆箱会调用intValue方法,但是此时a是null,所以会抛异常。平时在使用的时候,注意非空判断即可。

自动装拆箱带来的问题

==比较

       首先就是前面提到的关于==操作符的结果问题,因为自动装箱的机制,我们不能依赖于==操作符,它在一定范围内数值相同为true,但是在更多的空间中,数值相同的包装类型对象比较的结果为false。如果需要比较,可以考虑使用equals比较或者将其转换成对应的基本类型再进行比较可以保证结果的一致性。

空指针

       这是上面在说到运算符的时候提到的一种情况,因为有自动拆箱的机制,如果初始的包装类型对象为null,那么在自动拆箱的时候的就会报NullPointerException,在使用时需要格外注意,在使用之前进行非空判定,保证程序的正常运行。

内存浪费

这里有个例子:

Integer sum = 0;
for(int i=1000; i<5000; i++){
 sum+=i;
}

       上面代码中的 sum+=i 这个操作其实就是拆箱再装箱的过程,拆箱过程是发生在相加的时候,sum本身是Integer,自动拆箱成int与 i 相加。将得到的结果赋值给sum的时候,又会进行自动装箱,所以上面的for循环体中一句话,在编译后会变为:

n = Integer.valueOf((int)(n.intValue() + i));

       每次调用valueOf方法都会返回一个Integer对象,所以在进行了5000次循环后,会出现大量的无用对象造成内容空间的浪费,同时加重了垃圾回收的工作量,所以在日常编码过程中需要格外注意,避免出现这种浪费现象。

方法重载问题

       最典型的就是ArrayList中出现的remove方法,它有remove(int index)和remove(Object obj)方法,如果此时恰巧ArrayList中存储的就是Integer元素,那么会不会出现混淆的情况呢?其实这个只需要做一个简单的测试就行:

public static void test(Integer num) {
 System.out.println("Integer参数的方法被调用...");
}
​
public static void test(int num) {
 System.out.println("int参数的方法被调用...");
}
public static void main(String[] args) {
 int i = 2;
 test(i); //int参数的方法被调用...
 Integer j = 4;
 test(j);//Integer参数的方法被调用...
}

       所以可以发现,当出现这种情况的时候,是不会发生自动装箱和拆箱操作的。可以正常区分。

推荐阅读更多精彩内容