上周有个读者给我发了金现代的一套Java面试题,原题不算长,都是老面孔:i++ 和 ++i 的区别是什么?&&、&、| 有什么区别?自动装箱和自动拆箱有哪些坑?int 等基本类型的最大值怎么取?我看完第一反应是这题不难,但他说了一句话让我挺有感触:每个问题后面,面试官都会追问一个“为什么”,前两个还能勉强答,追到第三层就卡住了。
这其实暴露了一个很普遍的问题:很多人准备基础题是在“背结论”,而不是在“建体系”。Java基础题不是背个“i++先赋值后自增,++i先自增后赋值”就完事的,面试官真正想看你有没有从字节码、类型系统、边界条件这些角度去理解它们。这篇文章不打算给你一份“背诵答案”,而是把这四类题拆开揉碎,说说每道题背后的原理、常见变体,以及答题时可以主动抛出去的加分点。无论你是刚学Java,还是准备跳槽冲刺,都建议花二十分钟把这块补扎实。
1. 关于i++与++i,面试官真正想考察的三层认知
1.1 结论很简单,但面试官不会只看结论
先用最基础的说清楚。假设有这样一个片段:
int i = 1; int a = i++; // a = 1, i = 2 int b = ++i; // b = 3, i = 3i++ 这种后缀形式,表达式的值取自增前的旧值,变量本身再自增;++i 这种前缀形式,先把变量自增,表达式的值取自增后的新值。很多人在这一步都能答对,但面试官通常会立刻抛出一道容易算错的题:
int i = 0; for (int j = 0; j < 100; j++) { i = i++; } System.out.println(i);很多人凭直觉以为是100,实际输出是0。原因在于 Java 中i = i++的执行逻辑是:先取出 i 的旧值 0 作为表达式结果,然后 i 自增为 1,最后把表达式结果 0 赋回给 i,硬生生把自增的 1 覆盖掉了。这个例子告诉面试官,你不光知道结论,还知道“赋值动作发生在自增动作之后”,这是第一层认知。
1.2 从字节码看 i++ 和 ++i 的本质差异
第二层认知是把问题拉到 JVM 指令层面。写一段简单代码:
public void test() { int i = 0; int a = i++; int b = ++i; }用javap -c反编译这段代码,关键指令大概是这样的(不同JDK版本会在行号信息上略有差异,但核心指令一致):
0: iconst_0 1: istore_1 2: iload_1 3: iinc 1, 1 6: istore_2 7: iinc 1, 1 10: iload_1 11: istore_3注意看int a = i++对应的是iload_1在前面,iinc 1, 1在后面;而int b = ++i对应的是iinc 1, 1在前面,iload_1在后面。
这里的关键是iinc指令,它直接在局部变量表上对变量做加一操作,根本不经过操作数栈。后缀自增是先iload把变量的当前值压入操作数栈,然后iinc修改局部变量;前缀自增是先iinc修改局部变量,再iload把新值压入操作数栈。所以两者不是“一个在赋值前、一个在赋值后”这么简单,而是操作数栈和局部变量表之间的交互顺序完全不同。能把这个讲清楚,面试官基本就能看出你不是死记硬背。
1.3 复合自增表达式:最容易被算错的一类题
第三层认知是复合表达式中的执行顺序。Java 对表达式的求值有一个明确规则:从左到右计算操作数,然后按照运算符优先级决定执行顺序。经典题:
int i = 1; i = i++ + ++i; System.out.println(i);计算过程拆开看:
- 左边
i++:返回旧值1,执行后 i 变为2; - 右边
++i:先把 i 从2自增到3,返回新值3; - 1 + 3 = 4,最后把4赋给 i。
所以输出是4。再看一道:
int i = 1; int j = (i++) + (i++) + (++i); System.out.println(j); System.out.println(i);第一个 i++ 返回1,i变2;第二个 i++ 返回2,i变3;第三个 ++i 先把 i 从3变4,返回4。因此 j = 1 + 2 + 4 = 7,i 最终是4。
这种题在 Java 里结果是有确定答案的,因为 Java 语言规范明确规定了求值顺序,和 C/C++ 里常见的“未定义行为”不是一回事。不过我还是要说一句,生产代码里没人会这么写,面试官出这种题主要是考察求值顺序和前缀后缀的掌握程度,而不是鼓励你写花里胡哨的表达式。
1.4 除了答案,还要带上并发安全这个加分项
答完上面的内容后,如果还想再上一个台阶,可以主动补一个知识点:i++ 不是原子操作。从字节码也能看出来,i++至少包含了读取、自增、写回这几个过程,多线程环境下多个线程同时执行 i++,会导致更新丢失。这也是为什么在高并发场景下要用AtomicInteger或LongAdder,而不是直接对普通 int 加一。
你可以这样对面试官说:如果只是单线程局部变量,i++ 和 ++i 没有任何性能差异;但如果是多线程共享变量,无论前缀后缀都不是线程安全的,需要 CAS 或者锁机制来保证原子性。这句话一出来,面试官通常会认为你的知识面已经超出了“背答案”的层面,能拉开和其他候选人的差距。
2. &&、&、|:别把位运算和逻辑运算混为一谈
2.1 同一个符号在不同场景下的两种身份
标题里的 && 和 & 很容易被当成同一类运算符,其实它们有本质区别。&& 只能作用于布尔值,是逻辑与运算;& 既可以用在布尔值上,做逻辑非短路与,也可以用在整数上,做按位与运算。同理,|| 和 | 也是这种关系。
下面这张表值得记在脑子里:
| 运算符 | 操作数类型 | 是否短路 | 典型用途 |
|---|---|---|---|
&& | boolean | 是 | 逻辑与判断 |
& | boolean / 整数 | 否 | 布尔与、按位与 |
| ` | ` | boolean | |
| ` | ` | boolean / 整数 | 否 |
^ | boolean / 整数 | 无 | 异或 |
很多人不知道的是,&和|即使作用在布尔值上,也是“非短路”的。也就是说,左右两边的表达式都会被完整求值,这在某些场景下会造成隐蔽的Bug,也会成为面试题里的埋伏点。
2.2 短路行为是考点,更是写安全代码的护身符
面试题最喜欢问的问题是:下面这段代码会不会抛出空指针异常?
public boolean check(String s) { return s != null & s.length() > 0; }答案是会。因为&不会短路,当 s 为 null 时,s != null已经算出 false,但右边s.length()仍然会被执行,于是直接 NPE。如果改成s != null && s.length() > 0,左边为 false 时右边根本不会执行,也就不会有空指针。
类似的考点还有:
public boolean check(int x) { return x > 0 || increaseAndReturn(); }如果 x 大于0,increaseAndReturn()不会执行;用|就会执行,造成不必要的方法调用或副作用。因此,短路运算符不仅是为了性能,更多时候是为了安全地处理前置判断,比如判空后再访问属性、判断边界后再除零、判断类型后再强转。
2.3 位运算的真实业务场景:从权限位到状态判断
& 和 | 作为位运算符的价值,在真实的项目中其实经常出现,只是很多人不常写,慢慢就忘了。面试官考位运算,往往是想看你是不是只懂语法、不懂应用。
最常见的场景是权限系统。一套简单的文件权限可以用三个二进制位表示:
int READ = 1 << 0; // 0001 int WRITE = 1 << 1; // 0010 int EXEC = 1 << 2; // 0100 int user = READ | WRITE; // 0011,表示可读可写 boolean canRead = (user & READ) != 0; // true boolean canExec = (user & EXEC) != 0; // false用位掩码做权限,好处是多个权限用同一个 int 就能存储,判断时按位与一次即可,比多个 boolean 字段省空间,也比字符串拼接更规整。
另一个高频应用是从整数里提取字节。比如常见 RGB 颜色值是用一个 int 存三个颜色分量,取值时就需要移位和按位与:
int rgb = 0x336699; int red = (rgb >> 16) & 0xFF; int green = (rgb >> 8) & 0xFF; int blue = rgb & 0xFF;还有经典的奇偶判断:
boolean odd = (n & 1) == 1;以及判断一个数是不是2的幂:
boolean powerOfTwo = n > 0 && (n & (n - 1)) == 0;这些代码在网络协议解析、图像处理、嵌入式通信等领域非常常见。能把业务场景和位运算结合起来说,是面试中的加分项。
2.4 运算符优先级埋下的雷:为什么源码里总是带括号
和按位运算相关的常见翻车点,是运算符优先级。Java 中==的优先级高于&,所以n & 1 == 0实际上会被解释成n & (1 == 0),也就是n & boolean,这在 Java 里编译不过去。
正确的写法是:
boolean isEven = (n & 1) == 0;我在 review 代码时经常看到类似的问题:
// 错误,虽然编译可能不通过 if (x & y == 0) { } // 正确 if ((x & y) == 0) { }逻辑运算符同样有优先级问题。&&的优先级高于||,所以a || b && c等价于a || (b && c),那正好符合大多数人预期,但不加括号时仍然容易给阅读带来负担。位运算符&、^、|的优先级也存在层级差异,&高于^,^高于|。
我个人建议在真实的业务代码里,凡是同时出现位运算、逻辑运算或比较运算,不要纠结优先级,直接用括号把意图圈出来。面试官问到这个点时,你甚至可以主动说出结论:Java 的优先级设计并不完全符合直觉,与其死记硬背,不如用括号让代码自己会说话。
3. 自动装箱与自动拆箱:包装类的温柔与陷阱
3.1 装箱拆箱在编译期里到底发生了什么
自动装箱和自动拆箱是 Java 5 引入的语法糖。所谓装箱,就是把基本类型转换成对应的包装类;拆箱则是把包装类转换成基本类型。比如:
Integer a = 100; // 自动装箱 int b = a; // 自动拆箱第一行代码并不是真的直接创建一个Integer对象,而是编译成调用了Integer.valueOf(100)。第二行代码也并不是语法层面直接赋值,而是编译成调用了a.intValue()。用javap -c反编译一下就能看到这两个方法调用。
这一点非常关键,因为很多陷阱恰好藏在valueOf和intValue的实现细节里。如果只知道“自动装箱就是转成包装类”,那下面这些连环坑基本躲不过去。
3.2 Integer缓存:为什么127等于128不等于
对于 Integer 来说,valueOf方法内部并不是每次都创建一个新对象。它的实现大致是这样的:当传入的值在某个缓存区间内,直接返回缓存数组里已有的对象,超过区间才new Integer。默认缓存区间是-128到127。
因此会出现一个看起来很反直觉的现象:
Integer a = 127; Integer b = 127; System.out.println(a == b); // true,两个是同一个对象 Integer c = 128; Integer d = 128; System.out.println(c == d); // false,两个是不同对象因为 127 在缓存区间内,a 和 b 引用同一个缓存对象;128 超出缓存区间,c 和 d 分开创建了两个对象,用==比较引用当然不相等。如果换成一个 new 出来的对象,结果就更明显:
Integer e = new Integer(127); Integer f = 127; System.out.println(e == f); // false,new 出来的对象不在缓存里这个知识点其实可以延伸到一个经常被问的问题:为什么这样设计?因为 -128~127 是最常用到的数值区间,缓存这些小整数能显著减少对象创建,提升性能。面试官如果追问,你还可以补充:Integer 缓存上限可以通过 JVM 参数-XX:AutoBoxCacheMax调大,对业务里大量使用小整数封装对象的场景有一定帮助。
3.3 判等、空指针和重载的连环坑
自动拆箱容易引发空指针异常,这是面试中出现频率相当高的考点。
Integer x = null; int y = x; // x.intValue(),抛出 NullPointerException更隐蔽的是在混合运算中:
Integer x = null; Integer y = 1; int z = x + y; // 先拆箱,再相加,x 为 null,抛 NPE在判断包装类是否相等时,正确的做法永远是equals或者intValue()之后再用==。但equals也有一个需要注意的点:如果你把一个Integer和别的类型比较,比如Integer和Long,用equals也会返回 false,因为类型不匹配。最好的建议是先把它们拆成基本类型再比较数值。
自动装箱还会影响方法重载的选择。Java 在确定调用哪个重载方法时,会优先选择不需要装箱的调用,也就是基本类型的拓宽转换优先于自动装箱。比如:
void test(long x) { } void test(Integer x) { } test(1); // 会调用 test(long),不会调用 test(Integer)如果两个重载分别是test(Integer)和test(int...),那么test(1)会选择装箱成Integer的版本,因为固定参数比可变参数优先级更高。这类问题在面试中不算少见,但很多人平时没注意,等到被问才发现自己从来没系统想过重载时的选择顺序。
3.4 float、byte等基本类型在装箱拆箱中的单独说明
很多人把 Integer 的缓存规则套用到所有包装类上,这是不对的。Java 各包装类的缓存范围并不一致:
- Byte、Short、Integer、Long 都有自己的缓存区间,其中 Byte 全部缓存,Short、Integer、Long 默认缓存 -128~127;
- Character 缓存 0~127 的字符;
- Boolean 只有两个值,直接缓存 TRUE 和 FALSE;
- Float 和 Double 没有缓存,因为浮点数太多,不像整数那样有连续且常用的区间。
所以下面的结果是稳定可复现的:
Float f1 = 1.0f; Float f2 = 1.0f; System.out.println(f1 == f2); // false,因为每次都创建新对象再来看 byte。Byte b = 1;是合法的,因为数字字面量 1 是 int 类型的常量表达式,但它在符合 byte 范围内,可以直接窄化为 byte,然后自动装箱。如果写成Byte b = 200;就会编译报错,因为 200 超出 byte 范围。
还有一种常见但是比较细的点是:自动拆箱时浮点类型可能发生精度损失。int 转 float、long 转 float 或 double 时,虽然不会抛异常,但大数可能丢失精度。比如float f = 16777217;实际得到的是 16777216.0,因为 float 的尾数只有24位,无法精确表示所有超过 2^24 的整数。这类“不报错但是结果悄悄变错”的行为,在排查线上问题时往往最费时间。
3.5 性能问题:循环里的隐形对象创建
自动装箱的语法很舒服,但代价是创建了额外的包装对象。最典型的反例是在循环里累加:
Integer sum = 0; for (int i = 0; i < 100000; i++) { sum += i; }这段代码每次执行sum += i时,都会把 sum 拆箱成 int,做加法,再装箱成 Integer。等于每循环一次就要创建一个新的 Integer 对象,虽然现代 JVM 有逃逸分析等优化,但代码反编译出来仍然能看到大量Integer.valueOf调用。在高频循环或大数量场景下,直接使用 int 累加会更可靠。
面试时如果提到这个,建议顺便补充一句:在能明确不会逃逸的场景下,JVM 可能会做标量替换消除对象分配,但程序逻辑上依赖这种优化并不合适,老老实实写基本类型永远是更稳妥的选择。
4. 取int等基本类型的最大值:从常量到源码级理解
4.1 Integer.MAX_VALUE是多少,为什么是这个数
Integer.MAX_VALUE的值是 2147483647,等于十六进制的0x7fffffff。这串十六进制看起来像天书,拆开看就明白:
- int 在 Java 中占 32 位,采用二进制补码表示有符号整数;
- 最高位是符号位,0 表示正数,1 表示负数;
- 要表示最大正数,符号位必须是 0,剩下 31 位全部是 1。
所以最大正数的二进制是:01111111 11111111 11111111 11111111,换算成十进制就是2^31 - 1 = 2147483647。
Java 的源码里这样定义:
@Native public static final int MAX_VALUE = 0x7fffffff;这里的@Native只是表示可能与原生代码有关,不改变常量本身的含义。面试官如果问到 int 最大值,你不仅能背出 2147483647,还能解释补码和0x7fffffff的来源,这一题基本就稳了。
4.2 不背常量,用位运算推导最大值
有些面试官会问:如果让你不用Integer.MAX_VALUE,怎么计算出 int 的最大值?其实答案就是回到二进制位模型。
一个经典的写法是:
int max = ~(1 << 31);解释一下:1 << 31将 1 移动到最高位,也就是符号位变成1,其余为0,得到的值恰好是 int 最小值 -2147483648,对应十六进制0x80000000。对它按位取反,得到0x7fffffff,也就是 2147483647。
还有一个不太好理解的写法:
int max = (1 << 31) - 1;因为1 << 31已经是 int 最小值,再减 1 会触发整型溢出回绕,结果会绕回到最大值。这种写法虽然也能得到 2147483647,但可读性太差,我不建议在代码里这么写,面试时提一嘴“我们知道它溢出回绕了”可以证明你对补码运算有理解,但真正的生产代码还是老老实实写常量。
byte 和 short 的最大值也可以按位推:
byte maxByte = (byte) ((1 << 7) - 1); // 127 short maxShort = (short) ((1 << 15) - 1); // 32767long 的最大值推法类似:
long maxLong = ~(1L << 63); // 9223372036854775807L这里一定要用1L而不是1,否则移位时按 int 运算,结果在赋值给 long 前就已经错了。
4.3 各基本类型最大值一览,附上容易混淆的边界
下面把 Java 基本类型的位宽和最大最小值列出来,方便面试前快速过一遍:
| 类型 | 位数 | 最小值 | 最大值 | 获取方式 |
|---|---|---|---|---|
| byte | 8 | -128 | 127 | Byte.MAX_VALUE |
| short | 16 | -32768 | 32767 | Short.MAX_VALUE |
| int | 32 | -2147483648 | 2147483647 | Integer.MAX_VALUE |
| long | 64 | -9223372036854775808 | 9223372036854775807 | Long.MAX_VALUE |
| char | 16 | 0 | 65535 | Character.MAX_VALUE |
| float | 32 | -Float.MAX_VALUE | Float.MAX_VALUE | Float.MAX_VALUE |
| double | 64 | -Double.MAX_VALUE | Double.MAX_VALUE | Double.MAX_VALUE |
这里要特别提醒一个容易踩的坑:Float.MIN_VALUE并不是 float 类型的最小负数值,而是“最小正非零值”,大概是 1.4E-45。float 真正的最小负值是-Float.MAX_VALUE,因为 float 的符号位独立,正数和负数的绝对值范围是对称的。很多人在面试时说“float 的最小值是Float.MIN_VALUE”,直接把面试官逗笑了,所以这个边界一定要分清。
4.4 拿到最大值之后,面试官还会追问溢出处理
问完怎么取最大值,面试官几乎必问下一步:最大值加一会发生什么?
int max = Integer.MAX_VALUE; System.out.println(max + 1);结果是-2147483648,也就是 int 的最小值。这就是整型溢出,它不会抛异常,只会按照补码规则回绕。很多人第一次看到这个现象会以为是 Bug,但它其实是 Java 语言规范里定义好的行为。
生产代码里如果要避免溢出,通常有几种做法。最简单的是用更大的类型承接:
long safe = (long) a + b;Java 8 开始,Math类提供了一批精确运算方法,溢出时会抛异常:
try { int result = Math.addExact(max, 1); } catch (ArithmeticException e) { // 溢出被显式捕获 }如果数值范围可能超过 long,就需要BigInteger或者使用BigDecimal。还有一个比较常见的手写判断:
if (a > Integer.MAX_VALUE - b) { // 相加会溢出 }这里是把溢出判断转化为减法比较,避免先算后错。面试时如果能把溢出后回绕、异常、大数方案这类完整链路讲出来,这道最大值题就答得非常丰满了。
4.5 延伸一步:不转字符串怎么求 int 位数
这类基础题有个很有意思的延伸,也是面试官经常顺手抛的:给定一个 int,不转成 String,求它的十进制位数。
最简单的循环写法是这样:
public static int countDigits(int n) { if (n == 0) { return 1; } int count = 0; while (n != 0) { count++; n /= 10; } return count; }这里有两个边界要注意。第一,负数可以直接用n != 0作为循环条件,因为-123 / 10直到结果为0也能正确统计位数,符号不影响;第二,Integer.MIN_VALUE的绝对值 2147483648 超出了 int 正数范围,如果先取绝对值再循环就会越界。稳妥做法是先把 n 转成 long 处理:
public static int countDigits(int n) { long x = n; if (x == 0) { return 1; } x = Math.abs(x); int count = 0; while (x > 0) { count++; x /= 10; } return count; }这道延伸题本身不算难,但它和前面的最大值问题放在一起,恰好能看出一个人对待边界条件的敏感度。面试官喜欢通过这种不起眼的小地方判断你写代码是“跑通就行”,还是“真的一直在考虑极端情况”。
我自己在带新人时,经常说一句话:基础题最怕的不是你不会,而是你觉得自己会,但从来没往深处看过。像 i++、&&、自动装箱、MAX_VALUE 这些知识点,每个都能用两三行代码验证,也能用 javap 看到背后真实的执行过程。面试前与其背十道八股,不如亲手反编译一次,把每个“为什么”落在根上,这种准备方式比任何答案清单都管用。