0
点赞
收藏
分享

微信扫一扫

高薪程序员&面试题精讲系列64之synchronized你熟悉吗?它的底层原理是怎么样的?如何对其优化?

c一段旅程c 2022-02-16 阅读 80

一. 面试题及剖析

1. 今日面试题

2. 题目剖析

今天的题目,其实考察的是关于线程安全方面的内容,synchronized和lock锁都是用于保证线程安全的锁技术。在多线程方面,线程安全是考察时的重中之重。面试时,基本上就是先问了不了解多线程,接着就会问怎么保证线程安全,差不多这就是”线程3连“了。

二. synchronized锁

1. 简介

synchronized是Java中的一个关键字,解决的是多线程之间访问同一资源的同步性。它代表了一种同步的加锁操作,保证在同一时刻最多只能有一个线程 来执行被synchronized修饰的方法 或 代码块,这就保证了同一个共享资源在同一时间只能被一个线程访问到

所以synchronized关键字解决了多线程中的并发同步问题,实现了阻塞型的并发,保证了线程的安全。

2. 作用范围

synchronized的作用范围有如下几个:

3. 用法

接下来 壹哥 通过几个方法来展示synchronized关键字的用法。

public class SynchronizedDemo {

    /**
     * 对象锁:形式1,既是对象锁也是方法锁
     */
    public synchronized void Method01() {
        System.out.println("我是对象锁,也是方法锁");
        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }

    /**
     * 对象锁:形式2(代码块)
     */
    public void Method02() {
        synchronized (this) {
            System.out.println("我是对象锁");
            try {
                Thread.sleep(500);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }

    /**
     * 类锁形式1,给静态方法加锁
     */  
    public static synchronized void Method03() {
        System.out.println("类锁形式1");
        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }

    /**
     * 类锁形式2,给静态代码块加锁
     */  
    public void Method04() {
        synchronized (SynchronizedDemo.class) {
            System.out.println("类锁形式2");
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }

}

4. 锁分类

从上面的代码案例中,我们可以知道,synchronized 可以修饰 类的实例方法、静态方法还有代码块,根据这些修饰范围的不同,我们可以将synchronized分为不同类型锁,如下:

5. 底层原理(重点)

5.1 实验代码

在面试时,面试官其实一般不怎么问我们synchronized的用法,更多的是问我们synchronized的底层原理,那它的底层原理到底是什么样的呢?我们先来做个试验。

我们现在随便编写一段带有synchronized锁的方法和代码块,代码如下:

public class SynchronizedTest {

    //同步方法
    public synchronized void doSth(){
        System.out.println("Hello 壹壹哥");
    }

    public void doSth1(){
        //同步代码块
        synchronized (SynchronizedTest.class){
            System.out.println("Hello 壹壹哥");
        }
    }
}

5.2 反编译结果

我们先把上述代码进行编译,生成对应的class字节码,然后利用Javap 或者 JAD 这样的反编译工具,来对以上字节码进行反编译,结果(部分无用信息已过滤)会如下图所示:

5.3 ACC_SYNCHRONIZED与monitor指令

根据上面的反编译结果,我们可以看到Java编译器对doSth()和doSth1()的处理上是不同的,即JVM对同步方法和同步代码块的处理方式不同,处理规则如下:

这里的Monitor指令是依赖于底层操作系统的 Mutex Lock 来实现的,我们了解即可。

5.4 同步方法的底层处理逻辑

在同步方法中,会随着方法的调用和返回值隐式地执行加锁操作其底层是采用ACC_SYNCHRONIZED来进行加锁操作的。同步方法的常量池中会有一个ACC_SYNCHRONIZED标志位,当某个线程要访问某个方法的时候,会先检查该方法是否有ACC_SYNCHRONIZED标记。如果有设置,则需要先获得监视器锁,然后开始执行该方法,方法执行之后再释放监视器锁。这时如果有其他线程来请求执行该同步方法,会因为无法获得监视器锁而被阻断。值得注意的是,如果在同步方法的执行过程中,发生了异常,并且方法内部并没有处理该异常,那么在异常被抛到方法外面之前该监视器锁就会被自动释放。

同步方法的底层处理逻辑,我们可以查看官方文档,具体请参考:The Java® Virtual Machine Specification

5.5 同步代码块的底层处理逻辑

同步代码块的底层加锁逻辑,是通过使用 monitorenter和monitorexit 这两个指令来实现的。我们可以把执行monitorenter指令理解为加锁,执行monitorexit理解为释放锁。 每个实例对象都维护了一个记录着被锁次数的计数器,未被锁定时对象的计数器为0。

关键就是必须要获取对象的监视器monitor锁,当线程获取monitor后才能继续往下执行,否则就只能等待。而这个获取的过程是互斥的,即同一时刻只有一个线程能够获取到monitor锁。当一个线程获得锁(执行monitorenter)后,该计数器自增变为 1;当同一个线程再次获得该对象锁时,计数器会再次自增;当同一个线程释放锁(执行monitorexit指令)时,计数器自减1。当计数器变为0时,锁被释放,其他线程便可以获得锁了。

根据上述内容,我们可以用下图来展示Monitor、同步队列、线程状态之间的关系:

任意线程想要对Object对象进行同步访问,首先都要获得Object对象的监视器。如果获取失败,该线程就会进入到SynchronizedQueue同步队列中,且线程状态变为BLOCKED状态。当Object对象的监视器被之前的占有者释放后,存储在同步队列中的线程就会有机会重新获取该监视器。

同步代码块的底层处理逻辑,我们可以查看官方文档,具体请参考:The Java® Virtual Machine Specification

另外我们还要注意,因为synchronized先天具有可重入性,即在同一个锁程中,线程不需要再次获取同一把锁。所以上面代码案例的反编译结果中,doSth1中的第2个monitorexit指令没有对应的monitorenter指令,不需要再次获取monitorenter指令。

5.6 synchronized原理小节

上面讲解了这么多的内容,比较的复杂,接下来壹哥给大家把synchronized的原理简单总结一下。

以上就是synchronized的加锁和解锁逻辑。

6. Monitor简介

上面的章节中,我们多次提到Monitor对象,那什么是Monitor呢?这里我们对其简单来了解一下。

synchronized是通过对象内部一个叫做“监视器锁(monitor)”的对象来实现的,监视器锁本质上是依赖于底层操作系统的 Mutex Lock(互斥锁)来实现的。操作系统实现线程之间的切换,需要从用户态转换到核心态,这个成本非常高,状态之间的转换需要相对比较长的时间,这就是为什么 synchronized 效率低的原因。因此,这种依赖于操作系统 Mutex Lock 所实现的锁,我们称之为重量级锁。

7. synchronized优化(重点)

synchronized的最大特征就是可以保证在同一时刻只能有一个线程获得对象的监视器(monitor),这就是synchronized的互斥性(排它性)。但这种方式的效率较低,因为每次只能通过一个线程,而我们又不能增加每次通过的线程数量。另外synchronized还是一种重量级锁,使用时会涉及到操作系统状态的切换,效率较低。所以我们很希望可以优化synchronized,那该怎么实现呢?

目前可行的优化方案就是想办法让线程每次通过的速度加快!

Java官方也意识到了这一点,所以从JDK 1.6中开始,在synchronized中引入了偏向锁和轻量级锁,主要从减少获取和释放锁时的消耗方面来进行优化。接下来 壹哥 简单介绍一下关于偏向锁、轻量级锁等优化的内容。

7.1 偏向锁

引入偏向锁是为了在没有多线程竞争时,尽量减少不必要的轻量级锁执行路径。因为轻量级锁的获取及释放会依赖多次的CAS原子指令,而偏向锁只需要在置换ThreadID时依赖一次CAS原子指令(由于一旦出现多线程竞争的情况,就必须撤销偏向锁,所以偏向锁撤销操作的性能损耗必须小于节省下来的CAS原子指令的性能消耗)。

7.1.1 偏向锁的获取

偏向锁也是需要获取的,获取过程如下:

7.1.2 偏向锁的释放

偏向锁只有遇到其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁,线程不会主动去释放偏向锁。偏向锁的撤销,需要等待全局安全点(在这个时间点上没有字节码正在执行),它会首先暂停拥有偏向锁的线程,判断锁对象是否处于被锁定状态,撤销偏向锁后恢复到未锁定(标志位为“01”)或轻量级锁(标志位为“00”)的状态。

7.2 轻量级锁

轻量级锁并不是用来代替重量级锁的,它的本意是在没有多线程竞争的前提下,减少传统的重量级锁使用时所产生的性能消耗。

7.2.1 轻量级锁的加锁过程

7.2.2 轻量级锁的解锁过程

7.3 其他优化

7.4 优化小节

之前一直有人把 synchronized 称之为 “重量级锁” ,但在JDK 1.6中,对synchronized进行了以上这些优化之后,比如引入了 偏向锁 和 轻量级锁,减少了获得锁和释放锁带来的性能消耗而且synchronized 的底层实现现在主要是依靠 Lock-Free 队列,基本思路就是 自旋后阻塞,竞争切换后继续竞争锁,虽然稍微牺牲了公平性,但获得了高吞吐量。在线程冲突较少的情况下,可以获得和 CAS 类似的性能;而线程冲突严重的情况下,性能远高于CAS。所以在JDK 1.6之后,我们大可以放心的使用synchronized,不必太纠结于它的性能问题了。

上述核心内容,我们可以用下图总结展示:

关于synchronized锁,壹哥 今天就给大家讲解这么多,你都明白了吗?下一篇文章,我会给大家分析lock锁及其底层原理,还有synchronized与lock的区别,敬请关注哦。 

举报

相关推荐

0 条评论