今天是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到127
Java数据类型八股主要集中在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表示重入次数。每次拿锁state+1,每次释放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用监视器锁保证临界区串行执行,既能保证原子性也能保证可见性,但进入和退出锁有阻塞开销。CAS(Compare 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与Proxy:JDK动态代理的完整链路
动态代理的经典面试题是“讲一讲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动态代理而不是CGLIB
Spring AOP的选择逻辑,以前是“目标类实现了接口就用JDK代理,否则用CGLIB”,Spring Boot 2.x之后把默认策略调成了CGLIB优先。这个调整是有原因的:CGLIB直接生成目标类的子类,不需要目标类强制实现接口;而JDK代理因为要拿着接口来创建代理类,在类没有接口时根本无法工作。
但CGLIB有它的限制:目标类和方法不能是final,否则无法生成子类或无法覆写。而且因为代理是子类,内部this调用的方法不会经过代理拦截。这种“自调用不走代理”的问题,我在Spring事务场景里踩过不止一次:同类里方法A调用方法B,B上的@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八股,可以试试用同样的方式把知识链条串起来,你会发现很多考点其实是同一个底层机制的不同侧面。