【Java从入门到精通】第21篇:线程安全的不可变基石——volatile的可见性保证与synchronized的锁升级路径
目录
四、synchronized的锁升级路径:从偏向锁到重量级锁
一、可见性问题的根源:Java内存模型的分层架构
在单线程程序中,读写一个变量是理所当然的——写进去的值,下次读出来一定是刚才写的值。但在多线程环境下,这个理所当然被打破了。一个线程修改了共享变量的值,另一个线程可能永远看不到这个修改。
这种现象的根源在于Java内存模型的分层架构。JMM为每条线程分配了独立的工作内存——线程在读取共享变量时,先从主内存拷贝一份到自己的工作内存中,后续对该变量的读写都在工作内存的副本上进行,只在特定时刻将修改后的值刷新回主内存。线程之间不共享工作内存,一条线程无法看到另一条线程工作内存中的变量值。
这种设计不是JVM的缺陷,而是性能优化的必要手段。CPU的寄存器和各级缓存是工作内存的物理基础——如果每次读写变量都直接操作主内存,程序的执行速度将被内存访问延迟拖慢数个数量级。工作内存让线程在大多数时间内与高速缓存交互,只在必要时才与主内存同步。
volatile关键字正是为打破这种“缓存的隔离”而设计的。当一个变量被声明为volatile后,任何对该变量的写操作都会立即刷新到主内存,任何对该变量的读操作都会直接从主内存获取最新值——线程的工作内存中不再保留volatile变量的缓存副本。这就是volatile保证的可见性。
二、Happens-Before规则:可见性的形式化保障
volatile保证可见性并非通过模糊的“尽快刷新”来实现,而是由Java内存模型中严格定义的Happens-Before规则所保证。
Happens-Before规则定义了两个操作之间的偏序关系。如果操作A Happens-Before操作B,那么操作A的结果对操作B可见,且操作A在时间上先于操作B。这条规则是并发程序正确性的理论基础——编写并发代码时,通过确保关键操作之间存在Happens-Before关系来保证数据的一致性。
volatile的Happens-Before规则简洁而精确:对一个volatile变量的写操作,Happens-Before于后续对同一个volatile变量的读操作。这意味着写线程在写入volatile变量之前对共享变量所做的所有修改,在读线程读取这个volatile变量之后都可见——volatile变量的写入和读取不仅同步了它自身的值,还在同步点一次性同步了写线程工作内存中所有已修改的变量。
这一原理在并发编程中催生了一个经典用法:使用一个volatile布尔变量作为标志位,写线程在修改完所有共享数据后将标志位设为true,读线程先循环检查标志位,一旦读到true就从主内存获取写线程的全部修改。
三、volatile不能保证原子性:复合操作的非原子陷阱
volatile解决了可见性问题,但它不保证原子性。对一个volatile变量的自增操作——看似只是i++,实际上是三个独立操作的组合:读取当前值,计算加一,写入新值。即使变量是volatile的,读取和写入各自是原子的,但两者之间的计算窗口内,另一个线程可能已经读取了同样的旧值并进行了自己的计算。两个线程各执行一次自增,最终结果可能只增加了1而非2。
这就是复合操作的非原子性。volatile适用于布尔标志位、状态标记等单一读写操作,不适用于需要依赖当前值计算新值的递增、累加等复合操作。对于复合操作,需要synchronized或原子变量类来保证原子性。
四、synchronized的锁升级路径:从偏向锁到重量级锁
在JDK 6之前,synchronized直接映射为操作系统的互斥量——每次获取锁都涉及系统调用和线程上下文切换,在锁竞争不激烈时这个开销非常不划算。JDK 6引入了一系列锁优化,将synchronized从单一的重度锁转变为根据竞争程度自适应升级的多级锁体系。
偏向锁是锁的最低开销形态。当一个线程反复获取同一把锁时——例如循环中的同步块——偏向锁在第一次获取时在对象头中记录该线程的ID,此后该线程再次进入同步块时只需检查对象头中的线程ID是否为自己,无需任何CAS操作或系统调用。偏向锁的设计假设是“锁通常由同一个线程反复获取”,大多数同步块在生命周期内只有一个线程访问。
当另一个线程尝试获取同一把偏向锁时,偏向锁被撤销,升级为轻量级锁。轻量级锁通过CAS操作在对象头中设置指向当前线程栈帧中锁记录的指针。获取锁的线程执行CAS尝试将对象头中的指针指向自己的锁记录,成功的线程获得锁。未获得锁的线程进入自旋——在循环中反复尝试CAS获取锁,而非立即挂起。轻量级锁的设计假设是“锁竞争不激烈,获取锁的线程很快就会释放锁”。自旋的线程在等待短暂时间后通常能成功获取锁,避免了线程挂起的沉重开销。
当自旋超过一定次数仍无法获取锁,或者锁竞争激烈到多个线程同时自旋,轻量级锁膨胀为重量级锁。重量级锁将未获取锁的线程挂起进入BLOCKED状态,等待操作系统调度。当锁被释放时,操作系统从等待队列中唤醒线程。重量级锁涉及系统调用和线程上下文切换,开销最大,是竞争激烈场景下不得已的选择。
五、锁消除与锁粗化:编译器的静默优化
JVM在运行时不仅执行锁升级,还通过锁消除和锁粗化两种优化降低不必要的锁开销。
锁消除是JIT编译器的一项优化。当编译器分析代码发现一个同步块中的锁对象不可能被多个线程共享时——例如一个在方法内部创建、没有逃逸到外部的局部变量——编译器直接删除这个同步块的锁获取和释放指令。开发者不会刻意对局部变量加锁,但使用StringBuffer等线程安全类时,每个append调用内部都有同步。编译器通过逃逸分析识别出该StringBuffer对象不会离开当前方法后,消除其所有同步操作。
锁粗化是另一项优化。当一个方法中包含多个相邻的、对同一把锁的同步操作时——例如循环中反复append的StringBuffer——编译器将这些细粒度的同步合并为一个粗粒度的同步块,减少锁获取和释放的次数。一次获取锁、执行多次操作、一次释放,比多次获取和释放的总开销更低。
六、synchronized与Lock的选择
在JDK 6的锁优化之后,synchronized的性能已经与java.util.concurrent.locks.Lock接口的实现类相差无几。在多数场景下,synchronized以更简洁的语法提供了同等的性能。
Lock接口提供了synchronized不具备的能力——非阻塞获取锁、可中断获取锁、超时获取锁。这些特性在需要精细控制锁获取行为的场景中不可替代。公平锁保证等待时间最长的线程优先获取锁,在高竞争场景中避免线程饥饿。
选择的原则是:优先使用synchronized,代码更简洁且不易出错。只在需要Lock的独占特性——可中断、超时、公平锁——时才使用ReentrantLock等Lock实现。
七、结语
volatile与synchronized分别解决了并发编程中两个独立但互补的问题。volatile以内存屏障保证可见性,但不保证原子性,适用于单一读写操作的状态同步。synchronized以监视器锁保证原子性和可见性,在JDK 6的偏向锁、轻量级锁、重量级锁三级升级和编译器的锁消除、锁粗化优化之后,性能开销已经大幅降低。
理解volatile的可见性边界和synchronized的锁升级路径,是正确使用这两项基础并发机制的起点。下一篇,我们将进入JUC并发工具库——Lock、Condition与读写锁如何提供比synchronized更精细的并发控制能力。
更多推荐



所有评论(0)