技术演进中的开发沉思-367:锁机制(上)

上篇聊完了轻量级的volatile关键字,就该说说并发编程中更"重量级"、也更强大的同步工具------锁机制。在我们老程序的并发工具箱里,volatile是"轻骑兵",适合简单的状态同步场景;而锁就是"重装部队",适合更复杂的并发场景,比如多线程读写、复合操作同步、线程间协作等。和volatile一样,锁的核心价值也离不开内存语义的支撑,但锁的内存语义更全面、更严格,它不仅能解决可见性、重排序问题,还能完美解决volatile无力应对的原子性问题------这也是锁能成为并发编程"万能钥匙"的核心原因。

早年刚接触锁的时候,我总觉得它"笨重",觉得加锁会牺牲性能,总想用volatile替代锁,直到一次次踩坑才明白:锁的"笨重"背后,是更可靠的同步保障;它的内存语义,是volatile的延伸和强化,吃透锁的内存语义,才能真正分清锁和volatile的适用场景,避免"用轻量工具解决复杂问题"的尴尬,也才能写出既安全又高效的并发代码。

锁的内存语义,和volatile的内存语义有相似之处------本质上都是规定了共享变量在主内存和线程本地内存之间的操作规则,确保线程间的共享数据可见、有序、一致。但不同的是,volatile的内存语义只针对自身修饰的单个变量,而锁的内存语义则针对"整个临界区"的所有共享变量;volatile不保证复合操作的原子性,而锁通过"排他性",保证了临界区内所有操作的原子性。

锁的内存语义,同样可以概括为三点,这三点相互关联、层层递进,每一点都藏着底层的实现逻辑,每一点都有我们实战中踩过的坑,逐一吃透,才能真正读懂锁的"工作原理",用好锁这个强大的同步工具。

一、锁释放

锁释放的内存语义,和volatile写的内存语义有相似之处,但要求更严格:当线程执行锁释放操作(比如synchronized的解锁、Lock的unlock()方法)时,JMM会强制要求,该线程在持有锁期间,所有修改过的共享变量(无论是否被volatile修饰),都必须从线程本地内存中刷新到主内存中。也就是说,锁释放操作,相当于一个"全局刷新信号"------不仅刷新当前线程修改的共享变量,还会确保这些修改被主内存接收,为后续获取锁的线程提供最新的共享数据。

这里要注意,锁释放的刷新范围,是"整个临界区"的共享变量,而不是某个单个变量------这是它和volatile写最核心的区别之一。volatile写只能刷新自身修饰的变量,而锁释放会刷新该线程在持有锁期间所有修改过的共享变量,无论这些变量是否被其他同步工具修饰。

早年我写一个多线程订单修改程序时,曾踩过一个锁释放相关的坑。当时用synchronized锁保护临界区,临界区内修改了两个共享变量:订单状态和订单金额,其中订单状态被volatile修饰,订单金额没有。我以为只要订单状态被volatile修饰,就能保证可见性,所以在解锁后没有额外处理,结果发现,偶尔会出现其他线程拿到锁后,能读到最新的订单状态,却读不到最新的订单金额------后来才明白,是我误解了锁释放的内存语义。

排查后发现,当时我在临界区内修改了订单金额后,没有确保它被刷新到主内存,而我错误地认为"volatile修饰的订单状态刷新了,订单金额也会跟着刷新"。实际上,只有锁释放操作,才能强制刷新所有临界区内修改的共享变量;如果没有执行锁释放,哪怕有volatile修饰的变量,其他变量的修改依然可能"藏在"线程本地内存中,无法被其他线程看到。后来我规范了锁的使用,确保每次修改完共享变量后,都正常执行锁释放操作,问题就彻底解决了。

还要强调一点,锁释放的"强制刷新",是JMM的强制要求,无论CPU架构如何优化,都必须遵守这个规则。这和volatile写在x86平台的优化不同------volatile写在x86平台可以简化内存屏障,但锁释放的内存屏障插入策略,在任何平台都不会简化,必须确保所有共享变量的修改都被刷新到主内存,这也是锁的内存语义比volatile更严格的体现。

对于我们老程序来说,理解锁释放的内存语义,就能明白"锁为什么能保证临界区修改的可见性"------不是因为锁本身有特殊的刷新机制,而是锁释放操作强制刷新了所有临界区内的共享变量,从根源上解决了"修改不刷新"的可见性问题。这也提醒我们:写并发代码时,一定要确保"修改共享变量后,正常释放锁",绝不可以中途退出、跳过锁释放操作,否则会导致共享数据不一致,出现诡异的并发bug。

二、 锁获取

锁获取的内存语义,和volatile读的内存语义相似,但同样要求更严格:当线程执行锁获取操作(比如synchronized的加锁、Lock的lock()方法)时,JMM会强制将该线程的本地内存置为无效状态;之后,该线程在临界区内读取任何共享变量(无论是否被volatile修饰),都不能从自己的本地内存中读取,必须直接从主内存中读取最新的值。

和锁释放相对应,锁获取的无效范围,也是"整个临界区"的共享变量------只要线程获取了锁,它在临界区内读取的所有共享变量,都会从主内存中重新读取,确保读到的是其他线程释放锁时刷新的最新值。而volatile读,只能让自身修饰的变量从主内存读取,其他普通变量依然可能从本地内存读取旧值,这也是锁获取比volatile读更严格的地方。

我记得早年做一个多线程库存扣减程序时,曾遇到过一个诡异的问题:多个线程竞争锁,其中一个线程扣减库存后,正常释放了锁,但下一个获取锁的线程,有时候会读到扣减前的旧库存,导致库存扣减重复,出现超卖的情况。排查了很久才发现,问题出在锁获取的内存语义上------当时我用的是自定义锁,在实现lock()方法时,没有正确实现"本地内存置为无效"的逻辑,导致线程获取锁后,依然从本地内存中读取库存变量的旧值,没有从主内存中读取最新值。

后来我查阅了JMM的相关规范,才明白锁获取的核心要求:必须强制置空线程本地内存,确保临界区内的所有共享变量都从主内存读取。修改自定义锁的实现后,这个bug就彻底解决了------线程获取锁后,本地内存被置为无效,只能从主内存读取最新的库存值,再也没有出现过超卖的情况。

这里有一个容易被年轻程序员忽略的细节:锁获取的"本地内存置为无效",是针对该线程的整个本地内存,而不是某个单个变量的副本------这和volatile读只置空自身变量副本完全不同。也就是说,只要线程获取了锁,它本地内存中所有共享变量的副本都会被置为无效,任何共享变量的读取,都必须重新从主内存获取,这就确保了临界区内所有共享变量的可见性。

我们老程序在实战中,经常会遇到"获取锁后依然读不到最新值"的bug,很多时候就是因为没有理解锁获取的内存语义------误以为只要获取了锁,就能自动读到最新值,却不知道如果锁的实现不规范,没有置空本地内存,依然会读到旧值。这也提醒我们:在使用自定义锁时,一定要严格遵循JMM的要求,确保锁获取的内存语义生效;如果使用Java内置的锁(比如synchronized、ReentrantLock),则不需要担心这个问题,因为JDK已经帮我们实现了规范的内存语义。

三、 通信本质

理解了锁释放和锁获取的内存语义后,我们就能看透锁的核心通信本质------和volatile类似,锁实现线程间通信,也是以主内存为"中间载体",但不同的是,volatile的通信是"单变量消息传递",而锁的通信是"临界区消息传递":释放锁的线程,通过锁释放操作,将临界区内所有共享变量的最新修改,刷新到主内存(发送消息);获取锁的线程,通过锁获取操作,从主内存读取所有共享变量的最新值(接收消息),从而完成线程间的通信。

锁的通信本质,比volatile更强大、更全面------volatile只能传递单个变量的"值消息",而锁能传递整个临界区内所有共享变量的"状态消息";volatile的通信是"无排他性"的(多个线程可以同时读),而锁的通信是"排他性"的(同一时刻只有一个线程能进入临界区,确保消息传递的有序性和完整性)。

举个通俗的例子,这就像一个办公室(主内存),里面有多个文件(共享变量),多个员工(线程)需要查阅和修改这些文件。锁就相当于办公室的钥匙(排他性),只有拿到钥匙的员工,才能进入办公室修改文件;员工修改完所有文件后,离开办公室(释放锁),会把所有修改后的文件放回原位(刷新到主内存),相当于发送了"文件已更新"的消息;下一个拿到钥匙的员工(获取锁),进入办公室后,会直接查阅原位的文件(从主内存读取),拿到的都是最新修改后的版本,相当于接收了上一个员工发送的消息。

早年我写一个多线程任务协作程序时,就用锁的通信本质,解决了线程间的"状态同步"问题。程序中有两个线程:线程A负责处理任务数据(修改多个共享变量),线程B负责统计任务处理结果(读取多个共享变量)。一开始没有用锁,线程A修改的数据无法及时被线程B看到,统计结果一直出错;后来我用synchronized锁将线程A的处理逻辑和线程B的统计逻辑,分别保护在临界区内,线程A处理完数据后释放锁(发送消息),线程B获取锁后读取数据(接收消息),统计结果就变得准确了。

这里要强调一点,锁的通信本质,依赖于"锁的排他性"------正因为同一时刻只有一个线程能持有锁,进入临界区,才能保证"释放锁→获取锁"的顺序性,确保消息传递的完整性和有序性。如果锁没有排他性(比如读写锁的读锁,可以多个线程同时持有),则需要额外的机制保证通信的正确性,但即使是读写锁,写锁的释放和读锁的获取,依然遵循锁的内存语义,确保写操作的修改能被读操作看到。

还有一个容易被误解的点:锁的通信,不仅限于"同一个锁"的释放和获取------只要多个线程竞争的是同一个锁,无论这些线程是在同一个方法、同一个类中,还是在不同的类中,释放锁的线程修改的共享变量,都能通过主内存,被后续获取该锁的线程看到。当年我曾误以为"不同方法中的锁,无法实现线程通信",后来在实战中验证才明白,锁的通信本质是"主内存作为载体",只要是同一个锁,无论在哪里使用,都能实现线程间的消息传递。

最后小结

锁的内存语义,是锁实现同步保障的底层根基:锁释放的"全局刷新",确保了临界区内所有共享变量的修改被主内存接收;锁获取的"本地无效",确保了临界区内所有共享变量的读取都是最新值;而两者结合形成的"临界区消息传递",则是锁实现线程间通信的核心本质。和volatile相比,锁的内存语义更严格、更全面,能解决更复杂的并发问题,但也带来了更高的性能开销------这也是我们老程序在使用锁时,必须权衡的点:在保证并发安全的前提下,尽量减少锁的持有时间,避免不必要的性能损耗。

相关推荐
大黄说说2 小时前
FFmpeg 核心架构解析:关键数据结构的初始化流程
开发语言
Go_Zezhou2 小时前
render网站保存历史记录错误解决
开发语言·git·python·html
BigGGGuardian2 小时前
写了个 Spring Boot 防重复提交的轮子,已发到 Maven Central
java
ShoreKiten2 小时前
Upload-labs 高版本php环境非完全攻略
开发语言·php
hewence12 小时前
协程间数据传递:从Channel到Flow,构建高效的协程通信体系
android·java·开发语言
哈库纳2 小时前
dbVisitor 利用 queryForPairs 让键值查询一步到位
java·后端·架构
hoiii1872 小时前
拉丁超立方抽样(LHS)的MATLAB实现:基本采样与相关采样
开发语言·算法
~央千澈~2 小时前
抖音弹幕游戏开发之第6集:解析JSON数据·优雅草云桧·卓伊凡
开发语言·python·php
郝学胜-神的一滴2 小时前
深入解析Python中dict与set的实现原理
开发语言·python