4、基本类型

内容来自王争 Java 编程之美

上一节课,我们讲到,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 对象
posted @ 2023-05-11 21:12  lidongdongdong~  阅读(87)  评论(0编辑  收藏  举报