9.16
Java 语法基础 · 动手动脑与实验性问题整理
课程:Java 语言程序设计 教师:王建民
班级:信2505-2 学号:20253897 姓名:贺朝盼
教材/课件:第一讲《JAVA 语法基础》(共 78 页)
说明:本文整理课件中全部"动手动脑"与"课后实验性"问题,共 6 处。
每个问题的示例程序都已在本机(JDK 17)实际编译运行,文中输出的数据均为真实运行结果。
目录
| 序号 | 出处 | 类型 | 主题 |
|---|---|---|---|
| 1 | 第 43 页 | 动手动脑 | 枚举类型 EnumTest.java |
| 2 | 第 50 页 | 动脑动手 | 原码、反码、补码与位运算 |
| 3 | 第 56 页 | 动脑动手 | 同名变量的屏蔽原则(作用域) |
| 4 | 第 60 页 | 动手动脑 | 类型转换图与各类型位数、范围 |
| 5 | 第 62 页 | 动手实验 | TestDouble.java 与浮点精度 |
| 6 | 第 68 页 | 动手动脑 | 字符串拼接中的 + |
问题 1:仔细阅读示例 EnumTest.java,运行它,分析运行结果?你能得到什么结论?你掌握了枚举类型的基本用法了吗?
课件第 43 页
1.1 实验代码
enum Season { SPRING, SUMMER, AUTUMN, WINTER }
public class EnumTest {
public static void main(String[] args) {
Season a = Season.SPRING;
Season b = Season.SPRING;
System.out.println(Season.SPRING); // 直接输出
System.out.println(a == b); // 用 == 比较
System.out.println(a.equals(b)); // 用 equals 比较
System.out.println(System.identityHashCode(a)); // 身份哈希码
System.out.println(System.identityHashCode(b));
System.out.println(Season.SPRING.getClass().getSuperclass());
}
}
1.2 运行结果
Season.SPRING = SPRING
a == b -> true
a.equals(b) -> true
System.identityHashCode(a) = 2065530879
System.identityHashCode(b) = 2065530879
System.identityHashCode(Season.SPRING) = 2065530879
a == Season.SUMMER -> false
Season.SPRING.getClass().getName() = Season
Season.SPRING.getClass().getSuperclass()= class java.lang.Enum
values() 遍历输出:SPRING(序号0) SUMMER(序号1) AUTUMN(序号2) WINTER(序号3)
valueOf("AUTUMN") -> AUTUMN
valueOf("AUTUMN") == AUTUMN -> true
1.3 分析
第一个观察点:输出的是 SPRING 而不是一串地址。
如果是普通的对象,println 一个对象会打印出形如 Season@1b6d3586 的地址表达式。但这里却是名字 SPRING,说明 java.lang.Enum 重写了 toString() 方法,让它直接返回常量的名字。这是枚举用起来"舒服"的原因之一。
第二个观察点:三个身份哈希码完全相同,都是 2065530879。
这一点是全题的关键。System.identityHashCode() 与 hashCode() 不同——后者可以被重写,前者是按对象在内存中的地址计算的、不可被重写的。三个调用点返回同一个数字,说明 a、b、Season.SPRING 指向的是内存中的同一个对象,而不是三个内容相同的对象。
这直接印证了课件第 44 页给的那句结论:
枚举类型是引用类型!枚举不属于原始数据类型,它的每个具体值都引用一个特定的对象。相同的值则引用同一个对象。
第三个观察点:a == b 和 a.equals(b) 结果相同。
因为枚举常量是全局唯一的单例对象,== 比较引用地址、equals() 比较内容,当两者都指向同一个对象时,结果必然一致。所以课件说:
可以使用""和equals()方法直接比对枚举变量的值,换句话说,对于枚举类型的变量,""和equals()方法执行的结果是等价的。
这一点与 String 形成鲜明对比:两个内容相同的 String 用 == 比较可能是 false,必须用 equals();而枚举可以放心用 ==,而且 == 还能避免空指针异常。
第四个观察点:Season.SPRING.getClass().getSuperclass() 是 java.lang.Enum。
说明每个枚举常量其实是枚举类的一个实例,而枚举类隐式继承自 java.lang.Enum。因为 Java 是单继承,所以枚举不能再继承其他类,但可以实现接口。
1.4 结论
- 枚举是引用类型,不是原始数据类型。每个枚举常量都是一个对象。
- 相同值的枚举常量引用同一个对象(由 JVM 保证的单例特性),因此可以用
==安全地比较。 - 枚举的
toString()被重写,直接输出常量名,便于阅读和调试。 - 枚举继承自
java.lang.Enum,天然获得name()、ordinal()、values()、valueOf()等方法。 - 枚举可用于
switch语句(课件第 42 页已指出),且比int常量更安全,因为编译器能检查出未覆盖的分支。
1.5 枚举类型的基本用法
// ① 定义
enum Size { SMALL, MEDIUM, LARGE }
// ② 引用
Size s = Size.SMALL;
// ③ 从字符串转换(注意:课件上写的是 valueof,正确拼写是 valueOf,大写 O)
Size t = Size.valueOf("SMALL");
// ④ 遍历所有值
for (Size x : Size.values()) {
System.out.println(x + " 序号=" + x.ordinal());
}
// ⑤ 用于 switch
switch (s) {
case SMALL: System.out.println("小号"); break;
case MEDIUM: System.out.println("中号"); break;
case LARGE: System.out.println("大号"); break;
}
踩坑记录:课件第 41 页把
valueOf写成了valueof(小写 o)。实际编译会报错找不到符号: 方法 valueof(java.lang.String)。方法名在 Java 中区分大小写,必须写valueOf。
问题 2:阅读相应教材,或使用互联网搜索引擎,弄清楚反码、补码跟原码这几个概念,然后编写示例程序,对正数、负数进行各种位操作,观察输出结果,与手工计算的结果进行比对,看看 Java 中的数是采用上述哪种码表示的。
课件第 50 页
2.1 三个概念
| 名称 | 正数 | 负数 |
|---|---|---|
| 原码 | 符号位 0 + 绝对值 | 符号位 1 + 绝对值 |
| 反码 | 与原码相同 | 符号位不变,其余位按位取反 |
| 补码 | 与原码相同 | 反码 + 1 |
以 8 位的 +5 和 -5 为例:
+5 -5
原码 0000 0101 1000 0101
反码 0000 0101 1111 1010
补码 0000 0101 1111 1011
2.2 验证代码
public class BitOperateTest {
/** 把 int 的 32 位二进制打印出来(每 4 位加空格便于观察) */
static String toBinary32(int n) {
StringBuilder sb = new StringBuilder();
for (int i = 31; i >= 0; i--) {
sb.append(((n >>> i) & 1) == 1 ? '1' : '0');
if (i % 4 == 0 && i != 0) sb.append(' ');
}
return sb.toString();
}
public static void main(String[] args) {
System.out.println("-5 二进制 = " + toBinary32(-5));
System.out.println("-1 二进制 = " + toBinary32(-1));
System.out.println("-128 二进制 = " + toBinary32(-128));
int a = -5, b = 3;
System.out.println("-5 & 3 = " + (a & b));
System.out.println("-5 | 3 = " + (a | b));
System.out.println("-5 ^ 3 = " + (a ^ b));
System.out.println("~5 = " + (~5));
}
}
2.3 运行结果
+5 十进制=5 二进制= 0000 0000 0000 0000 0000 0000 0000 0101
-5 十进制=-5 二进制= 1111 1111 1111 1111 1111 1111 1111 1011
+0 十进制=0 二进制= 0000 0000 0000 0000 0000 0000 0000 0000
-1 十进制=-1 二进制= 1111 1111 1111 1111 1111 1111 1111 1111
+127 十进制=127 二进制= 0000 0000 0000 0000 0000 0000 0111 1111
-128 十进制=-128 二进制= 1111 1111 1111 1111 1111 1111 1000 0000
位运算与手工计算比对:
手工计算(8 位):
-5 & 3 : -5补码=1111 1011, 3=0000 0011, 按位与 -> 0000 0011 = 3
-5 | 3 : 按位或 -> 1111 1011 = -5
-5 ^ 3 : 按位异或 -> 1111 1000 = -8
~5 : 0000 0101 按位取反 -> 1111 1010 = -6
程序实际输出:
-5 & 3 = 3 二进制= 0000 0000 0000 0000 0000 0000 0000 0011 手工结果=3 【一致】
-5 | 3 = -5 二进制= 1111 1111 1111 1111 1111 1111 1111 1011 手工结果=-5 【一致】
-5 ^ 3 = -8 二进制= 1111 1111 1111 1111 1111 1111 1111 1000 手工结果=-8 【一致】
~5 = -6 二进制= 1111 1111 1111 1111 1111 1111 1111 1010 手工结果=-6 【一致】
四项位运算的手工结果与程序输出完全一致。
2.4 分析:为什么说是补码?
证据一:-1 的二进制全是 1。
-1 的实际输出:1111 1111 1111 1111 1111 1111 1111 1111
如果 Java 用原码表示,-1 应该是 1000 0001;用反码应该是 1111 1110。而实际输出是 32 个 1,这正是补码 -1 的写法。反过来验证:把 32 个 1 当作补码解析,其值 = -2^31 + 2^30 + ... + 2^0 = -2^31 + (2^31 - 1) = -1,吻合。
证据二:-128 能被正常输出。
8 位下 -128 是补码特有的表示(1000 0000)。原码和反码都无法表示 -128——原码的 1000 0000 表示的是 -0,反码的 1000 0000 同样不是 -128。程序能正常输出 -128,只能说明采用补码。
证据三:-5 的位模式与手算补码一致。
-5 输出 ...1111 1011,低 8 位正是 1111 1011,与手算的 -5 补码完全相同。
2.5 补充:补码存在的意义
这次实验最值得体会的是:为什么计算机不用原码,而用补码?
意义一:把减法变成加法,硬件只需一个加法器。
5 + (-5) = 0 补码相加恰好溢出归零
3 - 5 → 3 + (-5) = -2 用加法实现减法
补码的设计使 A - B 等价于 A + (-B),CPU 不需要专门设计减法电路,只需要一个加法器和取反电路即可。
意义二:消除了"正零"和"负零"的歧义。
原码中 0000 0000(+0)和 1000 0000(-0)都是零,两种表示会造成判断上的麻烦。补码只有唯一的零,多出来的那个编码正好用来表示 -128,所以:
8 位补码表示范围:-128 ~ +127(共 256 个)
8 位原码表示范围:-127 ~ +127(共 255 个有效值,+0 和 -0 占两份)
这就是为什么 Java 中 int 的最小值是 -2147483648 而不是 -2147483647——负数比正数多表示一个。
2.6 结论
Java 中的整数采用补码表示,这一点由三项证据共同证实:-1 的全 1 位模式、-128 可正常表示、位运算结果与补码手算结果完全一致。
问题 3:Java 变量遵循"同名变量的屏蔽原则",请课后阅读相关资料弄清楚相关知识,然后自己编写一些测试代码,就像本示例一样,有意识地在不同地方定义一些同名变量,看看输出的到底是哪个值。
课件第 56 页
3.1 实验代码
public class VariableScopeTest {
int value = 10; // 成员变量
static int staticValue = 999; // 静态变量
/** 实验1:局部变量屏蔽成员变量 */
void test1() {
System.out.println("方法一开始,value = " + value); // 用的是成员变量
int value = 20; // 定义同名局部变量
System.out.println("定义局部变量后,value = " + value);
System.out.println("用 this.value 访问被屏蔽的成员变量:" + this.value);
}
/** 实验3:for 循环变量与成员变量同名 */
void test3() {
for (int value = 0; value < 3; value++) {
System.out.println(" 循环内 value = " + value);
}
System.out.println("循环结束后,this.value = " + this.value);
}
/** 实验4:方法形参与成员变量同名 */
void test4(int value) {
System.out.println("形参 value = " + value);
System.out.println("this.value = " + this.value);
}
}
3.2 运行结果
--- 实验1:局部变量屏蔽成员变量 ---
方法一开始,value = 10 (此时可见的是成员变量)
定义 int value = 20; 之后,value = 20
-> 输出的 20 是【局部变量】,成员变量被屏蔽了
用 this.value 访问被屏蔽的成员变量:this.value = 10
-> 证明成员变量没有被销毁,只是被局部变量挡住了
--- 实验3:for 循环变量与成员变量同名 ---
循环内 value = 0
循环内 value = 1
循环内 value = 2
循环结束后,this.value = 10 (成员变量毫发无损)
--- 实验4:方法形参与成员变量同名 ---
形参 value = 30
this.value = 10
-> 形参 value 屏蔽了成员变量 value
-> 所以 set 方法必须写 this.value = value; 才能给成员变量赋值
--- 实验5:静态变量 ---
staticValue = 999
定义局部 int staticValue = 111; 之后 = 111
用【类名.变量】访问被屏蔽的静态变量:VariableScopeTest.staticValue = 999
3.3 分析
① 屏蔽 ≠ 覆盖。
实验 1 中,定义局部变量 value = 20 之后,直接写 value 得到的是 20;但用 this.value 访问到的仍然是 10。这证明成员变量并没有被销毁或修改,只是暂时"看不见"了——这就是"屏蔽"(shadowing)而非"覆盖"(overwriting)的含义。
② 优先级:离使用点越近,优先级越高。
类的成员变量 < 方法内的局部变量 < 代码块内的局部变量
(作用域由大到小,优先级由低到高)
实验 3 中,for 循环的循环变量 value 屏蔽了成员变量,循环结束后成员变量 this.value 依然是 10,说明循环变量的作用域仅限于 for 语句本身。
③ Java 与 C/C++ 的重要差异(本次实验最有价值的发现)。
我原本以为可以像 C/C++ 那样,在内层花括号里定义与外层同名的局部变量来实现屏蔽,于是写了这样一段代码:
void test2() {
int value = 100;
{
int value = 200; // 期望:内层屏蔽外层
System.out.println(value);
}
}
结果编译直接失败,报错:
错误: 已在方法 test2()中定义了变量 value
这说明:Java 不允许两个局部变量之间重名,即使它们位于不同的嵌套代码块中。也就是说:
| 场景 | C/C++ | Java |
|---|---|---|
| 内层局部变量屏蔽外层局部变量 | 允许 | 不允许(编译错误) |
| 局部变量屏蔽成员变量 | 允许 | 允许 |
| 方法形参屏蔽成员变量 | 允许 | 允许 |
Java 的做法更严格,目的是保证同一个名字在同一个方法体里只有一个确定的含义,避免读代码时产生歧义。这是一个容易踩的坑,写代码时应主动避开重名。
④ 为什么 set 方法必须写 this.value = value;。
实验 4 给出了答案:方法形参 value 与成员变量同名,形参会屏蔽成员变量。此时如果写成:
public void setValue(int value) {
value = value; // 错误!这是自己给自己赋值,成员变量永远不会被改到
}
那么左边和右边的 value 都是形参,成员变量完全没被触碰。必须显式用 this 指明:
public void setValue(int value) {
this.value = value; // 正确:this.value 是成员变量,value 是形参
}
3.4 结论
- 同名变量的屏蔽原则(就近原则):内层作用域的同名变量会屏蔽外层作用域的同名变量,离使用点最近的那个生效。
- 优先级顺序:成员变量 < 方法局部变量 < 代码块局部变量。
- 屏蔽 ≠ 消失:被屏蔽的成员变量可用
this.变量名访问,被屏蔽的静态变量可用类名.变量名访问;出了作用域立即恢复可见,值未被修改。 - Java 比 C/C++ 更严格:两个局部变量之间不允许重名,哪怕在内层块里,直接编译报错。
- 实践意义:这正是
set方法必须写this.xxx = xxx;的根本原因。
问题 4:看着这个图(类型转换图),再查查 Java 中每个数据类型所占的位数,和表示数值的范围,你能得出什么结论?
课件第 60 页
4.1 各数据类型的位数与取值范围
| 类型 | 位数 | 取值范围 | 是否有精度损失 |
|---|---|---|---|
byte |
8 | -128 ~ 127 | 否 |
short |
16 | -32768 ~ 32767 | 否 |
int |
32 | -2147483648 ~ 2147483647 | 否 |
long |
64 | -2^63 ~ 2^63-1 | 否 |
char |
16 | 0 ~ 65535(无符号) | 否 |
float |
32 | 约 ±3.4E38,7 位有效数字 | 有 |
double |
64 | 约 ±1.8E308,15 位有效数字 | 有 |
boolean |
— | true / false | — |
4.2 实测验证
public class TypeConvertTest {
public static void main(String[] args) {
// ① 自动类型转换(小 -> 大)
byte b = 100;
short s = b;
int i = s;
long l = i;
float f = l;
double d = f;
System.out.println(b + " -> " + s + " -> " + i + " -> " + l + " -> " + f + " -> " + d);
// ② 强制类型转换(大 -> 小)
double d2 = 1234567890.0;
System.out.println("double 强转 float: " + (float) d2);
int i3 = 300;
System.out.println("int 300 强转 byte: " + (byte) i3);
System.out.println("double 3.99 强转 int: " + (int) 3.99);
}
}
运行结果:
=============== 二、自动类型转换(小 -> 大,安全) ===============
byte 100 -> short -> int -> long -> float -> double 一路畅通:
100 -> 100 -> 100 -> 100 -> 100.0 -> 100.0
=============== 三、强制类型转换(大 -> 小,可能损失精度) ===============
double 1234567890.0 强转 float -> 1.23456794E9
原值 1234567890 变成了 1234567940,差了 50
int 300 强转 byte -> 44
300 的二进制 ...0001 0010 1100,截断成 8 位得 0010 1100 = 44
double 3.99 强转 int -> 3
注意:直接截掉小数部分,而不是四舍五入!
4.3 分析:从图中能得出的四个结论
结论 1:转换方向决定安全性。
沿着图中箭头方向(byte → short → int → long → float → double)是自动类型转换,因为目标类型"装得下"源类型的数据,不会丢失信息;逆着箭头方向必须强制类型转换,等于程序员向编译器显式确认"我知道可能丢数据"。
结论 2:long → float 这条线很特殊,暴露了"范围"与"精度"是两个不同的概念。
long 是 64 位,float 只有 32 位,位数明明变少了,Java 却允许自动转换。原因是浮点数采用科学计数法(符号位 + 指数位 + 尾数位)表示,虽然 float 的有效数字只有 7 位,但它能表示的数值范围远大于 long。
小结:"范围"不会丢,但"精度"会丢。这也正好解释了图中为什么 float、double 标着"有精度损失"。
验证就是从 double 1234567890.0 强转成 float 后变成 1234567940,整整差了 50。数值没有超出范围,但有效数字被截断了。
结论 3:位数固定时,"能表示的范围"与"精度"成反比。
把更多的位用来表示指数,范围就大、精度就低(float);把更多的位用来表示有效数字,精度就高、范围就小。整数类型则把所有位都用来表示数值本身,所以它能精确到个位,但范围有限。
结论 4:整数类型是"精确"的,浮点类型大多是"近似"的。
int / long 在各自范围内能精确表示每一个整数,运算结果不会出现"意外的小数尾巴";而 0.1、0.2、0.3 这类十进制小数,用二进制浮点数根本无法精确表示。
这是问题 5 中 double 运算不精确的根本原因,也决定了涉及金额计算时必须使用 BigDecimal。
另外注意强转的两个细节(实测验证):
| 操作 | 结果 | 说明 |
|---|---|---|
(float) 1234567890.0 |
1.23456794E9 |
有效数字截断,精度损失 |
(byte) 300 |
44 |
高位截断,数值溢出(比丢小数更严重) |
(int) 3.99 |
3 |
直接截断小数部分,不是四舍五入 |
(byte) -1 |
-1 |
负数的低位补码保持不变 |
需要四舍五入时必须用 Math.round(3.99),得到 4。
问题 5:请运行以下代码(TestDouble.java),你看到了什么样的输出,意外吗?
课件第 62 页
5.1 实验代码
public class TestDouble {
public static void main(String[] args) {
// ① 基本运算结果
System.out.println(0.1 + 0.2);
System.out.println(1.0 - 0.9);
System.out.println(4.015 * 100);
System.out.println(123.3 / 100);
// ② 判断是否相等
System.out.println(0.1 + 0.2 == 0.3);
// ③ 把误差放大
System.out.println((0.1 + 0.2 - 0.3) * 1e17);
}
}
5.2 运行结果
============ 一、复现:double 的基本运算结果 ============
0.1 + 0.2 = 0.30000000000000004
1.0 - 0.9 = 0.09999999999999998
0.3 - 0.2 = 0.09999999999999998
4.015 * 100 = 401.49999999999994
123.3 / 100 = 1.2329999999999999
============ 二、判断是否相等,问题就暴露了 ============
0.1 + 0.2 == 0.3 -> false
1.0 - 0.9 == 0.1 -> false
4.015 * 100 == 401.5 -> false
============ 三、把误差放大看清楚 ============
(0.1 + 0.2 - 0.3) * 1e17 = 5.551115123125783
本该是 0,实际是一个约 5.55 的小数
5.3 分析:为什么会有这样的输出?意外吗?
确实意外。 按数学计算,0.1 + 0.2 应该等于 0.3,但它输出的是 0.30000000000000004;更严重的是 0.1 + 0.2 == 0.3 的结果竟然是 false。这意味着在 Java 里,用 == 判断两个浮点数是否相等,是一件非常危险的事。
根本原因:十进制小数无法用二进制浮点数精确表示。
0.1、0.2、0.3 这些十进制小数,在二进制下都是无限循环小数:
0.1(十进制)= 0.0001100110011001100...(二进制,1100 无限循环)
0.2(十进制)= 0.0011001100110011001...(二进制)
double 只有 64 位,其中尾数占 52 位,因此必须在某个位置截断——这就产生了误差。参与运算的两个数都带着截断误差,相加后误差被叠加,结果自然不是精确的 0.3。
关于"为什么十进制小数在二进制里是无限循环的",可以这样理解:十进制的 0.1 相当于 1/10,而 10 = 2 × 5 含有因子 5;二进制只能表示分母是 2 的幂的分数(1/2、1/4、1/8...),所以 1/10 无法用有限位表示。这和十进制里 1/3 = 0.333... 除不尽是完全一样的道理。
课件第 64 页的提示正是这个方向:
这个问题,与浮点数在计算机内部的表示方法有关系。
浮点数采用 IEEE 754 标准表示,格式为:
[符号位 1 位] [指数位 11 位] [尾数位 52 位]
尾数位决定了有效数字的个数(double 约 15~16 位十进制有效数字),超过这个位数就无法精确表达了。
5.4 如何解决精度损失?—— 使用 BigDecimal
import java.math.BigDecimal;
import java.math.RoundingMode;
BigDecimal b1 = new BigDecimal("0.1");
BigDecimal b2 = new BigDecimal("0.2");
System.out.println(b1.add(b2)); // 0.3 完全精确
运行结果:
============ 四、用 BigDecimal 得到精确结果 ============
0.1 + 0.2 用 BigDecimal 算 = 0.3,完全精确
compareTo 返回 0 表示相等
============ 五、为什么必须用字符串构造? ============
new BigDecimal(0.1) = 0.1000000000000000055511151231257827021181583404541015625
new BigDecimal("0.1") = 0.1
为什么必须用字符串构造?
这是本题最容易忽略的一个坑。new BigDecimal(0.1) 传的是 double,而 0.1 在进入 BigDecimal 之前就已经不精确了——它本身就是一个近似值。BigDecimal 只是把这个近似值"原样精确地"记录下来,等于把误差一起搬了进来,问题一点没解决。
从输出的对比可以看得非常清楚:
new BigDecimal(0.1) = 0.1000000000000000055511151231257827021181583404541015625
new BigDecimal("0.1") = 0.1
传 double 得到的是 0.1 在二进制下的真实近似值;传字符串则直接按十进制字面量解析,从源头就是精确的。所以课件强调:
在构建 BigDecimal 对象时应使用字符串而不是 double 数值,否则,仍有可能引发计算精度问题。
另外两个使用要点:
// ① 比较大小必须用 compareTo,不能用 equals
b1.add(b2).compareTo(new BigDecimal("0.3")) == 0 // true
// ② 除法除不尽时必须指定精度和舍入模式,否则抛异常
BigDecimal r = new BigDecimal("1").divide(new BigDecimal("3"), 10, RoundingMode.HALF_UP);
System.out.println(r); // 0.3333333333
如果用 divide() 不指定精度计算 1/3,会抛出 ArithmeticException: Non-terminating decimal expansion(无限小数没有精确表示)。
5.5 一个真实场景:金额计算
商场购物车:单价 8.7 元,买 3 件,打 7 折
用 double 算 = 18.269999999999996
用 BigDecimal 算 = 18.27
金额计算中,double 的误差会随着交易笔数不断累积,最终导致对不上账。所以凡是涉及金额、利率、比例的场景,一律使用 BigDecimal。这是一个在实际项目中必须遵守的规范。
5.6 结论
double类型的运算结果不精确,这是课件第 63 页给出的结论。- 根本原因:十进制小数在二进制中是无限循环小数,而
double的 52 位尾数必须截断,产生误差。 - 因此绝对不能用
==判断两个浮点数是否相等。需要比较时应判断差值是否小于某个精度阈值,或改用BigDecimal。 - 解决精度损失的方法是使用
BigDecimal,并且必须用字符串构造。 BigDecimal比较大小用compareTo(),做除法必须指定精度和舍入模式。- 涉及金额的计算必须使用
BigDecimal。
问题 6:以下代码的输出结果是什么?为什么会有这样的输出结果?
课件第 68 页
int X = 100;
int Y = 200;
System.out.println("X+Y=" + X + Y);
System.out.println(X + Y + "=X+Y");
6.1 运行结果
第1行:
X+Y=100200
第2行:
300=X+Y
6.2 分析
关键前提:Java 中的 + 是一个重载的运算符,有两种含义。
| 情况 | 含义 | 结果类型 |
|---|---|---|
| 两个操作数都是数值 | 算术加法 | 数值 |
| 只要有一个操作数是字符串 | 字符串拼接 | String |
并且有两条规则:
- 结合方向是从左到右(赋值运算符
=除外,课件第 51 页已说明); - 一旦某一步的结果变成了字符串,后面再
+就都是拼接。
第 1 行 "X+Y=" + X + Y 的逐步分析:
第1步 "X+Y=" + X -> 左边是字符串,触发【拼接】-> 得到字符串 "X+Y=100"
第2步 "X+Y=100" + Y -> 仍然是拼接 -> 得到字符串 "X+Y=100200"
字符串出现在最左边,一上来就触发了拼接,之后 X 和 Y 都被当成字符串贴上去,所以看到的是 100 和 200 首尾相接的 100200,而不是 300。
第 2 行 X + Y + "=X+Y" 的逐步分析:
第1步 X + Y -> 两边都是数值,触发【算术加法】-> 得到数值 300
第2步 300 + "=X+Y" -> 右边是字符串,触发【拼接】 -> 得到字符串 "300=X+Y"
数值出现在最左边,前两步都是数值运算,先把 100 + 200 算成了 300,然后才去和字符串拼接,所以得到了正确的结果。
6.3 对照实验:一对括号就能改变结果
System.out.println("X+Y=" + (X + Y)); // X+Y=300
System.out.println("和是" + X + Y + "元"); // 和是100200元
System.out.println("和是" + (X + Y) + "元"); // 和是300元
运行结果证实了上面的分析。这说明一个很实际的问题:"漏括号"是日常开发中最常见的低级 bug 之一,而且它不报错、不崩溃,只是默默输出错误的字符串,非常难排查。
6.4 补充:用空字符串快速把数值转成字符串
String xStr = "" + X;
System.out.println(xStr.length()); // 3
"" + 数值 是一个常用的转换技巧,因为空字符串在最左边会立刻触发拼接,把数值变成字符串。不过更规范的写法是 String.valueOf(X) 或 Integer.toString(X)。
6.5 结论
两行输出不同的根本原因是字符串出现的位置不同:
| 表达式 | 字符串位置 | 第一步运算 | 最终结果 |
|---|---|---|---|
"X+Y=" + X + Y |
最左 | 拼接 | X+Y=100200 |
X + Y + "=X+Y" |
最右 | 加法 | 300=X+Y |
一句话记住:+ 在遇到第一个字符串之后就"变味"了,之后它只做拼接、不再做加法。想让某个子表达式先算出数值结果,务必用括号把它括起来。
总结
六处问题的知识点汇总
| 序号 | 出处 | 核心知识点 | 一句话结论 |
|---|---|---|---|
| 1 | 第 43 页 | 枚举类型 | 枚举是引用类型,相同值引用同一对象,== 与 equals() 等价 |
| 2 | 第 50 页 | 原码/反码/补码 | Java 整数采用补码,目的是把减法变加法、消除负零 |
| 3 | 第 56 页 | 变量作用域 | 同名变量就近屏蔽;Java 不允许两个局部变量重名(比 C/C++ 严格) |
| 4 | 第 60 页 | 类型转换 | 范围与精度成反比;long → float 范围不丢、精度会丢 |
| 5 | 第 62 页 | 浮点精度 | double 运算不精确,根因是二进制无法精确表示十进制小数 |
| 6 | 第 68 页 | 字符串拼接 | + 遇到第一个字符串后只做拼接,不再做加法 |
贯穿这六个问题的三条主线
主线一:Java 是强类型的静态语言,类型决定了行为。
问题 1 中枚举是引用类型所以能用 ==;问题 4 中类型决定了转换是否安全;问题 5 中 double 是浮点类型所以不精确。写 Java 代码时,时刻清楚每个变量是什么类型,是避免 bug 的第一道防线。
主线二:计算机的表示能力是有限的,理解底层表示才能理解"奇怪"的现象。
问题 2 的补码、问题 5 的浮点误差,本质都是"有限的二进制位如何去表示无限的数"这个问题在不同层面的体现。补码解决了负数的表示,IEEE 754 解决了小数与范围的表示,两者都做出了权衡(补码牺牲了"对称性"换来运算简化,浮点数牺牲了"精度"换来"范围")。理解了这一点,0.1 + 0.2 != 0.3 就不再是"诡异的现象",而是必然结果。
主线三:很多低级 bug 源于对语言细节的忽视。
问题 3 中 value = value 的自赋值、问题 5 中 new BigDecimal(0.1) 的错误构造、问题 6 中漏掉的括号——这些都不会导致程序崩溃,只是默默产生错误结果,反而是最难排查的一类问题。编译能通过,不代表逻辑是对的。
本次实验踩的三个坑(记录备查)
| # | 坑 | 现象 | 正确做法 |
|---|---|---|---|
| 1 | 课件把 valueOf 写成 valueof |
编译报错"找不到符号" | 方法名区分大小写,必须写 valueOf(大写 O) |
| 2 | 以为 Java 允许内层局部变量屏蔽外层 | 编译报错"已定义变量 value" | Java 不允许局部变量重名;屏蔽只发生在成员变量与局部变量之间 |
| 3 | Java 字符串字面量里混用了 ASCII 直引号 | 编译报错"需要 ')'" | 字符串内部要表示引号,中文用「」,或对 ASCII 引号做转义 \" |
附:完整示例程序清单
| 文件 | 对应问题 | 说明 |
|---|---|---|
EnumTest.java |
问题 1 | 枚举类型:==/equals、身份哈希码、父类、switch |
BitOperateTest.java |
问题 2 | 原码/反码/补码、位运算与手算比对 |
VariableScopeTest.java |
问题 3 | 作用域与同名变量屏蔽,5 组对照实验 |
TypeConvertTest.java |
问题 4 | 各类型位数范围、自动/强制转换实测 |
TestDouble.java |
问题 5 | double 精度问题与 BigDecimal 解决方案 |
PlusTest.java |
问题 6 | + 的两种语义与括号的作用 |
全部程序均在 JDK 17 环境下编译通过并实际运行,本文所引用的输出均为真实运行结果。

浙公网安备 33010602011771号