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

📅 2026/7/6 8:08:52
Java高并发底层原理(三)—— synchronized 为什么能够保证线程安全
第3章 synchronized 为什么能够保证线程安全上一章确认了一件事:count由读、算、写三步组成两个线程可能读到同一个旧值、算出同一个新值互相覆盖。要解决这个问题不是让加法变快也不是减少线程而是给读—算—写这一组操作定一条规则:一个线程在执行的时候别的线程不能进来。synchronized就是用来定这条规则的。这一章只讲它最基础的能力:怎么靠一把锁实现互斥、互斥为什么能保护复合操作、锁对象选错了为什么保护会失效。1. 把复合操作放进临界区把上一章的计数器改一下:publicclassCountDemo{privatestaticfinalintTIMES1_000_000staticclassCounter{privateintcount0publicsynchronizedvoidincrement(){count}publicintgetCount(){returncount}}publicstaticvoidmain(String[]args)throwsInterruptedException{CountercounternewCounter()ThreadthreadAnewThread(()-{for(inti0 iTIMES i){counter.increment()}})ThreadthreadBnewThread(()-{for(inti0 iTIMES i){counter.increment()}}) threadA.start() threadB.start() threadA.join() threadB.join()System.out.println(expected TIMES*2)System.out.println(actual counter.getCount())}}只改了一个地方:increment()加了synchronized。count本身还是读、算、写三步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对象那就是两把不同的锁两个线程可以同时各跑各的。CountercounterAnewCounter()CountercounterBnewCounter()ThreadthreadAnewThread(counterA::increment)ThreadthreadBnewThread(counterB::increment)这段代码里两个线程根本没竞争同一把锁因为counterA和counterB是两个对象。实例同步方法只能保护当前这一个实例的状态保护不了同类型的其他对象。锁和对象的关系画出来是这样:------------------------- ------------------------- | 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(){count0}A 在执行某个对象的increment()时B 不能同时执行同一个对象的reset()——竞争关系看的是锁不是方法。4.2 静态同步方法publicstaticsynchronizedvoidincrement(){count}静态方法不属于哪个实例锁的不是this而是这个类对应的Class对象。等价于:publicstaticvoidincrement(){synchronized(Counter.class){count}}实例锁和类锁是两把完全不同的锁。一个线程占着某个Counter实例的锁不影响另一个线程去拿Counter.class的锁所以实例同步方法和静态同步方法可以同时跑。4.3 同步代码块privatefinalObjectlocknewObject()publicvoidincrement(){synchronized(lock){count}}同步代码块可以自己指定锁对象也只保护真正需要互斥的那几行比直接锁整个方法更精确。publicvoidprocess(){prepare()synchronized(lock){count}finish()}prepare()和finish()不碰共享状态不需要放进临界区。缩小临界区能减少持锁时间但不能为了图省事把一个完整的复合操作拆散——读、判断、改如果本来就是一次业务动作就得放在同一个临界区里。5. 所有线程必须用同一把锁synchronized能不能生效不只看代码有没有写这个关键字还要看访问同一份共享状态的线程用的是不是同一把锁。下面这段代码看着加了锁实际完全没用:publicvoidincrement(){ObjectlocknewObject()synchronized(lock){count}}每次调用increment()都新建一个lock。A 锁的是一个对象B 锁的是另一个对象两把锁互不相干两个线程照样能同时执行count。------------------------- ------------------------- | Thread A | | Thread B | ------------------------- ------------------------- | Lock 0x2000 | | Lock 0x3000 | | Enter critical section | | Enter critical section | ------------------------- -------------------------正确做法是让所有线程稳定地用同一个锁对象比如把锁存成实例字段运行过程中不要换掉它:privatefinalObjectlocknewObject()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/monitorexit和ACC_SYNCHRONIZED是 JVM 识别同步区域的两种形式同一线程可以重复拿到自己已经持有的锁它同时保证互斥性和基于同一把锁的可见性它不会自动保护所有访问路径锁对象选错了照样白搭synchronized解决了正确性但线程抢锁要付出代价。下一章接着看抢锁的过程里到底发生了什么阻塞、唤醒、上下文切换又是怎么影响性能的。