简介:本文从操作系统资源调度的底层视角出发,系统梳理进程与线程的核心差异,并深入剖析Java内置锁Synchronized的字节码实现、锁升级路径与内存语义,帮助开发者构建完整的并发编程知识体系,轻松应对高难度技术面试。
在Java后端开发者的面试中,并发编程几乎是无法回避的核心板块。很多候选人能够熟练背诵“进程是资源分配的单位,线程是CPU调度的单位”这类定义,但当面试官追问“代价差异具体体现在哪里”或“Synchronized在字节码层面如何运作”时,往往语焉不详。本文将剥离表面概念,从操作系统内核态与用户态的切换成本出发,结合HotSpot虚拟机的对象头与监视器(Monitor)机制,对线程模型与内置锁进行系统性的深度拆解。
进程是操作系统进行资源分配和隔离的基本单位。每个进程拥有独立的地址空间、文件描述符表、信号处理器以及全局变量等。从内核视角看,进程是一个数据结构(如Linux中的task_struct),它完整描述了一个执行环境的全部上下文。进程的核心价值在于强隔离性,一个进程的崩溃通常不会直接影响其他进程,这也使得多进程模型在稳定性要求极高的场景(如数据库主从架构)中依然占据一席之地。
然而,隔离的代价是昂贵的。创建一个进程需要分配独立的虚拟地址空间,复制父进程的页表,初始化各种内核数据结构。更关键的是,进程间的上下文切换需要保存和恢复全部的寄存器状态、程序计数器、栈指针,并且要刷新TLB(旁路转换缓冲),这些操作都涉及用户态到内核态的陷入(trap)以及内核态到用户态的返回,开销极大。实验数据表明,进程切换的耗时通常在微秒级别,而线程切换仅在纳秒级别,二者相差约一个数量级。
线程是进程内部的执行路径,它“寄生”于进程的地址空间中。同一进程内的多个线程共享代码段、数据段、堆以及打开的文件资源,但每个线程拥有独立的程序计数器、寄存器集合和栈空间。这种设计带来了两个显著优势:
但共享也意味着风险——数据竞争。当多个线程同时修改共享变量时,由于现代CPU的多级缓存架构(L1/L2/L3)与指令重排序优化,一个线程对变量的修改可能延迟刷新到主内存,导致其他线程读到“过期值”。这正是并发编程中可见性与有序性问题的根源。
理解Java线程必须明确其底层映射。在主流JDK(如OpenJDK 8+)的实现中,java.lang.Thread实例通过JNI调用原生线程库(pthread),最终与操作系统内核线程建立一对一的映射关系。这意味着每个Java线程的阻塞与唤醒,本质上都是内核线程的阻塞与唤醒,必然伴随用户态与内核态的切换。这一事实是理解Synchronized性能瓶颈的关键前提——早期的重量级锁之所以“重”,正是因为它直接调用了操作系统提供的互斥量(mutex)与条件变量(condition variable)。
Synchronized是Java语言内置的同步机制,它通过字节码指令和对象监视器(Monitor)实现临界区的互斥访问。其演进过程是JVM优化的典型案例,从JDK 1.6之前“每次加锁都直接调用操作系统mutex”的笨重实现,逐步进化为如今偏向锁、轻量级锁与重量级锁三级联动的自适应锁策略。
使用javap对包含synchronized代码块的方法进行反编译,可以看到编译器在同步块的入口插入monitorenter指令,在正常出口和异常出口各插入monitorexit指令(异常路径是编译器自动生成的指令,确保锁必将释放)。对于synchronized修饰的实例方法,JVM会在方法标志位(access_flags)中设置ACC_SYNCHRONIZED,当方法调用时,调用指令会检查该标志,并隐式执行monitorenter和monitorexit操作,锁对象是当前实例(this);静态方法对应的锁对象是当前类的Class对象。
monitorenter的执行逻辑如下:
monitorexit则是将计数器减1,仅当计数器归零时,锁才真正释放,并唤醒等待队列中的线程。
锁状态的记录存储在Java对象的对象头中。在64位JVM下,对象头中的Mark Word占据64位(8字节),其存储结构根据锁状态动态变化:
针对这一结构的演变,可以梳理出加锁的完整路径:
第一步:偏向锁(Biased Locking):JVM认为大多数锁在生命周期内仅被单个线程持有。当线程A首次进入同步块时,JVM通过CAS操作将Mark Word中的线程ID设置为A,此后A再进入时,无需任何同步操作,仅比对线程ID即可通过。这消除了无竞争情况下的所有同步原语开销。
第二步:轻量级锁(Lightweight Locking):当线程B尝试获取该锁时,发现偏向标志位为1且线程ID不是自身,说明发生竞争。此时JVM撤销偏向锁,并在线程A的栈帧中分配锁记录(Lock Record)空间,通过CAS将对象头的Mark Word复制到锁记录中,并将Mark Word更新为指向锁记录的指针。若CAS成功,线程A持有轻量级锁;失败则说明有多个线程竞争,锁膨胀为重量级锁。
第三步:重量级锁(Heavyweight Locking):锁膨胀后,JVM申请一个ObjectMonitor结构体。该结构体内部包含:
锁膨胀完成后,后续所有竞争线程通过进入内核态的mutex进行阻塞和唤醒,这是性能开销最大的阶段。JDK 1.6之后的锁自旋优化(适应性自旋)在一定程度上缓解了该问题——当竞争线程在短时间内等待时,会执行有限次数的空循环(自旋),期间不释放CPU,避免了频繁的用户态/内核态切换。但自旋只适用于临界区执行时间极短的场景,且自旋本身消耗CPU周期。
从Java内存模型(JMM)的视角看,Synchronized天然满足两个核心特性:
值得注意的是,Java 5之后引入的java.util.concurrent包中的显式锁(Lock接口及其实现类)在内存语义上与Synchronized完全一致,但提供了更丰富的功能(如可中断获取锁、超时获取锁、公平锁/非公平锁切换)。在实际开发中,若仅需要基础的互斥与可见性保障,优先推荐使用Synchronized,因为JVM会不断对其进行优化(偏向锁、锁消除、锁粗化等),且使用更简洁。
面试官:为什么说Synchronized是“排它锁”?
回答要点:同一时刻仅允许一个线程进入临界区,其他线程必须阻塞等待。这在读写比为读多写少时十分低效,因此在高并发读操作场景下,应优先考虑ReadWriteLock或StampedLock。
面试官:请谈谈锁升级的过程,以及偏向锁是否总有效?
回答要点:偏向锁适用于竞争几乎不存在的场景。若程序刚启动时存在大量竞争,JVM会通过启动参数中的-XX:-UseBiasedLocking关闭偏向锁,以减少撤销操作的额外开销。现代JVM会基于运行数据动态调整策略,即自适应的锁偏向。
面试官:wait()和notify()与Synchronized的关系?
回答要点:调用wait()必须持有锁,该调用会释放当前Monitor的所有权并将线程放入WaitSet;notify()会从WaitSet中随机唤醒一个线程到EntryList,notifyAll()则唤醒全部。注意,被唤醒的线程不会立刻执行,必须等待唤醒线程释放锁后与其他竞争线程重新竞争。
尽管锁机制不断优化,但无竞争状态下的Synchronized加锁开销已极低(据估算,偏向锁加锁耗时约几十纳秒),但在高并发高争用场景下,重量级锁依然会带来显著的上下文切换成本。实践中建议遵循以下原则:
进程与线程的区别映射了操作系统在“资源隔离”与“执行效率”之间的平衡决策,而Synchronized则是Java在“语言级易用性”与“底层性能开销”之间不断调优的产物。从偏向锁到重量锁的升级路径,展示了JVM如何基于运行时数据动态选择最优同步策略。掌握这些原理,不仅有助于通过面试中的深度追问,更能指导实际开发中做出正确的并发设计决策——在正确的位置使用正确的同步机制,在保证安全性的同时最大化系统吞吐量。