ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Java进阶面试高频题解析:集合源码、并发原理、JVM调优与数据库索引

Java进阶面试高频题解析:集合源码、并发原理、JVM调优与数据库索引 JAVA面试题刷了很多但真正到现场还是容易卡壳这是不少准备跳槽的朋友跟我抱怨过的事。作为既当过求职者、也当过面试官的人我总结出一个小规律基础语法题背一背就能过但一碰到集合源码、并发原理、JVM调优、数据库索引这些题如果只是记住了结论而没搞懂机制三两句就会被问穿。这篇文章是系列第二篇重点针对Java进阶高频面试题把核心考点、底层原理和回答思路一起拆开讲内容密度会比较高建议收藏后慢慢消化。1. Java集合源码级面试题HashMap与ConcurrentHashMap集合框架是所有Java面试绕不开的板块其中HashMap几乎成了“必考题”。面试官通常不会直接问“HashMap怎么用”而是问底层结构、扩容机制、线程安全性。如果能把这一块讲透基本就能证明你真正读过源码。1.1 HashMap的底层结构与完整put流程HashMap在JDK 1.8之后是“数组 链表 红黑树”的结构这一点很多人都能背出来。但面试官真正想听的是过程当你调用map.put(key, value)时内部发生了什么。先看key定位的逻辑。HashMap会先调用hash(key)方法把key的hashCode高16位与低16位做异或目的是让高位的特征也能参与散列减少碰撞。然后再用(n - 1) hash计算数组下标这里的n是数组长度。为什么用位运算而不是取模因为当n是2的幂时(n - 1) hash等价于hash % n但位运算性能更高。HashMap初始化时即使你传的不是2的幂也会被改成最接近的2的幂。put流程大致是先判断数组是否为空为空则先扩容然后根据hash定位到数组槽位。如果槽位为空直接放入新节点。如果槽位不为空说明发生Hash冲突此时要么是转换为红黑树后的树节点要么是一个普通链表节点需要遍历链表用equals比较key是否相同找到相同key就替换value找不到就在链表尾部插入。插入完成后检查链表长度是否超过8并且数组长度是否达到64如果满足条件就转成红黑树。这里还有一个高频追问为什么负载因子是0.75默认情况下HashMap容量是16当元素个数达到1216 * 0.75时就会触发扩容。0.75是空间利用率和查询效率的折中。太小了浪费空间太大了hash冲突变多链表和红黑树查询成本上升。至于为什么是0.75而不是0.6或0.8源码注释里有提到泊松分布简单理解就是在这个值下桶中链表长度超过8的概率极低红黑树不会被频繁触发。面试时可以这样回答我会先说出底层结构然后结合put流程展开主动提到扰动函数、2的幂、负载因子这样面试官会觉得你不只是背过答案而是真的理解。1.2 JDK1.7到1.8的变化为什么引入红黑树面试中经常有这样一个对比题HashMap在JDK 1.7和JDK 1.8里有什么区别区别不少但最核心的是三点第一1.7是数组 链表1.8是数组 链表 红黑树第二1.7插入采用头插法1.8改为尾插法第三1.7扩容时所有元素重新计算下标1.8利用2的幂特性要么留在原位置要么移动到“原位置 旧容量”的位置。头插法改尾插法是最值得说的细节。1.7之所以用头插法是因为后插入的数据更容易被访问到某些场景下可以提升命中率。但头插法在并发扩容时会出现一个经典问题链表可能形成环导致下一次get死循环。1.8改成尾插法后即使并发扩容链表相对稳定不会再形成环。那为什么还要引入红黑树链表查询复杂度是O(n)一旦某个桶里的数据特别多性能退化严重。红黑树可以保证最坏情况下查询也是O(log n)。但红黑树节点占空间更大维护旋转成本高所以不能一有冲突就树化。JDK 1.8的规则是链表长度达到8且数组长度达到64才会转成红黑树。如果数组长度没到64即使链表已经很长也先选择扩容这样可以将一条长链分散到新的数组槽位中。还有一个容易忽略的知识点树化阈值是8但退化阈值是6。为什么不是7因为如果某个桶的链表一直在8和7之间反复抖动频繁树化和反树化开销很大。中间留一个缓冲8触发树化6触发退化为链表避免临界抖动。1.3 ConcurrentHashMap的线程安全演进HashMap本身是线程不安全的并发环境下要么用Hashtable要么用ConcurrentHashMap或者用Collections.synchronizedMap。但现在面试几乎只考察ConcurrentHashMap因为它在并发场景下的性能最优。JDK 1.7的ConcurrentHashMap采用分段锁设计底层是Segment数组每个Segment继承自ReentrantLock可以独立加锁。理论上最多支持Segment数量个线程并发写锁粒度比Hashtable粗得多。但问题在于Segments数量固定并且查询一个key需要先定位到Segment再定位到内部的HashEntry两步查找有一定开销。JDK 1.8的ConcurrentHashMap放弃了分段锁改用CAS synchronized。具体做法是如果数组槽位为空用CAS直接放入节点如果槽位不为空则对这个槽位的头节点加synchronized锁锁粒度从“一段”缩小到“一个桶”。这样并发度大大提升而且synchronized经过锁升级之后在低竞争场景下性能并不比ReentrantLock差实现也更简洁。面试中还常问为什么1.8不用ReentrantLock替换synchronized一方面锁粒度已经足够小用synchronized可以减少死锁和代码复杂度另一方面JVM对synchronized做了大量优化比如偏向锁、轻量级锁这些在低竞争时开销甚至接近于零。这里必须提醒一句如果你在项目里把HashMap当共享变量用多个线程同时put甚至可能连数据都丢。最轻的解决方案是直接用ConcurrentHashMap而不是自己对HashMap加synchronized因为后者锁粒度太大性能会打折。2. Java并发编程面试题从JMM到线程池并发是Java进阶路上最难跨过的一道坎也是高级岗位面试的必考范围。很多面试者能把synchronized和volatile区别背得很熟但题目一旦变换场景比如问“这个变量要不要加volatile”就露馅了。建议从JMM底层模型开始理解再对比锁和线程池。2.1 JMM与volatile的可见性、有序性Java内存模型即JMM定义了主内存和工作内存的关系。所有变量存储在主内存中每个线程有自己的工作内存里面保存了变量的副本。线程对变量的所有操作都必须在工作内存中进行不能直接读写主内存。如果线程A改了变量线程B如果没有同步它读到的很可能还是旧值这就是可见性问题。volatile的作用主要有两个保证可见性即volatile变量的修改会立即被其他线程看到保证有序性即禁止指令重排序。但它不保证原子性这是最容易考的点。举个例子两个线程同时执行count即使count加了volatile结果也可能小于20000。因为count在字节码层面包含“读取-加1-写回”三步volatile只能保证读取和写入是可见的不能把三步合并成一个不可分割的操作。volatile另一个经典应用场景是DCL单例模式public class Singleton { private static volatile Singleton instance; public static Singleton getInstance() { if (instance null) { // 第一次检查 synchronized (Singleton.class) { if (instance null) { // 第二次检查 instance new Singleton(); // 问题可能指令重排 } } } return instance; } }instance new Singleton()不是一步完成的它会先分配内存、初始化对象、把引用指向内存。如果不加volatileJVM可能重排成“先指向内存后初始化对象”另一个线程读到没有初始化的对象就会出现NPE。回答这类题时一定要主动说清“volatile不保证原子性”这个边界。面试官通常会顺着问“那AtomicInteger为什么能保证原子性”如果你能说出CAS和Unsafe类印象分会不错。2.2 synchronized锁升级与ReentrantLock对比老版本的synchronized是重量级锁性能不好很多人习惯用它跟ReentrantLock做对比。但其实JDK 1.6之后JVM对synchronized做了优化锁会经历无锁、偏向锁、轻量级锁、重量级锁的升级过程。这里的关键是锁只能升级不能降级。偏向锁适用于只有一个线程访问同步块的场景一旦出现第二个线程竞争就升级为轻量级锁通过自旋来等待如果自旋失败或竞争线程数超过阈值就膨胀为重量级锁阻塞等待。面试官常问ReentrantLock和synchronized比有什么优势答案是支持可中断、支持公平锁、支持非阻塞获取锁、支持多个Condition条件队列。比如lock.tryLock(2, TimeUnit.SECONDS)可以在有限时间拿不到锁时不再傻等这在处理业务超时时很有用。不过现实是即使ReentrantLock功能更丰富如果你的场景只是简单的互斥synchronized完全够用而且写法更简洁不容易出现忘释放锁的问题。我在实际项目里除非需要超时或公平性否则优先用synchronized。2.3 线程池参数与任务执行流程线程池属于“必考但很多人答不细”的题。面试官问得最多的就是ThreadPoolExecutor的七个参数corePoolSize、maximumPoolSize、keepAliveTime、unit、workQueue、threadFactory、handler。执行流程可以按这个顺序答先提交任务如果当前线程数小于核心线程数就创建新线程执行任务如果达到了核心线程数就放入任务队列如果队列满了并且当前线程数小于最大线程数就创建非核心线程如果最大线程数也满了就执行拒绝策略。核心参数怎么定没有固定答案但可以从两个角度估算。CPU密集型任务比如大循环、复杂计算线程数可以设为CPU核数 1避免频繁上下文切换。IO密集型任务比如远程调用、数据库操作线程数可以设为CPU核数 / (1 - 阻塞系数)阻塞系数一般取0.8到0.9之间。更稳妥的方式是先压测观察CPU利用率和响应时间再动态调整。拒绝策略有四种AbortPolicy直接抛异常CallerRunsPolicy由调用者线程执行DiscardPolicy直接丢弃DiscardOldestPolicy丢弃队列中最老的任务。生产环境一般不建议直接丢弃或抛异常尤其是涉及交易场景要么用CallerRunsPolicy让调用方慢慢执行要么自定义拒绝策略做补偿。另外千万别用Executors.newFixedThreadPool()和Executors.newCachedThreadPool()。前者使用无界队列任务一多可能积压导致内存溢出后者最大线程数是Integer.MAX_VALUE高并发下会创建大量线程非常危险。正确的做法是手动new ThreadPoolExecutor设置有界队列和合理的拒绝策略。3. JVM面试连环问内存区域、类加载、GCJVM内容是面试中的分水岭。初级岗位可能只问“运行时数据区有哪些”高级岗位会直接问“线上OOM了你怎么排查”“CMS和G1怎么选”。这一部分不只是背概念还要有实际解决问题的思路。3.1 JVM运行时数据区与OOM场景JVM运行时数据区分为线程私有和线程共享两块。线程私有的有程序计数器、虚拟机栈、本地方法栈线程共享的有堆和方法区JDK 1.8之后方法区被元空间取代使用的是本地内存。面试里最常见的两个OOM是堆溢出和栈溢出。堆溢出通常是因为对象太多或者对象过大不断往堆里放对象最终撑满堆抛出java.lang.OutOfMemoryError: Java heap space。栈溢出多是因为递归没有出口或递归层级太深抛出StackOverflowError。元空间OOM则常见于动态生成大量类比如CGLIB虽然元空间使用本地内存但也不是无限大。遇到OOM常规排查思路是先拿到堆转储文件比如用jmap -dump:formatb,fileheap.hprof pid导出然后用MAT或JVisualVM分析大对象和引用链。如果线上不允许停服可以先查看GC日志用jstat -gcutil看看老年代是否持续增长判断是否存在内存泄漏。3.2 垃圾回收算法与常见收集器垃圾回收算法有四种基础标记-清除、标记-复制、标记-整理、分代收集。标记-清除会产生碎片标记-复制适合存活率低的新生代标记-整理适合存活率高的老年代。实际收集器都是这些算法的组合。面试重点还是CMS和G1的对比。CMS是低停顿收集器全程目标是减少STW时间但有两个明显缺点一是并发阶段会产生浮动垃圾无法彻底回收干净二是标记-清除会导致内存碎片老年代空间明明够却可能出现分配大对象失败被迫Full GC。G1把堆划分成多个Region可以有选择地回收垃圾最多的Region并且能做到可预测停顿时间。G1在JDK 9之后成为默认收集器也是面试官比较认可的主流方案。回答GC题目时最好带上自己的实战经验。比如我调优过的服务堆设成8G使用G1MaxGCPauseMillis200同时限定G1HeapRegionSize4M之后通过压测观察停顿时间和吞吐量再做微调。这种具体参数描述会让面试官觉得你是真调过而不是纸上谈兵。3.3 类加载机制与双亲委派模型类加载分为加载、验证、准备、解析、初始化五个阶段。双亲委派模型的流程是类加载器收到加载请求后不会自己先加载而是先委派给父加载器逐层向上请求只有当父加载器无法完成加载时才由自己加载。为什么要双亲委派两个原因一是避免类重复加载同一个类由父加载器加载后子加载器不需要再加载二是保证核心类库安全比如java.lang.String必须由启动类加载器加载防止用户自定义一个假的String混进JVM。面试官还可能问哪些场景打破了双亲委派典型的如JDBC驱动加载因为JDBC规范由启动类加载器加载但具体数据库驱动由类路径下的应用类加载器加载两者需要配合所以JDBC使用线程上下文类加载器来突破。Tomcat也需要打破双亲委派以便每个Web应用都能拥有独立的类库互不影响。我经常在面试安全类题目时碰到“能不能自己写一个java.lang.String类放到classpath里”这种题。答案是能编译但运行时会报安全异常因为在加载时会被启动类加载器拦截不会加载自定义实现。这个问题只要理解了双亲委派马上就能回答出来。4. MySQL与Redis高频面试题索引、事务、缓存Java开发离不开数据库所以MySQL和Redis是面试里的大头。很多候选人Java基础不错但对数据库的理解停留在写SQL层面一追问索引底层和事务原理就说不下去。4.1 MySQL索引结构、回表与失效场景InnoDB的索引结构是B树而不是二叉树也不是B树。B树的特点是非叶子节点只存储索引键和指针叶子节点存储真实数据并且通过双向链表串联。这样一次查询可以定位到叶子节点天然支持范围查询而且因为非叶子节点不存数据单页能放更多索引项树更矮IO次数更少。面试中必问“回表”和“覆盖索引”。假如你在user表的name字段上建了普通索引select * from user where name 张三MySQL会先在name索引树中找到主键id再用主键id去聚簇索引树中查一整行这个过程就是回表。如果改成select id, name from user where name 张三要查的字段都在二级索引里不需要回表这个索引就叫覆盖索引。索引失效的场景要记熟几个典型的左模糊匹配like %abc对索引列使用函数或表达式隐式类型转换使用or且其中一个条件不是索引列联合索引不满足最左前缀原则范围查询右侧的列无法继续走索引。排查索引失效最直接的办法是使用EXPLAIN SELECT ...。重点看type、key、rows、Extra四个字段。type从好到差依次是const、ref、range、index、ALLALL代表全表扫描通常需要优化。如果Extra里出现Using filesort或Using temporary说明排序或分组用了临时文件也要小心。4.2 事务隔离级别与MVCC实现MySQL的四种隔离级别是读未提交、读已提交、可重复读、串行化。读未提交会出现脏读读已提交解决了脏读但会出现不可重复读可重复读解决了不可重复读但理论上还会出现幻读。InnoDB在可重复读级别下通过MVCC和多版本读再加间隙锁把幻读问题也控制住了所以日常开发使用默认的RR级别即可。MVCC的原理可以这样理解在InnoDB中每行记录都隐藏着两个字段一个是事务ID一个是回滚指针。事务对某行进行修改时会把旧版本写入undo log新版本行头指向旧版本从而形成版本链。查询时通过ReadView判断当前事务能看到哪个版本。读已提交和可重复读的区别就在ReadView的生成时机。RC级别下每次快照读都会生成一个新的ReadView所以两次同样查询可能看到不同的数据。RR级别下事务第一次快照读时就确定了ReadView后续所有查询都复用这个ReadView从而保证可重复读。如果面试官追问“间隙锁”你可以这样答InnoDB在RR级别下对普通索引和范围条件加锁时不仅锁定匹配的记录还会锁定这些记录之间的间隙防止其他事务插入新数据因此能够避免幻读。但间隙锁也容易导致死锁所以很多互联网公司会把隔离级别降成RC再配合其他手段保证一致性。4.3 Redis缓存三兄弟与分布式锁缓存题比数据库题更偏实战。穿透、击穿、雪崩是Redis面试里的三兄弟。缓存穿透请求的数据在缓存和数据库都不存在每次都打到数据库相当于穿透了缓存。解决方案有缓存空值以及布隆过滤器前置过滤。布隆过滤器可以在查询前判断key是否可能存在如果不存在就直接返回减少无效查询。缓存击穿某个热点key在同一时刻过期大量请求同时打到数据库。解决方式最常见的有互斥锁和逻辑过期。互斥锁是让其中一个线程去数据库加载并写缓存其他线程等待逻辑过期是把过期时间写到value里异步更新逻辑上不设物理过期时间。缓存雪崩大量key同时过期或者Redis服务整体宕机导致整个请求链路打到数据库。解决方式是给过期时间加随机值防止同时失效服务端要做熔断降级Redis本身需要高可用部署。分布式锁是Redis的必问题。早年很多人会写SET key value EXPIRE key 30这是错误的因为setnx和expire不是原子操作中间进程崩溃锁就变成永不过期。正确做法是使用一个命令SetParams params SetParams.setParams().nx().ex(30, TimeUnit.SECONDS); boolean ok redisTemplate.opsForValue().setIfAbsent(lock:order, clientId, params);释放锁时不能简单delete需要先比较value是不是自己的clientId防止误删别人的锁。生产环境建议直接用Redisson它的看门狗机制会自动给锁续期避免业务还没执行完锁就过期了。至于RedLock在多数复杂分布式环境下争议很大初级候选人可以不主动提但被问到时要能说出它解决的问题和局限。5. 框架与中间件面试题MyBatis、Kafka、分布式一致性Java服务端开发几乎天天和MyBatis、消息队列打交道。这一部分更贴近实际项目如果只是用过而没想过原理很容易被问住。5.1 MyBatis #{}与${}以及分页插件原理MyBatis中#{}和${}都能取参数但语义完全不同。#{}是预编译参数占位符MyBatis会把它转成?然后通过PreparedStatement设置参数可以防止SQL注入。${}是直接字符串替换把参数值拼进SQL里存在注入风险。实际工作中大部分场景都要用#{}。唯一推荐用${}的情况是动态表名、动态字段名、order by字段这类无法预编译的SQL片段比如SELECT * FROM ${tableName} ORDER BY ${orderColumn} DESC这种情况必须对传入参数做白名单校验否则一旦被外部传入恶意内容后果不堪设想。另一个高频题是PageHelper分页插件原理。PageHelper拦截了MyBatis执行的StatementHandler在原有SQL基础上拼接LIMIT语句。它会先通过反射获取当前执行的SQL用方言类生成分页SQL再执行查询。理解这个原理后你就知道为什么PageHelper必须紧跟第一条查询语句中间不能插入其他SQL因为它基于ThreadLocal保存分页参数。5.2 Kafka为什么快以及怎么保证消息可靠Kafka高吞吐的秘密可以从几个层面回答。第一是顺序写磁盘Kafka的消息都是追加写入Segment文件避免了随机IO。第二是零拷贝消费时使用sendfile系统调用数据从磁盘直接到网卡不走用户态拷贝。第三是分区并行一个topic分成多个partition生产者可以并行写消费者可以并行读。第四是批量操作生产者会把多条消息凑成一个批次再发送减少网络往返。消息可靠性要分开三端来说。生产者端需要设置acksall并开启重试保证消息写入所有副本成功。Broker端需要把min.insync.replicas设置合理配合副本机制避免leader选举丢消息。消费者端需要关了自动提交offset或在业务处理成功后再手动提交offset防止消费到一半进程重启造成消息丢失。面试中还常问“Kafka怎么保证不重复消费”。Kafka本身只能保证不丢消息不能完全避免重复消息所以在业务侧必须做幂等。最简单的幂等方案是给每条消息一个唯一ID处理前查询是否已处理过或者用状态机的流转把重复消息变成无效操作。5.3 分布式事务与数据一致性分布式事务是大型系统面试的重点。CAP理论是基础一致性、可用性、分区容错性三者不能兼得而网络分区是不可避免的所以大多数系统只能做最终一致性。具体方案有几种。2PC两阶段提交通过协调者统一决定所有节点提交或回滚强一致但性能差、协调者单点问题明显。TCCTry-Confirm-Cancel把每个业务分成三个动作性能和灵活性更好但对业务侵入大。本地消息表是经典方案业务执行时同时写业务表和消息表通过后台任务轮询消息表发送到MQ消费者处理成功后通知回调删除消息。这种方案简单可靠很多老项目还在用。更现代的方式是使用MQ的事务消息比如RocketMQ事务消息在半消息发送成功后执行本地事务再根据结果提交或回滚消息。回答数据一致性题目时要结合自己项目的交易链路。比如下单完成时扣库存、创建订单、发送积分三件事不能同时成功我会用本地消息表MQ最终一致性核心订单状态落库后消息一定不丢积分系统异步消费消费失败持续重试。如果面试官追问“消息一直失败怎么办”就答定时任务扫表补偿最后实在失败进入人工告警。6. 面试实战经验怎么把题答出亮点技术考点讲完了最后聊聊面试中怎么表达。很多人不是知识储备不够而是回答方式太直白面试官问一句答一句没有展示出思考深度。这部分是我在面试现场见过最多的问题。6.1 先给结论再展开原理我看到过太多候选人被问到“HashMap线程安全吗”时只回答“不安全”就停了。回答“不安全”是第一步但还不够。更好的方式是先给一个简明结论再说为什么最后给出替代方案。比如 “线程不安全。因为并发put时可能出现数据覆盖JDK 1.7甚至可能因为头插法导致链表成环。所以在并发场景我会用ConcurrentHashMap它用CAS和synchronized控制并发并且支持更高的并发度。”这种回答方式面试官基本不用再追问第三层但如果你只答“不安全”他一定会继续问“为什么那怎么办”而你未必准备过。讲项目时也建议用STAR逻辑背景、目标、方案、结果。比如“之前线上订单系统经常出现库存超卖我负责优化通过引入Redis分布式锁和数据库乐观锁把超卖次数降为0QPS从200提升到800”。重点不是背诵而是让面试官理解你在项目中的角色和取舍过程。6.2 手写代码的雷区面试最后经常会有手写代码环节。常见题目包括单例模式、生产者消费者、死锁、LRU缓存、多线程交替打印。写代码时最怕两件事一是边界条件没考虑二是方法签名不完整。比如手写单例很多人只写DCL但忘了加volatile。写生产者消费者时忘记用while(check)而用了if(check)导致被唤醒后没有重新检查条件。写LRU时可能把双向链表的指针接错。这些小细节在面试官眼里就是代码功底。我的建议是面试前专门把这些经典多线程题用纸笔写一遍不要只看不写。写的时候养成先定义清楚临界资源、锁和条件变量的习惯。如果面试时间允许可以边写边讲思路“这里需要加锁保护队列用条件变量等待和通知。”这种交流方式比闷头写更能加分。我个人在实际面试和带团队时始终觉得技术深度不是靠背诵堆出来的而是靠“为什么”串起来的。面试题只是一个窗口真正值钱的是你面对一个具体问题时能不能一层层拆开说出原理、场景和取舍。准备这套题的过程中重点不是把所有答案背下来而是要反复问自己“如果换一种实现会有什么问题”。想通了这些下次面试不管怎么换问法你都能接得住。
返回列表