写点什么

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

用户头像
叫练
关注
发布于: 2020 年 12 月 21 日
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


发布于: 2020 年 12 月 21 日阅读数: 38
用户头像

叫练

关注

我是叫练,边叫边练 2020.06.11 加入

Java高级工程师,熟悉多线程,JVM

评论

发布
暂无评论
Synchronized用法原理和锁优化升级过程(面试)