【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) XxxClass 对象 需要全类共享的一把锁时
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() 锁的是 a1a2.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 lockthis 不希望暴露锁时,用私有锁
静态字段,全类共享 private static final Object LOCKXxx.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 怎么选

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

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 保证互斥和可见性,但不解决死锁、不承诺公平,也不会提升吞吐量。

如果继续追问 synchronizedLock 的区别,可以补充:

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",而是"同一份共享数据的所有访问路径,都竞争同一把合适的锁"。