这是蜗牛互联网的第 112 期原创。
作者 | 白色蜗牛
来源 | 蜗牛互联网(ID: woniu_internet)
转载请联系授权(微信ID: 919201148)
本文大纲:
在上一篇文章 很清晰!带你图解 Java 程序的结构,变量和类型 里,我们知道 Java 的基本类型分整型类型,浮点型类型和布尔类型三种。那针对不同的类型,Java 提供的运算能力也是各有不同,本篇文章就分析下 Java 基本类型里的各种运算是怎么回事。
整数运算
首先是整数的运算。
Java 提供了很多操作符,这些操作符可以作用于整数值上。
比较操作符
第一个是比较操作符,它的结果是 boolean 类型的值。包括
- 数字比较运算符:
<, <=, > 和 >=
。小于,小于等于,大于,大于,大于等于
== 和 !=
。
- 等于,不等于
数字操作符
第二个是数字操作符,它的结果是 int 或 long 类型的值。包括
- 一元正负运算符:
+ 和 -
。正,负
*, / 和 %
。
- 乘,除,取模
+ 和 -
。
- 加,减
++
。
- 加一
--
。
- 减一
<<,>> 和 >>>
。
<<
:左移,低位补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 1111 | 127 | 127 | 127 |
0111 1110 | 126 | 126 | 126 |
0000 0010 | 2 | 2 | 2 |
0000 0001 | 1 | 1 | 1 |
0000 0000 | 0 | 0 | 0 |
1111 1111 | 255 | −127 | −0 |
1111 1110 | 254 | −126 | −1 |
1111 1101 | 253 | −125 | −2 |
1000 0001 | 129 | -1 | −126 |
1000 0000 | 128 | -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 1111 | 127 | 127 | 127 | 127 |
0111 1110 | 126 | 126 | 126 | 126 |
0000 0010 | 2 | 2 | 2 | 2 |
0000 0001 | 1 | 1 | 1 | 1 |
0000 0000 | 0 | 0 | 0 | 0 |
1111 1111 | 255 | −127 | −0 | -1 |
1111 1110 | 254 | −126 | −1 | -2 |
1111 1101 | 253 | −125 | −2 | -3 |
1000 0001 | 129 | -1 | −126 | -127 |
1000 0000 | 128 | -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 表示负数,0 表示正数,其余位是数值位。
- 正数的三码都一样。
- 负数的反码是在原码基础上对非符号位取反,即负数反码=符号位+原码数值位取反。
- 负数的补码是在反码基础上加一,即负数补码=反码+1。
- 负数补码转原码是在补码基础上减一,然后对非符号位取反,即负数原码=(补码-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 常用单精度和双精度,所以我们只讨论这两种浮点格式。
科学计数法
说到浮点数,就不得不说科学计数法!
科学计数法的出现,是用来表示一个极大或极小数,像四亿亿这样的数字,用整数也可以表示,但你要真写的话,都不知道写到猴年马月,而且可读性也很差,不科学!于是科学计数法就应运而生,简单清晰地表达这样的数字。
科学计数法由符号、有效数字和指数三个部分组成。现实世界的数字规则是十进制,从 0 到 9,指数以 10 为底。计算机世界的二极管只有通电和断电两种状态,那对应过来就是二进制。
浮点数表示
十进制的科学计数法要求有效数字的整数部分必须在
[1,9]
的区间内,而二进制的整数部分区间就只能是
[1]
,由于是确定的一个信息,为了节省成本,计算机就省去了对这个 1 的存储。32 位 float 单精度浮点数格式如下,黄色部分就是 1.xxx 后边的小数部分。
我们介绍下浮点数格式的结构,它分为三个部分。
【符号位】在最高二进制位上分配 1 位表示浮点数的符号,0 表示正数,1 表示负数。
【阶码位】相当于科学计数法的指数。在符号位右侧分配 8 位用来存储指数,IEEE754 标准规定阶码位存储的是指数对应的移码,而不是指数的原码或补码。
所谓移码,就是将一个真值在数轴上正向平移一个偏移量后得到的。也就是这 8 个绿色格子直接计算,得到的结果减去 127,就是实际的指数。
【尾数位】相当于科学计数法的有效数字。最右侧分配连续的 23 位用来存储有效数字,IEEE754 标准规定尾数以原码表示,规格化表示
省略 1.
,double 双精度浮点数的指数是 11 位,尾数部分是 52 位。
常用浮点数的规格化表示如表所示:
数值 | 浮点数二进制表示 | 说明 |
---|---|---|
-16 | 1100 0001 1000 0000 0000 0000 0000 0000 | 第 1 位是符号位,1 表示负数。阶码位 为 131 – 127 = 4,即 2 =16,尾数部分为 1.0 |
16.35 | 0100 0001 1000 0010 1100 1100 1100 1101 | 第 1 位是符号位,0 表示正数。阶码位同上,尾数部分有效数字为 1.000 0010 1100 1100 1100 1101,转换成十进制为 1.021875,然后乘以 2 得到 16.35000038。你会发现,计算机实际存储的值可能和真值不同。 |
0.35 | 0011 1110 1011 0011 0011 0011 0011 0011 | 16.35 和 0.35 尾数不同 |
1.0 | 0011 1111 1000 0000 0000 0000 0000 0000 | 127-127=0 即 2=1,尾数部分为 1.0 |
0.9 | 0011 1111 0110 0110 0110 0110 0110 0110 | 126 -127 = -1 即 2=0.5,尾数部分有效数字为 1.11001100110011001100110,转成十进制为 1.7999999523162842,然后乘以 0.5 得到 0.899999976158142,你会发现 0.9 并不能用有限二进制位进行精确表示。 |
加减运算
在数学中,进行两个小数的加减运算时,首先要将小数点对齐,然后同位数进行加减运算。对采用科学计数法表示的数做加减法运算时,想让小数点对齐,就要确保指数一样,然后再将有效数字按照正常的数进行加减运算。具体操作如下:
- 零值检测。阶码和尾数全为 0,即零值,有零值参与可以直接出结果。
- 对阶操作。通过阶码比较,确定小数点位置是否对齐。IEEE 754 规定对阶的移动方向为向右移动,即选择阶码小的数进行操作。
- 尾数求和。尾数按位相加求和,负数的话先转补码再运算。
- 结果规格化。计算的结果可能不符合规格化形式,此时要将其规格化。尾数位向右移动是右规,尾数位向左移动是左规。
- 结果舍入。对阶或右规过程中,最右端被移出的位会被丢弃,造成结果精度损失。为减少精度损失,要先将移出的数据先保存,叫保护位,等到规格化后再根据保护位进行舍入处理。
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.0 | 0 | 127 | 1000 0000 0000 0000 0000 0000 | 1000 0000 0000 0000 0000 0000 |
-0.9 | 1 | 126 | 1110 0110 0110 0110 0110 0110 | 0001 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。
为了方便大家理解上述步骤,蜗牛画了个图帮助大家记忆。
布尔运算
讲完了浮点数运算,我们看下最后一种运算:布尔运算。我这里分了两种,逻辑运算符和条件运算符。
逻辑运算符
逻辑运算符有
&, |, !, ^, ||, &&
,分别是与、或、非、异或,短路或和短路与。参与运算的是布尔值,输出结果也是布尔值。
条件运算符
然后是条件运算符,类似这种格式:
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,其实在浮点数的世界里容易被你忽略甚至用错的点还很多,比如判断两个浮点数是否相等,如果直接用
==
是会让程序出错的。限于篇幅,另外蜗牛的认知还不够深,就没继续展开。
这篇文章断断续续也写了一周多,看似简单的运算符,真正想分享的时候,才知道自己知之甚少,边学习边分享。真的是知道的越多,不知道的也就越多。不过这也是好处,只有知道自己在认知上的不足,才能去做弥补,从而看到更广阔的世界。按认知力漏斗来看,已经处于第二层了,需要继续精进。
写文不易,欢迎读者朋友点赞和转发,感谢你们!
我是蜗牛,大厂程序员,专注技术原创和个人成长,正在互联网上摸爬滚打。[strong]欢迎关注[/strong]我,和蜗牛一起成长,我们一起牛~下期见!