☰
Java面试核心知识点全解析:从基础到原理涵盖JVM并发数据库
2026/10/5 8:01:34 网站建设 项目流程

1. Java核心基础:基本功扎实不扎实,问这几个问题就知道

1.1 面向对象与数据类型:重写、重载、Integer缓存这类送分题别再丢分

先说重载和重写。很多候选人能说出“重写是子类重写父类方法,重载是方法名相同参数不同”,但追问两句就露馅。重载发生在编译期,本质是编译器根据参数列表、参数类型决定调用哪个方法,所以也叫编译时多态;重写则发生在运行期,由JVM根据对象的实际类型动态分派,所以叫运行时多态。

这两个概念在JVM层面也很值得展开。方法重写的本质是虚方法表(vtable)的分派机制,JVM通过invokevirtual指令查找实际类型对应的方法。而重载可能在编译阶段直接确定调用目标,有些场景下JVM会做“方法内联”优化。面试时能讲到这一层,基本就赢过九成背概念的人。

重写还有一个高频考点:重写方法的访问权限不能比父类更严格,返回值可以是父类返回类型的子类型(协变返回类型),抛出的异常范围不能比父类更宽。为什么允许协变返回类型?因为子类在语义上满足“is-a”关系,调用方按父类方法签名接收返回值时,子类返回的更具体类型天然兼容,这也是Java泛型、集合框架里经常出现的模式。

再来看== 与 equals。基础题,但很多人理解得不够彻底。== 在比较基本数据类型时比较的是数值,在比较引用类型时比较的是内存地址。equals 是Object类的方法,默认实现等价于==,但String、Integer这些类重写后比较的是内容。这里真正的考点是:重写equals时必须重写hashCode。道理很简单,以HashMap为例:put时先算hash定位桶,桶内再用equals判断是否相同。如果你只重写equals不重写hashCode,两个逻辑上相等的对象会被分到不同桶里,HashMap里就会出现重复数据。这个坑我在真实项目里见过不止一次,面试官问概率极高。

配套的还有一个经典题:Integer i1 = 100, i2 = 100,i1 == i2 返回什么?如果换成 Integer i3 = 200, i4 = 200 呢?答案是前者true、后者false。原因是IntegerCache默认缓存了[-128, 127]区间的对象,自动装箱时直接返回缓存中的同一个对象。这个缓存区间的上界可以通过JVM参数 -XX:AutoBoxCacheMax 调整。所以用包装类型比较值的时候,永远推荐用equals或者直接比较基本类型值,不要依赖==。

顺带说一个2026年面试里越来越常见的新特性题:Java 17和Java 21的Record、sealed class、switch模式匹配。Record可以理解成一行代码搞定不可变数据类的equals、hashCode、toString和构造方法,配合注解处理器,很多项目的Lombok依赖都能替换掉。记录类型定义时组件字段是final的,适合做DTO、事件对象这类场景。能主动讲出新特性在实际项目里怎么用的候选人,观感会好很多。

1.2 String与集合:天天用的代码,反而最容易被问倒

String不可变性是Java面试的必考题,它的价值远远超出“背答案”的范畴。String类内部用一个final修饰的byte数组存储字符串内容,所有修改操作都返回新对象,原对象不变。这个设计的直接好处有三个:一是字符串常量池可以放心复用,同一个字面量字符串在内存中只有一份,JVM做intern操作时不用担心被修改;二是String对象天然线程安全,作为HashMap的key时就算在多线程环境下也不会被意外改写;三是哈希值可以在第一次计算后缓存下来,这也是HashMap喜欢用String做key的原因之一。

经典的new String("abc")创建了几个对象,答案要看场景。如果字符串常量池里还没有"abc",那么JVM会在常量池中创建一个字面量对象,同时new关键字在堆中创建一个新对象,一共两个;如果常量池里已经有"abc",那只有堆中一个。这道题的对手法在于引导候选人讲清楚常量池和堆的关系,以及intern()方法的作用。

StringBuilder和StringBuffer的区别就不赘述了,核心就一句话:StringBuffer的关键方法加了synchronized关键字,保证线程安全但牺牲了性能,StringBuilder是线程不安全的但性能更高。现代Java开发里,单线程环境下的字符串拼接基本都是StringBuilder,编译器对字符串的+号拼接也会自动优化成StringBuilder.append。随带提一笔:JDK 9之后字符串存储从char[]换成了byte[],配合COMPACT_STRINGS特性可以按需使用Latin-1或UTF-16编码,这也是为什么源码里看到的是byte数组。

集合框架这部分,我面试必问HashMap。JDK 1.8版本之后HashMap的底层是数组加链表加红黑树:默认初始容量16,加载因子0.75,当链表长度达到8且数组长度达到64时链表转红黑树,当红黑树节点数降到6时转回链表。为什么阈值是8?源码注释里给了答案:泊松分布下,负载因子0.75时链表中节点数到达8的概率是千万分之六,这个阈值兼顾了空间和时间成本。

HashMap的扩容机制也特别容易考。扩容时元素需要重新计算桶位,JDK 1.7用的是头插法,并发扩容时可能形成环形链表,导致get死循环;JDK 1.8改成尾插法,解决了环形链表问题,但HashMap在并发写时仍然不安全,可能丢失数据。所以并发场景必须用ConcurrentHashMap。ConcurrentHashMap在JDK 1.8后放弃了分段锁,改用CAS加synchronized锁住数组的首节点,粒度更细,读操作完全无锁,这也是它性能好的原因。

ArrayList和LinkedList这对兄弟也总被拿来对比。ArrayList底层是Object数组,随机访问O(1);LinkedList底层是双向链表,头尾插入删除快,但中间插入也同样需要遍历找到位置,复杂度O(n)。真实开发里,我建议除了一些特殊的队列场景外无脑用ArrayList,因为它内存占用更友好,数据连续存储对CPU缓存友好,遍历性能明显优于LinkedList。LinkedList频繁创建节点对象,对GC压力更大,实际项目中用它的场景远比想象中少。

还有一个容易被问的细节是字符编码。开发中常见的乱码问题基本都是编码不一致导致的,比如文件是UTF-8编码,代码里却用GBK去读。处理原则很简单:读写必须指定同一套字符集,不要依赖系统默认编码。String.getBytes()不传参数时用的是JVM默认编码,换台机器可能行为就变了,线上出过太多这种事故。优先用UTF-8,所有IO操作显式声明StandardCharsets.UTF_8。

1.3 异常与常用库函数:睡着也能答的题,要有自己的思考

异常体系考察的是代码设计的边界意识。受检异常(checked exception)必须显式处理或向上抛出,比如IOException、SQLException;非受检异常(RuntimeException及其子类)不需要强制处理,比如NullPointerException、IllegalArgumentException;Error则代表JVM层面的严重问题,如OutOfMemoryError、StackOverflowError,程序通常无法恢复也不应该去捕获。

很多框架和项目现在反而很克制地使用受检异常,因为过度使用会导致代码里全是try-catch或throws声明,可读性急剧下降。Spring的Dao层异常就全部包装成非受检的DataAccessException,让业务代码可以更灵活地决定在哪个层级处理异常。面试官真正想看的不是你能不能背出Exception的继承树,而是你在设计接口时如何约定异常边界。

finally与return的执行顺序也值得认真研究一下。try中执行到return语句时,会先计算return后面的表达式结果,再跳转到finally执行,最后把刚才的结果返回,所以finally里对返回变量的修改不会影响已经算好的返回值。但有一种例外:如果finally块里也写了return,它会直接覆盖try中的return,这种写法极度危险,团队规范里应该直接禁止。

常用库函数方面,网上经常搜到“algorithm java”“常用库函数algorithm java”这类关键词,其实Java标准库没有一个统一的algorithm包,范围是散的:java.util.Arrays提供了sort、binarySearch、copyOf、fill这些数组操作;java.util.Collections提供了排序、反转、洗牌、不可变集合封装;java.lang.Math提供了数学计算。实际算法题里,Arrays.sort用的是双轴快速排序(基本类型)和TimSort(对象类型),这个底层细节也能体现你读源码的深度。

Comparator和Comparable的对比也顺手复习一下。Comparable是“类自己跟自己比”,实现compareTo就能指定自然排序;Comparator是“外部比较器”,通过lambda或匿名类传入sort方法,适合定义多种灵活的排序规则。Java 8之后Comparator还提供了链式写法:Comparator.comparing(User::getAge).thenComparing(User::getName),在业务排序里非常常用,面试时可以主动展示这一手。

2. JVM与内存管理:背书答不出亮点,能讲原理才值钱

2.1 内存区域与对象分配:画张图就能讲清楚的东西

JVM内存区域划分是面试必问,但我建议别停留在背名字的阶段,把每个区域的职责和可能抛出的异常说清楚才叫合格。

堆(Heap)是所有线程共享的区域,对象实例基本都在这里分配。堆内部可以进一步划分为新生代(Eden区、两个Survivor区S0和S1)和老年代,默认比例大约是8:1:1。新生代对象存活率低,适合用复制算法;老年代对象存活率高,适合用标记-清除或标记-整理算法。堆空间不足时抛出OutOfMemoryError: Java heap space。

虚拟机栈(VM Stack)是线程私有的,每个方法调用都会压入一个栈帧,栈帧里有局部变量表、操作数栈、动态链接、方法出口等结构。局部变量表里存放基本数据类型、对象引用和returnAddress,64位的long和double占用两个局部变量槽。栈深度不够时会抛StackOverflowError。

方法区在JDK 8之后被元空间(Metaspace)取代,核心区别是元空间使用本地内存而不是JVM堆内存,默认情况下不会OOM,但可以设置-XX:MaxMetaspaceSize限制。方法区里存的是类元信息、运行时常量池、静态变量等。程序计数器是唯一不会OOM的区域,它记录当前线程执行的字节码行号,用于分支、循环、跳转、异常恢复等场景。

对象创建流程是第一章的延伸:类加载检查到分配内存到零值初始化到设置对象头再到执行构造方法。这里值得展开的是内存分配方式:堆内存规整时用指针碰撞,不规整时用空闲列表。并发分配内存有两个解决思路,一个是CAS加失败重试,一个是TLAB(Thread Local Allocation Buffer)——每个线程预先在Eden区划一小块私有内存,分配对象先在自己私有的TLAB里分配,满了才去堆上申请新的。大部分Java对象分配是在TLAB中完成的,这也是为什么new对象在高并发场景下性能尚且可以接受的原因。

2.2 垃圾回收:从根搜索到三色标记,聊聊原理别只背算法名

判断对象是否存活有两种方案。引用计数法实现简单,每个对象维护一个计数器,被引用加一、引用失效减一,C++的智能指针就是这么做的,但它解决不了循环引用问题,Java没有采用。Java用的是可达性分析:从一组GC Roots出发,通过引用链遍历,凡是不可达的对象判定为可回收。GC Roots包括:虚拟机栈中引用的对象、静态属性引用的对象、常量引用的对象、JNI引用的对象。

三种基础回收算法要放在一起对比。标记-清除算法先标记可回收对象再统一清除,缺点是会产生大量不连续的内存碎片,后续分配大对象时可能要提前触发FG。标记-复制算法把内存分成两块,每次只用一块,存活对象复制到另一块后整块清理,没有碎片但浪费了一半空间。标记-整理算法让存活对象向一端移动,然后清理边界以外的内存,适合老年代这种对象存活率高的场景。新生代因为“朝生夕死”的特点天然适合复制算法,Eden和Survivor的比例设计就是为了把复制成本降到最低。

GC算法名称背得很溜,但面试官更想听你说清楚CMS和G1的设计思路。CMS(Concurrent Mark Sweep)主打低停顿,标记阶段和清除阶段尽量和用户线程并发执行,但CMS有两个硬伤:一是使用标记-清除算法会产生碎片,老年代碎片多了之后不得不退化成Serial Old的Full GC;二是CMS的并发阶段会和用户线程抢CPU,CPU核数少的时候吞吐量反而降低。G1把堆划分成一个个大小相同的Region,维护一个优先级列表,优先回收垃圾最多的Region,从而让停顿时间可预测。G1既用复制算法解决碎片问题,又用并发标记和SATB(快照式)保证正确性,是JDK 9之后的默认垃圾收集器。

如果要展示深度,可以聊聊三色标记和并发标记漏标问题。在并发标记阶段,为了不暂停业务线程,GC线程用“灰色”表示已访问但正在扫描的对象,“白色”表示未被访问的对象,“黑色”表示已访问且其引用也全部访问过的对象。并发标记最大的风险是“漏标”:黑色对象新引用了某个白色对象,同时这个白色对象原有的引用路径被切断,那么它就会被错误回收。CMS通过增量更新解决,G1通过SATB快照解决。能讲到这个层面,面试官基本会默默给你加分。

2.3 类加载与静态链接误区:Java到底是静态链接还是动态链接

网上有个流传很广的错误说法叫“Java是静态链接的”,看到这个热搜词的时候我愣了一下,这明显是把概念搞混了。Java类默认是动态链接的:一个类编译后引用其他类时,在字节码层面只是符号引用,真正执行到那条指令时,JVM通过解析符号引用为直接引用,把对应的类加载进来。这个“用到才加载、运行时才解析”的机制就是动态链接的经典表现。静态链接更贴切的例子是C/C++在编译期把用到的库函数代码直接复制进可执行文件。所以如果不幸在面试里被问到这个题,请你放心大胆地说“Java本质上是动态链接的”,顺着类加载和常量池解析往下讲,面试官会明白你是真的读过资料。

类加载过程分为加载、验证、准备、解析、初始化五个阶段。“加载”是找到class文件字节流并生成Class对象;“验证”是检查格式和安全,防止恶意字节码,包括了元数据验证、字节码验证等;“准备”是给类静态变量分配内存并初始化为零值(不是赋代码里写的值,那个在初始化阶段才做);“解析”是把常量池内的符号引用替换为直接引用,这里既有静态解析也有动态解析,比如方法调用指令中的虚方法就需要运行时才确定;“初始化”才真正执行类构造器<clinit>方法。

双亲委派模型这个点必须讲透。三层类加载器从下到上是应用类加载器、扩展类加载器(JDK 9后被平台类加载器替代)、启动类加载器。加载一个类时,子加载器先不自己加载,而是逐层向上委托,父加载器能加载就父加载器说了算,父加载器加载不了再向下传。这样做最大的意义是安全:你就算自己写一个java.lang.String,即便能编译通过,运行期也会被启动类加载器加载的JDK版本String截胡,你那个类根本不会加载。同时,类库的统一性也保证了同一个类在全JVM范围内只有一份。

双亲委派也不是万能的,JDK的SPI机制(Service Provider Interface)就遇到了麻烦。以JDBC的DriverManager为例,它是启动类加载器加载的rt.jar里的核心类,但它需要调用各数据库厂商实现的驱动类,这些驱动类放在应用的classpath里,由应用类加载器加载。按照双亲委派,DriverManager根本加载不到第三方的mysql-connector-java。于是JDK引入了线程上下文类加载器,让父加载器“反向”委托给子加载器去加载。这就是所谓的“破坏双亲委派”,理解了它,才算真正理解了类加载机制的全貌。

3. 并发编程:这里才真正区分“会用”和“懂原理”

3.1 volatile与synchronized:从可见性到锁升级

volatile这个词被问烂了,但能讲透的人真不多。它保证两件事:可见性和有序性。如果多个线程操作同一个变量,volatile修饰的变量每次读取都会从主内存中拿最新值,每次写入也会立即回写到主内存,避免了线程工作内存中“缓存过期”的问题。底层依托的是JMM(Java内存模型)以及处理器层面的缓存一致性协议,例如MESI协议配合内存屏障(读屏障、写屏障)实现。

但volatile有个致命短板:不保证原子性。经典的count++操作,即使count加了volatile,并发执行还是会丢值。原因很简单,count++在字节码层面是“读-改-写”三步,三步之间线程随时可能切换。volatile只保证你读的时候是最新值、写的时候其他线程看得见,但读取和写入不是一个不可分割的原子操作。这个题几乎每次面试都会出现,答案是“volatile不保证原子性,要原子操作用AtomicInteger或加锁”才对。

synchronized是Java内置锁,面试含量极高。早期synchronized是一个重量级实现,直接依赖操作系统的Monitor锁,线程阻塞和唤醒涉及用户态和内核态切换,开销非常夸张。JDK 1.6之后引入了锁升级:无锁到偏向锁到轻量级锁到重量级锁。偏向锁解决“单线程访问同步块”的场景,在对象头记录线程ID,不需要真的CAS;一旦有第二个线程争抢,撤销偏向锁并升级到轻量级锁,轻量级锁通过自旋CAS抢锁,抢不到就膨胀成重量级锁,线程挂起等待。

不过注意:JDK 15已经默认禁用了偏向锁,JEP 374给出的原因是维护成本太高、收益已经微乎其微。面试时把这段演进讲出来,既能展示你了解历史,又能说明你关注新版本变化。synchronized优化还有锁消除和锁粗化:JIT编译器发现某个对象不可能被多线程共享时会直接去掉锁,循环里反复加锁的代码会被合并成一次加锁,这些都是编译器层面的优化思路。

CAS(比较并交换)也是必考。CAS利用Unsafe类的compareAndSwap方法,在硬件层面完成“比较-更新”的原子操作,失败就重试。它避免了锁带来的线程阻塞,但引入了ABA问题:一个值从A变成B又变成A,CAS无法感知中间变化。Java里可以用AtomicStampedReference带版本号解决,实际开发中ABA的触发条件相当苛刻,但面试题就是喜欢拿来考。

3.2 Java并发工具:锁、ThreadLocal、线程池

ReentrantLock和synchronized的区别可以列一张表来记:后者是隐式的,锁的获取和释放由JVM自动管理,前者需要手动lock和unlock且必须放在finally里;后者不可中断,前者的lockInterruptibly可以响应中断;后者是非公平的,前者可以选择公平模式;后者只有一个等待队列,前者可以有多个Condition,实现精细化的等待通知。ReentrantLock基于AQS实现,AQS内部维护一个volatile的state状态和CLH线程等待队列,独占模式下state为1表示锁被占用,重入时state递增。

ThreadLocal是并发场景里被误解最多的工具。它的原理是每个Thread内部维护一个ThreadLocalMap,键是ThreadLocal的弱引用,值是真实的业务数据。所谓“线程隔离”其实就是各线程访问自己的Map。这里有个著名的坑:ThreadLocalMap的key是WeakReference,ThreadLocal对象失去了外部强引用后会被GC回收,但value是强引用,还留在Map里,导致内存泄漏。尤其在线程池中线程长时间存活,累积的value会越来越多。所以正规用法是:用完立即调用remove(),用try-finally包裹。

线程池是并发面试的重头戏。ThreadPoolExecutor的七个核心参数必须张口就来:corePoolSize核心线程数、maximumPoolSize最大线程数、keepAliveTime空闲线程存活时间、unit时间单位、workQueue工作队列、threadFactory线程工厂、handler拒绝策略。执行流程是:来一个任务,当前线程数小于corePoolSize就直接开新线程;大于等于corePoolSize就进队列排队;队列满了再检查是否小于maximumPoolSize,小于就开临时线程,大于等于就触发拒绝策略。

关于核心参数怎么设置,给一个实用经验。CPU密集型任务的线程数接近CPU核数加一,IO密集型任务因为等待IO期间CPU空闲,可以设置为核心数的两倍甚至更高。实际生产里我是这样做的:先用公式算初值,再压测调整。线程池跑在线上的时候,核心线程数的调整是可以动态的,setCorePoolSize方法支持运行时修改。拒绝策略有四种:AbortPolicy直接抛异常拒绝提交、CallerRunsPolicy让提交任务的线程自己执行、DiscardPolicy和DiscardOldestPolicy直接丢弃。业务上我不建议用Discard,因为任务丢了没有任何反馈,自定义拒绝策略里打日志加消息队列补偿才是稳妥方案。

4. 数据库与SQL实战:MySQL面试题无非这三个方向

4.1 索引与性能优化:explain都不看的简历我是不太信的

索引这部分想答出彩,要讲清楚“为什么是B+树”。B+树的非叶子节点只存键值和子节点指针,不存数据,所以一个16KB的页能存储更多索引项,树的高度更矮。三层的B+树大概能支撑千万级数据的查找,树矮意味着磁盘IO少。B+树的叶子节点通过双向链表串联,天然支持范围查询和排序,而B树每个节点都带数据,不管是范围查询还是遍历都要多次回溯。哈希索引适合等值查询,但无法支撑范围查询和排序,所以InnoDB的默认索引结构是B+树。

索引失效是高频题,我把常见场景整理一下:联合索引不满足最左前缀;where条件中对索引列使用了函数或计算,比如WHERE DATE(create_time) = '2026-01-01',索引列参与计算会导致优化器放弃索引;模糊查询左侧通配,like '%xxx'无法用B+树有序特性;隐式类型转换,比如对varchar类型的列写WHERE num = 123,MySQL会做类型转换,索引也会失效;用or连接时,两侧条件不全有索引;范围查询右侧的列可能失效。实际排查中用EXPLAIN查看type、key、rows字段最直接,从all到range再到ref,逐步做优化。

这里插一个非常实用的建议:建索引不是越多越好。每个二级索引都是一份独立的B+树,写操作需要同步维护多棵树的更新,插入和更新性能会明显下降。我在项目中见过一张表建了十几个索引,结果导入数据时慢到怀疑人生。索引的取舍原则是:优先覆盖查询频率最高的SQL,联合索引设计时把区分度高的列放前面,查询条件里等值判断的列排在范围判断的列之前。

4.2 事务、隔离级别与MVCC

事务的ACID四个特性要结合InnoDB的底层结构来讲。原子性靠undo log保证,事务一旦回滚,通过undo log恢复数据到事务开始前的状态;持久性靠redo log保证,事务提交时先把redo log刷盘,即使数据库崩溃,重启后通过redo log重放已经提交的事务;隔离性靠锁和MVCC机制;一致性则是前三者协同工作的最终结果。

隔离级别有四个档次。读未提交(READ UNCOMMITTED)会出现脏读;读已提交(READ COMMITTED)解决了脏读但会出现不可重复读;可重复读(REPEATABLE READ)解决了不可重复读但在更高的隔离级别下仍可能出现幻读;串行化(SERIALIZABLE)通过强制事务串行执行解决了所有问题但性能极差。MySQL默认隔离级别是可重复读,要记住InnoDB在可重复读级别下通过间隙锁和MVCC在很大程度上解决了幻读问题。

MVCC是被问得最多但答得最差的一个点。无锁的快照读依赖的是undo log版本链和ReadView。每一行数据除了业务字段外,还有隐藏的trx_id(最后修改事务ID)和roll_pointer(回滚指针),指向undo log中的历史版本。事务执行快照读时生成ReadView,里面记录了当前活跃事务ID列表、最小活跃ID、最大ID等信息;判断一个版本是否可见,本质上就是看trx_id是否在当前事务的快照范围内。可重复读级别下,事务第一次快照读时生成一次ReadView,整个事务期间复用同一份;读已提交级别下,每次快照读都会生成新的ReadView,正因为这个差异,两种隔离级别在同一个事务内看到的结果不同。

死锁的排查与解决也要会。死锁发生的四个必要条件:互斥、持有并等待、不可剥夺、循环等待。MySQL最常见的死锁场景是事务A先更新表X再更新表Y,事务B先更新表Y再更新表X,顺序相反导致互相等待。排查时执行SHOW ENGINE INNODB STATUS,关注LATEST DETECTED DEADLOCK段落,能看到最近一次死锁的SQL语句和持有锁的情况;再查information_schema.innodb_trx和innodb_locks表定位未提交的事务。预防手段包括:多表操作始终按固定顺序访问、事务尽量短小、合理设计索引避免间隙锁扩大锁范围。

4.3 分库分表与行级权限:业务功能背后都在考什么

分库分表考察的是数据量增长后的架构设计。垂直拆分把表按业务域拆开,比如订单库、用户库、商品库分离,核心是降低单库的压力;水平拆分则把同一张表的数据按某种规则分到多个库多个表,规则常见的是哈希取模和范围分片。哈希取模写分布均匀但扩容要迁移数据,范围分片扩容方便但可能热点集中。实际落地时,通常先垂直拆分,再对数据量最大的几张表做水平拆分。

分片后的全局主键怎么生成也是必考。数据库自增ID已经不好用了,常见的方案有:雪花算法(Snowflake),64位二进制,1位符号位加41位时间戳加10位机器ID加12位序列号,单机每秒可生成约409.6万个ID,趋势递增,不依赖数据库;美团Leaf的号段模式,每次从数据库取一批ID,客户端内存中分配,性能和持久性都不错。面试时能把雪花算法的位结构、时钟回拨问题的处理思路讲清楚,就是明显的加分项。

行级权限在Java开发里是很多业务系统的刚需,尤其是多租户SaaS、企业管理系统。场景很简单:用户A登录后只能看到自己创建的单据,部门经理能看到整个部门的,管理员能看到全部。实现思路是不要在每个查询SQL里手动拼权限条件,那样迟早会漏,而是通过MyBatis的拦截器统一处理。拦截StatementHandler的prepare方法,解析SQL的AST,利用JSqlParser这样的库找到where子句,自动拼上dept_id = #{currentUser.deptId}。

实现时要做几个防坑设计:一是必须通过注解标记哪些Mapper方法需要自动加权限条件,避免所有查询都被误加;二是拦截器只处理查询语句,insert、update、delete要放行;三是拼条件时要注意SQL本身是否已经带了相同字段的条件,否则会SQL语法错误或权限条件互相覆盖。这块考察的其实不只是MyBatis插件机制,还有你对SQL语法、AST解析的理解,项目里如果在简历上写了权限控制,一定要把这个方案吃透。

4.4 常见SQL面试题:排序、分组、行号与三表关联

SQL题来几道最常见的。第一道:查每个部门工资最高的员工。MySQL 8.0之后推荐用窗口函数ROW_NUMBER() OVER (PARTITION BY dept_id ORDER BY salary DESC),先计算行号再取行号为1的记录。要注意的是:如果允许并列,RANK()和DENSE_RANK()的区别要讲清楚,RANK会在并列后跳号,DENSE_RANK不跳号。如果面试官不让用窗口函数,就用关联子查询:找出每个部门的最高工资,再关联员工表取对应记录。

第二道高频题:查询连续N天登录的用户(或者叫连续签到)。核心技巧是用日期减去行号:把用户每天登录的日期排序后,减去行号,得到一个日期,如果用户连续登录,这个日期是相同的。具体是DATE_SUB(login_date, INTERVAL row_number() OVER (PARTITION BY user_id ORDER BY login_date) DAY),然后按user_id和这个计算出来的日期分组,count超过N就是连续登录N天的用户。这道题考察的是窗口函数和分组思路,平时写业务SQL不涉及,但面试很爱考。

第三道是行级权限相关SQL,比如一张表有author_id,要求用户只能看到自己或本部门的数据。切换到JDBC代码就是动态拼接SQL,但直接拼字符串有注入风险,正确做法是MyBatis的动态SQL加<where>标签,权限条件中的值用#{}绑定参数。如果涉及非本部门的数据过滤,还可以用IN子查询先查出部门成员ID,再作为过滤条件。这类题本质上考察的是“写安全的SQL”的能力,参数化查询和预编译是底线,任何把用户输入直接拼进SQL的行为都是面试一票否决项。

5. 框架与中间件高频题:Spring、MyBatis、Redis、Kafka

5.1 Spring核心:IOC循环依赖与AOP动态代理

Spring全家桶是Java后端面试的大头。IOC容器的核心是BeanFactory,ApplicationContext是其子接口,功能更完善。Bean的生命周期常考版流程是:实例化对象、属性填充、处理Aware接口(比如BeanNameAware、ApplicationContextAware)、BeanPostProcessor的before方法、执行init-method或InitializingBean、BeanPostProcessor的after方法、使用Bean、销毁时执行destroy-method。Spring Boot应用中大量使用了自动配置和条件装配,这块建议顺着@Configuration和@Bean注解讲。

循环依赖能问倒一大批人。Spring容器中解决构造器循环依赖无能为力,但解决setter或字段注入的循环依赖靠的是三级缓存:第一级singletonObjects存放完整的单例Bean,第二级earlySingletonObjects存放提前暴露的半成品Bean,第三级singletonFactories存放一个ObjectFactory,用来生成提前暴露的代理对象。流程简化版:A创建时需要注入B,B创建时需要注入A,此时A提前把自己暴露到三级缓存中,B注入到未完成的A,A最终完成创建并填充缓存,B也就能正常完成。AOP代理的情况下,如果A被切面代理,三级缓存里的ObjectFactory会提前生成代理对象,保证注入的也是代理。

AOP的底层是动态代理。基于接口的默认用JDK动态代理,基于类的用CGLIB。JDK动态代理要求目标类必须实现接口,通过java.lang.reflect.Proxy生成一个实现了同样接口的代理类,在InvocationHandler里统一处理;CGLIB通过ASM生成目标类的子类,重写被代理的方法,所以不需要接口。Spring Boot 2.x默认开启proxyTargetClass=true,即优先用CGLIB,理由是现代ASM生成字节码的性能已经不输反射。

5.2 MyBatis:一级缓存、二级缓存与动态SQL

MyBatis几乎所有项目都在用,但很多候选人只写了“会使用”,原理一问就慌。先说缓存。一级缓存是SqlSession级别的,默认开启,同一SqlSession内两次执行同样的查询不会重复查数据库;但执行增删改操作后一级缓存会被清空,避免脏数据。二级缓存是namespace级别的,需要配置,缓存粒度是Mapper接口,但多表操作时容易出现脏读——因为另一张表的更新不会触发这个namespace缓存的失效。所以真实项目里我基本不推荐开二级缓存,分布式环境下缓存一致性问题更难解决。

#{}和${}的区别是送分题。#{}是预编译方式,MyBatis生成PreparedStatement占位符,传入参数不会被拼接进SQL,能有效防止SQL注入;${}是直接拼接字符串,有注入风险,只能在表名、列名等无法使用占位符的场景使用,而且必须对传入值做白名单校验。

再深一层的考点是Mapper接口绑定。MyBatis启动时会为每个Mapper接口生成代理对象,调用接口方法时,通过方法名和接口全限定名找到对应的MappedStatement,执行SQL并映射结果。动态SQL的核心标签if、where、set、foreach、choose,底层通过OGNL表达式判断条件,拼接SQL时用<where>自动去掉多余的AND,用<set>去掉多余的逗号。这个细节值得记住,自己手写拼接SQL时永远容易踩。

顺带提一下MyBatis插件机制。MyBatis允许拦截四大核心对象:Executor(执行器)、StatementHandler(语句处理器)、ParameterHandler(参数处理)、ResultSetHandler(结果集处理),基于JDK动态代理实现。前面讲的行级权限方案就是拦截StatementHandler实现的。能说清插件机制和四大对象职责的候选人,面试评价会高一个档次。

5.3 Redis:缓存穿透、击穿、雪崩与分布式锁

Redis三兄弟是面试必问题,先把概念分清。缓存穿透是查询一个根本不存在的数据,缓存中没有、数据库也没有,每次请求都打到数据库,攻击者用不存在的ID刷接口就能拖垮数据库。常用解法是缓存空值(比如缓存key对应空对象,TTL设短一点)或者布隆过滤器(把所有可能存在的主键加载进过滤器,不存在的直接拦截)。布隆过滤器有误判率,但胜在内存占用极小,适用于海量数据场景。

缓存击穿是一个热点key在过期瞬间,大量并发请求同时打到数据库。解决办法:互斥锁,只有一个线程能去查库并重建缓存,其他线程等待后直接读缓存;或者逻辑过期,value里存一个过期时间戳,发现逻辑过期后异步线程去更新,但读取线程返回旧值,牺牲了一点点强一致性。缓存雪崩则是一大批key在同一时刻过期,或者Redis本身宕机,导致大量请求压到数据库。key过期时间加随机值打散过期时间,Redis用主从加哨兵或集群保证高可用,这都属于常规手段。

Redis分布式锁是另一个大热门。基于SET命令的SET key value NX PX 30000,NX表示不存在才设置,PX设置过期时间,value里存一个唯一请求ID(UUID或雪花ID),释放锁时必须用Lua脚本比较value再DEL,防止误删其他线程的锁。生产环境更推荐直接使用Redisson库,它提供了看门狗机制自动续期,默认每10秒续期一次,避免业务执行超过锁过期时间后锁被提前释放的问题。面试常追问:Redis主从切换时锁丢失怎么办,这引出了RedLock算法,但业界对RedLock争议很大,最基本的结论是:一致性和可用性要求特别高的场景,直接选ZooKeeper或etcd实现分布式锁,而不是纠结Redis。

5.4 Kafka与消息队列:可靠性与顺序性才是考察重点

消息队列的题,考察核心就两个词:可靠性和顺序性。先讲不丢消息。生产端要设置acks=all,表示分区所有副本都写入成功才确认;同时打开重试,设置retries大于0,避免网络抖动导致的偶发失败。Broker端核心是副本机制:topic副本数至少3,min.insync.replicas=2保证至少两个副本同步。消费端最容易丢消息的是自动提交offset,在业务还没处理完的时候offset已经提交,消费端重启后就会跳过未处理的几条消息。方案是改成手动提交,并保证在业务逻辑执行成功后再手动提交offset。

顺序性问题的解法思路是:Kafka只保证单个分区内的顺序,全局顺序需要牺牲吞吐量使用单分区。现实中大多数场景是“某个用户的消息必须有序”,那生产者在发送时按用户ID做key哈希,同一个key进同一个分区即可。消费者的瓶颈在于多线程并发处理订单消息时,同一个用户的多个消息可能被不同线程并发处理,导致顺序错乱,解决方式是按业务ID路由到内存队列,每个队列配单线程处理。

Kafka性能为何如此出众,细节也值得掌握。一是顺序写磁盘,Kafka的日志是追加写,虽然用了磁盘,但顺序写性能接近内存写;二是Page Cache,操作系统缓存热点数据,读写尽量命中内核页缓存;三是零拷贝,消费消息时数据从磁盘读到PageCache后直接通过sendfile发送到网卡socket,没有经过用户态缓冲区,减少了多次上下文切换和数据拷贝。Zero Copy这个点尤其能体现你对性能和OS底层的理解深度。

中间件这章还经常穿插Spring Boot自动配置原理。核心是@EnableAutoConfiguration注解,它通过ImportSelector机制加载META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports文件中注册的配置类,再配合@ConditionalOnClass、@ConditionalOnProperty等条件注解实现按需装配。所以引入一个starter依赖后,符合条件时自动创建对应的Bean,这就是“自动配置”的本质。定时任务方面,XXL-Job在分布式定时任务领域出镜率很高,它的分片广播、调度中心和执行器分离的特点都有可能在面试中出现。

6. 算法、系统设计与面试表达:最后一公里的这几分

6.1 手写排序:冒泡、快排、堆排序怎么讲出一种脱俗感

算法题在Java面试中比例不低,尤其校招。冒泡排序虽然简单,但很多人写不出带优化的版本。标准写法是双重循环,外层控制轮数,内层相邻比较交换;优化点在于增加一个标志位,如果某一轮循环没有发生任何交换,说明序列已经有序,直接终止后续循环。复杂度最好O(n)、最坏和平均O(n^2),稳定。平时刷算法网站,看到“冒泡排序Java”这类关键词的同学,大概率是刚开始准备面试,先把模板写熟,再思考优化。

快排是最常被要求手写的排序算法,它把分治思想体现得淋漓尽致。思路是选一个基准值,通过partition把数组分成左边小、右边大,然后递归处理左右两部分。平均复杂度O(nlogn),但如果每次基准都选到最大或最小值,退化到O(n^2),所以优化手段有:三数取中、随机选基准、小区间改用插入排序。写代码的时候有一个典型边界坑:partition中的边界条件搞错会导致死递归,建议平时就写一套固定模板,面试时照着自己的习惯快速输出。

堆排序或TopK问题也是常客。找一亿个数里最大的K个数,最优解不是全排序,而是维护一个容量为K的小顶堆:遍历数据,如果当前元素比堆顶大,就弹出堆顶并加入当前元素,最终堆里的K个元素就是最大的K个。Java里直接用PriorityQueue实现,默认是小顶堆。注意如果要最大K个数,小顶堆恰好是对的选择,因为堆顶就是当前K个候选中最小的,新元素比堆顶大才有资格进入。手写题答完后,可以顺带说一句PriorityQueue是基于数组实现的二叉堆,扩容时类似ArrayList的倍增策略。

6.2 分布式锁选型:从Redis到ZooKeeper的对比

分布式锁实际开发乃至面试中都很常见,这里给个横向对比表:

方案实现难度性能可靠性典型使用场景
数据库唯一索引低差一般,依赖事务提交回滚低频、对一致性要求不极端的业务
Redis SET NX EX中高存在主从切换丢锁问题大多数互联网业务,配合Redisson看门狗
ZooKeeper临时顺序节点高中高,基于ZAB协议对一致性敏感、核心金融类服务
etcd租约加续期高中高,raft共识云原生环境,k8s生态配套

数据库分布式锁的原理是利用唯一索引的特性:插入一条记录代表获取锁,删除记录代表释放锁。它最大的问题是性能差和没有自动过期机制,如果客户端宕机,需要额外任务清理超时记录。Redis方案性能最好,但主从架构中如果Master宕机时锁还没来得及同步到Slave,新的Master上锁就丢了,极端情况下两个客户端同时持有锁。ZooKeeper方案利用临时顺序节点加Watcher机制实现锁的公平性和自动释放,客户端断开时临时节点自动消失,不需要担心死锁,但要引入一套ZooKeeper集群,运维成本和延迟都高于Redis。

选型结论也很直接:90%的互联网业务场景,Redisson就够了,它的看门狗机制已经处理了业务超时问题;但如果你在做资金、交易这类不能接受“两个人同时拿到锁”的系统,老老实实用ZooKeeper或etcd。回答选型题的时候,给出“成本-可用性-一致性”的取舍逻辑,面试官会认为你真的做过架构决策,而不是在背题。

6.3 面试描述:三个真话要比一个漂亮话值钱

我参与面试这几年,最强烈的感受是:背题痕迹太明显了。候选人说出的话和网上搜到的按原文一字不差,但稍微换个场景就不会应变。准备的时候可以用“背景、方案、取舍、结果、反思”作为故事线,把每一个项目经历串起来。比如行级权限这个需求,讲清楚为什么要做,最初方案是什么,遇到什么问题(漏加条件、SQL拼接攻击面大),最后怎么改进,上线后有没有再出状况。这种讲述方式比单纯说“我用了MyBatis拦截器”要有说服力得多。

还有两个小建议。第一,被问到不会的问题时,不要装懂,坦诚说“这部分我平时用得少,但我理解它大概是…”会比硬编一个答案好很多,面试官也是技术人,看得出诚实和应变。第二,线上排查过的故障是稀缺加分项,比如Full GC导致接口超时、缓存穿透把数据库打爆、Kafka消费延迟翻了几十倍。细节是你真正处理过后才能讲出来的,如果只是看过文章,别硬安在自己身上,追问两个问题就会露馅。

写在最后

我自己的体会是,Java面试越来越务实了。2026年这个时间点,光会背答案的候选人已经很难过关,面试官更愿意聊系统设计的取舍、线上故障的排坑、版本演进背后的动机。准备面试最好的方式永远是回到代码本身,把常用的组件往深处再挖一层,把亲手做过的坑记录下来。

最后分享一个小技巧:准备一套自己的“追问推演”。每道题背后,至少要提前演练两个可能的追问方向。比如准备了HashMap,就要顺手准备ConcurrentHashMap、红黑树退化、扩容死循环、为什么加载因子是0.75。面试时最加分的不是你背得多熟,而是你能在原本的问题上主动延伸,把面试官想问的下一句提前说出来。

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

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

立即咨询