很多初学者的学习路径是:背下 是 4 个字节、 精度低、 能匹配 String,然后就开始写业务代码。直到某天线上金额对不上、循环多跑了一轮、 的结果和想象的不一样、 变成了负数,才意识到"背下来的语法"和"真正理解的机制"之间隔着一条鸿沟。本篇不打算把语法手册再抄一遍,而是把每一条语法规则背后的内存布局、二进制编码、字节码执行顺序挖出来,让你在面试中被追问"为什么"时,依然能答得上来。
一、标识符、关键字、保留字与代码规范
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 文件,也不影响运行时性能。
public class LiteralDemo {
public static void main(String[] args) {
int dec = 100; // 十进制
int bin = 0b0110_0100; // 二进制(JDK 7+),下划线分组,等价于 100
int oct = 0144; // 八进制,以 0 开头,等价于 100
int hex = 0x64; // 十六进制,以 0x 开头,等价于 100
long big = 9_223_372_036_854_775_807L; // long 必须加 L(大写),否则按 int 解析直接编译失败
float f = 3.14f; // float 必须加 f,否则 3.14 是 double,赋值给 float 需要强转
double d = 3.14; // 后缀 d/D 可省略
// long bad = 2147483648; // 编译错误:超出 int 范围,整数面量默认是 int 类型
System.out.println(dec == bin && bin == oct && oct == hex); // true
System.out.println(big);
}
}
为什么 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。
public class FloatPrecision {
public static void main(String[] args) {
System.out.println(0.1 + 0.2); // 0.30000000000000004
System.out.println(0.1 + 0.2 == 0.3); // false,永远不要用 == 比较浮点
System.out.println(1.0 - 0.9); // 0.09999999999999998
// 正确姿势一:允许误差的近似比较(epsilon 法)
double a = 0.1 + 0.2, b = 0.3;
final double EPS = 1e-9;
System.out.println(Math.abs(a - b) < EPS); // true
// 正确姿势二:使用 BigDecimal,且必须用 String 构造!
java.math.BigDecimal x = new java.math.BigDecimal("0.1");
java.math.BigDecimal y = new java.math.BigDecimal("0.2");
System.out.println(x.add(y)); // 0.3
// 错误示范:用 double 构造 BigDecimal,误差被"固化"进去
java.math.BigDecimal wrong = new java.math.BigDecimal(0.1);
System.out.println(wrong); // 0.1000000000000000055511151231257827021181583404541015625
}
}
记住两条铁律:金融计算一律用 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 拆就会得到乱码。
public class CharDemo {
public static void main(String[] args) {
char cn = '中';
System.out.println((int) cn); // 20013,char 与 int 可自由互转(无符号16位)
System.out.println('\u4e2d'); // 中,Unicode 转义在编译期处理
char nul = '\u0000';
System.out.println((int) nul); // 0,默认值
String emoji = "😀"; // U+1F600,属于增补平面
System.out.println(emoji.length()); // 2 —— 两个 UTF-16 码元
System.out.println(emoji.codePointCount(0, emoji.length())); // 1 —— 一个码点
System.out.println(Character.isSurrogate(emoji.charAt(0))); // true
// char 参与算术运算会被提升为 int
char c1 = 'a';
// char c2 = c1 + 1; // 编译错误:结果是 int,需要强转
char c2 = (char) (c1 + 1);
System.out.println(c2); // b
}
}
注意 \u4e2d 这类 Unicode 转义是在词法分析的最早阶段被替换的,甚至早于注释解析。所以 // \u000a 这样写在注释里也会被替换成换行符,可能导致后面的代码被"顶"出来而编译失败——这是冷门但真实存在的陷阱。
2.5 自动类型提升与强制转换
类型提升(numeric promotion)遵循两条规则:一是 byte、short、char 参与算术运算时一律先提升为 int(这是 JVM 操作数栈以 32 位槽位为单位决定的,字节码里根本没有 badd 这样的指令);二是当表达式中出现更大容量类型时,整体提升为该类型,顺序为 byte/short/char → int → long → float → double。
public class CastDemo {
public static void main(String[] args) {
byte b1 = 10, b2 = 20;
// byte b3 = b1 + b2; // 编译错误:byte + byte 的结果是 int
byte b3 = (byte) (b1 + b2); // 必须强转
int i = 10;
long l = i; // 自动拓宽(widening),安全
// int j = l; // 编译错误:窄化(narrowing)必须显式强转
int j = (int) l;
long lv = 100L;
float fv = lv; // long → float 是合法拓宽,但可能丢精度!
System.out.println(fv); // 100.0
System.out.println((int) 3.99); // 3 —— 浮点转整型直接截断小数,不是四舍五入
System.out.println(Math.round(3.5)); // 4 —— 四舍五入要用 Math.round
System.out.println(1 / 2); // 0 —— 整数除法!结果是 int 0,不是 0.5
System.out.println(1.0 / 2); // 0.5
System.out.println(1 / 2.0); // 0.5
}
}
一个反直觉的事实: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 忽略了。
public class OverflowDemo {
public static void main(String[] args) {
System.out.println(Integer.MAX_VALUE); // 2147483647
System.out.println(Integer.MAX_VALUE + 1); // -2147483648,静默溢出
System.out.println(Integer.MIN_VALUE - 1); // 2147483647
System.out.println(Integer.MIN_VALUE * -1); // -2147483648,取负也溢出!
System.out.println(Math.abs(Integer.MIN_VALUE)); // -2147483648,Math.abs 也救不了
System.out.println(Integer.MAX_VALUE + 1L); // 2147483648,提升到 long 就正确
// 安全的算术:JDK 8 提供的饱和/异常方法
try {
int r = Math.addExact(Integer.MAX_VALUE, 1); // 溢出时抛 ArithmeticException
} catch (ArithmeticException e) {
System.out.println("捕获溢出:" + e.getMessage());
}
System.out.println(Math.multiplyHigh(2, 3)); // 取乘法的高 64 位,不易溢出
}
}
三条实践建议:金额不要用 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。
public class InitOrder {
private static int s = print("static 变量");
private int i = print("实例变量");
static { System.out.println("static 代码块"); }
{ System.out.println("实例代码块"); }
public InitOrder() { System.out.println("构造器"); }
private static int print(String msg) {
System.out.println(msg);
return 1;
}
public static void main(String[] args) {
System.out.println("--- 第一次 new ---");
new InitOrder();
System.out.println("--- 第二次 new ---");
new InitOrder();
}
}
// 输出顺序:static 变量 → static 代码块 → --- 第一次 new --- → 实例变量 → 实例代码块 → 构造器
// → --- 第二次 new --- → 实例变量 → 实例代码块 → 构造器
// 关键点:static 部分只在类加载时执行一次
3.3 final 变量:引用不可变 ≠ 对象不可变
final 修饰基本类型时,值不可改;修饰引用类型时,引用(地址)不可改,但对象内部状态可以改。final List<String> list = new ArrayList<>(); list.add("a"); 完全合法。
public class FinalDemo {
private static final int MAX = 100; // 编译期常量(constant variable),会被内联进字节码
private static final long NOW = System.currentTimeMillis(); // 运行期常量,不内联
private final java.util.List<String> list = new java.util.ArrayList<>();
void test() {
list.add("hello"); // 合法:修改的是对象内容,不是引用
// list = new ArrayList<>(); // 编译错误:final 引用不可重新赋值
final int x;
x = 10; // 空白 final(blank final):声明时不赋值,使用前赋值即可
System.out.println(MAX + " " + x);
}
}
注意"编译期常量内联"的副作用:如果 A 工程引用了 B 工程中的 public static final int VERSION = 1,B 把值改成 2 后只重新编译 B 而不重新编译 A,A 里仍然内联着旧值 1。这是跨模块常量更新失效的经典原因。
3.4 var 局部变量类型推断(JDK 10+)
var 不是动态类型,Java 依然是强类型语言。var 只是让编译器根据初始化表达式推断出确定的静态类型,字节码与手写类型完全一致,运行时零开销。
import java.util.*;
public class VarDemo {
public static void main(String[] args) {
var name = "sjava"; // 推断为 String
var list = new ArrayList<String>(); // 推断为 ArrayList<String>,右侧泛型信息保留
var map = Map.of("k", 1); // 推断为 Map<String, Integer>
for (var e : list) { // 增强 for 中可用
System.out.println(e.length());
}
// 以下全部为编译错误(限制):
// var x; // 必须初始化,否则无法推断
// var n = null; // 不能推断为任何类型
// var a = 1, b = 2; // 不支持复合声明
// var arr = {1, 2, 3}; // 不支持数组初始化简写
// var f = () -> {}; // 不能推断 Lambda 的类型(Lambda 需要目标类型)
// var lambda = (var s) -> s.length(); // JDK 11+ 允许在 Lambda 形参上用 var(为了加注解)
}
// private var field = 1; // 编译错误:var 不能用于成员变量
// void m(var p) {} // 编译错误:var 不能用于方法参数(构造器也不行)
// var method() { return 1; } // 编译错误:var 不能用于返回类型
}
使用建议:当类型名冗长且从初始化表达式一眼可看出时使用(如 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):左侧已能确定结果时右侧不再执行。这既是性能优化,也是副作用陷阱。
public class ShortCircuit {
static boolean sideEffect(String tag) {
System.out.println("执行了:" + tag);
return true;
}
public static void main(String[] args) {
boolean r1 = false && sideEffect("A"); // A 不会被执行
boolean r2 = true || sideEffect("B"); // B 不会被执行
System.out.println(r1 + " " + r2);
// 正确用法:利用短路做空指针防护(顺序至关重要)
String s = null;
if (s != null && s.length() > 0) { // 安全
System.out.println("非空");
}
// if (s.length() > 0 && s != null) { } // NPE:顺序颠倒,短路保护失效
// 陷阱:把带副作用的表达式放在右侧,会导致逻辑依赖执行顺序
int i = 0;
if (false && (++i > 0)) { }
System.out.println("i = " + i); // 0,++i 根本没执行
}
}
如果确实需要两侧都执行(例如两个校验都要收集错误信息),请使用不短路的 & 和 |,它们作用于 boolean 时是合法的逻辑运算符,只是求值策略不同。
4.3 位运算实战:位掩码与位图
位运算的本质是直接操作二进制位,是 CPU 最原生的操作,在权限系统、状态压缩、高性能容器中大量使用。
public class BitMask {
// 权限位设计:每个权限占一个二进制位,一个 int 可表示 32 种权限
static final int READ = 1 << 0; // 0001 = 1
static final int WRITE = 1 << 1; // 0010 = 2
static final int DELETE = 1 << 2; // 0100 = 4
static final int ADMIN = 1 << 3; // 1000 = 8
public static void main(String[] args) {
int perm = READ | WRITE; // 授予:按位或,perm = 0011 = 3
System.out.println(has(perm, READ)); // true
System.out.println(has(perm, DELETE)); // false
perm |= DELETE; // 追加权限:或运算
perm &= ~WRITE; // 移除权限:与上一个取反的掩码
perm ^= READ; // 切换权限:异或(有则去,无则加)
System.out.println(Integer.toBinaryString(perm)); // 100 => 只剩 DELETE
// 位图(bitmap):用 long[] 标记 0~N 的存在性,JDK 的 BitSet 就是这个原理
long[] bitmap = new long[2]; // 可表示 128 个位置
int idx = 70;
bitmap[idx >>> 6] |= 1L << (idx & 63); // 置位(等价于 idx / 64 与 idx % 64)
boolean exists = (bitmap[idx >>> 6] & (1L << (idx & 63))) != 0; // 查询
System.out.println(exists); // true
// 其他常用技巧
System.out.println((8 & -8)); // 8 —— x & -x 取最低位的 1
System.out.println(5 << 1); // 10 —— 左移一位 = 乘 2
System.out.println(-8 >> 1); // -4 —— 带符号右移,高位补符号位
System.out.println(-8 >>> 1); // 2147483644 —— 无符号右移,高位补 0(注意 int 是 32 位)
}
static boolean has(int perm, int bit) {
return (perm & bit) == bit; // 关键:等于 bit 而不是 != 0(多权限组合时更严谨)
}
}
注意 -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 先自增再返回值。
public class IncrementTrap {
public static void main(String[] args) {
int i = 1;
int a = i++; // a = 1, i = 2
int b = ++i; // b = 3, i = 3
System.out.println("a=" + a + ", b=" + b + ", i=" + i);
int j = 1;
j = j++; // 经典陷阱!结果 j 仍然是 1
System.out.println("j = " + j);
int k = 1;
k = k++ + ++k; // 1 + 3 = 4(第一个 k++ 返回 1,之后 k=2;++k 先增到 3 再返回 3)
System.out.println("k = " + k);
}
}
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,让主流程保持在最外层。
public class GuardClause {
// 反例:箭头型代码,主逻辑被埋在三层嵌套里
static String bad(User u) {
if (u != null) {
if (u.isVip()) {
if (u.getBalance() > 100) {
return "允许下单";
} else {
return "余额不足";
}
} else {
return "非会员";
}
} else {
return "用户不存在";
}
}
// 正例:卫语句,逐层拦截
static String good(User u) {
if (u == null) return "用户不存在";
if (!u.isVip()) return "非会员";
if (u.getBalance() <= 100) return "余额不足";
return "允许下单";
}
static class User {
private boolean vip; private int balance;
boolean isVip() { return vip; }
int getBalance() { return balance; }
}
}
配套规范: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 做穷尽性检查 |
public class SwitchEvolution {
enum Status { NEW, PAID, CLOSED }
// 传统写法:每个 case 都要 break,忘写就穿透(fall-through)
static String classic(int day) {
String name;
switch (day) {
case 1: case 7: // 刻意的穿透合并
name = "周末";
break;
case 6:
name = "周六";
break;
default: // default 不是语法强制,但规范强烈建议保留
name = "工作日";
}
return name;
}
// String 匹配(JDK 7+):本质是 hashCode + equals,case 值不能为 null,否则 NPE
static int byString(String cmd) {
switch (cmd) {
case "start": return 1;
case "stop": return 2;
default: return -1;
}
}
// JDK 14+ 箭头语法:无穿透,单表达式直接返回值
static String arrow(Status s) {
return switch (s) {
case NEW -> "待支付";
case PAID -> "已支付";
case CLOSED -> "已关闭";
};
}
// JDK 14+ 需要多语句时用 {} + yield 返回值
static int score(Status s) {
return switch (s) {
case NEW -> 0;
case PAID -> {
int base = 100;
System.out.println("计算积分");
yield base * 2; // yield 只能在 switch 表达式块中使用
}
case CLOSED -> 0;
};
}
public static void main(String[] args) {
System.out.println(classic(7) + " " + byString("stop"));
System.out.println(arrow(Status.PAID) + " " + score(Status.PAID));
// String s = null; byString(s); // NullPointerException:switch 的表达式不能为 null
}
}
三个必须记住的细节: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 或数组索引 |
public class LoopTrap {
public static void main(String[] args) {
// 坑1:浮点数做循环变量,误差累积导致循环次数不确定
// for (double d = 0; d != 1.0; d += 0.1) {} // 可能死循环,永远达不到精确的 1.0
for (double d = 0; d < 1.0; d += 0.1) { // 用 < 而不是 !=
// ...
}
// 坑2:循环中修改集合结构
var list = new java.util.ArrayList<>(java.util.List.of("a", "b", "c"));
// for (String s : list) { if (s.equals("b")) list.remove(s); } // ConcurrentModificationException
var it = list.iterator(); // 正确:用迭代器的 remove
while (it.hasNext()) {
if (it.next().equals("b")) it.remove();
}
// 坑3:循环内创建对象/重复计算 length
int[] arr = new int[10];
for (int m = 0, len = arr.length; m < len; m++) { // 缓存 length,微优化
arr[m] = m;
}
// 坑4:分号陷阱 —— for 后面多写分号,循环体变成空语句
int sum = 0;
for (int n = 0; n < 10; n++); // 空循环体!下面这行只执行一次
{
sum++;
}
System.out.println("sum = " + sum); // 1,而不是 10
}
}
5.4 break、continue 与带标签的跳出
break 跳出当前循环,continue 跳过本次迭代。要跳出多层嵌套循环,有两种写法:标志位,或 Java 独有的标签(label)。标签写法虽然语言支持,但可读性差,生产代码更推荐把嵌套循环抽成一个方法后用 return。
public class LabelDemo {
public static void main(String[] args) {
int[][] matrix = { {1, 2, 3}, {4, 5, 6}, {7, 8, 9} };
// 推荐:抽成方法用 return,语义清晰
System.out.println(find(matrix, 5));
// 标签写法:outer 标记外层循环,break outer 直接跳出两层
outer:
for (int i = 0; i < matrix.length; i++) {
for (int j = 0; j < matrix[i].length; j++) {
if (matrix[i][j] == 5) {
System.out.println("找到于 [" + i + "][" + j + "]");
break outer;
}
}
}
// 无限循环的三种常见写法
// while (true) { ... } // 最常用
// for (;;) { ... } // 编译后指令更少,源码里两种等价
// do { ... } while (true);
}
// 推荐写法
static String find(int[][] m, int target) {
for (int i = 0; i < m.length; i++) {
for (int j = 0; j < m[i].length; j++) {
if (m[i][j] == target) return "[" + i + "][" + j + "]";
}
}
return "未找到";
}
}
六、数组与方法
6.1 数组的内存布局
数组在 Java 中是对象,无论元素是基本类型还是引用类型,数组对象本身都存放在堆中(JDK 6 起即使开启逃逸分析做标量替换,语义上仍是堆对象)。数组对象头部包含 Mark Word、类型指针和长度字段,随后是连续的元素区。
一维数组 int[] a = new int[3] 的内存布局:
栈帧局部变量表 堆
+---------+ +-----------------------------+
| a ----|------------------> | 对象头 (MarkWord + KlassPtr) |
+---------+ | length = 3 |
| [0] = 0 |
| [1] = 0 |
| [2] = 0 |
+-----------------------------+
二维数组在 Java 中其实是"数组的数组",因此天然支持不规则数组(每行长度不同):
int[][] m = new int[2][]; // 只分配了外层数组,m[0]、m[1] 都是 null
m[0] = new int[3];
m[1] = new int[5];
外层数组(堆) 内层数组(堆,各自独立、长度可不同)
+----------+ +---+---+---+
| m[0] ----|--------------->| 0 | 0 | 0 |
+----------+ +---+---+---+
| m[1] ----|--------------->| 0 | 0 | 0 | 0 | 0 |
+----------+ +---+---+---+---+---+
这就是为什么 m.length 是行数、m[0].length 是第 0 行长度,也为什么 new int[2][] 是合法的而 new int[][3] 不合法(必须先确定外层)。
6.2 初始化的三种方式与 Arrays 工具类
import java.util.Arrays;
public class ArrayDemo {
public static void main(String[] args) {
// 方式一:动态初始化(指定长度,元素取默认值)
int[] a = new int[5];
// 方式二:静态初始化(指定内容,长度由编译器推断)
int[] b = new int[]{1, 3, 5, 7, 9};
int[] c = {1, 3, 5, 7, 9}; // 简写,仅限声明时连用
// 方式三:匿名数组,用于方法传参
print(new int[]{2, 4, 6});
// 不规则数组
int[][] tri = new int[3][];
for (int i = 0; i < tri.length; i++) {
tri[i] = new int[i + 1]; // 第 i 行有 i+1 个元素
}
System.out.println(Arrays.deepToString(tri)); // [[0], [0, 0], [0, 0, 0]]
System.out.println(Arrays.toString(c)); // [1, 3, 5, 7, 9]
// Arrays 工具高频方法
int[] src = {5, 3, 1, 4, 2};
Arrays.sort(src); // 原地排序(基本类型用双轴快排)
System.out.println(Arrays.toString(src)); // [1, 2, 3, 4, 5]
System.out.println(Arrays.binarySearch(src, 4));// 3,二分查找前必须已排序
Arrays.fill(src, 0); // 全部填 0
int[] copy = Arrays.copyOf(src, 10); // 扩容拷贝,多余位补默认值
System.out.println(copy.length); // 10
}
static void print(int[] arr) {
System.out.println(Arrays.toString(arr));
}
}
| 方法 | 作用 | 注意 |
|---|---|---|
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)——对于引用类型数组,只复制引用本身,两个数组的元素仍指向同一批对象。
import java.util.Arrays;
public class CopyTrap {
static class Node { int v; Node(int v) { this.v = v; } }
public static void main(String[] args) {
Node[] src = { new Node(1), new Node(2) };
Node[] dst = Arrays.copyOf(src, src.length);
System.out.println(src == dst); // false,数组对象本身是新的
System.out.println(src[0] == dst[0]); // true,元素指向同一个 Node(浅拷贝!)
dst[0].v = 99;
System.out.println(src[0].v); // 99 —— 修改副本影响了原数组,这正是浅拷贝的副作用
// 基本类型数组没有这个问题,因为元素值就是数据本身
int[] p = {1, 2, 3};
int[] q = p.clone();
q[0] = 100;
System.out.println(p[0]); // 1,不受影响
// 深拷贝需要自己遍历并 new 出新对象
Node[] deep = new Node[src.length];
for (int i = 0; i < src.length; i++) {
deep[i] = new Node(src[i].v);
}
System.out.println(deep[0] == src[0]); // false
}
}
6.4 可变参数的原理与重载冲突
可变参数(varargs)T... args 在编译后就是一个数组,void f(int... a) 与 void f(int[] a) 的方法签名(descriptor)完全相同,无法共存于同一个类中(编译报 "cannot declare both")。
import java.util.Arrays;
public class VarargsDemo {
static int sum(int... nums) {
int s = 0;
for (int n : nums) s += n; // nums 就是 int[]
return s;
}
// static void sum(int[] nums) { } // 编译错误:与 int... 冲突(同一签名)
static void log(String prefix, Object... args) { // 可变参数必须是最后一个形参
System.out.println(prefix + Arrays.toString(args));
}
public static void main(String[] args) {
System.out.println(sum()); // 0,可变参数可以传 0 个
System.out.println(sum(1, 2, 3)); // 6
System.out.println(sum(new int[]{4, 5})); // 9,也可直接传数组
log("INFO", "a", 1);
// log("INFO", null); // 有歧义:null 既可是 (String, Object[]),也可是...
log("INFO", (Object[]) null); // 明确指定可消除歧义
}
}
6.5 方法重载的匹配规则(三阶段)
重载(overload)的决议发生在编译期,依据的是静态类型(声明类型),与运行时的真实对象无关。这一点务必与重写(override)的动态分派区分开——这是一道高频面试题。编译器按三个阶段依次尝试,一旦某阶段找到匹配就停止:
- 严格调用(strict invocation):不允许装箱/拆箱,不允许可变参数;
- 宽松调用(loose invocation):允许装箱/拆箱(含 varargs 的固定参数部分);
- 可变参数调用(variable arity):把剩余实参打包成数组。
public class OverloadRule {
static void f(int a) { System.out.println("int"); }
static void f(Integer a) { System.out.println("Integer"); }
static void f(long a) { System.out.println("long"); }
static void f(Object a) { System.out.println("Object"); }
static void f(int... a) { System.out.println("int..."); }
public static void main(String[] args) {
f(1); // int —— 阶段1 精确匹配 int
f(1L); // long —— 精确匹配 long
f(Integer.valueOf(1)); // Integer —— 精确匹配
short s = 1;
f(s); // int —— 没有 short 重载,拓宽为 int(阶段1 允许拓宽)
f('a'); // int —— char 拓宽为 int
f(1, 2); // int... —— 只有可变参数版本能匹配(阶段3)
Object obj = new Integer(1);
f(obj); // Object —— 依据变量的静态类型 Object,不是运行时的 Integer!
}
}
6.6 值传递的本质
Java 只有值传递(pass-by-value),没有引用传递。这一点常引起争议,根源在于对"值"的误解:对于引用类型,传递的值是引用的副本(地址值的拷贝),而不是对象本身,也不是"引用本身"。
public class PassByValue {
static void change(int x) { x = 100; }
static void change(int[] arr) { arr[0] = 100; }
static void reassign(int[] arr) { arr = new int[]{9, 9, 9}; }
public static void main(String[] args) {
int n = 1;
change(n);
System.out.println(n); // 1 —— 基本类型:形参是独立副本,改不动
int[] a = {1, 2, 3};
change(a);
System.out.println(a[0]); // 100 —— 引用副本指向同一对象,能改对象内容
reassign(a);
System.out.println(a[0]); // 100 —— 形参重新指向了新数组,主方法的 a 不受影响
}
}
用文本图说明 change(a) 调用时的内存状态:
调用前(main 栈帧) 调用中(main + change 两个栈帧)
main: a -> ref(0x100) ----+ main: a -> ref(0x100) ----+
| change: arr -> ref(0x100) -+--> 堆中数组 [1,2,3]
+--------> 堆中数组 [1,2,3]
(两个引用副本指向同一个对象,所以 arr[0]=100 对 main 可见)
reassign 执行 arr = new int[]{9,9,9} 后:
main: a -> ref(0x100) ----> 堆中数组 [100,2,3] (main 看到的仍是原数组)
change: arr -> ref(0x200) -> 堆中新数组 [9,9,9] (只有形参的副本改了指向)
结论一句话:形参永远无法改变实参变量本身的值,只能通过引用副本修改它所指向的对象内容。swap 方法在 Java 里无法通过交换引用来实现,就是这个原因。
6.7 递归与栈溢出
每次方法调用都会在虚拟机栈中压入一个栈帧,递归过深会耗尽栈空间,抛出 StackOverflowError(属于 Error 而非 Exception,不可恢复)。默认栈大小通常为 1MB(-Xss 可调),大约能容纳几千到上万层调用。存在循环替代方案时优先用循环;必须递归时应明确终止条件,并考虑尾递归(Java 未做尾调用优化,写了也没用)或改用手写栈。
public class Recursion {
// 经典:斐波那契,递归写法时间复杂度 O(2^n),改成循环后 O(n)
static long fib(int n) {
if (n <= 1) return n;
return fib(n - 1) + fib(n - 2);
}
static long fibFast(int n) {
if (n <= 1) return n;
long a = 0, b = 1;
for (int i = 2; i <= n; i++) {
long t = a + b; a = b; b = t;
}
return b;
}
public static void main(String[] args) {
System.out.println(fibFast(50)); // 12586269025
// try { deep(0); } catch (StackOverflowError e) { System.out.println("栈溢出"); }
}
static void deep(int i) { deep(i + 1); } // 无限递归 → StackOverflowError
}
七、输入输出基础与常见陷阱
7.1 Scanner 的 nextInt 与 nextLine 混用
这是新手 100% 会踩的坑。nextInt() 只读取数字,不会消费行尾的换行符;紧随其后的 nextLine() 读到这个残留换行符,立刻返回一个空字符串,看起来就像"被吞掉了一行"。
import java.util.Scanner;
public class ScannerTrap {
public static void main(String[] args) {
Scanner sc = new Scanner("18\nsjava\n");
// 错误示范
int age = sc.nextInt();
String name = sc.nextLine(); // 读到的是残留的换行符
System.out.println("age=" + age + ", name=[" + name + "]"); // name=[]
// 修复一:多调用一次 nextLine() 把换行符吃掉
Scanner sc2 = new Scanner("18\nsjava\n");
int age2 = sc2.nextInt();
sc2.nextLine(); // 吞掉残留换行
String name2 = sc2.nextLine();
System.out.println("age=" + age2 + ", name=[" + name2 + "]"); // name=[sjava]
// 修复二(推荐):全部用 nextLine() 读入,再自行解析
Scanner sc3 = new Scanner("18\nsjava\n");
int age3 = Integer.parseInt(sc3.nextLine().trim());
String name3 = sc3.nextLine().trim();
System.out.println("age=" + age3 + ", name=[" + name3 + "]");
// hasNext 的正确用法
Scanner sc4 = new Scanner("1 2 3");
while (sc4.hasNextInt()) {
System.out.print(sc4.nextInt() + " ");
}
sc.close();
}
}
另外,Scanner 基于正则分词,性能较差(每次读都要做模式匹配)。在算法题或大文件读入场景下,应使用 BufferedReader 或 StreamTokenizer。
7.2 高性能读入模板
import java.io.*;
import java.util.*;
public class FastInput {
// 模板一:BufferedReader + StringTokenizer,适合按空格分词的数据
static class FastScanner {
private final BufferedReader br;
private StringTokenizer st;
FastScanner(InputStream in) { br = new BufferedReader(new InputStreamReader(in)); }
String next() throws IOException {
while (st == null || !st.hasMoreTokens()) {
String line = br.readLine();
if (line == null) return null;
st = new StringTokenizer(line);
}
return st.nextToken();
}
int nextInt() throws IOException { return Integer.parseInt(next()); }
long nextLong() throws IOException { return Long.parseLong(next()); }
}
// 模板二:StreamTokenizer,纯数值读入最快的标准库方案
static class TokenScanner {
private final StreamTokenizer st;
TokenScanner(InputStream in) {
st = new StreamTokenizer(new BufferedReader(new InputStreamReader(in)));
st.ordinaryChar('\''); // 视需要调整分词字符
}
int nextInt() throws IOException { st.nextToken(); return (int) st.nval; }
double nextDouble() throws IOException { st.nextToken(); return st.nval; }
}
public static void main(String[] args) throws IOException {
// 示例:读 n 个整数求和
String data = "5\n1 2 3 4 5\n";
FastScanner fs = new FastScanner(new ByteArrayInputStream(data.getBytes()));
int n = fs.nextInt();
long sum = 0;
for (int i = 0; i < n; i++) sum += fs.nextInt();
System.out.println(sum); // 15
}
}
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 |
public class FormatDemo {
public static void main(String[] args) {
String name = "sjava";
double price = 19.9876;
int qty = 3;
System.out.printf("商品:%s,单价:%.2f,数量:%d,合计:%.2f%n", name, price, qty, price * qty);
// 商品:sjava,单价:19.99,数量:3,合计:59.96
System.out.printf("|%8s|%-8s|%n", "右", "左"); // 对齐与宽度
System.out.printf("折扣 %d%%%n", 20); // 输出百分号
System.out.printf("255 = 0x%x = 0%o%n", 255, 255); // 进制转换
// 生成字符串而不打印(用于日志拼接、文件名)
String s = String.format("%s-%03d", "order", 7); // order-007,%03d 表示补零到 3 位
System.out.println(s);
}
}
八、高频面试题与易错点清单
以下十条是本篇内容的浓缩检验,每条都给出结论 + 原因,建议先自己作答再对照。
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 位有效位到补码回绕,从操作数栈与局部变量表的分离到引用副本的传递。下一篇我们将进入面向对象的世界,重点拆解 的不可变性与字符串常量池、 与 的契约、以及为什么重写 必须同时重写 。建议把本篇的十二段示例代码逐个敲一遍并改造参数运行,尤其是 与 两段,看懂它们的输出比记住结论重要得多。