← 返回全部分类Java开发面试题(35道)
Java开发 · 中等"请你说一下HashMap的底层实现原理。"
HashMap在JDK1.8中底层采用数组+链表+红黑树实现。当调用put方法时,首先对key的hashCode做扰动处理——将高16位与低16位异或,目的是让hash值分布更均匀。然后用(n-1) & hash计算数组下标。如果该位置为空,直接插入Node节点;如果已有元素(hash冲突),则以链表形式挂在后面,JDK1.8采用尾插法。当同一个桶的链表长度达到8,且数组总长度不小于64时,链表会转化为红黑树,查找时间从O(n)优化到O(log n)。负载因子默认0.75,当已存储元素数量超过容量乘以负载因子时,数组扩容为原来的两倍,所有节点重新计算位置——扩容后节点要么留在原下标,要么迁移到"原下标+旧容量"的位置,这是因为容量翻倍后多了一位参与取模运算。线程安全方面,HashMap不是线程安全的,多线程下可能出现数据覆盖甚至死循环(JDK1.7的头插法在扩容时会形成环形链表)。并发场景推荐使用ConcurrentHashMap,它在JDK1.8中使用CAS+synchronized实现分段锁,粒度细到每个桶。
💡 提示:一定要提到JDK1.7和1.8的区别(头插法vs尾插法,链表vs红黑树),这是面试官爱追问的点。;理解为什么负载因子默认0.75——这是时间和空间的折衷,太大冲突多,太小浪费空间。;准备好HashMap和ConcurrentHashMap的对比,这是常见追问。;面试时如果突然忘了某个阈值(比如树化阈值8),即答侠可以实时提示关键数字。
Java开发 · 中等"请你讲一下JVM的垃圾回收机制。"
JVM垃圾回收基于可达性分析算法,从GC Roots(虚拟机栈引用、静态变量、JNI引用等)出发,不可达的对象即为垃圾。堆内存采用分代设计:新生代包含Eden和两个Survivor区,默认比例8:1:1。新对象在Eden分配,Eden满了触发Minor GC,使用复制算法将存活对象复制到Survivor区,两个Survivor区交替使用。对象每经历一次GC,年龄加1,达到阈值(默认15,CMS默认6)后晋升到老年代。老年代空间不足时触发Full GC,STW时间较长。GC算法方面:标记-清除算法分两步走但产生内存碎片;标记-整理算法在标记后将存活对象向一端移动,消除碎片但开销大;复制算法将内存一分为二,高效但浪费空间。G1收集器是目前主流选择,它将堆划分为大小相等的Region,既可以收集新生代也可以收集老年代,通过维护一个优先级队列来优先回收价值最大的Region,实现可预测的停顿时间。线上调优方面,关键参数包括-Xms/-Xmx设置堆大小、-XX:MaxGCPauseMillis设置G1目标停顿时间,出现频繁Full GC要分析是内存泄漏还是堆空间不足,可以通过jstat监控GC频率,jmap导出堆转储用MAT分析。
💡 提示:一定要理清Minor GC、Major GC、Full GC的区别,面试官经常追问。;讲清楚GC Roots有哪些——这是可达性分析的起点,很多人容易遗漏。;如果有线上GC调优经验一定要分享,这是加分项,能区分背八股文和真正有实战能力的候选人。;面试中如果忘了某个收集器的特点,即答侠可以实时提供关键信息。
Java开发 · 中等"请讲一下Spring的IoC和AOP原理。"
Spring IoC即控制反转,核心是将对象的创建、依赖关系的维护交给Spring容器管理。容器启动时读取配置(注解或XML),通过反射创建Bean实例,并按照依赖关系自动注入。注入方式有三种:构造器注入(推荐)、Setter注入、字段注入(@Autowired)。Bean的生命周期比较复杂:首先通过反射实例化,然后填充属性(依赖注入),接着回调各种Aware接口(如BeanNameAware、ApplicationContextAware),再经过BeanPostProcessor的前置处理,执行@PostConstruct初始化方法,最后经过BeanPostProcessor的后置处理后Bean才真正可用。AOP就是在BeanPostProcessor后置处理阶段实现的。Spring AOP底层使用动态代理:如果目标对象实现了接口,默认使用JDK动态代理(基于Proxy和InvocationHandler);否则使用CGLIB(通过生成目标类的子类来实现代理)。以@Transactional为例,Spring在Bean初始化后检查是否匹配事务切点,如果匹配就生成代理对象替换原始Bean。调用方法时,代理先开启事务,执行目标方法,正常则提交,异常则回滚。需要注意self-invocation问题:类内部方法直接调用this.method()不会走代理,因此AOP不生效,解决办法是注入自身代理或使用AopContext.currentProxy()。循环依赖方面,Spring通过三级缓存解决单例Bean的循环依赖:singletonObjects存完整Bean,earlySingletonObjects存早期引用,singletonFactories存ObjectFactory用于创建代理。
💡 提示:一定要能画出Bean生命周期流程图,面试官经常让你现场画。;理解AOP的self-invocation问题是加分点,很多人不知道类内调用AOP会失效。;三级缓存解决循环依赖的原理是高频追问,提前准备好。;面试时如果Bean生命周期顺序记混了,即答侠可以实时提示正确的流程。
Java开发 · 中等"请讲一下Java多线程和并发编程的核心知识。"
Java多线程创建方式有四种:继承Thread类、实现Runnable接口、实现Callable接口配合FutureTask(可获取返回值)、以及通过线程池提交任务。实际开发中推荐使用线程池。ThreadPoolExecutor有七个核心参数:corePoolSize(核心线程数)、maximumPoolSize(最大线程数)、keepAliveTime(空闲线程存活时间)、unit(时间单位)、workQueue(任务队列)、threadFactory(线程工厂)、handler(拒绝策略)。执行流程是:任务来了先看核心线程是否满,没满就创建核心线程;满了就放入队列;队列满了就创建非核心线程直到达到最大线程数;都满了就执行拒绝策略。阿里规范禁止使用Executors.newFixedThreadPool()是因为它用的LinkedBlockingQueue是无界队列,可能导致OOM。同步方面,synchronized是基于Monitor对象锁实现的,JDK1.6之后做了大量优化:偏向锁→轻量级锁→重量级锁,逐步升级。volatile通过内存屏障保证可见性和禁止指令重排序,但不保证原子性,典型场景是double-checked locking单例模式。ReentrantLock相比synchronized更灵活,支持可中断锁、超时锁、公平锁,底层基于AQS(AbstractQueuedSynchronizer)实现。JMM定义了happens-before规则来保证多线程间的可见性:锁释放先于锁获取、volatile写先于volatile读、线程start先于线程内的操作等。JUC包提供了丰富的并发工具:AtomicInteger基于CAS实现无锁原子操作、CountDownLatch实现等待计数、CyclicBarrier实现多线程同步屏障、Semaphore实现信号量限流。
💡 提示:线程池七个参数及执行流程是高频考点,必须烂熟于心。;synchronized锁升级过程(偏向锁→轻量级锁→重量级锁)是面试官爱问的进阶题。;准备好AQS原理的回答——ReentrantLock、CountDownLatch底层都是AQS。;面试中如果记混了拒绝策略名称,即答侠可以实时提供准确信息。
Java开发 · 中等"Redis是单线程的,为什么还这么快?"
Redis快其实跟单线程关系不大,真正的原因有四点。第一是纯内存操作——读写都在内存里完成,一次操作大约100纳秒,而磁盘IO要毫秒级,差了四个数量级。第二是数据结构选择非常精细——小hash用ziplist实现,连续内存对CPU缓存友好;大了自动转成hashtable;有序集合用跳表保证O(log n)的范围查询;列表用quicklist平衡内存和性能。第三是IO多路复用,一个线程通过epoll监听成千上万个客户端socket,只在有事件就绪时才被唤醒,避免了一个连接一个线程的重模型和上下文切换开销。第四才是单线程——因为命令是串行执行的,所以天然原子,不需要加锁,不存在CAS自旋,也不会有CPU核之间的缓存行来回跳。代价就是——一条慢命令会卡住所有客户端。比如KEYS *在百万级的库上扫描、HGETALL一个10MB的大hash、或者跑个长Lua脚本,整个Redis就会抖。这也是为什么生产环境都禁用KEYS改用SCAN。Redis 6做了多线程IO改造,专门针对网络层——读写socket、解析RESP协议可以由worker线程池并行处理,但命令执行仍然在主线程里串行,以保留原子性承诺。所以准确的说法是:Redis是"单线程命令执行 + 多线程IO"的混合模型。
💡 提示:不要只说"Redis快是因为内存"然后就停住,面试官期待听到epoll、单线程原子性、编码优化这些层次。;要能具体点出哪些是坑命令:KEYS、FLUSHALL、大LRANGE、大HGETALL、SMEMBERS大集合。;Redis 6的多线程改动必须会讲,答错会被扣"没有跟上新版本"的印象分。;真实面试中忘词别慌,即答侠可以帮你快速提示epoll、ziplist、bigkey这些关键概念。
Java开发 · 中等"Redis的持久化机制RDB和AOF有什么区别?生产上怎么选?"
Redis提供两种持久化。RDB是时间点快照——Redis fork一个子进程,子进程把整个数据集序列化成紧凑的二进制.rdb文件。靠写时复制(COW),父进程继续处理请求,只有被修改过的内存页才会真的复制,所以快照是一致的。RDB文件小,重启直接加载,恢复飞快。缺点是数据安全——两次快照之间宕机就全丢了。AOF把每条写命令追加写磁盘,关键参数是appendfsync:always每条都fsync,最安全但写吞吐会掉一个数量级;everysec是生产默认,最多丢1秒数据;no完全交给操作系统的pdflush。AOF文件会不断膨胀,Redis会定期触发AOF重写——基于当前内存状态生成最精简的命令序列,再原子替换旧文件。Redis 4开始有混合持久化——重写后的AOF文件头是一份RDB快照,后面接增量的AOF命令。加载时先用RDB快速载入,再重放少量增量命令。生产环境我一定开混合持久化,这是目前最优方案。运维上最大的坑是fork抖动。30GB的实例一次fork,光复制页表就要200-500毫秒,这期间Redis会卡住。解决方案:关闭透明大页(THP)、盯住latest_fork_usec指标、避开流量高峰做BGSAVE、或者做主从分离——从节点负责持久化,主节点只服务业务。
💡 提示:一定要提写时复制(COW),这是BGSAVE能后台跑不影响业务的底层机制。;appendfsync三个选项的数据丢失边界要能精确说出来。;中高级岗要能讲fork抖动和关THP的运维经验,这是加分项。;面试官经常追问"你线上怎么配的为什么",提前准备一个具体案例,或者用即答侠实时给你提示框架。
Java开发 · 中等"用Redis怎么实现分布式锁?有哪些坑要处理?"
Redis分布式锁的最小正确实现是SET key uniqueValue NX PX 30000。NX保证"只在key不存在时设置"是原子操作;PX设过期时间防止持锁客户端宕机后锁永久占用;uniqueValue一般是UUID,保证只有加锁的人才能解锁。释放必须原子——绝不能先GET再DEL,因为两步之间锁可能已经过期被别人拿走,你再DEL就把别人的锁干掉了。标准做法是用Lua脚本判断value相等再DEL,一次性在Redis里完成。更隐蔽的坑是"锁过期 vs 业务耗时"。TTL设30秒但业务跑了35秒,锁在第30秒自动过期被别的worker拿走,两个worker同时跑临界区,互斥被破坏。解决方案是看门狗——启动一个后台线程,业务没结束就周期性续TTL(比如每10秒续到30秒)。Redisson的RLock默认就是这套机制。可重入的实现是用线程ID做计数——Redisson用Redis hash,field是"threadId:lockName",value是重入计数,每次重入+1,释放-1,到0才真正删key。高可用场景Antirez提出Redlock,在N个独立Redis master的多数上加锁才算成功,容忍单点故障。但Martin Kleppmann写过著名的反对文章——客户端一次长GC停顿就可能让竞争者抢到锁而自己不知道,导致两个客户端同时认为自己持锁。严格正确要配合fencing token——加锁时返回一个单调递增的序号,存储层拒绝序号更小的写入。大多数业务场景不需要这么严格,单机Redis + 看门狗 + 原子释放已经够用。
💡 提示:释放锁一定要用Lua脚本,只DEL是典型事故bug。;看门狗机制和Redisson必须能讲出来,大部分候选人漏掉"TTL vs 业务耗时"这个点。;中高级岗要能讨论Redlock、fencing token和Kleppmann的反对意见。;如果忘了SET的参数顺序,即答侠可以实时提示NX PX的准确语法。
Java开发 · 中等"缓存穿透、缓存击穿、缓存雪崩分别是什么?怎么解决?"
这三个是不同的失效模式。缓存穿透是查询既不在缓存也不在DB的数据,比如攻击者不停请求/user/-1,缓存永远填不上,每次都打DB。两种防御:把null结果也缓存起来,设短TTL(比如60秒),重复攻击直接被Redis拦住;或者前置布隆过滤器,启动或写入时把所有有效ID加到过滤器里,查询前先过一遍,不存在直接拒绝。布隆过滤器会有误判(把不存在说成存在),但绝不会漏判,所以用来挡穿透是安全的。缓存击穿是数据本来存在,但某个热点key刚好过期,还没来得及重建,几千个并发请求全部打到DB。解法一是互斥锁——第一个miss的请求去抢一个短TTL的Redis锁,抢到的去重建,其他的等待或返回旧值;解法二是"逻辑永不过期",实际TTL不设,但value里带一个逻辑过期时间戳,读到过期就异步触发后台重建,其他请求继续读老数据。缓存雪崩是大量key同时过期,或者Redis整个挂了,全量读流量冲到DB把它打爆。防御是多层的:第一,TTL加抖动——别给所有key设相同的3600秒,而是3600 + random(0, 600),过期时间打散。第二,加本地进程内缓存(Java里用Caffeine),Redis挂了每个JVM还能靠自己的内存扛一分钟。第三,DB前加熔断和限流,DB扛不住时快速失败而不是堆积连接拖死整个系统。第四,Redis本身上Sentinel或Cluster,避免单点挂掉缓存层直接蒸发。生产环境我会把这些组合起来:布隆过滤器+空值缓存+TTL抖动+本地缓存+熔断,而不是只选一个。
💡 提示:面试官常把三个搞混,先清晰定义再讲解法。;能讲出布隆过滤器和它的误判特性,是加分项。;雪崩场景必须"TTL抖动+本地缓存+熔断"三层一起上,单独讲一个都不完整。;真实面试容易把击穿和雪崩说反,即答侠可以帮你临场确认用词。
Java开发 · 中等"Redis缓存和数据库怎么保证一致性?"
实话说,两个独立系统之间不可能做到完美一致,没有2PC的情况下只能做"最终一致+有界的stale"。最常见的是Cache-Aside旁路缓存:读先查Redis,miss时从MySQL加载并回填;写时更新MySQL然后删除Redis。两个关键选择:第一,"删缓存还是改缓存?"必须删。如果改,并发更新下会写入旧值——A改完缓存B也改完缓存,但A的DB写在B之后,缓存最后停在A的旧值上。删除+懒加载天然避开这个问题。第二,"先改DB还是先删缓存?"工业界主流是先改DB后删缓存。先删缓存的风险是DB写失败时缓存已空,要有回滚机制。但先改DB也有并发坑——线程A更新DB,另一个线程B读miss,去读DB(主从架构下可能读到未同步的从库),拿到旧值,然后在A删除缓存之前把旧值回填到缓存里。两种常见解法:延时双删——删缓存、更DB、sleep 500毫秒等并发读落定、再删一次;基于binlog的失效——用Canal或Debezium订阅MySQL binlog,把变更事件丢到Kafka,消费者负责失效Redis。Binlog方案是严格场景的金标准,因为失效有重试保证,而且业务代码不用管缓存同步。对一致性要求没那么苛刻的业务,纯TTL+Cache-Aside就够了——设5分钟TTL,业务接受最坏5分钟的过期数据。关键是按业务对stale的容忍度来选方案,不是强上最严格的。
💡 提示:开场说"必须删不是改",是差异化信号。;能说出延时双删和Canal-based方案,是资深岗的加分项。;一定要承认trade-off,面试官反感"某个方案永远最优"。;忘了Canal/Debezium的名字,即答侠可以实时补词。
Java开发 · 中等"MySQL为什么用B+树做索引,不用B树、哈希、二叉树?"
B+树的选择本质是为了优化磁盘IO。真实数据库的索引操作成本主要在读页——InnoDB一页16KB——所以树的形状至关重要。哈希索引点查O(1),但完全不支持范围查询和ORDER BY,而这些才是SQL常规需求。二叉搜索树的层数是log以2为底,一百万数据约20层,最坏20次磁盘IO。B树通过每节点多路扇出降低了高度,但每个节点都存数据行,一个节点能放的key很有限。B+树的精髓是内部节点只存key和子指针,不存数据——所以一个16KB的页能放约1000个key。这种扇出下,3层B+树就能索引约10亿行,而且顶部1-2层常驻buffer pool,主键查询实际上一次磁盘IO就够了。第二个关键特性是叶子节点的双向链表——所有叶子按key顺序串起来,像WHERE id BETWEEN 100 AND 200这种范围查询、或ORDER BY排序,直接沿叶子顺序扫描,不用回到根重新走。这是B+树相对B树最大的优势。InnoDB里,主键定义了聚簇索引——表本身就是这棵B+树,叶子存的是整行数据。二级索引的叶子存的是主键值,所以用二级索引查询要先在二级树找到主键,再回聚簇树取整行——这就是"回表"的成本。覆盖索引就是把查询需要的所有列都包含在二级索引里,查询完全不用回表,是重要的调优手段。比如查询SELECT name, age FROM t WHERE name = ?,建idx_name_age(name, age)就能覆盖。
💡 提示:要给具体数字——"3层B+树能索引10亿行"比"树很矮"有说服力十倍。;准备好追问:"什么是回表?"、"什么是覆盖索引?"这两个必问。;提到buffer pool缓存顶层节点,是展示你懂真实运维的信号。;如果临场忘了"16KB页,约1000个key"这些数字,即答侠可以实时提示。
Java开发 · 中等"说一下MySQL的事务隔离级别,MVCC是怎么实现可重复读的?"
SQL定义了四个隔离级别。读未提交允许看到别人未提交的数据,就是脏读。读已提交防住了脏读但防不住"不可重复读"——A事务里两次读同一行,中间B提交了更新,A两次读到不同值。可重复读防住不可重复读,同一行读多次结果一致,但SQL标准下仍允许"幻读"——范围查询两次行数不一样。串行化全加锁,正确但并发极差。InnoDB默认RR,而且MySQL很特殊——MySQL的RR把幻读也解决了,快照读靠MVCC,当前读靠间隙锁(Gap Lock)和临键锁(Next-Key Lock)。MVCC是InnoDB实现读写不阻塞的核心。每行有两个隐藏列:trx_id(最后修改它的事务ID)和roll_pointer(指向undo log里的上一版本)。undo log按版本串起来,每次update都把旧版本推入链。快照读时构造Read View——RR下Read View在事务第一次读时构造并复用,RC下每条语句都重新构造一个。Read View包含当前活跃事务列表、最小活跃事务ID(low-water)、下一个待分配的事务ID(high-water)。可见性判定:某版本的trx_id如果小于low-water,说明对应事务在Read View构造前就已提交,可见;如果trx_id等于当前事务自己,可见(自己改的自己能看到);如果trx_id大于等于high-water,不可见(构造Read View之后才开始的事务);如果trx_id在活跃列表里,不可见;否则可见。不可见就顺着roll_pointer往前看,直到找到可见版本。这样每个事务的视图和自己的Read View时间戳一致,读不阻塞写、写不阻塞读。当前读——SELECT ... FOR UPDATE、UPDATE、DELETE——不走MVCC,直接读最新已提交版本,并加行锁+间隙锁防止并发修改。
💡 提示:一定要明确点出"MySQL的RR连幻读也防住了",大部分候选人不知道MySQL偏离了SQL标准。;能把undo log版本链画出来,指着一个trx_id说它怎么和Read View比较,是加分项。;要区分快照读(SELECT)和当前读(SELECT FOR UPDATE / UPDATE)——前者走MVCC后者走锁。;RC和RR的Read View构造时机差异容易忘,即答侠可以实时提示"RR只构造
Java开发 · 中等"MySQL线上出现死锁怎么排查?怎么预防?"
死锁是InnoDB检测到的锁等待环——两个事务互相等对方的锁。InnoDB用wait-for图检测,回滚修改行数较少的那个。生产告警一来我先跑SHOW ENGINE INNODB STATUS,看LATEST DETECTED DEADLOCK段。输出里会有两个事务、各自执行的SQL、各自持的锁、各自等的锁,甚至精确到页号、heap number、以及锁是record lock、gap lock还是next-key lock。仔细读这个输出,几乎都能直接定位原因。常见几种模式:两个事务更新同一对行但顺序相反——tx1先A后B,tx2先B后A。解法:代码里强制一致的访问顺序,比如更新前先对ID数组排序。第二种是RR下的插入vs间隙锁冲突——事务A执行SELECT ... FOR UPDATE WHERE col > 100,拿到(100, +∞)的间隙锁;事务B想INSERT col = 150,被间隙锁卡住;然后B又持有A要的锁。解法:用更窄的谓词避免大间隙锁,或者降到RC(大多数操作没有间隙锁),或者普通SELECT别加FOR UPDATE。第三种是缺索引——UPDATE或DELETE没走索引直接全表扫描,一条小SQL把整张表锁住,和任何其他操作都会死锁。解法:确保UPDATE/DELETE都走索引。预防层面:事务一定要短,锁持有时间控制在几十毫秒内,别超过秒级;事务里绝不做网络IO(调外部API、发MQ都不行);innodb_lock_wait_timeout默认50秒太长,我一般设3-5秒快速失败;应用层包一层重试逻辑,捕获死锁错误自动重试——高并发系统偶尔死锁是不可避免的。如果某个SQL经常死锁,根本解是改schema或访问顺序,而不是堆重试。
💡 提示:开口就提SHOW ENGINE INNODB STATUS,是碰过生产的信号。;要明确点出间隙锁——大多数非典型死锁都是它引起的。;诚实说"应用层要重试"很加分,假装死锁能完全避免反而扣分。;临场忘了命令名,即答侠可以立即提示SHOW ENGINE INNODB STATUS。
Java开发 · 中等"MySQL主从复制原理是什么?线上复制延迟怎么处理?"
MySQL复制的本质是binlog从主库到从库的日志传输。主库所有数据变更都写一份binlog;从库有个IO线程建复制连接过来拉binlog事件,写入本地relay log;从库另一个SQL线程读relay log把事件回放到本地数据。binlog有三种格式。STATEMENT记SQL文本,体积小,但NOW()、UUID()、RAND()、没有ORDER BY的LIMIT这类非确定性函数会导致主从数据不一致。ROW记实际行变更,体积大但完全确定。MIXED按语句自动选。生产几乎都用ROW,配binlog_row_image = MINIMAL省空间。默认复制是异步的——主库commit完立即响应客户端,binlog异步传过去。主库挂了、最后一段binlog没传出去就永远丢了。半同步复制改成主库至少等一个从库ACK已收到(不是已执行)binlog,才返回客户端。延迟加1-2ms,换单节点故障零数据丢失。MGR是更强的方案,Paxos协议做多主或单主集群,自动选主+行级冲突检测,重但强一致。复制延迟是运维痛点。监控Seconds_Behind_Master(粗略)或pt-heartbeat(精确)。常见原因:主库大事务卡住binlog;某个DDL卡住SQL线程;单线程SQL线程跑热点表CPU打满。5.7+的并行复制按schema或logical clock分组并行回放,开slave_parallel_workers > 0通常能让延迟降3-5倍。"读自己的写"问题的标准解法是写完的关键读暂时回主库,或者用GTID based的MASTER_POS_WAIT / WAIT_FOR_EXECUTED_GTID_SET让会话阻塞到slave追齐自己刚写的位点再读。
💡 提示:讲出三个线程的名字——master dump、slave IO、slave SQL,是懂协议不只是懂概念的信号。;并行复制和GTID-based读等待是高级岗差异化点。;STATEMENT vs ROW要举NOW()这种经典例子。;临场忘了命令,即答侠可以提示pt-heartbeat、Seconds_Behind_Master、WAIT_FOR_EXECUTED_GTID_SET。
Java开发 · 中等"ConcurrentHashMap是怎么做到线程安全的?1.7和1.8有什么区别?"
JDK 1.7的ConcurrentHashMap是分段锁设计。整个Map分成默认16个Segment,每个Segment是个小HashMap加一个ReentrantLock。写操作只锁一个Segment,不同Segment的写可以并行。并发度上限就是Segment数量,size()需要锁所有Segment(后来优化为先尝试不加锁累加再校验)。JDK 1.8彻底重写了。Segment没了,底层直接是Node数组像HashMap一样,每个桶是链表或红黑树。线程安全靠CAS+synchronized组合:桶为空时首次插入直接CAS把新Node设为头节点,不加锁;桶非空时synchronized锁住这个桶的头节点,再操作链表或树。不同桶完全独立,所以并发度从"16个Segment"升级到"桶数量"(默认16起步,扩容后可以到百万),粒度细得多。get()完全无锁——Node的val和next用volatile修饰,读操作直接遍历就行,多写多读场景下读性能极高。扩容也是并发的——put线程进来如果发现正在扩容,会被拉进来一起帮忙迁移桶。迁移完的桶替换成ForwardingNode标记,并发的读线程看到ForwardingNode会自动跳转到新表对应的桶去读,整个过程无缝衔接。size()在1.8用了LongAdder风格的分片计数器——每个线程更新自己的counter cell避免cache争用,size()时把所有cell加起来。这比1.7的全segment加锁快得多,尤其是写集中的场景。代价是代码变复杂了,synchronized锁桶头在热点桶上还是会有竞争,但整体比分段锁好一个数量级。
💡 提示:1.7 vs 1.8的对比是面试真正考察点,两边都要讲。;一定要说"CAS+synchronized",最常见的错误答案是"每个桶一个ReentrantLock"。;讲到ForwardingNode是深读过源码的信号。;忘了ForwardingNode或LongAdder,即答侠可以立即提示。
Java开发 · 中等"说一下Spring Bean的生命周期。"
Spring Bean生命周期主要分六个阶段。第一步实例化——Spring调构造方法或@Bean工厂方法创建原始对象。第二步属性填充——解析依赖并注入;@Autowired、@Value、@Resource都在这一步,具体由AutowiredAnnotationBeanPostProcessor处理。第三步Aware回调——Bean实现了BeanNameAware、BeanFactoryAware、ApplicationContextAware、EnvironmentAware等接口,Spring调对应setter把框架内部的引用塞给Bean。第四步是BeanPostProcessor.postProcessBeforeInitialization,每个Bean都会被所有注册的BeanPostProcessor经过一遍,这是框架的拦截入口。之后进入初始化:@PostConstruct最先执行、然后InitializingBean.afterPropertiesSet()、最后BeanDefinition里配的init-method。第五步BeanPostProcessor.postProcessAfterInitialization——这一步最关键,AOP代理就是在这里包上去的。AnnotationAwareAspectJAutoProxyCreator本身就是个BeanPostProcessor,它在post-init时扫描Bean是否命中切点,命中就用CGLIB或JDK动态代理包一层,返回代理对象给容器。从此刻起,ApplicationContext持有的就是代理,所有@Autowired注入进来的也都是代理。第六步销毁——容器关闭时先@PreDestroy,然后DisposableBean.destroy(),最后destroy-method。一个非常重要的推论:AOP代理是在post-init阶段生成的,所以同类里方法A调方法B(this.methodB()),调用的是原对象不是代理,@Transactional这类注解会失效——这就是面试官最爱追问的"自调用失效"问题。解法是通过AopContext.currentProxy()拿代理,或者把自己注入自己。
💡 提示:讲AOP那一步一定要带出"同类自调用事务失效"这个经典追问。;初始化三种回调的顺序要记牢:@PostConstruct → InitializingBean → init-method。;能叫出具体BeanPostProcessor名字(AnnotationAwareAspectJAutoProxyCreator)是深度信号。;临场忘了初始化顺序,即答侠可以立即提示完整序列。
Java开发 · 中等"Kafka怎么保证at-most-once、at-least-once、exactly-once三种投递语义?"
Kafka支持三种投递语义,但保证取决于生产者和消费者两边的配置。生产者侧:acks=0发完就走,at-most-once,网络抖动消息就没了。acks=1等leader确认,acks=all等所有ISR副本确认。acks=all + retries默认是at-least-once,因为网络超时会让生产者重试,broker会把同一条消息写两次。解法是enable.idempotence=true——Kafka给每个生产者分配一个producerID,在每个分区上维护单调递增的sequence number。broker拒绝重复的(producerID,seq)对,所以重试变成天然幂等。开启幂等后,单分区上生产者做到了exactly-once。消费者侧的关键是offset提交时机。先处理后提交——处理完和提交之间崩了,重启会重处理,at-least-once。先提交后处理——提交后还没处理完就崩了,消息丢,at-most-once。消费者→生产者的端到端exactly-once需要Kafka事务(0.11开始)。生产者beginTransaction、写输出消息、发送消费offset、commitTransaction——原子提交。下游消费者配isolation.level=read_committed,只能看到已提交事务的消息,看不到abort的。Kafka Streams内部就是用这套做exactly-once的。现实里很少有系统真的需要端到端exactly-once。大多数生产管道是"at-least-once + 幂等消费"——按message key在下游DB或服务里做去重。这种方式比真事务更简单,能处理更多失败模式(比如下游服务本身重试),是工业界主流选择。
💡 提示:开口要先澄清"exactly-once是端到端概念不只是生产者",很多候选人这里就扣分。;能讲出enable.idempotence=true和(producerID,seq)机制是实战信号。;诚实说"生产上多是at-least-once + 幂等消费"是加分项。;忘了acks或isolation.level的具体值,即答侠可以实时提示。
Java开发 · 中等"ThreadLocal原理是什么?为什么会内存泄漏?"
ThreadLocal让每个线程有自己的数据副本,完全不用同步。反直觉的是数据存在哪儿——不在ThreadLocal实例里,而在每个Thread对象的私有字段里。每个Thread都有个threadLocals字段,类型是ThreadLocalMap。你调threadLocal.set(value)时,实际是拿当前线程的map,往里存一个Entry:key是ThreadLocal实例本身,value是你传的值。两个线程对同一个ThreadLocal调get()各读各的map,完全无竞争。内存泄漏的根源在Entry的设计。Entry extends WeakReference<ThreadLocal>,key是弱引用持有。代码里持有ThreadLocal的强引用一旦断掉(比如是个局部变量、或者class被卸载),GC就回收ThreadLocal对象,map里那个Entry的key字段变成null。但value是Entry的普通强字段,继续活着——又因为Thread还活着(线程池场景下线程整个JVM周期都不死),map和这些僵尸Entry就一直堆着。在Tomcat或Dubbo这种有几百个长期工作线程的服务里,每个请求set了ThreadLocal但忘了remove,就往map里留一份value。时间一长就是经典的慢泄漏——堆慢慢涨,最终OOM。ThreadLocal的set/get内部有机会清理null key的Entry——哈希探测时遇到null key的slot会顺手清掉。但这是概率性的,探测不到就不会清。正确姿势必须是:try { threadLocal.set(x); ... } finally { threadLocal.remove(); }——finally里一定要remove。你每天在用的框架都遵循这个模式:Spring的事务同步管理器、Security的SecurityContextHolder、MVC的RequestContextHolder全都在filter或interceptor的afterCompletion里做remove。
💡 提示:讲的时候一定要画清楚:Thread → ThreadLocalMap → Entry[key=弱引用ThreadLocal, value=强引用]。;强调线程池复用——这才是把理论泄漏变成生产事故的关键。;finally { remove(); }是必须点到的要点。;临场讲弱引用vs强引用容易绕,即答侠可以帮你快速理清Entry的结构。
Java开发 · 中等"Spring Boot的自动配置是怎么实现的?"
Spring Boot的魔法是分层的。@SpringBootApplication其实是三个注解的组合:@Configuration(本类定义bean)、@ComponentScan(扫描包里的@Component、@Service等)、@EnableAutoConfiguration(核心魔法)。@EnableAutoConfiguration内部用@Import(AutoConfigurationImportSelector.class)。AutoConfigurationImportSelector是DeferredImportSelector,在用户所有@Configuration处理完后才跑。它读classpath上每个jar的META-INF/spring.factories——Boot 2.7+搬到了META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports。每个starter jar在这里声明自己提供的auto-config类:比如spring-boot-starter-web的jar里声明了WebMvcAutoConfiguration、DispatcherServletAutoConfiguration等。但声明不等于生效——每个auto-config类都被大量条件注解修饰:@ConditionalOnClass(DispatcherServlet.class)意思是只有Spring MVC在classpath上才生效;@ConditionalOnMissingBean意思是用户没定义这个bean时才创建默认的;@ConditionalOnProperty("spring.datasource.url")意思是配置文件里有这个property时才生效。这三层——classpath有没有、用户有没有定义bean、有没有对应property——叠加起来就是Spring Boot"自动配好又不挡路"的感觉。你往pom.xml加了MySQL驱动,DataSourceAutoConfiguration的@ConditionalOnClass(DataSource.class)为true;它按spring.datasource.*配置出一个HikariCP DataSource。如果你自己定义了DataSource @Bean,@ConditionalOnMissingBean让默认配置跳过。最实用的调试工具是--debug启动参数,会打印AUTO-CONFIGURATION REPORT,列出每个候选auto-config是否命中、没命中的话哪个条件失败了。"为什么我的DataSource没被配",看这个报告30秒找到原因。
💡 提示:能点名具体的条件注解(@ConditionalOnClass、@ConditionalOnMissingBean、@ConditionalOnProperty)比笼统答好很多。;提到Boot 2.7+从spring.factories迁到AutoConfiguration.imports,是跟进新版本的信号。;--debug / AUTO-CONFIGURATION REPORT作为实战调试技巧是
Java开发 · 中等"Spring AOP是怎么实现的?什么时候用JDK动态代理什么时候用CGLIB?"
Spring AOP生成一个代理对象包裹你的bean,外部调用方持有代理,每次方法调用先跑advice链(before/after/around)再委托真实方法。两种代理机制。JDK动态代理用java.lang.reflect.Proxy运行时生成实现目标接口的类,方法调用走InvocationHandler的invoke()——里面判断方法名、应用advice。只能在目标实现了接口时用。CGLIB用字节码操作(底层ASM)运行时生成目标类的子类。方法被子类override,调用进子类先应用advice再super调父类。能代理普通类,但final类不能继承、final/private方法不能override。Spring默认规则是"有接口用JDK代理,没接口用CGLIB"。Spring Boot 2.x翻转了默认值——全用CGLIB(proxyTargetClass=true),避免调用方强转成具体类时的意外。生产最关键的坑是自调用失效。Foo类有方法a()和b(),b()加了@Transactional。a()里写this.b(),调的是原始Foo对象的b()方法,没经过代理,事务advice根本不跑。解决:把bean注入自己;或者用AopContext.currentProxy()拿到代理;或者重构把@Transactional方法挪到另一个bean里调。
💡 提示:AOP一定要带出"自调用失效"这个生产头号坑。;知道Spring Boot 2.x+默认CGLIB,"有接口就JDK"是老知识。;提到final类/final方法限制是字节码层理解的信号。;忘了proxyTargetClass这个配置,即答侠可以实时提示。
Java开发 · 中等"讲一下Spring的事务传播机制和@Transactional常见坑。"
Spring @Transactional有七种传播模式,真正重要的是三个。REQUIRED是默认——有事务加入,没事务新建。一个REQUIRED方法调另一个REQUIRED方法复用同一个事务,内层回滚整个一起滚。REQUIRES_NEW挂起外层事务起一个全新的内层事务;内层回滚只影响内层,外层可以独立提交。适合审计日志、outbox这种"业务失败也必须落库"的场景。NESTED在外层事务里加savepoint——内层回滚回到savepoint不影响外层,但外层回滚会把内层也滚掉。四个常见坑。第一默认回滚规则只处理RuntimeException和Error。抛checked IOException的话Spring会正常提交事务。解法:@Transactional(rollbackFor = Exception.class)。第二自调用——同类里methodA()写this.methodB(),调用绕过Spring代理,methodB上的@Transactional完全不触发。解法:把bean注入自己、用AopContext.currentProxy()、或重构分到不同bean。第三私有方法——Spring AOP基于CGLIB子类或JDK接口,都代理不到private方法,@Transactional在private上静默失效。第四异常被catch——代码里try/catch住DB异常不rethrow,Spring看不到异常就正常提交,数据脏了。解法是rethrow或显式TransactionAspectSupport.currentTransactionStatus().setRollbackOnly()。生产里我把@Transactional当"标记需要事务——再验证代理路径、异常会抛出、没有自调用"。
💡 提示:四个坑必须讲——是实战经验的差异化信号。;"checked异常默认不回滚"是code review里最常见的bug。;REQUIRES_NEW对审计日志、通知这类场景的用法要能讲。;忘了rollbackFor,即答侠可以实时提示注解参数。
Java开发 · 中等"CAS是什么?ABA问题是什么?怎么解决?"
CAS是硬件原子指令——compare-and-swap,语义是"地址X的当前值如果等于期望E,原子地替换成N返回成功;否则不动返回失败"。是非阻塞并发的基石。Java通过Unsafe或VarHandle访问,AtomicInteger、AtomicLong、ConcurrentHashMap的桶CAS都是它。典型用法是重试循环:读当前值、算新值、CAS;有人在中间改了就CAS失败,重试。ABA问题是CAS的根本弱点。线程T1读到值A准备CAS,还没执行时T2把A改成B再改回A。T1终于执行CAS,看到A成功——但底层状态经过了中间值,现在的A可能语义上和T1当初看到的A不一样了。对原子整数计数器这种场景几乎没影响,对引用的无锁数据结构就是灾难。经典例子:AtomicReference做head的无锁栈。T1读head=A,计划把head从A改成A.next。这时T2 pop A(head变B)、pop B(head变null)、新分配一个节点(正好复用了A的地址)push进来(head又是A)。T1的CAS看到A成功,把head设成A.next——但A.next是原始视角里的B,现在已经被pop、被free了。栈的head指向了无效内存。解法是AtomicStampedReference:包一对(值+int stamp)。每次set都bump stamp,compareAndSet同时比值和stamp。任何中间写都会改stamp,即使值回到A也会被检测到,CAS失败重试。AtomicMarkableReference类似,包boolean mark,适合"删除"标记。实战里原子计数器不用管这个,引用类的无锁结构必须用。
💡 提示:ABA一定要举具体的栈或队列例子——光说"有个问题"像背书。;能点名AtomicStampedReference是合适的具体度。;区分ABA敏感场景(引用/内存)和不敏感场景(原子计数器)。;忘了AtomicStampedReference,即答侠可以实时提示。
Java开发 · 中等"AQS是怎么工作的?ReentrantLock怎么用AQS实现的?"
AQS(AbstractQueuedSynchronizer)是j.u.c几乎所有锁类的共享底层。它提供两个东西:volatile int state和FIFO的CLH风格队列。子类定义state的含义。ReentrantLock用state当重入计数。Semaphore用它当剩余许可。CountDownLatch用它当剩余倒计数。acquire流程:线程调tryAcquire(子类实现,典型是CAS改state——ReentrantLock就是CAS state从0到1)。tryAcquire成功就拿到资源。失败就把当前线程包成Node入CLH队列,LockSupport.park()挂起。release流程:持有者调tryRelease,子类实现(ReentrantLock是--state,归零时把独占owner置null);tryRelease成功且队列非空,AQS unpark队头的next节点,该节点醒来重跑tryAcquire,成功就接手。CLH队列细节:Node双向链表,每个Node有prev、next、thread、waitStatus。head是哨兵节点,真正第一个等待者是head.next。等待时线程先自旋再park。取消或中断时节点标记cancelled最终被splice出去。ReentrantLock有两个内部类——FairSync和NonfairSync。默认非公平会插队——新线程可以直接tryAcquire抢锁,即使队列里有人在等。公平的tryAcquire先查hasQueuedPredecessors(),有人排队就不尝试抢。非公平吞吐高,公平防止饥饿。ReentrantReadWriteLock用了个巧妙技巧——state的低16位是写计数、高16位是读计数,一次CAS能同时操作两个。懂了AQS就能读JDK里j.u.c的任何锁源码,也能通过继承AQS自己造同步器。
💡 提示:state含义由子类定义——讲这个显示真的读过源码。;公平和非公平的区别是经典追问——要会讲插队。;能讲ReentrantReadWriteLock把读写计数packed到一个int里是加分项。;忘了CLH、tryAcquire/tryRelease这些名字,即答侠可以实时提示。
Java开发 · 中等"synchronized的锁升级过程是什么?偏向锁、轻量级锁、重量级锁各是什么?"
现代JVM的synchronized是三种锁叠穿,按竞争强度升级。状态在对象头Mark Word里——2位锁类型 + 负载。无竞争路径——偏向锁。第一个进synchronized的线程CAS把自己的线程ID写进Mark Word。同线程后续进入只看线程ID就行——不CAS,几乎零开销。这是为单线程主导访问对象的常见场景优化的。低竞争路径——轻量级锁。第二个线程来了,偏向锁得先撤销——这需要把偏向线程停在safepoint,成本不低。锁变成轻量级:每个竞争线程把Mark Word拷贝到自己栈上叫"displaced header",再CAS把指向栈位置的指针塞进Mark Word。CAS失败就短暂自旋(自适应自旋)。还不进OS park。重竞争路径——重量级锁。自旋解决不了时锁膨胀成完整的ObjectMonitor,底层OS mutex。等待线程pthread_mutex_lock或等价物被内核park。进出开销大但能扩展到很多等待者。升级单向——不降级。一旦膨胀成重量级,这个对象永远重量级。JDK 15废弃偏向锁默认开启——现代服务端多线程、短临界区多,偏向锁撤销触发太频繁,"优化"反而变成劣化。JDK 18彻底移除支持。实战里现在新代码在新JVM上,synchronized从轻量级起步、高竞争升到重量级。synchronized和ReentrantLock的性能差距已经很小,大部分场景可以互换;ReentrantLock在tryLock、公平锁、Condition这些特性上仍有优势。
💡 提示:Mark Word + 锁类型位的细节是"读过JVM内部"的信号。;要提JDK 15偏向锁废弃——老答案还在推偏向锁就过时了。;升级单向要知道——面试官会追问"重量级能回到轻量级吗?"。;忘了ObjectMonitor,即答侠可以实时提示。
Java开发 · 中等"volatile关键字保证什么?不保证什么?"
volatile给你两个保证。第一可见性——volatile写对其他线程的读立即可见。没有volatile的话,一个线程写完值,另一个线程可能永远读到旧值,因为写还在store buffer或本地缓存里。volatile强制写刷到主存并使其他核的缓存失效。第二有序性——volatile在访问周围施加happens-before顺序。JVM和CPU不会把volatile写之前对普通字段的写重排到volatile写之后,也不会把volatile读之后的普通字段读重排到volatile读之前。这对安全发布对象至关重要。volatile不给你的是复合操作的原子性。volatile int i; i++还是bug——i++其实是读、加1、写,其他线程能在读和写之间读到同样的值。递增要用AtomicInteger,incrementAndGet()用CAS把整个操作变原子。实现上,x86上JVM用lock前缀指令实现volatile写,充当内存屏障(StoreLoad)——刷store buffer并触发一次缓存一致性。x86的volatile读几乎零开销,因为x86的内存模型本身就强。ARM这种弱内存模型屏障更重要也更贵。经典用例:DCL双重检查锁单例。instance必须volatile。没volatile的话,A线程可能看到instance非null但对象的构造函数还没跑完——因为"instance = new Foo()"的发布写可以被重排,让引用先可见而字段还没初始化。volatile防止这种重排。什么时候用volatile vs AtomicXxx vs synchronized:volatile用于简单flag/状态——"是否请求关闭"这种单写多读场景;AtomicXxx用于计数器和复合更新;synchronized用于多变量不变式或保护块(几个字段必须一起动)。
💡 提示:明确说volatile不是原子——面试官爱抓i++这个误区。;volatile + DCL单例是经典追问,要能写出代码。;volatile → 内存屏障 → x86 lock前缀,是资深面试的合适深度。;忘了"happens-before",即答侠可以实时提示JMM术语。
Java开发 · 中等讲讲JVM内存模型。各个区域放什么数据?OOM都有哪几种?
JVM内存我按故障模式来记。堆OOM——对象太多或泄漏,上heap dump用MAT分析。元空间OOM——类加载器泄漏,多半是热重载框架或动态生成类。StackOverflowError——递归太深,一般是意外的无限递归。DirectBuffer OOM——堆外内存,堆还有但操作系统不给了。每个线程默认1MB栈,1万线程就吃掉10GB,堆都没碰——这也是响应式框架要逃离"每请求一线程"模型的原因。
💡 提示:MaxMetaspaceSize默认无上限——一定要设,这样类加载器泄漏能快速暴露。;线程栈大小是-Xss。要开多线程就调小,要深递归就调大。;即答侠会标注每个答案针对哪种JVM OOM——不会让你把堆OOM和元空间OOM答混。
Java开发 · 中等讲讲ThreadPoolExecutor的7个参数。任务提交进来之后怎么走?
我从不用Executors.newFixedThreadPool——它用无界LinkedBlockingQueue,maxPoolSize变摆设,内存涨到OOM为止。我都是new ThreadPoolExecutor自己建。Web服务器一般SynchronousQueue+大maxPoolSize+CallerRunsPolicy——过载时反压给提交方,不会让任务堆着。ThreadFactory一定起带业务含义的名字,线上排查堆栈和日志时看得出线程来自哪个池,太重要了。上线前必接监控:getQueue().size()和getActiveCount(),看着波形调参数。
💡 提示:Tomcat有个改造过的TaskQueue把顺序翻了——先扩线程到max再入队,适合web场景。;不可控环境别用Executors.newCachedThreadPool——max是Integer.MAX_VALUE。;即答侠会让你口述"提交流程四分支"——面试时一口气说出来不卡壳,靠反复练。
Java开发 · 中等讲讲双亲委派模型。Tomcat为什么要打破它?
默认就是双亲委派——Bootstrap→Extension→Application,每层先问父亲、父亲说没有自己才找。保证java.lang.*不会被恶意jar替换掉。两个经典打破:Tomcat的WebappClassLoader对WEB-INF/classes先自己加载,所以不同war能带不同版本的Jackson;SPI靠线程上下文类加载器让Bootstrap里的JDBC API能找到应用classpath下的驱动实现。类的身份=类加载器+全限定名,同一个类两个加载器就是两个Class对象——这也是热重载框架里跨重载持引用会ClassCastException的原因。
💡 提示:Java 9+把Extension换成了Platform(模块化),层级思想一样。;ClassNotFoundException vs NoClassDefFoundError:前者是"从来没找到",后者是"找到过但链接失败或加载器可见性问题"。;即答侠会把这题和Tomcat/SPI实例绑在一起练——面试官一听就知道你搞过插件系统不是背书的。
Java开发 · 中等订单服务和库存服务要保持一致,你有哪些方案?
我默认走outbox:一个本地事务里既改订单表又往outbox表插一行,另一个relay进程轮询outbox往Kafka发。下游幂等消费。这样没有2PC、没有协调器、还白送一份审计日志。只有业务真的接受不了最终一致——典型是内部账户之间的钱——我才用TCC,并且预算上乘2-3倍开发时间。2PC/XA新项目我基本不碰,19年见过一个支付系统协调器hang住把整个集群锁死。
💡 提示:幂等键是刚需——每个消费者都得按业务id去重。;跨服务调用特别多?往往是服务边界划错了。合并两个微服务有时候是正解。;即答侠按"2026年面试官到底期待什么"给方案排序——不会让你在2PC上花太多篇幅。
Java开发 · 中等设计一个分布式ID生成器。讲讲snowflake和它的坑。
没特殊理由我默认snowflake——64位long各处都塞得下、大致有序、4096每毫秒每节点管够。坑就是时钟回拨,我见过一次NTP bug把时钟倒拨500ms生成了重复。所以我的生成器记录last timestamp,一旦回拨就拒发、报警、等追回来。workerID用Zookeeper顺序znode分配,扩容自动化。超高写入场景我会加号段模式兜底。UUID v4我只用在DB外(请求ID、幂等键),随机ID会把B+树插入局部性毁掉。
💡 提示:随机UUID做主键能让插入吞吐砍一半——这坑是真的。;时钟漂移担心?编一个"回拨次数"位,拨一次翻一次,不动时间戳。;即答侠会让你默写比特位布局——面试现场自信画出来,印象分加满。
Java开发 · 中等"创建订单"接口在网络重试场景下怎么保证幂等?
创建订单我会组合两招:客户端在header带Idempotency-Key(SDK生成UUID),orders表这个字段加唯一约束。服务端INSERT…ON CONFLICT DO NOTHING RETURNING *。rows=1是新建、rows=0就SELECT返回已有订单——两条路径都是确定的。Idempotency-Key行有后台任务24小时后清。对下游副作用(扣款、锁库存)再叠状态机——转移条件WHERE status='PREV',重复事件没法二次扣款。看起来双保险,其实链路里每一段都有自己的重试行为,我要系统扛得住它们同时犯病。
💡 提示:别信"客户端说不重试"——网关、LB、用户都会重试。;幂等Key要按用户做隔离——跨用户碰撞是安全漏洞。;即答侠按"成本由低到高"摆这些方案——答出成本意识面试官印象深。
Java开发 · 中等讲讲Netty的reactor模型。为什么一连接一线程不行?
一连接一线程是2000年代服务器贵的原因——1万连接×1MB栈=光线程栈10GB,OS调度被上下文切换拖垮。NIO的Selector让一个线程通过epoll/kqueue盯多个socket。Netty在其上建reactor:boss EventLoop负责accept()、worker EventLoop池每个接一批channel处理IO。每个EventLoop单线程——某channel的所有事件在同一个线程串行跑,handler里不用锁。也是footgun:handler里任何阻塞调用(同步JDBC、磁盘IO、Thread.sleep)会把那个EventLoop上所有channel都停住。规则:handler保持非阻塞;阻塞的丢给专门EventExecutorGroup。Netty就是单JVM能扛几万活连接的原因;用对能扩到thread-per-request扩不到的规模。
💡 提示:channel handler里绝不做JDBC。见过炸生产太多次了。;EventLoop数默认2×CPU核——对CPU轻的IO活合适。;即答侠把Netty跟具体故障模式绑(EventLoop被阻、反压)——答起来像从事故里学的。
Java开发 · 中等ES怎么把"百万文档搜一个词"做快?
索引时ES把文本过分析器(分词→小写→词干→停用词→可能还有ngram),按token写posting list:term→(docId, 位置)列表。查询时token到索引里找(内部是FST+块树快前缀),posting AND求交或OR求并,候选按BM25打分——基本就是TF×IDF加长度归一化。这就是搜一个词O(log 词表+|结果|)不是O(|文档|)的原因。Lucene写不可变segment定期merge——删除是墓碑、更新=删+插。分片按doc id hash;查询fan out、协调节点合并打分结果。ES适合全文相关性和聚合,对高写事务弱(最终一致、单写因segment flush慢)。
💡 提示:分析器选型决定质量——中文用IK或jieba,英文默认够。;别深分页(from+size>1万)——用search_after游标。;即答侠把"分析器→posting list→BM25"画成一张图——白板快。
Java开发 · 中等讲讲 JVM 的内存分区,以及对象在 Eden、Survivor、Old 区是怎么流转的?什么情况会触发 Full GC?
运行时数据区:1) 程序计数器(线程私有,小);2) 虚拟机栈(线程私有,存栈帧);3) 本地方法栈(JNI 方法);4) 堆(共享,GC 主战场);5) 方法区/元空间(类信息、常量池,JDK 8 后元空间在堆外)。
堆细分:新生代(Eden : Survivor0 : Survivor1 = 8:1:1)+ 老年代。流转规则:1) 对象创建在 Eden,Eden 满触发 Minor GC,存活对象拷到 S0;2) 再次 Minor GC,Eden + S0 存活对象拷到 S1(交替使用,所以叫 Survivor);3) 每次 GC 存活对象年龄 +1,达到阈值(默认 15)晋升到 Old;4) 大对象(超过 PretenureSizeThreshold)直接进 Old,避免新生代复制开销;5) 动态年龄:S 区某年龄段对象总大小 > S 区一半,该年龄段以上对象全晋升。
触发 Full GC 的情况:a) System.gc() 显式调用(可关 DisableExplicitGC);b) Old 区空间不足以容纳晋升对象;c) 元空间(Metaspace)不足;d) CMS concurrent mode failure;e) G1 中 mixed GC 跟不上分配速度;f) 老年代碎片化严重无法分配大对象。
生产排查:用 jstat -gcutil 看各代占比和 GC 频次;频繁 Full GC 大概率是内存泄漏或对象过早晋升,用 jmap/MAT 看堆 dump,找 GC roots 反向追责。
💡 提示:内存分区 5 个不能漏(尤其元空间 JDK 8 变化);Eden/S0/S1 = 8:1:1 必背;Full GC 触发条件 5+ 要能数得出
Java开发 · 困难synchronized 和 ReentrantLock 有什么区别?什么场景选哪个?synchronized 的锁升级过程是什么?
本质区别:synchronized 是 JVM 层关键字,ReentrantLock 是 JDK API。能力差异:1) 中断:Lock.lockInterruptibly() 可响应中断,synchronized 不行;2) 超时:Lock.tryLock(timeout) 可超时获取,synchronized 不行;3) 公平性:ReentrantLock 可选公平,synchronized 只能非公平;4) 条件变量:Lock + Condition 可有多个等待队列,synchronized 只有 wait/notify 单个队列;5) 读写分离:有 ReentrantReadWriteLock,synchronized 没有。
场景选择:简单互斥(只用一次 lock/unlock,无超时无中断)用 synchronized,代码简洁、不会忘 unlock(try-finally 不用写);复杂场景(超时、可中断、多条件、读多写少)用 ReentrantLock 或 ReadWriteLock。
synchronized 锁升级(JDK 6 后):1) 无锁:对象创建时;2) 偏向锁:第一个线程访问时,把线程 ID 写入 Mark Word,之后该线程再进无任何 CAS;3) 轻量级锁:第二个线程来竞争,偏向锁撤销,升级为轻量级,用 CAS 自旋;4) 重量级锁:自旋超阈值(默认 10 次)或竞争激烈,升级为重量级锁,线程进入 Monitor 队列阻塞(操作系统级互斥)。锁升级是单向的,只能升不能降。JDK 15 后偏向锁默认关闭,因为现代应用线程切换频繁,偏向锁反而带来开销。
💡 提示:5 项能力差异要能完整列出(中断/超时/公平/条件/读写);锁升级 4 步必背:无锁→偏向→轻量→重量;JDK 15 偏向锁默认关闭是新版本知识点,加分
Java开发 · 中等Spring 的 IoC 和 AOP 分别是什么?@Transactional 是怎么生效的?自调用为什么失效?
IoC(控制反转):对象的创建和依赖关系交给容器管理,通过依赖注入(DI)取得依赖,而不是 new 出来。好处:解耦、可测试、可替换实现。三种 DI:构造器注入(推荐,字段 final,不可变)、setter 注入、字段注入(@Autowired,不推荐,无法在 final 字段上,且测试时难替换)。
AOP(面向切面):把横切关注点(日志、事务、权限、限流)从业务代码抽离,通过代理(JDK 动态代理或 CGLIB)在运行时织入。实现机制:Spring 启动时扫描所有 @Aspect 注解,根据 pointcut 表达式判断哪些 bean 需要代理,生成代理对象注入容器。
@Transactional 是 AOP 应用:Spring 给 bean 生成代理,代理在调用方法前后开启/提交/回滚事务。代理本质是'类的子类(CGLIB)或接口实现(JDK)',调用 proxy.method() 时进入代理逻辑,先 begin tx,再调真实方法,最后 commit/rollback。
自调用失效原因:同类内 this.methodA() 调用 this.methodB(),this 是真实对象不是代理对象,绕过了代理,事务逻辑不会执行。解决:1) 把 methodB 抽到另一个 bean;2) 注入自己的代理 (@Autowired self);3) AopContext.currentProxy() 取代理对象;4) Spring 4 后用 @EnableAspectJAutoProxy(exposeProxy=true)。
其它失效场景:private 方法(代理拦截不到)、final 类(CGLIB 无法继承)、异常类型不匹配(默认只回滚 RuntimeException)。
💡 提示:IoC vs DI 别讲混(IoC 是思想,DI 是手段);AOP 的代理机制(JDK vs CGLIB)能讲清;@Transactional 自调用失效是阿里高频考点必答