synchronized底层原理_从青铜到王者,来聊聊 Synchronized 底层实现原理 | 原力计划...

9397fbe7b8e3c8dda9e3e2252e5fde01.png

作者 | IT贱男

责编 | 夕颜

出品 | CSDN(ID:CSDNnews)

3096852ea918979ad5a3b77506046b56.png

引言

这篇文章码了小编***个小时,点个赞不过分吧~~

文本内容有点多,如果有写错或者不好地方,还请多多指教~~~~~~~

Table of Contents

一、引言

二、倔强青铜

2.1 多线程一定快吗?

2.2 上下文切换

2.3 测试上下文切换次数

2.4 Java内存模型

2.5 主内存与工作内存之间的数据交互过程

三、秩序白银

3.1 多线程带来的可见性问题

3.2 多线程带来的原子性问题

3.3 多线程带来的有序性问题

四、荣耀黄金

4.1 sync可重入特性

4.2 sync不可中断特性

4.3 反汇编学习sync原理

五、尊贵铂金

5.1 montior 监视器锁

5.2 monitor 竞争

5.3. monitor 等待

5.4 monitor 释放

六、永恒钻石

6.1 CAS 介绍

6.2 sync 锁升级过程

6.3 对象的布局

七、至尊星耀

7.1 偏向锁

7.2 轻量级锁

7.3 自旋锁

7.4 消除锁

7.5 锁粗化

八、最强王者

终章:平时写代码如何对synchroized优化

c7dddf8d8d54fd73522d0c46442cafff.png

倔强青铜

2.1 多线程一定快吗?

我们先来看下面一段代码,有两个方法对各自a、b属性进行累加操作,其中concurrency方法是采用多线程进行操作,结果如下:

/** * @Auther: IT贱男 * @Date: 2020/3/9 10:37 * @Description: */public class ConcurrencyTest { // 累加次数 private static final long count = 10000L; public static void main(String[] args) throws InterruptedException { concurrency; serial; } /** * 多线程累加 * * @throws InterruptedException */ private static void concurrency throws InterruptedException { long start = System.currentTimeMillis; // 启动新线程执行运行操作 Thread thread = new Thread(new Runnable { @Override public void run { int a = 0; for (int i = 0; i < count; i++) { a += 5; } } }); thread.start; int b = 0; for (int i = 0; i < count; i++) { b--; } // 等线程执行完 thread.join; long end = System.currentTimeMillis - start; System.out.println("concurrency 总共耗时" + end); } /** * 单线程累加 */ private static void serial { long start = System.currentTimeMillis; int a = 0; for (int i = 0; i < count; i++) { a += 5; } int b = 0; for (int i = 0; i < count; i++) { b--; } long end = System.currentTimeMillis - start; System.out.println("serial 总共耗时" + end); }}

那这边的答案是"不一定"的,小编测试了几组数据如下(抽取部分结果):

多线程与单线程效率测试

e9827928afac87cdc4ebff67f2b38287.png

由以上的结果可以明确我们的答案是正确的,那为什么多线程在某些情况下会比单线程还要慢呢?这是因为多线程有创建和上下文切换的开销。

2.2 上下文切换

那什么是上下文切换呢?

目前来说即使是单核处理器也支持多线程执行代码,CPU通过给个线程分配CPU时间片来实现这个机制。时间片是CPU分配给各个线程的时间,因为时间片一般是几十毫秒,所以CPU需要通过不停地切换线程来执行。假设当我们线程A获得CPU分配的时间片等于10毫秒,执行10毫秒之后,CPU需要切换到线程B去执行程序。等线程B的时间片执行完事了,又切回线程A继续执行。

显然易见,我们CPU相当于是循环的切换上下文,来达到同时执行的效果。当前执行完一个时间片后会切换下一个任务。但是在切换前会保存当前任务的状态,方便下次切换会这个任务的时候,可以恢复这个任务之前的状态。所以任务从保存到再次被加载的过程就是一次上下文切换。

2.3 测试上下文切换次数

这里我们需要使用一个命令叫做:"vmstat 1",这个命令是linux系统上的,可对操作系统的进程、虚拟内存、CPU活动进行监控。看下图CS(Content Switch) 表示上下文切换的次数,从图可见系统一般CS的值维持在600~800之间,当我们一直在运行ConcurrencyTest程序时,很明细发现CS飙升到1000以上。

74c3db57399a6f6b5beabf706b075340.png

2.4 Java内存模型

在我们学习sync原理之前,我们需要搞清楚Java内存模型的一个概念知识。很重要、很重要、很重要

3c0f2bcf75aa4450f8693cbba1e59580.png

Java内存模型全称:Java Memory Model ,简称Java内存模型或者JMM,Java线程之间的通信由JMM来控制,JMM决定一个线程对共享变量的写入,何时对另外一个线程可见。我们由图可见,线程之间的共享变量是存储在主内存当中,每一个线程都有一个属于自己的本地内存(也可以叫做工作内存),这个本地内存中存储了主内存当中的共享变量。就相当于把主内存的共享变量copy了一份给自己。为了提供效率,线程是不会直接与主内存进行打交道,而是通过本地内存来进行数据的读取。

如果线程A与线程B之间要通信,需要经历下面两个步骤:

1 )线程A把本地内存A中更新过的共享变量,刷新到主内存当中去。

2 )线程B到主内存中重新读取更新后的共享变量。

2.5 主内存与工作内存之间的数据交互过程

那么主内存与工作内存之间的交互经过了哪些步骤呢?

ce9c1f5a611e81de9cd5373aae6bc4a2.png

lock(锁定):作用于主内存的变量,把一个变量标识为一条线程独占状态。

unlock(解锁):作用于主内存的变量,把一个处于锁定状态的变量释放出来,释放之后的变量才可以被其他线程锁定。

read(读取):作用于主内存的变量,读取主内存变量的值。

load(载入):作用于主内存的变量,把read操作从主内存中得到的变量值放入到线程本地内存的变量副本中。

use(使用):作用于工作内存的变量,把工作内存中的一个变量传递给执行引擎。

assign(赋值):作用域工作内存的变量,它把一个从执行引擎接收到的值赋值给工作内存的变量。

store(存储):作用域工作内存的变量,把工作内存中的一个变量值传输到主内存中,以便随后的write操作。

write(写入):作用域工作内存的变量,把stroe操作从工作内存中一个变量的值传送到主内存的变量中去。

上个笔记图:更加详细的解释如上几个步骤

cbbc4fca4533d286e0de167eccde0af5.png

JMM是一种规范,其中定义几条规则,小编挑选出相对本文比较重要的:

1、如果想要把一个变量从主内存复制到工作内存,就需要按照顺序执行read和load操作,如果把变量从工作内存同步到主内存中,就要按照顺序执行store和write操作。但Java内存模型只要求上述操作必须按照顺序执行,而没有保证必须是连续执行。

2、程序中如果有同步操作才会有lock和unlock操作,一个变量在同一个时刻只允许一条线程对其进行lock操作,但lock操作可以被同一条线程重复执行多次,执行多次后,必须执行相对应次数但unlock操作,变量才会被解锁。lock和unlock必须成对出现。

3、如果对一个变量执行lock操作,将会清空工作内存中此变量的值,在执行引擎使用这个变量前需要重新执行load或者assign操作初始化变量但值。

4、java内存模型同步规则小编暂时提到这么多,感兴趣的小伙伴可以自行去了解一下

a4617550e708835bb1f9b5ccbe6ab190.png

秩序白银

3.1 多线程带来的可见性问题

什么是可见性问题呢?

所谓可见性:一个线程对主内存的修改可以及时被其他线程观察到。

当一个共享属性,被线程二修改了,但是线程一无法获得最新的值,导致死循环。原因Java内存模型也说清楚了,线程是和本地内存做交互的。

1、线程一把falg属性读取到线程私有的本地内存中,值为true。

2、线程二把falg属性修改为false,并且刷新到主内存当中,但是线程一它是不知道falg被修改了。

public class SyncExample5 { static boolean falg = true; // 锁对象 static Object lock = new Object; public static void main(String[] args) throws InterruptedException { // 线程一 new Thread(new Runnable { @Override public void run { while (falg) { // 默认不可见,死循环,放开以下注释即可解决不可见操作 // 方式一,加上sycn操作即可解决可见性问题 // synchronized (lock){} // 方式二, println 方法实现加上了同步机制,保证每次输出都是最新值 // System.out.println(falg); } } }).start; // 睡眠两秒 Thread.sleep(2000L); // 线程二 new Thread(new Runnable { @Override public void run { falg = false; System.out.println("falg 值已修改"); } }).start; }}

sync怎么解决可见性问题呢?

这个就涉及到本地内存与工作内存交互的步骤了,还记得文本上面有讲的8个步骤吗?

如果程序中有加同步的机制,则会有Lock、Unlock操作,Lock操作会使本地内存中的属性失效,从而去主内存中重新读取数据。

8974b6026089fd6f5c314bc84213d163.png

3.2 多线程带来的原子性问题

什么是原子性问题呢?

所谓原子性:提供了互斥访问,同一个时刻只能有一个线程来对它进行操作。

这里一次任务累加1千次,同时启动5个线程进行累加,最后的结果正常应该是5000才对,但由于多线程会造成不一样的结果。

public class SyncExample6 { static int index = 0; static Object lock = new Object; public static void main(String[] args) throws InterruptedException { // index 累加 1000次,使用lambda表达式 Runnable task =  -> { // 不加sync则不能保证原子操作 // synchronized (lock) { for (int i = 0; i < 1000; i++) { index++; } // } }; // 启动五个线程来执行任务 for (int i = 0; i < 5; i++) { Thread thread = new Thread(task); thread.start; } // 为了代码直观直接睡眠等待结果,实际需要调用线程的join方法等待线程结束 Thread.sleep(2000L); System.out.println("index = " + index); }}

我们使用java命令来编译以上代码:

javac SyncExample6.java

javap -p -v SyncExample6.class ,这样我们就能看到sync到底在底层做了什么事。

编译代码之后找到“lambda$main$0”,因为我们同步机制是写在main方法中,用lambda表达式所写。

 private static void lambda$main$0; descriptor: V flags: ACC_PRIVATE, ACC_STATIC, ACC_SYNTHETIC Code: stack=2, locals=3, args_size=0 0: iconst_0 1: istore_0 2: iload_0 3: sipush 1000 6: if_icmpge 39 9: getstatic #18 // Field lock:Ljava/lang/Object; 12: dup 13: astore_1 14: monitorenter 15: getstatic #14 // Field index:I 18: iconst_1 19: iadd 20: putstatic #14 // Field index:I 23: aload_1 24: monitorexit 25: goto 33 28: astore_2

造成原子性的问题的原因是什么?

这个就涉及到文章一开始所讲的上下文切换的知识点,index ++ 一共涉及到4条指令,如下

15: getstatic #14 // 步骤一:获取index值18: iconst_1 // 步骤二:准备常量119: iadd // 步骤三:相加操作20: putstatic #14 // 步骤四:重新赋值

以上这4条指令就是index ++ 的四个步骤,假设我们线程一进来,执行到步骤三,这个时候CPU切换线程。切换到线程二,线程二执行步骤一,这个时候index的值还是等于0,因为线程一并没有执行步骤四就被切换上下文了。等线程二执行完成,又切回到线程一,线程一会接着执行步骤三,并不会重新获取index的值,这就导致计算结果不正确了。

sync怎么解决原子性问题呢?

 14: monitorenter 15: getstatic #14 // Field index:I 18: iconst_1 19: iadd 20: putstatic #14 // Field index:I 23: aload_1 24: monitorexit

当我们加上了sync同步机制之后, 会插入monitorenter、monitorexit两条指令。

又到了假设环节:假设线程一执行到步骤三,被切换到线程二,当我们线程二执行monitorenter这个指令会发现,这个对象已经被其他线程占用了,所以就只能等待着不会进行操作。现在又切回到线程一,线程一操作完整个步骤执行monitorexit来释放锁。这个时候线程二才可以获得锁。这样一操作就能保证同一个时刻只能有一个线程来对它进行操作,从而保证原子性。

monitorenter指令是在编译后插入到同步代码块到开始位置,而monitorexit是插入到同步代码块结束位置和异常位置。JVM需要保障每个monitorenter必须有对应的monitorexit。任何一个对象都会有一个monitor来关联,当且一个monitor被持有后,它就处理锁定状态。当线程执行到monitorenter指令的时候,将会尝试获取对象所对应的monitor的所有权,即尝试获取锁对象。

3.3 多线程带来的有序性问题

什么是有序性问题呢?

有序性,指的是程序中代码的执行顺序,Java在编译时和运行时会对代码进行优化,会导致程序最终的执行顺序不一定就是我们编写代码时的顺序。

// 指定使用并发测试@JCStressTest // 预测的结果与类型,附加描述信息,如果1,4 则是ok,如果结果有为0也能勉强接受@Outcome(id = {"1
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值