Java 真实面试专题

13 篇 · 免费在线阅读

Java 基础与集合面试题精选

Java 后端真实面试专题 · Java 基础与集合篇

基础不牢面试官第一关就刷人。这一篇是真实面经里的开胃高频题,每题三段: ① 标准答(讲透)→ ② 拓展(成体系带出关联点和必追问的)→ ③ 怎么接到你自己的项目

年限标签:🟢 3年内 🔴 3年+


1. 🟢 面向对象的三大特性?

标准答: 先给结论:三大特性分别解决边界、复用和替换问题,但不是“用了 private、extends、接口”就算理解。面试时可以按“定义 → 运行机制 → 价值和边界”来讲。

  • 封装:把状态和改变状态的操作放在同一个类中,只暴露稳定的业务接口,隐藏内部表示。private 只能算语法手段,更重要的是让对象自己维护不变量。例如订单不能由调用方随意把状态改成“已完成”,而应通过 pay()ship() 这样的受控方法校验状态后再修改。这样数据库、缓存或字段类型变化时,调用方不需要跟着改,耦合更低。
  • 继承:子类复用父类的可见行为并进行扩展,要求子类确实满足父类的 is-a 契约。继承同时带来父子类的强耦合:父类改一个 protected 方法,多个子类可能一起受影响;因此只为“共享实现且语义稳定”的关系使用继承,其他情况优先组合/委托。
  • 多态:调用方依赖父类或接口,实际对象可以是任意实现。重写方法采用动态分派:编译器先检查引用类型是否存在该方法,运行时再根据对象真实类型选择实现;字段和 static 方法不参与这种动态分派。多态让新增实现时调用方不改,是策略、工厂等模式的基础。

一个最小例子:

interface PayService {
    PayResult pay(Order order);       // 调用方只依赖契约
}

final class WechatPay implements PayService {
    @Override public PayResult pay(Order order) { return doWechatPay(order); }
}

PayService service = channel == WECHAT ? new WechatPay() : new AlipayPay();
PayResult result = service.pay(order); // 运行时执行真实实现

判断是否真的用好多态,可以继续说明:新增 UnionPay 只增加实现和装配配置;如果调用方又出现一串渠道 if-else,说明多态边界没有设计好。

拓展

  • "多态怎么实现的?"——靠动态绑定:编译看引用类型、运行看实际对象类型。
  • 继承的缺点:耦合强,所以"组合优于继承"。
  • 多态是策略模式、工厂模式的基础。

往项目引 ⭐:"我项目多渠道支付就是多态的实战——定义支付接口,微信、支付宝各实现,用父类型引用调用、运行时执行实际实现。三大特性不是背概念,封装/继承/多态在我项目代码里到处是。"


2. 🟢 重载(Overload)和重写(Override)的区别?

标准答: 两者都叫“多态”,但发生阶段不同,判断依据也不同:

对比项重载 Overload重写 Override
发生位置同一个类,也可在继承层次中子类与父类/接口之间
方法签名方法名相同,参数个数、类型或顺序不同方法名和参数列表相同
返回值不能只靠返回值区分重载可使用协变返回类型
决定时机编译期,按静态类型和参数类型选方法运行期,按对象真实类型动态分派
常见用途给调用方提供不同入参的便利 API替换父类默认行为,实现多态
class JsonUtil {
    String toJson(Order order) { return "..."; }
    String toJson(List<Order> orders) { return "..."; } // 重载
}

class PayService {
    Object pay(Order order) { return null; }
}
class WechatPay extends PayService {
    @Override
    String pay(Order order) { return "success"; } // 协变返回值,属于重写
}

PayService service = new WechatPay();
service.pay(order); // 编译先看 PayService,运行再调 WechatPay

重写时,子类方法的访问权限不能更窄,不能抛出比父类更宽的受检异常;finalprivate 方法不能被重写,static 方法是隐藏而不是重写。@Override 应始终保留,它能把参数写错等问题提前变成编译错误。

拓展

  • "重写的限制?"——@Override 校验、不能重写 final/static/private 方法、返回值可协变。
  • 重载是编译期多态、重写是运行期多态。

往项目引 ⭐:"我项目里 Service 实现接口方法是重写、工具类提供多个参数版本的方法是重载——能各举一个例子,比背定义有说服力。"


3. 🟢 == 和 equals 的区别?为什么重写 equals 要重写 hashCode?

标准答: 先分清比较对象:

  • 基本类型使用 == 比数值;引用类型使用 == 比“是不是同一个对象”,不会调用 equals
  • Object.equals 的默认实现本质也是身份比较;String、包装类和很多值对象会重写它,按内容/字段判断相等。
  • equals 的契约包括自反、对称、传递、一致,以及与 null 比较必须为 false。参与比较的字段最好不可变,否则对象放进集合后字段变化会破坏集合定位。

HashMap/HashSet 的查找分两步:先用 hashCode 定位桶,再在桶内用 equals 精确比较。因此必须满足:如果 a.equals(b) 为 true,则 a.hashCode() == b.hashCode() 也必须为 true。反过来 hash 相同不代表 equals 相同,因为哈希冲突是允许的。

record UserKey(Long tenantId, Long userId) {}

Map<UserKey, String> cache = new HashMap<>();
cache.put(new UserKey(1L, 9L), "A");
cache.get(new UserKey(1L, 9L)); // record 同时生成匹配的 equals/hashCode,可取到 A

如果只重写 equals 而不重写 hashCode,两个逻辑相等的 key 可能落到不同桶;如果重写后又修改参与比较的字段,甚至会出现“明明放过却再也取不到”的问题。实体类作为 Map key 时,通常选择稳定的业务唯一键,并让其不可变。

这也是契约的落点:equals 判定相等时,两个对象必须先落到同一个桶(hashCode 相同),再由 equals 处理哈希冲突。

拓展

  • "hashCode 相等,equals 一定相等吗?"——不一定(哈希冲突);但 equals 相等 hashCode 必须相等。
  • 这题常和 HashMap 原理连着问。

往项目引 ⭐:"我项目里自定义对象做去重/做 Map 的 key 时,一定同时重写 equals 和 hashCode——踩过坑:只重写 equals 没重写 hashCode,导致 Set 去重失效、对象重复。"


4. 🟢 Integer 的缓存机制?Integer a=100,b=100; a==b 是 true 吗?127 呢?128 呢?

标准答Integer a = 100 会自动装箱,等价于调用 Integer.valueOf(100)valueOf 会优先从缓存取对象。Java 规范保证常量表达式在 -128127 范围内共享对象,JVM 也可能通过启动参数把上限调得更大,所以不能把“超过 127 一定不相等”当成业务规则。

Integer a = 100, b = 100;
Integer c = 128, d = 128;
Integer e = new Integer(100); // 明确创建新对象(已不推荐)

a == b;                 // 通常 true:命中缓存
c == d;                 // 通常 false:未命中缓存,不能依赖结果
e.equals(a);            // true:比较数值

== 比较包装类型时比较的是引用,拆箱、缓存和 null 都可能造成隐蔽 bug;业务代码比较数值用 Objects.equals(a, b) 或先明确判空后用 equals。参与算术运算时会自动拆箱,Integer n = null; n + 1 会抛 NullPointerException,也应在边界处处理。

拓展

  • 这是自动装箱触发的——Integer a=100 等于 Integer.valueOf(100),valueOf 在缓存范围内返回缓存对象。
  • "为什么缓存这个范围?"——小整数用得最多,缓存省内存。
  • 金额、id 比较用 Integer 一律 equals,别用 ==。

往项目引 ⭐:"我项目里订单状态、数量这些用 Integer 比较一律用 equals——曾经有人用 == 比 Integer,数值大于 127 时偶发判断错误,排查半天才发现是装箱缓存的坑。"


5. 🟢 String、StringBuilder、StringBuffer 的区别?

标准答

  • String 是不可变值对象。创建后字符序列不能改变,+concat 等操作会产生新的字符串(编译器对常量表达式会做折叠)。不可变让它可以安全地做 Map key、缓存 hashCode,也便于字符串常量池复用。
  • StringBuilder 内部维护可扩容的字符数组,append 在同一个对象上修改,单线程或线程封闭场景性能最好。容量不足时会扩容并复制,已知长度时可预设容量减少复制。
  • StringBuffer 与 Builder API 类似,但大多数方法带同步,适合确实需要多个线程共享同一个构造器的旧代码;现代代码更常让每个线程使用自己的 Builder,再在边界处合并。
StringBuilder sql = new StringBuilder(128);
for (Long id : ids) {
    if (sql.length() > 0) sql.append(',');
    sql.append('?');
}

循环中用 + 会反复创建临时对象;编译器通常只会把单个表达式优化成 Builder,无法替代跨循环的显式 Builder。StringBuffer 的线程安全也只覆盖单次方法调用,若多个调用组成复合逻辑,仍需要外部同步。

拓展

  • "String 为什么不可变?"——内部 char/byte 数组 final,好处是安全(做 key/参数)、可缓存 hashCode、支持常量池复用。
  • "String a = "a"+"b" 会创建几个对象?"——编译期常量折叠成 "ab",一个。
  • 循环拼接用 StringBuilder,别用 +(每次生成新 String + StringBuilder)。

往项目引 ⭐:"我项目里拼 SQL、拼日志、拼大字符串一律用 StringBuilder——循环里用 + 会产生大量临时对象加重 GC。并发拼接才用 StringBuffer,但那种场景很少。"


6. 🟢 接口和抽象类的区别?什么时候用哪个?

标准答: 接口和抽象类都能表达抽象,但抽象层次不同。可以用“是否共享状态和实现”来做选择:

对比项抽象类接口
继承关系一个类只能继承一个抽象类一个类可以实现多个接口
成员状态可有实例字段、构造器、任意访问级别的方法字段默认 public static final;可有 default/static 方法,不能保存实例状态
语义强调同一类对象的共同身份(is-a)强调可被不同对象复用的能力/契约(can-do)
适用场景复用模板流程、保护共同不变量对外 API、插件扩展、解耦实现

例如支付渠道都“能支付”,应依赖 PayService 接口;如果所有渠道还有统一的签名、重试和审计流程,可再用抽象类承载模板。JDK 8 的默认方法主要用于演进接口兼容性,不代表接口变成了有状态的抽象类。

interface PayService {
    PayResult pay(Order order);
    default void validate(Order order) { /* 公共校验 */ }
}

abstract class AbstractPayService implements PayService {
    @Override public final PayResult pay(Order order) {
        validate(order);
        return doPay(order);       // 模板方法:步骤固定,细节交给子类
    }
    protected abstract PayResult doPay(Order order);
}

设计上优先面向接口编程;只有当“共享实现”能长期保持稳定时才抽象成父类,否则父类会把不相关的子类绑在一起。

拓展

  • "JDK8 接口能有方法体了,还要抽象类吗?"——要,抽象类能有状态(成员变量)和构造器、做模板方法更合适。
  • 设计原则:优先面向接口编程。

往项目引 ⭐:"我项目里用接口定义支付能力(PayService)让各渠道实现,用抽象类做流程模板(公共步骤写在抽象类、差异步骤留抽象方法给子类)——按'定义能力用接口、复用实现用抽象类'来选。"


7. 🟢 Java 异常体系?检查异常和非检查异常的区别?

标准答: 异常层次从上到下是 Throwable -> Error / ExceptionError 通常表示 JVM 或运行环境无法恢复的故障(如 OutOfMemoryErrorStackOverflowError),应用不应靠捕获它继续运行;Exception 才是业务代码通常处理的异常。

Exception 里又分两类:

  • 受检异常(checked):除 RuntimeException 外的 Exception,编译器要求调用方 try-catchthrows,适合调用方有机会恢复的外部故障,如文件不存在、网络/数据库访问失败。
  • 非受检异常(unchecked)RuntimeException 及其子类,通常代表编程错误或业务校验失败,如 NPE、越界、参数非法。编译器不强制声明,Spring 项目里的业务异常通常继承它。

处理异常要保留上下文:底层异常作为 cause 传上去,不要 catch (Exception e) {} 静默吞掉,也不要把敏感堆栈直接返回给客户端。资源释放使用 try-with-resources;finally 通常会执行,但 JVM System.exit、进程崩溃等情况下不能保证。

事务还要特别注意:Spring 默认对 RuntimeExceptionError 回滚,受检异常需要显式 @Transactional(rollbackFor = Exception.class),否则可能出现业务失败但事务已提交。

判断处理策略时,先问“调用方有没有可恢复的动作”:有就保留为受检异常或转换为明确的错误结果;没有就让异常向上冒泡,由统一边界记录日志、映射错误码。

拓展

  • "Spring 事务默认对哪种回滚?"——只对 RuntimeException 和 Error 回滚,受检异常要配 rollbackFor。
  • 自定义业务异常一般继承 RuntimeException(不想强制处理)。
  • finally 一定执行(除非 JVM 退出)。

往项目引 ⭐:"我项目自定义业务异常 BusinessException 继承 RuntimeException,配合全局异常处理器统一返回错误码。知道'Spring 默认只回滚 RuntimeException',所以业务异常都是运行时异常,事务才会正确回滚。"


8. 🟢 反射是什么?有什么优缺点?能调用私有方法吗?

标准答: 反射把“编译期写死的类型操作”推迟到运行期:通过 Class<?> 获取构造器、字段、方法,再按名称/注解动态创建或调用。Spring 扫描组件、创建代理,MyBatis 把结果集映射到实体,底层都大量使用反射。

Class<?> type = Class.forName("com.example.PayHandler");
Constructor<?> ctor = type.getDeclaredConstructor(Config.class);
Object handler = ctor.newInstance(config);
Method method = type.getDeclaredMethod("handle", Order.class);
method.setAccessible(true);       // Java 9+ 还受模块开放策略约束
Object result = method.invoke(handler, order);

它的价值是插件化、通用框架和按注解装配;代价是类型检查和重构支持变弱、调用有查找/访问检查开销,并可能绕过封装。setAccessible(true) 可以在权限允许时访问 private,但在 Java 模块系统下若模块未 opens,可能抛 InaccessibleObjectException,不能把它当成绕过安全边界的手段。高频路径应缓存 Method/元数据,或使用 MethodHandle/代码生成降低开销;业务代码能静态调用时不要为了“灵活”滥用反射。

拓展

  • "反射为什么慢?"——要做安全检查、不能被 JIT 充分优化(可缓存 Method 对象缓解)。
  • 框架几乎都靠反射 + 注解工作。

往项目引 ⭐:"我项目里写过一个通用工具——用反射把 Excel 的列自动映射到实体字段,靠反射动态 set 值。理解反射也让我看懂 Spring/MyBatis 这些框架'自动注入、自动映射'的底层。"


9. 🟢 Java 集合框架的整体结构?

标准答: Java 集合先分成两个接口家族:Collection 表示一组元素,Map 表示 key-value 映射;Map 不继承 Collection

  • List 有序、允许重复:ArrayList 基于动态数组,LinkedList 基于双向链表。
  • Set 不允许重复:HashSet 按哈希、LinkedHashSet 额外保留插入顺序、TreeSet 按比较器/自然顺序排列。
  • Queue/Deque 表示队列/双端队列:优先级队列用 PriorityQueue,栈/队列通常用 ArrayDeque
  • Map 按 key 查值:HashMap 无序,LinkedHashMap 可维护插入或访问顺序,TreeMap 有序,ConcurrentHashMap 面向并发。

选型先看访问契约,再看并发和顺序需求:需要按索引读就选 ArrayList,需要去重但不关心顺序就选 HashSet,需要并发读写就选并发容器。Collections.synchronizedXxx 只是给单次操作加互斥,复合操作仍需外部同步;不能因为“线程安全”就忽略迭代和性能特征。

拓展

  • TreeMap/TreeSet 有序(红黑树)、LinkedHashMap 保留插入/访问顺序(可做 LRU)。
  • 并发用 ConcurrentHashMap、CopyOnWriteArrayList,别用 Vector/Hashtable(锁整个,性能差)。

往项目引 ⭐:"我项目按场景选集合——去重用 HashSet、要排序用 TreeMap、做 LRU 缓存用 LinkedHashMap、并发计数用 ConcurrentHashMap。能说出'什么场景用哪个、为什么'就够。"


10. 🟢 ArrayList 和 LinkedList 的区别?

标准答ArrayList 保存连续的 Object[]:按下标访问是 O(1),尾部追加均摊 O(1),中间插入/删除要移动后续元素 O(n)。扩容时会申请更大的数组并复制旧元素,容量不足前可用 ensureCapacity 预留。

LinkedList 每个节点保存前后指针:已拿到节点时在头尾插入/删除是 O(1),但按索引查找仍要从头或尾遍历 O(n),节点对象和指针还带来额外内存与缓存不友好。add(index, e) 看似是 O(1) 增删,实际通常先花 O(n) 定位节点。

因此“增删多就用 LinkedList”并不成立:大多数业务列表读多、需要遍历,ArrayList 的连续内存和 CPU 缓存优势更好;只有明确需要频繁在两端操作时,通常也优先考虑 ArrayDeque。两者都不是线程安全容器,并发场景应选合适的并发方案。

拓展

  • "ArrayList 扩容?"——默认 10,扩容 1.5 倍,Arrays.copyOf 复制。
  • "LinkedList 真的增删快吗?"——只有"已定位到位置"时快,按索引增删还要先遍历找位置。

往项目引 ⭐:"我项目里列表数据基本都用 ArrayList——随机访问多、CPU 缓存友好。LinkedList 几乎没用,因为它的'增删快'在大多数场景体现不出来还更占内存。"


11. 🟢 HashMap 的底层原理?put 流程?

标准答: JDK 8 HashMap 的核心结构是“桶数组 + 冲突链表”,链表过长时在满足条件后树化为红黑树。它不是按 key 排序的容器,也不保证迭代顺序;平均 get/put 接近 O(1),极端哈希冲突下才会退化。

一次 put 大致经过这些步骤:

  1. 首次使用时懒初始化桶数组;将 hashCode 做高低位扰动(h ^ (h >>> 16)),再用 (n - 1) & hash 定位桶。容量为 2 的幂时,位运算代替取模。
  2. 桶为空,直接放入新节点;桶不为空,先比较 hash 和 key(==equals),相同则覆盖 value。
  3. key 不同则沿链表查找;链表节点达到树化阈值 8 时,只有数组容量至少 64 才树化,否则优先扩容,避免小表过早建树。
  4. 插入后 size 超过 threshold = capacity * loadFactor 才扩容。JDK 8 扩容时节点只会留在原下标或移动到“原下标 + 旧容量”,不需要重新计算完整哈希。

HashMap 允许一个 null key 和多个 null value,但这不是并发安全保证;并发读写应使用 ConcurrentHashMap 或在边界处进行可靠同步。

拓展

  • "为什么链表长度 8 转红黑树?"——泊松分布下冲突到 8 的概率极低,转树是兜底优化查询。
  • "红黑树什么时候退化链表?"——节点 ≤6 时。
  • "为什么扰动?"——让高位也参与运算、减少冲突。

往项目引 ⭐:"HashMap 是面试'铁三角'之一,几乎必问。我项目里大量用 HashMap 做缓存和映射,理解它的扩容和冲突机制让我能合理设初始容量、避免频繁扩容。"


12. 🟢 HashMap 为什么扩容是 2 倍?初始容量多少?

标准答: 构造器的默认初始容量是 16,但这是逻辑默认值,桶数组在第一次 put 时才真正分配;默认负载因子是 0.75。当元素数量超过 capacity * loadFactor,表扩容为原来的 2 倍。

容量保持 2 的幂有两个好处:

  • 下标可用 (n - 1) & hash 计算,避免较慢的取模,并让桶分布更均匀。
  • 扩容从 n 变成 2n 时,旧节点的新位置只有两种:原下标,或原下标加 n,可根据 hash 的新增那一位快速拆分,减少重新计算和移动。

负载因子是空间与冲突的折中:调大能少扩容但冲突和查询成本上升,调小则浪费内存、扩容更频繁。已知容量时应按负载因子预估初始值(例如预计放 100 个元素,可传入约 256 的容量),并注意构造器参数会被向上取整到 2 的幂。

扩容不是“越大越好”:它会瞬间分配新数组并迁移节点,生产代码可通过容量规划避免在高峰期触发;如果只是暂存少量数据,则保持默认配置更简单。

拓展

  • "负载因子为什么 0.75?"——空间和时间的平衡,太大冲突多、太小浪费空间。
  • "为什么用位运算定位桶?"——比取模快。
  • 知道大概元素数量时,设合理初始容量避免多次扩容。

往项目引 ⭐:"我项目里如果知道 Map 大概要放多少元素,会按负载因子预留容量(如预计 100 个就设 256),避免默认 16 反复扩容、rehash 影响性能。"


13. 🟢 HashMap 为什么线程不安全?并发场景用什么?

标准答HashMap 没有对 size、桶数组或节点链接做并发协调。两个线程同时 put 可能互相覆盖、丢失其中一个节点,扩容期间还可能读到不完整结构;JDK 7 的头插法扩容甚至可能形成环形链表,导致遍历死循环。JDK 8 改了插入方式,不能因此把它当成线程安全容器。

按需求选方案:

  • 只读并且构建完成后不再修改,可安全发布一个普通 HashMap(要保证发布可见性)。
  • 多线程读写用 ConcurrentHashMap;它在空桶时用 CAS 写入,非空桶只对当前桶头节点做 synchronized,把竞争范围缩小到桶级别,并支持扩容时多线程协助迁移。
  • 需要整体快照或复合事务时,不能只依赖单次线程安全方法,仍要用外部锁、原子组合方法(compute/putIfAbsent)或不可变数据结构。

Hashtable 给大多数方法加整表 synchronized,安全但并发度低;Collections.synchronizedMap 也需要在遍历时手动锁住 map。ConcurrentHashMap 不允许 null key/value,因为并发下无法区分“没有映射”和“映射值为 null”。

拓展

  • "为什么不用 Hashtable?"——它锁整个表,性能差。
  • "ConcurrentHashMap 的 size 怎么算?"——baseCount + CounterCell 分散统计。
  • "key/value 能为 null 吗?"——ConcurrentHashMap 不能(并发下歧义)。

往项目引 ⭐:"我项目并发统计、本地缓存都用 ConcurrentHashMap,不用 Collections.synchronizedMap(锁整个)。比如各接口调用量统计用 ConcurrentHashMap<String, LongAdder>,高并发下又准又快。"


14. 🟢 Java 是值传递还是引用传递?

标准答: Java 只有值传递。调用方法时,形参拿到的是实参值的副本:基本类型复制数值;对象类型复制的是“引用值”(可以理解为指向对象的地址),并没有把调用方的引用变量本身交给被调方法。

static void change(User user) {
    user.setName("new");       // 两个引用指向同一个对象,外部能看到属性变化
    user = new User("other");  // 只改了形参副本,外部引用仍指向原对象
}

User u = new User("old");
change(u);
// u.getName().equals("new"),这里只表示内容,不用 == 比较 String

因此“对象是引用传递”是方便但不严谨的说法。若要让调用方看到整体替换,应返回新对象,或把可变容器作为返回值;不要依赖修改形参引用的副作用。理解这一点也能解释数组、集合传参:可以修改其中元素,但给形参重新 new 一个集合不会改变调用方变量。

拓展

  • 经典例子:方法里给传入的对象 set 属性,外面能看到改变(改的是同一个对象);方法里把参数指向新对象,外面不受影响(改的是引用副本)。
  • 很多人误以为对象是引用传递,其实是"传递引用的值"。

往项目引 ⭐:"理解'传引用的副本'帮我避免过 bug——以为在方法里把参数重新赋值能影响外部,其实不能。改对象内容可以、改引用指向不行。"


15. 🟢 BigDecimal 是什么?为什么金额要用它?

标准答float/double 使用 IEEE 754 二进制浮点表示,很多十进制小数无法有限表示,累计计算和比较会出现误差;金额、税率、汇率等需要十进制语义的场景应使用 BigDecimal,或按最小货币单位用 long 保存分/厘。

BigDecimal price = new BigDecimal("19.90");
BigDecimal count = BigDecimal.valueOf(3);
BigDecimal total = price.multiply(count);
BigDecimal tax = total.multiply(new BigDecimal("0.06"))
        .setScale(2, RoundingMode.HALF_UP);
BigDecimal payable = total.add(tax).setScale(2, RoundingMode.HALF_UP);

构造时优先使用字符串或 BigDecimal.valueOf(double),不要直接 new BigDecimal(0.1),后者会把 double 已有的二进制误差带进来。除法可能除不尽,必须明确 scaleRoundingMode;比较数值大小用 compareTo1.01.00 返回 0),equals 还会比较 scale。序列化和数据库字段也要统一精度、舍入规则,否则 Java 端精确并不代表系统最终一致。

拓展

  • "为什么 double 不精确?"——很多十进制小数没法用有限二进制精确表示。
  • 除法要指定精度和舍入模式,否则除不尽会异常。

往项目引 ⭐:"我项目所有金额计算用 BigDecimal、字符串构造、compareTo 比较、除法指定舍入。这是涉及钱的铁律,用 double 算钱迟早出精度事故。"


16. 🟢 fail-fast 和 fail-safe 是什么?

标准答: 这两个词不是 Java 官方接口分类,而是面试中对迭代语义的俗称。

  • fail-fast:迭代器保存创建时的结构修改计数(如 modCount),发现外部发生了未通过迭代器的结构修改,就尽快抛 ConcurrentModificationException。它只是尽早暴露并发/误用问题,不是并发安全保证,也不承诺 100% 捕获所有竞态。
  • “fail-safe”:泛指修改时迭代仍能进行的实现。CopyOnWriteArrayList 迭代固定快照,遍历看不到之后的修改;ConcurrentHashMap 迭代器是弱一致的,可能看到部分新数据,但不会因并发修改抛异常。两者语义不同,不能简单都叫“副本”。

单线程遍历删除应使用迭代器的 remove(),或用 removeIf;不要在增强 for 中直接调用集合的 remove。并发场景先明确“要快照、弱一致还是强一致”,再选容器:Copy-on-write 适合读多写少,ConcurrentHashMap 适合并发读写但不提供全局快照。

拓展

  • "怎么在遍历时安全删除?"——用迭代器的 remove(),别用集合的 remove。
  • 并发修改集合用并发容器。

往项目引 ⭐:"我项目里踩过 ConcurrentModificationException——在 for-each 里删元素。改用迭代器的 remove 或并发容器解决。理解 fail-fast 的 modCount 机制才知道为什么。"


17. 🟢 final 关键字的作用?

标准答

  • 修饰变量:基本类型只能赋值一次;引用类型只能改变“指向”,不能阻止所指对象内部状态变化。final 字段必须在声明处、构造器或初始化块中完成赋值。
  • 修饰方法:子类不能重写该方法,但仍可继承和调用;private 方法本身也不存在重写关系。
  • 修饰类:类不能被继承,常用于不可变值对象或工具类(如 String)。
final List<String> tags = new ArrayList<>();
tags.add("java");       // 可以:对象内容可变
// tags = new ArrayList<>(); // 编译错误:引用不可重新指向

final 不等于深度不可变:若要真正不可变,字段也应是 private final,并在构造时做防御性拷贝,不能把可变集合直接暴露出去。JMM 对 final 字段有“构造完成后可安全看到初始值”的特殊保证,但这不替代对象安全发布,也不保证后续可变字段的线程安全。Lambda 捕获的局部变量必须是 final 或事实 final,原因是捕获的是值副本而非可变局部变量。

拓展

  • final 字段有特殊的初始化安全保证,但不等于整个对象线程安全。
  • 局部 final 变量才能被匿名内部类/Lambda 捕获。
  • final 不等于不可变(对象内容还能改)。

往项目引 ⭐:"我项目常量用 static final、不希望被改的引用用 final、工具类用 final 防继承。Lambda 里引用的外部局部变量也必须 final(事实 final)。"


18. 🟢 深拷贝和浅拷贝的区别?

标准答: 浅拷贝只创建一层新对象:基本类型字段复制值,引用字段仍指向原来的子对象;修改嵌套对象会影响原副本。深拷贝则把需要隔离的对象图一起复制,副本和原对象不共享可变状态。

Order copy = new Order(original); // 若构造器只复制 address 引用,这是浅拷贝
copy.getAddress().setCity("Shanghai"); // original 的地址也可能被改

实现方式要按对象模型选择:手写复制构造器/转换器最可控;序列化反序列化或 JSON 转换简单但有性能、类型和日期精度成本;Object.clone() 默认是字段级浅拷贝,且 Cloneable 设计容易误用。深拷贝并不意味着所有外部资源都能复制,数据库连接、线程、缓存等应重新获取或明确共享策略。若对象是不可变的,直接共享引用通常比复制更安全高效。

拓展

  • 实现深拷贝:递归 clone、序列化反序列化、用工具(如 JSON 转换)。
  • Object.clone() 默认是浅拷贝。

往项目引 ⭐:"我项目里需要'改副本不影响原对象'时用深拷贝(常用 JSON 序列化反序列化实现)——比如把一个配置对象拷一份给某次请求改,不能影响共享的原配置。"


19. 🟢 JDK8 有哪些新特性?

标准答: 面试可以按“语言、集合处理、标准库、兼容性”归纳:

  • Lambda + 函数式接口:把行为作为参数传递,PredicateFunctionConsumerSupplier 是常用预定义接口。
  • Stream API:用 filter/map/flatMap/sorted/collect 描述数据处理流水线;中间操作惰性执行,终止操作触发遍历,适合无副作用的转换和聚合。
  • Optional:表达“可能没有值”的返回结果,减少链式 NPE;不应把它当实体字段或所有参数的包装器。
  • 新日期时间 APILocalDateLocalDateTimeInstantZonedDateTime 不可变且线程安全;和旧 Date 转换时要明确时区。
  • 接口默认/静态方法:在不破坏已有实现的前提下演进接口;java.util.function、方法引用、重复注解等也属于这一代能力。
Map<String, Long> countByStatus = orders.stream()
        .filter(Order::isValid)
        .collect(Collectors.groupingBy(
                Order::status, Collectors.counting()));

“元空间替代永久代”是 JDK 8 的 JVM 运行时变化,不是 Java 语法特性,回答时最好单独说明。Stream 并不天然更快,复杂循环、频繁装箱或并行流的线程池开销可能更慢;先保证可读性,再用基准测试决定是否优化。

拓展

  • Stream 的 map/filter/collect/groupingBy 大幅简化集合操作。
  • Optional 避免 NPE。
  • 这题常顺着问 Stream 用法、Lambda 本质(函数式接口实现)。

往项目引 ⭐:"我项目大量用 Stream 处理集合——分组统计用 groupingBy、转换用 map、过滤用 filter,代码比传统 for 循环简洁很多。也用 Optional 优雅处理可能为空的返回值。"


20. 🟢 Java 里的泛型是什么?类型擦除了解吗?

标准答: 泛型把类型参数化,让编译器在集合、接口和方法调用处做类型检查,减少强制类型转换;它主要提供编译期安全,并不让同一个泛型类为每种类型生成一份新的运行时 class。

static <T> T first(List<T> values) {
    if (values.isEmpty()) throw new NoSuchElementException();
    return values.get(0);
}

List<String> names = new ArrayList<>();
String name = first(names); // 编译器推断 T=String

Java 采用类型擦除List<String>List<Integer> 运行时通常都是 List,编译器在取值处插入转换。因此不能直接 new T()、不能用基本类型作为类型参数,也不能用 instanceof List<String>;需要运行时类型时,把 Class<T>Type 或 Jackson TypeReference<T> 显式传入。

通配符遵循 PECS:? extends T 是生产者,只适合读取为 T? super T 是消费者,可以安全写入 T;无界 ? 只承诺“某种未知类型”。泛型类的静态字段不能依赖类型参数,因为静态成员属于类而不是某个参数化实例。设计公共 API 时优先让类型参数表达真实约束,避免到处使用原始类型导致警告和运行时异常。

所以 List<String>List<Integer> 在运行时通常都是同一个 List 类;泛型安全主要发生在编译期,跨越序列化或反射边界时要显式携带 ClassType

拓展

  • "类型擦除带来什么限制?"——不能 new T()、不能用基本类型(要用包装类)、运行时拿不到泛型类型(要靠传 Class 或 TypeReference)。
  • 通配符:? extends(上界,读)、? super(下界,写)。

往项目引 ⭐:"我项目封装统一返回 Result<T> 用了泛型保证类型安全;反序列化泛型集合时因为类型擦除,要用 TypeReference 传递泛型信息——踩过'泛型运行时拿不到类型'的坑才理解类型擦除。"


你能答到第几层?

  • 三段都能答、还能往项目引:基础这块你过关稳稳的。
  • 标准答 + 拓展能成体系答:基础扎实,差把它接到项目里的实际用法。
  • 标准答都磕巴:基础是地基(OOP → 集合 → 异常 → 泛型),系统过一遍就稳,面试第一关靠它。

这是面试专题的「Java 基础与集合篇」,网站上还有并发、MySQL、Redis、Spring、微服务、消息队列、JVM、安全认证、项目场景等系统整理。 🌐 更多真实面试专题与资料:smallredtech.com 💬 想系统学 / 简历与辅导咨询,加微信:Ahongbb666(备注「面试题」)