4、基本类型
上一节课,我们讲到,Java 中的类型可以分为两类:基本类型和引用类型,并且,重点讲解了引用类型,本节,我们重点讲一下基本类型
作为面向对象编程语言,在 Java 语言中,有一个比较流行的说法,那就是 "一切皆对象",这也是 Java 语言的设计理念之一
但基本类型的存在似乎与此相矛盾,因此也有人说,Java 是非纯的面向对象编程语言
既然已经有了 Integer、Long 等类,为什么 Java 语言又保留了 int、long 等基本类型呢?本节,我们就详细讲解一下 Java 中的基本类型以及对应的包装类
1、八种基本类型
Java 中的基本类型有 8 种,并且可以分为三类,具体如下所示
1、整型类型:byte(字节型)、short(短整型)、int(整型)、long(长整型)
2、浮点类型:float(单精度)、double(双精度)
3、字符类型:char
4、布尔类型:boolean
Java 不支持无符号类型,除此之外,不管 32 位 JVM,还是 64 位 JVM,这 8 种基本类型的长度(占用的字节个数)都是固定的,如下所示
对比 C / C++,在 32 位编译器中,long 类型的长度是 4 字节,而在 64 位编译器中,long 类型的长度是 8 字节
基本类型 | 字节大小 | 数值范围 | 默认值 |
---|---|---|---|
byte | 1 | -128 ~ 127 | 0 |
short | 2 | -32768 ~ 32767 | 0 |
int | 4 | -2 ^ 31 ~ 2 ^ 31 - 1 | 0 |
long | 8 | -2 ^ 63 ~ 2 ^ 63 - 1 | 0L |
float | 4 | -3.4e38 ~ 3.4e38 | 0.0f |
double | 8 | -1.7e308 ~ 1.7e308 | 0.0 |
boolean | 1 | true or false | false |
char | 2 | '\u0000' ~ '\uFFFF' | '\u0000' |
关于上图给出的每种类型的长度和数据范围,我们有几点需要解释一下
1.1、对整型类型的说明
在 byte、short、int、long 这四个整型类型的数据范围中,负数比正数多一个
这是为什么呢?负数在计算机中是如何用二进制来表示的呢?关于这一点,我们在第 5 节课中讲解
1.2、对浮点类型的说明
同样是 4 字节长度,为什么 float 表示的数据范围比 int 大?同理,为什么 double 表示的数据范围比 long 的大?
计算机是如何用二进制表示浮点数的?关于这一点,我们在第 6 节课中讲解
1.3、对 boolean 类型的说明
boolean 类型只有 true 和 false 两个值,理论上只需要 1 个二进制位就可以表示
我们知道,数据存储的位置是通过内存地址来标识的,内存地址一般以字节为单位,一个字节一个地址
单个二进制位很难存储和访问,考虑到字节对齐(第 9 节中会详讲),在 JVM 具体实现 boolean 类型时,大都采用 1 个字节来存储,用 0 表示 false,用 1 表示 true
尽管在存储空间上有些浪费,但操作起来更加简单
当然,如果在项目中需要大量使用 boolean 类型的数据,那么我们也有更加节省内存的存储方式,关于这一点,我们在容器这一部分讲解
1.4、对 char 类型的说明
char 类型的长度是 2 字节,而在 C / C++ 中 char 类型的数据长度是 1 个字节,为什么会有这种差别呢?关于这一点,我们在第 7 节中讲解
在上图中,每种类型的数据在没有赋值和初始化之前,都会有默认值(所有二进制位都是 0)
注意,long、float 类型的默认值的后面都带着标志符,这是因为在 Java 中
整数(整型字面常量)默认是 int 类型的,如果要表示 long 类型的字面常量(Literals),需要在整数的后面添加 l 或 L 标志符
浮点数(浮点类型字面常量)默认是 double 类型的,如要表示 float 类型的字面常量,需要在浮点数后面添加 f 或 F 标志符
假设我们需要计算 100 年有多少秒,如下计算方法将会出错
long s = 100 * 12 * 30 * 86400;
System.out.println(s); // 打印 -1184567296
尽管等号后面的式子的结果并没有超过 long 型数据的最大值,但已经超过 int 型数据的最大值,在没有明确指明类型的情况下,整数默认是 int 类型的
所以,等号右边式子的计算结果放入 int 类型中时会溢出,溢出之后的值再赋值给变量 s,所以,s 中存储的就不是正确的值了
正确的写法应该是下面这样子
long s = 100L * 12 * 30 * 86400;
System.out.println(s); // 打印 3110400000
2、基本类型转换
基本类型转换有两种:自动类型转换和强制类型转换
自动类型转化也叫做隐式类型转换,强制类型转换也叫做显式类型转化
一般来讲,从数据范围小的类型向数据范围大的类型转换,可以触发自动类型转换;从数据范围大的类型向数据范围小的类型转换,需要强制类型转换
不过,boolean 类型数据不支持与任何类型的自动或强制类型转换
2.1、自动类型转换
自动类型转换规则如下所示
1、byte 自动转换为:short, int, long, float, double
2、short 自动转换为:int, long, float, double
3、char 自动转换为:int, long, float, double
4、int 自动转换为:long, float, double
5、long 自动转换为:float、double
6、float 自动转换为:double
注意,这里是将数据范围作为是否自动转换的参考,而非类型长度(所占字节个数)
这也是为什么 short 和 char 都占用 2 个字节,却不能互相转换,而 long 占用 8 个字节,却能转换为长度为 4 字节的 float 的原因
char 类型数据转换为 int、long、float、double 类型,得到的是 char 类型数据的 UTF-16 编码(在第 7 节中讲解)
而 char 类型数据转换为 short 类型时,因为 char 类型数据的范围是 0 ~ 65535,而 short 类型数据的范围是 -32768 ~ 32767
char 类型中的 UTF-16 编码大于 32767 的数据,将会转换为 short 类型中的负数,转换跨度有点大,所以,Java 不允许 char 类型数据自动转换为 short 类型
同理,也不允许 short 类型数据转换为 char 类型
尽管 long 类型是 8 字节的,float 类型是 4 字节的,但 float 类型可以表示的数据范围却比 long 类型大,这是因为浮点数的表示方法比较特殊,采用的是科学计数法
关于这一点,我们今天暂时不讲,会在第 6 节中详细讲解
将 long 类型的数据转换为 float 类型,会损失一些精度,相当于做了类似四舍五入的精度舍弃,对于本身就无法表示精确值的 float 类型来说,这种转换是符合常理的
所以,Java 允许 long 类型数据自动转换为 float 类型
long l = 500000000000000l;
float f = l;
System.out.println(f); // 输出 4.99999993E14
2.2、强制类型转换
除了以上罗列的允许自动类型转换的规则之外,其他类型之间的转换都需要显示地指明,也就是需要强制类型转换
强制类型转换有可能会导致数据的截断(将高位字节舍弃)或精度的丢失,需要程序员自己保证转换的正确性,符合自己的应用场景
在真实的项目开发中,精度丢失是可以接受的,如下面代码中从 float 转为 int,可以实现取整操作,但大部分截断是不被允许的
如下面代码中从很大的 long 值转换为 int,因为这种截断得到的值没有意义
只有程序员保证数据落在另一个类型可表示的范围内时,这种转换才是有意义的,如下面代码中从比较小的 long 值转换为 int
public class Demo4_1 {
public static void main(String[] args) {
long l1 = 500000000000000l;
func((int) l1); // 输出 1382236160
long l2 = 3245;
func((int) l2); // 输出 3245
float f = 19234.54343f;
func((int) f); // 输出 19234
}
public static void func(int i) {
System.out.println(i);
}
}
我们刚刚讲了基本类型之间互相转换,实际上,引用类型也可以互相转换
不过,这种转换仅限于有继承关系的类之间,转换有两种类型:向上转换(Upcasting)和向下转换(Downcasting)
向上转换的意思是将对象的类型转换为父类或接口类型,向上转换总是被允许的,所以是自动类型转换
向下转换的意思是将对象的类型转换为子类类型,向下转换需要显式指明,所以是强制类型转换
需要特别注意的是,对于向下转换,因为转换为子类之后,有可能会调用子类存在而父类不存在的属性和方法
所以,程序员需要保证转换的对象本身就是子类类型的,只不过暂时转换为了父类类型了,现在只是再转换回去而已
public class Parent {
public int a;
}
public class Child extends Parent {
public int b;
}
public class Other {
public int c;
}
public class Demo4_1 {
public static void main(String[] args) {
Child child = new Child();
f(child);
}
// 传递给 parent 的对象, 本身就是 Child 类型的
public static void f(Parent parent) {
Other other = (Other) parent; // 报错
Child child = (Child) parent; // OK
System.out.println(child.b);
}
}
3、自动装箱拆箱
Java 一切皆对象,所以,对于基本类型,Java 还提供了对应的包装类(Wrapper Class)
如下所示,其中,Number 是整型和浮点型包装类的父类
基本类型 | 对应包装类 |
---|---|
byte | Byte |
short | Short |
int | Integer |
long | Long |
float | Float |
double | Double |
char | Character |
boolean | Boolean |
基本类型和包装类之间可以转换,这种转换可以显式执行,也可以隐式执行
3.1、显式转换
Java 提供了一些方法来实现这种显示的转换,以上八种基本类型和包装类在使用上类似,所以,我们只拿 Integer 类举例讲解,示例代码如下所示
// 基本类型转换为包装类
int i = 5;
Integer iobj1 = new Integer(i);
Integer iobj2 = Integer.valueOf(i);
// 包装类转换为基本类型
i = iobj1.intValue();
3.2、隐式转换
除了以上显式地调用方法来转换之外,Java 还支持基本类型和包装类之间的隐式转换,专业的叫法为自动装箱(autoboxing)和自动拆箱(unboxing)
自动装箱是指自动将基本类型转换为包装类,自动拆箱是指自动将包装类转换为基本类型
Integer iobj = 12; // 自动装箱, 底层实现为:Integer iobj = Integer.valueof(12);
int i = iobj; // 自动拆箱, 底层实现为:int i = iobj.intValue();
字面常量 12 是 int 基本类型的,当赋值给包装类 Integer 对象时,触发自动装箱操作,创建一个 Integer 类型的对象,并赋值给变量 iobj
实际上,自动装箱只是一个语法糖,其底层相当于执行了 Integer 类的 valueof() 方法
反过来,当把包装类对象 iobj 赋值给基本类型变量i时,触发自动拆箱操作,将 iobj 中的数据取出,再赋值给变量 i
其底层相当于执行了 Integer 类的 intValue() 方法
了解了自动装箱和自动拆箱原理之后,我们来看下,什么时候会触发自动装箱和自动拆箱,我总结了以下几种情况
1、将基本类型数据赋值给包装类变量(包括参数传递)时,触发自动装箱
2、将包装类对象赋值给基本类型变量(包括参数传递)时,触发自动拆箱
3、当包装类对象参与算术运算时,触发自动拆箱操作
4、当包装类对象参与关系运算(<、>)时,触发自动拆箱操作
5、当包装类对象参与关系运算(==),并且另一方是基本类型数据时,触发拆箱操作
对于以上几种触发自动装箱和自动拆箱的情况,我们举例说明一下,如下所示
// 第一种情况: 赋值
int i1 = 4;
Integer iobj1 = 5; // 自动装箱
iobj1 = i1; // 自动装箱
List<Integer> list = new ArrayList<>();
list.add(i1); // 自动装箱
// 第二种情况: 赋值
Integer iobj2 = new Integer(6);
int i2 = iobj2; // 自动拆箱
// 第三种情况: 算术运算
Integer iobj3 = iobj1 + iobj2; // 自动拆箱
System.out.println(iobj3); // 输出 10
// 第四种情况: 大于小于关系运算
boolean bl = (iobj1 < iobj2); // 自动拆箱
System.out.println(bl); // 输出 true
bl = (iobj1 < 2); // 自动拆箱
System.out.println(bl); // 输出 false
// 第五种情况: == 关系运算
Integer iobj4 = new Integer(1345);
bl = (iobj4 == 1345); // 自动拆箱
System.out.println(bl); // 输出 true
3.3、性能问题
自动装箱和自动拆箱给我们的开发带来了便利,但同时,不恰当的使用它们,也会导致性能问题,如下代码所示
public class Demo4_3 {
public static void main(String[] args) {
Integer count = 0;
for (int i = 0; i < 10000; ++i) {
count += 1;
}
}
}
在上述代码中,count += 1 等价于 count = count + 1,因为 Integer 等包装类是不可变类(至于为什么设计成不可变类,我们在第 8 节中讲解)
执行这条语句会先触发自动拆箱,执行加法操作,然后再触发自动装箱,生成新的 Integer 类对象,最后赋值给 count 变量
也就是说,执行上述代码,要执行 10000 次自动装箱和拆箱,并且生成 10000 个 Integer 对象,相对于如下代码实现方式,上述实现方式内存消耗大,执行速度慢
public class Demo4_3 {
public static void main(String[] args) {
int count = 0;
for (int i = 0; i < 10000; ++i) {
count += 1;
}
}
}
性能对比
public class Test {
public static void test1() {
long start = System.nanoTime();
Integer count = 0;
for (int i = 0; i < 10000000; ++i) count += 1;
long end = System.nanoTime();
double time = (end - start) / 1000000000.0;
System.out.println(time + " s");
}
public static void test2() {
long start = System.nanoTime();
int count = 0;
for (int i = 0; i < 10000000; ++i) count += 1;
long end = System.nanoTime();
double time = (end - start) / 1000000000.0;
System.out.println(time + " s");
}
public static void main(String[] args) {
test1();
test2();
}
}
// 0.0464398 s
// 0.0012643 s
4、常量池技术
我们先来看下面这段代码,你觉得它的打印结果是什么,这也是一道常考的面试题
Integer a = 12;
Integer b = 12;
Integer c = new Integer(12);
System.out.println("a == 12: " + (a == 12)); // 输出 true
System.out.println("a == b: " + (a == b)); // 输出 true
System.out.println("a == c: " + (a == c)); // 输出 false
对于第一条打印语句:a == 12 触发自动拆箱,所以打印 true
对于第三条打印语句:a 和 c 引用不同的对象,所以打印 false
对于第二条打印语句:a 和 b 引用不同的对象,理应打印 false,但运行程序,打印结果却是 true,这是为什么呢?
这是因为 Integer 等包装类使用了常量池技术,IntegerCache 类中会缓存值为 -128 到 127 之间的 Integer 对象
当我们通过自动装箱,也就是调用 valueOf() 来创建 Integer 对象时,如果要创建的 Integer 对象的值在 -128 和 127 之间,会从 IntegerCache 中直接返回,否则才会真正调用 new 方法创建,Integer 类的 valueOf() 的代码实现如下所示
public static Integer valueOf(int i) {
if (i >= IntegerCache.low && i <= IntegerCache.high)
return IntegerCache.cache[i + (-IntegerCache.low)];
return new Integer(i);
}
IntegerCache 类的代码如下所示,它是享元模式的典型引用,关于享元模式,在我的《设计模式之美》一书中有详细讲解
private static class IntegerCache {
static final int low = -128;
static final int high;
static final Integer cache[];
static {
// high value may be configured by property
int h = 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);
// Maximum array size is Integer.MAX_VALUE
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++);
// range [-128, 127] must be interned (JLS7 5.1.7)
assert IntegerCache.high >= 127;
}
private IntegerCache() {
}
}
为什么 IntegerCache 只缓存 -128 到 127 之间的整型值呢?
在 IntegerCache 的代码实现中,当这个类被加载的时候,缓存的 Integer 对象会被集中一次性创建好
毕竟整型值太多了,我们不可能在 IntegerCache 类中预先创建好所有的整型值对象,这样既占用太多内存,也使得加载 IntegerCache 类的时间过长,更没有必要
所以,JVM 只选择缓存对大部分应用来说常用的整型值,也就是一个字节范围内的整型值(-128 ~ 127)
实际上,JVM 也提供了方法,让我们可以自定义缓存的最大值
如果你通过分析应用程序的 JVM 内存占用情况,发现 -128 到 255 之间的数据占用的内存比较多,你就可以用如下方式,将缓存的最大值从 127 调整到 255
不过,JDK 并没有提供设置最小值的方法
# 方法一
-Djava.lang.Integer.IntegerCache.high=255
# 方法二
-XX:AutoBoxCacheMax=255
综上所述,在平时的开发中,对于下面这样三种创建整型对象的方式,我们优先使用后两种
这是因为,第一种创建方式并不会使用到 IntegerCache,而后面两种创建方法可以利用 IntegerCache 缓存,返回共享的对象,以达到节省内存的目的
举一个极端一点的例子,假设应用程序需要创建 1 万个 -128 到 127 之间的 Integer 对象
使用第一种创建方式,我们需要分配 1 万个 Integer 对象的内存空间
使用后两种创建方式,我们最多只需要分配 256 个 Integer 对象的内存空间
Integer a = new Integer(123);
Integer a = 123;
Integer a = Integer.valueOf(123);
除了 Integer 类型之外,其他部分包装类也使用了常量池技术
其中,Byte、Short、Long 利用常量池技术来缓存值在 -128 到 127 之间对象
Float、Double 表示浮点数,无法利用常量池技术
Boolean 只有两个值,不需要使用常量池技术
Character 利用常量池技术缓存值在 0 到 127 之间的对象(因为 Character 的值没有负数)
5、基本类型 VS 包装类
了解了基本类型和包装类,我们来对比一下两者的优缺点
包装类是引用类型,对象的引用和对象本身是分开存储的,而对于基本类型数据,变量对应的内存块直接存储数据本身
所以,基本类型数据在读写效率方面,都要比包装类高效
除此之外,在 64 位 JVM 上在开启引用压缩的情况下,一个 Integer 对象占用 16 个字节的内存空间(关于这一点,我们在第 9 节详细讲解)
而一个 int 类型数据只占用 4 字节的内存空间,前者对空间的占用是后者的 4 倍
也就是说,不管是读写效率,还是存储效率,基本类型都比包装类高效,这就是 Java 保留基本类型的原因
尽管 Java 最初的设计理念是一切皆对象,这样可以统一对变量的处理逻辑,但为了性能做了妥协,毕竟基本类型数据在开发中使用太频繁了
不过,Java 真的想要做到一切皆对象,也是有可能的,Java 可以只提供包装类,不提供基本类型给开发者使用,编译器在底层将包装类转换为基本类型处理
这样相当于提供了基本类型的语法糖给开发者使用,实际上,像 Groovy、Scala 等语言也正是这么做的
而 Java 之所以没有这么做,我猜测,很可能是历史的原因,毕竟 Java 发明于上个世纪 90 年代,当时没有考虑那么全面
而之后大家已经习惯了使用基本类型,如果再将其废弃,那么影响过于大
不过,包装类也有优势,它提供了更加丰富的方法,可以更加方便地实现复杂功能
基本类型和包装类各自有各自的优点,那么,在平时的开发中,什么时候使用基本类型?什么时候使用包装类呢?
在项目开发中,首选基本类型,毕竟基本类型在性能方面更好,当然,也有一些特殊情况,比较适合使用包装类
比如映射数据库的 Entity、映射接口请求的 DTO,在数据库或请求中的字段值为 null 时,我们需要将其映射为 Entity 或 DTO 中的 null 值
还有,我们在初始化变量时,需要将其设置为没有业务意义的值,如果某个变量的默认值 0 是有业务意义的值,这个时候,我们需要找一个其他值(例如 -1)来初始化变量
这种情况下,我们就推荐使用包装类,因为包装类变量的默认值是 null,是没有业务意义的
6、课后思考题
以下代码打印结果是什么
public class Demo4_4 {
public static void main(String[] args) {
int i = 100;
int j = 100;
compare(i, j);
}
public static void compare(Integer obj1, Integer obj2) {
Integer obj3 = obj1 + 1;
Integer obj4 = obj2 + 1;
System.out.println("" + (obj3 == obj4));
}
}
打印结果为 true
将 i 和 j 赋值给参数 obj1 和 obj2 使用常量池技术,因此,obj1 和 obj2 指向相同的 Integer 对象
obj1 + 1 和 obj2 + 2 会触发自动拆箱,最终结果为基本类型值 101
将 101 赋值给 obj3 和 obj4 会触发常量池技术,因此,obj3 和 obj4 指向相同的 Integer 对象
本文来自博客园,作者:lidongdongdong~,转载请注明原文链接:https://www.cnblogs.com/lidong422339/p/17392273.html