从源码角度理解Java并发编程面试题

发布时间:2026/7/6 13:10:59
从源码角度理解Java并发编程面试题 当面试官问你“synchronized在JDK 1.6之后做了哪些优化”你只答出偏向锁、轻量级锁、重量级锁就算及格但要从源码层面讲清楚锁升级过程才是加分项。面试的本质不是背诵概念而是考察你对底层实现的理解是否穿透到HotSpot虚拟机源码那一层。今天我们从源码角度解剖五道高频并发面试题每一题都直击JUC源码的实现细节。1. synchronized的锁升级究竟在源码里怎么标记很多人以为锁升级是JVM自动判断的但具体如何标记却说不清。在HotSpot中每个对象的对象头Mark Word里存储了锁状态信息。JDK 8中Mark Word的位模式如下无锁0x01偏向模式位为0偏向锁线程ID epoch 偏向模式位1 锁标志位01轻量级锁指向栈中锁记录的指针 锁标志位00重量级锁指向监视器Monitor的指针 锁标志位10关键在于锁标志位最后2位。当线程第一次尝试获取偏向锁时源码中ObjectSynchronizer::fast_enter会判断对象是否可偏向。如果偏向锁撤销次数超过阈值默认20次JVM会禁用偏向锁直接升级为轻量级锁。这个阈值在源码biasedLocking.hpp中定义为BiasedLockingBulkRebiasThreshold和BiasedLockingBulkRevokeThreshold。轻量级锁通过CAS在栈帧中创建锁记录Lock Record若CAS失败则膨胀为重量级锁——调用inflate函数创建ObjectMonitor。重量级锁的等待线程会进入ObjectMonitor的_EntryList和_WaitSet这直接对应操作系统层面的mutex。2. volatile如何实现可见性内存屏障在源码里怎么插volatile的可见性靠内存屏障Memory Barrier保证。在HotSpot源码中volatile写操作会在指令序列后插入store-load屏障volatile读操作会在指令序列前插入load-load屏障。源码位于orderAccess.hpp和orderAccess.cpp中例如x86架构下inline void OrderAccess::storeload() { fence(); } inline void OrderAccess::loadload() { acquire(); }但更为关键的是JVM在生成字节码时会对volatile字段的访问加上ACC_VOLATILE标志C1/C2编译器在JIT阶段根据该标志插入内存屏障。底层对应的是lock前缀指令x86该指令会锁住总线并刷新缓存行。面试常问“volatile能保证原子性吗”绝不能。源码中volatile只保证可见性和禁止指令重排序不保证复合操作如i的原子性。若需要原子性必须配合CAS通过Unsafe的compareAndSwapInt实现。Unsafe类的compareAndSwapInt是native方法最终调用Atomic::cmpxchg底层使用lock cmpxchg指令。3. AQS的同步队列和条件队列如何协作源码中state的作用是关键抽象队列同步器AQS是JUC锁与工具的基石。state是核心变量被volatile修饰用于表示资源状态0表示无锁1表示加锁。当线程获取锁失败时会加入同步队列CLH变体。源码acquire方法public final void acquire(int arg) { if (!tryAcquire(arg) acquireQueued(addWaiter(Node.EXCLUSIVE), arg)) selfInterrupt(); }addWaiter将线程包装成Node节点通过CAS插入队列尾部。队列头部的节点即拥有锁的线程。当持有锁的线程释放锁时会唤醒后继节点unparkSuccessor。条件队列Condition与同步队列不同它通过await和signal操作维护一个独立的单向链表。一个锁可以对应多个条件队列但每个条件队列的节点最终被转移到同步队列才能竞争锁。源码await方法会创建一个Node.CONDITION节点加入条件队列然后释放锁signal则将条件队列头节点转移到同步队列通过transferForSignal。关键点AQS的设计避免了线程粒度的频繁阻塞只在必要时才park极大减少了上下文切换。4. ReentrantLock的公平锁与非公平锁在源码里只有一行CAS的差异ReentrantLock内部通过Sync继承AQS实现两把锁FairSync和NonfairSync。非公平锁的lock方法final void lock() { if (compareAndSetState(0, 1)) setExclusiveOwnerThread(Thread.currentThread()); else acquire(1); }非公平锁一上来就尝试CAS抢占如果成功就直接获得锁不管同步队列里有没有等待线程。而公平锁的tryAcquireprotected final boolean tryAcquire(int acquires) { final Thread current Thread.currentThread(); int c getState(); if (c 0) { if (!hasQueuedPredecessors() compareAndSetState(0, acquires)) { setExclusiveOwnerThread(current); return true; } } ... }关键差异在hasQueuedPredecessors()它检查队列中是否有比当前线程等待更久的节点。如果有即使state为0也不抢占老老实实排队。面试常问“非公平锁的性能为什么更好”因为非公平锁减少了线程唤醒的延迟但可能导致“饥饿”。但源码显示即使是非公平锁当CAS失败进入acquire后也会按照队列顺序来所以并非完全无序。5. ConcurrentHashMap的size()方法在JDK 1.8和1.7中实现迥异如何保证一致性JDK 1.7的ConcurrentHashMap使用Segment数组每个Segment内部维护一个HashEntry数组size()通过遍历所有Segment并累加modCount如果两次累加结果不一致则加锁。源码中通过sumCount()方法使用tryLock尝试获取Segment锁最多重试RETRIES_BEFORE_LOCK2次。而JDK 1.8抛弃了Segment使用CounterCell数组分散计数竞争public int size() { long n sumCount(); return ((n 0L) ? 0 : (n (long)Integer.MAX_VALUE) ? Integer.MAX_VALUE : (int)n); }sumCount()遍历CounterCell数组并加上baseCount同时考虑扩容时的迁移状态。在put操作时如果CAS更新baseCount失败就会创建CounterCell并CAS更新。size()不要求绝对精确只能保证弱一致性因为并发put/remove时返回的值可能是几毫秒前的快照。但get()方法同样弱一致在1.8中get不锁通过Unsafe.getObjectVolatile直接读Node数组但读到的可能不是最新值。如果你需要强一致性请使用ConcurrentHashMap的compute方法或结合lock。6. 线程池的corePoolSize和maximumPoolSize代码中是怎么判断的ThreadPoolExecutor的execute方法源码清晰展示了线程池工作策略public void execute(Runnable command) { int c ctl.get(); if (workerCountOf(c) corePoolSize) { if (addWorker(command, true)) return; c ctl.get(); } if (isRunning(c) workQueue.offer(command)) { int recheck ctl.get(); if (! isRunning(recheck) remove(command)) reject(command); else if (workerCountOf(recheck) 0) addWorker(null, false); } else if (!addWorker(command, false)) reject(command); }当工作线程数小于corePoolSize即使其他线程空闲也会创建新线程注意addWorker(command, true)中第二个参数true表示按corePoolSize校验。如果core线程数已满则尝试加入阻塞队列如果队列已满则尝试创建新线程直到maximumPoolSize如果超过maximumPoolSize则拒绝。面试高频陷阱corePoolSize0maximumPoolSize较大值且队列为SynchronousQueue时会发生什么每次任务都会直接尝试创建线程因为队列无法容纳任何元素offer立即失败所以会直接创建新线程直到最大值然后拒绝。源码中SynchronousQueue的offer返回false导致走向else if (!addWorker(command, false))分支。理解ctl变量的设计也很重要它用一个AtomicInteger的高3位表示线程池状态RUNNING/SHUTDOWN/STOP/TIDYING/TERMINATED低29位表示工作线程数。7. ThreadLocal内存泄漏的根源是什么Entry的弱引用是设计还是缺陷ThreadLocal的每个Thread内部有一个ThreadLocalMap其Entry的key是弱引用WeakReference 。源码中Entry定义static class Entry extends WeakReferenceThreadLocal? { Object value; Entry(ThreadLocal? k, Object v) { super(k); value v; } }key使用弱引用的目的是当ThreadLocal对象不再被外部强引用时GC可以及时回收key。但此时value仍被Entry强引用如果线程一直存活如线程池中的线程value就永远不会被回收造成内存泄漏。JDK的设计者在ThreadLocalMap的set、get、remove方法中会自动清理key为null的Entry例如private int expungeStaleEntry(int staleSlot) { ... tab[i] null; ... }但如果不调用remove且线程长期存活泄漏依旧存在。真正的解决方案是每次使用完ThreadLocal务必调用remove()。面试中经常问“为什么不用强引用”如果key是强引用即使ThreadLocal对象被设为nullThreadLocalMap中仍持有引用导致无法回收ThreadLocal本身泄漏更严重。弱引用清理机制是权衡后的最佳实践。8. CAS的ABA问题在源码层面如何解决AtomicStampedReference的底层原理CASCompare And Swap在Java中通过Unsafe类的compareAndSwapObject实现但存在ABA问题一个值从A变为B再变回ACAS检查时认为没有变化。AtomicStampedReference通过额外维护一个整数时间戳来解决。核心数据结构private volatile PairV pair; private static class PairT { final T reference; final int stamp; private Pair(T reference, int stamp) { this.reference reference; this.stamp stamp; } }每次修改时需要同时比较reference和stamp并且更新两者通过compareAndSet。源码中Unsafe的compareAndSwapObject只支持对引用对象进行CAS所以AtomicStampedReference通过unsafe.objectFieldOffset获取Pair字段的偏移量对整个Pair对象进行CAS。这种设计保证了“引用版本号”的原子性。面试官常问“AtomicMarkableReference和它有什么区别”后者只用一个boolean标记适合标记对象是否被修改过。9. CountDownLatch的await()为什么能阻塞多个线程底层只用了一个AQS共享模式CountDownLatch的构造器传入计数N内部Sync继承AQS并将state初始化为N。countDown()调用releaseShared(1)public final boolean releaseShared(int arg) { if (tryReleaseShared(arg)) { doReleaseShared(); return true; } return false; }tryReleaseShared通过CAS将state减1当state变为0时触发doReleaseShared该方法会唤醒等待队列中的所有线程。await()调用acquireSharedInterruptibly(1)内部检查tryAcquireShared返回是否小于0即state0。关键点AQS的共享模式支持多个线程同时被唤醒而独占模式只能唤醒头节点。State的作用在CountDownLatch中非常纯粹大于0时所有acquireShared线程阻塞等于0时全部释放。与CyclicBarrier不同CountDownLatch是一次性的不能reset。10. 活锁与死锁在源码层面如何避免Lock提供的tryLock超时设计死锁通常通过锁的有序获取来避免但JUC的ReentrantLock提供了tryLock(long time, TimeUnit unit)方法源码调用AQS的tryAcquireNanospublic final boolean tryAcquireNanos(int arg, long nanosTimeout) throws InterruptedException { if (Thread.interrupted()) throw new InterruptedException(); return tryAcquire(arg) || doAcquireNanos(arg, nanosTimeout); }doAcquireNanos会在循环中计算剩余等待时间使用LockSupport.parkNanos精确休眠超时后返回false。这是避免活锁和死锁的有效手段线程不会无限等待可以选择放弃或重试。此外显式的锁接口支持可中断获取lockInterruptibly源码中通过acquireInterruptibly在park期间响应中断从而打破死锁。面试时若提到“哲学家就餐问题”用tryLock配合随机退避能完美解决活锁。总结源码视角下的并发面试题本质是“看懂变量理解内存模型熟悉AQS框架”以上的每一道题都源自真实面试但只有从源码层面解析才能体现你对并发机理的透彻理解。不要停留在“背答案”要问自己这段代码在JVM里是如何变成指令的当你看到synchronized锁升级时脑海中要浮现Mark Word的位分布看到volatile要想到X86的lock前缀看到ReentrantLock要浮现AQS的CLH队列在内存中如何组织。这是区分“会用”和“懂”的分水岭。从今天起打开OpenJDK源码逐行阅读java.util.concurrent下的核心类你会发现面试题里所有“为什么”都有了答案。