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

在并发编程中,happens-before关系是一个非常核心、却又容易被年轻程序员忽略的概念。教科书上对它的定义很抽象:如果一个操作A happens-before 操作B,那么操作A的执行结果,必须对操作B可见,且操作A的执行顺序,必须在操作B之前。简单来说,happens-before关系,就是用来保证"操作顺序"和"可见性"的------只要两个操作之间存在happens-before关系,就不用担心操作顺序错乱、结果不可见的问题。

而锁机制,作为并发编程中最常用的同步工具,其核心内存语义,本质上就是通过happens-before关系来体现和保障的。对于锁来说,最关键的happens-before关系,就是围绕"锁释放"和"锁获取"展开的,具体可以概括为两条核心规则,这两条规则相互关联、相互支撑,也是我们老程序在写并发代码时,必须牢记于心的"同步准则"。

当年我刚接触这两条规则时,总觉得它们很简单,不就是"释放在前、获取在后"吗?直到一次次踩坑才明白,这背后藏着底层的内存语义逻辑,也藏着并发编程的"陷阱"------忽略了happens-before关系,哪怕加了锁,也可能出现并发bug;吃透了它,就能轻松避开很多不必要的麻烦,写出更稳定、更可靠的并发代码。

一、锁释放操作 happens-before

这是锁相关的happens-before关系中,最基础、最核心的一条规则。它的核心含义是:对于同一个锁而言,线程A执行的"锁释放"操作,happens-before 后续任何线程B执行的"锁获取"操作。也就是说,线程A释放锁之后,线程B获取到同一个锁,那么线程A在释放锁之前的所有操作结果,都必须对线程B可见;而且线程A释放锁的执行顺序,一定在线程B获取锁之前。

这条规则,其实就是锁的内存语义的"上层体现"------结合我们前文聊到的锁的内存语义,就能轻松理解它的底层逻辑:线程A释放锁时,会将自己本地内存中所有修改过的共享变量,全部刷新到主内存(锁释放的内存语义);而线程B获取锁时,会将自己的本地内存置为无效,从主内存读取所有共享变量的最新值(锁获取的内存语义)。happens-before关系,就是把这种底层的内存操作,转化为了直观的"操作顺序"和"可见性"保证。

我举一个当年踩过的坑,帮大家更好地理解这条规则。早年我写一个多线程用户余额修改程序,用synchronized锁保护临界区,线程A负责扣减用户余额(写操作),线程B负责查询用户余额(读操作),两者使用同一个锁。一开始我错误地认为,只要加了锁,线程B就能读到线程A修改后的最新余额,结果却发现,偶尔会出现线程A已经释放了锁,线程B获取锁后,读到的依然是扣减前的旧余额。

排查了很久才发现,问题出在"同一锁"这个关键前提上------当时我一时疏忽,线程A使用的是"用户对象锁"(synchronized(user)),而线程B使用的是"类锁"(synchronized(User.class)),虽然都是锁,但并不是同一个锁,因此"锁释放 happens-before 锁获取"的关系不成立。线程A释放用户对象锁时,刷新的共享变量(余额),线程B获取类锁后,并不会强制从主内存读取,依然可能读到本地缓存的旧值,导致查询结果错误。

后来我将两个线程的锁统一改为"用户对象锁",确保是同一个锁,这个bug就彻底解决了。这也让我深刻体会到:这条happens-before规则,有一个绝对不能忽略的前提------"同一锁"。如果是不同的锁,哪怕一个线程释放锁,另一个线程获取另一个锁,两者之间也不存在任何happens-before关系,自然也无法保证操作结果的可见性和顺序性。

还有一个容易被忽略的细节:这里的"后续",指的是"锁释放之后"的锁获取操作,而不是"时间上的后续"。也就是说,只要线程B的锁获取操作,是在线程A的锁释放操作之后发生的(无论时间上相隔多久),那么两者之间就存在happens-before关系。哪怕线程A释放锁后,过了几秒、甚至几分钟,线程B才获取同一个锁,线程B依然能看到线程A释放锁之前的所有操作结果------这就是happens-before关系的"跨时间"可见性保障。

对于我们老程序来说,这条规则是"加锁同步"的核心准则:只要使用同一个锁,释放锁之后的获取操作,就一定能看到释放锁之前的所有操作结果。这也提醒我们:写并发代码时,一定要确保"释放锁"和"获取锁"的是同一个锁,这是保证同步有效性的前提;否则,加了锁也等于白加,依然会出现并发bug。

二、传递性happens-before 获取锁后的操作

这是锁相关的happens-before关系的延伸,也是最实用、最能体现锁同步价值的一条规则。它的核心含义是:基于第一条规则(锁释放 happens-before 后续同一锁的获取),结合happens-before关系的传递性,我们可以推导出:线程A在释放锁之前执行的所有操作,都happens-before 线程B在获取同一个锁之后执行的所有操作。

要理解这条规则,我们可以先回顾一下happens-before关系的传递性:如果操作A happens-before 操作B,操作B happens-before 操作C,那么操作A happens-before 操作C。结合锁的场景,我们可以拆解为三步:

第一步,线程A在释放锁之前,执行了一系列操作(比如修改共享变量a、b、c),这些操作(记为操作A1、A2、A3),happens-before 线程A执行的"锁释放"操作(记为操作A4)------因为在同一个线程内部,所有操作都遵循"程序顺序执行"的happens-before关系(单线程内,前面的操作happens-before后面的操作)。

第二步,根据第一条规则,线程A的锁释放操作(A4),happens-before 线程B的锁获取操作(记为操作B1)。

第三步,线程B执行的"锁获取"操作(B1),happens-before 线程B在获取锁之后执行的所有操作(记为操作B2、B3、B4)------同样是因为单线程内的程序顺序happens-before关系。

根据传递性,操作A1、A2、A3 → A4 → B1 → B2、B3、B4,因此操作A1、A2、A3,都happens-before 操作B2、B3、B4。也就是说,线程B在获取锁之后,执行的所有操作,都能看到线程A在释放锁之前执行的所有操作结果------这就是传递性带来的核心价值。

这条规则,彻底打通了"不同线程之间的操作可见性"------通过同一个锁的"释放-获取",将两个线程的操作,串联成了一个有顺序、有可见性保障的"同步链路"。这也是锁机制能解决复杂并发场景的关键------它不仅能保证单个共享变量的可见性,还能保证整个临界区内所有操作的可见性和顺序性。

当年我写一个多线程订单处理系统时,就深刻体会到了这条规则的价值。系统中有两个核心线程:线程A负责处理订单(修改订单状态、扣减库存、记录日志),线程B负责更新订单统计数据(读取订单状态、读取库存、累加统计值),两者使用同一个锁。一开始我担心,线程A的多个修改操作,会不会有部分操作结果无法被线程B看到?后来吃透了这条传递性规则,我就彻底放心了。

按照传递性规则:线程A处理订单的所有操作(修改订单状态、扣减库存、记录日志),都happens-before 线程A释放锁;线程A释放锁 happens-before 线程B获取锁;线程B获取锁 happens-before 线程B更新统计数据的所有操作。因此,线程B更新统计数据时,一定能看到线程A处理订单的所有操作结果,不会出现"只看到部分修改"的情况。

但我也曾踩过一个和传递性相关的坑。当年我在一个多线程任务调度程序中,用同一个锁串联三个线程:线程A释放锁后,线程B获取锁执行操作,线程B释放锁后,线程C获取锁执行操作。我以为根据传递性,线程A的操作一定能被线程C看到,结果却发现,偶尔会出现线程C读不到线程A操作结果的情况。

排查后发现,问题出在"锁的释放不完整"------线程B在获取锁后,执行操作时出现了异常,没有正常释放锁,导致线程B的锁释放操作没有执行。这样一来,线程A的释放锁 happens-before 线程B的获取锁,但线程B的释放锁操作没有发生,因此线程A的操作和线程C的操作之间,没有形成完整的happens-before传递链路,线程C自然无法保证看到线程A的操作结果。

后来我在代码中加入了异常处理,确保线程B无论是否出现异常,都会正常释放锁,这个bug就彻底解决了。这也让我深刻体会到:要保证happens-before关系的传递性,必须确保"锁释放-锁获取"的链路完整------每一个获取锁的操作,都必须有对应的、前置的锁释放操作;如果有一个环节的锁释放失败,整个传递链路就会断裂,同步保障也会失效。

对于我们老程序来说,这条传递性规则,是"锁同步"的"终极保障"。它让我们明白:加锁不仅仅是保护单个共享变量,更是保护整个临界区内的操作链路------只要使用同一个锁,释放锁之前的所有操作,都会被后续获取锁的线程完整看到,不会出现操作顺序错乱、结果不可见的问题。

总结下来,锁相关的happens-before关系,是锁内存语义的"上层抽象",也是我们程序员运用锁机制的"核心抓手"。第一条规则(锁释放 happens-before 后续同一锁的获取),明确了"同一锁"的同步前提;第二条规则(传递性),则将同步保障延伸到了"整个操作链路",让锁能轻松应对复杂的并发场景。

几十年的并发编程经验告诉我:吃透锁的happens-before关系,就能避开很多加锁相关的并发bug。很多年轻程序员加了锁还出问题,大多是因为忽略了"同一锁"的前提,或者破坏了"锁释放-锁获取"的完整链路。对于我们老程序来说,这两条规则,就像并发编程中的"红绿灯",指引着我们写出有序、可见、一致的并发代码------只要遵循它,就能在复杂的并发环境中,守住同步的"底线"。

后来我查阅了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 小时前
抖音弹幕游戏开发之第12集:添加冷却时间机制·优雅草云桧·卓伊凡
java·服务器·前端
大黄说说2 小时前
MySQL数据库运维管理基础知识:从安装到日常维护的完整指南
开发语言
HAPPY酷2 小时前
C++ 多线程实战三板斧
java·开发语言·c++·技术美术
独自破碎E2 小时前
BISHI54货物堆放
android·java·开发语言
json{shen:"jing"}2 小时前
分割回文串
java
workflower3 小时前
易用性和人性化需求
java·python·测试用例·需求分析·big data·软件需求
小同志003 小时前
JVM 运⾏时数据区
jvm
小灵不想卷3 小时前
LangChain4 初体验
java·langchain·langchain4j
忍者必须死3 小时前
ConcurrentHashMap源码解析
java