前言

毫无疑问,synchronized 是我们用过的第一个并发关键字,很多博文都在讲解这个技术。不过大多数讲解还停留在对 synchronized 的使用层面,其底层的很多原理和优化,很多人可能并不知晓。因此本文将通过对 synchronized 的大量 C 源码分析,让大家对他的了解更加透彻点。

本篇将从为什么要引入 synchronized,常见的使用方式,存在的问题以及优化部分这四个方面描述,话不多说,开始表演。

可见性问题及解决

概念描述

指一个线程对共享变量进行修改,另一个能立刻获取到修改后的最新值。

代码展示

类:

  1. public class Example1 {

复制代码

运行结果:

关于Synchronized锁升级,你该了解这些 - 图1

分析

这边先要了解下 Java 的内存模式,不明白的可点击传送门,todo。

下图线程 t1,t2 从主内存分别获取 flag=true,t1 空循环,直到 flag 为 false 的时候退出循环。t2 拿到 flag 的值,将其改为 false,并写入到主内存。此时主内存和线程 t2 的工作内存中 flag 均为 false,但是线程 t1 工作内存中的 flag 还是 true,所以一直退不了循环,程序将一直执行。

关于Synchronized锁升级,你该了解这些 - 图2

synchronized 如何解决可见性

首先我们尝试在 t1 线程中加一行打印语句,看看效果。

代码:

  1. public class Example1 {

复制代码

运行结果:

关于Synchronized锁升级,你该了解这些 - 图3

我们发现 if 里面的语句已经打印出来了,线程 1 已经感知到线程 2 对 flag 的修改,即这条打印语句已经影响了可见性。这是为啥?

关于Synchronized锁升级,你该了解这些 - 图4

答案就是 println 中,我们看下源码:

关于Synchronized锁升级,你该了解这些 - 图5

println 有个上锁的过程,即操作如下:

  1. 获取同步锁。

  2. 清空自己工作内存上的变量。

  3. 从主内存获取最新值,并加载到工作内存中。

  4. 打印并输出。

所以这里解释了为什么线程 t1 加了打印语句之后,t1 立刻能感知 t2 对 flag 的修改。因为每次打印的时候其都从主内存上获取了最新值,当 t2 修改的时候,t1 立刻从主内存获取了值,所以进入了 if 语句,并最终能跳出循环。

synchronized 的原理就是清空自己工作内存上的值,通过将主内存最新值刷新到工作内存中,让各个线程能互相感知修改。

原子性问题及解决

概念描述

在一次或多个操作中,要不所有操作都执行,要不所有操作都不执行。

代码展示

类:

  1. public class Example2 {

复制代码

运行结果:

关于Synchronized锁升级,你该了解这些 - 图6

分析

每个线程执行的逻辑是循环 1 万次,每次加 1,那我们希望的结果是 2 万,但是实际上结果是不足 2 万的。我们先用 javap 命令反汇编,我们看到很多代码,但是 number++ 涉及的指令有四句,具体看第二张图。

关于Synchronized锁升级,你该了解这些 - 图7

关于Synchronized锁升级,你该了解这些 - 图8

如果有多条线程执行这段 number++ 代码,当前 number 为 0,线程 1 先执行到 iconst_1 指令,即将执行 iadd 操作,而线程 2 执行到 getstatic 指令,这个时候 number 值还没有改变,所以线程 2 获取到的静态字段是 0,线程 1 执行完 iadd 操作,number 变为 1,线程 2 执行完 iadd 操作,number 还是 1。这个时候就发现问题了,做了两次 number++ 操作,但是 number 只增加了 1。

并发编程时,会出现原子性问题,当一个线程对共享变量操作到一半的时候,另外一个线程也有可能来操作共享变量,这个时候就出现了问题。

synchronized 如何解决原子性问题

在上面的分析中,我们已经知道发生问题的原因,number++ 是由四条指令组成,没有保证原子操作。所以,我们只要将 number++ 作为一个整体就行,即保证他的原子性。具体代码如下:

  1. public class Example2 {

复制代码

关于Synchronized锁升级,你该了解这些 - 图9

我们看到最终 number 为 20000,那为什么要加上 synchronized,结果就正确了?我们再反编译下 Example2,可以看到在四行指令前后分别有 monitorenter 和 monitorexist,线程 1 在执行中间指令时,其他线程不可以进入 monitorenter,需要等线程 1 执行完 monitorexist,其他进程才能继续 monitorenter,进行自增操作。

关于Synchronized锁升级,你该了解这些 - 图10

有序性问题及解决

概念描述

代码中程序执行的顺序,Java 在编译和运行时会对代码进行优化,这样会导致我们最终的执行顺序并不是我们编写代码的书写顺序。

代码展示

咱先来看一个概念,重排序,也就是语句的执行顺序会被重新安排。其主要分为三种:

  1. 编译器优化的重排序:可以重新安排语句的执行顺序。

  2. 指令级并行的重排序:现代处理器采用指令级并行技术,将多条指令重叠执行。

  3. 内存系统的重排序:由于处理器使用缓存和读写缓冲区,所以看上去可能是乱序的。

上面代码中的 a = new A(); 可能被被 JVM 分解成如下代码:

复制代码

复制代码

一旦假设发生了这样的重排序,比如线程 A 在执行了步骤 1 和步骤 3,但是步骤 2 还没有执行完。这个时候线程 B 进入了第一个语句,它会判断 a 不为空,即直接返回了 a。其实这是一个未初始化完成的 a,即会出现问题。

synchronized 如何解决有序性问题

给上面的三个步骤加上一个 synchronized 关键字,即使发生重排序也不会出现问题。线程 A 在执行步骤 1 和步骤 3 时,线程 B 因为没法获取到锁,所以也不能进入第一个语句。只有线程 A 都执行完,释放锁,线程 B 才能重新获取锁,再执行相关操作。

synchronized 的常见使用方式

修饰代码块(同步代码块)

  1. synchronized (object) {

复制代码

修饰方法

  1. synchronized void test(){

复制代码

synchronized 不能继承?(插曲)

父类 A:

  1. public class A {

复制代码

子类 B:(未重写 test 方法)

  1. public class B extends A {

复制代码

子类 C:(重写 test 方法)

  1. public class C extends A {

复制代码

线程 A:

  1. public class ThreadA extends Thread {

复制代码

线程 B:

  1. public class ThreadB extends Thread {

复制代码

线程 C:

  1. public class ThreadC extends Thread{

复制代码

测试类 test:

  1. public class test {

复制代码

运行结果:

关于Synchronized锁升级,你该了解这些 - 图11

子类 B 继承了父类 A, 但是没有重写 test 方法,ThreadB 仍然是同步的。子类 C 继承了父类 A,也重写了 test 方法,但是未明确写上 synchronized,所以这个方法并不是同步方法。只有显式的写上 synchronized 关键字,才是同步方法。

所以 synchronized 不能继承这句话有歧义,我们只要记住子类如果想要重写父类的同步方法,synchronized 关键字一定要显示写出,否则无效。

修饰静态方法

  1. synchronized static void test(){

复制代码

修饰类

  1. synchronized (Example2.class) {

复制代码

Java 对象 Mark Word

在 JVM 中,对象在内存中的布局分为三块区域:对象头,实例数据和对齐数据,如下图:

关于Synchronized锁升级,你该了解这些 - 图12

其中 Mark Word 值在不同锁状态下的展示如下:(重点看线程 id,是否为偏向锁,锁标志位信息)

关于Synchronized锁升级,你该了解这些 - 图13

在 64 位系统中,Mark Word 占了 8 个字节,类型指针占了 8 个字节,一共是16个字节。Talk is cheap. Show me the code. 咱来看代码。

  • 我们想要看 Java 对象的 Mark Word,先要加载一个 jar 包,在 pom.xml 添加即可。

复制代码

  • 新建一个对象 A,拥有初始值为 666 的变量 x。
    public class A {

复制代码

  • 新建一个测试类 test,这涉及到刚才加载的 jar,我们打印 Java 对象。
    import org.openjdk.jol.info.ClassLayout;

复制代码

  • 我们发现对象头(object header)占了12个字节,为啥和上面说的 16 个字节不一样。

关于Synchronized锁升级,你该了解这些 - 图14

  • 其实上是默认开启了指针压缩,我们需要关闭指针压缩,也就是添加-XX:-UseCompressedOops配置。

关于Synchronized锁升级,你该了解这些 - 图15

  • 再次执行,发现对象头为 16 个字节。

关于Synchronized锁升级,你该了解这些 - 图16

偏向锁

什么是偏向锁

JDK1.6 之前锁为重量级锁(待会说,只要知道他和内核交互,消耗资源),1.6 之后 Java 设计人员发现很多情况下并不存在多个线程竞争的关系,所以为了资源问题引入了无锁偏向锁轻量级锁重量级锁的概念。先说偏向锁,他是偏心,偏袒的意思,这个锁会偏向于第一个获取他的线程。

偏向锁演示

  • 创建并启动一个线程,run 方法里面用了 synchronized 关键字,功能是打印 this 的 Java 对象。
    public class test {

复制代码

标红的地方为 000,根据之前 Mark Word 在不同状态下的标志,得此为无锁状态。理论上一个线程使用 synchronized 关键字,应为偏向锁。

关于Synchronized锁升级,你该了解这些 - 图17

  • 实际上偏向锁在 JDK1.6 之后是默认开启的,但是启动时间有延迟,所以需要添加参数-XX:BiasedLockingStartupDelay=0,让其在程序启动时立刻启动。

关于Synchronized锁升级,你该了解这些 - 图18

  • 重新运行下代码,发现标红地方 101,对比 Mark Word 在不同状态下的标志,得此状态为偏向锁。

关于Synchronized锁升级,你该了解这些 - 图19

偏向锁原理图解

  • 在线程的 run 方法中,刚执行到 synchronized,会判断当前对象是否为偏向锁和锁标志,没有任何线程执行该对象,我们可以看到是否为偏向锁为 0,锁标志位 01,即无锁状态。

关于Synchronized锁升级,你该了解这些 - 图20

  • 线程会将自己的 id 赋值给 markword,即将原来的 hashcode 值改为线程 id,是否是偏向锁改为 1,表示线程拥有对象锁,可以执行下面的业务逻辑。如果 synchronized 执行完,对象还是偏向锁状态;如果线程结束之后,会撤销偏向锁,将该对象还原成无锁状态。

关于Synchronized锁升级,你该了解这些 - 图21

  • 如果同一个线程中又对该对象进行加锁操作,我们只要对比对象的线程 id是否与线程 id相同,如果相同即为线程锁重入问题。

优势

加锁和解锁不需要额外的消耗,和执行非同步方法相比只有纳秒级的差距。

白话翻译

线程 1 锁定对象 this,他发现对象为无锁状态,所以将线程 id 赋值给对象的 Mark Word 字段,表示对象为线程 1 专用,即使他退出了同步代码,其他线程也不能使用该对象。

同学 A 去自习教室 C,他发现教室无人,所以在门口写了个名字,表示当前教室有人在使用,这样即使他出去吃了饭,其他同学也不能使用这个房间。

轻量锁

什么是轻量级锁

在多线程交替同步代码块的情况下,线程间没有竞争,使用轻量级锁可以避免重量级锁引入的性能消耗。

轻量级图解

  • 在刚才偏向锁的基础上,如果有另外一个线程也想错峰使用该资源,通过对比线程 id 是否相同,Java 内存会立刻撤销偏向锁(需要等待全局安全点),进行锁升级的操作。

关于Synchronized锁升级,你该了解这些 - 图22

  • 撤销完轻量级锁,会在线程 1 的方法栈中新增一个锁记录,对象的 Mark Word 与锁记录交换。

关于Synchronized锁升级,你该了解这些 - 图23

优势

线程不竞争的时候,避免直接使用重量级锁,提高了程序的响应速度。

白话翻译

在刚才偏向锁的基础上,另外一个线程也想要获取资源,所以线程 1 需要撤销偏向锁,升级为轻量锁。

同学 A 在使用自习教室外面写了自己的名字,所以同学 B 来也想要使用自习教室,他需要提醒同学 A,不能使用偏向锁,同学 A 将自习教室门口的名字擦掉,换成了一个书包,里面是自己的书籍。这样在同学 A 不使用自习教室的时候,同学 B 也能使用自习教室,只需要将自己的书包也挂在外面即可。这样下次来使用的同学就能知道已经有人占用了该教室。

重量级锁

什么是重量级锁

当多线程之间发生竞争,Java 内存会申请一个 Monitor 对象来实现。

重量级锁原理图解

在刚才的轻量级锁的基础上,线程 2 也想要申请资源,发现锁的标志位为 00,即为轻量级锁,所以向内存申请一个 Monitor, 让对象的 MarkWord 指向 Monitor 地址,并将 ower 指针指向线程 1 的地址,线程 2 放在等待队列里面,等线程 1 指向完毕,释放锁资源。

关于Synchronized锁升级,你该了解这些 - 图24

Monitor 源码分析

环境搭建

我们去官网http://openjdk.java.net/找下 open 源码,也可以通过其他途径下载。源码是 C 实现的,可以通过 DEV C++ 工具打开,效果如下图:

关于Synchronized锁升级,你该了解这些 - 图25

构造函数

我们先看下\hotspot\src\share\vm\runtime\ObjectMonitor.hpp,以. hpp 结尾的文件是导入的一些包和一些声明,之后可以被. cpp 文件导入。

  1. ObjectMonitor() {

复制代码

锁竞争的过程

我们先看下\hotspot\src\share\vm\interpreter\interpreterRuntime.cppIRT_ENTRY_NO_ASYNC即为锁竞争过程。

复制代码

slow_enter 实际上调用的 ObjectMonitor.cpp 的 enter 方法

  1. void ATTR ObjectMonitor::enter(TRAPS) {

复制代码

白话翻译

同学 A 在使用自习教室的时候,同学 B 在同一时刻也想使用自习教室,那就发生了竞争关系。所以同学 B 在 A 运行过程中,加入等待队列。如果此时同学 C 也要使用该教室,也会加入等待队列。等同学 A 使用结束,同学 B 和 C 将竞争自习教室。

自旋优化

自旋优化比较简单,如果将其他线程加入等待队列,那之后唤醒并运行线程需要消耗资源,所以设计人员让其空转一会,看看线程能不能一会结束了,这样就不要在加入等待队列。

白话来说,如果同学 A 在使用自习教室,同学 B 可以回宿舍,等 A 使用结束再来,但是 B 回宿舍再来的过程需要 1 个小时,而 A 只要 10 分钟就结束了。所以 B 可以先不回宿舍,而是在门口等个 10 分钟,以防止来回时间的浪费。

结语

唉呀妈呀,终于结束了,累死了。终于将 synchronized 写完了,如果有不正确的地方,还需要各位指正。如果觉得写得还行,麻烦帮我点赞,评论哈。

参考资料

Java 中 System.out.println() 为何会影响内存可见性

别再问什么是 Java 内存模型了,看这里!

JVM—- 汇编指令集

Java 中 Synchronized 的使用

synchronized 同步方法(08)同步不具有继承性

发布于: 2020 年 06 月 05 日阅读数: 175
https://xie.infoq.cn/article/ae57ae101b6ecaf6bbeedcb42