Java八股复习:从背答案到讲原理,深挖AQS、数据一致性源码
发布时间:2026/9/29 17:01:36 作者:尧图编辑部 阅读量:1,286

今天是Java技术八股学习打卡的第23天。和前几天专注“背结论”不同今天我把自己切换成面试官视角把前段时间学过的Java基础、并发工具、数据一致性、对象深度拷贝和动态代理这些高频八股重新过了一遍。目标只有一个从“知道答案是A”升级到“知道为什么是A以及A背后藏着什么”。写这篇笔记的时候我已经踩过了不少坑。比如最开始我只会背AQS的state和CLH队列但真被问到“为什么非公平锁更快”就卡壳再比如对象拷贝那块我记得序列化能实现深拷贝但没意识到transient字段会被跳过。这些东西八股文题目里不会写全只有自己推一遍源代码、自己写一遍Demo才真正长在脑子里。如果你也正在准备Java面试或者正处于刷八股又觉得学了就忘的阶段这篇文章应该能帮你少走两天弯路。我会按今天实际复习的顺序来写每个核心点都配一个“为什么”的解释已经翻过源码的地方也会给出源码层的依据最后再分享我自己整理的复习方法。1. 从“背答案”转向“讲原理”Day23的基础回归清单今天的复习没有一上来就啃源码而是先把基础类八股盘了一遍。别小看这部分很多看似简单的问题往深了问一样能问出层次感。1.1 面向对象封装、继承、多态的“真实意图”面试题里最常见的开头一定是“谈一谈面向对象编程Java的三大特性”。基础答法谁都会封装隐藏细节、继承实现复用、多态让同一行为在不同对象上有不同表现。但面试官紧接着就会问那么“继承”和“组合/聚合”你更倾向哪个这里我需要理清一组容易混的概念。聚合Aggregation和组合Composition都表示整体与部分的关系区别在生命周期聚合中部分可以脱离整体独立存在比如班级和学生组合中部分随整体创建和销毁比如订单和订单项。这其实是UML里的基础概念但Java八股里经常把它和“面向对象设计原则”一起考。推荐答案是“组合优先于继承”原因很实在继承打破了封装性子类依赖父类实现细节父类一改子类可能就崩了组合则是通过接口交互边界清晰替换实现也更方便。多态这块我今天的复习重点是重载和重写。重载是编译期行为看参数列表重写是运行期行为看实际对象类型。更进阶的追问会是静态方法能不能被重写答案是“不能”子类里定义同签名静态方法只是隐藏了父类静态方法调用时看引用类型。这个点很多人在项目里没踩过但面试非常爱考。1.2 数据类型与包装类Integer缓存为什么是-128到127Java数据类型八股主要集中在8种基本类型和对应的包装类。我提醒自己不漏掉这些细节byte占1字节short和char占2字节int占4字节long和double占8字节float占4字节boolean在JVM规范中没有明确规定大小一般按int处理。这些背下来不难难的是包装类的“隐藏机制”。高频考点是Integer的缓存区间。为什么Integer.valueOf(127) Integer.valueOf(127)为true而Integer.valueOf(128) Integer.valueOf(128)为false因为IntegerCache默认缓存了-128到127之间的Integer对象valueOf在区间内直接返回缓存对象区间外才new Integer。这个设计本质上是空间换时间小整数在业务里出现频率极高避免频繁创建对象。如果真想改缓存上界可以用JVM参数-XX:AutoBoxCacheMax但一般不建议动。顺着这块我顺带纠正一个热词里常出现的误解“Java是静态链接的”这句话不对。Java类默认是动态加载的通过ClassLoader在运行时按需装载和C/C那种编译期静态链接完全不同。把这一点和“类加载机制”串起来面试官会高看你一眼。1.3 StringBuilder循环里拼接字符串的致命写法String、StringBuilder、StringBuffer三者的区别属于Java基础面试题里必考的。String不可变每次拼接都会产生新对象StringBuilder线程不安全但性能好StringBuffer加了同步方法所以线程安全但慢。真正的坑在循环拼接。很多人以为编译器会把优化成StringBuilder确实会但只在单条语句里有效。如果写成String result ; for (int i 0; i 10000; i) { result i; }编译后的结果等价于每次循环都new StringBuilder()然后append再toString循环一万次就创建一万个中间对象。正确写法是在循环外定义一个StringBuilder循环里只append。这个点现在仍然是面试高频因为很多线上OOM就是这么来的。1.4 switch对空数据处理null为什么直接NPE这里值得单独记一笔。switch(null)会直接抛NullPointerException原因在于switch语句对String、枚举、包装类型等做匹配时需要调用目标对象的hashCode()或equals()null根本没法调方法。如果你在项目里遇到“switch空数据”问题排查思路是先判断入参是否可能为null不能假设外部调用方一定传非空。这也是为什么我现在的代码习惯是switch之前统一加空值校验或者直接用if-else配合常量比较因为if (A.equals(value))天然防空。2. AQS到底解决了什么从ReentrantLock反推同步器设计如果Java基础部分只是热身那AQS就是今天第一个真正需要“啃源码”的大块。热搜词里有aqs java说明跟我一样在准备这道题的人非常多。我的复习方法是不看源码注释直接看ReentrantLock是怎么用AQS的。2.1 从lock()到acquire(int)一个简单调用的背后ReentrantLock的lock()最终会走到sync.acquire(1)而这个acquire是AQS的模板方法public final void acquire(int arg) { if (!tryAcquire(arg) acquireQueued(addWaiter(Node.EXCLUSIVE), arg)) selfInterrupt(); }我一开始读这段代码完全看不懂后来才发现要用“三层视角”拆解第一层线程先进来尝试获取锁tryAcquire是子类实现的快速路径。非公平锁的做法是CAS尝试把state从0改成1成功就直接拿锁。第二层如果CAS失败说明锁被占用当前线程会被封装成一个Node节点加入CLH等待队列。第三层acquireQueued让线程在队列里自旋或阻塞等前驱节点释放锁后唤醒自己。state这个字段是关键。它在AQS里用volatile修饰专门用来记录同步状态对ReentrantLock来说0表示无锁大于0表示重入次数。每次拿锁state1每次释放state-1减到0才真正释放锁。这就是“可重入”的实现基础。2.2 CLH等待队列线程是怎么“排队”的CLH队列原版是一种基于链表的自旋锁AQS里的实现是它的变体变成了双向队列每个Node持有prev、next、thread、waitStatus四个关键字段。我之前一直没想明白一个问题既然CAS失败后直接让线程阻塞不就行了为什么还要维护一个队列答案在于“唤醒谁”。如果一个线程直接阻塞没有人知道它在哪锁释放时也不知道该通知谁。CLH队列把竞争锁失败的线程按顺序串起来每个节点当发现自己前驱节点的状态是SIGNAL时就安心进入阻塞。锁释放时头节点负责唤醒后继节点。这样就把无序竞争变成了有序等待。这里有面试官常追问的细节为什么CLH队列必须是双向的因为单向链表只能依次找后续节点取消等待时会很麻烦双向链表可以快速把取消的节点摘除重新连接前后节点。2.3 公平锁与非公平锁只是一行hasQueuedPredecessors()的差别ReentrantLock构造方法可以传布尔值true是公平锁false是非公平锁。两者在源码上的差别集中在tryAcquire里。非公平锁进来先CAS抢一次抢不到再走排队公平锁在CAS之前会先调用hasQueuedPredecessors()判断等待队列里有没有比自己更早的线程。有就老老实实去排队没有才允许CAS尝试抢锁。为什么非公平锁性能更好因为线程有可能刚好在锁释放的瞬间到来CAS一次就成功省去了线程阻塞、唤醒、上下文切换的开销。但代价是等待队列里的线程可能“饿死”虽然概率低。面试时你如果能说出“非公平锁吞吐量更高但极端情况下公平性差”这个层次就过关了。2.4 面试官追问AQS时我建议你这样组织语言我总结了一套回答AQS的“四段式”今天实际自测效果不错一是说一下AQS是什么抽象的同步队列器是整个JUC锁和同步工具的基础设施。二是说核心字段volatile int state 加 CLH双向等待队列。三是说获取锁流程tryAcquire快速尝试失败就addWaiter入队acquireQueued自旋/阻塞等待前驱唤醒。四是说释放锁流程tryRelease把state减到0再unpark后继线程。如果面试官继续追问“哪些组件基于AQS”可以把ReentrantLock、Semaphore、CountDownLatch、ReentrantReadWriteLock都列出来然后强调它们各自的语义不一样比如Semaphore用state表示剩余许可数CountDownLatch用state表示计数器但底层获取和释放的骨架是同一套模板方法。3. 数据一致性从JVM内存到数据库再到分布式层层补丁“Java怎么保证数据一致性”是热搜里关注度很高的词也是面试中覆盖面最广的一道综合体。我自己复习的时候按“单机并发 - 数据库 - 分布式”三层来拆每一层的问题类型完全不同不能混在一起答。3.1 单机并发volatile、synchronized、CAS各自的边界单机层面的数据一致性核心是解决多线程同时访问共享变量的问题。三个工具要分清楚volatile保证可见性禁止指令重排序但不保证原子性。比如count这种读-改-写操作volatile完全管不住。synchronized用监视器锁保证临界区串行执行既能保证原子性也能保证可见性但进入和退出锁有阻塞开销。CASCompare And Swap是无锁方案CPU指令级别原子比较并交换像AtomicInteger就是靠它实现在高并发读多写少场景下比synchronized更轻量。面试官很喜欢问一个问题既然synchronized能解决并发为什么还要有CAS我的理解是synchronized是阻塞式的拿到锁的线程执行慢其他线程全部阻塞挂起挂起唤醒都很重CAS是非阻塞的竞争失败就立刻重试不会让线程进入阻塞状态所以在短临界区场景下吞吐更高。CAS也有自己的坑ABA问题可以用AtomicStampedReference加版本号解决自旋过多会浪费CPU。3.2 数据库层事务隔离级别与乐观锁version字段数据一致性到了数据库层就要聊ACID、事务隔离级别和锁。热词“java怎么保证数据一致性”在数据库场景下通常对应两个具体问题隔离级别怎么选高并发扣减库存怎么防止超卖。隔离级别从低到高是读未提交、读已提交、可重复读、串行化。MySQL默认是可重复读。面试里常考“幻读”比如统计订单总数时别的事务插入了新订单导致两次统计结果不一致。可重复读通过MVCC解决了快照读的幻读但当前读select ... for update下的幻读需要间隙锁或临键锁来解决。乐观锁实现超卖防护是业务里最常见的手法UPDATE stock SET count count - 1, version version 1 WHERE sku_id #{skuId} AND version #{oldVersion} AND count 1;重点要看受影响行数如果返回0说明冲突或库存不足就需要重试或提示用户。这套方案相比悲观锁for update在冲突不激烈时性能好很多但要注意它是“最终一致”两个并发事务可能都读到同一个version只有一个能更新成功。3.3 分布式最终一致性不是“偷懒”是取舍跨服务的数据一致性没办法依赖单机事务。这是CAP理论的核心分布式系统在网络分区发生时一致性C和可用性A必须二选一。绝大多数业务选AP靠最终一致性兜底。我在实际项目中常用的方案是“本地消息表 MQ 消费者幂等”。本地先写业务表同时写一条消息表两个操作在同一个本地事务里。后台任务扫消息表把状态为pending的消息发送到MQ。消费者处理完业务后回执更新本地消息表状态为done。这套方案的好处是强依赖于非常成熟的本地事务坏处是消息表会不断增长需要定期清理。更关键的是消费端一定要幂等。我踩过真实的坑订单支付回调由于网络重试被MQ重复投递了好几次结果订单状态被覆盖成旧值。后来用“订单号 事件类型”做唯一键重复消息直接被数据库拒绝问题才根治。所以说分布式一致性从来不是单点技术而是一整套重试、幂等、对账机制的组合。3.4 顺带一说行级权限的本质是SQL改写别和一致性混淆热搜词里有“行级权限java”刚好今天资料里也串到了这个点。行级权限和数据一致性是两个维度一致性解决的是“数据在并发下是否正确”行级权限解决的是“不同用户能看哪些数据”。常见实现思路是MyBatis拦截器统一改写SQL根据当前登录用户动态拼上tenant_id或org_id条件。比如SELECT * FROM order_info WHERE user_id #{currentUserId}这样每个用户都自动只能看到自己订单。需要注意的是SQL改写要和分页插件、聚合查询配合好否则很容易出现先查全表再过滤、或者聚合结果不正确的隐患。权限过滤器最好在SQL生成阶段就注入而不是在应用层手动拼接字符串否则每条SQL都容易漏条件。4. 对象深度拷贝与动态代理两道“底层送命题”的正确打开方式这两个知识点放在一起复习是因为它们都要求你理解Java对象在运行时的真实结构。一个是复制对象一个是动态生成对象看起来不太相关但底层都绕不开反射和类加载机制。4.1 深拷贝的三种实现思路和各自的坑对象拷贝八股从浅拷贝开始讲只复制引用不复制对象本身。用默认的Object.clone()实现Cloneable接口得到的就是浅拷贝。如果对象里有List、Map或者自定义引用类型修改复制后对象的字段会直接影响原对象。深拷贝三种主流实现路线我分别列一下适用场景和坑第一种重写clone方法并手动递归拷贝引用字段。可控性强但字段一多代码非常啰嗦而且容易漏字段。第二种通过序列化和反序列化实现深拷贝ByteArrayOutputStream baos new ByteArrayOutputStream(); ObjectOutputStream oos new ObjectOutputStream(baos); oos.writeObject(original); ObjectInputStream ois new ObjectInputStream(new ByteArrayInputStream(baos.toByteArray())); Object copy ois.readObject();这段代码最省事但要求所有涉及的类型都实现Serializable而且transient字段不会被序列化拷贝出来直接丢失。性能也不理想每次拷贝都要走一遍完整的序列化流程。第三种手写拷贝构造器或工厂方法。例如new Person(p.getName(), new Address(p.getAddress()))。这种方式最直观也能保证只拷贝需要拷贝的字段但同样需要维护。避坑提醒JDK的clone()是浅拷贝原型Hutool的BeanUtil.copyProperties默认也是浅拷贝。很多生产事故就是“以为拷了深层对象实际上共享了内层引用”排查起来特别隐蔽。4.2 InvocationHandler与ProxyJDK动态代理的完整链路动态代理的经典面试题是“讲一讲JDK动态代理原理”。要答好绝不能只说“基于接口”。我的复习路径是从Proxy.newProxyInstance的调用链展开public interface UserService { void saveUser(String name); } public class LogInvocationHandler implements InvocationHandler { private final Object target; public LogInvocationHandler(Object target) { this.target target; } Override public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { System.out.println(before: method.getName()); Object result method.invoke(target, args); System.out.println(after: method.getName()); return result; } } UserService proxy (UserService) Proxy.newProxyInstance( UserService.class.getClassLoader(), new Class[]{UserService.class}, new LogInvocationHandler(new UserServiceImpl()) );运行期发生的事情是Proxy类利用ProxyGenerator生成一个继承了Proxy并实现UserService接口的字节码类这个类把所有接口方法都转发到InvocationHandler的invoke方法。真正的业务逻辑在invoke里通过反射调用。所以InvocationHandler是代理逻辑的载体Method和Object[]是目标方法的元信息与入参。值得多说一句的是“为什么JDK动态代理必须基于接口”。因为生成的代理类已经继承了Proxy类Java是单继承没法再继承目标类只能通过实现接口来扩展能力。想代理没有接口的类就要用CGLIB。4.3 为什么Spring AOP默认选JDK动态代理而不是CGLIBSpring AOP的选择逻辑以前是“目标类实现了接口就用JDK代理否则用CGLIB”Spring Boot 2.x之后把默认策略调成了CGLIB优先。这个调整是有原因的CGLIB直接生成目标类的子类不需要目标类强制实现接口而JDK代理因为要拿着接口来创建代理类在类没有接口时根本无法工作。但CGLIB有它的限制目标类和方法不能是final否则无法生成子类或无法覆写。而且因为代理是子类内部this调用的方法不会经过代理拦截。这种“自调用不走代理”的问题我在Spring事务场景里踩过不止一次同类里方法A调用方法BB上的Transactional完全没生效。解决办法是拆类或者通过AopContext.currentProxy()显式调用代理对象。4.4 手写一个极简动态代理Demo为了验证自己是不是真的理解我每次复习都会手敲一遍上述Demo并执行断言。关键验证点有三个代理对象类型不是UserServiceImpl调用saveUser前后都打印了日志代理对象实现了UserService接口。这类小Demo最大的价值不是跑通而是改装。我会故意把InvocationHandler里改成直接调用method.invoke(target, args)之前打印代理类的类名就能直观看到生成的代理类形态。如果没有这一步光看文档会一直觉得动态代理很抽象。5. 经典排序八股冒泡排序讲出“优化层次”才叫真复习排序算法在项目里用得越来越少但面试仍然爱问尤其热搜词里的冒泡排序java。为什么还问这种“老掉牙”的算法因为编码能力、边界思维和优化意识都能在一道冒泡里体现出来。5.1 冒泡排序的三版递进基础版谁都会public static void bubbleSortBasic(int[] arr) { int n arr.length; for (int i 0; i n - 1; i) { for (int j 0; j n - 1 - i; j) { if (arr[j] arr[j 1]) { swap(arr, j, j 1); } } } }第一版优化加入标志位如果某一轮遍历没有任何交换说明数组已经有序直接退出。这个优化在近乎有序的数组上效果立竿见影最好情况时间复杂度直接降到O(n)。第二版优化记录最后交换位置每一轮最后发生交换的位置之后的元素已经有序下一轮内层循环只需要遍历到该位置即可减少无意义的比较。第三版是双向冒泡也叫鸡尾酒排序。每轮先从左往右把最大值移到最后再从右往左把最小值移到最前。适用于“大部分元素有序、只有少量错位”的数组。5.2 时间复杂度的最佳/最坏情况怎么推很多人背“冒泡排序时间复杂度最坏O(n²)、平均O(n²)、最好O(n)”但真被问“为什么平均是O(n²)”就露怯。我的推导方法是内层比较次数总和近似 n (n-1) ... 1等差数列求和是 n(n-1)/2所以数量级是O(n²)。空间复杂度O(1)因为只用了常数个交换变量稳定性是稳定因为相邻元素相等时不交换相对顺序不会被破坏。面试时把“稳定”的判断标准讲清楚也很加分排序前后相等元素的相对位置是否改变冒泡因为只交换逆序相邻元素相等元素永远不会交换所以稳定。5.3 刷题资源与学习路线的实用性建议搜“java学习路线”、“java免费刷题”能看到铺天盖地的资源帖但我的建议是回归一手资料。JDK要从官网下载既安全又能获取最新版本信息语法基础看Oracle官方的Java Tutorial框架细节直接查Spring官方文档。技术博客可以辅助理解但不能替代源码验证。如果基础薄弱我的顺序建议是先刷完Java基础题和入门网站上的语法题然后精读集合源码ArrayList、HashMap、ConcurrentHashMap再进入并发包源码接着做Spring Boot实战小项目最后才是八股文冲刺。八股文和项目是互补关系没有项目经验八股答案再漂亮也显得飘没有八股体系项目里出了问题也难定位。5.4 把八股当“索引”把源码当“正文”Day23复习到这儿我对八股本身的态度有了点变化。以前觉得八股就是面试应付现在觉得它更像一本“索引”它压缩了Java知识体系的关键词比如可见性、原子性、重排序、CLH队列、MVCC、幂等每个词背后都对应一大片源码和实战场景。我的复习方式也调整成“先背索引再读正文”。背完一个概念立刻去翻对应源码或写验证Demo写完Demo再回来用一段话总结。这样看上去耗时但记忆牢固度比单纯刷题高很多。注意这里不是鼓励大家不看项目实战只啃理论。面试官问“你用过AQS吗”最好回答“我是在排查线上锁竞争问题时深入看了ReentrantLock源码”而不是“我在刷题网站看过”。回到今天的学习日记本身。第23天最大的收获是把散落的知识点串成了一条线并发下的数据问题从JVM层延伸到数据库层再到分布式层对象和代理的底层机制又反向加深了对AOP的理解。八股文复习到这个阶段我越发觉得能讲清楚“为什么”比“记住了什么”重要得多。明天我打算做一次模拟面试把今天这几个点按“结论 原理 项目例子”的格式完整输出一遍再整理一份错题清单。如果你也在刷Java八股可以试试用同样的方式把知识链条串起来你会发现很多考点其实是同一个底层机制的不同侧面。