
【Java杂项】synchronized 锁的到底是谁this、Class 和 lock 选择详解一、先看现象同一个方法不一定是同一把锁二、精确一点锁对象、monitor 和对象本身的关系三、先用决策树选锁四、三种 synchronized 写法分别锁谁1. 普通同步方法锁当前实例2. 静态同步方法锁 Class 对象3. 同步代码块锁括号里的对象五、实例锁、类锁和私有锁怎么选六、为什么工程里更偏向 private final lock七、实现侧补充为什么 synchronized 能自动释放锁八、synchronized 保证什么不保证什么九、和 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会把它理解成“给方法加了全局排队”。但普通同步方法并不是按方法名加锁而是按调用它的对象加锁。示例普通同步方法默认锁当前实例对象。classCounter{privateintcount0;publicsynchronizedvoidincrease(){count;System.out.println(Thread.currentThread().getName() - count);}}如果两个线程操作的是同一个对象示例两个线程调用同一个对象的方法会竞争同一把锁。CountercounternewCounter();newThread(counter::increase,A).start();newThread(counter::increase,B).start();线程 A 和线程 B 竞争的是counter这同一个对象锁所以会互斥。但如果两个线程操作的是两个不同对象示例两个线程调用不同对象的方法默认不是同一把锁。Counterc1newCounter();Counterc2newCounter();newThread(c1::increase,A).start();newThread(c2::increase,B).start();线程 A 锁的是c1线程 B 锁的是c2。两把锁互不相干所以两个线程可以同时进入increase()。关键不是“这个方法有没有synchronized”而是所有访问同一份共享数据的线程是否竞争同一把锁。二、精确一点锁对象、monitor 和对象本身的关系入门时说“synchronized锁的是对象”没有问题因为从 Java 代码层面看确实是某个对象决定了锁身份示例同步代码块用括号里的对象作为锁身份。synchronized(lock){// 临界区}这里的lock表达式会求出一个对象引用JVM 以这个对象作为同步入口。多个线程只有拿同一个对象做同步入口才会互斥。但如果说得更精确一点synchronized 不是把对象本身变成一段互斥代码 也不是说对象内部有一个 Java 字段叫 monitor。 它是以某个对象作为锁身份JVM 围绕这个对象关联并获取 monitor。对象头中的 Mark Word 会记录与锁相关的状态在锁膨胀等场景下JVM 会关联到更完整的 monitor 结构。初学阶段不需要背对象头格式只要避免两个误解误解更准确的理解对象本身就是锁的全部实现对象提供锁身份JVM 负责关联和管理 monitor每个对象里都有一个 Java 字段叫monitormonitor 不是普通 Java 字段不能通过对象属性访问同一个类的方法天然共用一把锁普通同步方法默认锁各自的this所以本文后面说“锁this”“锁Xxx.class”“锁lock”都是从代码可读性的角度说“用哪个对象作为锁身份”。三、先用决策树选锁写synchronized前不要先想语法先问要保护的共享状态属于谁。要保护的共享状态是谁 ├─ 只属于某一个实例对象 │ ├─ 可以接受外部也拿这个对象当锁吗 │ │ ├─ 可以用 synchronized 实例方法或 synchronized(this) │ │ └─ 不想暴露用 private final Object lock │ └─ 注意不同实例默认不是同一把锁 ├─ 属于整个类或者是 static 字段 │ ├─ 需要简单写法用 static synchronized 方法 │ └─ 想隐藏锁对象用 private static final Object LOCK ├─ 多个对象需要协同保护同一份资源 │ └─ 明确传入或共享同一个 lock 对象 └─ 需要 tryLock、超时、公平锁或多个条件队列 └─ 考虑 ReentrantLock这棵树背后的原则很简单共享数据在哪里锁的范围就要覆盖到哪里。 锁太小保护不住锁太大竞争变重。后面的所有写法都可以放回这棵树里判断。四、三种 synchronized 写法分别锁谁1. 普通同步方法锁当前实例普通同步方法示例普通同步方法写法。classAccount{publicsynchronizedvoidwithdraw(){// 扣款逻辑}}等价于示例普通同步方法等价于锁this。classAccount{publicvoidwithdraw(){synchronized(this){// 扣款逻辑}}}它适合保护这个对象自己的实例字段。比如一个Account对象有自己的余额一个Cart对象有自己的商品列表。但同一个类的不同实例不会天然互斥示例两个不同实例对应两把不同的实例锁。Accounta1newAccount();Accounta2newAccount();a1.withdraw()锁的是a1a2.withdraw()锁的是a2。2. 静态同步方法锁 Class 对象静态同步方法示例静态同步方法写法。classIdGenerator{publicstaticsynchronizedintnextId(){return1;}}等价于示例静态同步方法等价于锁当前类的Class对象。classIdGenerator{publicstaticintnextId(){synchronized(IdGenerator.class){return1;}}}静态方法不属于某个实例所以它锁的是IdGenerator.class这个类对象。它适合保护静态字段、全局计数器、类级缓存这类“全类共享”的状态。3. 同步代码块锁括号里的对象同步代码块最灵活示例同步代码块显式指定锁对象。synchronized(lock){// 临界区}这里锁的不是变量名lock而是变量当前指向的对象。只要两个线程进入同步块时括号里求出来的是同一个对象它们就会互斥。这也是为什么锁对象通常要稳定反例锁字段可以被重新赋值互斥关系会被破坏。classBadCounter{privateObjectlocknewObject();privateintcount0;publicvoidincrease(){synchronized(lock){count;}}publicvoidchangeLock(){locknewObject();}}如果lock被换成新对象后续线程就会去竞争新锁。旧锁和新锁互不相干互斥关系会被破坏。更稳的写法是示例把锁对象声明为private final防止引用被换掉。classSafeCounter{privatefinalObjectlocknewObject();privateintcount0;publicvoidincrease(){synchronized(lock){count;}}}final不是让对象“更能锁”而是防止锁引用被换掉。五、实例锁、类锁和私有锁怎么选最常见的选择其实就是这三类。要保护的状态推荐锁说明实例字段只属于当前对象private final Object lock或this不希望暴露锁时用私有锁静态字段全类共享private static final Object LOCK或Xxx.class不要只用实例锁保护静态状态多个方法共享同一段实例状态同一个实例锁普通同步方法之间默认共用this多份独立状态可以并发处理不同私有锁减小锁粒度减少无关竞争一个典型错误是用实例锁保护静态变量反例实例锁保护不了所有实例共享的静态字段。classWrongCounter{privatestaticintcount0;publicsynchronizedvoidincrease(){count;}}count是全类共享的但increase()锁的是每个实例自己的this。如果业务里创建了多个WrongCounter对象多线程仍然可能同时修改同一个静态变量。更匹配的写法是静态同步方法示例静态同步方法用类锁保护静态字段。classCounter{privatestaticintcount0;publicstaticsynchronizedvoidincrease(){count;}}或者使用静态私有锁示例静态私有锁也能覆盖全类共享状态。classCounter{privatestaticfinalObjectLOCKnewObject();privatestaticintcount0;publicstaticvoidincrease(){synchronized(LOCK){count;}}}这两种写法都把锁范围提升到了类级别能覆盖所有实例。六、为什么工程里更偏向 private final lock直接锁this很方便但this会暴露给外部代码反例外部代码也可以拿到this并占用同一把锁。OrderServiceservicenewOrderService();synchronized(service){// 外部代码也锁住了 service 这个对象}如果OrderService内部也用普通同步方法或synchronized (this)外部代码就可能意外拖住内部逻辑。更麻烦的是外部调用者未必知道你内部也在锁this锁关系会变得隐蔽。所以内部临界区通常更推荐示例用私有锁对象保护类内部状态。classOrderService{privatefinalObjectlocknewObject();privateintstock100;publicbooleanreduceStock(){synchronized(lock){if(stock0){returnfalse;}stock--;returntrue;}}}这把锁只在类内部可见语义更单一。同时下面这些对象不适合作为锁不推荐锁的对象原因可重新赋值的字段引用一变锁就变了字符串常量字符串常量池可能让无关代码共享同一对象包装类对象可能有缓存也容易被当成值对象使用可能为null的对象synchronized (null)会直接抛NullPointerException外部传入或第三方对象你不知道别的代码是否也拿它当锁锁对象最好满足三个条件稳定、私有、语义单一。七、实现侧补充为什么 synchronized 能自动释放锁这一节只作为理解边界不影响前面的锁选择。从 JVM 实现看同步方法和同步代码块的形式不同写法JVM 层面的直观理解同步方法方法元数据带有同步标记调用时先获取对应锁同步代码块字节码里出现monitorenter/monitorexit可以把同步代码块理解成monitorenter尝试获取与锁对象关联的 monitor monitorexit退出同步块释放 monitor如果同步块里抛出异常JVM 也会保证退出路径释放锁。这就是synchronized比手写Lock更省心的地方不用自己写unlock()。但自动释放只发生在代码块正常结束或异常退出时。如果线程在同步块里死循环、长时间 IO、等待远程服务它仍然会一直占着锁。八、synchronized 保证什么不保证什么synchronized解决的是“同一把锁保护下的临界区访问问题”。它主要保证两件事能力含义互斥同一时刻只有一个线程能持有同一把锁并进入对应同步区域可见性一个线程释放锁前的修改对后续获取同一把锁的线程可见所以count放进同一把synchronized锁里可以安全是因为读、加、写这几步被放进了互斥临界区并且退出锁后修改结果对后续拿锁线程可见。但它不保证这些事不保证说明不自动避免死锁两个线程以相反顺序拿多把锁仍然可能死锁不承诺公平顺序等得久的线程不一定先拿到锁不提升吞吐量锁会让临界区串行化粒度过大反而会拖慢不修复错误的锁选择不是同一把锁写再多synchronized也保护不住不替你设计线程协作等待/通知、队列、线程池仍要按场景选择工具因此使用synchronized时要同时问两件事这段代码是不是必须互斥 所有访问同一份共享数据的路径是不是用了同一把锁九、和 Lock 怎么选synchronized和Lock都能保护临界区但它们解决问题的姿势不同。synchronized简单、内置、自动释放示例synchronized的临界区写法。synchronized(lock){// 临界区}Lock更灵活但必须手动释放示例ReentrantLock必须在finally中释放。importjava.util.concurrent.locks.Lock;importjava.util.concurrent.locks.ReentrantLock;classService{privatefinalLocklocknewReentrantLock();publicvoidwork(){lock.lock();try{// 临界区}finally{lock.unlock();}}}可以这样选场景推荐只需要保护一小段共享状态优先synchronized希望代码简单异常时自动释放优先synchronized需要尝试加锁、超时放弃ReentrantLock需要公平锁ReentrantLock(true)需要多个条件队列LockCondition不要因为Lock看起来更“高级”就替换所有synchronized。普通临界区里synchronized往往更清楚也更不容易忘记释放锁。十、面试里怎么答如果面试问synchronized锁的到底是谁可以这样答synchronized 的互斥效果取决于锁对象。普通同步方法锁的是 this静态同步方法锁的是当前类的 Class 对象同步代码块锁的是括号里表达式得到的对象。 更精确地说Java 代码里是用某个对象作为锁身份JVM 会围绕这个对象关联并获取 monitormonitor 不是对象里的普通 Java 字段。只有多个线程竞争同一个锁对象时互斥才会生效。 所以两个线程调用同一个实例的 synchronized 方法会互斥如果分别调用两个不同实例的方法就不是同一把锁。静态同步方法和 synchronized(ClassName.class) 是类锁所有实例共享。实例锁和类锁不是同一把锁。 工程上一般推荐用 private final Object lock new Object() 作为内部锁避免锁 this、字符串常量、包装类或可能变化的字段。synchronized 保证互斥和可见性但不解决死锁、不承诺公平也不会提升吞吐量。如果继续追问synchronized和Lock的区别可以补充synchronized 是 Java 关键字进入和退出由 JVM 管理代码块结束或异常退出时会自动释放锁Lock 是 J.U.C 提供的接口通常用 ReentrantLock需要手动 lock 和 unlock必须放在 finally 里释放。 Lock 提供 tryLock、超时等待、公平锁、Condition 等更灵活的能力synchronized 更简单适合普通临界区。二者都要关注锁粒度和是否锁住同一个共享状态。十一、总结synchronized的核心不是“写在哪里”而是“用哪个对象作为锁身份”。误区正确认知方法加了synchronized就全局互斥普通同步方法只锁当前实例两个对象调用同一个同步方法会互斥不会它们锁的是不同的this静态同步方法和实例同步方法会互斥不会类锁和实例锁不是同一把锁synchronized(lock)锁的是变量名锁的是变量当前指向的对象对象里有一个普通字段叫monitormonitor 由 JVM 围绕对象锁身份关联和管理锁对象随便选都行锁对象要稳定、私有、语义单一synchronized能解决所有并发问题它保证互斥和可见性不负责死锁、公平性和吞吐量最后用这句话收束线程安全不是“哪里都加 synchronized”而是“同一份共享数据的所有访问路径都竞争同一把合适的锁”。