Java并发锁详解:六类锁策略与 synchronized 底层原理

这一部分的内容主要针对面试,所以内容相对有些干巴~

1.常见锁策略

1.1 乐观锁和悲观锁

不知道你第一次见是不是误以为:一个锁比较开心,另一个锁比较消极;

不过其实描述的主要是:对于"接下来会不会发生线程冲突"

悲观锁:

每次访问共享数据都先假设别人可能会修改,因此线上锁;其他线程如果也需要访问,就只能等待锁释放。

悲观锁的想法比较保守,加锁的时候,预测接下来的锁竞争的情况会非常激烈,就需要针对这样的激烈情况额外做一些工作。

还是继续拿公司举例子,假设财务办公室有一份账本。张三觉得"这个账本这么重要,李四王五说不定随时都会来改,我还是先把门锁上。"

乐观锁:

先假设并发冲突发生的概率不高,在真正提交更新时再检查是否发生冲突。

乐观锁的想法就相反,加锁的时候,预测接下来的锁竞争的情况不激烈,就不需要做额外的工作。

乐观锁和悲观锁的对比就像下面一样说的一样

比如:现在张三和李四想找老师问问题。

张三比较悲观:"老师平时这么忙,我直接过去估计也是白跑。"所以张三就先发消息"老师,你下午两点有空吗?"

老师确认"有空"后,张三才过去~

像上面这样就是悲观锁

李四就比较乐观"老师应该没这么忙吧,我直接过去"。于是李四直接跑办公室,如果老师正好有空,那最好直接把问题解决~

这样就根本没有提前预约产生额外流程。

如果老师正忙,"哦,那算了,我下次再来。"

虽然这次失败了,但他能够识别到"现在发生冲突了"然后重新尝试~

这就是乐观锁的类比

两种策略没有绝对优劣,关键看当前场景。

比如冲突非常频繁,你还一直乐观"失败 再试 失败 又试",就像知老师每天都忙得要死,你还天天直接跑办公室碰碰运气。最后反而白跑很多次,一直浪费资源这就比较适合悲观锁了

反过来,假如有 1000 次操作,可能只有 1 次冲突。如果每一次都申请锁->等待->解锁,这就有点像:老师一年都闲的要死,你每次都为"1+1"等于几之前,还提前三天预约~这种冲突概率很低的场景,就更适合乐观锁策略。

那么乐观锁到底怎么知道"别人改没改"?

这个问题就先埋下来~

而悲观锁其实很好理解:真的把锁拿到手

那乐观锁既然不提前把别人挡住,那它凭什么知道别人有没有改过数据?

一个典型的方案就是版本号~

就比如:当前数据是余额 = 100 ,version = 1

线程 A 读到:100 / version 1

准备修改为:50

真正提交的时候再看 version 是否等于 1

如果还是version = 1就说明我读取之后没人改过;如果发现version != 1就说明我不在的时候有人动过这份数据~那么本次修改就失败

这其实就是为我后面要说的CAS 埋个伏笔

既然提到锁,就不得不提一下synchronized,synchronized 开始时会倾向使用更乐观、开销较低的策略;如果发现锁竞争逐渐频繁,则会逐步转向更悲观、开销更高的处理方式。

1.2 重量级锁和轻量级锁

从前面我们学习到了,锁的核心目的是保证原子性 / 互斥;

可以先暂时不用深入研究 JVM 和操作系统底层是如何实现锁的,就记住Java 的锁当然不是凭空出现的,它最终还是要依赖 CPU 提供的原子指令以及操作系统提供的线程调度能力。

那么重量级锁 和轻量级锁是什么意思?

  • 重量级锁

这里提到的重量级,不是说把锁本身"更大",而是发生锁竞争时,为了实现线程之间的互斥,需要付出的系统开销比较大。

就比如,当一个线程获取不到锁时,可能需要让该线程进入阻塞状态。等到锁被释放以后,又需要将这个线程唤醒,让它重新参与 CPU 调度。

这其中可能涉及到:

  1. 用户态与内核态之间的切换
  2. 线程阻塞与唤醒
  3. 线程上下文切换
  4. 操作系统线程调度

这些操作相对于普通的用户态代码来说成本比较高,所以被称为重量级锁。

  • 轻量级锁

轻量级锁的思路则是:如果锁竞争并不严重,就尽量不要马上让线程进入阻塞状态,而是在用户态先尝试解决竞争。

就比如:JVM 可以利用 CPU 提供的 CAS 原子指令,或者让线程进行短暂的自旋,尝试获取锁。

整个过程中线程可能并没有真正进入阻塞状态,因此也就避免了比较昂贵的线程阻塞、唤醒和上下文切换。这也就是所说的"轻量级"。

上面提及的用户态 / 内核态指的就是:

用户态:程序自己在用户空间里完成,不需要操作系统直接介入线程的阻塞和唤醒。

内核态:当锁竞争严重、线程需要被阻塞或唤醒时,需要操作系统内核参与线程调度,可能产生用户态与内核态切换以及线程上下文切换。

你可以这么理解用户态和内核态:

假设你去银行,在大厅外面,自己能完成的事情(填表、看号码、整理材料)这些事情自己就能做,这就可以类比为用户态。

但是 有些事情你没有权限自己完成,必须交给拥有更高权限的工作人员,所以只能去柜台,让银行工作人员处理,这就可以类似内核态。

对于上面所说的"重量级锁"和"轻量级锁",主要区别并不是谁更安全,而是谁的实现成本更高。所以还是那句话:没有哪一种策略永远最好,关键看当前的竞争情况。

1.3 自旋锁和挂起等待锁

我们知道,线程在竞争锁时可能会遇到一种情况:线程 A 已经持有锁,线程 B 尝试获取锁但失败了。对于线程 B 通常会有两种不同的处理思路:

  1. 继续占用 CPU,不断尝试获取锁 -> 自旋等待
  2. 暂时放弃 CPU,进入阻塞等待状态 -> 挂起等待

这也就是我这一节要说的自旋锁和挂起等待锁~

自旋锁:

刚刚说到的第一种思路就是自旋锁(继续占用 CPU,不断尝试获取锁)

按照传统的处理方式,如果线程获取锁失败,可以直接进入阻塞状态,将 CPU 让给其他线程。这样做虽然不会一直浪费 CPU,但是线程阻塞、唤醒以及上下文切换本身也是有成本的。

于是就产生了一个问题:

假设线程 A 虽然正在持有锁,但是它马上就要释放了,如果这时候线程 B 马上进入阻塞,那么阻塞和唤醒线程的成本,可能反而比等待锁释放的时间还长~

所以换一种思路就是先不要马上阻塞线程,让线程继续运行一小段时间,不断尝试获取锁,这就是自旋的含义。

自旋你可以抽象理解为:

java 复制代码
while (获取锁失败) {
    // 继续尝试获取锁
}

注意哈,这只是帮助理解的伪代码。

我让 AI 解释了下,实际 JVM 或并发组件中的实现会更加复杂,并不会简单地让线程无条件无限循环。

再补充一下,自旋锁其实是属于一种偏轻量级的等待策略。自旋线程仍然处于运行状态,因此仍然会占用 CPU~

挂起等待锁:

挂起等待锁其实就是另一种思路了:既然暂时获取不到,那我就先不抢了,把 CPU 让出来。

从过程上来看其实就是"我拿不到锁,我先休息,把 CPU 让出来,等锁释放后再唤醒我"

此处所说的"挂起"并不是说 Java 线程永远停止,而是线程暂时进入等待 / 阻塞状态,不再持续占用 CPU 执行。

这里说到的自旋锁 和挂起等待锁你可以这样理解一下:

想象一下,去追求一个女神。当男生像女神表白后,女神说:你是个好人,但是我有男朋友了~~

挂起等待锁:陷入沉沦不能自拔...过了很久很久之后,突然女神发消息过来,"要不咱俩试试?"(注意,这个很长时间间隔里,女神可能已经换了好几个男票了)

自旋锁:死皮赖脸坚韧不拔,仍然每天坚持地和女神说早安晚安。一旦女神和上一任分手,那么就能立刻抓住机会上位。

我让 GPT 总结了上面的内容给出了对比表格:

对比 自旋等待 挂起等待
获取锁失败后 不断尝试获取锁 线程进入等待 / 阻塞
是否继续占用 CPU 是 通常不持续占用 CPU
是否需要线程阻塞 通常不需要 需要
是否涉及调度 较少 通常需要
等待时间很短 更合适 阻塞成本可能不划算
等待时间很长 会浪费 CPU 更合适

所以上述两种方式没有绝对的好坏,而是适用的场景不同~

其实还是回到了锁竞争的激烈程度:

悲观锁 -> 重量级锁 -> 挂起等待锁

乐观锁 -> 轻量级锁 -> 自旋锁

1.4 公平锁和非公平锁

这一部分其实更好理解些,主要说的是:

如果很多线程都在等同一把锁,那么这把锁到底要不要讲"先来后到"?

假设现在有三个线程,分别是 A,B,C

A 先尝试获取锁,获取成功。然后 B 再尝试获取锁,获取失败,阻塞等待。然后 C 也尝试获取锁,C 也获取失败,也阻塞等待。

那么,当线程 A 释放锁的时候,会有两种情况:

公平锁:遵循"先来后到",B 比 C 先来,所以当 A 释放锁的时候,B 会先于 C 获取到锁。

非公平锁:不遵循"先来后到",B 和 C 都有可能获取到锁。

其实你可以认为,非公平锁也能定义为公平,因为锁默认情况下,就相当于"概率均等",操作系统针对线程的调度是随机的~

操作系统线程调度本身可以看成带有随机性;如果没有额外机制限制,锁通常不会天然保证公平。如果要实现公平,就需要额外的数据结构记录线程到来的先后顺序。

下面这个例子希望更能加深对公平锁和非公平锁的印象~

再补充一句,synchronized 其实是非公平锁,在之前学习 synchronized 的时候就说到,当一个线程释放锁以后,其他等待的线程会重新竞争,并不一定严格按照先来后到。

1.5 可重入锁和不可重入锁

在之前的初阶已经稍微接触过,只是当时是为了说明synchronized不会让线程"自己把自己锁死"。

可重入的意思就是:

同一个线程已经拿到第一把锁以后,还可以再次获取同一把锁。

或者逼入一个递归函数里有加锁操作,递归过程中这个锁会阻塞自己吗?如果不会,那么这个锁就是可重入锁(因为这个原因可重入锁也被称为递归锁)。

就比如下面这样:

java 复制代码
public class Demo {
    public synchronized void method1() {
        System.out.println("method1");
        method2();
    }
    public synchronized void method2() {
        System.out.println("method2");
    }
}

Demo demo = new Demo();
demo.method1();

比如我 new 了一个 Demo,调用了 method1,method1 内部又调用了method2,它们同样都是 synchronized;

如果是可重入锁,method2 允许再次进入。

但如果是不可重入锁,method2 就会被拒之门外,然后method1 又无法释放锁。此时就会发生死锁情况。

这样的锁就称为不可重入锁。

synchronized 是可重入锁

1.6 普通互斥锁和读写锁

在多线程环境下,对共享数据的访问通常可以分为两类:读取数据 和修改数据

如果只是两个线程同时读取数据,两个线程都不会修改数据,因此一般不会破坏数据的一致性。

但如果一个线程读取数据,一个线程修改数据,这样就可能产生问题;同样,如果是两个线程都在修改数据也必须得保持互斥。

你可以这样认为:

普通互斥锁

普通互斥锁的问题主要是,即使多个线程之间只是读取操作,它们仍然不能同时执行。

就像是之前学习 synchronized 一样:

java 复制代码
public synchronized int getData() {
    return data;
}

public synchronized void setData(int data) {
    this.data = data;
}

这样虽然能保证线程安全,但是假设三个线程都执行getData()只是读取数据。但又由于synchronized是互斥的。也就是说即使多个线程之间只是读取操作,他们仍然不能同时执行。

线程安全是保证了,但是并发能力下降了。

所以这时候就产生了一个新的思路:既然读操作不会修改数据,那就允许多个线程同时读取;

读写锁

这就是读写锁由来。

读写锁并不是只有"一把锁",而是把锁拆为了读锁 (Read Lock) 和 写锁 (Write Lock) 分别对待读取操作和修改操作。

Java 标准库提供了 ReentrantReadWriteLock,它的内部提供了两种锁,分别是读锁类 ReentrantReadWriteLock.ReadLock 和写锁类 ReentrantReadWriteLock.WriteLock。

以上两个类都提供了 lock / unlock 方法分别进行加锁和释放锁。

读锁最大的特点就是:读锁之间可以共享 ,所以读锁也被称为共享锁

而写锁的话就必须保证互斥,所以读锁也能理解为独占锁,因为同一时间只能有一个线程持有写锁。

这样就实现了上面那种图片的效果了:

读锁 + 读锁:不互斥

写锁 + 写锁:互斥

读锁 + 写锁:互斥

也就是说,只要有线程持有写锁,其他线程既不能获取读锁,也不能获取写锁

反过来,只要还有线程持有读锁,写线程就不能获取写锁

使用实例如下:

java 复制代码
ReentrantReadWriteLock readWriteLock =
        new ReentrantReadWriteLock();
ReentrantReadWriteLock readWriteLock =
        new ReentrantReadWriteLock();

Lock readLock = readWriteLock.readLock();
Lock writeLock = readWriteLock.writeLock();
// 读操作加锁
public int getData() {
    readLock.lock();

    try {
        return data;
    } finally {
        readLock.unlock();
    }
}
// 写操作加锁
public int getData() {
    readLock.lock();

    try {
        return data;
    } finally {
        readLock.unlock();
    }
}

再补充一句,synchronized不是读写锁~

对于以上锁的内容,一般面试考察的就是概念性的

然后对于读写锁来说还有一个要注意的地方:

在ReentrantReadWriteLock中,持有写锁的线程,可以继续获取读锁。

例如:

这种操作叫做:锁降级

也就是从写锁变成了读锁,权限从独占 变成了共享读取~

2.synchronized 原理

synchronized 前面已经学过了,但结合上面说到的锁策略问题,你可以将 synchronized 先理解成:

  1. 开始倾向乐策略竞争频繁后转向更悲观的处理
  2. 开始使用轻量级实现竞争严重后可能转成重量级锁
  3. 轻量级阶段可能使用自旋
  4. 非公平锁
  5. 可重入锁
  6. 不是读写锁

JVM 将 synchronized锁分为无锁、偏向锁、轻量级锁、重量级锁状态。

2.1 加锁过程

synchronized不是一上来就用最重的方案,你可以这么理解:

竞争不严重的时候,能简单处理就简单处理。

当竞争越来越严重,简单办法搞不定,再逐渐换成更重的办法。

先不急着背这几个词,可以把线程放进一个具体场景。

java 复制代码
class Counter {
    private final Object lock = new Object();
    private int count = 0;

    public void add() {
        synchronized (lock) {
            count++;
        }
    }
}

假设线程 A、B 都调用同一个 Counter 对象的 add()。它们竞争的就是该对象中的同一个lock。

无锁:目前还没有人使用这把锁

lock刚创建出来的时候还没有线程进入这段同步代码~

这时候线程 A 来执行 add,他想做的事情就只是:

"我要进入synchronized(lock),修改count"

JVM 需要保证 A 修改期间,如果 B 也来了,B 不能同时修改~

但现在只有 A,一个竞争者都没有。但如果每次都按"许多线程争锁"的方式处理,成本就白花了。

偏向锁:一直都是 A 在使用

我还是拿之前的例子来吧:

假设我是个妹子,我要谈男朋友,我又希望把男朋友换的快一些,那么就有下面两个步骤:

  1. 和当前男朋友分手

我需要 zuo 一 zuo,打一打拳,逐渐消耗他的耐心~~

我再谈分手,一定得是哭的梨花带雨,让它认为都是他的名字~

  1. 和下一个小哥哥培养感情

当我最开始谈这个男朋友的时候,我和他做各种情侣之间做的事情,但是从不和他确认关系(搞暧昧)这就让他能够更好的满足我对于男朋友的需求~

然后当有一天我对他厌烦了,这个时候就不必麻烦了,直接和他说,咱们不要再见面了~

但这样搞暧昧的手段也有一定副作用,万一有别的妹子也在接近我的小哥哥,我的小哥哥也可以瞬间把我给踹了,然后用同样的话"咱们只是普通朋友"应对我

所以我只要发现了有妹子试图接近我家哥哥,只要一有这样的苗头,我就立即和我家哥哥确认关系,并且朋友圈官宣~让其他妹子就可以 gun 远了~

ok,像上述这样的过程,就是偏向锁的过程~

进行synchronized刚一上来,不是真加锁,而是只是简单做一个标记(搞暧昧)

这个标记,非常轻量,相比于加锁解释来说,效率高很多~

如果没有其他线程来竞争这个锁,最终当前线程执行到解锁代码,也就只是简单清除上述标记即可(不涉及真加锁、真解锁),就像是搞暧昧,不真确立关系,后续分手就很快

如果有其他线程来竞争,就抢先一步,在另一个线程拿到锁之前,抢先拿到锁。也就是真加锁了, 偏向锁 ->轻量级锁。其他线程只能阻塞等待~

本质上也是懒汉模式思想的体现

偏向锁之所以省事,前提是基本没有别的线程来争;前提变了,策略也得变。

轻量级锁:有竞争,但也许很快就结束

假设 A 正在执行:

java 复制代码
synchronized (lock) {
    count++;
}

此时 B 恰好也来了。B 发现自己暂时拿不到 lock。

如果同步代码只有一个 count++,A 很可能马上就执行完了。此时此刻让 B 挂起,等操作系统以后再唤醒它,可能比"稍等一下"还费事。

尝试取得锁 :用 CAS 检查并尝试更新与锁有关的状态。(先把 CAS 理解成一个"确认状态仍符合预期,才尝试修改"的动作)

短暂自旋:如果这次没取得锁,B 可以短暂重试,看看 A 是否已经释放。

这就和上面提到的自旋锁策略接上了,"我先不去休息,在小试一小会,也许你马上就用完了"

但 如果 A 持续时间很长,B 一直原地重试。这时候 B 就会持续消耗 CPU,所以自旋不会毫无节制地一直进行。短暂等待仍拿不到锁,就需要考虑成本更合适的处理方式。

重量级锁:持续竞争,就让线程等待

假设 A 进入同步代码后久久不退出,B 试了多次还是拿不到锁;或者有更多线程都来争这把锁。

此时要是再试一下就不划算了~所以就需要才需更重的处理方式,也就是重量级锁:有 JVM 的监视器机制管理竞争,拿不到锁的线程可能进入等待,等锁释放后再被唤醒、重新争取。


对于上述的内容我是这样认为

无锁 --> 偏向锁:代码进入synchronized的代码块

偏向锁 --> 轻量级锁:拿到偏向锁的线程运行过程中,遇到了其他线程尝试竞争这个锁

轻量级锁 --> 重量级锁:JVM 发现,当前竞争锁的情况非常激烈

当前 JVM 中,只提供了"锁升级"不能"锁降级"。注意此处我说的锁降级与前面提到的 ReentrantReadWriteLock 的锁降级不是同一种。

ReentrantReadWriteLock 的"锁降级",是并发锁 API 的读写权限转换;synchronized 所谓"锁升级",是 JVM/HotSpot 对 Monitor 的底层实现形态变化。

2.2 其他的优化操作

锁消除

锁消除就是编译器和 JVM 判断某些锁实际上不会发生线程竞争,于是把没有必要的加锁、解锁操作直接去掉~

就比如,都知道 StringBuffer 很多方法本身就带同步。

java 复制代码
StringBuffer sb = new StringBuffer();
sb.append("a");
sb.append("b");
sb.append("c");
sb.append("d");

但如果,sb 只是一个方法里的局部变量,而且整个过程只有当前线程能访问。那么就根本没有线程在跟你抢,那这时候还"加锁、append、解锁"就纯粹是白忙活~

所以 JVM 如果能分析出这个对象不会被其他线程访问,那么就可以把这些没有意义的同步操作消掉~

再注意一下,JVM 只有在能够判断这个锁不会发生真实竞争时,才能进行这种优化

锁粗化

再看另一种情况:

java 复制代码
for (int i = 0; i < 1000; i++) {
    synchronized (lock) {
        count++;
    }
}

假设 JVM 按代码表面上的范围处理,每轮循环都要 取得锁 -> count++ -> 释放锁

如果是循环一千次,那么就重复一千次

但问题是如果这些操作紧挨着进行,反复去取得、释放同一把锁,本身也会有成本。

所以锁粗化就是在合适的情况下,JVM 可能把多次相邻的加锁范围合并,减少反复申请和释放锁的次数。

举个例子:假设你要向领导交代三个任务

  • 方式一:打电话交代任务 1,挂断;再打电话交代任务 2,挂断;再打一次交代任务 3.
  • 方式二:打一次电话,把三个任务交代完,再挂断。

你给领导打电话(对领导加锁),这个时候领导需要全心全意应付你,没法做别的事情,所以必然会对整体效率产生影响~

所以方法二就省掉了重复的成本,锁粗化成一把锁省的也是反复"取得、释放"的成本。所以上面的加锁可以优化成这样:

java 复制代码
synchronized (lock) {
    for (int i = 0; i < 1000; i++) {
        count++;
    }
}

所以 synchronized 真正厉害的地方,不只是"能加锁",而是 JVM 在背后替我们做了很多优化。

那么对于如果有类似下面的面试题回答就不算困难了~

  1. 什么是偏向锁?

偏向锁不是真的加锁,而只是在锁的对象头中记录一个标记(记录该锁所属的线程)。如果没有其他线程参与竞争锁,那么就不会真正执行加锁操作,从而降低程序开销。一旦真的涉及到其他的线程竞争,再取消偏向锁状态,进入轻量级锁状态。

  1. synchronized 实现原理是什么?
相关推荐
和裕1 小时前
全纸结构重型纸箱能否满足 1 吨以上设备出口熏蒸豁免要求?通关合规性全解析
大数据·运维·网络·人工智能·算法
sunshine22 girl1 小时前
Java学习五 面向对象高级5 内部类1
java·学习
tangguofeng1 小时前
不带头结点的链队操作集(不含InitFlag)(C语言版)
算法
特创数字科技2 小时前
一个纯本地运行的图片处理工具:压缩 / 裁剪 / 九宫格 / 圆角 / 滤镜 / 拼图 / 水印,终生使用
前端
IT_陈寒3 小时前
Vue的v-if和v-for混用居然是个天坑
前端·人工智能·后端
天衍四九-3 小时前
【无标题】
前端·spring boot·mysql·nginx·docker
广州华水科技3 小时前
2026年单北斗GNSS变形监测系统推荐榜单,解锁GNSS位移监测新高度
前端
苏打豆3 小时前
CRISP源码阅读——基于ROS2力矩反馈控制器与速度加速度零空间
c++·线性代数·算法·矩阵·机器人
用户1305180712153 小时前
JDBC学习DAY2:从Statement到PreparedStatement
java