☰
Java面试核心知识点全解析:从HashMap到分布式锁的进阶指南
2026/10/15 5:48:25 网站建设 项目流程

别急着往下翻答案。这篇文章是我这些年做面试官、帮人改简历、模拟面试之后,把问到吐的那些Java问题做了一次系统的整理。不是那种一眼望到头的名词解释,而是把我自己会怎么答、踩过哪些坑、哪些点容易被追问,都写进去了。如果你正在准备校招或者社招,这份清单基本可以覆盖大多数中高级岗位的常驻题库。

1. Java基础高频题:语法之外的考察点

1.1 为什么面试总是先问HashMap

HashMap在面试中的地位就像房产中介带你看的第一套房,不一定成交,但一定会看。原因很简单,它的底层涉及数组、链表、红黑树、哈希算法、扩容机制,几乎把数据结构的基础知识一网打尽。

面试官最常问的问题就三个。

第一,HashMap的底层结构是什么样的。JDK 8之后是数组+链表+红黑树。链表长度超过8、数组容量不小于64时,链表转红黑树。这里有个容易记混的细节:两个条件缺一不可,如果链表长度到了8但数组长度不到64,会先触发扩容而不是直接树化。

第二,为什么容量必须是2的幂。Hash值计算出来是一个int,需要映射到数组下标,芯片级最快的方式是取模运算的位运算等价形式,也就是hash & (length - 1)。这个公式成立的唯一前提是length是2的幂。所以put的时候发现需要扩容,都会扩成原来的两倍。

第三,为什么负载因子默认是0.75。这是空间和时间的折中。负载因子越大,链表越容易变长,查找效率下降;越小,数组越稀疏,空间浪费严重。0.75是JDK作者测试出来的一个平衡点,简单记就是泊松分布推算出来的一个结论,口诀是“空间换时间、时间换空间,0.75不偏科”。

追问环节还有一个高频变体:JDK 7和JDK 8的HashMap区别。除了引入红黑树,还有一个重点,JDK 7的头插法在并发场景下扩容会产生环形链表,导致get死循环;JDK 8改成尾插法解决了这个问题。另外,JDK 8在扩容后重哈希时,节点的新位置要么等于原索引,要么等于原索引+旧容量,这个规律就是看hash值新增的那一位是0还是1。

1.2 String、StringBuilder、StringBuffer对比

这道题看起来基础,但很容易答得丢分。

String是不可变的,final修饰的字符数组保存内容。每次字符串拼接都会创建一个新的String对象,底层是new一个StringBuilder再做toString。所以在循环里做字符串拼接,会创建大量无用对象,性能很差。

StringBuilder和StringBuffer的构造几乎一样,唯一区别是StringBuffer的方法加了synchronized,线程安全但性能稍差。现在几乎没人用StringBuffer做并发场景的字符串处理,因为那个粒度的锁太重,不如直接用局部变量或者加锁范围更合理的方案。

面试官常在这里追加一个刁钻问题:String s = new String("abc") 创建了几个对象。答案是两个——常量池里的"abc"和堆里的String对象;但如果常量池已有这个字符串,那只创建一个堆对象。还有一个紧密相关的题:s.intern()是做什么的,JDK 7之前和之后有体积差异,简单说就是去常量池里找这个字符串,找到就返回引用,没有就放进常量池再返回引用,JDK 7之后放的是堆内存的引用而非复制副本。

1.3 ArrayList和LinkedList怎么选

这道题几乎成了“背答案”重灾区,大多数人的回答是“ArrayList查询快、增删慢,LinkedList增删快、查询慢”,把面试官气得想摔杯子。

其实这个回答放在常规场景下勉强成立,但有一个典型的反例:链表增删是“找到指定节点后的O(1)”,可你删的是LinkedList中间某个位置的元素,要走到这个位置,时间复杂度是O(n)。此时总成本反而是ArrayList更优,因为ArrayLlist的System.arraycopy是一个底层C语言级别的内存拷贝,常数很小。你在头部删除的场景,两个都不快。

更准确的答案是,ArrayList基于随机访问友好的数组,有容量概念,扩容是1.5倍;LinkedList是双向链表,实现了Deque,可以做栈和队列用。日常开发选型基本只看一点——要不要按下标频繁随机访问,要就ArrayList,不要就ArrayList,因为现代CPU对数组的缓存友好性远高于链表,链表的指针跳转对Cache很不友好。真要用队列,直接上ArrayDeque。

1.4 == 和 equals 的底层差异

这题没有悬念,==比较的是栈内存里的地址,equals默认就是==,但String重写后被用来比较内容。

但面试官通常追问两个细节。第一,Integer的缓存范围是-128到127,这个范围内的Integer对象用==比较相等,超出范围不相等,原因是IntegerCache常量池在类加载时初始化了数组。第二,重写equals必须重写hashCode,因为两个相等的对象必须拥有相同的hashCode,否则在HashMap中会出现存得进去、取不出来的诡异bug。这个问题的经典场景就是往HashSet里放一个对象,值变了之后再也删不掉。

2. 并发编程:从锁到线程池的全套逻辑

2.1 synchronized和ReentrantLock谁更强

这已经不是二选一的问题,而是要看场景理解锁的本质。

synchronized是JVM层面的关键字,实现基于Monitor,进临界区走monitorenter、出临界区走monitorexit,可重入,JDK 6之后升级为轻量级锁、偏向锁、自适应自旋,锁升级机制让它在多数情况下性能并不差。ReentrantLock是JDK层面的Lock实现,API更丰富,支持公平锁、非公平锁、等待可中断、多个条件队列、tryLock超时获取。

面试官最想听到的答案可以是这样的:资源竞争不激烈时synchronized因为无锁偏向和轻量级锁机制,开销反而更低;真正需要复杂控制时,ReentrantLock灵活性更强。现在还有个不可忽视的新选项,juc包下的StampedLock提供了乐观读,读多写少场景性能很香,但要注意它不能重入,需要小心使用。

我还在项目中遇到过synchronized锁不住字符串的坑。String s = "order:" + orderId,这个字符串会进入常量池,如果多个订单号恰好拼出同一个字符串,锁就被放大到所有同类订单上。正确做法是用一个内部对象或Long作为锁标识。

2.2 volatile到底做了什么

volatile解决了两个问题,可见性和有序性。解决可见性是因为volatile变量写操作会立即刷到主内存,读操作会直接从主内存读。解决有序性是因为内存屏障,LongAdder里那段Unsafe代码和单例双重检查锁,都靠volatile挡指令重排。

很多人没搞懂的是,volatile不保证原子性。经典的count++场景,即使count声明为volatile,多线程下依然会丢值,因为++是三条指令:读、加、写,volatile只约束了读和写各自的内存语义,中间那条加操作可以被打断。所以要实现原子计数,还得用AtomicInteger的CAS或者LongAdder。

单例模式里的双重检查锁,为什么singleton必须加volatile?核心原因在于对象创建不是原子步骤:先分配内存,再初始化,最后引用指向内存。编译器可能把后两步重排,另一个线程就会拿到一个还没初始化的半成品对象。加了volatile,禁止这步重排,问题就没了。这是面试官最满意的完整回答。

2.3 线程池的七个参数和拒绝策略

线程池的考察已经到了“必须背下来”的程度,但很多人背的都是表面。

七个参数:核心线程数、最大线程数、空闲存活时间、时间单位、任务队列、线程工厂、拒绝策略。最关键的理解是,在线程池里,任务来了之后不会直接创建线程直到最大线程数,顺序是这样的:先让核心线程处理;核心线程满载后,新任务进队列等待;队列满了之后,才开始创建非核心线程;连非核心线程也满了,才触发拒绝策略。这个执行顺序很多人答错——他们以为线程池会优先开满线程再排队,实际上默认语义是“先排队、后加人”。

四种拒绝策略分别是AbortPolicy抛异常、CallerRunsPolicy让调用者线程跑、DiscardPolicy静默丢弃、DiscardOldestPolicy丢弃最老任务。生产环境我会根据业务选,核心链路希望失败就快速失败然后告警,所以用AbortPolicy更多;允许丢弃的任务如日志上报这一类,用DiscardPolicy。

线程数量怎么定的问题也绕不开。CPU密集型建议N+1,IO密集型建议2N。这个公式是理论值,但真实业务往往混合,推荐用一个动态公式:线程数 = CPU核心数 * (1 + 平均等待时间 / 平均计算时间),IO占比高,线程就多一些。还有一点技巧,真正高并发场景强烈建议用ThreadPoolExecutor自定义,配合有界队列,生产环境直接禁止用Executors.newFixedThreadPool,因为它用的是无界队列LinkedBlockingQueue,任务堆积会把内存打爆。

2.4 CAS和AQS的理解深度

CAS是乐观锁的实现基础,三个操作数:内存值V、旧的预期值A、要设置的新值B。只有V等于A时才把V改成B,循环着做,直到成功。最典型的应用是AtomicInteger、ConcurrentHashMap的put操作。

AQS是AbstractQueuedSynchronizer,很多并发工具(ReentrantLock、CountDownLatch、Semaphore)都建立在它之上。核心是一个volatile int state和CLH队列。获取锁时用CAS把state从0改成1,失败就入队,线程挂起;释放锁时把state改回0,唤醒队列里第一个线程。

面试官要是追问公平锁和非公平锁的区别,就答:非公平锁的线程在进队列前先直接CAS抢锁,抢不到才排队;公平锁直接看队列有没有前驱节点,有就老实排队。非公平锁性能更好,因为减少了线程挂起和唤醒的开销。

3. JVM与内存管理:背不完但必须成体系

3.1 内存区域怎么划分

这道题的答案必须是口算级别的熟练度。线程私有的部分:程序计数器、虚拟机栈、本地方法栈。线程共享的部分:堆、方法区(JDK 8之后叫元空间)。

每部分的职责和可能抛出的异常要一起说。虚拟机栈栈帧深度超限抛StackOverflowError,堆空间不足抛OutOfMemoryError,元空间不足也会抛OOM。JDK 8之后元空间从堆里剥离,改到本地内存,默认情况下只能用满物理内存,这解决了之前永久代容易OOM的痛点。

这里老被追问的细节是:程序计数器为什么是线程私有且没有OOM?因为它只是记录当前线程执行到哪条字节码指令,不会溢出,这个区域是唯一一个不抛OOM的。

还有一个间接考点,对象的创建过程。从类加载检查、分配内存、初始化零值、设置对象头,到执行构造方法。内存分配方式有两种,指针碰撞和空闲列表,取决于堆是否规整,而堆规整与否又取决于垃圾收集器是否带压缩整理功能。

3.2 垃圾回收算法和收集器演进

先说三件事:哪些对象要回收、什么时候回收、怎么回收。

判断可达性靠可达性分析,就是从GC Roots出发,走一遍引用链。GC Roots包括栈帧中的局部变量、静态变量、JNI引用等。别把引用计数法当答案,因为循环引用问题解决不了,JVM没走这条路。

回收算法三个:标记-清除、标记-复制、标记-整理。老套路,标记-清除有碎片,复制浪费空间但高效,标记-整理避免了碎片但移动对象贵。

收集器是这里最容易挂的地方。面试官要的是横向对比记忆法:Serial是单线程、ParNew是Serial的多线程版、Parallel Scavenge注重吞吐量、CMS追求最短停顿时间、G1把堆分成Region,可预测停顿。

CMS已经是过去的王者了,JDK 8默认还是它吗?不,JDK 8默Parallel Scavenge加Parallel Old,CMS在JDK 9开始被废弃,JDK 14被移除。新项目无脑用G1是没问题的。G1的Region把堆分成多个1MB到32MB的独立区,维护一个优先列表,每次回收价值最大的Region,这就是它能做到可预测停顿的原因。

3.3 类加载机制和双亲委派

类加载过程:加载、链接(验证、准备、解析)、初始化。外面常常把验证、准备、解析统称“连接”。

准备阶段是把静态变量分配内存并设置默认零值,比如static int a,准备后a=0,而不是赋值语句里的值;真正赋初值发生在初始化阶段,对应 方法。

双亲委派模型是必考大题。一个类加载请求会先交给父加载器,父加载器处理不了再下沉。这么做的核心意义有两个:一是防止核心API被篡改,所有人都能自定义一个java.lang.String放到classpath,如果没有双亲委派,系统就乱了;二是避免重复加载。破坏双亲委派最经典的案例是Tomcat,它需要为每个Web应用提供独立的类加载器以隔离多个部署包,Spring的SPI机制也通过ThreadContextClassLoader打破了类似限制。

3.4 有实际价值的JVM调优参数

生产环境高频的JVM参数我列一份速查。必须掌握的有-Xms和-Xmx,堆初始和最大值,建议设成相同,减少运行时扩容的开销。-Xmn是新生代大小。-XX:MetaspaceSize元空间初始大小。-XX:+UseG1GC选择G1。-XX:MaxGCPauseMillis是G1目标停顿时间。-XX:+HeapDumpOnOutOfMemoryError和-XX:HeapDumpPath会在OOM时自动导出堆快照,配合MAT分析,这是线上排查内存问题的第一抓手。

调优思路讲一句就够:绝大多数性能问题不用调参,先看是不是代码层面有巨大的无用对象。我见过一次典型的GC压力大,最后发现是日志框架在某条高频路径里把整个请求报文拼成字符串再判断要不要输出,一个小改动GC次数降了一个量级。

4. 框架与设计模式:Spring已成必考项

4.1 IOC和DI的本质

IOC的容器思想就是用容器统一管理对象的创建和依赖注入,程序员从new对象变成声明依赖。DI是IOC的具体实现方式之一。

面试时最好举一个自己项目里的例子:用构造器注入代替@Autowired字段注入。构造器注入能让依赖关系以参数形式显现,方便单测时传mock对象,还能防止循环依赖。构造器注入遇到循环依赖会直接启动失败,这是好事,接口在早期暴露问题。

还有一个经常被挖的设计维度,为什么不用new而用容器管?核心目的是解耦+便于替换。例如支付场景里,先定义一个支付接口,支付宝和微信各一套实现,容器里通过@Primary或@Qualifier路由。后续增加新支付方式,业务代码不用变。

4.2 Spring Bean的生命周期

这是框架题里最硬核的一道,大多数人只答出创建、使用、销毁三个词,明显不够。

完整链路大概是:实例化 -> 属性填充 -> Aware接口回调 -> BeanPostProcessor前置 -> 初始化方法 -> BeanPostProcessor后置 -> 使用 -> 销毁。

细化一点,实例化时如果Bean实现了BeanNameAware、BeanFactoryAware、ApplicationContextAware,会依次回调注入对应对象。然后执行BeanPostProcessor的postProcessBeforeInitialization,再走@PostConstruct和InitializingBean的afterPropertiesSet,最后是自定义的init-method。初始化完成后,BeanPostProcessor的postProcessAfterInitialization会接手,这也是Spring AOP真正发生的地方——目标Bean初始化完成后,被代理包装成代理对象。

这里可以反向问自己一句:AOP实现原理是什么?答动态代理。JDK动态代理要求目标类实现接口,CGLIB直接生成子类代理。Spring默认优先JDK代理,类没有接口时走CGLIB。现在Spring Boot 2.x之后默认把proxyTargetClass设为true,优先CGLIB。

4.3 Spring事务失效场景

事务失效是最现实的面试题,候选人能说出一条算一条,说出五条就稳了。我整理高频的几条:

  • 方法通过this调用,没走代理,@Transactional失效
  • 方法不是public,Spring默认基于动态代理无法拦截非public方法
  • 异常被吞掉,事务感知不到RuntimeException
  • 数据库引擎不支持事务,比如MyISAM
  • 多线程调用,事务切面只在主线程生效
  • 传播行为配置错误,如Propagation.NOT_SUPPORTED

生产中最容易踩的是第一种,同一个类里方法A调方法B,B标注了@Transactional,结果是A的事务没生效,因为this调用绕过了代理对象。解决办法是把事务方法放到另一个类,或注入自身代理对象,或者使用编程式事务TransactionTemplate。

4.4 Redis缓存和MySQL一致性怎么保证

这个题在Java面试里已经成了标配。核心矛盾是缓存和数据库没法做到强一致,只能追求最终一致。

常见方案是Cache Aside Pattern:读的时候先读缓存,没有就读库再回填;写的时候先更新数据库,再删缓存。为什么删缓存而不是更新缓存?因为更新缓存需要知道新值,而且并发写时更新缓存的顺序容易错,删除则简单粗暴,下次读再回填。为什么先更新库再删缓存?如果先删缓存再更新库,那么中间瞬间的读请求会打到库上,引发缓存击穿。

实际开发中,“先更库后删缓存”在极端条件下也可能有短暂不一致,但概率极低,且可以通过延迟双删(更新库后等待几百毫秒再删一次缓存)或订阅binlog异步淘汰缓存来兜底。

5. 数据库与SQL优化:追问到底的硬功夫

5.1 MySQL索引底层为什么是B+树

索引用B+树不用红黑树,也不直接用B树。判断依据见以下几个维度。

B+树内节点不存数据,只存索引,同样大小的页能容纳更多索引条目,树的高度通常为3层,从根到叶子三次磁盘IO就能定位数据。B树内节点也要存数据,相同数据量下树更矮但每层IO带回的数据量更大,且范围查询需要中序遍历。

B+树的叶子节点用指针串成链表,范围查询“between 1 and 100”只需要找到起始叶子,然后顺着链表往下扫,完美适配数据库范围扫描场景。相比之下,B树范围查询要回溯多层。

聚簇索引和非聚簇索引的区别也是经典连续题。InnoDB主键索引的叶子节点存整行数据,这就是聚簇索引;二级索引叶子存主键值。所以查询非主键字段时,可能要先查二级索引得到主键,再去主键索引回表找整行。这个过程叫回表。覆盖索引就是查询字段已经包含在二级索引里,不用再回表。

5.2 最左前缀原则和索引失效场景

最左前缀不仅仅是“查最左列才会用到索引”,准确的表述是:联合索引的匹配从最左边的列开始,一直向右匹配直到遇到范围查询就停止。比如索引(a,b,c),查询条件where a=1 and c=3,只有a能走索引;查询where b=1就完全失效。

需要留个心眼的是,MySQL 8.0之后有了索引跳跃扫描,某些情况下跳过了最左列也能使用索引,但这个更多是优化器的选择,不要把希望寄托在它上面。

索引失效的六大典型场景我顺手整理一下:

  • 对索引列做了函数操作或隐式类型转换
  • 使用LIKE且通配符在开头
  • OR连接的条件里包含非索引列
  • 联合索引不满足最左前缀
  • order by、group by与索引顺序不一致
  • 数据量极小时,优化器认为全表扫描更快而放弃索引

5.3 事务隔离级别和MVCC

四大隔离级别从低到高:读未提交、读已提交、可重复读、串行化。MySQL默认是可重复读。读未提交会有脏读;读已提交解决了脏读,但不可重复读;可重复读解决了不可重复读,但悄悄话“幻读”是InnoDB通过间隙锁和MVCC在快照读下规避了,当前读下还是可能遇到;串行化性能最差。

MVCC是InnoDB实现隔离级别的核心。每行数据带两个隐藏列:trx_id事务ID和roll_pointer回滚指针。每条更新语句会生成一条undo log版本链,事务读数据时根据ReadView判断自己能看到哪个版本。可重复读的ReadView在事务第一次读时生成并固定,读已提交是每次读都刷新ReadView。这个差异就是两者行为不同的根本。

5.4 一条SQL慢在哪里

排查慢SQL的标准路径可以概括成四步。

先看执行计划,EXPLAIN里面看type字段,从好到坏依次是system、const、eq_ref、ref、range、index、ALL。ALL就是全表扫描,是大忌。再看key字段,确认有没有走到预期索引。然后看rows,预估扫描多少行,如果几十万行只有几百条结果,大概率索引设计不合理。最后看Extra,出现Using filesort或Using temporary就说明排序和去重没走索引,需要优化索引或改写SQL。

我在工作中常用的调优手段包括:覆盖索引消除回表、拆分大查询为小批量、避免select *因为回表代价大、深分页问题用延迟关联,也就是先查出主键再关联原表取完整行。

6. 分布式场景的常见追问

6.1 分布式锁到底怎么实现

Java层面锁只对单进程有效,分布式系统需要跨进程锁。常规实现有三种路子:数据库唯一索引、Redis的SETNX、ZooKeeper临时顺序节点。

数据库方案最朴素但性能差,通常只用来兜底。Redis方案最常见,一条SET key value NX EX 30就能上锁。真正要答出水平的是下面这些细节:value要存唯一标识,释放锁时先比对再删除,避免误删别人的锁;用Lua脚本保证比对和删除的原子性;锁要加过期时间防死锁,但业务没执行完锁过期的场景,要用看门狗机制续期。

Redisson的分布式锁就是基于上述思路封装好的。进一步追问,RedLock红锁是Redisson作者提出的一种多节点容错方案,但它本身也有争议,问得不多,知道其核心思想是多节点加锁过半即成功即可。

6.2 幂等性怎么保证

这个问题的产生背景是,网络超时后客户端重试,同一个请求被执行多次,结果必须一致。

常见方案包括:唯一ID加唯一索引去重,插入前先查询,但查询本身不是原子的,所以更可靠的还是数据库唯一索引冲突时捕获DuplicateKeyException兜底;Redis的SETNX做幂等判断,适合高并发场景;状态机方式,比如订单状态只允许从待支付改为已支付,状态不存在时抛异常,天然拒绝重复流转。

设计REST接口时,POST请求用全局唯一ID(Token机制)作为幂等键,服务端存一份,重复提交直接拒绝。这套方案在后端开发面试里属于高频考点了。

6.3 负载均衡算法有哪些

这个题看起来简单,但每次都能筛掉一批只会被动的候选人。

轮询是最基础的算法,适合集群机器性能相近的情况。加权轮询解决的是机器性能差异问题。最小连接数适合长连接场景,转发给当前活跃连接最少的机器。一致性哈希解决的是缓存服务器场景下节点增删导致的大面积缓存失效,它把请求和节点都映射到哈希环上,节点变化时只影响环上的部分数据。

实际生产环境往往在Nginx层做加权轮询,在自研网关层做最小连接数。动态权重方案会结合实例的CPU、内存、RT等指标实时调权重,这已经是偏中高级架构师的话题了。

7. 从答案到技巧:面试官眼中不会明说的细则

说一句不中听但很真实的话:大部分候选人挂在面试上,不是题不会,而是“答题方式”不对。

一个建议是把答案结构化为三段:是什么、怎么用、为什么选它。比如问到Redis为什么快,不要只回“内存数据库所以快”,要补充IO多路复用、高效数据结构、单线程避免上下文切换三个层级。每一点展开谈谈,面试官才知道你不只是背了结论。

遇到不会的题,不要直接说不会。可以先说出自己的分析框架:这个问题的核心可能是XX,我现在的知识盲区出现在XX部分,但根据已知信息,我推测应该从XX方向考虑。这种表达能体现思路和沟通能力,比硬着头皮编答案强得多。

另一个很实用的经验是,给自己准备一份“项目里的数字”。面试官问项目经验时,把接口QPS、响应时间、缓存命中率、并发峰值这些指标提前准备在脑子里,用数字佐证自己的技术选型和调优效果。比如“这个接口原来RT是120ms,通过加缓存和索引优化后降到15ms”,这比任何时候背面试题都有说服力。

最后再分享一个我这几年被反复验证的判断标准:面试不会只问一个知识点,它永远是通过一个知识点探测你掌握的宽度和深度。所以别背题,要把每一道题当成一条线索,顺藤摸瓜把周边的内容都理顺。顺着HashMap摸到红黑树和扩容机制,顺着事务隔离级别摸到MVCC和锁机制,摸完了,这一轮面试基本上就稳了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询