【多线程系列 4】机制的“神锁“———synchronized

本文详细解释了Java中的synchronized关键字,包括其互斥、可重入特性,以及wait和notify方法的使用。讲解了synchronized的底层原理,涉及偏向锁、轻量级锁和重量级锁的转换过程。还介绍了锁消除和锁粗化的优化策略。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

🎇🎇🎇🎇🎇🎇🎇🎇🎇🎇🎇🎇🎇🎇🎇🎇🎇🎇🎇🎇

                                synchronized 锁                            

🎇🎇🎇🎇🎇🎇🎇🎇🎇🎇🎇🎇🎇🎇🎇🎇🎇🎇🎇🎇

今日推荐歌曲: death bed (coffee for your head )    -- Powfu / beabadoobee  🎵🎵


文章目录

目录

一、 Synchronize 关键字 - 监视器锁 ( monitor lock )

1.1 synchronized 的特性

 1. 互斥

2. 可重⼊

3. 其他锁特性

二 、synchronized 使用

1. 对对象加锁

2. wait 和 notify (等待和唤醒)

2.1wait()⽅法

2.2 notify()⽅法

2.3 wait 和sleep的对⽐(⾯试题)

三、 synchronized 原理

加锁⼯作过程

1) 偏向锁

2) 轻量级锁

3) 重量级锁

其他的优化操作

锁消除

锁粗化

相关⾯试题

总结


前言 🏐

之前介绍了多线程,了解到可以使用多个线程来执行任务,效率就会大大提高. 但是多线程有个致命问题,就是线程安全问题,可以参考我主页的线程安全问题的那篇博客.为了让线程按照我们所想去执行,我们必须去对线程加锁.这里锁保证程序的正确运行. 这里会详细讲述最常用锁 synchronnized (monitor lock)


一、 Synchronize 关键字 - 监视器锁 ( monitor lock )

1.1 synchronized 的特性

 1. 互斥

synchronized 会起到互斥效果 , 某个线程执⾏到某个对象的synchronized中时,其他线程如果也执⾏ 到同⼀个对象 synchronized 就会阻塞等待.

• 进⼊synchronized修饰的代码块,相当于加锁

• 退出synchronized修饰的代码块,相当于解锁

 

synchronized ⽤的锁是存在 Java 对象头⾥的。

可以粗略理解成,每个对象在内存中存储的时候,都存有⼀块内存表⽰当前的"锁定"状态(类似于厕所 的 "有⼈/⽆⼈" )。

  • 如果当前是"⽆⼈"状态,那么就可以使⽤,使⽤时需要设为"有⼈"状态.
  • 如果当前是"有⼈"状态,那么其他⼈⽆法使⽤,只能排队

理解"阻塞等待".

针对每⼀把锁,操作系统内部都维护了⼀个等待队列.当这个锁被某个线程占有的时候,其他线程尝试 进⾏加锁,就加不上了,就会阻塞等待,⼀直等到之前的线程解锁之后,由操作系统唤醒⼀个新的线程, 再来获取到这个锁.

注意:

  • 上⼀个线程解锁之后,下⼀个线程并不是⽴即就能获取到锁.⽽是要靠操作系统来"唤醒".这也就 是操作系统线程调度的⼀部分⼯作.
  • 假设有ABC三个线程,线程A先获取到锁,然后B尝试获取锁,然后C再尝试获取锁,此时B和C 都在阻塞队列中排队等待.但是当A释放锁之后,虽然B⽐C先来的,但是B不⼀定就能获取到锁, ⽽是和C重新竞争,并不遵守先来后到的规则

synchronized的底层是使⽤操作系统的mutexlock实现的.

2. 可重⼊

synchronized 同步块对同⼀条线程来说是可重⼊的,不会出现⾃⼰把⾃⼰锁死的问题;

理解"把⾃⼰锁死"

⼀个线程没有释放锁,然后⼜尝试再次加锁.

// 第⼀次加锁,加锁成功 lock();

// 第⼆次加锁,锁已经被占⽤,阻塞等待. lock();

按照之前对于锁的设定,第⼆次加锁的时候,就会阻塞等待.直到第⼀次的锁被释放,才能获取到第⼆ 个锁.但是释放第⼀个锁也是由该线程来完成,结果这个线程已经躺平了,啥都不想⼲了,也就⽆法进 ⾏解锁操作.这时候就会死锁.

Java 中的synchronized是可重⼊锁,因此没有上⾯的问题.

for (int i = 0; i < 50000; i++) {
 synchronized (locker) {
 synchronized (locker) {
 count++;}
 }
 }

在可重⼊锁的内部,包含了"线程持有者"和"计数器"两个信息.

  • 如果某个线程加锁的时候,发现锁已经被⼈占⽤,但是恰好占⽤的正是⾃⼰,那么仍然可以继续获取 到锁,并让计数器⾃增.  
  • 解锁的时候计数器递减为0的时候,才真正释放锁.(才能被别的线程获取到)

3. 其他锁特性

以上其他锁特性的详细介绍可以参考我这这篇文章--历年必问高频面试题之--线程安全之常见的锁策略 ! ! !-优快云博客

(这里边详细介绍了上述未提到的锁 🔒)


二 、synchronized 使用

1. 对对象加锁

synchronized 本质上要修改指定对象的"对象头".从使⽤⻆度来看,synchronized也势必要搭配⼀个 具体的对象来使⽤.

1) 修饰代码块 : 明确指定锁哪个对象.

锁任意对象

public class SynchronizedDemo {
     private Object locker = new Object();
     public void method() {
        synchronized (locker) {}
     }
 }

锁当前对象

 public class SynchronizedDemo {
     public void method() {
        synchronized (this) {}
     }
 }

2) 直接修饰普通⽅法 : 锁的SynchronizedDemo对象

public class SynchronizedDemo {
 public synchronized void methond() { }
 }

3) 修饰静态⽅法 :锁的SynchronizedDemo类的对象

 public class SynchronizedDemo {
 public synchronized static void method() { }
 }

我们重点要理解,synchronized锁的是什么.两个线程竞争同⼀把锁,才会产⽣阻塞等待.

两个 线程分别尝试获取两把不同的锁,不会产⽣竞争.


2. wait 和 notify (等待和唤醒)

由于线程之间是抢占式执⾏的,因此线程之间执⾏的先后顺序难以预知.

但是实际开发中有时候我们希望合理的协调多个线程之间的执⾏先后顺序.

球场上的每个运动员都是独⽴的"执⾏流",可以认为是⼀个"线程".

⽽完成⼀个具体的进攻得分动作,则需要多个运动员相互配合,按照⼀定的顺序执⾏⼀定的动作,线程 1 先"传球",线程2才能"扣篮".

完成这个协调⼯作,主要涉及到三个⽅法

  • wait()/wait(long timeout): 让当前线程进⼊等待状态.
  • notify() / notifyAll(): 唤醒在当前对象上等待的线程.

     注 意: wait,notify, notifyAll 都是 Object 类的⽅法.

2.1wait()⽅法

wait 做的事情:

  • 使当前执⾏代码的线程进⾏等待.(把线程放到等待队列中)
  •  释放当前的锁
  •  满⾜⼀定条件时被唤醒,重新尝试获取这个锁.

wait 要搭配synchronized来使⽤.脱离synchronized使⽤wait会直接抛出异常.

wait 结束等待的条件:

• 其他线程调⽤该对象的notify⽅法.

• wait等待时间超时(wait⽅法提供⼀个带有timeout参数的版本,来指定等待时间).

• 其他线程调⽤该等待线程的interrupted⽅法,导致wait抛出 InterruptedException 异常.

代码⽰例:观察wait()⽅法使⽤

public static void main(String[] args) throws InterruptedException {
 Object object = new Object();
   synchronized (object) {
    System.out.println("等待中");
    object.wait();
    System.out.println("等待结束");
   }
 }

这样在执⾏到object.wait()之后就⼀直等待下去,那么程序肯定不能⼀直这么等待下去了。这个时候就 需要使⽤到了另外⼀个⽅法唤醒的⽅法notify()。

2.2 notify()⽅法

notify ⽅法是唤醒等待的线程.

• ⽅法notify()也要在同步⽅法或同步块中调⽤,该⽅法是⽤来通知那些可能等待该对象的对象锁的其 它线程,对其发出通知notify,并使它们重新获取该对象的对象锁。

• 如果有多个线程等待,则有线程调度器随机挑选出⼀个呈wait状态的线程。(并没有"先来后到")

• 在notify()⽅法后,当前线程不会⻢上释放该对象锁,要等到执⾏notify()⽅法的线程将程序执⾏ 完,也就是退出同步代码块之后才会释放对象锁。

代码⽰例: 使⽤notify()⽅法唤醒线程

package Thread;

public class ThreadDemo17 {
    public static void main(String[] args) {
        Object locker = new Object();

        Thread t1  =new Thread(()->{
            synchronized (locker){
                System.out.println("wait之前!");
                try {
                    locker.wait();
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
                System.out.println("wait之后!");
            }
        });
        Thread t2= new Thread(()->{
            try {
                Thread.sleep(3000);
                synchronized(locker){
                    System.out.println("notify之前!");
                    locker.notify();
                    System.out.println("notify之后!");
                }
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }

        });
        t1.start();
        t2.start();


    }
}

2.3 wait 和sleep的对⽐(⾯试题)

其实理论上wait和sleep完全是没有可⽐性的,因为⼀个是⽤于线程之间的通信的,⼀个是让线程阻 塞⼀段时间, 唯⼀的相同点就是都可以让线程放弃执⾏⼀段时间.

当然为了⾯试的⽬的,还是总结下: 

1. wait需要搭配synchronized使⽤.sleep不需要.

2. wait是Object的⽅法sleep是Thread的静态⽅法.


三、 synchronized 原理

基本特点

结合上⾯的锁策略,就可以总结出,synchronized具有以下特性(只考虑JDK1.8):

1. 开始时是乐观锁,如果锁冲突频繁,就转换为悲观锁.

2. 开始是轻量级锁实现,如果锁被持有的时间较⻓,就转换成重量级锁.

3. 实现轻量级锁的时候⼤概率⽤到的⾃旋锁策略

4. 是⼀种不公平锁

5. 是⼀种可重⼊锁

6. 不是读写锁

加锁⼯作过程

JVM将synchronized锁分为⽆锁、偏向锁、轻量级锁、重量级锁状态。会根据情况,进⾏依次升 级。

1) 偏向锁

第⼀个尝试加锁的线程,优先进⼊偏向锁状态.

偏向锁不是真的"加锁" , 只是给对象头中做⼀个"偏向锁的标记" , 记录这个锁属于哪个线程 .  如果后续没有其他线程来竞争该锁 , 那么就不⽤进⾏其他同步操作了 ( 避免了加锁解锁的开销 ) 如果后续有其他线程来竞争该锁 ( 刚才已经在锁对象中记录了当前锁属于哪个线程了 , 很容易识别当前 申请锁的线程是不是之前记录的线程 ) , 那就取消原来的偏向锁状态 , 进⼊⼀般的轻量级锁状态 . 偏向锁本质上相当于" 延迟加锁 " . 能不加锁就不加锁 , 尽量来避免不必要的加锁开销 . 但是该做的标记还是得做的 , 否则⽆法区分何时需要真正加锁.

2) 轻量级锁

随着其他线程进⼊竞争,偏向锁状态被消除 , 进⼊轻量级锁状态(⾃适应的⾃旋锁).

此处的轻量级锁就是通过 CAS 来实现.

• 通过CAS检查并更新⼀块内存(⽐如null=>该线程引⽤)

• 如果更新成功,则认为加锁成功

• 如果更新失败,则认为锁被占⽤,继续⾃旋式的等待(并不放弃CPU).

⾃旋操作是⼀直让CPU空转,⽐较浪费CPU资源.

因此此处的⾃旋不会⼀直持续进⾏,⽽是达到⼀定的时间/重试次数,就不再⾃旋了.

也就是所谓的"⾃适应"

3) 重量级锁

如果竞争进⼀步激烈,⾃旋不能快速获取到锁状态,就会膨胀为重量级锁

此处的重量级锁就是指⽤到内核提供的mutex.

• 执⾏加锁操作,先进⼊内核态.

• 在内核态判定当前锁是否已经被占⽤

• 如果该锁没有占⽤,则加锁成功,并切换回⽤⼾态.

• 如果该锁被占⽤,则加锁失败.此时线程进⼊锁的等待队列,挂起.等待被操作系统唤醒.

• 经历了⼀系列的沧海桑⽥,这个锁被其他线程释放了,操作系统也想起了这个挂起的线程,于是唤醒 这个线程,尝试重新获取锁.

其他的优化操作

锁消除

编译器+JVM判断锁是否可消除.如果可以,就直接消除.

什么是"锁消除"

有些应⽤程序的代码中,⽤到了synchronized,但其实没有在多线程环境下.(例如StringBuffer)

StringBuffer sb = new StringBuffer();
 sb.append("a");
 sb.append("b");
 sb.append("c");
 sb.append("d");

此时每个append的调⽤都会涉及加锁和解锁.但如果只是在单线程中执⾏这个代码,那么这些加锁解 锁操作是没有必要的,⽩⽩浪费了⼀些资源开销.

锁粗化

⼀段逻辑中如果出现多次加锁解锁,编译器+JVM会⾃动进⾏锁的粗化.

实际开发过程中,使⽤细粒度锁,是期望释放锁的时候其他线程能使⽤锁.

但是实际上可能并没有其他线程来抢占这个锁.这种情况JVM就会⾃动把锁粗化,避免频繁申请释放 锁.

相关⾯试题

1. 什么是偏向锁?

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

2. synchronized 实现原理是什么? 参考上⾯的 synchronized 原理 章节全部内容.


总结

可见设计 Java 的大佬们确实为我们这些初学者操碎了心 , 给他们点赞👍👍👍

真嘟很细节, 半夜 手敲 原创  , 小累。希望能帮助大伙 , 有什么问题可以在评论区讨论哦 ~~

 博客不易,点赞 收藏 加关注,知识进脑不迷路!!!

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值