【Java杂项】synchronized 锁的到底是谁?this、Class 和 lock 选择详解

【Java杂项】synchronized 锁的到底是谁?this、Class 和 lock 选择详解

    • 一、先看现象:同一个方法不一定是同一把锁
    • [二、精确一点:锁对象、monitor 和对象本身的关系](#二、精确一点:锁对象、monitor 和对象本身的关系)
    • 三、先用决策树选锁
    • [四、三种 synchronized 写法分别锁谁](#四、三种 synchronized 写法分别锁谁)
      • [1. 普通同步方法:锁当前实例](#1. 普通同步方法:锁当前实例)
      • [2. 静态同步方法:锁 Class 对象](#2. 静态同步方法:锁 Class 对象)
      • [3. 同步代码块:锁括号里的对象](#3. 同步代码块:锁括号里的对象)
    • 五、实例锁、类锁和私有锁怎么选
    • [六、为什么工程里更偏向 private final lock](#六、为什么工程里更偏向 private final lock)
    • [七、实现侧补充:为什么 synchronized 能自动释放锁](#七、实现侧补充:为什么 synchronized 能自动释放锁)
    • [八、synchronized 保证什么,不保证什么](#八、synchronized 保证什么,不保证什么)
    • [九、和 Lock 怎么选](#九、和 Lock 怎么选)
    • 十、面试里怎么答
    • 十一、总结

🎬 博主名称: 超级苦力怕

🔥 个人专栏: 《基本功修炼大全》

🚀 每一次思考都是突破的前奏,每一次复盘都是精进的开始!


文章元信息:

  • 适合读者: 已经学过 Java 多线程基础,正在区分 synchronized 实例锁、类锁和自定义锁的读者
  • 前置知识: 了解 Java 类与对象、static、线程安全和 synchronized 的基本语法

本文从 synchronized "锁的到底是谁"这个高频问题切入,先用两个线程调用同步方法的现象说明"同一个方法不一定是同一把锁",再用决策树串起 this、Class 和 private final lock 的选择标准。

写法 锁身份 适合保护
synchronized void m() 当前实例 this 这个对象自己的实例字段
static synchronized void m() 当前类的 Class 对象 类级共享状态、静态字段
synchronized (this) 当前实例 this 需要和其他实例同步方法共用一把锁时
synchronized (Xxx.class) Xxx 的 Class 对象 需要全类共享的一把锁时
synchronized (lock) 括号里的对象 明确设计出来的一把私有锁

一、先看现象:同一个方法不一定是同一把锁

很多人第一次用 synchronized,会把它理解成"给方法加了全局排队"。但普通同步方法并不是按方法名加锁,而是按调用它的对象加锁。

示例:普通同步方法默认锁当前实例对象。

java 复制代码
class Counter {
    private int count = 0;

    public synchronized void increase() {
        count++;
        System.out.println(Thread.currentThread().getName() + " -> " + count);
    }
}

如果两个线程操作的是同一个对象:

示例:两个线程调用同一个对象的方法,会竞争同一把锁。

java 复制代码
Counter counter = new Counter();

new Thread(counter::increase, "A").start();
new Thread(counter::increase, "B").start();

线程 A 和线程 B 竞争的是 counter 这同一个对象锁,所以会互斥。

但如果两个线程操作的是两个不同对象:

示例:两个线程调用不同对象的方法,默认不是同一把锁。

java 复制代码
Counter c1 = new Counter();
Counter c2 = new Counter();

new Thread(c1::increase, "A").start();
new Thread(c2::increase, "B").start();

线程 A 锁的是 c1,线程 B 锁的是 c2。两把锁互不相干,所以两个线程可以同时进入 increase()。

关键不是"这个方法有没有 synchronized",而是:

text 复制代码
所有访问同一份共享数据的线程,是否竞争同一把锁。

二、精确一点:锁对象、monitor 和对象本身的关系

入门时说"synchronized 锁的是对象"没有问题,因为从 Java 代码层面看,确实是某个对象决定了锁身份:

示例:同步代码块用括号里的对象作为锁身份。

java 复制代码
synchronized (lock) {
    // 临界区
}

这里的 lock 表达式会求出一个对象引用,JVM 以这个对象作为同步入口。多个线程只有拿同一个对象做同步入口,才会互斥。

但如果说得更精确一点:

text 复制代码
synchronized 不是把对象本身变成一段互斥代码,
也不是说对象内部有一个 Java 字段叫 monitor。
它是以某个对象作为锁身份,JVM 围绕这个对象关联并获取 monitor。

对象头中的 Mark Word 会记录与锁相关的状态;在锁膨胀等场景下,JVM 会关联到更完整的 monitor 结构。初学阶段不需要背对象头格式,只要避免两个误解:

误解 更准确的理解
对象本身就是锁的全部实现 对象提供锁身份,JVM 负责关联和管理 monitor
每个对象里都有一个 Java 字段叫 monitor monitor 不是普通 Java 字段,不能通过对象属性访问
同一个类的方法天然共用一把锁 普通同步方法默认锁各自的 this

所以,本文后面说"锁 this""锁 Xxx.class""锁 lock",都是从代码可读性的角度说"用哪个对象作为锁身份"。


三、先用决策树选锁

写 synchronized 前,不要先想语法,先问要保护的共享状态属于谁。

text 复制代码
要保护的共享状态是谁?
├─ 只属于某一个实例对象
│  ├─ 可以接受外部也拿这个对象当锁吗?
│  │  ├─ 可以:用 synchronized 实例方法或 synchronized(this)
│  │  └─ 不想暴露:用 private final Object lock
│  └─ 注意:不同实例默认不是同一把锁
├─ 属于整个类,或者是 static 字段
│  ├─ 需要简单写法:用 static synchronized 方法
│  └─ 想隐藏锁对象:用 private static final Object LOCK
├─ 多个对象需要协同保护同一份资源
│  └─ 明确传入或共享同一个 lock 对象
└─ 需要 tryLock、超时、公平锁或多个条件队列
   └─ 考虑 ReentrantLock

这棵树背后的原则很简单:

text 复制代码
共享数据在哪里,锁的范围就要覆盖到哪里。
锁太小,保护不住;锁太大,竞争变重。

后面的所有写法,都可以放回这棵树里判断。


四、三种 synchronized 写法分别锁谁

1. 普通同步方法:锁当前实例

普通同步方法:

示例:普通同步方法写法。

java 复制代码
class Account {
    public synchronized void withdraw() {
        // 扣款逻辑
    }
}

等价于:

示例:普通同步方法等价于锁 this。

java 复制代码
class Account {
    public void withdraw() {
        synchronized (this) {
            // 扣款逻辑
        }
    }
}

它适合保护这个对象自己的实例字段。比如一个 Account 对象有自己的余额,一个 Cart 对象有自己的商品列表。

但同一个类的不同实例不会天然互斥:

示例:两个不同实例对应两把不同的实例锁。

java 复制代码
Account a1 = new Account();
Account a2 = new Account();

a1.withdraw() 锁的是 a1,a2.withdraw() 锁的是 a2。

2. 静态同步方法:锁 Class 对象

静态同步方法:

示例:静态同步方法写法。

java 复制代码
class IdGenerator {
    public static synchronized int nextId() {
        return 1;
    }
}

等价于:

示例:静态同步方法等价于锁当前类的 Class 对象。

java 复制代码
class IdGenerator {
    public static int nextId() {
        synchronized (IdGenerator.class) {
            return 1;
        }
    }
}

静态方法不属于某个实例,所以它锁的是 IdGenerator.class 这个类对象。它适合保护静态字段、全局计数器、类级缓存这类"全类共享"的状态。

3. 同步代码块:锁括号里的对象

同步代码块最灵活:

示例:同步代码块显式指定锁对象。

java 复制代码
synchronized (lock) {
    // 临界区
}

这里锁的不是变量名 lock,而是变量当前指向的对象。只要两个线程进入同步块时,括号里求出来的是同一个对象,它们就会互斥。

这也是为什么锁对象通常要稳定:

反例:锁字段可以被重新赋值,互斥关系会被破坏。

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

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

    public void changeLock() {
        lock = new Object();
    }
}

如果 lock 被换成新对象,后续线程就会去竞争新锁。旧锁和新锁互不相干,互斥关系会被破坏。

更稳的写法是:

示例:把锁对象声明为 private final,防止引用被换掉。

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

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

final 不是让对象"更能锁",而是防止锁引用被换掉。


五、实例锁、类锁和私有锁怎么选

最常见的选择,其实就是这三类。

要保护的状态 推荐锁 说明
实例字段,只属于当前对象 private final Object lock 或 this 不希望暴露锁时,用私有锁
静态字段,全类共享 private static final Object LOCK 或 Xxx.class 不要只用实例锁保护静态状态
多个方法共享同一段实例状态 同一个实例锁 普通同步方法之间默认共用 this
多份独立状态可以并发处理 不同私有锁 减小锁粒度,减少无关竞争

一个典型错误是用实例锁保护静态变量:

反例:实例锁保护不了所有实例共享的静态字段。

java 复制代码
class WrongCounter {
    private static int count = 0;

    public synchronized void increase() {
        count++;
    }
}

count 是全类共享的,但 increase() 锁的是每个实例自己的 this。如果业务里创建了多个 WrongCounter 对象,多线程仍然可能同时修改同一个静态变量。

更匹配的写法是静态同步方法:

示例:静态同步方法用类锁保护静态字段。

java 复制代码
class Counter {
    private static int count = 0;

    public static synchronized void increase() {
        count++;
    }
}

或者使用静态私有锁:

示例:静态私有锁也能覆盖全类共享状态。

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

    public static void increase() {
        synchronized (LOCK) {
            count++;
        }
    }
}

这两种写法都把锁范围提升到了类级别,能覆盖所有实例。


六、为什么工程里更偏向 private final lock

直接锁 this 很方便,但 this 会暴露给外部代码:

反例:外部代码也可以拿到 this 并占用同一把锁。

java 复制代码
OrderService service = new OrderService();

synchronized (service) {
    // 外部代码也锁住了 service 这个对象
}

如果 OrderService 内部也用普通同步方法或 synchronized (this),外部代码就可能意外拖住内部逻辑。更麻烦的是,外部调用者未必知道你内部也在锁 this,锁关系会变得隐蔽。

所以,内部临界区通常更推荐:

示例:用私有锁对象保护类内部状态。

java 复制代码
class OrderService {
    private final Object lock = new Object();
    private int stock = 100;

    public boolean reduceStock() {
        synchronized (lock) {
            if (stock <= 0) {
                return false;
            }
            stock--;
            return true;
        }
    }
}

这把锁只在类内部可见,语义更单一。

同时,下面这些对象不适合作为锁:

不推荐锁的对象 原因
可重新赋值的字段 引用一变,锁就变了
字符串常量 字符串常量池可能让无关代码共享同一对象
包装类对象 可能有缓存,也容易被当成值对象使用
可能为 null 的对象 synchronized (null) 会直接抛 NullPointerException
外部传入或第三方对象 你不知道别的代码是否也拿它当锁

锁对象最好满足三个条件:

text 复制代码
稳定、私有、语义单一。

七、实现侧补充:为什么 synchronized 能自动释放锁

这一节只作为理解边界,不影响前面的锁选择。

从 JVM 实现看,同步方法和同步代码块的形式不同:

写法 JVM 层面的直观理解
同步方法 方法元数据带有同步标记,调用时先获取对应锁
同步代码块 字节码里出现 monitorenter / monitorexit

可以把同步代码块理解成:

text 复制代码
monitorenter:尝试获取与锁对象关联的 monitor
monitorexit:退出同步块,释放 monitor

如果同步块里抛出异常,JVM 也会保证退出路径释放锁。这就是 synchronized 比手写 Lock 更省心的地方:不用自己写 unlock()。

但自动释放只发生在代码块正常结束或异常退出时。如果线程在同步块里死循环、长时间 IO、等待远程服务,它仍然会一直占着锁。


八、synchronized 保证什么,不保证什么

synchronized 解决的是"同一把锁保护下的临界区访问问题"。

它主要保证两件事:

能力 含义
互斥 同一时刻只有一个线程能持有同一把锁并进入对应同步区域
可见性 一个线程释放锁前的修改,对后续获取同一把锁的线程可见

所以,count++ 放进同一把 synchronized 锁里可以安全,是因为读、加、写这几步被放进了互斥临界区,并且退出锁后修改结果对后续拿锁线程可见。

但它不保证这些事:

不保证 说明
不自动避免死锁 两个线程以相反顺序拿多把锁,仍然可能死锁
不承诺公平顺序 等得久的线程不一定先拿到锁
不提升吞吐量 锁会让临界区串行化,粒度过大反而会拖慢
不修复错误的锁选择 不是同一把锁,写再多 synchronized 也保护不住
不替你设计线程协作 等待/通知、队列、线程池仍要按场景选择工具

因此,使用 synchronized 时要同时问两件事:

text 复制代码
这段代码是不是必须互斥?
所有访问同一份共享数据的路径,是不是用了同一把锁?

九、和 Lock 怎么选

synchronized 和 Lock 都能保护临界区,但它们解决问题的姿势不同。

synchronized 简单、内置、自动释放:

示例:synchronized 的临界区写法。

java 复制代码
synchronized (lock) {
    // 临界区
}

Lock 更灵活,但必须手动释放:

示例:ReentrantLock 必须在 finally 中释放。

java 复制代码
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

class Service {
    private final Lock lock = new ReentrantLock();

    public void work() {
        lock.lock();
        try {
            // 临界区
        } finally {
            lock.unlock();
        }
    }
}

可以这样选:

场景 推荐
只需要保护一小段共享状态 优先 synchronized
希望代码简单,异常时自动释放 优先 synchronized
需要尝试加锁、超时放弃 ReentrantLock
需要公平锁 ReentrantLock(true)
需要多个条件队列 Lock + Condition

不要因为 Lock 看起来更"高级"就替换所有 synchronized。普通临界区里,synchronized 往往更清楚,也更不容易忘记释放锁。


十、面试里怎么答

如果面试问:synchronized 锁的到底是谁?

可以这样答:

text 复制代码
synchronized 的互斥效果取决于锁对象。普通同步方法锁的是 this,静态同步方法锁的是当前类的 Class 对象,同步代码块锁的是括号里表达式得到的对象。

更精确地说,Java 代码里是用某个对象作为锁身份,JVM 会围绕这个对象关联并获取 monitor;monitor 不是对象里的普通 Java 字段。只有多个线程竞争同一个锁对象时,互斥才会生效。

所以两个线程调用同一个实例的 synchronized 方法会互斥;如果分别调用两个不同实例的方法,就不是同一把锁。静态同步方法和 synchronized(ClassName.class) 是类锁,所有实例共享。实例锁和类锁不是同一把锁。

工程上一般推荐用 private final Object lock = new Object() 作为内部锁,避免锁 this、字符串常量、包装类或可能变化的字段。synchronized 保证互斥和可见性,但不解决死锁、不承诺公平,也不会提升吞吐量。

如果继续追问 synchronized 和 Lock 的区别,可以补充:

text 复制代码
synchronized 是 Java 关键字,进入和退出由 JVM 管理,代码块结束或异常退出时会自动释放锁;Lock 是 J.U.C 提供的接口,通常用 ReentrantLock,需要手动 lock 和 unlock,必须放在 finally 里释放。

Lock 提供 tryLock、超时等待、公平锁、Condition 等更灵活的能力;synchronized 更简单,适合普通临界区。二者都要关注锁粒度和是否锁住同一个共享状态。

十一、总结

synchronized 的核心不是"写在哪里",而是"用哪个对象作为锁身份"。

误区 正确认知
方法加了 synchronized 就全局互斥 普通同步方法只锁当前实例
两个对象调用同一个同步方法会互斥 不会,它们锁的是不同的 this
静态同步方法和实例同步方法会互斥 不会,类锁和实例锁不是同一把锁
synchronized(lock) 锁的是变量名 锁的是变量当前指向的对象
对象里有一个普通字段叫 monitor monitor 由 JVM 围绕对象锁身份关联和管理
锁对象随便选都行 锁对象要稳定、私有、语义单一
synchronized 能解决所有并发问题 它保证互斥和可见性,不负责死锁、公平性和吞吐量

最后用这句话收束:

text 复制代码
线程安全不是"哪里都加 synchronized",而是"同一份共享数据的所有访问路径,都竞争同一把合适的锁"。