我们知道Java是面向对象的语言,号称一切皆对象,但是有8种原始数据类型(boolean、byte 、short、char、int、float、double、long)需要除排除在外。
在面试过程中经常会遇到,考察原始数据类型和其包装类语言特性的问题。
本文就以原始数据类型int和其包装类Integer为例进行讲解,主要包含以下几个方面的内容:
1.Integer的不可变性
2.自动装箱和自动拆箱(boxing/unboxing)
3.Integer的缓存值
4.使用Integer注意的事项
一.Integer的不可变性
A.什么是不可变对象?
如果一个对象,在它创建完成之后,不能再改变它的状态,那么这个对象就是不可变的。
前面讲解的字符串String是不可变对象,另外8种包装类型也都是不可变对象,其中自然包括Integer。
B.Integer如何实现不可变
1.Integer类定义使用final修饰,不能被继承,这样Integer的所有方法就不能被重写;
2.存储数据的字段声明为private final int value,使用private final修饰,value的值构造函数初始化后不能被修改。
C.Integer不可变带来的好处
1.Integer只有设计成不可变的对象,才能为其建立缓存值(后面会相信讲解),以达到节约内存的目的;
2.Integer是不可变的,必然是线程安全的,这样同一个Integer对象就可以被多个线程安全地共享,而且不需要任何同步操作;
3.Integer是不可变的,可以保证信息的安全。比如我们使用Integer来保存服务器某个服务的端口,如果我们可以轻易地把Integer对象改变为其他数值,这会给产品的可靠性带来严重的问题。
D.举例说明:
上面代码执行结果:
number和numberToIncrease两个变量的内存示意图,如下图所示:
1. 执行number = 1的时候,number执行value = 1的Integer对象;
2. 调用increase方法,number作为实参传给numberToIncrease,此时 number和numberToIncrease都指向了value = 1的Integer对象;
3. 执行numberToIncrease = numberToIncrease + 1;numberToIncrease就指向了value = 2的Integer对象,但是number的指向不变,还是原来value = 1的Integer对象。
最终,有了上面的执行结果。
二.自动装箱和自动拆箱(boxing/unboxing)
Java中的自动装箱和拆箱操作是通过语法糖实现的。什么是语法糖?
语法糖,是指计算机语言中添加的某种语法,这种语法对语言的功能并没有影响,但是更方便程序员使用。
通常来说使用语法糖能够增加程序的可读性,从而减少程序代码出错的机会。
在讲解自动装箱和自动拆箱操作之前,先说明一下装箱和拆箱的概念:
装箱:把Java原始数据类型(如:int)转化为其对应的包装类型(如:Integer)的过程我们称为装箱操作;
拆箱:把Java包装类型(如:Integer)转化为其对应的原始数据类型(如:int)的过程我们称为拆箱操作。
下面举个例子,说明一下装箱和拆箱的操作,例1:
1. main方法的第一行,代码中的0是int类型,当赋值给Integer类型的sum变量时,调用了Integer.valueOf将int类型转换成Integer类型,这个过程就是装箱;
2. main方法的第二行,sum要进行加法操作时,Integer类型无法直接进行加法操作,先执行sum.intValue()变成int后,再进行加法操作,而转化为int的过程就是拆箱;
3. main方法的第二行,当需要把加1的结果,再赋值给sum的时,再次调用Integer.valueOf进行类型转换,又进行了一次装箱操作。
当然平时我们很少会写上面那种臃肿的代码,常用的写法如下,例2:
但是我们查看这两个类main方法的字节码,结果完全一致,如下所示:
从上面的字节码可以看出:
虽然例2中的代码中没有显式地去调用Integer.valueOf和Integer.intValue方法。
但是编译后的class文件中,却有这两个方法的隐式调用,而这个隐式调用过程就是自动装箱和自动拆箱的操作。
自动装箱:当一个Integer类型的值,需要变成int的时候(比如要进行加法运算),Java编译器会加入Integer.intValue()的方法调用,将Integer类型自动转换成int;
自动拆箱:当一个int的值,需要变成Integer的时候(比如把int类型的值赋值给Integer),Java编译器会加入一段Integer.valueOf(int i)的方法调用,把int类型自动转换为Integer类型。
三.Integer的缓存值
关于Integer的值缓存,涉及到Java 5的一个改进。在Java 5之前,构建Integer对象的传统方式是,直接调用其构造函数创建出一个新的对象。
但是根据实践的结果,我们发现大部分int数值运算的结果都集中在有限的、较小的数值范围内。
因此,在Java 5在Integer类上新增了一个valueOf的静态工厂方法,在调用它的时候会利用一个缓存机制,最终带来了明显的性能改进。
我们先看看下面的代码,猜测一下代码执行的结果,代码如下:
代码执行结果如下:
当没有读过Integer的源码,看到上面的结果,是不是会很惊讶。
为什么a和b赋值为1,进行==判断返回true,而c和d赋值为128,进行==判断就返回false了呢?
前面了解了自动装箱操作后,我们知道,当把int型的值,赋值为Integer类型的时候,会调用Integer.valueOf进行自动装箱操作,奥秘应该就是在Integer.valueOf方法中,源码如下:
当传入的参数i大于等于IntegerCache.low并且小于等于IntegerCache.high时,则从IntegerCache.cache中取值;
而其他情况下则新创建一个Integer对象。
IntegerCache的low和high是多少,还有cache又是什么呢,接着读一下IntegerCache的源码:
通过上面的代码,我们得出下面结论:
1.low的值是固定为-128;
2.high的默认值是127,但是可以通过java.lang.Integer.IntegerCache.high这个property进行设置。
最终取127和设置值中的较大值,并且取Integer.MAX_VALUE - (-low) -1和设置值中的较小值,作为最终的high的值;
3.最后根据low和high的值,对cache进行初始化,其cache[0] = -128,cache[cache.length - 1] = high。
还是上面的代码,如果加上JVM参数-Djava.lang.Integer.IntegerCache.high=128后再次执行,结果如下:
通过JVM参数,IntegerCache缓存的最大值设置为128,128也进行了缓存,c == d就由原来的false变成了true。
四.使用Integer注意的地方
1.使用int类型替换Integer进行数值计算
Integer类型无法直接进行数值计算,在计算之前需要进行拆箱变成int后进行计算,在计算之后赋值给Integer类型的时候又要进行装箱操作。
大量的装箱和拆箱操作非常浪费CPU和内存,下面代码对比一下二者的效率,代码如下:
上面代码执行结果如下所示:
computeByInt方法中,直接对int变量进行操作;
而computeByInteger方法中,sum = sum + 1,有一次自动装箱和一次自动拆箱操作,极大地影响了性能。
从最终的结果来看,两者之前有成千上万倍的性能差异。
2.使用int[]数组替换Integer[]和ArrayList
我们知道Java的对象都是引用类型,如果是一个原始数据类型数组,它在内存里是一段连续的内存;
而对象数组则不一样,数据存储的是引用,对象往往是分散地存储在堆的不同位置。
这种设计虽然带来了极大灵活性,但是也导致了数据操作的低效,尤其是无法充分利用现代CPU缓存机制。
下面举个例子说明二者性能的差异:
上面代码运行结果如下:
从运行结果上看,sumInt方法和sumInteger两个方法的性能差异巨大,主要有两个原因:
1. sumInteger进行加法操作的时候,多了一次拆箱操作;
2. int[]数组内数据存储是连续的,可以充分利用现代CPU缓存机制,而Integer[]中每个元素的intValue值,就分散地存储在堆的不同位置,无法充分利用CPU缓存机制,导致性能损失。
所以,使用原始数据类型替换包装类,使用数组替换动态数组(如ArrayList),在性能极度敏感的场景往往具有比较大的优势,一些追求极致性能的产品或者类库,会极力避免创建过多对象。
当然,在大多数纯业务功能代码里,并没有必要这么做,还是以开发效率优先。