
聊到 Java 面试八股文很多人第一反应是“背背就行”。我当过面试官也被别人面过我的真实感受是八股文本身不是问题问题在于你把它当成“背答案”。面试官往前追问一层你是能讲清背后的原理和边界还是当场卡壳差距就是这么拉开的。这篇《基础篇一》我按面试提问频率最高的几块内容来梳理面向对象、方法传参与 String、equals 与 hashCode、集合框架、异常与 Lambda以及环境变量这类看起来不起眼却经常翻车的细节。适合正在准备校招或社招的 Java 工程师参考也适合刚入门不久、想系统补基础的同学对照自查。如果你已经把《Java 开发手册》之类的入门书翻过一遍但总觉得面试题看着眼熟、答起来不踏实这篇文章正好解决这个问题。我不会只给结论每一个高频考点都会讲清楚“面试官为什么这么问”“背后的机制是什么”“实际开发中踩过什么坑”。这样你答出来的东西才有信息量而不是干巴巴背概念。1. 面向对象三大特性定义之外的追问才是分水岭如果我让候选人说“什么是封装、继承、多态”十个人里有九个能背出来。但如果我接着问“封装到底封的是什么继承最大的代价是什么多态在 JVM 里是怎么实现的”很多人就开始含糊了。这三大特性是 Java 面试的绝对基础但大部分人只停留在概念层面。1.1 封装降低使用成本才是第一目的封装的定义是“把属性和方法包装在类中通过访问修饰符控制可见性”这句话本身没错但面试官真正想听的是封装的第一目的是降低调用方的使用成本。调用方只需要知道 public 方法能做什么不需要关心内部有多少字段、有多少中间状态。我举个例子。一个订单服务内部可能要校验库存、计算优惠、生成流水号如果这些步骤全部暴露出去调用方就必须按顺序调用顺序错了就出问题。封装之后对外只留一个 placeOrder()内部逻辑怎么变都不影响外部调用方。这才是封装的核心价值。访问修饰符这块面试官喜欢追问 private、protected、default、public 的区别。注意 protected 不仅同包可见子类也可以访问但有个易错点子类访问父类 protected 成员时只能通过“该子类类型或其子类类型的引用”来访问不能拿一个父类引用去访问子类继承来的 protected 成员。这个细节在笔试里反复出现值得记一下。package a; public class Father { protected int value; } package b; public class Son extends Father { public void test(Father f) { // 这里访问 f.value 会编译报错因为不能通过父类引用来访问 protected 成员 // int v f.value; // 但通过 this.value 访问没问题 } }1.2 继承复用能力的另一面是强耦合继承是 is-a 关系这是标准答案。但我面试时更愿意问的是继承到底给你带来了什么答案不只是代码复用更是一种类型体系上的抽象能力——你可以用父类引用指向子类对象这为多态提供了基础。不过继承的代价经常被忽视。父类一改子类可能全部受影响父类方法如果没设计好子类覆写时很容易破坏父类自己的内部约定。我见过一个真实项目基类某个方法里调用了几个 protected 方法后来有人覆写了其中一个导致线上行为完全变了。这种问题特别难排查因为代码看起来没问题编译也通过但运行逻辑就是不正常。所以现在很多设计规范都提倡“组合优先于继承”。面试时你如果能主动提到这一点并且说出一个具体场景会加分不少。比如策略模式、装饰器模式本质上都是用组合替代继承来解决扩展问题。1.3 多态动态绑定与“开闭”多态的实现基础是动态绑定。编译时看的是引用类型运行时 JVM 根据实际对象类型找到对应的方法实现。Java 的普通实例方法默认是虚方法但 private、static、final 方法不会参与多态。这里有几个经典考点。第一子类里定义一个和父类 static 方法签名相同的方法这不叫重写叫隐藏——调用哪个看引用类型。第二字段成员变量也不参与多态编译时按引用类型决定访问哪个字段。第三重写要求方法签名一致返回类型可以是父类返回类型的子类型协变返回但访问权限不能比父类更严格抛出的受检异常不能比父类更宽泛。从设计层面看多态的价值是开闭原则对扩展开放对修改关闭。我最常说的一句话是凡是需要大量 if-else 判断对象类型的代码大概率可以用多态重构。比如订单状态处理switch 里写一堆 case 判断状态码不如定义个 State 接口每个状态一个实现类。面试答设计题时用这个思路非常讨巧。2. 方法传参、String 与包装类型把“只有值传递”讲透面试里几乎必问Java 到底是值传递还是引用传递正确答案是Java 只有值传递。这里最容易混的点其实是“对象引用”本身也是一个值。2.1 值传递为什么方法内 new 对象不会改变外部引用方法调用时实参的值会复制一份传给形参。对于基本类型复制的是数值本身对于引用类型复制的是引用地址。所以在方法里 new 一个新对象赋给形参外部引用不会变但如果用形参直接修改对象内部字段外部能看到变化。public static void main(String[] args) { StringBuilder sb new StringBuilder(hello); change(sb); System.out.println(sb.toString()); // 输出 hello world } public static void change(StringBuilder sb) { sb.append( world); // 操作的是同一个对象 sb new StringBuilder(other); // 重新赋值形参外部引用不变 }这段代码就能解释清楚append 修改的是对象内部状态所以外部可见new 出来新对象赋给形参只是把形参这个局部变量指向了新对象和外部引用没关系。我当年面试也栽过这个细节笔试考“方法内重新赋值对象后外部引用是否改变”答案是“不变”。一句话总结你无法在方法内让外部引用指向一个新对象但可以改变对象内部的状态。2.2 String 不可变性与字符串常量池String 为什么设计成不可变原因有三个安全类加载、网络参数、反射场景下不可变更可靠、线程安全不可变对象天然线程安全、以及配合字符串常量池实现复用。面试官通常会问 String 的 拼接底层发生了什么。在 Java 8 中字面量拼接在编译期就完成了如果是变量拼接编译器会 new 一个 StringBuilder然后调用 append 方法最后 toString。问题出在循环里循环每次都会 new 一个 StringBuilder性能差所以循环内拼接推荐直接使用 StringBuilder。字符串常量池在 Java 7 之后从方法区移到了堆里。intern() 方法的作用是让字符串进入常量池并返回池中的引用。这个知识点面试频率偏高但平时开发很少用知道原理就够了不用深入。String s1 java; String s2 new String(java); System.out.println(s1 s2); // false System.out.println(s1 s2.intern()); // true2.3 包装类型缓存与 的陷阱Integer 对 -128 到 127 做了缓存所以Integer a 100; Integer b 100; a b返回 true但Integer c 200; Integer d 200; c d返回 false。拆箱之后比较没有这个问题。new Integer(100)不走缓存但new Integer(100) 100会自动拆箱结果还是 true。这类题目看似绕其实就是在考两件事缓存范围以及 和 equals 的语义。实际开发里包装类型比较我基本都用 equals或者先把其中一个拆箱省得莫名踩坑。比如从 Map 里 get 出来的 Integer和常量比较时用Objects.equals(map.get(count), 1)比map.get(count) 1稳妥得多。3. equals 与 hashCode约定的来源是集合的检索机制equals 和 hashCode 的约定是 Java 面试的高频区但很多人只背了“重写 equals 必须重写 hashCode”解释不清楚为什么。3.1 hashCode 决定了对象被放进哪个桶HashMap、HashSet 的查找流程是先调 hashCode 定位到桶再在桶里用 equals 比较。如果两个对象 equals 相等但 hashCode 不同它们会被分到不同桶HashMap 就认为它们不是同一个 key导致你 put 进去却 get 不出来。所以约定是equals 相等的对象hashCode 必须相等hashCode 相等的对象equals 不一定相等。这个约束是单向的方向不能反。对应到集合里如果 key 的 hashCode 变了但 equals 没变HashMap 里就会出现找不到旧值的情况这也是为什么 HashMap 的 key 尽量用不可变对象来设计。3.2 重写 equals 需要满足五大规则重写 equals 要满足自反性、对称性、传递性、一致性以及 equals(null) 必须返回 false。这些不是空话。最容易翻车的是对称性。比如父类和子类都用 instanceof 判断可能出现父类.equals(子类) 和子类.equals(父类) 结果不对称。典型的反面案例public class Person { protected String name; Override public boolean equals(Object o) { if (this o) return true; if (!(o instanceof Person)) return false; return Objects.equals(name, ((Person) o).name); } } public class Employee extends Person { private String company; Override public boolean equals(Object o) { if (this o) return true; if (!(o instanceof Employee)) return false; Employee e (Employee) o; return Objects.equals(name, e.name) Objects.equals(company, e.company); } }这种写法有个坑Person.equals(employee)可能返回 true但employee.equals(person)返回 false对称性就破坏了。所以实际项目中如果类可能被继承equals 和 hashCode 的实现要格外谨慎或者干脆用组合而不是继承。3.3 实际的推荐写法实际写实体类我基本都用 IDE 生成 equals/hashCode不手写。手写出 bug 的概率太高而且 IDE 生成的代码已经处理好了 null 判断、类型判断和字段比较。Override public boolean equals(Object o) { if (this o) return true; if (!(o instanceof Person person)) return false; return age person.age Objects.equals(name, person.name); } Override public int hashCode() { return Objects.hash(name, age); }注意 Java 16 之后可以用 instanceof 模式匹配o instanceof Person person一步完成判断和强转。面试时可以提一嘴显得你关注新特性。4. 集合框架高频题扩容、哈希计算和遍历删除的坑集合这块是面试的必考区也是平时写代码天天打交道的。ArrayList 和 HashMap 的底层原理基本每一场面试都会问到。4.1 ArrayList 底层与扩容机制ArrayList 底层是 Object[] 数组。默认初始容量是 10当元素数量超过容量时扩容为新容量的 1.5 倍即oldCapacity (oldCapacity 1)。扩容需要创建新数组然后用 System.arraycopy 拷贝旧数据。频繁 add 导致频繁扩容性能损耗是真实的。所以一个开发习惯如果能预估元素数量创建 ArrayList 时直接指定初始容量。比如从数据库查出一万条数据要放进 Listnew ArrayList(10000)比默认容量一路扩上去快很多。还有一个高频考点是 fail-fast。ArrayList 的 iterator 在遍历过程中如果检测到 modCount 发生变化add、remove 操作都可能导致 modCount 变化会抛出 ConcurrentModificationException。这是设计上的快速失败机制避免在遍历过程中出现不确定状态。所以如果你想在遍历时删除元素不要用 list.remove()应该用 iterator.remove()或者直接用 Java 8 的 removeIf。ListString list new ArrayList(Arrays.asList(a, b, c)); list.removeIf(s - s.equals(b)); // 推荐4.2 HashMap 底层数组链表到红黑树的演进面试一般按版本问。Java 7 的 HashMap 是数组加链表Java 8 开始链表长度超过 8 且数组长度达到 64 时链表会树化成红黑树。树化是为了应对极端哈希冲突下的查询性能退化。put 流程我建议这样答先对 key 的 hashCode 做扰动计算(h key.hashCode()) ^ (h 16)把高 16 位向低位扩散减少哈希冲突。用(n - 1) hash定位数组下标n 是数组长度。该位置为空直接放新节点不为空用 equals 判断 key 是否相同相同就替换值不同就尾插到链表或树里。元素数量超过阈值容量 * 0.75时触发扩容容量按 2 倍扩展元素重新分布。为什么数组长度是 2 的幂因为(n - 1) hash等价于 hash % n位运算更快。为什么要扰动因为低位可能分布不均匀把高 16 位异或到低位能让高位也参与下标计算减少冲突。扩容后元素要么在原位置要么在原位置加旧容量这个规律在源码注释里写得很清楚。我面试时会让候选人手写一个简易 HashMap不要求写红黑树能写出数组加链表、put 时处理冲突、get 时遍历链表就已经能说明理解了。4.3 并发场景下的集合选择HashMap、ArrayList 都不是线程安全的。面试通常会问 Hashtable 和 ConcurrentHashMap 的区别以及在遍历时删除元素该怎么做。ConcurrentHashMap 在 Java 8 里是 CAS synchronized锁粒度是单个桶所以并发性能好。Hashtable 给整个表加锁秒杀场景下基本不可用工程上很少用。CopyOnWriteArrayList 适合读多写少的场景因为写时复制整份数组读操作无锁但写操作开销大。这些工具类没有银弹要根据业务场景选。关于遍历删除我之前在一个项目里遇到过 ConcurrentModificationException 线上报错排查了半天最后发现是一个 for-each 循环里调用了 list.remove。改成了 iterator.remove 就正常了。这种问题在面试里也常出现多留意一下没坏处。5. 异常、Lambda 与容易翻车的“零散基础”这一节把热搜词里常见的“基础边角料”汇总一下异常体系、环境变量配置、标识符命名、数组越界、枚举、Lambda。这些点单独拿出来都不难但在面试和日常开发里出现的频率非常高。5.1 异常体系受检异常还是运行时异常Java 里 Throwable 下面分两大分支Error 和 Exception。Error 一般是 JVM 级别的问题比如 OutOfMemoryError、StackOverflowError这些不应该捕获捕了也处理不了。Exception 又分受检异常Checked Exception和运行时异常RuntimeException。受检异常强制处理最典型的是 IOException。写文件、读网络流的时候编译器会逼你 try-catch 或 throws这种设计的好处是强制开发者处理可能失败的场景坏处是代码嵌套很容易变得难看。我见过不少项目为了省事方法签名一路往上 throws SQLException最后堆到 controller 层整个调用链都在抛异常异常信息反而丢了。所以实际开发中业务异常通常继承 RuntimeException不强制每个调用链都 try-catch。这样代码简洁而且可以在需要的地方统一处理。自定义异常时至少提供一个构造方法接收 message 和 cause。关于资源关闭现在强烈推荐 try-with-resources 语法不需要手动 finally close实现了 AutoCloseable 的资源都会自动关闭。代码少写一堆还能避免忘了 close 导致资源泄漏。try (InputStream in new FileInputStream(a.txt); BufferedReader reader new BufferedReader(new InputStreamReader(in))) { // 业务逻辑 } catch (IOException e) { // 异常处理 }5.2 环境变量配置与 JDK 版本错乱问题热搜词里反复出现“java 环境变量配置详细教程”和“源发行版 17 需要目标发行版 17”这确实是非常常见的入门问题。环境变量主要配两个JAVA_HOME 和 PATH。JAVA_HOME 指向 JDK 安装目录PATH 里加%JAVA_HOME%\binWindows或$JAVA_HOME/binLinux/macOS。CLASS_PATH 其实现在不用配也能跑不用纠结。最容易踩的坑是装多个 JDK 之后java -version 和 javac -version 不一致甚至 java -version 是 8但 IDEA 里 SDK 指到 17。原因很简单PATH 里前面的 JDK 版本不是你想用的那个版本。建议把 JAVA_HOME\bin 放在 PATH 最前面或者检查系统变量和用户变量里有没有其他 Java 路径。IDEA 里报“源发行版 17 需要目标发行版 17”本质是 project structure 里的 SDK 版本和 module 的 language level 没对齐。检查三个地方Project SDK、Project language level、Settings 里 Java Compiler 的 target bytecode version三个都统一成同一个版本就没问题了。5.3 数组越界、枚举与 Lambda 的问法数组越界异常 ArrayIndexOutOfBoundsException 是运行时异常面试题常考“循环遍历数组时最容易犯什么错”。最常见的就是边界条件写错比如i arr.length而不是。写 for 循环时我建议有条件的直接用 for-each 或 Arrays 工具类减少手动下标操作从源头避免越界。枚举比 static final 常量好在哪里类型安全、自带实例数量控制、还能携带行为。比如订单状态枚举里可以写 next() 方法表达状态流转这比在业务代码里写一堆 if (state 某个常量) 清晰得多。public enum OrderState { CREATED { Override public OrderState next() { return PAID; } }, PAID { Override public OrderState next() { return SHIPPED; } }, SHIPPED { Override public OrderState next() { return COMPLETED; } }, COMPLETED { Override public OrderState next() { return this; } }; public abstract OrderState next(); }Lambda 的底层是 invokedynamic但面试一般不会深究到字节码层面。更常问的是函数式接口概念有且只有一个抽象方法的接口。Runnable、Comparator、Function、Predicate 这些都是。记一个技巧方法引用::是 Lambda 的简洁写法list.sort(Comparator.comparing(User::getAge))比手写 compare 方法优雅得多。5.4 标识符命名规范看似简单实则常考标识符由字母、数字、下划线和 $ 组成不能以数字开头不能是关键字。面试题爱考“下面哪些是合法标识符”比如1var不合法_id合法$name合法class不合法。这些规则平时写代码感受不深但笔试里经常出现。规范层面类名大驼峰方法名变量名小驼峰常量全大写加下划线包名全小写。这套规范不直接影响功能但直接影响代码可维护性。团队协作时命名规范比代码注释更重要因为注释会过期但命名不一致的问题会积累到难以忍受。6. 准备面试的一个实用笔记法基础篇的内容其实远不止这些集合、JVM、并发、Spring 都还能各写一个大系列。我个人的体会是八股文不要死记结论要把每个问题当成一个“为什么”自己动手写个小 demo 验证一遍印象会深很多。尤其是线程安全、fail-fast 这类行为纸上谈兵和实际跑一遍是完全不同的理解深度。我自己准备面试时有个习惯建一个笔记文档把每个高频考点按“定义、原理、经典追问、实战场景”四行来组织。这样复习时不用一本书从头翻到尾只看笔记就能快速过一遍。比如 HashMap 这一条我会写“数组链表/红黑树、扰动函数、扩容阈值、并发操作会怎样、实际项目中为什么不用 Hashtable”。几轮复习下来整个知识网络就很清晰了。这篇先到这里下一篇我会继续整理 JVM 内存区域与垃圾回收相关的高频题那块更是八股文重灾区但也是值得花时间啃透的部分。先把这一篇里的内容消化掉面试时基础关会稳很多。