三、数据背后的二进制

文章目录

  • 数据背后的二进制
    • [1.1 整数的二进制表示与位运算](#1.1 整数的二进制表示与位运算)
      • [1.1.1 正整数的二进制表示](#1.1.1 正整数的二进制表示)
      • [1.1.2 负整数的二进制表示](#1.1.2 负整数的二进制表示)
    • [1.2 原码、反码、补码](#1.2 原码、反码、补码)
      • [1.2.1 机器数和机器数的真值](#1.2.1 机器数和机器数的真值)
      • [1.2.2 原码, 反码, 补码的基础概念和计算方法](#1.2.2 原码, 反码, 补码的基础概念和计算方法)
      • [1.2.3 为何要使用原码、反码和补码](#1.2.3 为何要使用原码、反码和补码)
      • [1.2.4 补码计算原理](#1.2.4 补码计算原理)
    • [1.3 小数的二进制表示](#1.3 小数的二进制表示)
      • [2.3.1 小数为什么会出错](#2.3.1 小数为什么会出错)
      • [2.3.2 二进制表示](#2.3.2 二进制表示)
    • [1.4 字符的编码和乱码](#1.4 字符的编码和乱码)
      • [1.4.1 常见非`Unicode`编码](#1.4.1 常见非Unicode编码)
      • [1.4.2 Unicode编码](#1.4.2 Unicode编码)
      • [1.4.3 编码转换](#1.4.3 编码转换)
      • [1.4.4 乱码原因](#1.4.4 乱码原因)
      • [1.4.5 从乱码中恢复](#1.4.5 从乱码中恢复)
    • [1.5 Java 中的char](#1.5 Java 中的char)

数据背后的二进制

本文为书籍《Java编程的逻辑》^1^和《剑指Java:核心原理与应用实践》^2^阅读笔记

java 复制代码
public void TestMy() {
    int a = 2147483647 * 2;
    System.out.println(a); // 正整数相乘的结果居然出现了负数
    System.out.println(0.1 * 0.1); // 非常基本的小数运算结果居然不精确。
    char c = 'A';
    System.out.println((c + 32)); // 字符类型也可以进行算术运算和比较。
    System.out.println((char) (c + 32)); // 字符类型和整型可以相互转换。
}

上述代码运算得到:

-2
0.010000000000000002
97
a

上面体现了四个问题:

  1. 正整数相乘的结果居然出现了负数:整型 a ∗ 2 a*2 a∗2得到了 − 2 -2 −2;
  2. 0.1 ∗ 0.1 0.1*0.1 0.1∗0.1得不到 0.01 0.01 0.01,而是 0.010000000000000002 0.010000000000000002 0.010000000000000002;
  3. c h a r char char类型数据可以和整型数据运算;
  4. c h a r char char类型数据可以和整数相互转换;

要理解上述的行为,就需要理解计算机是如何保存数据,这部分分为三部分,一部分介绍整数,一部分介绍小数,最后一部分介绍字符的基本类型 c h a r char char以及文本的编码。

1.1 整数的二进制表示与位运算

要理解整数的二进制,我们先来看下熟悉的十进制整数。比如 123 123 123,可以表示为: 1 × ( 1 0 2 ) + 2 × ( 1 0 1 ) + 3 × ( 1 0 0 ) 1×(10^2)+2× (10^1)+3× (10^0) 1×(102)+2×(101)+3×(100),它表示的是各个位置数字含义之和,每个位置的数字含义与位置有关,从右向左,数字含义为第一位数字乘以 1 0 0 = 1 10^0=1 100=1,第二位数字乘以 1 0 1 = 10 10^1=10 101=10,即 10 10 10,第三位数字乘以 1 0 2 = 100 10^2=100 102=100,以此类推。换句话说,每个位置都有一个位权,从右到左,第一位为 1 1 1,然后依次乘以 10 10 10,即第二位为 10 10 10,第三位为 100 100 100,以此类推。

1.1.1 正整数的二进制表示

正整数的二进制表示与此类似,只是在十进制中,每个位置可以有 10 10 10个数字,为 0 ∼ 9 0\sim9 0∼9,但在二进制中,每个位置只能是 0 0 0或 1 1 1。位权的概念是类似的,从右到左,第一位为 1 1 1,然后依次乘以 2 2 2,即第二位为 2 2 2,第三位为 4 4 4​,以此类推。

javaInteger.toBinaryString可以获得整数的二进制表示。如下面代码,调用Integer.toBinaryString函数,获取几个正整数的二进制表示如下:

java 复制代码
    @Test
    public void testPositiveIntegerBinaryRepresentation() {
        assertTrue("01111111111111111111111111111111"
                .equals(StringUtils.leftPad(Integer.toBinaryString(2_147_483_647), 32, "0")));
        assertTrue("00000000000000000000000000000000"
                .equals(StringUtils.leftPad(Integer.toBinaryString(0), 32, "0")));
        assertTrue("00000000000000000000000000000010"
                .equals(StringUtils.leftPad(Integer.toBinaryString(2), 32, "0")));
        assertTrue("00000000000000000000000000000011"
                .equals(StringUtils.leftPad(Integer.toBinaryString(3), 32, "0")));
        assertTrue("00000000000000000000000000001010"
                .equals(StringUtils.leftPad(Integer.toBinaryString(10), 32, "0")));
    }

1.1.2 负整数的二进制表示

十进制的负数表示就是在前面加一个负数符号 − - −即可,例如 − 123 -123 −123。但二进制如何表示负数呢?其实概念是类似的,二进制使用最高位表示符号位,用 1 1 1表示负数,用 0 0 0表示正数。但哪个是最高位呢?整数有 4 4 4种类型byteshortintlong,分别占1248个字节,即分别占8163264位,每种类型的符号位都是其最左边的一位。为方便举例,下面假定类型是byte,即从右到左的第8位表示符号位。对比十进制负整数,负整数的二进制是不是简单地将最高位变为 1 1 1​即可呢?下面看几个负数二进制表示的例子。

  1. (byte) -1,如果只是将最高位变为 1 1 1,二进制应该是 1000 0001 1000\ 0001 1000 0001,但实际上,它应该是 1111 1111 1111\ 1111 1111 1111。
  2. (byte) -127,如果只是将最高位变为 1 1 1,二进制应该是 1111 1111 1111\ 1111 1111 1111,但实际上,它却应该是 1000 0001 1000\ 0001 1000 0001。
java 复制代码
	@Test
	public void testNegativeIntegerBinaryRepresentation() {
		assertTrue("00000001".equals(Integer.toBinaryString(((byte) 1 & 0xFF) + 0x100).substring(1)));
		assertTrue("11111111".equals(Integer.toBinaryString(((byte) -1 & 0xFF) + 0x100).substring(1)));
		assertTrue("10000001".equals(Integer.toBinaryString(((byte) -127 & 0xFF) + 0x100).substring(1)));
	}

为什么跟我们的直觉相反呢?因为计算机中使用补码保存数据,那么什么是补码,补码如何计算以及为什么计算机使用补码保存数据呢?

1.2 原码、反码、补码

本文参考博客《原码、反码、补码》^3^

1.2.1 机器数和机器数的真值

在学习原码,反码和补码之前, 需要先了解机器数真值的概念。

1、机器数

一个数在计算机中的二进制表示形式,叫做这个数的机器数。机器数是带符号的,在计算机用机器数的最高位 存放符号,正数为 0 0 0,负数为 1 1 1。比如,十进制中的数 + 3 +3 +3,计算机字长为8位 ,转换成二进制就是 0000 0011 0000\ 0011 0000 0011。如果是 − 3 -3 −3,就是 100 00011 100\ 00011 100 00011。那么,这里的 0000 0011 0000\ 0011 0000 0011和 1000 0011 1000\ 0011 1000 0011就是机器数。

2、机器数的真值

因为第一位是符号位,所以机器数的形式值就不等于真正的数值。例如上面的有符号数 1000 0011 1000\ 0011 1000 0011,其最高位 1 1 1代表负,其真正数值是 − 3 -3 −3,而不是形式值 131 131 131( 1000 0011 1000\ 0011 1000 0011转换成十进制等于 131 131 131)。所以,为区别起见,将带符号位的机器数对应的真正数值 称为机器数的真值 。例: 00000001 0000 0001 00000001的真值 = + 000 0001 = + 1 = +000\ 0001 = +1 =+000 0001=+1, 1000 0001 1000\ 0001 1000 0001的真值 = -- 000 0001 = -- 1 = --000\ 0001 = --1 =--000 0001=--1

1.2.2 原码, 反码, 补码的基础概念和计算方法

在探求为何机器要使用补码之前,让我们先了解原码、反码和补码的概念。对于一个数,计算机要使用一定的编码方式进行存储,原码、反码、补码是机器存储一个具体数字的编码方式。

1. 原码

原码就是符号位加上真值的绝对值 ,即用第一位表示符号,其余位表示值。比如:如果是 8 8 8位二进制:

  • 1 +1 +1的原码= 0000 0001 0000\ 0001 0000 0001

− 1 -1 −1的原码 = 1000 0001 = 1000\ 0001 =1000 0001

第一位是符号位,因为第一位是符号位,所以8位二进制数的取值范围就是:(即第一位不表示值,只表示正负) [ 1111 1111 , 0111 1111 ] [1111\ 1111,\ 0111\ 1111] [1111 1111, 0111 1111],即 [ − 127 , 127 ] [-127,\ 127] [−127, 127]。原码是人脑最容易理解和计算的表示方式。

2. 反码

反码的表示方法是:正数的反码是其本身;负数的反码是在其原码的基础上,符号位不变,其余各个位取反。

  • 1 +1 +1 的原码 = [ 0000 0001 ] = [0000\ 0001] =[0000 0001],反码 = [ 0000 0001 ] =[0000\ 0001] =[0000 0001]

− 1 -1 −1的原码 = [ 1000 0001 ] = [1000\ 0001] =[1000 0001],反码 = [ 1111 1110 ] = [1111\ 1110] =[1111 1110]

可见如果一个反码表示的是负数,人脑无法直观的看出来它的数值。通常要将其转换成原码再计算。

3. 补码

补码的表示方法是:正数的补码就是其本身;负数的补码是在其原码的基础上,符号位不变,其余各位取反,最后 + 1 +1 +1(也即在反码的基础上 + 1 +1 +1)。

  • 1 +1 +1的原码 = [ 0000 0001 ] = [0000\ 0001] =[0000 0001],反码 = [ 0000 0001 ] = [0000\ 0001] =[0000 0001],补码 = [ 0000 0001 ] = [0000\ 0001] =[0000 0001]

− 1 -1 −1的原码 = [ 1000 0001 ] = [1000\ 0001] =[1000 0001],反码 = [ 1111 1110 ] = [1111\ 1110] =[1111 1110],补码 = [ 1111 1111 ] = [1111\ 1111] =[1111 1111]

对于负数,补码表示方式也是人脑无法直观看出其数值的。通常也需要转换成原码再计算其数值。

1.2.3 为何要使用原码、反码和补码

现在我们知道了计算机可以有三种编码方式表示一个数,对于正数因为三种编码方式的结果都相同:

  • 1 +1 +1的原码 = [ 0000 0001 ] = [0000\ 0001] =[0000 0001],反码 = [ 0000 0001 ] = [0000\ 0001] =[0000 0001],补码 = [ 0000 0001 ] = [0000\ 0001] =[0000 0001]

所以不需要过多解释,但是对于负数:

− 1 -1 −1的原码 = [ 1000 0001 ] = [1000\ 0001] =[1000 0001],反码 = [ 1111 1110 ] = [1111\ 1110] =[1111 1110],补码 = [ 1111 1111 ] = [1111\ 1111] =[1111 1111]

可见原码,反码和补码是完全不同的。既然原码才是被人脑直接识别并用于计算表示方式,为何还会有反码和补码呢?首先, 因为人脑可以知道第一位是符号位,在计算的时候我们会根据符号位,选择对真值区域的加减。但是对于计算机,加减乘除已经是最基础的运算,要设计的尽量简单,计算机辨别"符号位"显然会让计算机的基础电路设计变得十分复杂!于是人们想出了将符号位也参与运算的方法。我们知道,根据运算法则减去一个正数等于加上一个负数,即: 1 − 1 = 1 + ( − 1 ) = 0 1-1 = 1 + (-1) = 0 1−1=1+(−1)=0, 所以机器可以只有加法而没有减法,这样计算机运算的设计就更简单了。于是人们开始探索将符号位参与运算,并且只保留加法的方法。首先来看原码:

计算十进制的表达式: 1 − 1 = 0 1 - 1 = 0 1−1=0

1 − 1 = 1 + ( − 1 ) = [ 0000 0001 ] 1 - 1 = 1 + (-1) = [0000\ 0001] 1−1=1+(−1)=[0000 0001]原 + [ 1000 0001 ] + [1000\ 0001] +[1000 0001]原 = [ 1000 0010 ] = [1000\ 0010] =[1000 0010]原 = − 2 = -2 =−2

如果用原码表示,让符号位也参与计算,显然对于减法来说,结果是不正确的。这也就是为何计算机内部不使用原码表示一个数。

为了解决原码做减法的问题, 出现了反码:

计算十进制的表达式: 1 − 1 = 0 1 - 1 = 0 1−1=0

1 − 1 = 1 + ( − 1 ) = [ 0000 0001 ] 1 - 1 = 1 + (-1) = [0000\ 0001] 1−1=1+(−1)=[0000 0001]原 + [ 1000 0001 ] + [1000\ 0001] +[1000 0001]原 = [ 0000 0001 ] = [0000\ 0001] =[0000 0001]反 + [ 1111 1110 ] + [1111\ 1110] +[1111 1110]反 = [ 1111 1111 ] = [1111\ 1111] =[1111 1111]反 = [ 1000 0000 ] = [1000\ 0000] =[1000 0000]原 = − 0 = -0 =−0

发现用反码计算减法,结果的真值部分是正确的。而唯一的问题其实就出现在"0"这个特殊的数值上,虽然人们理解上**+0和-0**是一样的,但是 0 0 0带符号是没有任何意义的,而且会有[0000 0000]原[1000 0000]原两个编码表示 0 0 0。

于是补码的出现,解决了0的符号问题以及0的两个编码问题:

1 − 1 = 1 + ( − 1 ) = [ 0000 0001 ] 1-1 = 1 + (-1) = [0000\ 0001] 1−1=1+(−1)=[0000 0001]原 + [ 1000 0001 ] + [1000\ 0001] +[1000 0001]原 = [ 0000 0001 ] = [0000\ 0001] =[0000 0001]补 + [ 1111 1111 ] + [1111\ 1111] +[1111 1111]补 = [ 10000 0000 ] = [1 0000\ 0000] =[10000 0000]补 = [ 0000 0000 ] =[0000\ 0000] =[0000 0000]补 = [ 0000 0000 ] =[0000\ 0000] =[0000 0000]原

注意:进位 1 1 1不在计算机字长里。

这样0用[0000 0000]表示,而以前出现问题的-0则不存在了。而且可以用[1000 0000]表示-128:-128的由来如下:

( − 1 ) + ( − 127 ) = [ 1000 0001 ] (-1) + (-127) = [1000\ 0001] (−1)+(−127)=[1000 0001]原 + [ 1111 1111 ] + [1111\ 1111] +[1111 1111]原 = [ 1111 1111 ] = [1111\ 1111] =[1111 1111]补 + [ 1000 0001 ] + [1000\ 0001] +[1000 0001]补 = [ 1000 0000 ] = [1000\ 0000] =[1000 0000]补

− 1 − 127 -1-127 −1−127的结果应该是 − 128 -128 −128,在用补码运算的结果中,[1000 0000]补就是 − 128 -128 −128,但是注意因为实际上是使用以前的 − 0 -0 −0的补码来表示 − 128 -128 −128,所以 − 128 -128 −128并没有原码和反码表示。(对 − 128 -128 −128的补码表示[1000 0000]补,算出来的原码是[0000 0000]原,这是不正确的)。

使用补码,不仅仅修复了 0 0 0的符号以及存在两个编码的问题,而且还能够多表示一个最低数。这就是为什么 8 8 8位二进制,使用原码或反码表示的范围为 [ − 127 , + 127 ] [-127, +127] [−127,+127],而使用补码表示的范围为 [ − 128 , 127 ] [-128, 127] [−128,127]。因为机器使用补码,所以对于编程中常用到的有符号的 32 32 32位 i n t int int类型,可以表示范围是: [ − 2 31 , 2 31 − 1 ] [-2^{31}, 2^{31}-1] [−231,231−1]。因为第一位表示的是符号位,而使用补码表示时又可以多保存一个最小值。

1.2.4 补码计算原理

计算机巧妙地把符号位参与运算,并且将减法变成了加法,背后蕴含了怎样的数学原理呢?将钟表想象成是一个 1 1 1位的 12 12 12进制数。如果当前时间是 6 6 6点,我希望将时间设置成 4 4 4点,需要怎么做呢?我们可以:

  1. 往回拨 2 2 2个小时: 6 − 2 = 4 6 - 2 = 4 6−2=4
  2. 往前拨 10 10 10个小时: ( 6 + 10 ) m o d 12 = 4 (6 + 10)\ mod\ 12 = 4 (6+10) mod 12=4
  3. 往前拨 10 + 12 = 22 10+12=22 10+12=22个小时: ( 6 + 22 ) m o d 12 = 4 (6+22)\ mod\ 12 =4 (6+22) mod 12=4

上面2、3方法中的mod是指取模操作, 16 m o d 12 = 4 16\ mod\ 12=4 16 mod 12=4,即用 16 16 16除以 12 12 12后的余数是 4 4 4。所以钟表往回拨(减法)的结果可以用往前拨(加法)替代!现在的焦点就落在了如何用一个正数,来替代一个负数呢?上面的例子我们能感觉出来一些端倪,发现一些规律。但是数学是严谨的,不能靠感觉。首先介绍一个数学中相关的概念:同余。同余的概念 :两个整数 a a a, b b b,若它们除以整数 m m m所得的余数相等,则称 a a a, b b b对于模 m m m同余,记作 a ≡ b ( m o d m ) a \equiv b\ (\ mod\ m) a≡b ( mod m),读作 a a a与 b b b关于模 m m m同余。

举例说明:

  • 4 m o d 12 = 4 4\ mod\ 12\ =\ 4 4 mod 12 = 4

  • 16 m o d 12 = 4 16\ mod\ 12\ =\ 4 16 mod 12 = 4

  • 28 m o d 12 = 4 28\ mod\ 12\ =\ 4 28 mod 12 = 4

所以 4 4 4, 16 16 16, 28 28 28对于模 12 12 12同余。

负数取模

正数进行mod运算是很简单的,但是负数呢?下面是关于mod运算的数学定义:

x m o d y = x − y ⌊ x y ⌋ , f o r y ≠ 0 x\ mod\ y=\ x-y\lfloor {\frac{x}{y}} \rfloor,\qquad for\qquad y\neq0 x mod y= x−y⌊yx⌋,fory=0

上面公式的意思是:

x mod y 等于 x 减去 y 乘上 x 与 y 的商的下界。

举例如下:

− 3 m o d 2 = − 3 − 2 ⌊ − 3 2 ⌋ = − 3 − 2 × ⌊ − 1.5 ⌋ = − 3 − 2 × ( − 2 ) = − 3 + 4 = 1 -3\ mod\ 2=-3-2\lfloor\frac{-3}{2}\rfloor=-3-2\times\lfloor-1.5\rfloor=-3-2\times(-2)=-3+4=1 −3 mod 2=−3−2⌊2−3⌋=−3−2×⌊−1.5⌋=−3−2×(−2)=−3+4=1

− 2 m o d 12 = − 2 − 12 ⌊ − 2 12 ⌋ = − 2 − 12 × ( − 1 ) = 10 -2\ mod\ 12 =-2-12\lfloor\frac{-2}{12}\rfloor=-2-12\times(-1)=10 −2 mod 12=−2−12⌊12−2⌋=−2−12×(−1)=10

− 4 m o d 12 = − 4 − 12 ⌊ − 4 12 ⌋ = − 4 − 12 × ( − 1 ) = 8   -4\ mod\ 12=-4-12\lfloor\frac{-4}{12}\rfloor = -4-12\times(-1)=8  −4 mod 12=−4−12⌊12−4⌋=−4−12×(−1)=8

− 5 m o d 12 = − 5 − 12 ⌊ − 5 12 ⌋ = − 5 − 12 × ( − 1 ) = 7   -5\ mod\ 12 = -5-12\lfloor\frac{-5}{12}\rfloor=-5-12\times(-1)=7  −5 mod 12=−5−12⌊12−5⌋=−5−12×(−1)=7

开始证明

再回到时钟的问题上,根据前面介绍,我们知道:

  • 回拨2小时 = 前拨10小时

  • 回拨4小时 = 前拨8小时

  • 回拨5小时= 前拨7小时

结合上面学到的同余的概念,实际上:

− 2 m o d 12 = 10 m o d 12 = 10 -2\ mod\ 12=10\ mod\ 12=10 −2 mod 12=10 mod 12=10, − 2 -2 −2与 10 10 10​是同余的,

− 4 m o d 12 = 8 m o d 12 = 8 -4\ mod\ 12 =8\ mod\ 12 = 8 −4 mod 12=8 mod 12=8, − 4 -4 −4与 8 8 8是同余的。

距离成功越来越近了。要实现用正数替代负数,只需要运用同余数的两个定理:

  1. 反身性: a ≡ a ( m o d m ) a \equiv a\ (\ mod\ m) a≡a ( mod m)

  2. 线性运算定理 :如果 a ≡ b ( m o d m ) a \equiv b\ (\ mod\ m) a≡b ( mod m), c ≡ d ( m o d m ) c ≡ d\ (\ mod\ m) c≡d ( mod m)那么: a ± c ≡ b ± d ( m o d m ) a \pm c \equiv b \pm d\ (\ mod\ m) a±c≡b±d ( mod m) 以及 a × c ≡ b × d ( m o d m ) a \times c \equiv b \times d\ (\ mod\ m) a×c≡b×d ( mod m)

所以:

7 ≡ 7 ( m o d 12 ) 7\equiv7\ (\ mod\ 12) 7≡7 ( mod 12)

− 2 ≡ 10 ( m o d 12 ) -2\equiv10\ (\ mod\ 12) −2≡10 ( mod 12)

7 − 2 ≡ 7 + 10 ( m o d 12 ) 7-2 \equiv 7 + 10 (\ mod\ 12) 7−2≡7+10( mod 12)

5 ≡ 17 ( m o d 12 ) 5 \equiv 17 (\ mod\ 12) 5≡17( mod 12)

现在我们为一个负数,找到了它的正数同余数。但是并不是 7 − 2 = 7 + 10 7-2 = 7+10 7−2=7+10,而是 7 − 2 ≡ 7 + 10 ( m o d 12 ) 7-2 \equiv 7 + 10\ (\ mod\ 12) 7−2≡7+10 ( mod 12),即计算结果的余数相等。

接下来回到二进制的问题上,看一下: 2 − 1 = 1 2-1=1 2−1=1的问题。

2 − 1 = 2 + ( − 1 ) = [ 0000 0010 ] 2-1=2+(-1) = [0000\ 0010] 2−1=2+(−1)=[0000 0010]原 + [ 1000 0001 ] + [1000\ 0001] +[1000 0001]原 = [ 0000 0010 ] = [0000\ 0010] =[0000 0010]反 + [ 1111 1110 ] + [1111\ 1110] +[1111 1110]反

先到这一步, − 1 -1 −1的反码表示是 1111 1110 1111\ 1110 1111 1110。如果这里将 [ 1111 1110 ] [1111\ 1110] [1111 1110]认为是原码,则 [ 1111 1110 ] [1111\ 1110] [1111 1110]原 = − 126 = -126 =−126,这里将符号位除去,即认为是 126 126 126。

发现有如下规律:

( − 1 ) m o d 127 = 126 (-1)\ mod\ 127\ =\ 126 (−1) mod 127 = 126

126 m o d 127 = 126 126\ mod\ 127 = 126 126 mod 127=126

即:

2 ≡ 2 ( m o d 127 ) 2 \equiv 2\ (\ mod\ 127) 2≡2 ( mod 127)

( − 1 ) ≡ 126 ( m o d 127 ) (-1) ≡ 126\ (\ mod\ 127) (−1)≡126 ( mod 127)

2 − 1 ≡ 2 + 126 ( m o d 127 ) 2-1 ≡ 2+126 (\ mod\ 127) 2−1≡2+126( mod 127)

2 − 1 2-1 2−1 与 2 + 126 2+126 2+126的余数结果是相同的!而这个余数,正式我们的期望的计算结果: 2 − 1 = 1 2-1=1 2−1=1

所以说一个数的反码,实际上是这个数对于一个模的同余数。而这个模并不是我们的二进制,而是所能表示的最大值!这就和钟表一样,转了一圈后总能找到在可表示范围内的一个正确的数值!而 2 + 126 2+126 2+126很显然相当于钟表转过了一轮,而因为符号位是参与计算的,正好和溢出的最高位形成正确的运算结果。

既然反码可以将减法变成加法,那么现在计算机使用的补码呢?为什么在反码的基础上加 1 1 1,还能得到正确的结果?

2 − 1 = 2 + ( − 1 ) = [ 00000010 ] 2-1=2+(-1) = [0000 0010] 2−1=2+(−1)=[00000010]原 + [ 10000001 ] + [1000 0001] +[10000001]原 = [ 00000010 ] = [0000 0010] =[00000010]补 + [ 11111111 ] + [1111 1111] +[11111111]补

如果把 [ 11111111 ] [1111 1111] [11111111]当成原码,去除符号位,则:

[ 01111111 ] [0111 1111] [01111111]原 = 127 = 127 =127

其实,在反码的基础上 + 1 +1 +1,只是相当于增加了模的值:

( − 1 ) m o d 128 = 127 (-1)\ mod\ 128 = 127 (−1) mod 128=127

127 m o d 128 = 127 127\ mod\ 128 = 127 127 mod 128=127

2 ≡ 2 ( m o d 127 ) 2 \equiv 2\ (\ mod\ 127) 2≡2 ( mod 127)

2 − 1 ≡ 2 + 127 ( m o d 128 ) 2-1\equiv 2+127\ (\ mod\ 128) 2−1≡2+127 ( mod 128)

此时,表盘相当于每 128 128 128个刻度转一轮。所以用补码表示的运算结果最小值和最大值应该是 [ − 128 , 128 ] [-128, 128] [−128,128]。但是由于 0 0 0的特殊情况,没有办法表示 128 128 128,所以补码的取值范围是 [ − 128 , 127 ] [-128, 127] [−128,127]。

1.3 小数的二进制表示

前面在计算 0.1 × 0.1 0.1\times0.1 0.1×0.1时,我们得到的数据并不准确,实际上,在小数计算上计算机总是不怎么准确的。

2.3.1 小数为什么会出错

实际上,不是运算本身会出错,而是计算机根本就不能精确地表示很多数,比如 0.1 0.1 0.1这个数。计算机是用一种二进制格式存储小数的,这个二进制格式不能精确表示 0.1 0.1 0.1,它只能表示一个非常接近 0.1 0.1 0.1但又不等于 0.1 0.1 0.1的一个数。数字都不能精确表示,在不精确数字上的运算结果不精确也就不足为奇了。

0.1 0.1 0.1怎么就不能精确表示呢?在十进制的世界里是可以的,但在二进制的世界里不行。在说二进制之前,我们先来看下熟悉的十进制。实际上,十进制也只能表示那些可以表述为 10 10 10的多少次方和的数,比如 12.345 12.345 12.345,实际上表示的是 1 × 10 + 2 × 1 + 3 × 0.1 + 4 × 0.01 + 5 × 0.001 1× 10+2× 1+3× 0.1+4× 0.01+5× 0.001 1×10+2×1+3×0.1+4×0.01+5×0.001,与整数的表示类似,小数点后面的每个位置也都有一个位权,从左到右,依次为 0.1 0.1 0.1、 0.01 0.01 0.01、 0.001 0.001 0.001 ... ... ...即 1 0 − 1 , 1 0 − 2 , 1 0 − 3 10^{-1},10^{-2},10^{-3} 10−1,10−2,10−3等。很多数十进制也是不能精确表示的,比如 1 3 \frac{1}{3} 31,保留三位小数的话,十进制表示是 0.333 0.333 0.333,但无论后面保留多少位小数,都是不精确的,用 0.333 0.333 0.333进行运算,比如乘以 3 3 3,期望结果是 1 1 1,但实际上却是 0.999 0.999 0.999。二进制是类似的,但二进制只能表示那些可以表述为 2 2 2的多少次方和的数。来看下 2 2 2的次方的一些例子。

二进制 十进制
2 − 1 2^{-1} 2−1 0.5 0.5 0.5
2 − 2 2^{-2} 2−2 0.25
2 − 3 2^{-3} 2−3 0.125
2 − 4 2^{-4} 2−4 0.0625

所以,二进制可以精确表示为 2 2 2的某次方之和的数可以精确表示,其他数则不能精确表示。如果编写程序进行试验,会发现有的计算结果是准确的。比如:

java 复制代码
System.out.println(0.1f+0.1f);
System.out.println(0.1f*0.1f);

Java计算第一行输出 0.2 0.2 0.2,第二行输出 0.010000001 0.010000001 0.010000001。按照上面的说法,第一行的结果应该也不对。其实,这只是 J a v a Java Java语言给我们造成的假象,计算结果其实也是不精确的,但是由于结果和 0.2 0.2 0.2足够接近,在输出的时候, J a v a Java Java选择了输出 0.2 0.2 0.2这个看上去非常精简的数字,而不是一个中间有很多 0 0 0的小数。在误差足够小的时候,结果看上去是精确的,但不精确其实才是常态。

计算不精确时,怎么办呢?大部分情况下,我们不需要那么高的精度,可以四舍五入,或者在输出的时候只保留固定个数的小数位。如果真的需要比较高的精度,一种方法是将小数转化为整数进行运算,运算结束后再转化为小数;另一种方法是使用十进制的数据类型,这个并没有统一的规范。在Java中是BigDecimal,运算更准确,但效率比较低。

2.3.2 二进制表示

二进制中为表示小数,也采用类似十进制的科学表示法,形如 m × ( 2 e ) m\times(2^e) m×(2e)。 m m m称为尾数, e e e称为指数。指数可以为正,也可以为负,负的指数表示那些接近 0 0 0的比较小的数。在二进制中,单独表示尾数部分和指数部分,另外还有一个符号位表示正负。几乎所有的硬件和编程语言表示小数的二进制格式都是一样的。这种格式是一个标准,叫做IEEE 754标准,它定义了两种格式:一种是 32 32 32位的,对应于Javafloat;另一种是 64 64 64位的,对应于Javadouble。 32 32 32位格式中, 1 1 1位表示符号, 23 23 23位表示尾数, 8 8 8位表示指数。 64 64 64位格式中, 1 1 1位表示符号, 52 52 52位表示尾数, 11 11 11位表示指数。在两种格式中,除了表示正常的数,标准还规定了一些特殊的二进制形式表示一些特殊的值,比如负无穷、正无穷、 0 0 0、 N a N NaN NaN(非数值,比如 0 0 0​​乘以无穷大)。

IEEE 754标准中,存储有三部分:sign数符(一位符号位)、exponent阶码(含阶符)(指数)、fraction尾数。

数符 阶码(含阶符) 尾数
sign exponent fraction

例如: 178.125 178.125 178.125转换为 32 32 32位二进制表示

  1. 先把浮点数分别把整数部分和小数部分转换成二进制:

    1. 整数部分用除 2 2 2取余的方法,求得: 10110010 10110010 10110010;
    2. 小数部分用乘 2 2 2取整的方法,求得:001;
    3. 合起来即是: 10110010.001 10110010.001 10110010.001;
    4. 转换成二进制的浮点数,即把小数点移动到整数位只有 1 1 1,即为: 1.0110010001 × 2 111 1.0110010001 \times 2^{111} 1.0110010001×2111, 111 111 111是二进制,由于左移了 7 7 7位,所以是 111 111 111;
  2. 把浮点数转换二进制后,这里基本已经可以得出对应 3 3 3部分的值了:

    1. 数符:由于浮点数是正数,故为 0 0 0(负数为 1 1 1);

    2. 阶码 : 阶码的计算公式:阶数 + 偏移量, 阶码是需要作移码运算,在转换出来的二进制数里,阶数是 111 111 111(十进制为 7 7 7),对于单精度的浮点数,偏移值为 01111111 01111111 01111111( 127 127 127)(偏移量的计算是: 2 e − 1 − 1 2^{e-1}-1 2e−1−1, e e e为阶码的位数,即为 8 8 8,因此偏移值是 127 127 127),即: 111 + 01111111 = 10000110 111+01111111=10000110 111+01111111=10000110;

    3. 尾数:小数点后面的数,即 0110010001 0110010001 0110010001;

    4. 最终根据位置填到对位的位置上;

      | 数符 | 阶码(含阶符) |||||||| 尾数 |||||||||||||||||||||||

      sign exponent fraction
      0 1 0 0 0 0 1 1 0 0 1 1 0 0 1 0 0 0 1 0 0 0 0 0 0 0 0 0 0 0 0 0

也许我们会问, 1.0110010001 × 2 111 1.0110010001 \times 2^{111} 1.0110010001×2111中 1.0110010001 1.0110010001 1.0110010001中, 1 1 1在哪里呢?因为这里始终为 1 1 1,所以隐藏,同时也节省了 1 1 1个位出来存储小数,提高精度。

如果想查看浮点数的具体二进制形式,在Java中,可以使用如下代码:

java 复制代码
    @Test
    public void testFloatBinaryRepresentation() {
        assertTrue("1000011001100100010000000000000".equals(Integer.toBinaryString(Float.floatToIntBits(178.125f))));
        assertTrue("100000001100110010001000000000000000000000000000000000000000000"
                .equals(Long.toBinaryString(Double.doubleToLongBits(178.125d))));
    }

1.4 字符的编码和乱码

编码和乱码听起来比较复杂,但其实并不复杂。我们先学习各种编码,然后学习编码转换,分析乱码出现的原因,最后学习如何从乱码中恢复。编码有两大类:一类是非Unicode编码;另一类是Unicode编码。我们先学习非Unicode编码。

1.4.1 常见非Unicode编码

主要的非Unicode编码包括ASCIIISO 8859-1Windows-1252GB2312GBKGB18030Big5

1、ASCII

世界上虽然有各种各样的字符,但计算机发明之初没有考虑那么多,基本上只考虑了美国的需求。美国大概只需要 128 128 128个字符,所以就规定了 128 128 128个字符的二进制表示方法。这个方法是一个标准,称为ASCII编码,全称是American Standard Code for InformationInterchange,即美国信息互换标准代码。 128 128 128个字符用 7 7 7位刚好可以表示,计算机存储的最小单位是byte,即 8 8 8位, A S C I I ASCII ASCII码中最高位设置为 0 0 0,用剩下的 7 7 7位表示字符。这 7 7 7位可以看作数字 0 ~ 127 0~127 0~127, ASCII码规定了从 0 ~ 127 0~127 0~127​的每个数字代表什么含义。

ASCII码对美国是够用了,但对其他国家而言却是不够的,于是,各个国家的各种计算机厂商就发明了各种各种的编码方式以表示自己国家的字符,为了保持与ASCII码的兼容性,一般都是将最高位设置为 1 1 1。也就是说,当最高位为 0 0 0时,表示ASCII码,当为 1 1 1时就是各个国家自己的字符。在这些扩展的编码中,在西欧国家中流行的是ISO 8859-1Windows-1252,在中国是GB2312GBKGB18030Big5

2、 ISO 8859-1

ISO 8859-1又称Latin-1,它也是使用一个字节表示一个字符,其中 0 ~ 127 0~127 0~127与ASCII一样, 128 ~ 255 128~255 128~255规定了不同的含义。在 128 ~ 255 128~255 128~255中, 128 ~ 159 128~159 128~159表示一些控制字符, 160 ~ 255 160~255 160~255表示一些西欧字符。`

3、Windows-1252

ISO 8859-1虽然号称是标准,用于西欧国家,但它连欧元(€)这个符号都没有,因为欧元比较晚,而标准比较早。实际中使用更为广泛的是Windows-1252编码,这个编码与ISO 8859-1基本是一样的,区别只在于数字 128 ~ 159 128~159 128~159​​,区别于ISO8859-1的部分这个编码中加入了欧元符号以及一些其他常用的字符。基本上可以认为,ISO 8859-1已被Windows-1252取代,在很多应用程序中,即使文件声明它采用的是ISO 8859-1编码,解析的时候依然被当作Windows-1252编码。HTML5甚至明确规定,如果文件声明的是ISO 8859-1编码,它应该被看作Win-dows-1252编码。为什么要这样呢?因为大部分人搞不清楚ISO 8859-1Windows-1252的区别,当他说ISO 8859-1的时候,其实他指的是Windows-1252,所以标准干脆就这么强制规定了。

4、GB2312

美国和西欧字符用一个字节就够了,但中文显然是不够的。中文第一个标准是GB2312GB2312标准主要针对的是简体中文常见字符,包括约 7000 7000 7000个汉字和一些罕用词和繁体字。GB2312固定使用两个字节表示汉字,在这两个字节中,最高位都是 1 1 1,如果是 0 0 0,就认为是ASCII字符。在这两个字节中,其中高位字节范围是0xA1~0xF7,低位字节范围是0xA1~0xFE

5、GBK

建立在GB2312的基础上,向下兼容GB2312,也就是说,GB2312编码的字符和二进制表示,在GBK编码里是完全一样的。GBK增加了 14000 14 000 14000多个汉字,共计约 21000 21 000 21000个汉字,其中包括繁体字。GBK同样使用固定的两个字节表示,其中高位字节范围是0x81~0xFE,低位字节范围是0x40~0x7E0x80~0xFE。需要注意的是,低位字节是从0x40(也就是 64 64 64)开始的,也就是说,低位字节的最高位可能为 0 0 0。那怎么知道它是汉字的一部分,还是一个ASCII字符呢?其实很简单,因为汉字是用固定两个字节表示的,在解析二进制流的时候,如果第一个字节的最高位为 1 1 1​,那么就将下一个字节读进来一起解析为一个汉字,而不用考虑它的最高位,解析完后,跳到第三个字节继续解析。

6、GB18030

GB18030向下兼容GBK,增加了 55000 55000 55000多个字符,共 76000 76000 76000多个字符,包括了很多少数民族字符,以及中日韩统一字符。用两个字节已经表示不了GB18030中的所有字符,GB18030使用变长编码,有的字符是两个字节,有的是四个字节。在两字节编码中,字节表示范围与GBK一样。在四字节编码中,第一个字节的值为0x81~0xFE,第二个字节的值为0x30~0x39,第三个字节的值为0x81~0xFE,第四个字节的值为0x30~0x39。解析二进制时,如何知道是两个字节还是四个字节表示一个字符呢?看第二个字节的范围,如果是0x30~0x39就是 4 4 4个字节表示,因为两个字节编码中第二个字节都比这个大。

7、Big5

Big5是针对繁体中文的,广泛用于我国台湾地区和我国香港特别行政区等地。Big5包括 13000 13000 13000多个繁体字,和 G B 2312 GB2312 GB2312类似,一个字符同样固定使用两个字节表示。在这两个字节中,高位字节范围是0x81~0xFE,低位字节范围是0x40~0x7E0xA1~0xFE

8、编码汇总

我们简单汇总一下前面的内容。ASCII码是基础,使用一个字节表示,最高位设为 0 0 0,其他 7 7 7位表示 128 128 128个字符。其他编码都是兼容ASCII的,最高位使用 1 1 1来进行区分。西欧主要使用Windows-1252,使用一个字节,增加了额外 128 128 128个字符。我国内地的三个主要编码GB2312GBKGB18030有时间先后关系,表示的字符数越来越多,且后面的兼容前面的,GB2312GBK都是用两个字节表示,而GB18030则使用两个或四个字节表示。我国香港特别行政区和我国台湾地区的主要编码是Big5。如果文本里的字符都是ASCII码字符,那么采用以上所说的任一编码方式都是一样的。

1.4.2 Unicode编码

以上我们介绍了中文和西欧的字符与编码,但世界上还有很多其他国家的字符,每个国家的各种计算机厂商都对自己常用的字符进行编码,在编码的时候基本忽略了其他国家的字符和编码,甚至忽略了同一国家的其他计算机厂商,这样造成的结果就是,出现了太多的编码,且互相不兼容。世界上所有的字符能不能统一编码呢?可以,这就是UnicodeUnicode做了一件事,就是给世界上所有字符都分配了一个唯一的数字编号,这个编号范围从0x000000~0x10FFFF,包括 110 110 110多万。但大部分常用字符都在0x0000~0xFFFF之间,即 65536 65536 65536个数字之内。每个字符都有一个Unicode编号,这个编号一般写成十六进制,在前面加U+。大部分中文的编号范围为U+4E00~U+9FFF

简单理解,Unicode主要做了这么一件事,就是给所有字符分配了唯一数字编号,但并没有规定这个编号怎么对应到二进制表示。这是与上面介绍的其他编码不同的,其他编码都既规定了能表示哪些字符,又规定了每个字符对应的二进制是什么,而Unicode本身只规定了每个字符的数字编号是多少。

那编号怎么对应到二进制表示呢?有多种方案,主要有UTF-32UTF-16UTF-8

1、UTF-32

这个最简单,就是所有字符编号的整数二进制形式都采用 4 4 4个字节。但有个细节,就是字节的排列顺序,如果第一个字节是整数二进制中的最高位,最后一个字节是整数二进制中的最低位,那这种字节序就叫"大端"(Big Endian,BE),否则,就叫"小端"(Little Endian, LE)。对应的编码方式分别是UTF-32BEUTF-32LE。可以看出,每个字符都用 4 4 4个字节表示,非常浪费空间,实际采用的也比较少。

2、UTF-16

UTF-16使用变长字节表示:

  1. 对于编号在U+0000~U+FFFF的字符(常用字符集),直接用两个字节表示。需要说明的是,U+D800~U+DBFF的编号其实是没有定义的。
  2. 字符值在U+10000~U+10FFFF的字符(也叫做增补字符集),需要用 4 4 4个字节表示。前两个字节叫高代理项,范围是U+D800~U+DBFF,后两个字节叫低代理项,范围是U+DC00~U+DFFF。数字编号和这个二进制表示之间有一个转换算法,在此不详细介绍。

区分是两个字节还是 4 4 4个字节表示一个字符就看前两个字节的编号范围,如果是U+D800~U+DBFF,就是 4 4 4​个字节,否则就是两个字节。UTF-16也有和UTF-32一样的字节序问题,如果高位存放在前面就叫大端(BE),编码就叫UTF-16BE,否则就叫小端,编码就叫UTF-16LEUTF-16常用于系统内部编码,UTF-16UTF-32节省了很多空间,但是任何一个字符都至少需要两个字节表示,对于美国和西欧国家而言,还是很浪费的。

3、UTF-8

UTF-8使用变长字节表示,每个字符使用的字节个数与其Unicode编号的大小有关,编号小的使用的字节就少,编号大的使用的字节就多,使用的字节个数为 1 ~ 4 1~4 1~4不等。具体来说,各个Unicode编号范围对应的二进制格式如下表所示。

编号范围 二进制格式
0x00~0x7F(0~127) 0xxxxxxx
0x80~0x7FF(128~2047) 110xxxxx 10xxxxxx
0x800~0xFFFF(2048~65535) 1110xxxx 10xxxxxx 10xxxxxx
0x10000~0x10FFFF(65536 以上) 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx 10xxxxxx

小于 128 128 128的,编码与ASCII码一样,最高位为 0 0 0。其他编号的第一个字节有特殊含义,最高位有几个连续的 1 1 1就表示用几个字节表示,而其他字节都以 10 10 10开头。对于一个Unicode编号,具体怎么编码呢?首先将其看作整数,转化为二进制形式(去掉高位的 0 0 0),然后将二进制位从右向左依次填入对应的二进制格式x中,填完后,如果对应的二进制格式还有没填的x,则设为 0 0 0。比如,"马"的Unicode编号是0x9A6C,整数编号是39532,其对应的UTF-8二进制格式是:1001 101001 101100。将这个二进制位从右到左依次填入二进制格式中,结果就是其UTF-8编码:11101001 10101001 10101100,十六进制表示为:0xE9A9AC

UTF-32/UTF-16不同,UTF-8是兼容ASCII的,对大部分中文而言,一个中文字符需要用三个字节表示。

4、Unicode编码小结

Unicode给世界上所有字符都规定了一个统一的编号,编号范围达到 110 110 110多万,但大部分字符都在 65536 65536 65536以内。Unicode本身没有规定怎么把这个编号对应到二进制形式。UTF-32/UTF-16/UTF-8都在做一件事,就是把Unicode编号对应到二进制形式,其对应方法不同而已。UTF-32使用 4 4 4个字节,UTF-16大部分是两个字节,少部分是 4 4 4个字节,它们都不兼容ASCII编码,都有字节顺序的问题。UTF-8使用1~4个字节表示,兼容ASCII编码,英文字符使用 1 1 1个字节,中文字符大多用 3 3 3​个字节。

1.4.3 编码转换

有了Unicode之后,每一个字符就有了多种不兼容的编码方式,比如说"马"这个字符,它的各种编码方式对应的十六进制如下表所示。

编码方式 十六进制编码
GB18030 C2 ED
Unicode编号 9A 6C
UTF-8 E9 A9 AC
UTF-16LE 6C 9A

这几种格式之间可以借助Unicode编号进行编码转换。可以认为:每种编码都有一个映射表,存储其特有的字符编码和Unicode编号之间的对应关系,这个映射表是一个简化的说法,实际上可能是一个映射或转换方法。编码转换的具体过程可以是:一个字符从A编码转到B编码:

  1. 先找到字符的A编码格式,通过A的映射表找到其Unicode编号;
  2. 然后通过Unicode编号再查B的映射表,找到字符的B编码格式。

举例来说,"马"从GB18030转到UTF-8,先查GB18030->Unicode编号表,得到其编号是9A 6C,然后查Uncode编号->UTF-8编号表,得到其UTF-8编码:E9 A9AC。编码转换改变了字符的二进制内容,但并没有改变字符看上去的样子。

1.4.4 乱码原因

乱码有两种常见原因:一种比较简单,就是简单的解析错误;另外一种比较复杂,在错误解析的基础上进行了编码转换。

1、解析错误

比如一个Windows-1252编码的文件使用GB18030打开,这时,看到的可能的就是乱码。这种情况下,之所以看起来是乱码,是因为看待或者说解析数据的方式错了。只要使用正确的编码方式进行解读就可以纠正了。切换查看编码的方式并没有改变数据的二进制本身,而只是改变了解析数据的方式,从而改变了数据看起来的样子,这与前面提到的编码转换正好相反。很多时候,做这样一个编码查看方式的切换就可以解决乱码的问题,但有的时候这样是不够的。

2、错误的解析和编码转换

如果怎么改变查看方式都不对,那很有可能就不仅仅是解析二进制的方式不对,而是文本在错误解析的基础上还进行了编码转换。这种情况其实很常见,比如,计算机程序为了便于统一处理,经常会将所有编码转换为一种方式,比如UTF-8,在转换的时候,需要知道原来的编码是什么,但可能会搞错原来的编码,而一旦搞错并进行了转换,就会出现这种乱码。这种情况下,无论怎么切换查看编码方式都是不行的。

1.4.5 从乱码中恢复

"乱"主要是因为发生了一次错误的编码转换,所谓恢复,是指要恢复两个关键信息:一个是原来的二进制编码方式A;另一个是错误解读的编码方式B。恢复的基本思路是尝试进行逆向操作,假定按一种编码转换方式B获取乱码的二进制格式,然后再假定一种编码解读方式A解读这个二进制,查看其看上去的形式,这要尝试多种编码,如果能找到看着正常的字符形式,应该就可以恢复。

Java中处理字符串的类有StringString中有我们需要的两个重要方法:

  1. public byte[] getBytes(String charsetName),这个方法可以获取一个字符串的给定编码格式的二进制形式。

  2. public String(byte bytes[], String charsetName),这个构造方法以给定的二进制数组bytes按照编码格式charsetName解读为一个字符串。

java 复制代码
    public static void recover(String str) throws UnsupportedEncodingException {
        String[] charsets = new String[] {
                "windows-1252", "GB18030", "Big5", "UTF-8" };
        for (int i = 0; i < charsets.length; i++) {
            for (int j = 0; j < charsets.length; j++) {
                if (i != j) {
                    String s = new String(str.getBytes(charsets[i]), charsets[j]);
                    System.out.println("---- 原来编码(A)假设是: "
                            + charsets[j] + ", 被错误解读为了(B): " + charsets[i]);
                    System.out.println(s);
                    System.out.println();
                }
            }
        }
    }

    @Test
    public void testEncodeRecover() throws UnsupportedEncodingException {
        String originString = "认真学习Java";
        String newStr = new String(originString.getBytes("UTF-8"), "GB18030");
        recover(newStr);
    }

以上代码使用不同的编码格式进行测试,如果输出有正确的,那么就可以恢复。可以看出,恢复的尝试需要进行很多次,上面例子尝试了常见编码GB18030Windows 1252Big5UTF-8共 12 12 12种组合。这 4 4 4​种编码是常见编码,在大部分实际应用中应该够了。如果有其他编码,可以增加一些尝试。不是所有的乱码形式都是可以恢复的,如果形式中有很多不能识别的字符(如),则很难恢复。另外,如果乱码是由于进行了多次解析和转换错误造成的,也很难恢复。

1.5 Java 中的char

char用于表示一个字符,这个字符可以是中文字符,也可以是英文字符。赋值时把常量字符用单引号括起来。但为什么字符类型也可以进行算术运算和比较呢?它的本质到底是什么呢?

Java内部进行字符处理时,采用的都是Unicode,具体编码格式是UTF-16BE。我们知道,UTF-16使用两个或四个字节表示一个字符,Unicode编号范围在 65536 65536 65536以内的占两个字节,超出范围的占 4 4 4个字节,BE就是先输出高位字节,再输出低位字节,这与整数的内存表示是一致的。char本质上是一个固定占用两个字节的无符号正整数,这个正整数对应于Unicode编号,用于表示那个Unicode编号对应的字符。由于固定占用两个字节,char只能表示Unicode编号在 65536 65 536 65536​以内的字符,而不能表示超出范围的字符。那超出范围的字符怎么表示呢?使用两个char。类CharacterString有一些相关的方法,后面再学习。在这个认识的基础上,我们再来看下char的一些行为。char有多种赋值方式:

java 复制代码
char c = 'A'
char c = '马'
char c = 39532
char c = 0x9a6c
char c = '\u9a6c'

第1种赋值方式是最常见的,将一个能用ASCII码表示的字符赋给一个字符变量。第2种赋值方式也很常见,但这里是个中文字符,需要注意的是,直接写字符常量的时候应该注意文件的编码。"马"对应的Unicode编号是 39532 39 532 39532,所以第2种赋值方式和第3种赋值方式是一样的。第3种赋值方式是直接将十进制的常量赋给字符。第4种赋值方式是将十六进制常量赋给字符,第5种赋值方式是按Unicode字符形式。所以,第2、3、4、5种赋值方式都是一样的,本质都是将Unicode编号39 532赋给了字符。

由于char本质上是一个整数,所以可以进行整数能做的一些运算,在进行运算时会被看作int,但由于char占两个字节,运算结果不能直接赋值给char类型,需要进行强制类型转换,这和byteshort参与整数运算是类似的。char类型的比较就是其Unicode编号的比较。同样可以用Integer的方法查看char的二进制表示。


  1. [1]马俊昌.Java编程的逻辑[M].北京:机械工业出版社,2018. ↩︎

  2. [2]尚硅谷教育.剑指Java:核心原理与应用实践[M].北京:电子工业出版社,2023. ↩︎

  3. 原码、反码、补码 - 知乎 (zhihu.com) ↩︎

相关推荐
Am心若依旧4095 分钟前
[c++11(二)]Lambda表达式和Function包装器及bind函数
开发语言·c++
明月看潮生8 分钟前
青少年编程与数学 02-004 Go语言Web编程 20课题、单元测试
开发语言·青少年编程·单元测试·编程与数学·goweb
Yan.love14 分钟前
开发场景中Java 集合的最佳选择
java·数据结构·链表
椰椰椰耶17 分钟前
【文档搜索引擎】搜索模块的完整实现
java·搜索引擎
大G哥17 分钟前
java提高正则处理效率
java·开发语言
VBA633727 分钟前
VBA技术资料MF243:利用第三方软件复制PDF数据到EXCEL
开发语言
轩辰~29 分钟前
网络协议入门
linux·服务器·开发语言·网络·arm开发·c++·网络协议
小_太_阳38 分钟前
Scala_【1】概述
开发语言·后端·scala·intellij-idea
向宇it39 分钟前
【从零开始入门unity游戏开发之——unity篇02】unity6基础入门——软件下载安装、Unity Hub配置、安装unity编辑器、许可证管理
开发语言·unity·c#·编辑器·游戏引擎
智慧老师1 小时前
Spring基础分析13-Spring Security框架
java·后端·spring