Java 基本类型的各种运算,你真的了解了么?

390 阅读20分钟

本文大纲:

在上一篇文章 很清晰!带你图解 Java 程序的结构,变量和类型 里,我们知道 Java 的基本类型分整型类型,浮点型类型和布尔类型三种。那针对不同的类型,Java 提供的运算能力也是各有不同,本篇文章就分析下 Java 基本类型里的各种运算是怎么回事。

整数运算

首先是整数的运算。

Java 提供了很多操作符,这些操作符可以作用于整数值上。

比较操作符

第一个是比较操作符,它的结果是 boolean 类型的值。包括

  • 数字比较运算符:<, <=, > 和 >=
    • 小于,小于等于,大于,大于,大于等于
  • 数字相等运算符:== 和 !=
    • 等于,不等于

数字操作符

第二个是数字操作符,它的结果是 intlong 类型的值。包括

  • 一元正负运算符:+ 和 -
    • 正,负
  • 乘法运算符:*, / 和 %
    • 乘,除,取模
  • 加法运算符:+ 和 -
    • 加,减
  • 递增运算符:++
    • 加一
  • 递减运算符:--
    • 减一
  • 有符合和无符号的移位操作符:<<,>> 和 >>>
    • << :左移,低位补0,不区分正数负数。
    • >> :右移,正数右移,高位补0,负数右移,高位补1。
    • >>> :无符号右移,高位补0,不区分正数负数。
  • 按位求补运算符:~
  • 整数按位运算符:&, ^ 和 |

转换运算符

第三个是转换运算符。

在学习转换之前,我们先了解下 Java 基本类型的精度高低顺序,从低到高的话,就是 byte->short->char->int->long->float->double

低精度的类型转高精度,Java 是怎么处理呢?

隐式转换

这种情况其实本质不会损失精度,因此 Java 会进行类型的自动转换,也叫隐式类型转换

比如以下这段代码,它的输出你能猜到么?

public class TypeConvert {

    /**
     * from 公众号:蜗牛互联网
     *
     * @param args 入参
     */
    public static void main(String[] args) {

        // 数字 65 实际表示大写字母 A
        char charValue = 65;

        // 初始化的char
        System.out.println("initCharValue=" + charValue);

        // 加一
        charValue += 1;
        System.out.println("CharAddOneValue=" + charValue);

        // 自增符号 打印 B 后,charValue 的值已经是 C 了,也就是 67
        System.out.println("CharAddOneValue=" + charValue++);

        // 加法运算 输出 134,即 67+67=134
        System.out.println(charValue + charValue);

        // 往高精度自动转
        int intValue = charValue;
        System.out.println("intValue=" + intValue);

        long longValue = intValue;
        System.out.println("longValue=" + longValue);

        double doubleValue = intValue;
        System.out.println("doubleValue=" + doubleValue);

    }
}

以下是输出:

initCharValue=A
CharAddOneValue=B
CharAddOneValue=B
134
intValue=67
longValue=67
doubleValue=67.0

你会发现,char 类型会转换为其对应的 ASCII 码byte、char、short 参与运算时会自动转为 int ,但 +=++ 不会转 int 。多种类型混合运算的时候,会自动转成精度最大的类型。这个类型可以覆盖到浮点数,但不能和布尔类型发生转换

自动转换 Java 就帮忙做掉了,不需要我们代码里显式声明。

显示转换

另外就是,高精度转低精度,这种情况下就需要强制转换了,也叫显式转换

你比如说以下代码:

// 高精度到低精度,走强转
int highIntValue = 129;
byte lowByteValue = (byte)highIntValue;

// 但强转后会出现精度丢失,比如这里会输出 -127
System.out.println(lowByteValue);

你会发现居然输出的是 -127,而不是 129。这是怎么回事呢?

原来是 Java 在做高精度到低精度类型转换的过程中,丢失了精度。至于精度为什么会丢,为什么打印出来是另外一个值,我们需要先明确一个计算机基础知识。

那就是计算机存储 Java 数字类型时,它在内存中的数据是以什么形式存在的

这就要涉及到原码,反码和补码的概念了。

原码

原码是未经更改的码。它由最左边的符号位二进制数构成。符号位是 0 表示正数,符号位是 1 表示负数。符号位是哪一位,由计算机的位数决定。比如数字 6 在 8 位计算机中原码的表示就是:0000 0110。它的优点就是简单直观,可以直接表示数,所以你看到程序打印的值都是原码,无非是我们这里做了下二进制到十进制的转换。

但原码也有缺点,就是不能直接参与运算,容易出错。你比如在数学上, 1+(-1)=0 ,但在二进制中 00000001+10000001=10000010 ,换算成十进制就是 -2,显然不符合预期。

于是有人就提出了反码。

反码

反码是正数不变,负数取反的码。正数的反码和原码一样,负数的反码需要保留最左边符号位,然后将原码数值位按照每位取反得到

比如数字6在 8 位计算机中反码就是它的原码:0000 0110。数字(-6)在计算机中反码就是:1111 1001。以下图表是更多的原码例子,列出了 8位数值的无符号所得值,用原码表示所得值和用反码表示所得值。

数值无符号所得值用原码表示所得值用反码表示所得值
0111 1111127 127 127 
0111 1110126 126 126 
0000 00102 2 2 
0000 00011 1 1 
0000 00000 0 0 
1111 1111255 −127−0 
1111 1110254 −126−1 
1111 1101253 −125−2 
1000 0001129 -1−126 
1000 0000128 -0−127 

(反码示例数据表)

反码就解决了原码进行减法运算时计算错误的问题,虽然反码解决方案也有一定缺陷,我们看下反码是怎么做的。

数学表达:

1 - 1 = 1 + (-1) = 0;
1 - 2 = 1 + (-2) = (-1);

反码表达:

0000 0001 + 1111 1110 = 1111 1111(-0);//有问题
0000 0001 + 1111 1101 = 1111 1110(-1);//正确

这说明反码在进行减法运算时,大部分场景是正确的,只有在结果为 0 时,可能会带负号。0 还能带负号,理解起来真的是怪怪的,这其实是反码天然的缺陷。从上面 (反码示例数据表)中我们就可以看出,反码的表示范围包括了** -127 到 -0 以及 0 到 127**,总共 256 个数。它把 0 也区分了正负,这显然是不符合逻辑的!

为了解决这个问题,补码就出现了。

补码

补码是正数不变,负数取反补一的码。正数的补码和原码一样,负数的补码需要保留最左边符号位,然后将原码数值位按照每位取反再加一

不同于反码系统中 0 有两种表示方式,补码系统的 0 就只有一种表示方式,就是数字 0 本身

从反码角度上定义补码,正数的补码和反码一样负数的补码就是它的反码加一

如下面这张表所示。

数值无符号所得值用原码表示所得值用反码表示所得值用补码表示所得值
0111 1111127 127 127 127
0111 1110126 126 126 126 
0000 00102 2 2 2
0000 00011 1 1 1
0000 00000 0 0 0
1111 1111255 −127−0 -1
1111 1110254 −126−1 -2
1111 1101253 −125−2 -3
1000 0001129 -1−126 -127
1000 0000128 -0−127 -128

(补码示例数据表)

补码的这种表示方式很适合计算机处理,依然是上面的减法问题,我们看下补码是怎么做的。

数学表达:

1 - 1 = 1 + (-1) = 0;
1 - 2 = 1 + (-2) = (-1);

补码表达:

    0000 0001 (1)
  + 1111 1111 (-1)
--------------
   10000 0000 (0)

    0000 0001 (1)
  + 1111 1110 (-2)
--------------
    1111 1111 (-1)

第一个结果 10000 0000 看上去似乎是错的,因为已经超过八个比特,不过若忽略掉(从右开始数)第 9 个比特,结果是 0000 0000(0)。这次的计算结果依然是 0,但和反码计算结果相比,就没了负号。

对照补码示例数据表,我们也可以看出,补码的表示范围包括了 -128 到 0 再到 127,总共 256 个数。

补码这样设计,使符号位能与有效值部分一起参与运算,从而简化运算规则,同时也把减法运算转换为加法运算,进一步简化了计算机中运算器的线路设计。

基于这样的优势,补码也就成为了计算机数据存储的最常用的方式。而我们看到 Java 程序打印输出的值都是计算机把补码转成了原码显示的,反码是中间的过渡。

原码、反码和补码可谓是计算机领域的三架“码”车,它们共同支撑了数据在计算机中存储与表达的形式,它们之间的关系如下:

  1. 三码都是二进制表达
  2. 三码第一位是符号位,1 表示负数,0 表示正数,其余位是数值位
  3. 正数的三码都一样。
  4. 负数的反码是在原码基础上对非符号位取反,即负数反码=符号位+原码数值位取反
  5. 负数的补码是在反码基础上加一,即负数补码=反码+1
  6. 负数补码转原码是在补码基础上减一,然后对非符号位取反,即负数原码=(补码-1)&&数值位取反

了解原码、反码和补码的概念后,我们回到精度丢失的问题上,回顾下之前的代码:

// 高精度到低精度,走强转
int highIntValue = 129;
byte lowByteValue = (byte)highIntValue;

// 但强转后会出现精度丢失,比如这里会输出 -127
System.out.println(lowByteValue);

在上面代码中,我们知道,int 类型数据是 32位,byte 类型数据为 8 位,Java 把 int 类型数据转成 byte 类型数据时,实质上是截取 int 后 8 位存到 byte 中。

int 类型的 129 三码一致,都为:0000 0000 0000 0000 0000 0000 1000 0001。计算机中存的是补码

从 int 转换 byte,截取后 8 位为:1000 0001。得到的数据为依然是补码

我们按负数补码转原码的公式,会发现其原码为:补码(1000 0001)–> 反码(1000 0000)–> 原码(1111 1111)。即 **1111 1111 **就是 (byte)highIntValue 的结果。

转换成十进制就是 lowByteValue=-(64+32+16+8+4+2+1)=-127。

是不是恍然大悟了?计算机奇怪的现象其实也是有迹可循的!

字符串串联运算符

第四个是字符串串连运算符:+

当给定一个 String 操作数和一个整数操作数时,这个运算符就会把整数操作数转换为表示其十进制形式的 String,将两个字符串串联起来,生成一个新创建的 String。

以下代码会输出什么呢?

// 用二进制形式定义一个 int
int strAppendInt = 0b111;

System.out.println(strAppendInt);

// 字符串连接打印
System.out.println("字符串串联运算符测试,原定义为:0b111,打印值为:" + strAppendInt);

没错,程序会打印 7 以及和一段字符串的拼接。

7
字符串串联运算符测试,原定义为:0b111,打印值为:7

浮点数运算

讲完了整数运算,我们再来看看浮点数运算

浮点数在计算机中的存储方式遵循 IEEE 754 浮点数的计数

浮点数运算和整数运算相比,只能进行加减乘除的数值运算,不能做位运算。不过浮点数在计算机里表示的范围会比较大,32 位的 float 都比 64 位的 long 精度大!但它也有个缺点,就是浮点数有时候不能精确表示

IEEE 754规定了四种表示浮点数值的方式:单精确度(32位)、双精确度(64位)、延伸单精确度(43比特以上,很少使用)与延伸双精确度(79比特以上,通常以80位实现)。

Java 常用单精度和双精度,所以我们只讨论这两种浮点格式。

科学计数法

说到浮点数,就不得不说科学计数法!

image.png

科学计数法的出现,是用来表示一个极大或极小数,像四亿亿这样的数字,用整数也可以表示,但你要真写的话,都不知道写到猴年马月,而且可读性也很差,不科学!于是科学计数法就应运而生,简单清晰地表达这样的数字。

科学计数法由符号、有效数字和指数三个部分组成。现实世界的数字规则是十进制,从 0 到 9,指数以 10 为底。计算机世界的二极管只有通电和断电两种状态,那对应过来就是二进制。

浮点数表示

十进制的科学计数法要求有效数字的整数部分必须在** [1,9] 的区间内,而二进制的整数部分区间就只能是 [1],由于是确定的一个信息,为了节省成本,计算机就省去了对这个 1 的存储**。32 位 float 单精度浮点数格式如下,黄色部分就是 1.xxx 后边的小数部分。

屏幕快照 2021-04-05 下午12.09.43.png

我们介绍下浮点数格式的结构,它分为三个部分。

【符号位】在最高二进制位上分配 1 位表示浮点数的符号,0 表示正数,1 表示负数。

【阶码位】相当于科学计数法的指数。在符号位右侧分配 8 位用来存储指数,IEEE754 标准规定阶码位存储的是指数对应的移码,而不是指数的原码或补码

所谓移码,就是将一个真值在数轴上正向平移一个偏移量后得到的。也就是这 8 个绿色格子直接计算,得到的结果减去 127,就是实际的指数。

【尾数位】相当于科学计数法的有效数字最右侧分配连续的 23 位用来存储有效数字,IEEE754 标准规定尾数以原码表示,规格化表示**省略 1. **,double 双精度浮点数的指数是 11 位,尾数部分是 52 位。

常用浮点数的规格化表示如表所示:

数值浮点数二进制表示说明
-161100 0001 1000 0000 0000 0000 0000 0000第 1 位是符号位,1 表示负数。阶码位 为 131 - 127 = 4,即 2 =16,尾数部分为 1.0
16.350100 0001 1000 0010 1100 1100 1100 1101第 1 位是符号位,0 表示正数。阶码位同上,尾数部分有效数字为 1.000 0010 1100 1100 1100 1101,转换成十进制为 1.021875,然后乘以 2 得到 16.35000038。你会发现,计算机实际存储的值可能和真值不同。
0.350011 1110 1011 0011 0011 0011 0011 001116.35 和 0.35 尾数不同
1.00011 1111 1000 0000 0000 0000 0000 0000127-127=0 即 2=1,尾数部分为 1.0
0.90011 1111 0110 0110 0110 0110 0110 0110126 -127 = -1 即 2=0.5,尾数部分有效数字为 1.11001100110011001100110,转成十进制为 1.7999999523162842,然后乘以 0.5 得到 0.899999976158142,你会发现 0.9 并不能用有限二进制位进行精确表示。

加减运算

在数学中,进行两个小数的加减运算时,首先要将小数点对齐,然后同位数进行加减运算。对采用科学计数法表示的数做加减法运算时,想让小数点对齐,就要确保指数一样,然后再将有效数字按照正常的数进行加减运算。具体操作如下:

  1. 零值检测。阶码和尾数全为 0,即零值,有零值参与可以直接出结果。
  2. 对阶操作。通过阶码比较,确定小数点位置是否对齐。IEEE 754 规定对阶的移动方向为向右移动,即选择阶码小的数进行操作。
  3. 尾数求和。尾数按位相加求和,负数的话先转补码再运算。
  4. 结果规格化。计算的结果可能不符合规格化形式,此时要将其规格化。尾数位向右移动是右规尾数位向左移动是左规
  5. 结果舍入。对阶或右规过程中,最右端被移出的位会被丢弃,造成结果精度损失。为减少精度损失,要先将移出的数据先保存,叫保护位,等到规格化后再根据保护位进行舍入处理。

1.0 - 0.9 运算过程说明

你知道 1.0 - 0.9 的值是多少么?

    public static void main(String[] args) {

        System.out.println(1.0f - 0.9f);
        
    }

答案是:0.100000024

0.100000024

我们分析下计算机的计算过程。

1.0 的二进制为: 0011 1111 1000 0000 0000 0000 0000 0000 -0.9 的二进制为:1011 1111 0110 0110 0110 0110 0110 0110

我们对这两个浮点数分别拆解下:

浮点数符号阶码尾数(实际值)尾数补码
1.001271000 0000 0000 0000 0000 00001000 0000 0000 0000 0000 0000
-0.911261110 0110 0110 0110 0110 01100001 1001 1001 1001 1001 1010

尾数最左端有个隐藏位,所以我们尾数实际值最高位都补 1。后续计算都基于实际的尾数位进行。

先进行对阶。1.0 的阶码是 127,-0.9 的阶码是 126。比较阶码大小后需要右移 -0.9 尾数的补码,使其阶码变为 127,同时高位补 1,那移动后的结果就是 10001 1001 1001 1001 1001 101。

然后进行尾数求和。基于补码按位相加即可,注意符号位也要参与运算。

符号位		尾数位
0		1000 0000 0000 0000 0000 0000
1		1000 1100 1100 1100 1100 1101
---------------------------------------
0		0000 1100 1100 1100 1100 1101

最左端是符号位,计算结果为 0,尾数位计算结果为 0000 1100 1100 1100 1100 1101

接着进行规范化。按照规范,尾数最高位必须是 1,因此要将结果向左移动 4 位,同时阶码要减 4。移动后的阶码等于 123(二进制为 1111011),尾数为 1100 1100 1100 1100 1101 0000。再隐藏尾数最高位,进而变为 100 1100 1100 1100 1101 0000。

那最终得到的结果的符号为 0,阶码为 1111011,尾数为 100 1100 1100 1100 1101 0000,三部分组合起来就是 1.0 - 0.9 的结果,对于的十进制就是 0.100000024

为了方便大家理解上述步骤,蜗牛画了个图帮助大家记忆。

image.png

布尔运算

讲完了浮点数运算,我们看下最后一种运算:布尔运算。我这里分了两种,逻辑运算符条件运算符

逻辑运算符

逻辑运算符有 &, |, !, ^, ||, && ,分别是与、或、非、异或,短路或和短路与。参与运算的是布尔值,输出结果也是布尔值。

条件运算符

然后是条件运算符,类似这种格式:type identifier = boolean-expression? true-res : false-res。这就是所谓的三元表达式,三元分别是布尔运算表达式,布尔运算值为 true 时的结果值,布尔运算值为 false 时的结果值。

例如: int b = a > 10? 10 : a,在 a 是 99 的时候就返回了 10,在 a 是 6 的时候就返回了 a 本身也就是 6。

小结

本文介绍了 Java 基本类型的三大类运算,包括整数运算,浮点数运算和布尔运算,在讲解各种运算的过程中,也引出了计算机的一些基础知识,像原码,反码,补码这类,也举例说明了一些你平时可能不会注意到的问题,比如 1.0 减去 0.9 在计算机的世界里居然不是整整的 0.1,其实在浮点数的世界里容易被你忽略甚至用错的点还很多,比如判断两个浮点数是否相等,如果直接用 == 是会让程序出错的。限于篇幅,另外蜗牛的认知还不够深,就没继续展开。

这篇文章断断续续也写了一周多,看似简单的运算符,真正想分享的时候,才知道自己知之甚少,边学习边分享。真的是知道的越多,不知道的也就越多。不过这也是好处,只有知道自己在认知上的不足,才能去做弥补,从而看到更广阔的世界。按认知力漏斗来看,已经处于第二层了,需要继续精进。

image.png

写文不易,欢迎读者朋友点赞和转发,感谢你们!