从源码角度理解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抢占,如果成功就直接获得锁,不管同步队列里有没有等待线程。而公平锁的tryAcquire:
protected 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_LOCK(2次)。而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则拒绝。面试高频陷阱:corePoolSize=0,maximumPoolSize=较大值,且队列为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 WeakReference<ThreadLocal<?>> { 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对象被设为null,ThreadLocalMap中仍持有引用,导致无法回收ThreadLocal本身,泄漏更严重。弱引用+清理机制是权衡后的最佳实践。
8. CAS的ABA问题在源码层面如何解决?AtomicStampedReference的底层原理
CAS(Compare And Swap)在Java中通过Unsafe类的compareAndSwapObject实现,但存在ABA问题:一个值从A变为B再变回A,CAS检查时认为没有变化。AtomicStampedReference通过额外维护一个整数时间戳来解决。核心数据结构:
private volatile Pair<V> pair; private static class Pair<T> { 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(即state>0)。关键点:AQS的共享模式支持多个线程同时被唤醒,而独占模式只能唤醒头节点。State的作用在CountDownLatch中非常纯粹:大于0时所有acquireShared线程阻塞,等于0时全部释放。与CyclicBarrier不同,CountDownLatch是一次性的,不能reset。
10. 活锁与死锁在源码层面如何避免?Lock提供的tryLock超时设计
死锁通常通过锁的有序获取来避免,但JUC的ReentrantLock提供了tryLock(long time, TimeUnit unit)方法,源码调用AQS的tryAcquireNanos:
public 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下的核心类,你会发现面试题里所有“为什么”都有了答案。