ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

Java实习面试复盘:高并发转账与Synchronized/ReentrantLock锁机制详解

Java实习面试复盘:高并发转账与Synchronized/ReentrantLock锁机制详解 前几天刚帮一个准备秋招的学弟做了一次Java实习岗位的模拟面试按蔚来汽车一面的规格来压。整场下来印象最深的是三个环节高并发转账的手撕代码、synchronized和ReentrantLock的对比、以及一个把前面所有知识串起来的超纲追问。复盘花了三个小时整理出来的东西说实在的比很多付费面经都值今天就完整拆开揉碎写出来希望能帮到正在准备Java后端实习面试的同学。这是一篇基于真实模拟面试场景的深度复盘覆盖了并发编程、JVM锁机制、业务场景设计三个层面的高频考点非常适合准备Java后端实习或校招一面、打算投递蔚来等新能源车企技术岗、以及虽然还没到面试阶段但想把并发基础打扎实的同学阅读。文章不只给答案更重要的是还原面试官每个追问背后的考点以及那些在八股文里背不到、但实战一定会踩的坑。1. 模拟面试全程概览与考察逻辑1.1 一个没有简历的模拟面试怎么开始这场模拟面试我刻意没有提前看学弟的项目经历也没有给他时间准备就是要还原一手真实一面现场的感觉。蔚来的一面通常是电话面试加在线代码时间控制在四十五分钟左右前半段是基础问答后半段是手撕代码加追问节奏相当紧。开场我让他一分钟自我介绍然后直接扔了两个基础题热场HashMap在JDK 8里put的完整流程、volatile和synchronized的区别。这两个题是典型的“送分题”但恰恰是最能看出水平的题因为背过八股的人能说对但说不到点子上。学弟回答得中规中矩HashMap的扩容和红黑树转化说清楚了volatile只提到了可见性和禁止重排序对原子性为什么不能保证阐述得比较浅。这里我就已经在心里埋了一个伏笔后面转账题一定会把volatile的这个软肋炸出来。1.2 蔚来一面到底在考什么复盘的时候我把整场面试考察的能力域做了一个拆解大致可以分成三层第一层是语言基础HashMap、并发包、JMM这些属于硬底子过了这层才谈得上后面的业务设计。第二层是并发编程实战能力手撕转账就是典型的并发场景题考的是能不能把锁、原子性、可见性这些抽象概念落到一段能跑的代码里。第三层是系统设计意识这个在实习面试里不会考到架构级别但会通过连环追问来试比如转账场景会追问到数据库事务、行锁、幂等性其实就是想看候选人有没有从单机并发往分布式方向思考的潜质。三层层层递进而且每一层都会夹带追问。这就是大厂一面最常见的套路面试官手里的问题清单是树状的你答到哪个分支他就往哪个分支往下挖直到挖到你的知识边界为止。所以准备面试最忌背题更忌只会背题知识一定要成体系每个知识点都要准备好往下说两层。2. 高并发转账手撕从“能跑”到“靠谱”的三次演进2.1 最先会犯的错误不做同步直接扣款手撕题的要求很简单实现一个账户转账方法要求多线程环境下安全。给我看第一版代码学弟用了大概不到两分钟写出来代码长这样public class Account { private BigDecimal balance; public void debit(BigDecimal amount) { this.balance this.balance.subtract(amount); } public void credit(BigDecimal amount) { this.balance this.balance.add(amount); } } public class TransferService { public void transfer(Account from, Account to, BigDecimal amount) { if (from.getBalance().compareTo(amount) 0) { throw new RuntimeException(余额不足); } from.debit(amount); to.credit(amount); } }写完之后我问他“你觉得这段代码在高并发下会发生什么”他犹豫了一下说“可能会多扣或者少扣”。确实这个答案方向是对的但这只是第一层。这里藏着两个很关键的问题。第一个是余额不足的判断和扣款不是原子的线程A和线程B同时读到余额100元同时通过校验然后都执行扣款最终余额变成负数也就是发生超扣。第二个是balance本身没有做任何同步保护debit和credit方法在多线程并发写入时会产生丢失更新一个线程的修改会被另一个线程的覆盖掉。为什么会这样底层要往Java内存模型去理解。每个线程都有自己的工作内存线程对共享变量的操作是先复制到工作内存操作完再写回主内存。两个线程同时操作balanceA写回的值可能把B写回的值覆盖掉这就是典型的可见性和原子性双重问题。在这个场景里单靠volatile是解决不了的因为volatile只能保证可见性保证不了复合操作check-then-act的原子性。2.2 只加一个锁就够了吗synchronized方法的陷阱第一版被否定之后学弟很快改了第二版他的直觉是加锁这方向没错。他在transfer方法上直接加了synchronized关键字public class TransferService { public synchronized void transfer(Account from, Account to, BigDecimal amount) { if (from.getBalance().compareTo(amount) 0) { throw new RuntimeException(余额不足); } from.debit(amount); to.credit(amount); } }这版能跑但问题很大。我问他“这个锁加的是什么对象锁粒度会不会太大”他想了想说是锁的this也就是TransferService这个对象同一个service实例的所有转账操作都会被串行化。这个锁粒度的问题在真实业务里是不可接受的。转账本身是高并发场景用户A给B转和用户C给D转这两笔操作完全互不影响但因为在同一个service实例上加了锁两笔转账只能一笔一笔执行吞吐量直接腰斩。更麻烦的是如果服务做了集群部署有多个service实例每个实例的锁是独立的跨实例的并发转账依然会出问题。也就是说这版代码在单机单实例下是安全的但换个部署方式就崩了。那有没有一种锁既保证同一对账户的转账不并发又允许不同账户的转账并行这就是按账户加锁的思路。而且这里还埋着一个大坑锁的顺序问题刚才我们说到的只是第一层。2.3 按账户ID加锁锁顺序决定生死真正靠谱的版本是给参与转账的两个账户分别加锁按固定的顺序获取锁避免死锁。我给学弟提示到“给Account对象加锁”之后他写出了这个版本public class TransferService { public void transfer(Account from, Account to, BigDecimal amount) { Account lock1 from.getId().compareTo(to.getId()) 0 ? from : to; Account lock2 from.getId().compareTo(to.getId()) 0 ? from : to; synchronized (lock1) { synchronized (lock2) { if (from.getBalance().compareTo(amount) 0) { throw new RuntimeException(余额不足); } from.debit(amount); to.credit(amount); } } } }这个版本的巧妙之处在于引入了锁顺序。如果两个线程同时执行反向转账线程A执行fromX、toY的转账线程B执行fromY、toX的转账如果不排序A先锁X再锁YB先锁Y再锁X两个线程各持有一把锁等待另一把就死锁了。排序之后两个线程都会先锁那把小ID的账户再锁大ID的账户锁的获取顺序一致死锁就天然避免了。这里需要补充一个并发编程里很重要的概念死锁的四个必要条件互斥、持有并等待、不可抢占、循环等待。在这个场景里互斥和持有并等待都天然存在我们能打破的主要是循环等待统一锁顺序就是打破循环等待的最简单方案。但按Account对象加锁仍然有个隐患如果Account对象被序列化到缓存里或者有多个实例指向同一条账户数据那锁的还是不同的对象并发控制会失效。所以真正生产级的写法通常是给每个账户分配一个独立的锁对象例如用一个ConcurrentHashMap维护accountId对应的锁key保证同一个账户永远命中同一把锁。不过实习面试能写出按ID排序加锁版本已经能拿到不错的印象分了。2.4 从JVM走向数据库事务与行锁兜底写完上面这版我顺势问了一句“如果多个服务实例同时跑这个JVM锁还靠得住吗”这个追问通常就是一面从并发编程过渡到分布式场景的转折点。学弟沉默了几秒说“那就得用数据库锁了”然后说出了Transactional加行锁的思路。单体应用最稳妥的兜底方案是让数据库来保证一致性。核心思路是转账操作放进一个事务里扣款时用SELECT FOR UPDATE锁定源账户行或者直接用UPDATE语句配合条件判断实现原子扣款Transactional public void transfer(Long fromId, Long toId, BigDecimal amount) { Account from accountMapper.selectForUpdate(fromId); if (from.getBalance().compareTo(amount) 0) { throw new RuntimeException(余额不足); } accountMapper.decreaseBalance(fromId, amount); accountMapper.increaseBalance(toId, amount); }SELECT FOR UPDATE会锁住数据库里那一行其他事务想操作同一行只能等待这就把锁的粒度精细到了行级别不同账户之间的转账可以并行性能和安全性都兼顾了。注意这里必须让事务提交或回滚时才释放锁所以方法上必须有Transactional而且要确保方法被Spring代理调用否则事务注解不会生效锁也会失效。数据库方案也有成本要额外考虑连接池占用、长事务等问题。如果流量再往上走就要引入分布式锁或消息队列来做最终一致性了。实习一面讲到数据库行锁这层已经完全够用面试官心里那杆秤已经过了。而且这部分可以说给学弟点醒了Java并发锁只是在单机层面解决一致性真正的工程问题往往发生在更远的边界上。3. synchronized和ReentrantLock别只背八股3.1 两者到底差在哪一张表看懂转账题写完我自然地把话题转到了锁的对比上这也是面经里出现频率极高的八股题。学弟对这个知识点的掌握属于应试水平能说出几个区别但缺乏系统性和深度。这里先给大家一个整理好的对比维度表是我反复验证过信息比较齐全的版本对比维度synchronizedReentrantLock背景JVM关键字基于Monitor机制JDK并发包提供的API基于AQS锁的获取/释放自动由字节码指令monitorenter/monitorexit控制手动lock()和unlock()成对出现通常配finally可重入性支持同一个线程可重复加锁支持内部记录持有线程和重入次数可中断性不支持线程进入锁等待后不可中断支持lockInterruptibly()可响应中断超时获取不支持支持tryLock(timeout, unit)公平性非公平默认为非公平支持构造参数设为公平底层实现JDK 6后基于锁升级MonitorAQSAbstractQueuedSynchronizer LockSupport组合能力无法单独将等待/唤醒拆开可结合多个Condition实现精细等待通知性能JDK 6优化后与ReentrantLock相差不大高并发场景下可调参数性能可控这张表基本能应付大多数面试环节但关键点在于光记住这张表是不够的面试官一定会挑一两个点往深了问问他最熟悉也最容易说的比如锁升级、AQS原理、Condition任何一个点都能聊上十分钟。3.2 synchronized的锁升级与底层实现很多人以为synchronized是重量级锁这其实是老黄历了。JDK 6之前synchronized确实会直接依赖操作系统的互斥量来实现线程阻塞和唤醒要陷入内核态开销非常大所以才有“重量级锁”的称号。但JDK 6之后引入了大规模的锁优化锁不再是“一上来就重型”而是根据竞争程度从轻到重不断升级。整个升级路径是无锁、偏向锁、轻量级锁、重量级锁。偏向锁的核心思想是大多数情况下锁不仅不竞争而且总是由同一个线程获得。所以第一个获取锁的线程会在对象头里记录自己的线程ID之后再次进入同步块时不需要任何同步操作直接执行。当另一个线程尝试获取偏向锁时偏向模式就撤销了。轻量级锁则适用于线程交替执行同步块的场景通过CAS自旋来获取锁自旋期间不放弃CPU等待线程持锁释放。自旋超过一定次数还没拿到锁或者竞争加剧到多个线程同时等待锁就会膨胀成重量级锁进入内核态阻塞等待。对象头的Mark Word是实现锁升级的关键它里面存储的数据会根据锁状态复用存储hashCode、偏向线程ID、锁记录指针、重量级锁指针等信息。这也是为什么面试里聊到锁升级时很多面试官会顺带问对象头布局因为不了解对象头就理解不了升级机制。我建议准备这块的时候别死记硬背三个锁的切换条件要抓住一个主线锁升级是JVM在“减少无谓的线程阻塞”和“保证并发安全”之间做权衡的手段。理解了这条主线面试官问什么角度你都接得住。3.3 ReentrantLock源码级的三个核心能力ReentrantLock为什么在有些场景下无法被synchronized替代要落到源码层面去看就看三点。第一点是非公平与公平锁的切换。ReentrantLock默认是非公平的线程抢锁时不管队列里有没有排队的线程先CAS抢一把抢不到才入队。公平锁则是先检查队列中是否有前驱节点如果有就乖乖入队。非公平性能更好但可能出现线程饥饿公平锁保证了先到先得但吞吐量低。源码里就一个hasQueuedPredecessors()方法的区别。第二点是lockInterruptibly。这个方法让线程在等待锁的过程中可以响应中断调用线程的interrupt()之后线程会抛出InterruptedException从等待中退出。这在处理某些可能长期阻塞的业务场景里非常有用比如一个转账线程卡在锁等待上运维想让它停下来用了可中断锁就能优雅地叫停。第三点是Condition学弟对这个基本没概念我打了个比方他就懂了synchronized的wait和notify就像整个屋子的人都喊一嗓子没法精确定位通知哪个人而Condition相当于把在锁上等待的线程分成了好几个小组每个小组有自己专属的等待队列signal可以只通知指定小组里的线程。生产者消费者里有多个等待条件时Condition就比wait/notify优雅得多。这三个能力是ReentrantLock存在的核心价值。如果只是普通的互斥同步synchronized完全够用代码还更简洁。只有在需要公平性控制、需要可中断等待、需要精细的等待通知机制时ReentrantLock才真正体现不可替代性。3.4 面试被问“选哪个”时真正想听的答案很多面经给的答案是“性能差不多优先用synchronized”这个结论本身没错但如果面试只答这一句会让人觉得你没有实战判断力。真正专业的回答应该是分场景的如果需求就是简单的互斥同步不需要额外控制优先用synchronized代码更简洁不可能出现忘记释放锁的问题JDK 6优化后性能也不输而且jetty、netty这些高性能框架里大量地方也仍然使用synchronized它并不“low”。如果用到了多个 Condition 做精细等待通知或者需要 tryLock 做带超时的锁获取、需要锁可中断那就必须用 ReentrantLock。典型场景是复杂的有界缓冲队列或者某些不允许无限期等待的限流场景。另外一定要提的一点是如果代码里能用并发容器、能用原子类尽量避免使用显式锁。ConcurrentHashMap、LongAdder这些工具类已经在内部做了精细的并发控制比自己加锁性能好得多。这也是一种软件设计意识的体现能用无锁方案就不用锁能缩小锁范围就缩小锁范围。这样分层去答面试官会觉得你对这块是有体系化理解的而不是背了一道题。我在复盘时专门跟学弟强调过很多八股题拉开差距的都不是答案本身而是组织答案的方式和覆盖的维度。4. “最牛技术问题”实战拆解连环追问是如何炼成的4.1 第一个坑锁的可重入与多方法调用模拟面试过半我开始放大招了。我说“刚才提到的第二版代码在transfer方法上加了synchronized然后我在transfer内部又调用了另一个synchronized方法会不会死锁”学弟差点被绕进去想了想才说不会因为synchronized是可重入的。这个问题问的就是可重入性的价值。所谓可重入是指同一个线程在外层方法获得锁之后进入内层方法若也要获取同一把锁会直接成功不会因为“自己锁住自己”而死锁。锁内部会记录持有线程和重入次数每次进入加一次计数每次退出减一次计数归零才真正释放锁。面试官为什么要问这个因为在真实的工程代码里一个同步方法调用另一个同步方法是非常常见的事。如果把Java的锁设计成不可重入开发人员就必须小心翼翼地在每次方法调用前判断锁是否已经持有否则极其容易死锁。可重入性是一个设计上的重大简化理解了它才能理解为什么没人愿意用底层的不可重入锁。4.2 第二个坑可见性余额为负与volatile的边界接下来我追问了一个我特别笃定他会踩坑的问题“如果balance用volatile修饰转账还安全吗”他几乎是条件反射地说“安全”这正好踩进了最常见的陷阱。volatile确实能保证可见性一个线程修改了balance其他线程能立刻看到最新值。但转账安全需要三个条件原子性、可见性、有序性。volatile保证不了check-then-act这个复合操作的原子性。两个线程同时读到余额100同时通过余额校验然后同时执行扣款。虽然volatile能保证他们读到的是最新值但在这两个线程的执行序列之间没有任何互斥机制阻止它们同时通过校验。最终结果还是扣了两次余额变成-20。这是高并发场景里最经典的“检查再操作”竞态条件。这里我建议所有准备面试的同学都记住一个结论volatile适合修饰状态标志位、适合做轻量级的可见性保证但绝不适合修饰参与复合计算的共享变量。判断一个变量是否适合用volatile就看对它所有的访问是否都是原子的单步操作。凡是“先读、再判断、再写”这种三步以上逻辑都必须交给锁或者原子类。4.3 第三个坑锁顺序与死锁的优雅解法“最牛技术问题”的第三个层次我重新回到了锁顺序。我设置了一个场景“两个线程同时转账一个从A转给B另一个从B转给A你们的实现会不会死锁”学弟的第一版不加锁的代码当然不会死锁但会数据错乱。他在第二版给整个方法加锁时也不会死锁因为整个方法串行化了但性能不可接受。到第三版按账户分别加锁时如果没有按固定顺序加锁就危险了。这里其实考的是一个递归上升的设计思路。面试官并不是真的要你当场发明死锁解决方案而是想看你能否逐步意识到锁顺序这个约束条件并且给出一个理论上能证明正确的方案。对于两个账户按账户ID固定顺序加锁是标准的解法这个结论在只有两个参与者时是最优雅的。如果有多个参与者那就要考虑按全局顺序对所有参与者排序或者引入锁超时机制比如tryLock加超时回滚这也是一些中间件在分布式锁里的常见做法。我当时引导学弟的时候特别强调了死锁排查不是靠运气而是靠约束。给锁的获取强加一个全序关系死锁的循环等待条件就不可能出现。这个思路在系统设计题里很值钱比死记硬背几个死锁案例有用得多。4.4 第四层追问业务如何补偿最后一层我把问题从代码层面拉到了业务层面也是我认为最能体现候选人综合能力的一层“代码层面保证并发安全就够了吗如果account服务调用链很长你在扣款之后、加款之前系统崩了用户的钱去哪里了”这正是转账场景最要命的“分布式事务”问题。在单机数据库场景下把扣款和加款放进同一个数据库事务要么都成功要么都回滚一致性由数据库保证。一旦拆成跨服务调用就没有一个全局的事务管理器能同时控制两个服务的事务了必须引入分布式事务方案。实习一面不会让你设计完整的分布式事务方案但每个候选人至少要知道几种主流思路两阶段提交2PC保证强一致但性能和可用性差TCCTry-Confirm-Cancel通过补偿来实现最终一致性本地消息表加消息队列做异步确保以及基于MQ的事务消息方案。在金融类场景里对账系统和幂等设计也是必须考虑的比如每笔转账生成唯一流水号消费端按流水号做去重避免重复加款。这个问题问完学弟明显感觉到自己的知识边界了。他说自己能理解问题的严重性但对解决方案只能说出“消息队列做补偿”这种非常笼统的话。这很正常分布式事务对实习生来说本来就是超纲的面试官通过这道题观察的是候选人面对未知问题时的思考路径能不能把问题拆解成“一致性、可用性、幂等性”这几个子问题能不能有逻辑地提出几种候选方案。知道自己不知道什么并且知道如何拆解本身就是一个很好的面试表现。5. 实习面试复盘与避坑建议5.1 手撕题没写完不代表面试失败模拟面试结束后学弟最焦虑的一个点是转账手撕的第一版代码太烂会不会直接挂掉。我给他的判断是不会而且很多拿到offer的人手撕代码都不是一遍写对的。面试官看手撕题核心看三点而不是看标准答案。第一点是思路的展开过程你先从暴力版本开始再一步步分析问题、逐层优化这种推进过程本身就展示了工程思维比一次写出终极版本更能说明问题。第二点是边界意识你有没有意识到余额不足、并发超扣、死锁这些边界和陷阱并且主动处理它们。第三点是表达能不能边写边讲让面试官听懂你每一步的想法。很多候选人恰恰死在“憋大招”上。他们在面试里一句话不说憋十分钟想写一个完美版本结果漏洞百出面试官想引导都没机会。正确做法是从一个能跑的基础版本开始一边写一边说“这里可能有个并发问题我先加个锁”把思考过程暴露出来面试官反而更容易给正面评价。5.2 我在复盘中发现的高频失误整理整场模拟面试的录音时我总结了几个实习生高频踩坑的点写在这里给大家提个醒。第一个是代码命名和结构问题。学弟写的Account类里balance直接public暴露没有用getter和setter封装这在工程里是无法接受的。类应该保持良好封装balance应该设计成不可直接修改只能通过debit和credit这类业务方法来改变这样并发控制的逻辑才能收敛到一个地方。面试手撕题虽然不需要写出完整的工程结构但好的命名、清晰的类职责、稳定的封装都是印象分。第二个是工具类使用不熟练。在需要保证原子加减的场景里很多人不知道可以用AtomicReference或AtomicLong。虽然这里的balance是BigDecimal不适合直接用AtomicLong但至少应该知道有AtomicReference这个工具它内部依赖CAS机制可以原子地更新对象引用。知识面这个东西面试时一旦露出来就很容易成为加分项。第三个是不敢说“不知道”。我问他分布式事务的时候他的第一反应是支支吾吾试图编一个答案这种感觉非常不好。面试官都是身经百战的你编没编他立刻就能察觉。正确策略是坦诚地说“这个场景我还没实操过但我理解的思路是xxx”然后展开自己的分析哪怕不完整也比硬编强得多。诚恳加逻辑思考永远比虚张声势更圈好感。5.3 给准备面试的Java同学最后一句大实话复盘到最后我给学弟说了一段话现在也送给读这篇文章的同学面试准备真正要做的不是背答案而是把每一个常见问题向下追问两三层直到逼近自己的知识边界然后把这个边界向外推一点点。认真过一遍转账这个问题你收获的绝不只是synchronized和ReentrantLock的区别而是集合了并发编程、JMM、数据库事务、分布式一致性的知识网几张网交织出这个领域的真正骨架。中间被问到不会的内容也别慌面试官能通过你处理未知问题的方式看到潜力和上限。恰恰是有好奇心、有自发探索精神、愿意做事后复盘的人在公司面试里更有优势。我今天写这篇图文并茂的复盘本意也是希望每个人都能通过一次模拟面试把自己在知识体系、代码功底、临场反应上的真实段位测出来再针对性地补课。
返回列表