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
2
3
4
5
6
7
8
9
10
11
12
13
14
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。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
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 拆就会得到乱码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
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。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
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 忽略了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
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。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
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"); 完全合法。

1
2
3
4
5
6
7
8
9
10
11
12
13
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 只是让编译器根据初始化表达式推断出确定的静态类型,字节码与手写类型完全一致,运行时零开销。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
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):左侧已能确定结果时右侧不再执行。这既是性能优化,也是副作用陷阱。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
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 最原生的操作,在权限系统、状态压缩、高性能容器中大量使用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
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 先自增再返回值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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,让主流程保持在最外层。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
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 做穷尽性检查
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
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 或数组索引
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
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。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
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] 的内存布局:

1
2
3
4
5
6
7
8
栈帧局部变量表                     堆
+---------+ +-----------------------------+
| a ----|------------------> | 对象头 (MarkWord + KlassPtr) |
+---------+ | length = 3 |
| [0] = 0 |
| [1] = 0 |
| [2] = 0 |
+-----------------------------+

二维数组在 Java 中其实是“数组的数组”,因此天然支持不规则数组(每行长度不同):

1
2
3
4
5
6
7
8
9
10
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 工具类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
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)——对于引用类型数组,只复制引用本身,两个数组的元素仍指向同一批对象。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
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”)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
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)的动态分派区分开——这是一道高频面试题。编译器按三个阶段依次尝试,一旦某阶段找到匹配就停止:

  1. 严格调用(strict invocation):不允许装箱/拆箱,不允许可变参数;
  2. 宽松调用(loose invocation):允许装箱/拆箱(含 varargs 的固定参数部分);
  3. 可变参数调用(variable arity):把剩余实参打包成数组。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
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),没有引用传递。这一点常引起争议,根源在于对”值”的误解:对于引用类型,传递的值是引用的副本(地址值的拷贝),而不是对象本身,也不是”引用本身”。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
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) 调用时的内存状态:

1
2
3
4
5
6
7
8
9
调用前(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 未做尾调用优化,写了也没用)或改用手写栈。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
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() 读到这个残留换行符,立刻返回一个空字符串,看起来就像”被吞掉了一行”。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
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 高性能读入模板

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
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
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
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 位有效位到补码回绕,从操作数栈与局部变量表的分离到引用副本的传递。下一篇我们将进入面向对象的世界,重点拆解 String 的不可变性与字符串常量池、equals 与 hashCode 的契约、以及为什么重写 equals 必须同时重写 hashCode。建议把本篇的十二段示例代码逐个敲一遍并改造参数运行,尤其是 BitMask 与 PassByValue 两段,看懂它们的输出比记住结论重要得多。