Skip to content

八、其他高频考点(J71–J75) ​

J71 · 知识点:泛型与类型擦除 ​

【题目】关于 Java 泛型,下列说法正确的是?

A. List<String> 与 List<Integer> 在运行期是不同类型 B. 泛型通过类型擦除实现,编译后泛型信息被移除(伪泛型) C. 泛型信息在运行期仍然存在 D. 泛型方法不能声明类型参数

答案:B

【考点】类型擦除(Type Erasure):编译期检查、运行期擦除。

【结论】选 B。Java 泛型通过类型擦除实现,编译后类型实参被擦除,运行期无法按对象查到具体的泛型参数类型(声明处的 Signature 属性仍可用反射读取,详见 C 项辨析)。

【逐项辨析】

  • A 错误:泛型信息仅在编译期存在,运行期已被擦除。虚拟机并不感知 List<String> 和 List<Integer> 的区别。
  • B 正确:Java 采用类型擦除机制实现泛型,属于“伪泛型”。编译器在编译阶段完成类型检查后,将泛型类型替换为其上界或 Object,并插入必要的强制类型转换代码。
  • C 错误:类型擦除后,运行期对象上不再携带类型实参(List<String> 与 List<Integer> 的实例在运行期都是 List,list.getClass() 无法区分)。口径要拿捏准确:擦除抹掉的是「实参类型」,而声明处的泛型签名会以 Signature 属性保留在 class 文件里,getGenericSuperclass()、getGenericReturnType()、getGenericParameterTypes() 仍能读到——所以严格说是「运行期无法按对象查询实参类型」,不是「字节码里一点泛型痕迹都没有」。本题按教材口径判 C 错。
  • D 错误:泛型方法完全可以声明类型参数,例如 public <T> T method(T t),其中 <T> 就是方法级别的类型参数声明。

【知识点】Java 泛型的核心实现机制是类型擦除(Type Erasure),这是 Java 与 C++ 模板、C# 泛型的本质区别。编译器在处理泛型代码时,会执行以下操作:一是类型检查,确保类型安全;二是类型擦除,将泛型参数替换为原始类型(raw type)或其上界;三是插入强制类型转换代码。例如,List<String> 在字节码层面变为 List,get() 返回的 Object 会被自动转换为 String。这种设计虽然保证了与旧版本字节码的兼容性,但也带来了限制:无法创建泛型数组(如 new T[10] 编译失败)、无法使用 instanceof 判断泛型类型(如 if (list instanceof ArrayList<String>) 编译错误)、无法获取泛型参数的实际类型(Class.getGenericSuperclass() 等反射手段只能拿到声明信息)。这些限制统称为“伪泛型”的代价。

【记忆锚点】“编译看泛型,运行变 Object;想造数组和判断,编译器直接拦。”

【易混对比】

对比维度Java 泛型(类型擦除)C++ 模板C# 泛型
实现机制类型擦除,运行时无泛型信息编译期代码生成,类型实例化运行时保留泛型信息(reified)
能否创建泛型数组不能能能
instanceof 泛型不能能能
类型膨胀无(一份字节码)有(每种类型生成代码)无(JIT 优化)
兼容性与 Java 1.4 字节码兼容无历史包袱.NET 2.0 起支持

换问法:以下哪项是 Java 泛型无法做到的? A. 编译期类型检查 B. 运行时获取 List<String> 的元素类型 C. 避免强制类型转换 D. 实现类型安全的集合

答:选 B。类型擦除后运行期无泛型信息,无法直接获取 List<String> 的元素类型;编译期类型检查、避免强转、集合类型安全都是泛型能做到的。

【自测】

java
List<String> list1 = new ArrayList<>();
List<Integer> list2 = new ArrayList<>();
System.out.println(list1.getClass() == list2.getClass());

上述代码输出什么?

答:输出 true。因为类型擦除后,list1 和 list2 的运行时类型都是 ArrayList,getClass() 返回同一个 Class 对象。 与第 J72 题连考。

【知识关联】

  • 同库关联:与 J31(集合泛型)、J21(不能 instanceof 泛型参数)、J11(重载与擦除)。
  • 实现层:擦除为上界/Object;桥接方法;Class 对象不携带类型实参。
  • 面试追问:① 如何获取泛型信息(TypeToken)?② 擦除带来的限制?

【拓展延伸】

  • 变式问法:List<String> 与 List<Integer> 的 class 是否相同。
  • 版本差异:Java 泛型擦除模型长期不变;与 C++ 模板/C# reified 对照。
  • 工程注意点:JSON 反序列化传 TypeReference;避免原始类型;通配符 PECS。

J72 · 知识点:枚举 ​

【题目】关于枚举(enum)的说法,正确的是?

A. 枚举可以用 new 实例化任意多个对象 B. 枚举不能定义构造器 C. 枚举类隐式继承 java.lang.Enum,不能再继承其他类,但可以实现接口 D. 枚举常量的 == 与 equals 结果不同

答案:C

【考点】枚举的本质:继承 Enum 的特殊类。

【结论】选 C。枚举类隐式继承 java.lang.Enum,受单继承限制不能再继承其他类,但可以实现多个接口。

【逐项辨析】

  • A错误:枚举常量是预定义的有限实例,由 JVM 在类加载时自动创建,不允许使用 new 关键字创建新的枚举实例。尝试 new Color.RED 会导致编译错误。
  • C正确:所有枚举类型都隐式继承 java.lang.Enum,由于 Java 的单继承机制,枚举类不能再 extends 其他类,但可以通过 implements 实现一个或多个接口。
  • B错误:枚举可以定义构造器,但构造器必须是 private(或 package-private,隐式为 private),用于在定义枚举常量时传入参数,例如 RED(“红色”, 1)。
  • D错误:枚举常量是单例的,每个常量在整个 JVM 中只有一个实例,因此 == 和 equals() 的结果始终相同,都是比较引用地址。

【知识点】枚举(enum)是 Java 1.5 引入的特殊类类型,其底层实现是一个继承自 java.lang.Enum 的 final 类。编译器会为枚举类自动生成若干关键成员:一是静态的枚举常量字段(public static final);二是静态的 values() 方法,返回所有枚举常量的数组;三是静态的 valueOf(String) 方法,根据名称获取枚举常量。枚举的构造器只能由编译器在类加载阶段调用,开发者无法在外部实例化。枚举天然具备单例模式的特性,因此是实现单例的最佳实践(Effective Java 推荐)。此外,枚举支持定义抽象方法,每个常量可以实现不同的行为,这被称为“策略枚举”模式。枚举还自动支持 Serializable 和 Comparable,compareTo() 按照常量声明的顺序比较。

【记忆锚点】“Enum 是 final 类,单继承、多接口;构造必私有,单例是本质;== equals 都一样,常量加载即定型。”

【易混对比】

对比维度枚举 enum普通 class接口 interface
继承能力隐式继承 Enum,不能再继承类可继承一个类可继承多个接口
实现接口可以实现多个接口可以实现多个接口本身就是接口
实例化编译期固定,运行时不可 new可随时 new不能实例化
构造器只能 private任意访问修饰符无构造器(Java 8+ 可有 default/static)
比较方式== 与 equals 等价equals 通常需重写无实例可比较
线程安全类加载时创建,天然线程安全需额外保证—

换问法:以下关于枚举的说法,错误的是?A. 枚举可以实现接口;C. 枚举构造器可以是 public;B. 枚举是线程安全的单例;D. 枚举支持 switch-case 语句。

答:选 C。枚举构造器只能是 private(或隐式 private),不能声明为 public。

【自测】

java
public enum Status {
    ACTIVE("激活"), INACTIVE("未激活");
    private final String desc;
    Status(String desc) { this.desc = desc; }
    public String getDesc() { return desc; }
}

上述枚举的构造器能否改为 public Status(String desc)?为什么?

答:不能。枚举构造器隐式为 private,即使不写 private 修饰符,编译器也会将其视为 private。显式声明为 public 或 protected 会导致编译错误。这是为了确保枚举常量的数量在编译期固定,防止外部通过 new 创建新的实例破坏枚举的语义。 与第 J73 题连考。

【知识关联】

  • 同库关联:与 J04(switch 用枚举)、J14(私有构造)、单例模式最佳实践。
  • 实现层:继承 Enum;values/valueOf 生成;类加载时创建常量,天然线程安全单例。
  • 面试追问:① 枚举单例为何防反射/序列化破坏?② 策略枚举?

【拓展延伸】

  • 变式问法:枚举实现接口;EnumMap/EnumSet 性能。
  • 版本差异:JDK5 引入;后续稳定。
  • 工程注意点:状态机/常量优先枚举而不是 int;不要在枚举构造里依赖未初始化常量。

J73 · 知识点:移位运算 ​

【题目】int i = 5; i = i << 2; 执行后 i 的值是?

A. 10 B. 15 C. 20 D. 25

答案:C

【考点】左移 n 位相当于乘以 2^n。

【推导过程】

  1. 初始值 i = 5,其二进制表示为 32 位 int:0000 0000 0000 0000 0000 0000 0000 0101
  2. 执行 i << 2,将所有位向左移动 2 位,右侧补 0:0000 0000 0000 0000 0000 0000 0001 0100
  3. 转换回十进制:0001 0100 = 16 + 4 = 20
  4. 等价数学运算:5 × 2² = 5 × 4 = 20

【结论】选 C。5 左移 2 位后得到 20,等价于 5 乘以 4。

【逐项辨析】

  • A 错误:10 是 5 左移 1 位(5 × 2¹ = 10)的结果,而非左移 2 位。
  • B 错误:15 是 5 加 10 的结果,与移位运算无关,属于干扰项。
  • C 正确:5 的二进制为 101,左移 2 位后变为 10100,即十进制的 20。
  • D 错误:25 是 5 的平方,与移位运算无关,属于干扰项。

【知识点】Java 的移位运算符包括左移(<<)、带符号右移(>>)和无符号右移(>>>)。左移运算的规则是将操作数的所有位向左移动指定的位数,右侧空出的位用 0 补齐,其数学效果等同于乘以 2 的 n 次方(在没有溢出的情况下)。带符号右移(>>)左侧空出的位用原数的符号位补齐,数学效果等同于除以 2 的 n 次方并向负无穷取整;无符号右移(>>>)左侧空出的位始终用 0 补齐,对于正数与 >> 结果相同,对于负数则会变成很大的正数。关键细节:对于 int 类型,移位位数只使用低 5 位(即实际位移量为 n % 32);对于 long 类型,只使用低 6 位(即实际位移量为 n % 64)。因此 1 << 33 等价于 1 << 1,结果为 2。移位运算的优先级低于加减乘除但高于比较运算,建议用括号明确意图。

【记忆锚点】“左移乘二,右移除二;无符号右移变正数,负数慎用要牢记。int 取低五位,long 取低六位,移再多也循环回。”

【易混对比】

运算符名称左侧补位适用场景示例(以 -8 为例)
<<左移右侧补 0快速乘以 2 的幂-8 << 2 = -32
>>带符号右移左侧补符号位快速除以 2 的幂(保留符号)-8 >> 2 = -2
>>>无符号右移左侧补 0提取字节、哈希计算等-8 >>> 2 = 1073741822

换问法:byte b = -8; int result = b >>> 2; result 的值是多少? A. -2 B. 2 C. 1073741822 D. 编译错误

答:选 C。byte 先符号扩展为 int 得 0xFFFFFFF8,无符号右移 2 位后为 0x3FFFFFFE = 1073741822。

【自测】

java
int x = -1;
System.out.println(x << 33);

上述代码输出什么?为什么?

答:输出 -2。因为对于 int 类型,移位运算符只使用右侧操作数的低 5 位,33 的二进制低 5 位是 00001,即 33 % 32 = 1。因此 x << 33 等价于 x << 1。-1 的二进制为 32 个 1,左移 1 位后变为 32 个 1 后面补 0(实际是最低位变为 0),即 0xFFFFFFFE,对应的十进制为 -2。 与第 J74 题连考。

【知识关联】

  • 同库关联:与 J05 同考点(建议连做);与 J34 扰动函数 >>>。
  • 实现层:左移低位补 0;溢出回绕(如 1<<31 为负)。
  • 面试追问:① 1<<31 是多少?② 无乘法器时代为何用移位?

【拓展延伸】

  • 变式问法:5<<2 与 5*4;移位与溢出。
  • 版本差异:语义稳定。
  • 工程注意点:可读性优先写乘除,除非是热点位运算;注意 int 溢出。

J74 · 知识点:强制类型转换 ​

【题目】double d = 3.99; int i = (int) d; 执行后 i 的值是?

A. 3 B. 4 C. 3.99 D. 编译错误

答案:A

【考点】浮点转整型的截断规则(向零取整)。

【推导过程】

  1. 声明并初始化 double 变量 d = 3.99
  2. 执行强制类型转换 (int) d,将 double 类型转换为 int 类型
  3. 强制类型转换的规则是截断小数部分,向零取整(truncate towards zero)
  4. 3.99 截断小数部分后得到 3,舍弃 0.99
  5. 最终 i 的值为 3

【结论】选 A。double 强制转换为 int 时直接截断小数部分向零取整,3.99 变为 3。

【逐项辨析】

  • A 正确:(int) d 执行截断操作,3.99 的小数部分被直接丢弃,结果为 3。这是 Java 规范中定义的向零取整行为。
  • B 错误:4 是四舍五入的结果,但强制类型转换不会进行四舍五入。若要得到 4,应使用 Math.round(3.99) 返回 long 后再强转,或直接使用 (int) Math.round(3.99)。
  • C 错误:3.99 是 double 类型,而 i 是 int 类型,无法直接存储小数。此外,强制转换是编译期允许的合法操作,不会保留原值。
  • D 错误:double 到 int 的强制类型转换在 Java 中是合法的窄化转换(narrowing conversion),虽然可能丢失精度,但不会导致编译错误。

【知识点】Java 中的强制类型转换(cast)遵循明确的数值转换规则。对于浮点到整型的转换,规范明确规定为“向零取整”(round toward zero),即直接丢弃小数部分,不进行任何舍入操作。这意味着正数变小时接近零,负数变大时也接近零,例如 (int) -3.99 的结果是 -3,而非 -4。需要注意的是,如果浮点数值超出目标整型的范围,结果将是目标类型的最大值或最小值(int 为 2³¹-1 或 -2³¹),具体取决于浮点值的具体大小。与强制转换不同,Math.round() 方法执行的是四舍五入(实际上是加 0.5 后向下取整),返回 long 类型;Math.floor() 返回小于等于参数的最大整数值(向负无穷取整);Math.ceil() 返回大于等于参数的最小整数值(向正无穷取整)。这三者与强制转换的语义完全不同,需根据业务场景谨慎选择。

【记忆锚点】“强转向零走,小数直接丢;四舍五入用 round,地板天花板要记熟。”

【易混对比】

方法/操作行为描述示例 (3.99)示例 (-3.99)
(int)向零取整,截断小数3-3
Math.round()四舍五入,返回 long4-4
Math.floor()向负无穷取整3.0-4.0
Math.ceil()向正无穷取整4.0-3.0
(int) Math.floor()先 floor 再强转3-4

换问法:以下代码执行后 i 的值是多少?double d = -3.99; int i = (int) d; A. -3 B. -4 C. 3 D. 编译错误

答:选 A。强制类型转换向零取整,-3.99 截断小数部分后为 -3,而非四舍五入的 -4。

【自测】

java
double d1 = 1.7e9 + 0.5;
int i1 = (int) d1;
double d2 = 3e9;
int i2 = (int) d2;
System.out.println(i1 + ", " + i2);

上述代码输出什么?为什么?

答:输出 "1700000000, 2147483647"。首先 1.7e9 等于 1700000000,在 int 范围内,(int) d1 截断后为 1700000000。其次 3e9 超出了 int 的最大值 2147483647,根据 IEEE 754 到 int 的转换规则,超出范围的浮点值转换为 int 时,如果值大于 Integer.MAX_VALUE,结果为 Integer.MAX_VALUE,即 2147483647。如果值小于 Integer.MIN_VALUE,结果为 Integer.MIN_VALUE。 与第 J75 题连考。

【知识关联】

  • 同库关联:与 J03(整数除法截断)、J06、J29;与 Python int() 向零取整对照(P09/P19)。
  • 实现层:d2i 向零截断;溢出饱和为 Integer.MAX/MIN(与 C 不同的回绕行为)。
  • 面试追问:① (int)Double.NaN 是多少(0)?② (int)1e10?

【拓展延伸】

  • 变式问法:3.99 与 -3.99 转 int;Math.round/floor/ceil 区别。
  • 版本差异:语义稳定。
  • 工程注意点:金额不要用 double 强转;溢出检查用 Math.toIntExact。

J75 · 知识点:接口默认方法与继承链 ​

【题目】执行下列代码,输出结果是?

java
interface A {
    default void f() { System.out.print("A"); }
}
class B implements A {
    public void f() { System.out.print("B"); }
}
class C extends B { }
new C().f();

A. A B. B C. 编译错误 D. AB

答案:B

【考点】接口默认方法在继承链中的查找规则。

【推导过程】

  1. 接口 A 定义了默认方法 f(),输出 "A"
  2. 类 B 实现了接口 A,并显式重写了方法 f(),输出 "B"
  3. 类 C 继承自类 B,没有重写 f() 方法
  4. 创建 C 的实例并调用 f(),JVM 开始方法解析:
    • 第一步:在 C 类中查找 f() → 未找到
    • 第二步:在 C 的直接父类 B 中查找 f() → 找到 public void f(),执行输出 "B"
    • 方法解析结束,不会继续查找接口 A 中的默认方法
  5. 最终输出结果为 "B"

【结论】选 B。类 B 重写了接口 A 的默认方法 f(),类 C 继承 B 时获得的是 B 中的重写版本,因此调用 new C().f() 输出 B。

【逐项辨析】

  • A 错误:接口 A 中的默认方法 f() 已经被实现类 B 重写。一旦实现类提供了具体的重写实现,接口的默认方法就不再被使用,子类继承的是重写后的版本。
  • B 正确:方法解析遵循类优先原则(class-before-interface)。JVM 在解析方法时,先在类的继承链中查找,如果找到匹配的方法就直接使用,只有当类继承链中找不到时,才会去接口的默认方法中查找。
  • C 错误:代码完全可以正常编译和运行。C 继承 B,B 实现了 A 且重写了 f(),C 可以合法地调用 f()。
  • D 错误:Java 的方法调用不会自动串联多个实现。f() 是一个普通方法调用,只会执行最终解析到的那一个方法体,不会先执行 A 的默认方法再执行 B 的重写方法(除非 B 中显式调用 A.super.f())。

【知识点】Java 8 引入的默认方法(default method)旨在允许在不破坏现有实现类的情况下,向接口添加新方法。当涉及默认方法的继承冲突时,JVM 遵循以下解析优先级(由高到低):一是类中声明的实例方法永远优先于接口默认方法;二是在子接口中重写的默认方法优先于父接口中的默认方法;三是如果多个无关接口提供了同名同参的默认方法,实现类必须显式重写该方法以消除歧义,否则编译错误。这一规则被称为“类优先于接口”原则,确保了向后兼容性——即使接口新增了默认方法,已有的类继承链行为也不会改变。此外,如果一个类同时继承了一个类和实现了一个接口,且类和接口有同名方法,类的实现总是优先,这也与 Java 一贯的单继承语义一致。

【记忆锚点】“类优先,接口垫后;子接口覆盖父接口;多接口同名必须重写,不然编译就报错。”

【易混对比】

场景解析结果说明
类重写默认方法类的方法优先类优先原则
子接口重写父接口默认方法子接口版本优先接口继承链中的重写
多个接口提供同名默认方法实现类必须显式重写编译器报错,强制消除歧义
类继承和接口默认方法同名类的方法优先即使类没有 implements 该接口
接口中调用默认方法使用 InterfaceName.super.method()在实现类或子接口中访问

换问法:以下代码能否编译通过?interface A { default void f() {} } interface B { default void f() {} } class C implements A, B {} A. 能编译,输出 A B. 能编译,输出 B C. 编译错误 D. 运行时异常

答:选 C。多个无关接口提供同名默认方法时,实现类必须显式重写以消除歧义,否则编译错误。

【自测】

java
interface A {
    default void f() { System.out.print("A"); }
}
interface B extends A {
    default void f() { System.out.print("B"); }
}
class C implements B { }
class D implements A, B { }

执行 new C().f() 和 new D().f() 分别输出什么?

答:new C().f() 输出 "B",因为 B 是 A 的子接口,重写了默认方法 f(),C 实现 B 时继承的是 B 的版本。new D().f() 同样输出 "B",因为 D 同时实现了 A 和 B,在接口继承链中 B 作为子接口重写了 f(),优先级高于父接口 A,因此无需 D 显式重写,自动继承 B 的版本。 与第 J71 题连考。

【知识关联】

  • 同库关联:与 J17(接口 default 方法)、J12(重写规则)、J16(抽象类与接口)构成「抽象与多态」题群;与 J13(多态)对照记忆「编译期类型 vs 运行期类型」。本题还与 J20(初始化顺序)连带考查:C 的实例化会先初始化 B 的字段与构造,再解析方法表。
  • 实现层:javac 在生成 C 的方法表时,会把父类 B 已重写的 f() 直接继承进 C 的 vtable;接口 default 方法只有在类继承链中找不到同名同参实例方法时,才会被填入 vtable 的接口槽位。字节码层 invokevirtual C.f() 解析顺序为:C → B → Object;接口默认方法走 invokeinterface 或在类解析失败后的 default 查找,本题在 B 层已命中故终止。
  • 面试追问:① 若 B 不实现 f(),仅 C extends B implements A,输出是什么?(A 的 default)② 多接口 default 同名冲突时,A.super.f() 的调用前提是什么?(必须在直接实现该接口的类中显式重写)③ default 方法能否被声明为 static/private?(Java 9+ 接口允许 private default 辅助方法;static 接口方法不参与继承解析)

【拓展延伸】

  • 变式问法:① 给出「接口 A default f、接口 B default f、class C implements A,B」问编译结果(必须显式重写);② 给出「class B implements A { void f() }」(去掉 public)问能否编译(不能,重写访问权限不能收窄);③ 给出「class C extends B implements A」且 B 未重写、A 有 default,问输出(A 的默认实现)。
  • 版本差异:default 方法自 Java 8 引入;Java 9 增加接口 private 方法,便于 default 之间抽公共逻辑;Java 17/21 无语义变化。与 Kotlin 接口、C# 默认接口方法(.NET Core 3.0+)对照:C# 同样采用「类优先」,但对菱形冲突更早报错。
  • 工程注意点:向已有接口追加 default 是兼容手段,但会静默改变「未显式重写」的实现类行为,发布前需评估调用方;框架 SPI 扩展接口优先用 default 提供空实现或默认逻辑;团队规范可要求「多实现类场景禁止依赖 default 默认体,必须显式重写」。阿里 Java 规约亦提醒 default 方法滥用会破坏接口抽象边界。

持续学习,持续积累。