Java 从入门到精通(二):基础语法全解——数据类型、运算符与流程控制

Java 从入门到精通(二):基础语法全解——数据类型、运算符与流程控制
神经蛙很多初学者的学习路径是:背下 int 是 4 个字节、float 精度低、switch 能匹配 String,然后就开始写业务代码。直到某天线上金额对不上、循环多跑了一轮、i = i++ 的结果和想象的不一样、Integer.MAX_VALUE + 1 变成了负数,才意识到”背下来的语法”和”真正理解的机制”之间隔着一条鸿沟。本篇不打算把语法手册再抄一遍,而是把每一条语法规则背后的内存布局、二进制编码、字节码执行顺序挖出来,让你在面试中被追问”为什么”时,依然能答得上来。
一、标识符、关键字、保留字与代码规范
1.1 标识符的合法性与命名约定
标识符(identifier)是我们给类、方法、变量、包起的名字。Java 语言规范对标识符的硬性约束只有三条:可以由字母、数字、下划线 _、美元符 $ 组成;不能以数字开头;不能与关键字(keyword)重名。这里最容易踩的第一个坑是——中文其实可以作为标识符,因为 Java 的字符集是 Unicode,int 年龄 = 18; 是能编译通过的,但没有任何团队会允许你这么做。
真正有约束力的是命名约定(convention),它不是编译器强制的,却是《阿里巴巴 Java 开发手册》和几乎所有 Code Review 工具的强制项。下面这张表是必须刻进肌肉记忆的:
| 元素 | 约定 | 正例 | 反例 |
|---|---|---|---|
| 类名 / 接口名 | UpperCamelCase,首字母大写 | UserOrderService |
userOrderService、USERORDERSERVICE |
| 方法名 / 变量名 | lowerCamelCase,首字母小写 | getUserName()、maxRetry |
GetUserName()、max_retry |
| 常量(static final) | 全大写,下划线分隔 | MAX_STOCK_COUNT |
maxStockCount |
| 包名 | 全小写,单数,点分隔 | com.sjava.order.util |
com.sjava.Order.Utils |
| 布尔变量 | 不加 is 前缀(POJO 中) |
deleted、enabled |
isDeleted(易被序列化框架误判) |
| 泛型字母 | 单大写字母 | E、T、K、V |
Element、Type |
包名必须全小写且用单数形式,utils 在阿里规范里也是被点名的反例,正确写法是 util。布尔字段加 is 前缀的问题在于:Lombok 和 Jackson 默认按照 JavaBean 规范生成 isDeleted() / getDeleted(),RPC 框架(如 FastJSON 反序列化)可能出现属性丢失,这个坑在第五篇讲序列化时会再次遇到。
1.2 关键字与保留字的区别
保留字(reserved word)包含关键字(keyword)和字面量(literal)。目前 Java 有 53 个关键字(含 2 个保留未使用的 goto、const),以及 true、false、null 三个字面量。关键字全部小写,因此 Int 是合法标识符而 int 不是——这也是 Integer 能成为类名的原因。
| 分类 | 关键字 |
|---|---|
| 访问控制 | public protected private |
| 类/接口/继承 | class interface enum extends implements abstract record(JDK 16+)sealed(JDK 17+) |
| 流程控制 | if else switch case default for while do break continue return |
| 异常处理 | try catch finally throw throws |
| 修饰符 | static final native synchronized transient volatile strictfp |
| 基本类型 | byte short int long float double char boolean void |
| 对象相关 | new this super instanceof package import |
| 其他 | assert var(JDK 10+,上下文关键字)yield(JDK 14+,上下文关键字) |
| 保留未使用 | goto const |
注意 var、yield、record、sealed、permits 属于上下文关键字(contextual keyword):它们只在特定语法位置被当作关键字,别处仍可作为变量名。int var = 1; 在 JDK 10 之后依然合法,但 var x = 1; 会触发类型推断。这是一个经典的笔试陷阱题。
1.3 阿里规范的几个高频硬性要求
除命名外,几条在 Review 中经常被卡住的规范:任何常量必须预先定义,禁止在代码中出现魔法值(magic number);long 字面量必须用大写 L 而不是小写 l,因为 1l 极易被误读为 11;单个方法的总行数不超过 80 行;Object 的 equals 方法容易抛空指针,应使用 java.util.Objects.equals(a, b) 或常量调用 equals。这些规范背后都是真实的线上事故,而非洁癖。
二、八种基本数据类型与内存模型
2.1 位宽、取值范围与默认值
Java 是强类型语言,八种基本类型(primitive type)的位宽是由语言规范写死的,不随操作系统和 JVM 位数变化——这正是 Java “一次编写,到处运行”的基石之一。C/C++ 里 int 在 16 位机上是 2 字节、在 64 位机上是 4 字节,Java 没有这个问题。
| 类型 | 位宽 | 字节 | 取值范围 | 默认值 | 包装类 |
|---|---|---|---|---|---|
byte |
8 | 1 | -128 ~ 127 | 0 | Byte |
short |
16 | 2 | -32,768 ~ 32,767 | 0 | Short |
int |
32 | 4 | -2^31 ~ 2^31-1(约 ±21.47 亿) | 0 | Integer |
long |
64 | 8 | -2^63 ~ 2^63-1 | 0L | Long |
float |
32 | 4 | 约 ±3.4e38,有效位 7 位 | 0.0f | Float |
double |
64 | 8 | 约 ±1.8e308,有效位 15~16 位 | 0.0d | Double |
char |
16 | 2 | \u0000 ~ \uFFFF(0 ~ 65535) |
'\u0000' |
Character |
boolean |
未严格定义 | 未定义 | true / false |
false |
Boolean |
关于 boolean,JVM 规范并没有规定它的存储大小:单个 boolean 变量在 HotSpot 中通常被编译为 int(4 字节),而 boolean[] 数组则按 byte(1 字节)访问,这是出于”最小可寻址单元”和字节码指令集(iconst_1 / ifeq)的权衡。所以”boolean 占 1 位”这个说法是错的。
还有一个常被忽略的点:默认值只存在于成员变量。int[] a = new int[3] 的元素是 0,类的 int 字段默认是 0,但方法内的局部变量没有默认值,未初始化就使用会直接编译报错。
2.2 整数字面量的四种进制与下划线分隔
JDK 7 起支持二进制字面量和数字分隔符,这在处理位掩码、硬件寄存器、金额分组时非常有用。分隔符只是编译期糖,不会进入 class 文件,也不影响运行时性能。
1 | public class LiteralDemo { |
为什么 long x = 2147483648; 会报错而 long x = 2147483648L; 不会?因为整数字面量的类型在词法分析阶段就被确定为 int,赋值时的类型提升发生在之后。编译器先发现 2147483648 无法装进 int,就报错了,根本走不到”拓宽转换”那一步。这是”字面量类型”与”变量类型”的区别。
2.3 IEEE 754:为什么 0.1 + 0.2 不等于 0.3
这是最高频的 Java 面试题之一。根本原因不是”Java 有 bug”,而是二进制无法精确表示十进制的 0.1,就像十进制无法精确表示 1/3 一样。
double 采用 IEEE 754 双精度格式,64 位被划分为三段:1 位符号位 S、11 位指数位 E(偏置值 1023)、52 位尾数位 M(加上隐含的最高位 1,实际精度 53 位)。数值为 (-1)^S × 1.M × 2^(E-1023)。float 则是 1 + 8 + 23,偏置值 127。
十进制的 0.1 转化为二进制是无限循环小数 0.00011001100110011...(循环节 0011),就像十进制里的 0.333...。53 位之后必须截断并舍入,于是 0.1 在内存里实际是 0.1000000000000000055511151231257827,0.2 是 0.200000000000000011102230246251565。两者相加后的结果比 0.3 的近似值略大,最终打印为 0.30000000000000004。
1 | public class FloatPrecision { |
记住两条铁律:金融计算一律用 BigDecimal(且用 String 构造器);非金融场景用 Math.abs(a-b) < 1e-9 做近似比较。另外 float 的有效位只有 7 位,把 1234567.89f 存进去会立刻失真,所以默认情况下请无脑使用 double。
2.4 char 与 Unicode、UTF-16 代理对
char 是 16 位无符号整型,这与其他七种类型都不同(其余都是有符号,最高位是符号位)。因此 char c = 65535; 合法,而 char c = -1; 非法。Java 在语言层面采用 UTF-16 编码,char 直接对应一个 UTF-16 码元(code unit)。
这里有一个真实存在的坑:BMP(基本多文种平面)之外的字符需要两个 char 表示。比如 Emoji 😀 的码点(code point)是 U+1F600,超出 65535,必须用高位代理 \uD83D 和低位代理 \uDE00 两个 char 组合。于是 "😀".length() 返回 2 而不是 1,遍历字符串时如果按 char 拆就会得到乱码。
1 | public class CharDemo { |
注意 \u4e2d 这类 Unicode 转义是在词法分析的最早阶段被替换的,甚至早于注释解析。所以 // \u000a 这样写在注释里也会被替换成换行符,可能导致后面的代码被”顶”出来而编译失败——这是冷门但真实存在的陷阱。
2.5 自动类型提升与强制转换
类型提升(numeric promotion)遵循两条规则:一是 byte、short、char 参与算术运算时一律先提升为 int(这是 JVM 操作数栈以 32 位槽位为单位决定的,字节码里根本没有 badd 这样的指令);二是当表达式中出现更大容量类型时,整体提升为该类型,顺序为 byte/short/char → int → long → float → double。
1 | public class CastDemo { |
一个反直觉的事实:long 转 float 属于拓宽转换,但会丢失精度。long 有 64 位有效位,float 只有 24 位有效位(含隐含位),所以 long 值超过 2^24 后转 float 就不再精确。这解释了为什么”拓宽一定安全、窄化才丢精度”这个简化说法是错的——是否丢精度取决于有效位数,而非字节数。
2.6 整数溢出与二进制补码回绕
Java 的整数运算不抛异常,溢出时静默回绕(wrap around)。Integer.MAX_VALUE + 1 的结果是 Integer.MIN_VALUE,即 -2147483648。原因是补码(two’s complement)表示下,0111...111 加 1 得到 1000...000,而后者按补码规则解读就是 -2^31。CPU 的加法器只做模 2^32 加法,溢出标志位被 JVM 忽略了。
1 | public class OverflowDemo { |
三条实践建议:金额不要用 int 存”分”(大额交易容易溢出),用 long 或 BigDecimal;循环计数超过 21 亿时必须用 long;关键计算使用 Math.addExact / multiplyExact,让溢出暴露成异常而不是悄悄变成一个负数。
2.7 包装类缓存与 null(简述)
八大基本类型各有对应的包装类(wrapper class),JDK 5 引入的自动装箱/拆箱(autoboxing / unboxing)让它们可以混用。但包装类是对象,默认值是 null,这是 NPE 的温床。Integer 内部维护了 IntegerCache,默认缓存 -128 ~ 127(上限可通过 -XX:AutoBoxCacheMax 调整),所以 Integer.valueOf(127) == Integer.valueOf(127) 为 true,而 Integer.valueOf(128) == Integer.valueOf(128) 为 false——永远用 equals 比较包装类。这部分机制、缓存源码与 Integer 与 int 的 == 判定细节,会在本系列第五篇展开。
三、变量与作用域
3.1 三类变量的对比
Java 中变量按声明位置分为三种,它们的存储位置、生命周期、初始化时机完全不同,这决定了它们在并发、GC、序列化中的行为差异。
| 维度 | 局部变量(local) | 成员变量 / 实例变量(instance) | 静态变量(static / class) |
|---|---|---|---|
| 声明位置 | 方法、构造器、代码块内 | 类内、方法外,无 static |
类内、方法外,带 static |
| 存储位置 | 栈帧的局部变量表 | 堆中的对象实例内 | 方法区(JDK 8 后为元空间 Metaspace) |
| 生命周期 | 方法调用结束即销毁 | 随对象 GC 而消亡 | 随类加载而生,类卸载才消亡 |
| 默认值 | 无默认值,必须显式初始化 | 有默认值(0 / false / null) | 有默认值 |
| 线程安全 | 天然安全(栈封闭) | 需同步 | 多线程共享,需同步 |
能否用 var |
可以(JDK 10+) | 不可以 | 不可以 |
局部变量之所以没有默认值,是 JVM 的设计取舍:栈帧复用时不会清零,强制显式初始化可以把”读取到上一帧残留脏数据”这类随机性 Bug 在编译期就拦住。
3.2 初始化时机与执行顺序
类成员的初始化顺序是:static 变量与 static 块(按代码出现顺序)→ 实例变量与实例块 → 构造器。这个顺序在继承场景下极易出错——父类构造器执行期间,子类的实例变量还是默认值,如果父类构造器中调用了被子类重写的方法,就会读到 0 或 null。
1 | public class InitOrder { |
3.3 final 变量:引用不可变 ≠ 对象不可变
final 修饰基本类型时,值不可改;修饰引用类型时,引用(地址)不可改,但对象内部状态可以改。final List<String> list = new ArrayList<>(); list.add("a"); 完全合法。
1 | public class FinalDemo { |
注意”编译期常量内联”的副作用:如果 A 工程引用了 B 工程中的 public static final int VERSION = 1,B 把值改成 2 后只重新编译 B 而不重新编译 A,A 里仍然内联着旧值 1。这是跨模块常量更新失效的经典原因。
3.4 var 局部变量类型推断(JDK 10+)
var 不是动态类型,Java 依然是强类型语言。var 只是让编译器根据初始化表达式推断出确定的静态类型,字节码与手写类型完全一致,运行时零开销。
1 | import java.util.*; |
使用建议:当类型名冗长且从初始化表达式一眼可看出时使用(如 var entry = map.entrySet().iterator().next());如果推断出的类型对读者不直观(例如工厂方法返回的是接口还是实现类),请老老实实写出类型。滥用 var 会降低代码可读性,这是《Java 开发手册》嵩山版明确提示的点。
四、运算符与优先级
4.1 运算符分类全表
| 类别 | 运算符 | 说明与要点 | ||||||
|---|---|---|---|---|---|---|---|---|
| 算术 | + - * / % |
+ 可拼接字符串;/ 整数除法截断;% 结果符号与被除数一致 |
||||||
| 一元 | + - ++ -- ! ~ |
~ 是按位取反,不是逻辑非 |
||||||
| 赋值 | `= += -= *= /= %= &= | = ^= <<= >>= >>>=` | 复合赋值隐含强制转换 | |||||
| 比较 | > < >= <= == != instanceof |
结果为 boolean;== 对引用比较地址 |
||||||
| 逻辑 | `&& \ | \ | ! & \ | ^` | `&& \ | \ | 短路,& \ |
` 不短路(对 boolean 也可作逻辑用) |
| 位运算 | `& \ | ^ ~ << >> >>>` | >> 带符号右移,>>> 无符号右移 |
|||||
| 三元 | 条件 ? a : b |
右结合,两个分支类型不一致时会发生提升 | ||||||
| 类型 | (Type) expr |
引用类型转换失败抛 ClassCastException |
一个隐蔽的差异:复合赋值自带强制转换。byte b = 10; b += 1; 编译通过,而 b = b + 1; 编译失败。因为 b += 1 等价于 b = (byte)(b + 1),编译器帮你插入了窄化转换。这既是便利(写 sum += money 很自然),也是坑(溢出被静默吞掉)。
4.2 短路求值:陷阱与正确使用
&& 和 || 采用短路求值(short-circuit):左侧已能确定结果时右侧不再执行。这既是性能优化,也是副作用陷阱。
1 | public class ShortCircuit { |
如果确实需要两侧都执行(例如两个校验都要收集错误信息),请使用不短路的 & 和 |,它们作用于 boolean 时是合法的逻辑运算符,只是求值策略不同。
4.3 位运算实战:位掩码与位图
位运算的本质是直接操作二进制位,是 CPU 最原生的操作,在权限系统、状态压缩、高性能容器中大量使用。
1 | public class BitMask { |
注意 -8 >>> 1 的结果是 2147483644 而不是一个小数:>>> 对 int 只移动 32 位寄存器内的位,高位补 0 后符号位变 0,于是得到了一个巨大的正数。另外移位运算的移动位数会被模 32(long 则模 64)处理,1 << 32 等价于 1 << 0,结果是 1 而不是 0——这个坑在写 1 << n 循环时经常出现。
4.4 自增自减:i++ 与 ++i 与 i = i++
i++ 与 ++i 单独成句时效果完全相同,差异只在参与表达式时:i++ 先返回值再自增,++i 先自增再返回值。
1 | public class IncrementTrap { |
j = j++ 为什么是 1?看字节码就一目了然:JVM 先把 j 的当前值 load 到操作数栈(iload_1,值 1),然后执行 iinc 1, 1 让局部变量表中的 j 变成 2,最后 istore_1 把操作数栈顶的值(还是 1)写回 j。于是自增的结果被覆盖掉了。同理,i = i++ 永远等于原值。这条也常被用来考察”是否理解操作数栈与局部变量表的分离”。
4.5 运算符优先级(从高到低)
记不住全部没关系,记住三条就能解决 90% 的问题:一元 > 算术 > 移位 > 关系 > 相等 > 位与 > 位异或 > 位或 > 逻辑与 > 逻辑或 > 三元 > 赋值。优先级不确定时直接加括号,可读性的收益远大于”炫技”。
| 优先级 | 运算符 | 结合性 | ||
|---|---|---|---|---|
| 1 | () [] . |
左 | ||
| 2 | ! ~ ++ -- +(一元) -(一元) (类型) new |
右 | ||
| 3 | * / % |
左 | ||
| 4 | + - |
左 | ||
| 5 | << >> >>> |
左 | ||
| 6 | < <= > >= instanceof |
左 | ||
| 7 | == != |
左 | ||
| 8 | & |
左 | ||
| 9 | ^ |
左 | ||
| 10 | `\ | ` | 左 | |
| 11 | && |
左 | ||
| 12 | `\ | \ | ` | 左 |
| 13 | ? : |
右 | ||
| 14 | = += -= *= /= %= &= `\ |
=`^= <<= >>= >>>= |
右 |
经典验证题:System.out.println(1 + 2 + "3") 输出 "33"(先算 1+2=3,再拼字符串),而 System.out.println("1" + 2 + 3) 输出 "123"(第一次遇到字符串后全部变为拼接)。这是 + 左结合性与字符串拼接规则叠加的结果。
五、流程控制
5.1 if-else 与卫语句
深层嵌套的 if-else 是代码可读性的头号杀手。《重构》中的卫语句(guard clause)技巧:把异常/边界情况提前 return,让主流程保持在最外层。
1 | public class GuardClause { |
配套规范:if 后即使只有一条语句也必须加大括号(阿里规范强制),否则后续插入语句时极易出现”看起来在 if 内、实际不在”的 Bug;判断布尔值不要写 if (flag == true),直接 if (flag);常量放左边写 "abc".equals(str) 可避免 NPE。
5.2 switch 的演进:从 int 到箭头语法与 yield
switch 支持的表达式类型经历了四个阶段,了解演进史有助于理解老项目代码和面试追问。
| JDK 版本 | 支持的类型 | 说明 |
|---|---|---|
| 1.0 ~ 1.4 | byte short char int |
底层编译为 tableswitch(连续)或 lookupswitch(稀疏) |
| 5 | 新增 enum、包装类(Integer 等) |
枚举匹配实际用的是 ordinal() |
| 7 | 新增 String |
编译为 hashCode + equals 两段比较 |
| 14 | 箭头语法 ->、多值 case、yield |
switch 表达式,可直接返回值 |
| 17 / 21 | 模式匹配(预览 / 正式) | case Integer i -> ...,可配合 sealed 做穷尽性检查 |
1 | public class SwitchEvolution { |
三个必须记住的细节:switch 的表达式为 null 时抛 NPE(String、enum 都一样);传统写法中 break 遗漏导致的穿透是 Bug 高发区,箭头语法从语言层面消灭了它;switch 表达式要求穷尽所有分支(或用 default),否则编译报错。
5.3 三种循环的区别与经典坑
| 循环 | 语法 | 执行次数 | 适用场景 |
|---|---|---|---|
while |
while (cond) {} |
0 ~ N | 次数未知,先判断 |
do-while |
do {} while (cond); |
1 ~ N | 至少执行一次(注意末尾分号) |
for |
for (init; cond; update) {} |
0 ~ N | 次数已知,计数器作用域被限制在循环内 |
| 增强 for | for (T e : arr) {} |
遍历 | 底层是 Iterator 或数组索引 |
1 | public class LoopTrap { |
5.4 break、continue 与带标签的跳出
break 跳出当前循环,continue 跳过本次迭代。要跳出多层嵌套循环,有两种写法:标志位,或 Java 独有的标签(label)。标签写法虽然语言支持,但可读性差,生产代码更推荐把嵌套循环抽成一个方法后用 return。
1 | public class LabelDemo { |
六、数组与方法
6.1 数组的内存布局
数组在 Java 中是对象,无论元素是基本类型还是引用类型,数组对象本身都存放在堆中(JDK 6 起即使开启逃逸分析做标量替换,语义上仍是堆对象)。数组对象头部包含 Mark Word、类型指针和长度字段,随后是连续的元素区。
一维数组 int[] a = new int[3] 的内存布局:
1 | 栈帧局部变量表 堆 |
二维数组在 Java 中其实是“数组的数组”,因此天然支持不规则数组(每行长度不同):
1 | int[][] m = new int[2][]; // 只分配了外层数组,m[0]、m[1] 都是 null |
这就是为什么 m.length 是行数、m[0].length 是第 0 行长度,也为什么 new int[2][] 是合法的而 new int[][3] 不合法(必须先确定外层)。
6.2 初始化的三种方式与 Arrays 工具类
1 | import java.util.Arrays; |
| 方法 | 作用 | 注意 |
|---|---|---|
toString / deepToString |
打印一维 / 多维数组 | 直接 System.out.println(arr) 只会得到 [I@1b6d3586 |
sort / parallelSort |
排序 | 基本类型是快排(不稳定),对象数组是 TimSort(稳定) |
binarySearch |
二分查找 | 前必须先排序,未找到返回 -(插入点)-1 |
copyOf / copyOfRange |
拷贝并可能扩容 | 底层是 System.arraycopy |
equals / deepEquals |
比较内容 | 数组没重写 Object.equals,a.equals(b) 仍是比地址 |
fill / setAll |
填充 | setAll 可用生成器函数 |
asList |
转 List | 返回定长 List,且基本类型数组会被当成一个元素 |
stream |
转流 | 便于做聚合、过滤 |
Arrays.asList(intArray) 的坑:由于泛型不支持基本类型,Arrays.asList(new int[]{1,2,3}) 得到的是 List<int[]>(只有一个元素),而不是 List<Integer>。应改用 Integer[] 或 Arrays.stream(arr).boxed().collect(...)。
6.3 数组拷贝与浅拷贝陷阱
四种拷贝方式:System.arraycopy(最快,本地方法)、Arrays.copyOf(封装了前者,可扩容)、clone()、for 循环。全部都是浅拷贝(shallow copy)——对于引用类型数组,只复制引用本身,两个数组的元素仍指向同一批对象。
1 | import java.util.Arrays; |
6.4 可变参数的原理与重载冲突
可变参数(varargs)T... args 在编译后就是一个数组,void f(int... a) 与 void f(int[] a) 的方法签名(descriptor)完全相同,无法共存于同一个类中(编译报 “cannot declare both”)。
1 | import java.util.Arrays; |
6.5 方法重载的匹配规则(三阶段)
重载(overload)的决议发生在编译期,依据的是静态类型(声明类型),与运行时的真实对象无关。这一点务必与重写(override)的动态分派区分开——这是一道高频面试题。编译器按三个阶段依次尝试,一旦某阶段找到匹配就停止:
- 严格调用(strict invocation):不允许装箱/拆箱,不允许可变参数;
- 宽松调用(loose invocation):允许装箱/拆箱(含 varargs 的固定参数部分);
- 可变参数调用(variable arity):把剩余实参打包成数组。
1 | public class OverloadRule { |
6.6 值传递的本质
Java 只有值传递(pass-by-value),没有引用传递。这一点常引起争议,根源在于对”值”的误解:对于引用类型,传递的值是引用的副本(地址值的拷贝),而不是对象本身,也不是”引用本身”。
1 | public class PassByValue { |
用文本图说明 change(a) 调用时的内存状态:
1 | 调用前(main 栈帧) 调用中(main + change 两个栈帧) |
结论一句话:形参永远无法改变实参变量本身的值,只能通过引用副本修改它所指向的对象内容。swap 方法在 Java 里无法通过交换引用来实现,就是这个原因。
6.7 递归与栈溢出
每次方法调用都会在虚拟机栈中压入一个栈帧,递归过深会耗尽栈空间,抛出 StackOverflowError(属于 Error 而非 Exception,不可恢复)。默认栈大小通常为 1MB(-Xss 可调),大约能容纳几千到上万层调用。存在循环替代方案时优先用循环;必须递归时应明确终止条件,并考虑尾递归(Java 未做尾调用优化,写了也没用)或改用手写栈。
1 | public class Recursion { |
七、输入输出基础与常见陷阱
7.1 Scanner 的 nextInt 与 nextLine 混用
这是新手 100% 会踩的坑。nextInt() 只读取数字,不会消费行尾的换行符;紧随其后的 nextLine() 读到这个残留换行符,立刻返回一个空字符串,看起来就像”被吞掉了一行”。
1 | import java.util.Scanner; |
另外,Scanner 基于正则分词,性能较差(每次读都要做模式匹配)。在算法题或大文件读入场景下,应使用 BufferedReader 或 StreamTokenizer。
7.2 高性能读入模板
1 | import java.io.*; |
7.3 System.out.printf 格式化占位符
| 占位符 | 含义 | 示例输出 |
|---|---|---|
%d |
十进制整数 | 100 |
%s |
字符串(任意对象调 toString) |
sjava |
%f |
浮点,默认 6 位小数 | 3.141593 |
%.2f |
浮点,保留 2 位(四舍五入) | 3.14 |
%e |
科学计数法 | 3.141593e+00 |
%c |
字符 | A |
%b |
布尔 | true |
%x / %o |
十六进制 / 八进制 | 64 / 144 |
%n |
平台无关换行(优于 \n) |
|
%% |
字面量百分号 | % |
%5d / %-5s |
宽度 5,默认右对齐 / 左对齐 | 100 |
1 | public class FormatDemo { |
八、高频面试题与易错点清单
以下十条是本篇内容的浓缩检验,每条都给出结论 + 原因,建议先自己作答再对照。
1. 0.1 + 0.2 == 0.3 的结果是什么? 结果是 false,实际值为 0.30000000000000004。原因是二进制无法精确表示十进制的 0.1(类似十进制无法精确表示 1/3),IEEE 754 双精度只有 53 位有效位,超出后必须舍入,误差在相加后被放大。正确做法是用 BigDecimal(String) 或 Math.abs(a-b) < epsilon。
2. Integer.MAX_VALUE + 1 等于多少?如何避免? 等于 -2147483648。整数采用补码表示且 Java 不做溢出检查,CPU 的加法器按模 2^32 运算,溢出标志被忽略。规避方式:提升到 long 计算、使用 Math.addExact 抛异常、金额改用 BigDecimal。
3. i = i++ 之后 i 的值是多少? 仍是原值。字节码顺序是 iload(把原值压栈)→ iinc(局部变量表自增)→ istore(把栈顶原值写回),自增结果被覆盖。同理 int j = 0; for (int k = 0; k < 10; k++) j = j++; 结束后 j 依旧是 0。
4. byte b = 10; b = b + 1; 与 b += 1; 为什么一个报错一个通过? b + 1 中 byte 被提升为 int,赋值给 byte 需要显式强转,所以第一个编译失败;复合赋值 += 由编译器隐式插入窄化转换,等价于 b = (byte)(b + 1),因此编译通过——但也意味着溢出会被静默吞掉。
5. switch 支持哪些类型?为什么 String 可以而 long 不可以? 支持 byte/short/char/int、枚举、String(JDK 7+)以及对应的包装类,JDK 14+ 还支持箭头表达式与 yield。String 的支持是编译期糖:先按 hashCode 做 tableswitch 定位,再用 equals 逐个确认(防止哈希碰撞)。long 不支持是因为 JVM 的 tableswitch/lookupswitch 指令索引基于 32 位 int。
6. Java 到底是值传递还是引用传递? 只有值传递。基本类型传的是数据副本;引用类型传的是引用地址的副本。因此可以在方法内修改对象状态(如 arr[0] = 100),但无法让外部变量指向新对象(如 arr = new int[3] 对外无效),Java 也无法写出真正交换两个引用的 swap。
7. Arrays.asList(new int[]{1,2,3}) 得到什么? 得到 List<int[]>,长度为 1。因为泛型不支持基本类型,整个数组被当成了单个元素。应改用 Integer[],或 Arrays.stream(arr).boxed().collect(Collectors.toList())。该函数返回的 List 还是定长的,add/remove 会抛 UnsupportedOperationException(它是 Arrays 的内部类,不是 ArrayList)。
8. 为什么 char 能赋值为整数,byte 却不行? char 是 16 位无符号类型,范围是 0~65535,所以 char c = 65; 合法(值为 'A');而 byte b = 128; 超出 -128~127 范围,编译失败。另外 char 之间做算术会先提升为 int,结果必须强转才能赋回 char。
9. var 能用在哪些地方?有什么限制? 只能用于局部变量(含增强 for 的循环变量),且必须立即初始化、不能赋 null、不能复合声明、不能用于成员变量/方法参数/返回类型/Lambda 变量。var 是编译期静态类型推断,字节码与手写类型一致,运行时无开销。
10. 数组拷贝的四种方式都是深拷贝吗? 都不是,System.arraycopy、Arrays.copyOf、clone()、循环赋值全是浅拷贝。对引用类型数组,只复制引用本身,副本与原数组共享元素对象,修改副本元素的内部字段会影响原数组。需要深拷贝必须自己遍历并创建新对象。
到这里,基础语法的”表层”与”底层”两条线索已经打通:从 IEEE 754 的 53 位有效位到补码回绕,从操作数栈与局部变量表的分离到引用副本的传递。下一篇我们将进入面向对象的世界,重点拆解 String 的不可变性与字符串常量池、equals 与 hashCode 的契约、以及为什么重写 equals 必须同时重写 hashCode。建议把本篇的十二段示例代码逐个敲一遍并改造参数运行,尤其是 BitMask 与 PassByValue 两段,看懂它们的输出比记住结论重要得多。

















