复习&面试 | Synchronized超详细读解

简介: ♨️本篇文章记录的为JUC知识中Synchronized相关内容,适合在学Java的小白,也适合复习中,面试中的大佬🙉🙉🙉。♨️如果文章有什么需要改进的地方还请大佬不吝赐教❤️🧡💛💖💖。

♨️本篇文章记录的为JUC知识中Synchronized相关内容,适合在学Java的小白,也适合复习中,面试中的大佬🙉🙉🙉。
♨️如果文章有什么需要改进的地方还请大佬不吝赐教❤️🧡💛

💖个人主页
💖继续学习

@[TOC]

Synchronized 的性能变化

  • java5 以前,只有 Synchronized,这个是操作系统级别的重量级操作,重量级锁,假如锁的竞争比较激烈的话,性能下降。
  • 在 Java 早期版本中,synchronized 属于重量级锁,效率低下,因为监视器锁 (monitor) 是依赖于底层的操作系统的 Mutex Lock 来实现的,挂起线程和恢复线程都需要转入内核态去完成,阻塞或唤醒一个 Java 线程需要操作系统切换 CPU 状态来完成,这种状态切换需要耗费处理器时间,如果同步代码块中内容过于简单,这种切换的时间可能比用户代码执行的时间还长”,时间成本相对较高,这也是为什么早期的 synchronized 效率低的原因
  • Java 6 之后,为了减少获得锁和释放锁所带来的性能消耗,引入了轻量级锁和偏向锁,需要有个逐步升级的过程,别一开始就直接使用重量级锁

Synchronized 原理分析

加锁和释放锁的原理

从字节码的角度来看 :创建如下的代码

public class Lock_SyncDemo {
   
   
    private Object object;

    public void m1(){
   
   
        synchronized (object){
   
   
            System.out.println("+++++");
        }
        m2();
    }

    public synchronized void m2(){
   
   
        System.out.println("+++++");
    }
    public static synchronized void m3(){
   
   
        System.out.println("+++++");
    }
}

通过插件 ByteCode 查看 字节码
在这里插入图片描述
关注红色方框里的 monitorentermonitorexit

MonitorenterMonitorexit 指令,会让对象在执行,使其锁计数器加 1 或者减 1。每一个对象在同一时间只与一个 monitor (锁) 相关联,而一个 monitor 在同一时间只能被一个线程获得,一个对象在尝试获得与这个对象相关联的 Monitor 锁的所有权的时候,monitorenter 指令会发生如下 3 中情况之一:

  • monitor 计数器为 0,意味着目前还没有被获得,那这个线程就会立刻获得然后把锁计数器 + 1,一旦 + 1,别的线程再想获取,就需要等待
  • 如果这个 monitor 已经拿到了这个锁的所有权,又重入了这把锁,那锁计数器就会累加,变成 2,并且随着重入的次数,会一直累加
  • 这把锁已经被别的线程获取了,等待锁释放

monitorexit指令:释放对于 monitor 的所有权,释放过程很简单,就是讲 monitor 的计数器减 1,如果减完以后,计数器不是 0,则代表刚才是重入进来的,当前线程还继续持有这把锁的所有权,如果计数器变成 0,则代表当前线程不再拥有该 monitor 的所有权,即释放锁。

每一个对象都可以成为锁
  • Java 对象是天生的 Monitor , 每一个 Java 对象都有成为 Monitor 的潜质,因为在 Java 的设计中,每一个 Java 对象自被创建出来出来就带了一把看不见的锁,它叫做内部锁或者 Monitor 锁。
  • Monitor 的本质是依赖于底层操作系统的Mutex Lock实现,操作系统实现线程之间的切换需要从用户态到内核态的转换,成本非常高

每个 Monitor 对象 都包括 ContentionList 、EntryList 、 WaitList 、OnDeck 、 Owner、!Owner 这 6 个属性,每个属性的数据都代表不同的状态。

  • ContentionList :锁竞争队列,所有请求锁的线程都被放在锁竞争队列中。
  • EntryList:竞争候选队列,在 ContentionList 中有资格成为候选者来竞争锁资源的线程都被移动到了 - EntryList 中。
  • WaitSet:等待集合,调用 wait 方法后被阻塞的线程放在 WaitSet 中
  • OnDeck:竞争候选者,在同一时刻最多只有一个线程在竞争锁资源,该线程的 Id 就被记录在 Ondeck 中
  • Owner:竞争到锁资源的线程 Id 被记录在 Owner 中。
  • !Owner:在 Owner 中记录的线程释放锁后,会被移动到!Owner 中。

Synchronized 在收到新的锁请求时,先自旋,如果通过自旋也没获取到锁资源,则将被放入 ContentionList 中。当 Owner 线程释放锁时,将 ContentionList 中的部分线程移动到 EntryList 中,并指定 EntryList 中某个线程为 OnDeck 线程(一般为最先进入 EntryList 的线程),Owner 线程并没有把 锁传递给 OnDeck ,而是把锁竞争的权力交给 OnDeck 线程,让 OnDeck 线程重新参与竞争锁,这是锁膨胀以及获取锁的一个简略过程,实际还有锁重入的判断。

下图表现了对象,对象监视器,同步队列以及执行线程状态之间的关系:

在这里插入图片描述
可重入原理:加锁次数计数器

可重入锁:又名递归锁,是指在同一个线程在外层方法获取锁的时候,再进入该线程的内层方法会自动获取锁(前提锁对象得是同一个对象或者 class),不会因为之前已经获取过还没释放而阻塞。

public class Main {
   
   
    public static void main(String[] args) {
   
   
        Main demo =  new Main();
        demo.m1();
    }

    private synchronized void m1() {
   
   
        System.out.println(Thread.currentThread().getId() + ": method1()");
        m2();
    }

    private synchronized void m2() {
   
   
        System.out.println(Thread.currentThread().getId()+ ": method2()");
        m3();
    }

    private synchronized void m3() {
   
   
        System.out.println(Thread.currentThread().getId()+ ": method3()");
    }
}

结合前文中加锁和释放锁的原理,不难理解:

  • 执行 monitorenter 获取锁
    • (monitor 计数器 = 0,可获取锁)
    • 执行 m1 () 方法,monitor 计数器 + 1 -> 1 (获取到锁)
    • 执行 m2 () 方法,monitor 计数器 + 1 -> 2
    • 执行 m3 () 方法,monitor 计数器 + 1 -> 3
  • 执行 monitorexit 命令
    • m3 () 方法执行完,monitor 计数器 - 1 -> 2
    • m2 () 方法执行完,monitor 计数器 - 1 -> 1
    • m2 () 方法执行完,monitor 计数器 - 1 -> 0 (释放了锁)
    • (monitor 计数器 = 0,锁被释放了)

这就是 Synchronized 的重入性,即在同一锁程中,每个对象拥有一个 monitor 计数器,当线程获取该对象锁后,monitor 计数器就会加一,释放锁后就会将 monitor 计数器减一,线程不需要再次获取同一把锁。

保证可见性的原理:管程锁定规则

管程锁定规则 :一个 unlock 操作先行发生于后面对同一个锁的 lock 操作。

public class MonitorDemo {
   
   
    private int a = 0;

    public synchronized void writer() {
   
        // 1
        a++;                                // 2
    }                                       // 3

    public synchronized void reader() {
   
       // 4
        int i = a;                         // 5
    }                                      // 6
}

根据 happens-before 的定义中的一条:如果 A happens-before B,则 A 的执行结果对 B 可见,并且 A 的执行顺序先于 B。线程 A 先对共享变量 A 进行加一,由 2 happens-before 5 关系可知线程 A 的执行结果对线程 B 可见即线程 B 所读取到的 a 的值为 1。

JVM 中锁的优化

我们都知道,由于使用 Mutex Lock 需要将当前线程挂起并从用户态切换到内核态来执行,这种切换的代价是非常昂贵的;然而在现实中的大部分情况下,同步方法是运行在单线程环境 (无锁竞争环境) 如果每次都调用 Mutex Lock 那么将严重的影响程序的性能。但是在 jdk1.6 之后对锁的实现引入了大量的优化,如锁粗化 (Lock Coarsening)、锁消除 (Lock Elimination)、轻量级锁 (Lightweight Locking)、偏向锁 (Biased Locking)、适应性自旋 (Adaptive Spinning) 等技术来减少锁操作的开销。

  • 锁粗化(Lock Coarsening):也就是减少不必要的紧连在一起的 unlock,lock 操作,将多个连续的锁扩展成一个范围更大的锁。
  • 锁消除(Lock Elimination):通过运行时 JIT编译器的逃逸分析来消除一些没有在当前同步块以外被其他线程共享的数据的锁保护,通过逃逸分析也可以在线程本的 Stack 上进行对象空间的分配 (同时还可以减少 Heap 上的垃圾收集开销)。
  • 轻量级锁(Lightweight Locking):这种锁实现的背后基于这样一种假设,即在真实的情况下我们程序中的大部分同步代码一般都处于无锁竞争状态 (即单线程执行环境),在无锁竞争的情况下完全可以避免调用操作系统层面的重量级互斥锁,取而代之的是在 monitorenter 和monitorexit 中只需要依靠一条 CAS 原子指令就可以完成锁的获取及释放。当存在锁竞争的情况下,执行 CAS 指令失败的线程将调用操作系统互斥锁进入到阻塞状态,当锁被释放的时候被唤醒 (具体处理步骤下面详细讨论)。
  • 偏向锁(Biased Locking):是为了在无锁竞争的情况下避免在锁获取过程中执行不必要的 CAS 原子指令,因为 CAS 原子指令虽然相对于重量级锁来说开销比较小但还是存在非常可观的本地延迟。
  • 适应性自旋(Adaptive Spinning):当线程在获取轻量级锁的过程中执行 CAS 操作失败时,在进入与 monitor 相关联的操作系统重量级锁 (mutex semaphore) 前会进入忙等待 (Spinning)
    然后再次尝试,当尝试一定的次数后如果仍然没有成功则调用与该 monitor 关联的 semaphore (即互斥锁) 进入到阻塞状态。
锁的类型

在 Java SE 1.6 里 Synchronied 同步锁,一共有四种状态:无锁、偏向锁、轻量级锁、重量级锁,它会随着竞争情况逐渐升级。锁可以升级但是不可以降级,目的是为了提供获取锁和释放锁的效率。

膨胀方向: 无锁 → 偏向锁 → 轻量级锁 → 重量级锁 (此过程是不可逆的)

自旋锁

自旋锁本质上与阻塞并不相同,先不考虑其对多处理器的要求,如果锁占用的时间非常的短,那么自旋锁的性能会非常的好,相反,其会带来更多的性能开销 (因为在线程自旋时,始终会占用 CPU 的时间片,如果锁占用的时间太长,那么自旋的线程会白白消耗掉 CPU 资源)。因此自旋等待的时间必须要有一定的限度,如果自旋超过了限定的次数仍然没有成功获取到锁,就应该使用传统的方式去挂起线程了,在 JDK 定义中,自旋锁默认的自旋次数为 10 次,用户可以使用参数 -XX:PreBlockSpin 来更改。 可是现在又出现了一个问题:如果线程锁在线程刚好自旋 10 次后刚结束就释放掉了锁,那么是不是有点得不偿失。所以这时候我们需要更加聪明的锁来实现更加灵活的自旋。来提高并发的性能。(这里则需要自适应自旋锁!)

自适应自旋锁

在 JDK 1.6 中引入了自适应自旋锁。这就意味着自旋的时间不再固定了,而是由前一次在同一个锁上的自旋 时间及锁的拥有者的状态来决定的。如果在同一个锁对象上,自旋等待刚刚成功获取过锁,并且持有锁的线程正在运行中,那么 JVM 会认为该锁自旋获取到锁的可能性很大,会自动增加等待时间。比如增加到 100 此循环。相反,如果对于某个锁,自旋很少成功获取锁。那再以后要获取这个锁时将可能省略掉自旋过程,以避免浪费处理器资源。有了自适应自旋,JVM 对程序的锁的状态预测会越来越准确,JVM 也会越来越聪明

锁消除

锁消除是指虚拟机即时编译器再运行时,对一些代码上要求同步,但是被检测到不可能存在共享数据竞争的锁进行消除。锁消除的主要判定依据来源于逃逸分析的数据支持。意思就是:JVM 会判断再一段程序中的同步明显不会逃逸出去从而被其他线程访问到,那 JVM 就把它们当作栈上数据对待,认为这些数据是线程独有的,不需要加同步。此时就会进行锁消除。

当然在实际开发中,我们很清楚的知道哪些是线程独有的,不需要加同步锁,但是在 Java API 中有很多方法都是加了同步的,那么此时 JVM 会判断这段代码是否需要加锁。如果数据并不会逃逸,则会进行锁消除。

锁粗化

原则上,我们都知道在加同步锁时,尽可能的将同步块的作用范围限制到尽量小的范围 (只在共享数据的实际作用域中才进行同步,这样是为了使得需要同步的操作数量尽可能变小。在存在锁同步竞争中,也可以使得等待锁的线程尽早的拿到锁)。

大部分上述情况是完美正确的,但是如果存在连串的一系列操作都对同一个对象反复加锁和解锁,甚至加锁操作时出现在循环体中的,那即使没有线程竞争,频繁的进行互斥同步操作也会导致不必要的性能操作。

public static String test(String s1, String s2, String s3) {
   
   
    StringBuffer sb = new StringBuffer();
    sb.append(s1);
    sb.append(s2);
    sb.append(s3);
    return sb.toString();
}

在上述的连续 append () 操作中就属于这类情况。JVM 会检测到这样一连串的操作都是对同一个对象加锁,那么 JVM 会将加锁同步的范围扩展 (粗化) 到整个一系列操作的 外部,使整个一连串的 append () 操作只需要加锁一次就可以了。

偏向锁

/**
 *
 * @author him
 * @date 2022/10/21
 */
public class producersAndConsumers1 {
   
   
    public static void main(String[] args) {
   
   
        AirCondition airCondition=new AirCondition();
        new Thread(()->{
   
    for (int i = 1; i <100 ; i++) airCondition.increment();},"线程A").start();
        new Thread(()->{
   
    for (int i = 1; i <100 ; i++) airCondition.decrement();},"线程B").start();
    }
    static class AirCondition{
   
   
        private int number=0;
        public synchronized void increment(){
   
   
            System.out.println(Thread.currentThread().getName()+":"+ ++number);
        }
        public  synchronized void decrement(){
   
   
            System.out.println(Thread.currentThread().getName()+":"+ --number);
        }
    }
}

通过运行结果我们会发现:在大多实际环境下,锁不仅不存在多线程竞争,而且总是由同一个线程多次获取,那么在同一个线程反复获取所释放锁中,其中并还没有锁的竞争,那么这样看上去,多次的获取锁和释放锁带来了很多不必要的性能开销和上下文切换。

于是 jdk1.6 中对 Synchronized 进行了优化,引入了偏向锁。当一个线程访问同步块并获取锁时,会在对象头和栈帧中的锁记录里存储锁偏向的线程 ID,以后该线程在进入和推出同步块时不需要进行 CAS 操作来加锁和解锁。只需要简单的测试一下对象头的 Mark Word 里是否存储着指向当前线程的偏向锁。如果成功,表示线程已经获取到了锁。

偏向锁的撤销

偏向锁使用了一种等待竞争出现才会释放锁的机制。所以当其他线程尝试获取偏向锁时,持有偏向锁的线程才会释放锁。但是偏向锁的撤销需要等到全局安全点 (就是当前线程没有正在执行的字节码)。

  1. 它会首先暂停拥有偏向锁的线程,让你后检查持有偏向锁的线程是否活着。
  2. 如果线程不处于活动状态,直接将对象头设置为无锁状态。
  3. 如果线程活着,JVM 会遍历栈帧中的锁记录,栈帧中的锁记录和对象头要么偏向于其他线程,要么恢复到无锁状态或者标记对象不适合作为偏向锁。

在这里插入图片描述

轻量级锁

如果要理解轻量级锁,那么必须先要了解 HotSpot 虚拟机中对象头的内存布局。上面介绍 Java 对象头也详细介绍过。在对象头中 (Object Header) 存在两部分。第一部分用于存储对象自身的运行时数据,HashCode、GC Age、锁标记位、是否为偏向锁。等。一般为 32 位或者 64 位 (视操作系统位数定)。官方称之为 Mark Word,它是实现轻量级锁和偏向锁的关键。 另外一部分存储的是指向方法区对象类型数据的指针 (Klass Point),如果对象是数组的话,还会有一个额外的部分用于存储数据的长度

轻量级锁加锁

在线程执行同步块之前,JVM 会先在当前线程的栈帧中创建一个名为锁记录 (Lock Record) 的空间,用于存储锁对象目前的 Mark Word 的拷贝 (JVM 会将对象头中的 Mark Word 拷贝到锁记录中,官方称为 Displaced Mark Ward) 这个时候线程堆栈与对象头的状态如图:
在这里插入图片描述
如上图所示:如果当前对象没有被锁定,那么锁标志位为 01 状态,JVM 在执行当前线程时,首先会在当前线程栈帧中创建锁记录 Lock Record 的空间用于存储锁对象目前的 Mark Word 的拷贝。

然后,虚拟机使用 CAS 操作将标记字段 Mark Word 拷贝到锁记录中,并且将 Mark Word 更新为指向 Lock Record 的指针。如果更新成功了,那么这个线程就有用了该对象的锁,并且对象 Mark Word 的锁标志位更新为 (Mark Word 中最后的 2bit) 00,即表示此对象处于轻量级锁定状态,如图:

在这里插入图片描述
如果这个更新操作失败。

  1. JVM 会检查当前的 Mark Word 中是否存在 指向当前线程的栈帧锁记录的指针,
  2. 如果有,说明该锁已经被获取,可以直接调用。
  3. 如果没有,则说明该锁被其他线程抢占了,如果有两条以上的线程竞争同一个锁,那轻量级锁就不再有效,直接膨胀为重量级锁,没有获得锁的线程会被阻塞。此时,锁的标志位为 10,MarkWord 中存储的时指向重量级锁的指针。

轻量级解锁时,会使用原子的 CAS 操作将 Displaced Mark Word 替换回到对象头中,如果成功,则表示没有发生竞争关系。如果失败,表示当前锁存在竞争关系。锁就会膨胀成重量级锁。两个线程同时争夺锁,导致锁膨胀的流程图如下:
在这里插入图片描述

锁的优缺点对比

在这里插入图片描述

Synchronized 与 Lock

synchronized 的缺陷
  • 效率低:锁的释放情况少,只有代码执行完毕或者异常结束才会释放锁;试图获取锁的时候不能设定超时,不能中断一个正在使用锁的线程,相对而言,Lock 可以中断和设置超时
  • 不够灵活:加锁和释放的时机单一,每个锁仅有一个单一的条件 (某个对象),相对而言,读写锁更加灵活
  • 无法知道是否成功获得锁,相对而言,Lock 可以拿到状态,如果成功获取锁,….,如果获取失败,….
Lock 解决相应问题

Lock 类这里不做过多解释,主要看里面的 4 个方法:

  • lock(): 加锁
  • unlock(): 解锁
  • tryLock(): 尝试获取锁,返回一个 boolean 值
  • tryLock(long,TimeUtil): 尝试获取锁,可以设置超时

Synchronized 只有锁只与一个条件 (是否获取锁) 相关联,不灵活,后来 Condition与Lock的结合解决了这个问题。

多线程竞争一个锁时,其余未得到锁的线程只能不停的尝试获得锁,而不能中断。高并发的情况下会导致性能下降。ReentrantLock 的 lockInterruptibly () 方法可以优先考虑响应中断。 一个线程等待时间过长,它可以中断自己,然后 ReentrantLock 响应这个中断,不再让这个线程继续等待。有了这个机制,使用 ReentrantLock 时就不会像 synchronized 那样产生死锁了。

深入理解

synchronized 是通过软件 (JVM) 实现的,简单易用,即使在 JDK5 之后有了 Lock,仍然被广泛的使用。

  • 使用 Synchronized 有哪些要注意的?
    • 锁对象不能为空,因为锁的信息都保存在对象头里
    • 作用域不宜过大,影响程序执行的速度,控制范围过大,编写代码也容易出错
    • 避免死锁
    • 在能选择的情况下,既不要用 Lock 也不要用 synchronized 关键字,用 java.util.concurrent 包中的各种各样的类,如果不用该包下的类,在满足业务的情况下,可以使用 synchronized 关键,因为代码量少,避免出错
  • synchronized 是公平锁吗?

synchronized 实际上是非公平的,新来的线程有可能立即获得监视器,而在等待区中等候已久的线程可能再次等待,这样有利于提高性能,但是也可能会导致饥饿现象。

如果这篇【文章】有帮助到你💖,希望可以给我点个赞👍,创作不易,如果有对Java后端或者对redis感兴趣的朋友,请多多关注💖💖💖
💖个人主页
💖阿千弟

目录
相关文章
|
6月前
|
存储 Java 开发者
面试官:小伙子知道synchronized的优化过程吗?我:嘚吧嘚吧嘚,面试官:出去!
面试官:小伙子知道synchronized的优化过程吗?我:嘚吧嘚吧嘚,面试官:出去!
72 1
|
6月前
|
Java
【面试问题】Synchronized 和 ReentrantLock 区别?
【1月更文挑战第27天】【面试问题】Synchronized 和 ReentrantLock 区别?
|
10天前
|
存储 安全 Java
面试高频:Synchronized 原理,建议收藏备用 !
本文详解Synchronized原理,包括其作用、使用方式、底层实现及锁升级机制。关注【mikechen的互联网架构】,10年+BAT架构经验倾囊相授。
面试高频:Synchronized 原理,建议收藏备用 !
|
4月前
|
缓存 安全 算法
Java面试题:如何通过JVM参数调整GC行为以优化应用性能?如何使用synchronized和volatile关键字解决并发问题?如何使用ConcurrentHashMap实现线程安全的缓存?
Java面试题:如何通过JVM参数调整GC行为以优化应用性能?如何使用synchronized和volatile关键字解决并发问题?如何使用ConcurrentHashMap实现线程安全的缓存?
46 0
|
1月前
|
存储 安全 Java
面试题:再谈Synchronized实现原理!
面试题:再谈Synchronized实现原理!
|
3月前
|
存储 安全 Java
【多线程面试题十七】、如果不使用synchronized和Lock,如何保证线程安全?
这篇文章探讨了在不使用`synchronized`和`Lock`的情况下保证线程安全的方法,包括使用`volatile`关键字、原子变量、线程本地存储(`ThreadLocal`)以及设计不可变对象。
|
3月前
|
Java
【多线程面试题十五】、synchronized可以修饰静态方法和静态代码块吗?
这篇文章讨论了Java中的`synchronized`关键字是否可以修饰静态方法和静态代码块,指出`synchronized`可以修饰静态方法,创建一个类全局锁,但不能修饰静态代码块。
|
3月前
|
Java 调度
【多线程面试题十四】、说一说synchronized的底层实现原理
这篇文章解释了Java中的`synchronized`关键字的底层实现原理,包括它在代码块和方法同步中的实现方式,以及通过`monitorenter`和`monitorexit`指令以及`ACC_SYNCHRONIZED`访问标志来控制线程同步和锁的获取与释放。
|
3月前
|
Java
【多线程面试题十三】、说一说synchronized与Lock的区别
这篇文章讨论了Java中`synchronized`和`Lock`接口在多线程编程中的区别,包括它们在实现、使用、锁的释放、超时设置、锁状态查询以及锁的属性等方面的不同点。
|
4月前
|
安全 Java
Java面试题:解释synchronized关键字在Java内存模型中的语义
Java面试题:解释synchronized关键字在Java内存模型中的语义
46 1