synchronized用法原理和锁优化升级过程(面试)

简介

多线程一直是面试中的重点和难点,无论你现在处于啥级别段位,对synchronized关键字的学习避免不了,这是我的心得体会。下面咱们以面试的思维来对synchronized做一个系统的描述,如果有面试官问你,说说你对synchronized的理解?你可以从synchronized使用层面synchronized的JVM层面synchronized的优化层面3个方面做系统回答,说不定面试官会对你刮目相看哦!文章会有大量的代码是方便理解的,如果你有时间一定要动手敲下加深理解和记忆。如果这篇文章能对您能有所帮助是我创作路上最大欣慰。

image.png

synchronized使用层面

大家都知道synchronized是一把锁,锁究竟是什么呢?举个例子,你可以把锁理解为厕所门上那把锁的唯一钥匙,每个人要进去只能拿着这把钥匙可以去开这个厕所的门,这把钥匙在一时刻只能有一个人拥有,有钥匙的人可以反复出入厕所,在程序中我们叫做这种重复出入厕所行为叫锁的可重入。它可以修饰静态方法,实例方法和代码块 ,那下面我们一起来看看synchronized用于同步代码锁表达的意思。

  • 对于普通同步方法,锁的是对象实例。
  • 对于静态同步方法,锁的是类的Class对象。
  • 对于同步代码块,锁的是括号中的对象。

先说下同步和异步的概念。

  • 同步:交替执行。
  • 异步:同时执行。

举个例子比如吃饭和看电视两件事情,先吃完饭后再去看电视,在时间维度上这两件事是有先后顺序的,叫同步。可以一边吃饭,一边看刷剧,在时间维度上是不分先后同时进行的,饭吃完了电视也看了,就可以去学习了,这就是异步,异步的好处是可以提高效率,这样你就可以节省时间去学习了。

下面我们看看代码,代码中有做了很详细的注释,可以复制到本地进行测试。如果有synchronized基础的童鞋,可以跳过锁使用层面的讲解。

/**
 * @author :jiaolian
 * @date :Created in 2020-12-17 14:48
 * @description:测试静态方法同步和普通方法同步是不同的锁,包括synchronized修饰的静态代码块用法;
 * @modified By:
 * 公众号:叫练
 */
public class SyncTest {

    public static void main(String[] args) {
        Service service = new Service();
        /**
         * 启动下面4个线程,分别测试m1-m4方法。
         */
        Thread threadA = new Thread(() -> Service.m1());
        Thread threadB = new Thread(() -> Service.m2());
        Thread threadC = new Thread(() -> service.m3());
        Thread threadD = new Thread(() -> service.m4());
        threadA.start();
        threadB.start();
        threadC.start();
        threadD.start();

    }

    /**
     * 此案例说明了synchronized修饰的静态方法和普通方法获取的不是同一把锁,因为他们是异步的,相当于是同步执行;
     */
    private static class Service {
        /**
         * m1方法synchronized修饰静态方法,锁表示锁定的是Service.class
         */
        public synchronized static void m1() {
            System.out.println("m1 getlock");
            try {
                Thread.sleep(2000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println("m1 releaselock");
        }

        /**
         * m2方法synchronized修饰静态方法,锁表示锁定的是Service.class
         * 当线程AB同时启动,m1和m2方法是同步的。可以证明m1和m2是同一把锁。
         */
        public synchronized static void m2() {
            System.out.println("m2 getlock");
            System.out.println("m2 releaselock");
        }

        /**
         * m3方法synchronized修饰的普通方法,锁表示锁定的是Service service = new Service();中的service对象;
         */
        public synchronized void m3() {
            System.out.println("m3 getlock");
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println("m3 releaselock");
        }

        /**
         * 1.m4方法synchronized修饰的同步代码块,锁表示锁定的是当前对象实例,也就是Service service = new Service();中的service对象;和m3一样,是同一把锁;
         * 2.当线程CD同时启动,m3和m4方法是同步的。可以证明m3和m4是同一把锁。
         * 3.synchronized也可以修饰其他对象,比如synchronized (Service.class),此时m4,m1,m2方法是同步的,启动线程ABD可以证明。
         */
        public void m4() {
            synchronized (this) {
                System.out.println("m4 getlock");
                System.out.println("m4 releaselock");
            }
        }

    }
}

经过上面的测试,你可以能会有疑问,锁既然是存在的,那它存储在什么地方?答案:对象里面。下面我们用代码来证明下。

锁在对象头里面,一个对象包括对象头,实例数据和对齐填充。对象头包括MarkWord和对象指针,对象指针是指向方法区的对象类型的,,实例对象就是属性数据,一个对象可能有很多属性,属性是动态的。对齐填充是为了补齐字节数的,如果对象大小不是8字节的整数倍,需要补齐剩余的字节数,这是方便计算机来计算的。在64位机器里面,一个对象的对象头一般占12个自己大小,在64位操作系统一般占4个字节,所以MarkWord就是8个字节了。

MarkWord包括对象hashcode,偏向锁标志位,线程id和锁的标识。为了方便测试对象头的内容,需要引入maven openjdk的依赖包。

<dependency>
  <groupId>org.openjdk.jol</groupId>
  <artifactId>jol-core</artifactId>
  <version>0.10</version>
</dependency>
/**
 * @author :duyang
 * @date :Created in 2020-05-14 20:21
 * @description:对象占用内存
 * @modified By:
 *
 *  Fruit对象头是12字节(markword+class)
 *  int 占4个字节
 *
 *  32位机器可能占8个字节;
 *
 *  Object对象头12 对齐填充4 一共是16
 */
public class ObjectMemory {
    public static void main(String[] args) {
        //System.out.print(ClassLayout.parseClass(Fruit.class).toPrintable());
        System.out.print(ClassLayout.parseInstance(Fruit.class).toPrintable());
    }
}

/**
 *Fruit 测试类
 */
public class Fruit {

    //占一个字节大小
    private boolean flag;

}

测试结果:下面画红线的3行分别表示对象头,实例数据和对齐填充。对象头是12个字节,实例数据Fruit对象的一个boolean字段flag占1个字节大小,其余3个字节是对齐填充的部分,一共是16个字节大小。

image.png

咦?你说的锁呢,怎么没有看到呢?小伙,别着急,待会我们讲到synchronized升级优化层面的时候再来详细分析一波。下面我们先分析下synchronized在JVM层面的意思。

image.png

最后上图文总结:

image.png

synchronized JVM层面

/**
 * @author :jiaolian
 * @date :Created in 2020-12-20 13:43
 * @description:锁的jvm层面使用
 * @modified By:
 * 公众号:叫练
 */
public class SyncJvmTest {
    public static void main(String[] args) {
        synchronized (SyncJvmTest.class) {
            System.out.println("jvm同步测试");
        }
    }
}

上面的案例中,我们同步代码块中我们简单输出一句话,我们主要看看jvm中它是怎么实现的。我们用Javap -v SyncJvmTest.class反编译出上面的代码,如下图所示。

image.png

上图第一行有一个monitorenter和第六行一个monitorexit,中间的jvm指令(2-5行)对应的Java代码中的main方法的代码,synchronized就是依赖于这两个指令实现。我们来看看JVM规范中monitorenter语义

  1. 每个对象都有一把锁,当一个线程进入同步代码块,都会去获取这个对象所持有monitor对象锁(C++实现),如果当前线程获取锁,会把monitor对象进入数自增1次。
  2. 如果该线程重复进入,会把monitor对象进入数再次自增1次。
  3. 当有其他线程进入,会把其他线程放入等待队列排队,直到获取锁的线程将monitor对象的进入数设置为0释放锁,其他线程才有机会获取锁。

synchronized的优化层面

synchronized是一个重量级锁,主要是因为线程竞争锁会引起操作系统用户态和内核态切换,浪费资源效率不高,在jdk1.5之前,synchronized没有做任何优化,但在jdk1.6做了性能优化,它会经历偏向锁,轻量级锁,最后才到重量级锁这个过程,在性能方面有了很大的提升,在jdk1.7的ConcurrentHashMap是基于ReentrantLock的实现了锁,但在jdk1.8之后又替换成了synchronized,就从这一点可以看出JVM团队对synchronized的性能还是挺有信心的。下面我们分别来介绍下无锁,偏向锁,轻量级锁,重量级锁。下面我们我画张图来描述这几个级别锁的在对象头存储状态。如图所示。

image.png

  • 无锁。如果不加synchronized关键字,表示无锁,很好理解。
  • 偏向锁。
  • 升级过程:当线程进入同步块时,Markword会存储偏向线程的id并且cas将Markword锁状态标识为01,是否偏向用1表示当前处于偏向锁(对着上图来看),如果是偏向线程下次进入同步代码只要比较Markword的线程id是否和当前线程id相等,如果相等不用做任何操作就可以进入同步代码执行,如果不比较后不相等说明有其他线程竞争锁,synchronized会升级成轻量级锁。这个过程中在操作系统层面不用做内核态和用户态的切换,减少切换线程带来的资源消耗。
  • 膨胀过程:当有另外线程进入,偏向锁会升级成轻量级锁。比如线程A是偏向锁,这是B线程进入,就会成轻量级锁,只要有两个线程就会升级成轻量级锁

下面我们代码来看下偏向锁的锁状态。

package com.duyang.base.basic.markword;

import lombok.SneakyThrows;
import org.openjdk.jol.info.ClassLayout;

/**
 * @author :jiaolian
 * @date :Created in 2020-12-19 11:25
 * @description:markword测试
 * @modified By:
 * 公众号:叫练
 */
public class MarkWordTest {

    private static Fruit fruit = new Fruit();

    public static void main(String[] args) throws InterruptedException {
        Task task = new Task();
        Thread threadA = new Thread(task);
        Thread threadB = new Thread(task);
        Thread threadC = new Thread(task);
        threadA.start();
        //threadA.join();
        //threadB.start();
        //threadC.start();
    }

    private static class Task extends Thread {

        @SneakyThrows
        @Override
        public void run() {
            synchronized (fruit) {
                System.out.println("==================="+Thread.currentThread().getId()+" ");
                try {
                    Thread.sleep(3000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.print(ClassLayout.parseInstance(fruit).toPrintable());
            }
        }
    }
}

上面代码启动线程A,控制台输出如下图所示,红色标记3个bit是101分别表示,高位的1表示是偏向锁,01是偏向锁标识位。符合偏向锁标识的情况。

image.png

  • 轻量级锁。
  • 升级过程:在线程运行获取锁后,会在栈帧中创造锁记录并将MarkWord复制到锁记录,然后将MarkWord指向锁记录,如果当前线程持有锁,其他线程再进入,此时其他线程会cas自旋,直到获取锁,轻量级锁适合多线程交替执行,效率高(cas只消耗cpu,我在cas原理一篇文章中详细讲过。)。
  • 膨胀过程:有两种情况会膨胀成重量级锁。1种情况是cas自旋10次还没获取锁。第2种情况其他线程正在cas获取锁,第三个线程竞争获取锁,锁也会膨胀变成重量级锁。

下面我们代码来测试下轻量级锁的锁状态。

打开23行-24行代码,执行线程A,B,我的目的是顺序执行线程A B ,所以我在代码中先执行threadA.join(),让A线程先执行完毕,再执行B线程,如下图所示MarkWord锁状态变化,线程A开始是偏向锁用101表示,执行线程B就变成轻量级锁了,锁状态变成了00,符合轻量级锁锁状态。证明完毕。

image.png

  • 重量级锁。重量级锁升级后是不可逆的,也就是说重量锁不可以再变为轻量级锁。

打开25行代码,执行线程A,B,C,我的目的是先执行线程A,在代码中先执行threadA.join(),让A线程先执行完毕,然后再同时执行线程BC ,如下图所示看看MarkWord锁状态变化,线程A开始是偏向锁,到同时执行线程BC,因为有激烈竞争,属于轻量级锁膨胀条件第2种情况,当其他线程正在cas获取锁,第三个线程竞争获取锁,锁也会膨胀变成重量级锁。此时BC线程锁状态都变成了10,这种情况符合重量级锁锁状态。膨胀重量级锁证明完毕。

image.png

到此为止,我们已经把synchronized锁升级过程中的锁状态通过代码的形式都证明了一遍,希望对你有帮助。下图是自己总结。

image.png

总结

多线程synchronized一直是个很重要的话题,也是面试中常见的考点。希望大家都能尽快理解掌握,分享给你们希望你们喜欢!

我是叫练,多叫多练,欢迎大家和我一起讨论交流,我会尽快回复大家,喜欢点赞和关注哦!公众号【叫练】。

牛逼.gif

持续技术输出

19 声望
4 粉丝
0 条评论
推荐阅读
Java通过socket和DTU,RTU连接工业传感器通信
现在做DTU传感器监测数据一块,给大家分享如何通过socket技术连接到DTU,并能和DTU下面的传感器通信的,分享一下自己的心得和体会。​

叫练阅读 1.4k

封面图
ETag 接口软缓存
Time 由优化前的 3460ms 降低为 193ms,减少 3267ms,减少 94% 耗时(注意,这个是因为作者的下行带宽小,商用带宽不会有这么大的差异)

momo7075770452阅读 686评论 2

封面图
分布式数据库--SQL优化之Plan Hint
Hint是嵌入SQL语句的对优化器进行提示的信息,是DBA进行SQL优化的常用手段。SQL语句经过优化器(规则优化(RBO)、代价优化(CBO)),通常会选择正确的查询路径,但是智者千虑,必有一失,有时优化器也会选择一个很差...

KaiwuDB1阅读 818

封面图
深入解析 Raft 模块在 KaiwuDB 中的优化改造(下)
KaiwuDB 是由浪潮开源的一款 NewSQL 分布式数据库,具备 HTAP 特性,拥有强一致、高可用的分布式架构。其中,KaiwuDB 各方面的强一致性都依靠 Raft 算法实现。我们在上一篇文章中介绍了 Raft 一致性算法在分布式...

KaiwuDB1阅读 1.1k

IO多路复用和多线程会影响Redis分布式锁吗?
前言前置知识Redis 虽然是单线程的,但是它利用了内核的 IO 多路复用,从而能同时监听多个连接Redis6 出现了可以利用多个 IO 线程并发进行的操作那么问题来了,这两者会导致我们的分布式锁的原子性有影响吗?我们...

LinkinStar1阅读 332

封面图
深入解析分布式数据库的 SQL 引擎优化
开务数据库的 SQL 引擎包含连接、编译、缓存、分布式日志和分布式执行五大服务组件,实现了多集群多节点协同的高效计算,大大提升了用户的查询效率。

KaiwuDB阅读 1k

Java并行流:一次搞定多线程编程难题,让你的程序飞起来!
&emsp;&emsp;在日常的工作中,为了提高程序的处理速度,充分利用多核处理器的性能,我们需要手动编写多线程代码。但是多线程编程非常复杂,容易出现死锁、竞态条件等问题,给我们带来了很大的困扰。而 Java 并行...

不一样的科技宅阅读 853

持续技术输出

19 声望
4 粉丝
宣传栏