拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

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

Java面试进阶:AQS、动态代理、深拷贝与数据一致性核心机制解析

Java面试进阶:AQS、动态代理、深拷贝与数据一致性核心机制解析 如果你准备Java面试已经到了第16天那正好处于最容易焦虑的阶段基础语法、集合、JVM这些常规内容基本过了一遍但是看到AQS、动态代理、数据一致性这类深水区题目又总觉得心里没底。我自己的复习计划里这一天就是个分水岭前面的内容偏向“记忆”今天开始真正转向“机制理解”。这篇文章就把我这天的复盘完整写出来内容核心锁定在四个方向AQS的并发骨架、JDK与CGLIB动态代理原理、浅拷贝与深拷贝的实现方案、数据一致性保障思路全部是面试高频点也是日常开发里非常容易踩坑的位置。如果你正在按部就班地刷Java面试八股文或者已经工作但想把并发和代理这块原理补扎实这篇内容应该对你有用。下面的每一节都按“原理拆解 代码验证 面试追问”来组织尽量做到看完能用自己的话讲出去而不是只会背结论。1. 第16天的学习定位知识点开始从“背诵”转向“推导”进入复习的中段之后最大的变化不是题目变得更难而是考察方式从“是什么”变成了“为什么”和“怎么做”。第16天之所以被我当作节点是因为这一天的内容与前后章节的衔接非常紧密一旦理解到位后面看Spring AOP、事务传播、缓存设计都会顺很多。1.1 为什么我会把第16天排成“机制专项日”大多数人的备考周期在25到40天之间。按30天来算前两周基本覆盖了Java语法、面向对象、集合、异常、JVM内存模型和多线程的入门知识。这些内容有个特点背下来就能得分但得分上限很低。第15天之后面试题库的核心考点开始转向那些需要现场推理的题目比如“非公平锁为什么吞吐量高”“JDK动态代理生成的类长什么样”“缓存双写怎么保持一致”。这类题目光背书是撑不住的面试官只要连续追问两三层背诵痕迹很容易暴露。所以我把第16天单独留出来不碰新框架也不刷简单题集中解决四块硬骨头AQS框架、动态代理、对象深度拷贝、数据一致性。选择这四个方向的原因也很直白它们构成了并发编程、Spring底层、对象生命周期和分布式系统设计的基础交汇点跳过任何一个后面都容易出现理解断层。1.2 当天的学习清单与时间分配这一段可以给同样在备考的朋友一个参考我的实际分配是这样上午约2小时AQS源码走读重点看ReentrantLock的非公平锁加锁流程下午约1.5小时动态代理手写demo分别用JDK和CGLIB各实现一次下午约1小时深拷贝实验对比三种拷贝方式在嵌套对象上的表现晚上约1.5小时缓存一致性方案整理画流程图并准备追问话术一整天加起来6个小时左右。真正投入之后会发现时间看似紧其实每个主题都能形成一条完整链路先看原理再写代码验证最后整理成可以“讲出来”的答案。02 每天不需要贪多能把这四块啃透比刷二十道简单题有价值得多。2. AQS源码级拆解并发面试的大魔王AQS在Java并发里是绕不开的一个名字全称AbstractQueuedSynchronizer。无论是ReentrantLock、Semaphore、CountDownLatch还是ThreadPoolExecutor里的Worker核心机制都建立在它之上。面试官喜欢问AQS往往不是真指望你能把源码默写出来而是想确认你有没有能力拆解一段复杂的并发框架代码。2.1 AQS的三个核心组成理解AQS需要抓住三样东西一个状态量、一个等待队列、一套模板方法。状态量就是volatile修饰的int变量state。volatile保证了多线程之间的可见性任何一个线程修改state之后其他线程都能立即看到最新值。至于state具体代表什么AQS本身并不关心它把含义留给子类去定义ReentrantLock里state是重入次数Semaphore里state是剩余许可证数量CountDownLatch里state是还需要等待完成的任务数。这种设计抽象度很高也是AQS能被那么多工具复用的原因。等待队列是CLH变种的一个FIFO双向队列。线程拿不到锁时会被封装成Node节点挂到队列尾部然后阻塞等待前一个节点在释放资源之后会调用unpark唤醒后继节点。这个队列解决的是“资源暂时不可用线程该去哪等”的问题。模板方法部分更关键。AQS规定了大流程比如acquire、release、acquireShared这些方法它们都是final的子类不能修改骨架逻辑。但tryAcquire、tryRelease、tryAcquireShared、tryReleaseShared这几个方法由子类去实现用来定义“怎么才算获取成功”“怎么才算释放完毕”。2.2 从ReentrantLock看非公平锁的完整加锁流程我在白板上画这个流程时发现如果只背结论不看源码很容易把“排队”理解错。ReentrantLock内部有一个继承AQS的Sync类它又分成FairSync和NonfairSync两个实现。以默认的非公平锁为例lock()方法的第一行就很说明问题final void lock() { if (compareAndSetState(0, 1)) { setExclusiveOwnerThread(Thread.currentThread()); } else { acquire(1); } }注意非公平锁在进入队列之前会先用CAS尝试把state从0改成1。如果当前锁确实没人持有新来的线程会直接获得锁根本不去排队。只有CAS失败了才进入acquire(1)流程。acquire里做的事情也很直白public final void acquire(int arg) { if (!tryAcquire(arg) acquireQueued(addWaiter(Node.EXCLUSIVE), arg)) { selfInterrupt(); } }首先tryAcquire再试一次如果还是失败就把当前线程封装成Node节点通过addWaiter挂到等待队列尾部然后进入acquireQueued自旋。在acquireQueued里线程会反复尝试获取锁拿不到就阻塞挂起直到被前驱节点唤醒。整个链路走完之后非公平锁的“非公平”就表现得很清晰新线程可以直接抢锁等待队列里的老线程只能被动等待。这样做的代价是可能出现饥饿但好处是减少了线程阻塞和唤醒的次数整体吞吐量更高。这也是ReentrantLock默认选择非公平锁的原因。2.3 AQS如何复用到Semaphore和CountDownLatch面试里常常会把AQS相关的工具类放在一起比较实际上它们就是同一个骨架的不同用法。Semaphore把state初始化为N每一个acquire请求会尝试把state减1减到0就无法继续获取线程进入等待队列release时把state加1并唤醒等待线程。CountDownLatch则把state初始化为N每次countDown都会把state减1当state变成0的时候等待在latch上的所有线程一次性被唤醒。这两个例子说明AQS本质上是“资源状态管理框架”锁只是其中一种资源语义。理解了CAS修改state和等待队列唤醒这两个核心机制之后再看这些并发工具类就不会觉得彼此孤立。2.4 面试高频追问清单追问方向参考答案要点state为什么必须用volatile修饰因为多个线程需要实时可见锁状态如果不可见线程可能拿不到最新状态而出现错误判断非公平锁会导致什么问题等待队列中的线程可能被新线程反复插队极端情况下出现饥饿AQS是乐观锁还是悲观锁AQS本身基于CAS是一种乐观机制但ReentrantLock对外提供的锁语义是悲观锁Node节点有哪些状态主要有CANCELLED、SIGNAL、CONDITION、PROPAGATE其中CANCELLED表示节点已取消等待3. 动态代理从InvocationHandler到Spring AOP动态代理这个考点表面考的是代理模式实际上考的是Java反射和字节码生成机制。Spring AOP、MyBatis的Mapper接口代理、RPC框架的透明调用背后都离不开动态代理。面试时如果你只能说出“动态代理可以在运行时生成代理类”但说不清JDK动态代理和CGLIB的区别那基本等于没准备。3.1 JDK动态代理的完整工作链路先写一个最直观的demo。JDK动态代理有两个核心角色Proxy类和InvocationHandler接口。InvocationHandler里唯一的方法是invoke所有通过代理对象发起的接口方法调用最终都会进入这个invoke方法。public interface UserService { void sayHello(String name); } public class UserServiceImpl implements UserService { Override public void sayHello(String name) { System.out.println(hello: name); } } public class LogHandler implements InvocationHandler { private final Object target; public LogHandler(Object target) { this.target target; } Override public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { System.out.println(before method: method.getName()); Object result method.invoke(target, args); System.out.println(after method: method.getName()); return result; } } UserService userService (UserService) Proxy.newProxyInstance( UserService.class.getClassLoader(), new Class[]{UserService.class}, new LogHandler(new UserServiceImpl()) ); userService.sayHello(张三);运行结果会依次打印before method: sayHello hello: 张三 after method: sayHello这说明代理对象拦截了方法调用并在实际目标方法前后插入了日志逻辑。底层来看JDK在运行时动态生成一个继承Proxy、实现传入接口的代理类比如$Proxy0。这个类中所有接口方法都重写成了对InvocationHandler.invoke的调用。关键点来了由于Java只支持单继承生成的代理类已经继承了Proxy所以JDK动态代理必须面向接口没法直接代理一个没有接口的类。关于这一点Spring在目标类实现接口时会用JDK动态代理目标类没有接口时则会切换到CGLIB。3.2 CGLIB为什么能代理普通类CGLIB走的是另一条路通过继承目标类生成子类在子类中重写目标方法把增强逻辑织入方法调用前后。子类也就是代理对象可以直接当作目标类使用所以它不需要接口也能工作。但也正因为使用继承CGLIB有很明显的限制final类不能被代理因为final类不能有子类final方法不能被重写所以也无法被增强。另外CGLIB生成子类的过程比JDK动态代理更重首次创建代理对象时性能开销更大后续调用性能倒是不错。3.3 两种方案的对比对比项JDK动态代理CGLIB必要条件目标必须实现接口普通类即可底层机制生成实现接口的代理类并继承Proxy生成目标类的子类并重写方法final限制接口方法与final定义无冲突final类无法代理final方法无法增强Spring中的使用目标类实现接口时默认选用目标类没有接口时自动切换性能特点代理对象创建较快代理对象创建稍慢调用性能也不错3.4 面试追问的应对策略常见追问是“Spring AOP到底用的哪种代理”。这个问题要分情况回答Spring Boot 2.x及更早版本中默认行为有过调整我现在的建议是重点关注“目标类是否实现接口”这个条件。传统Spring项目中有接口就用JDK动态代理没有接口才用CGLIBSpring Boot 2.x之后默认走CGLIB。理解原理后再看官方文档里的配置项就不需要死记。另一个追问是“动态代理和静态代理的区别”。静态代理在编译期就写好了代理类一个代理类往往只能代理一种类型动态代理在运行时生成代理对象同一个InvocationHandler可以服务多个目标对象具备更强的通用性。4. 浅拷贝与深拷贝一不小心就出Bug的经典话题对象拷贝在业务代码里经常出现比如做数据快照、复制请求参数、保存历史版本。面试官问起来方向也很固定浅拷贝和深拷贝的区别是什么有哪些实现方式序列化拷贝有什么坑如果平时只写过clone()这个部分很容易讲不透。4.1 先看懂浅拷贝的“浅”在哪里浅拷贝的理解难点在于引用类型字段。基本类型字段被复制后相互独立但引用类型字段复制的是对象的地址不是对象本身。用一个例子就能说明public class Address implements Cloneable { public String city; public Address(String city) { this.city city; } Override public Object clone() throws CloneNotSupportedException { return super.clone(); } } public class Person implements Cloneable { public String name; public Address address; public Person(String name, Address address) { this.name name; this.address address; } Override public Object clone() throws CloneNotSupportedException { return super.clone(); } }如果执行Person p1 new Person(张三, new Address(北京)); Person p2 (Person) p1.clone(); p2.address.city 上海;最终打印p1.address.city结果是“上海”。原因就是p1和p2的address字段指向同一个Address对象修改任意一个都会通过同一个引用影响到另一个。这种情况在业务里很隐蔽尤其是当对象被传到其他模块修改时原对象的字段被悄悄改动排查起来相当费劲。4.2 深拷贝的三种常见实现第一种是手动复制。把所有引用类型字段逐个new新对象赋值。优点是直观可控缺点是对象层级一深代码会变得极其啰嗦而且每新增一个字段就要同步修改拷贝逻辑很容易遗漏。第二种是重写clone()。在clone方法里先调用super.clone()再对引用字段再次调用clone()Override public Object clone() throws CloneNotSupportedException { Person cloned (Person) super.clone(); cloned.address (Address) address.clone(); return cloned; }这种做法的隐藏坑在于嵌套对象的所有层级都必须实现Cloneable接口并重写clone()。一旦某一层漏了运行时会直接抛CloneNotSupportedException编译期还不会提示。层级越深维护成本越高。第三种是利用序列化。把对象写入字节流再读回来由于序列化保存的是字段的值而不是内存引用地址反序列化后的对象与原始对象完全不共享引用。具体实现可以用ByteArrayOutputStream和ObjectOutputStream配合完成ByteArrayOutputStream bos new ByteArrayOutputStream(); ObjectOutputStream oos new ObjectOutputStream(bos); oos.writeObject(original); oos.flush(); ObjectInputStream ois new ObjectInputStream(new ByteArrayInputStream(bos.toByteArray())); Object copied ois.readObject();这种方式对嵌套结构非常友好前提是对象及其所有字段都必须实现Serializable接口否则会抛NotSerializableException。像线程、Socket、文件句柄这类不可序列化的资源正确做法是标记为transient让序列化机制跳过它们。4.3 序列化拷贝的三个隐藏风险第一序列化不经过构造函数。这意味着序列化拷贝出来的新对象不会执行类里的构造逻辑某些依赖构造器初始化的状态会丢失。如果类里有对构造器的强依赖使用序列化拷贝前要再三确认。第二会破坏单例模式。如果被拷贝的对象是单例序列化读出来的对象与原来的单例是两个不同实例。要想继续维持单例必须在类中添加readResolve方法并返回同一个单例实例。第三性能开销。序列化需要把对象完整写入字节流对象越复杂开销越大不适合放在高频路径上。我自己的实践习惯是深拷贝只在低频操作里用比如接口入参快照、审计日志、配置副本而不放在核心交易链路里。5. 数据一致性从并发可见性到缓存一致性“Java怎么保证数据一致性”是一个非常泛的问题面试官一般会从单机并发和分布式缓存两个角度去问。这一节就把两条线拆开讲清楚。5.1 volatile能保证什么不能保证什么在单机多线程环境下数据不一致的根源是可见性、原子性和有序性问题。volatile可以解决可见性和一定程度的有序性但解决不了原子性。最经典的例子就是多个线程同时对volatile变量执行ipublic class Counter { private volatile int count 0; public void increment() { count; // 不是原子操作 } }count在字节码层面是“读取count、修改count、写回count”三步操作。volatile只保证每次读取都能拿到最新值但无法阻止多个线程同时读取旧值再各自写回。要保证原子性可以用synchronized对读写方法加锁或者使用AtomicInteger底层通过CAS保证更新步骤的原子性。给一个简单的判断标准单存单取用volatile复合操作要加锁或用原子类。变量只被一个线程写、多个线程读volatile是最轻量的方案。5.2 缓存与数据库的一致性怎么解这道题在面试里出现频率极高。最常用的模型是Cache Aside。读的逻辑很直接先读缓存缓存没命中就读数据库再回填缓存。写的逻辑则讲究一些先更新数据库再删除缓存而不是更新缓存。为什么是“删缓存”而不是“更新缓存”核心原因是更新缓存可能出现“覆盖旧值”的问题。假设两个并发请求A和BA先更新数据库B后更新数据库但B的缓存更新请求反而先执行把B的新值写进缓存随后A的缓存更新又执行把A的旧值覆盖回去缓存和数据库就长期不一致了。删缓存则不存在这个问题缓存被删除后下一次读请求会拉取数据库最新值回填自然恢复一致。还有一个经典问题为什么不先删缓存再更新数据库因为存在时间窗口。先删缓存后一个读线程在数据库更新前查库拿到旧值并回填缓存随后数据库被更新成新值但缓存里的旧值已经被回填了。解决这个窗口的常用手段是延迟双删删除缓存、更新数据库、休眠一小段时间再次删除缓存第二次删除的目的就是清掉可能回填的旧缓存数据。这个休眠时间要依据实际业务的读耗时来调没有万能值最好通过压测确定。再进一步还可以用版本号方案。缓存数据里携带版本号数据库更新后版本号加一读取时发现缓存版本落后就淘汰或回源。这套方案能减少误删但实现复杂度更高适合对一致性要求更苛刻的场景。5.3 面试回答的三段式思路准备这类问题的答案时我会分三段组织第一段说场景。先明确是单机多线程还是分布式缓存与数据库双写。不同场景的答案完全不同。第二段说目标。单机并发场景通常要求强一致或者操作原子性分布式缓存场景现实目标一般是最终一致。第三段说手段。单机用volatile加CAS或synchronized缓存场景用Cache Aside加延迟双删必要时加版本号。这样回答的好处是不会被面试官牵着走就算追问“延迟双删真的能保证一致吗”也能够自然地承认“做不到绝对一致只能缩小窗口最终靠补偿机制兜底”。6. 第16天的踩坑记录与复习心得知识点归知识点实际复习过程中仍然有一些误区值得提前避开。这部分是我当天真实踩过的坑整理出来希望对你有帮助。6.1 复盘时的三个认知误区第一个误区以为CLH队列的排队顺序等于获取锁的顺序。严格来说对于公平锁队列顺序基本等于获得锁的顺序但对于非公平锁新线程可以直接抢锁排队的线程可能被反复插队。画图之后才发现非公平锁的“非公平”体现在竞争入口而队列本身仍然是FIFO这是两个维度不能混为一谈。第二个误区把JDK动态代理生成的代理对象“当作”目标实现类。我之前尝试把代理对象强制转换成UserServiceImpl类型结果直接ClassCastException。原因很简单JDK动态代理生成的代理类是Proxy的子类并实现了UserService接口它跟UserServiceImpl没有任何继承关系。只有转换到接口类型才安全。第三个误区用序列化做深拷贝时忽略了不可序列化字段。当时我给一个带Thread字段的对象做深拷贝运行直接抛NotSerializableException。这提醒我序列化拷贝虽然优雅但适用范围有限携带资源型字段的类不能直接这样做。6.2 对流程型知识最有效的复习方法我的方法是白板讲演法。把当天的知识点像讲课一样对着白板或者空白文档讲一遍要求自己完整讲出流程、代码逻辑和可能的面试追问。讲的时候明显会发现有些环节说不清楚比如AQS里addWaiter和acquireQueued的先后顺序、动态代理的类继承结构。这些含混点就是真正需要补的地方补完之后再讲第二遍基本就能做到脱稿。如果你时间更紧还可以退一步采用“追问清单”法。把每个知识点的核心追问整理成表格面试前快速过一遍。我在上文AQS和动态代理部分已经整理了两个表格可以直接参考使用看过之后加上自己的理解就够了。6.3 明天的预习衔接按照计划第17天开始切入Spring的深水区重点看循环依赖和事务传播机制。这两个话题跟今天的动态代理关系非常大Spring解决循环依赖时会涉及早期对象暴露和代理对象创建顺序的问题AOP配置又依赖动态代理的原理。建议预习时带着今天的问题去读源码比如“如果Bean被AOP代理了三级缓存里的早期引用是原始对象还是代理对象”这样新知识和旧知识就能串成一张网。第16天复盘到这里就写完了。我自己的体会是八股学习最有价值的部分不是记住答案而是通过追问把知识点之间的逻辑打通。AQS、动态代理、深拷贝、数据一致性这几个主题单独看是四座孤岛放在一起其实就是并发、反射、对象生命周期和一致性保证这些底层机制的组合应用。能把它们串起来讲清楚就算面试碰到没准备过的题也可以从底层原理现场推导出一个像样的回答。明天进入Spring部分之后我再找时间把循环依赖和事务的复习心得整理出来。
返回列表