☰
Java面试题别只背答案:HashMap、JVM、并发与调优的源码追问
2026/11/2 5:33:27 网站建设 项目流程

简介:这份《Java面试八股文2023最新版》面向准备跳槽或系统梳理Java知识体系的开发者,尤其适合有一到三年工作经验、需要应对中初级Java岗位面试的人群。内容以高频面试题为线索,串联Java SE基础、集合与数据结构、JVM内存管理与垃圾回收、多线程并发、异常处理,以及Spring、Spring Boot、Spring Cloud、MyBatis、ZooKeeper、Dubbo、Elasticsearch、Redis、MySQL、Nginx、Linux等技术栈,帮助读者按模块查漏补缺并建立回答思路。资源包共1个PDF文件,大小约9.43MB,目录按基础篇、JVM篇、多线程&并发篇、Spring篇等划分,题目编号清晰,便于检索和对照复习。目前已有15846人学习,适合用作面试前的知识清单、专项突破和快速回顾材料,也可结合项目经验整理个性化答案。

1. 两个false背后的同一条追问链

3 * 0.1 == 0.3返回false,Integer a = 128, b = 128; a == b也返回false。这两道题在 Java 面试八股文里通常被归进「基础篇」,很多人背完结论就翻页了:一个说浮点数精度丢失,一个说包装类缓存范围是 -128 到 127。答案没错,但停在这里等于什么都没学到。真正值钱的是往下追一层:0.1 为什么在二进制里表示不出来,==在包装类上到底比较了什么,Integer.valueOf的缓存数组是什么时候填的,为什么缓存上界偏偏是 127 而不是 100 或 200。一份按技术栈分册整理的 Java 面试题集,三百多道题摊开在基础、JVM、并发、框架、中间件、网络这些目录下,它最大的用处不是提供三百个标准答案,而是每一道题都能当成一个入口,顺着它往回走到语言规范、虚拟机实现和 JDK 源码里去。搞懂这条链的人,遇到没背过的题也能推;只背结论的人,换个问法就翻车。下面挑几条最典型的链路拆开看。

2. HashMap 的桶数组:为什么长度必须卡在 2 的 N 次方

2.1 一次取模,为什么要换成位运算加扰动

put一个键值对时,JDK 干三件事:算 hash、定位桶、处理冲突。定位那行代码是关键:

// 摘自 JDK 8 HashMap.putVal 的桶定位 if ((p = tab[i = (n - 1) & hash]) == null) tab[i] = newNode(hash, key, value, null);

这里n是 table 数组长度,hash是扰动后的散列值。为什么不用更直观的hash % n?因为在 CPU 上整除是一条慢指令,而位与是一个时钟周期就能完成的运算。当n是 2 的整数次幂时,n - 1的二进制是一串连续的 1,hash & (n - 1)的结果与hash % n完全等价,但快得多。这条等价关系是整个设计的支点:长度必须是 2 的 N 次方,否则位运算取模不成立。

代价也随之而来。n - 1只覆盖低位,假设 table 长度是 16,参与运算的只有 hash 的低 4 位,高 28 位全部被丢弃。如果一批 key 的 hashCode 恰好低位相同、高位不同,它们会全部挤进同一个桶。扰动函数就是来补这个窟窿的:

// JDK 8 HashMap.hash:高 16 位与低 16 位异或 static final int hash(Object key) { int h; return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16); }

h >>> 16把高 16 位无符号右移到低位,再与原值异或,效果是把高位信息折叠进低位,让低位不再是单一来源。为什么选异或而不是与、或?与运算会让结果偏向 0,或运算偏向 1,都会破坏 0/1 的均匀分布;异或能同时保留两个操作数各自的随机性,是散列混合里最常用的手段。还有一个细节值得注意:key 为 null 时 hash 直接返回 0,所以 null 永远落在 0 号桶,这就是 HashMap 允许一个 null 键的实现基础。Hashtable 直接对 key 调hashCode(),null 会抛空指针,所以它连一个 null 键都不许有——两个类的这点差异,本质是两行代码的选择。

2.2 初始容量、0.75 负载因子与扩容时的链拆分

new HashMap<>(1000)并不会得到一个长度 1000 的数组,构造函数只是把阈值暂存起来,真正建表在第一次 put 时:

// 返回大于等于 cap 的最小 2 的幂 static final int tableSizeFor(int cap) { int n = cap - 1; // 减 1,处理 cap 本身已是 2 的幂的边界 n |= n >>> 1; // 把最高位的 1 依次向右复制 n |= n >>> 2; n |= n >>> 4; n |= n >>> 8; n |= n >>> 16; // 此时低位全为 1 return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1; }

传 1000 进去,出来是 1024。但这 1024 不是最终阈值,真正的扩容阈值是capacity * loadFactor,即 1024 × 0.75 = 768。也就是说,想装 1000 个元素却只给 1000 的初始容量,照样会扩容一次。负载因子取 0.75 是空间与冲突的折中:调低会让数组更稀疏、冲突更少但内存浪费;调高会让桶更拥挤、查询退化成链表遍历。0.75 大致对应「期望探测次数接近常数」的区间,也是泊松分布下桶内链表长度分布最合理的取值点之一。

扩容阶段有一处常被忽略的优化:

// JDK 8 resize():按 hash 在 oldCap 那一位是 0 还是 1 拆成两条链 if ((e.hash & oldCap) == 0) { if (loTail == null) loHead = e; else loTail.next = e; loTail = e; // 低位链:索引仍是 j } else { if (hiTail == null) hiHead = e; else hiTail.next = e; hiTail = e; // 高位链:索引变成 j + oldCap }

容量翻倍后n - 1只多出一个最高位,所以一个元素的新索引要么原地不动,要么正好加上旧容量,判断依据就是 hash 在oldCap对应位上是不是 1。一次与运算替代了重新计算索引,这是 JDK 8 相对 JDK 7 的实打实改进。JDK 7 里每个元素都要重新算一次索引,而且用头插法把新节点塞到链头——并发扩容时两条链可能首尾相接成环,后续get一旦落进这个桶就是死循环,CPU 直接被一个查询线程打满。这条血泪经验后来被固化成「HashMap 非线程安全」的标准答案,但真正的原因不是「会丢数据」这么轻描淡写,而是会挂死。

2.3 fail-fast 是尽力而为,不是并发安全

集合的迭代器内部维护一个expectedModCount,集合每次结构性修改(增删、扩容导致 rehash)就让modCount自增,迭代器在next()里比对两者:

final Node<K,V> nextNode() { Node<K,V>[] t; Node<K,V> e = next; if (modCount != expectedModCount) // 结构性修改检测 throw new ConcurrentModificationException(); if (e == null) throw new NoSuchElementException(); ... }

这里最容易被误读成「迭代时不能改集合」。准确的说法是:不能在迭代器之外改。单线程里正确做法是iterator.remove(),它会同步刷新expectedModCount;直接调map.remove(k)就会在下次next()时炸。更值得注意的是,fail-fast 只是一次没有同步保护的脏读检测,多线程下既可能漏检也可能误报,它提供的是一份「尽早暴露问题」的契约,不是任何并发安全保证。真要并发,Hashtable用 synchronized 锁整张表,粒度粗但语义简单;JDK 8 的ConcurrentHashMap用 CAS 加 synchronized 锁单个桶头节点,扩容时靠 ForwardingNode 协助迁移,读操作几乎不加锁。这三者的差别不是「线程安全/不安全」两个标签,而是锁的粒度从整表缩到桶、再到无锁读的演进。

3. JVM:一个对象从new到被回收,中间隔了多少层机制

3.1 分配内存那一步,为什么需要 TLAB

new指令在字节码层面只是触发一次内存分配,但 JVM 内部要走一串流程:检查常量池里能否定位到类符号引用、类是否已加载解析初始化、然后才分配内存。分配方式取决于堆是否规整:用带压缩的收集器时,堆里已用和空闲泾渭分明,靠一个指针往后挪就行,这叫指针碰撞;用标记清除这类不压缩的算法时,空闲块散落在各处,必须维护一张空闲列表来查找可用块。

真正有工程价值的细节是并发。堆是所有线程共享的,如果每个线程分配对象都去动同一个指针,就必须加锁或 CAS,高频分配下这条路径会成为瓶颈。TLAB 就是为此设计的:在新生代 Eden 区里给每个线程预先划出一小块私有区域,线程在自己那块里分配不需要任何同步,用完了再申请新的一块。它是默认开启的,大小默认约占 Eden 的 1%,可以通过-XX:TLABSize显式指定,-XX:-ResizeTLAB关掉自适应调整。一个常见的调优误判是看到大量 TLAB 相关的 GC 日志就去调参,其实TLAB waste高往往说明对象太大、放不进 TLAB,该检查的是大对象直接进老年代的阈值设置。

「对象一定分配在堆上吗」这道题的标准答案在开启逃逸分析后就不成立了。JIT 在编译期分析方法内创建的对象有没有逃出方法或线程:没有逃逸,就可以做标量替换,把对象拆成几个局部变量直接放在栈帧上;进一步还能做锁消除,把没人竞争的同步块直接抹掉。相关开关是-XX:+DoEscapeAnalysis和-XX:+EliminateAllocations,默认都开。所以更准确的回答是:语义上对象在堆上,优化后可以不在。

3.2 对象头的 4 个 bit,决定了分代年龄只能到 15

对象在内存里由对象头、实例数据、对齐填充三部分组成。对象头里的 Mark Word 是个复用的结构,同一个 32 位或 64 位空间在不同状态下装不同的东西:无锁状态下存 hashCode、分代年龄、偏向标志和锁标志位;偏向锁状态下存持有锁的线程 ID 和 epoch;轻量级锁状态下存指向栈帧中锁记录的指针;重量级锁状态下存指向监视器的指针。

分代年龄只占 4 个 bit,最大值就是 15。这不是随手定的数,而是位宽决定的硬上限——所以-XX:MaxTenuringThreshold默认值 15,超过这个值对象还没死就直接进老年代。理解这一点,才能明白为什么「对象年龄到 15 岁晋升」这条规则改不动:想改得先改对象头布局。

分代本身基于三条假说:绝大多数对象朝生夕死;熬过越多次回收的对象越难死;跨代引用相对少数。前两条支撑新生代采用复制算法,把内存切成 Eden 和两块 Survivor,比例 8:1:1,Minor GC 时把存活对象挪到 Survivor 再整体清空 Eden,代价是浪费一块 Survivor 和一次复制。第三条支撑用记忆集——把老年代切成一个个卡片,用 Card Table 标记哪些卡片里存在指向新生代的引用,Minor GC 只扫这些卡片,不必遍历整个老年代。反过来,老年代满了触发 Full GC,那才是真正意义上需要扫全堆、通常也伴随明显停顿的一次。

3.3 Stop The World 与安全点:为什么停顿躲不掉

垃圾回收要准确判断对象死活,前提是引用关系在标记期间不能变。如果一边标记一边有线程新建或断开引用,就可能把活对象当垃圾清掉,或者漏掉本该回收的。所以多数收集器在标记阶段需要 Stop The World。

但线程不是随时能停的。如果一条指令执行到一半被挂起,栈帧和寄存器里的内容跟 OopMap 对不上,根节点枚举就会出错。OopMap 是一张记录「栈帧某个偏移、某个寄存器里存的是不是引用」的表,它不是每条指令都生成——那样太占空间——只在特定位置生成,这些位置就叫安全点:方法调用、循环跳转、异常跳转。GC 触发时,JVM 不是强行挂起线程,而是把「该进安全点了」写进一个约定的内存页,用户线程在自己代码里轮询这个页,发现了就主动停在最近的安全点上。

这就解释了一个实际现象:一段没有方法调用、又用 int 做计数器的大循环,可能长时间不经过安全点,导致 STW 被拉长。补偿手段是加循环安全点相关的选项,但根本办法还是在循环体里放一点能触发安全点检查的操作。排查这类问题需要打开安全点日志:

# 同时观察 GC 停顿与安全点耗时 java -Xms2g -Xmx2g -XX:+UseG1GC \ -XX:+PrintGCDetails \ -XX:+PrintGCApplicationStoppedTime \ -Xlog:safepoint=info \ -jar app.jar

-XX:+PrintGCApplicationStoppedTime打印每次全局停顿的总时长,-Xlog:safepoint=info才能看出这段时间里有多少花在真正做 GC、多少花在等所有线程到达安全点。两者差值大,说明问题不在收集器参数,而在代码里存在长时间不进入安全点的路径——这是典型的「参数调了半天没用」场景。

顺带说一句元空间替换永久代的动机。永久代在堆内,大小受-XX:MaxPermSize限制,回收条件苛刻,类加载器一多就容易抛永久代溢出;元空间改用本地内存,默认不设上限,类的元数据存放在里面,字符串常量池早在 JDK 7 就搬到了堆上。动机不只是「调参玄学太多」,还包括让不同虚拟机实现共用一套内存布局。理解这层动机,才不会把「永久代换成元空间」当成一句背诵点。

4. 并发:锁升级和 AQS 队列,两套完全不同的等待哲学

4.1 Mark Word 上的状态机:偏向锁为什么被废弃

synchronized在字节码里是monitorenter/monitorexit两条指令,但 JVM 实际执行时走的是一条从轻到重的升级路径:无锁、偏向锁、轻量级锁、重量级锁,原则上只升不降。

偏向锁的依据是一个观察:大多数加锁场景压根不存在竞争,同一个线程反复进出同一个同步块。所以第一次进入时,用 CAS 把自己的线程 ID 写进 Mark Word,之后同一线程再来只比对 ID,不做任何原子操作,成本近乎为零。可一旦出现第二个线程,就得撤销偏向,撤销必须在安全点上进行,涉及遍历该线程栈上所有锁记录,代价不小。为此还引入了批量重偏向和批量撤销机制,通过-XX:BiasedLockingBulkRebiasThreshold和-XX:BiasedLockingBulkRevokeThreshold控制阈值。后来的 JVM 版本直接把偏向锁默认关掉并标记为废弃,原因很直接:现代应用里线程池普及,同一个锁在不同线程间流转频繁,撤销成本超过了节省下来的 CAS 开销。

轻量级锁的思路是「有竞争但很短暂」:把 Mark Word 拷贝到当前栈帧的锁记录里,用 CAS 把它指向这条记录,失败就自适应自旋一会儿再试,还失败就膨胀成重量级锁。重量级锁对应一个监视器对象,里面维护持有者、竞争队列和等待集合,抢不到锁的线程被真正的操作系统挂起,恢复需要陷入内核,成本最高但不会空耗 CPU。

这里有个必须点破的事:锁升级是具体 JVM 实现的优化策略,不是语言规范要求。所以「synchronized 一定是重量级锁」这个说法是错的,而「synchronized 保证原子性、可见性、有序性」才是规范层面的可靠答案。面试时把这两层分开讲,比背一张状态迁移图有价值得多。

4.2 AQS:一个 int 状态加一条双向队列

AQS 的骨架极其精简:一个 volatile 的state、一条先进先出的双向队列(CLH 队列的变体)、以及节点上的等待状态位(取消、等待唤醒、条件等待、传播)。子类只需要定义state的语义和tryAcquire/tryRelease的实现,排队、阻塞、唤醒这些脏活全由基类完成。

// 非公平锁的 tryAcquire:上来就抢,不管队列 protected final boolean tryAcquire(int acquires) { final Thread current = Thread.currentThread(); int c = getState(); if (c == 0) { if (compareAndSetState(0, acquires)) { // 抢到就占用 setExclusiveOwnerThread(current); return true; } } else if (current == getExclusiveOwnerThread()) { int nextc = c + acquires; // 重入:state 累加 if (nextc < 0) throw new Error("Maximum lock count exceeded"); setState(nextc); return true; } return false; }

公平锁与非公平锁的差别只有一行:公平锁在c == 0时先调hasQueuedPredecessors(),队列里有人排队就不抢,老老实实去队尾。非公平锁省掉这次判断,让刚到的线程有机会插队,吞吐更高但存在饥饿风险;公平锁严格保证先来后到,代价是每次都要检查队列、唤醒后继,上下文切换更多。这就是设计取舍的原型——吞吐与公平无法同时最大化。

入队逻辑也值得看:addWaiter先用一次 CAS 尝试快速挂到队尾,失败才进自旋加 CAS 的循环;acquireQueued里,如果前驱节点正好是头节点,说明自己就是下一个该拿锁的,会再试一次tryAcquire,失败才把前驱的等待状态置为「需要唤醒」并 park 自己。这段设计避免了无谓的唤醒,也解释了为什么 AQS 的队列头部节点永远是一个不代表任何线程的哨兵。

4.3 线程池:队列类型决定了 maximumPoolSize 是不是摆设

线程池的执行流程是:核心线程未满就建核心线程;核心满了就入队;队列满了才建非核心线程;到最大线程数才走拒绝策略。这条链路里最关键的耦合点是队列容量。

固定大小线程池用的是无界队列,队列永远不会满,于是「创建非核心线程」和「触发拒绝策略」这两个分支永远走不到。后果是最大线程数配了等于没配,任务无限堆积,直到内存被打爆。这就是为什么很多团队的编码规范里直接禁用工具类创建线程池,要求手动new ThreadPoolExecutor(...)并显式传一个有界队列。拒绝策略也要挑:默认的抛异常策略能快速暴露问题;让调用方线程执行任务的策略会形成天然的反压,把压力推回上游,在流量突增时比静默丢弃更可控;丢弃策略在日志采集这类允许丢失的场景才合适。

核心线程数怎么定,CPU 密集型的经验值是核数加一,IO 密集型大约是核数乘以(1 加等待时间与计算时间的比值)。但这个公式只适合当起点,真实系统必须压测,因为等待时间的比例会随下游响应时间变化,而队列长度、拒绝策略、超时设置会一起改变系统的实际吞吐曲线。

5. 排查清单:五类「配了却没生效」的坑怎么定位

5.1 容量与参数类:数字给了,语义没对上

第一坑:初始容量给了 16,装 16 个元素照样扩容。现象是往new HashMap<>(16)里放第十三个元素时表长翻倍,触发一次 rehash。原因是构造参数被tableSizeFor处理成 2 的幂后,真正的扩容阈值还得乘负载因子,16 × 0.75 = 12,第十三个元素就过线了。解决办法是按元素数反推:容量给(int)(expected / 0.75f) + 1,或者直接用第三方工具类里专门算期望容量的工厂方法。血泪经验是批量装载几千条数据的场景,一次扩容的拷贝成本比预估容量的这点内存贵得多。

第二坑:线程池最大线程数设了两百,实际并发只有八个。现象是监控里活跃线程数纹丝不动,任务在队列里越堆越多。原因是队列没有设容量上限,任务压根到不了「创建非核心线程」那个分支。解决办法是换成有界队列并把拒绝策略显式写出来,再用压测确认拒绝率在可接受范围内。这条坑最阴的地方在于它上线初期完全无症状,等流量上来才集体爆发。

第三坑:堆内存参数写得很漂亮,容器里还是被杀。现象是进程被外部直接终止,日志里连一次完整的垃圾回收都没留下。原因是 JVM 早期版本按宿主机物理内存推算默认堆大小,容器限制对它不可见;即使在新版本里默认支持容器感知,如果显式写死了绝对值也会绕过限制。解决办法是用按比例设置堆大小的参数,并给堆外内存留足余量——元空间、直接内存缓冲区、线程栈都不在堆里,把它们算漏是常见的翻车点。

5.2 语义类:代码看着对,约定没对上

第四坑:加了@Transactional,异常抛了事务没回滚。现象是日志里异常清清楚楚,数据库里的记录纹丝不动地留在那。原因通常是三选一:方法内部自己把异常吞了;抛的是受检异常而默认只对运行时异常回滚;方法被同一个类内部直接调用,绕过了代理对象所以注解根本没被解析。排查顺序是先确认调用是否经过代理,再看注解有没有指定回滚的异常类型,最后检查异常是不是在方法内部就被 catch 掉了。这类问题的共性是「注解不是魔法,它依赖代理」,理解这一点比记住三条规则管用。

第五坑:索引建了,查询还是全表扫。现象是执行计划里的访问类型显示为全表扫描,明明 where 条件里的字段上有索引。原因常见于三类:字段上套了函数或运算,索引按原值排序,函数结果无法定位;字段类型与传参类型不一致触发隐式转换,等价于在字段上套了转换函数;复合索引不满足最左前缀,跳过了第一个列。定位手段是看执行计划里是否真的用到了索引、以及用到了哪一列,然后逐个条件核对是否「裸用」了字段。这条坑的反直觉之处在于,索引不是「建了就有用」,而是「条件写法决定了能不能用」。

第六坑:查不到缓存就查库回填,回填完发现还是旧值。现象是并发读写时读到已经过期的数据,过一会儿又自己好了。原因是删除缓存和更新数据库之间有一个时间窗口,读请求可能在这个窗口里把旧值重新装回缓存。解决办法有轻有重:轻的是延迟双删,写完库等一小段时间再删一次;重的是订阅数据库变更日志异步删除,把窗口压到最小;更务实的一条是给缓存加过期时间兜底,接受短暂不一致,把「绝对一致」的成本省下来。取舍点在于业务能容忍多久的旧值,这个问题答不上来,任何方案都是拍脑袋。

6. 取舍:所有机制都在用可预期的开销换确定性

把这些机制摆在一起看,会发现它们做的是同一件事:用一份可以计算、可以预期的额外成本,换取某种确定性。

哈希表用 0.75 的负载因子和一段扰动逻辑,换来冲突率的可控上界;扩容时那次倍增的内存峰值和拷贝开销,换的是查询不退化成链表遍历。垃圾回收用一次 Stop The World,换来标记结果的绝对准确,顺便把内存管理从开发者手里收走;分代和记忆集则是把这次停顿的规模按「大多数对象朝生夕死」这个统计事实压缩下来。AQS 用一条排队队列,换来「不会有两个线程同时进入临界区」这个语义;公平锁多付的那点检查成本,买的是没有饥饿。缓存用过期时间和一段不一致窗口,换来数据库不必承受全量读压力。没有哪个机制是全面的最优解,它们都是在某个假设成立时,把代价挪到一个能承受的位置。

判断自己是不是真懂了,可以拿三个问题做自测,任何一个答不上来都说明只记住了结论:

追问层次问法答不上来意味着
结论层这个机制叫什么、解决什么问题没入门
机制层它靠什么数据结构或指令实现,关键参数是什么只会背,不会用
边界层什么条件下它会失效,失效时表现成什么样无法在真实故障里定位

比如问「线程池」,结论层是核心参数和执行流程;机制层是队列容量如何决定最大线程数是否生效;边界层是无界队列在流量突增时会把内存打爆、而这个时候日志里往往什么异常都没有。三层都能讲清楚,才算真正掌握了这个知识点;只答得出第一层,遇到线上问题依然只能靠猜。

我自己的习惯是,每看完一份面试题集,就挑三个自己答得最顺的题,逼自己往下再追问两层,然后去源码或者日志里找证据。这个习惯来自一次很尴尬的经历:某次排查一个响应变慢的问题,我张口就说「是垃圾回收参数没调好」,结果打开安全点日志才发现停顿几乎全花在等线程到达安全点,跟收集器半点关系没有。从那以后我每次下结论前,都强制走一遍「结论、机制、边界」这三层,先问自己凭什么这么判断。这份按技术栈分册的资料,目录本身就是一张现成的技术栈地图——基础、虚拟机、并发、框架、数据库、缓存、注册中心、网络、设计模式逐层铺开,遇到哪块答不上来就顺着目录回去补那一段的机制,比从头背一遍高效得多。希望帮到你。

本文还有配套的精品资源,点击获取

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

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

立即咨询