news 2026/7/6 8:08:41

Java高并发底层原理(三)—— synchronized 为什么能够保证线程安全

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Java高并发底层原理(三)—— synchronized 为什么能够保证线程安全

第3章 synchronized 为什么能够保证线程安全

上一章确认了一件事:count++由读、算、写三步组成,两个线程可能读到同一个旧值、算出同一个新值,互相覆盖。要解决这个问题,不是让加法变快,也不是减少线程,而是给"读—算—写"这一组操作定一条规则:一个线程在执行的时候,别的线程不能进来。

synchronized就是用来定这条规则的。这一章只讲它最基础的能力:怎么靠一把锁实现互斥、互斥为什么能保护复合操作、锁对象选错了为什么保护会失效。

1. 把复合操作放进临界区

把上一章的计数器改一下:

publicclassCountDemo{privatestaticfinalintTIMES=1_000_000staticclassCounter{privateintcount=0publicsynchronizedvoidincrement(){count++}publicintgetCount(){returncount;}}publicstaticvoidmain(String[]args)throwsInterruptedException{Countercounter=newCounter()ThreadthreadA=newThread(()->{for(inti=0; i<TIMES; i++){counter.increment()}})ThreadthreadB=newThread(()->{for(inti=0; i<TIMES; i++){counter.increment()}}); threadA.start(); threadB.start(); threadA.join(); threadB.join()System.out.println("expected = "+TIMES*2)System.out.println("actual = "+counter.getCount())}}

只改了一个地方:increment()加了synchronizedcount++本身还是读、算、写三步,JVM 没有把它变成一条指令。变的是执行权限——一个线程进了increment(),另一个线程就不能同时进同一个对象的这段代码。

这段被保护起来的代码叫临界区(Critical Section)。这里的临界区就是整个increment()方法,保护的是字段count

2. synchronized 怎么实现互斥

每个synchronized都对应一个锁对象。线程进临界区之前先要拿到这把锁,执行完再释放。同一时刻只有一个线程能持有同一把锁,所以用同一把锁保护的代码不可能被两个线程同时执行。

一种可能的执行过程:

+------+---------------------------+---------------------------+-------------+ | Step | Thread A | Thread B | Lock | +------+---------------------------+---------------------------+-------------+ | 1 | Acquire | | Owner = A | | 2 | Read, Calculate, Write | Acquire failed | Owner = A | | 3 | Release | Waiting | Free | | 4 | | Acquire | Owner = B | | 5 | | Read, Calculate, Write | Owner = B | | 6 | | Release | Free | +------+---------------------------+---------------------------+-------------+

Thread B 不是不能干活,它只是进不了这把锁保护的临界区。A 释放锁之后 B 才能拿到锁往下执行。这样一来,两次count++之间只会是完整的先后关系,不会再出现两个线程同时读到旧值的情况。

这种"同一时刻只允许一个线程进入"的特性叫互斥(Mutual Exclusion)。它不改变单个线程内部的执行步骤,改变的是多个线程能以什么方式组合执行。count++本身依然不是一条原子指令,但对所有用同一把锁的线程来说,整个临界区表现得就像一个不能被插队的整体。

3. synchronized 锁的是对象,不是代码

习惯上说"给方法加锁",但准确地说,synchronized锁住的不是一段代码,而是一个对象。代码只是规定:线程必须先拿到某个对象的锁,才能往下执行。

实例同步方法:

publicsynchronizedvoidincrement(){count++}

等价于:

publicvoidincrement(){synchronized(this){count++}}

这里锁的是this,也就是当前这个Counter实例。如果 A、B 两个线程调用的是同一个Counter对象,它们抢的就是同一把锁;如果调用的是两个不同的Counter对象,那就是两把不同的锁,两个线程可以同时各跑各的。

CountercounterA=newCounter()CountercounterB=newCounter()ThreadthreadA=newThread(counterA::increment)ThreadthreadB=newThread(counterB::increment)

这段代码里两个线程根本没竞争同一把锁,因为counterAcounterB是两个对象。实例同步方法只能保护当前这一个实例的状态,保护不了同类型的其他对象。

锁和对象的关系画出来是这样:

+-------------------------+ +-------------------------+ | Thread A Stack | | Thread B Stack | +-------------------------+ +-------------------------+ | counter = 0x1000 | | counter = 0x1000 | +-------------------------+ +-------------------------+ \ / \ / \____________________________/ | +----------------------------+ | Heap | +----------------------------+ | 0x1000 | | +------------------------+ | | | Counter Object | | | | count = 0 | | | | monitor: one owner | | | +------------------------+ | +----------------------------+

图里的monitor表示同步语义,不代表 Java 对象里真有这么一个能直接访问的字段。JVM 会给对象配一套监视器锁(Monitor)机制,线程通过它完成进入、退出、竞争。具体怎么实现——对象头、锁记录这些——留到后面讲锁实现细节的章节。

4. synchronized 的三种写法

synchronized能用在实例方法、静态方法、代码块上。三种写法核心逻辑一样,区别只在锁的是谁、临界区多大。

4.1 实例同步方法

publicsynchronizedvoidincrement(){count++}

锁的是this。同一个对象上所有的同步实例方法共用一把锁,哪怕方法名不一样,也不能被两个线程同时进入。

publicsynchronizedvoidincrement(){count++}publicsynchronizedvoidreset(){count=0}

A 在执行某个对象的increment()时,B 不能同时执行同一个对象的reset()——竞争关系看的是锁,不是方法。

4.2 静态同步方法

publicstaticsynchronizedvoidincrement(){count++}

静态方法不属于哪个实例,锁的不是this,而是这个类对应的Class对象。等价于:

publicstaticvoidincrement(){synchronized(Counter.class){count++}}

实例锁和类锁是两把完全不同的锁。一个线程占着某个Counter实例的锁,不影响另一个线程去拿Counter.class的锁,所以实例同步方法和静态同步方法可以同时跑。

4.3 同步代码块

privatefinalObjectlock=newObject()publicvoidincrement(){synchronized(lock){count++}}

同步代码块可以自己指定锁对象,也只保护真正需要互斥的那几行,比直接锁整个方法更精确。

publicvoidprocess(){prepare()synchronized(lock){count++}finish()}

prepare()finish()不碰共享状态,不需要放进临界区。缩小临界区能减少持锁时间,但不能为了图省事把一个完整的复合操作拆散——读、判断、改如果本来就是一次业务动作,就得放在同一个临界区里。

5. 所有线程必须用同一把锁

synchronized能不能生效,不只看代码有没有写这个关键字,还要看访问同一份共享状态的线程用的是不是同一把锁。下面这段代码看着加了锁,实际完全没用:

publicvoidincrement(){Objectlock=newObject()synchronized(lock){count++}}

每次调用increment()都新建一个lock。A 锁的是一个对象,B 锁的是另一个对象,两把锁互不相干,两个线程照样能同时执行count++

+-------------------------+ +-------------------------+ | Thread A | | Thread B | +-------------------------+ +-------------------------+ | Lock = 0x2000 | | Lock = 0x3000 | | Enter critical section | | Enter critical section | +-------------------------+ +-------------------------+

正确做法是让所有线程稳定地用同一个锁对象,比如把锁存成实例字段,运行过程中不要换掉它:

privatefinalObjectlock=newObject()publicvoidincrement(){synchronized(lock){count++}}

锁对象通常声明成private final——private防止外部代码不小心用了同一把锁,final防止锁引用中途被换掉。这里固定的是引用,不是让锁对象有什么特殊能力,真正重要的是所有相关线程每次进临界区用的都是同一个对象。

只保护写操作也不一定够。如果某次读必须跟某次写保持一致,这次读也得守同一套锁规则。要不要同步,看的是这次访问和其他操作之间的关系,不是光看这一行有没有赋值。

6. synchronized 是可重入的

一个线程已经持有某个对象的锁,还能再次拿到这同一把锁,这个能力叫可重入(Reentrant)

publicsynchronizedvoidouter(){inner()}publicsynchronizedvoidinner(){count++}

A 调用outer()时已经拿到了this的锁,outer()里面又调用了inner()inner()还是要同一把锁。如果synchronized不支持重入,线程就得等自己把锁放了才能继续,可它又必须跑完inner()才能从outer()退出——直接死锁在自己手里。

可重入锁会记住是谁持有的、进了几层。同一个线程再进一次,计数加一;每退出一层,计数减一;计数归零锁才真正释放,别的线程才有机会拿到。

可重入只对同一个线程成立。A 持有锁,不代表 B 能重入,B 还是得老老实实等 A 完全放手。

7. 抢不到锁的线程在干什么

线程执行到synchronized时,如果锁是空闲的,直接拿锁进临界区;如果锁被别的线程占着,它就进不去这段代码。Java 线程状态里,这种等锁的线程通常显示为BLOCKED

锁释放之后,等待的线程会重新参与竞争,但不能想当然地认为等得最久的一定先拿到——synchronized不保证严格的先来后到,具体谁先拿到跟 JVM 实现和调度都有关系。

也不能把每次抢锁都当成一次很重的操作。现代 JVM 会根据竞争激烈程度做不同优化,有时候线程会先短暂尝试几下,真抢不到才会走阻塞、唤醒那条路。这里先记住一个稳定的结论就够:没拿到锁的线程进不了临界区,竞争和等待会带来额外开销。锁具体怎么升级、怎么跟操作系统配合,留到后面章节单独讲。

8. synchronized 到底保证了什么

对用同一把锁的线程来说,synchronized首先保证互斥——临界区同一时刻只能有一个线程在跑,读、算、写不会跟另一个用同一把锁保护的操作交错,count++的丢失更新问题也就没了。

它还保证可见性。一个线程退出同步区域之前对共享数据做的修改,另一个线程随后拿到同一把锁时应该能看到。关键还是"同一把锁"——如果写的线程和读的线程用的是两把不同的锁,这层同步关系根本没建立起来。

完整的可见性规则属于 Java 内存模型的内容,缓存、指令重排、happens-before 这些留到后面再展开,这里先记住结论就行:synchronized不只是限制谁先进谁后进,也在锁的释放和后续加锁之间建立了数据可见的保证。

synchronized不会自动保护一个对象的所有字段,也不会自动帮你判断哪些代码算一次完整的业务操作。它只对真正被同步区域覆盖的代码生效,而且要求所有相关访问都守同一套锁规则——只要有一条修改路径绕开了锁,整体照样不安全。

9. 回到 count++

加锁之后的increment()可以拆成:

Acquire lock Read count Calculate count + 1 Write count Release lock

读、算、写这三步没有消失,只是被塞进了同一个临界区。A 持锁的时候 B 进不来;A 写完释放锁,B 才能读到最新的count。执行顺序可以是 A 先 B 后,也可以反过来,但不会再出现两个线程同时基于同一个旧值各自更新的情况。

结论就一句话:

synchronized没有把复合操作变成一条指令,它是靠互斥,不让别的线程在复合操作执行到一半的时候插进来。

这个方案能把正确性找回来,但也带来了新问题。线程要进临界区得先抢锁,抢不到就得等,等就可能牵扯阻塞、唤醒、上下文切换。临界区越大、竞争越激烈,这部分开销就越明显。这是后面分析锁实现和并发性能的起点。

小结

count++的问题源于多个线程对同一份共享状态执行可交错的复合操作。synchronized靠锁对象建立互斥,让用同一把锁的线程不能同时进临界区,把读、算、写当成一个完整过程保护起来。

几个要记住的点:

  • synchronized锁的是对象,不是代码本身
  • 实例同步方法锁this,静态同步方法锁对应的Class对象
  • 同步代码块可以自己指定锁对象和临界区范围
  • 访问同一份共享状态的所有线程必须用同一把锁
  • monitorenter/monitorexitACC_SYNCHRONIZED是 JVM 识别同步区域的两种形式
  • 同一线程可以重复拿到自己已经持有的锁
  • 它同时保证互斥性和基于同一把锁的可见性
  • 它不会自动保护所有访问路径,锁对象选错了照样白搭

synchronized解决了正确性,但线程抢锁要付出代价。下一章接着看抢锁的过程里到底发生了什么,阻塞、唤醒、上下文切换又是怎么影响性能的。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/7/6 8:06:00

XUnity.AutoTranslator终极指南:一键实现Unity游戏实时翻译

XUnity.AutoTranslator终极指南&#xff1a;一键实现Unity游戏实时翻译 【免费下载链接】XUnity.AutoTranslator 项目地址: https://gitcode.com/gh_mirrors/xu/XUnity.AutoTranslator 你是否曾因为语言障碍而无法畅玩心仪的日文或英文游戏&#xff1f;XUnity.AutoTran…

作者头像 李华
网站建设 2026/7/6 8:04:02

2026深度剖析!好用的AI论文写作辅助平台全在这里

在2026年&#xff0c;学术写作正迎来一场智能化的变革&#xff0c;越来越多人开始依赖AI论文写作工具。在撰写硕士或博士论文等长篇学术作品时&#xff0c;很多这样的工具要么缺乏必要的理论深度&#xff0c;要么逻辑结构松散。这使得普通的AI写论文工具无法满足严谨的学术要求…

作者头像 李华
网站建设 2026/7/6 8:03:12

2026年要知道!AI论文写作神器深度测评,助你快速完成高质量论文

你是否还在为撰写期刊论文、毕业论文或者职称论文而感到烦恼&#xff1f;在进行论文写作的时候&#xff0c;面对一堆文献资料仿佛像在大海中找针&#xff0c;复杂的格式要求也让人感到无所适从&#xff0c;频繁的修改更是让人失去耐心&#xff0c;导致效率低下成了许多学术工作…

作者头像 李华
网站建设 2026/7/6 8:02:33

Python之ride-cli包语法、参数和实际应用案例

1. 引言ride-cli 是一个基于 Python 的命令行工具包&#xff0c;专为 Robot Framework 测试用例的编写、执行和管理而设计。它提供了丰富的交互式编辑功能&#xff0c;帮助测试工程师更高效地创建和维护自动化测试脚本。本文将全面介绍 ride-cli 的功能特性、安装方法、核心语法…

作者头像 李华