从 JMM 透析 volatile 与 synchronized 原理

在面试、并发编程、一些开源框架中老是会遇到 volatilesynchronizedsynchronized 如何保证并发安全?volatile 语义的内存可见性指的是什么?这其中又跟 JMM 有什么关系,在并发编程中 JMM 的做用是什么,为何须要 JMM?与 JVM 内存结构有什么区别?java

「码哥字节」 总结出里面的核心知识点以及面试重点,图文并茂无畏面试与并发编程,全面提高并发编程内功!git

  1. JMM 与 JVM 内存结构有什么区别?
  2. 到底什么是 JMM (Java Memory Model) 内存模型,JMM 的跟并发编程有什么关系?
  3. 内存模型最重要的内容:指令重排、原子性、内存可见性
  4. volatile 内存可见性指的是什么?它的运用场景以及常见错误使用方式避坑指南。
  5. 分析 synchronized 实现原理跟 monitor 的关系;

JVM 内存与 JMM 内存模型

「码哥字节」会分别图解下 JVM 内存结构和 JMM 内存模型,这里不会讲太多 JVM 相关的,将来会有专门讲解 JVM 以及垃圾回收、内存调优的文章。敬请期待……程序员

接下来咱们经过图文的方式分别认识 JVM 内存结构JMM 内存模型,DJ, trop the beat, lets’go!github

JVM 内存结构这么骚,须要和虚拟机运行时数据一块儿唠叨,由于程序运行的数据区域须要他来划分各领风骚。面试

Java 内存模型也很妖娆,不能被 JVM 内存结构来搞混淆,实际他是一种抽象定义,主要为了并发编程安全访问数据。编程

总结下就是:数组

  • JVM 内存结构和 Java 虚拟机的运行时区域有关;
  • Java 内存模型和 Java 的并发编程有关。

JVM 内存结构

Java 代码是运行在虚拟机上的,咱们写的 .java 文件首先会被编译成 .class 文件,接着被 JVM 虚拟机加载,而且根据不一样操做系统平台翻译成对应平台的机器码运行,以下如所示:缓存

JVM跨平台

从图中能够看到,有了 JVM 这个抽象层以后,Java 就能够实现跨平台了。JVM 只须要保证可以正确加载 .class 文件,就能够运行在诸如 Linux、Windows、MacOS 等平台上了。安全

JVM 经过 Java 类加载器加载 javac 编译出来的 class 文件,经过执行引擎解释执行或者 JIT 即时编译调用才调用系统接口实现程序的运行。多线程

JVM加载

而虚拟机在运行程序的时候会把内存划分为不一样的数据区域,不一样区域负责不一样功能,随着 Java 的发展,内存布局也在调整之中,以下是 Java 8 以后的布局状况,移除了永久代,使用 Mataspace 代替,因此 -XX:PermSize -XX:MaxPermSize 等参数变没有意义。 JVM 内存结构以下图所示:

JVM内存布局

执行字节码的模块叫作执行引擎,执行引擎依靠程序计数器恢复线程切换。本地内存包含元数据区域以及一些直接内存。

堆(Heap)

数据共享区域存储实例对象以及数组,一般是占用内存最大的一块也是数据共享的,好比 new Object() 就会生成一个实例;而数组也是保存在堆上面的,由于在 Java 中,数组也是对象。垃圾收集器的主要做用区域。

那一个对象建立的时候,究竟是在堆上分配,仍是在栈上分配呢?这和两个方面有关:对象的类型和在 Java 类中存在的位置。

Java 的对象能够分为基本数据类型和普通对象。

对于普通对象来讲,JVM 会首先在堆上建立对象,而后在其余地方使用的实际上是它的引用。好比,把这个引用保存在虚拟机栈的局部变量表中。

对于基本数据类型来讲(byte、short、int、long、float、double、char),有两种状况。

咱们上面提到,每一个线程拥有一个虚拟机栈。当你在方法体内声明了基本数据类型的对象,它就会在栈上直接分配。其余状况,一般在在堆上分配,逃逸分析的状况下可能会在栈分配。

注意,像 int[] 数组这样的内容,是在堆上分配的。数组并非基本数据类型。

虚拟机栈(Java Virtual Machine Stacks)

Java 虚拟机栈基于线程,即便只有一个 main 方法,都是以线程的方式运行,在运行的生命周期中,参与计算的数据会出栈与入栈,而「虚拟机栈」里面的每条数据就是「栈帧」,在 Java 方法执行的时候则建立一个「栈帧」并入栈「虚拟机栈」。调用结束则「栈帧」出栈,随之对应的线程也结束。

public int add() {
  int a = 1, b = 2;
  return a + b;
}

add 方法会被抽象成一个「栈帧」的结构,当方法执行过程当中则对应着操做数 1 与 2 的操做数栈入栈,而且赋值给局部变量 a 、b ,遇到 add 指令则将操做数 一、2 出栈相加结果入栈。方法结束后「栈帧」出栈,返回结果结束。

每一个栈帧包含四个区域:

  1. 局部变量表:基本数据类型、对象引用、retuenAddress 指向字节码的指针;
  2. 操做数栈
  3. 动态链接
  4. 返回地址

这里有一个重要的地方,敲黑板了:

  • 实际上有两层含义的栈,第一层是「栈帧」对应方法;第二层对应着方法的执行,对应着操做数栈。
  • 全部的字节码指令,都会被抽象成对栈的入栈与出栈操做。执行引擎只须要傻瓜式的按顺序执行,就能够保证它的正确性。

每一个线程拥有一个「虚拟机栈」,每一个「虚拟机栈」拥有多个「栈帧」,而栈帧则对应着一个方法。每一个「栈帧」包含局部变量表、操做数栈、动态连接、方法返回地址。方法运行结束则意味着该「栈帧」出栈。

以下图所示:

JVM虚拟机栈

方法区(Method Area)元空间

存储每一个 class 类的元数据信息,好比类的结构、运行时的常量池、字段、方法数据、方法构造函数以及接口初始化等特殊方法。

元空间是在堆上么?

答:不是在堆上分配的,而是在堆外空间分配,方法区就是在元空间中。

字符串常量池在那个区域中?

答:这个跟 JDK 不一样版本不一样区别,JDK 1.8 以前,元空间尚未出道成团,方法区被放在一个叫永久代的空间,而字符串常量就在此间。

JDK 1.7 以前,字符串常量池也放在叫做永久带的空间。 JDK 1.7 以后,字符串常量池从永久带挪到了堆上凑。

因此,从 1.7 版本开始,字符串常量池就一直存在于堆上。

本地方法栈(Native Method Stacks)

跟虚拟机栈相似,区别在于前者是为 Java 方法服务,而本地方法栈是为 native 方法服务。

程序计数器(The PC Register)

保存当前正在执行的 JVM 指令地址。咱们的程序在线程切换中运行,那凭啥指导这个线程已经执行到什么地方呢?

程序计数器是一块较小的内存空间,它的做用能够看做是当前线程所执行的字节码的行号指示器。这里面存的,就是当前线程执行的进度。

JMM(Java Memory Model,Java 内存模型)

DJ, drop the beats!有请“码哥字节”,拨弄 Java 内存模型这根动人心弦。

首先他不是“真实存在”,而是和多线程相关的一组“规范”,须要每一个 JVM 的实现都要遵照这样的“规范”,有了 JMM 的规范保障,并发程序运行在不一样的虚拟机获得出的程序结果才是安全可靠可信赖。

若是没有 JMM 内存模型来规范,就可能会出现通过不一样 JVM “翻译”以后,运行的结果都不相同也不正确。

JMM 与处理器、缓存、并发、编译器有关。它解决了 CPU 多级缓存、处理器优化、指令重排等致使的结果不可预期的问题数据,保证不一样的并发语义关键字获得相应的并发安全的数据资源保护。

主要目的就是让 Java 程序员在各类平台下达到一致性访问效果。

是 JUC 包工具类和并发关键字的原理保障

volatile、synchronized、Lock 等,它们的实现原理都涉及 JMM。有了 JMM 的参与,才让各个同步工具和关键字可以发挥做用同步语义才能生效,使得咱们开发出并发安全的程序。

JMM 最重要的的三点内容:重排序、原子性、内存可见性

指令重排序

咱们写的 bug 代码,当我觉得这些代码的运行顺序按照我神来之笔的书写的顺序执行的时候,我发现我错的。实际上,编译器、JVM、甚至 CPU 都有可能出于优化性能的目的,并不能保证各个语句执行的前后顺序与输入的代码顺序一致,而是调整了顺序,这就是指令重排序

重排序优点

可能咱们会疑问:为何要指令重排序?有啥用?

以下图:

Java并发编程78讲

通过重排序以后,状况以下图所示:

Java并发编程78讲

重排序后,对 a 操做的指令发生了改变,节省了一次 Load a 和一次 Store a,减小了指令执行,提高了速度改变了运行,这就是重排序带来的好处。

重排序的三种状况

  • 编译器优化

    好比当前唐伯虎爱慕 “秋香”,那就把对“秋香”的爱慕、约会放到一块儿执行效率就高得多。避免在撩“冬香”的时候又跑去约会“秋香”,减小了这部分的时间开销,此刻咱们须要必定的顺序重排。不太重排序并不意味着能够任意排序,它须要须要保证重排序后,不改变单线程内的语义,不能把对“秋香”说的话传到“冬香”的耳朵里,不然能任意排序的话,后果不堪设想,“时间管理大师”非你莫属。

  • CPU 重排序

    这里的优化跟编译器相似,目的都是经过打乱顺序提升总体运行效率,这就是为了更快而执行的秘密武器。

  • 内存“重排序”

    我不是真正意义的重排序,可是结果跟重排序有相似的成绩。由于仍是有区别因此我加了双引号做为不同的定义。

    因为内存有缓存的存在,在 JMM 里表现为主存本地内存,而主存和本地内存的内容可能不一致,因此这也会致使程序表现出乱序的行为。

    每一个线程只可以直接接触到工做内存,没法直接操做主内存,而工做内存中所保存的数据正是主内存的共享变量的副本,主内存和工做内存之间的通讯是由 JMM 控制的。

举个例子:

线程 1 修改了 a 的值,可是修改后没有来得及把新结果写回主存或者线程 2 没来得及读到最新的值,因此线程 2 看不到刚才线程 1 对 a 的修改,此时线程 2 看到的 a 仍是等于初始值。可是线程 2 却可能看到线程 1 修改 a 以后的代码执行效果,表面上看起来像是发生了重顺序。

内存可见性

先来看为什么会有内存可见性问题

public class Visibility {
    int x = 0;
    public void write() {
        x = 1;
    }

    public void read() {
        int y = x;
    }
}

内存可见性问题:当 x 的值已经被第一个线程修改了,可是其余线程却看不到被修改后的值。

假设两个线程执行的上面的代码,第 1 个线程执行的是 write 方法,第 2 个线程执行的是 read 方法。下面咱们来分析一下,代码在实际运行过程当中的情景是怎么样的,以下图所示:

它们均可以从主内存中去获取到这个信息,对两个线程来讲 x 都是 0。但是此时咱们假设第 1 个线程先去执行 write 方法,它就把 x 的值从 0 改成了 1,可是它改动的动做并非直接发生在主内存中的,而是会发生在第 1 个线程的工做内存中,以下图所示。

那么,假设线程 1 的工做内存还未同步给主内存,此时假设线程 2 开始读取,那么它读到的 x 值不是 1,而是 0,也就是说虽然此时线程 1 已经把 x 的值改动了,可是对于第 2 个线程而言,根本感知不到 x 的这个变化,这就产生了可见性问题。

volatile、synchronized、final、和锁 都能保证可见性。要注意的是 volatile,每当变量的值改变的时候,都会立马刷新到主内存中,因此其余线程想要读取这个数据,则须要从主内存中刷新到工做内存上。

而锁和同步关键字就比较好理解一些,它是把更多个操做强制转化为原子化的过程。因为只有一把锁,变量的可见性就更容易保证。

原子性

咱们大体能够认为基本数据类型变量、引用类型变量、声明为 volatile 的任何类型变量的访问读写是具有原子性的(long 和 double 的非原子性协定:对于 64 位的数据,如 long 和 double,Java 内存模型规范容许虚拟机将没有被 volatile 修饰的 64 位数据的读写操做划分为两次 32 位的操做来进行,即容许虚拟机实现选择能够不保证 64 位数据类型的 load、store、read 和 write 这四个操做的原子性,即若是有多个线程共享一个并未声明为 volatile 的 long 或 double 类型的变量,而且同时对它们进行读取和修改操做,那么某些线程可能会读取到一个既非原值,也不是其余线程修改值的表明了“半个变量”的数值。

但因为目前各类平台下的商用虚拟机几乎都选择把 64 位数据的读写操做做为原子操做来对待,所以在编写代码时通常也不须要将用到的 long 和 double 变量专门声明为 volatile)。这些类型变量的读、写自然具备原子性,但相似于 “基本变量++” / “volatile++” 这种复合操做并无原子性。好比 i++;

Java 内存模型解决的问题

JMM 最重要的的三点内容:重排序、原子性、内存可见性。那么 JMM 又是如何解决这些问题的呢?

JMM 抽象出主存储器(Main Memory)和工做存储器(Working Memory)两种。

  • 主存储器是实例位置所在的区域,全部的实例都存在于主存储器内。好比,实例所拥有的字段即位于主存储器内,主存储器是全部的线程所共享的。
  • 工做存储器是线程所拥有的做业区,每一个线程都有其专用的工做存储器。工做存储器存有主存储器中必要部分的拷贝,称之为工做拷贝(Working Copy)。

线程是没法直接对主内存进行操做的,以下图所示,线程 A 想要和线程 B 通讯,只能经过主存进行交换。

经历下面 2 个步骤:

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

2)线程 B 到主内存中去读取线程 A 以前已更新过的共享变量。

JMM内存模型

从抽象角度看,JMM 定义了线程与主内存之间的抽象关系:

  1. 线程之间的共享变量存储在主内存(Main Memory)中;
  2. 每一个线程都有一个私有的本地内存(Local Memory),本地内存是 JMM 的一个抽象概念,并不真实存在,它涵盖了缓存、写缓冲区、寄存器以及其余的硬件和编译器优化。本地内存中存储了该线程以读/写共享变量的拷贝副本。
  3. 从更低的层次来讲,主内存就是硬件的内存,而为了获取更好的运行速度,虚拟机及硬件系统可能会让工做内存优先存储于寄存器和高速缓存中。
  4. Java 内存模型中的线程的工做内存(working memory)是 cpu 的寄存器和高速缓存的抽象描述。而 JVM 的静态内存储模型(JVM 内存模型)只是一种对内存的物理划分而已,它只局限在内存,并且只局限在 JVM 的内存。

八个操做

为了支持 JMM,Java 定义了 8 种原子操做(Action),用来控制主存与工做内存之间的交互:

  1. read 读取:做用于主内存,将共享变量从主内存传动到线程的工做内存中,供后面的 load 动做使用。
  2. load 载入:做用于工做内存,把 read 读取的值放到工做内存中的副本变量中。
  3. store 存储:做用于工做内存,把工做内存中的变量传送到主内存中,为随后的 write 操做使用。
  4. write 写入:做用于主内存,把 store 传送值写到主内存的变量中。
  5. use 使用:做用于工做内存,把工做内存的值传递给执行引擎,当虚拟机遇到一个须要使用这个变量的指令,就会执行这个动做。
  6. assign 赋值:做用于工做内存,把执行引擎获取到的值赋值给工做内存中的变量,当虚拟机栈遇到给变量赋值的指令,执行该操做。好比 int i = 1;
  7. lock(锁定) 做用于主内存,把变量标记为线程独占状态。
  8. unlock(解锁) 做用于主内存,它将释放独占状态。

深刻浅出Java虚拟机

如上图所示,把一个变量数据从主内存复制到工做内存,要顺序执行 read 和 load;而把变量数据从工做内存同步回主内存,就要顺序执行 store 和 write 操做。

因为重排序、原子性、内存可见性,带来的不一致问题,JMM 经过 八个原子动做,内存屏障保证了并发语义关键字的代码可以实现对应的安全并发访问。

原子性保障

JMM 保证了 read、load、assign、use、store 和 write 六个操做具备原子性,能够认为除了 long 和 double 类型之外,对其余基本数据类型所对应的内存单元的访问读写都是原子的。

可是当你想要更大范围的的原子性保证就须要使用 ,就可使用 lock 和 unlock 这两个操做。

内存屏障:内存可见性与指令重排序

那 JMM 如何保障指令重排序排序,内存可见性带来并发访问问题?

内存屏障(Memory Barrier)用于控制在特定条件下的重排序和内存可见性问题。JMM 内存屏障可分为读屏障和写屏障,Java 的内存屏障实际上也是上述两种的组合,完成一系列的屏障和数据同步功能。Java 编译器在生成字节码时,会在执行指令序列的适当位置插入内存屏障来限制处理器的重排序

组合以下:

  • Load-Load Barriers:load1 的加载优先于 load2 以及全部后续的加载指令,在指令前插入 Load Barrier,使得高速缓存中的数据失效,强制从新从驻内存中加载数据。
  • Load-Store Barriers:确保 load1 数据的加载先于 store2 以及以后的存储指令刷新到内存。
  • Store-Store Barriers:确保 store1 数据对其余处理器可见,而且先于 store2 以及全部后续的存储指令。在 Store Barrie 指令后插入 Store Barrie 会把写入缓存的最新数据刷新到主内存,使得其余线程可见。
  • Store-Load Barriers:在 Load2 及后续全部读取操做执行前,保证 Store1 的写入对全部处理器可见。这条内存屏障指令是一个全能型的屏障,它同时具备其余 3 条屏障的效果,并且它的开销也是四种屏障中最大的一个。

JMM 总结

JMM 是一个抽象概念,因为 CPU 多核多级缓存、为了优化代码会发生指令重排的缘由,JMM 为了屏蔽细节,定义了一套规范,保证最终的并发安全。它抽象出了工做内存于主内存的概念,而且经过八个原子操做以及内存屏障保证了原子性、内存可见性、防止指令重排,使得 volatile 能保证内存可见性并防止指令重排、synchronised 保证了内存可见性、原子性、防止指令重排致使的线程安全问题,JMM 是并发编程的基础。

而且 JMM 为程序中全部的操做定义了一个关系,称之为 「Happens-Before」原则,要保证执行操做 B 的线程看到操做 A 的结果,那么 A、B 之间必须知足「Happens-Before」关系,若是这两个操做缺少这个关系,那么 JVM 能够任意重排序。

Happens-Before

  • 程序顺序原则:若是程序操做 A 在操做 B 以前,那么多线程中的操做依然是 A 在 B 以前执行。
  • 监视器锁原则:在监视器锁上的解锁操做必须在同一个监视器上的加锁操做以前执行。
  • volatile 变量原则:对 volatile 修饰的变量写入操做必须在该变量的毒操做以前执行。
  • 线程启动原则:在线程对 Tread.start 调用必须在该线程执行任何操做以前执行。
  • 线程结束原则:线程的任何操做必须在其余线程检测到该线程结束前执行,或者从 Thread.join 中成功返回,或者在调用 Thread.isAlive 返回 false。
  • 中断原则:当一个线程在另外一个线程上调用 interrupt 时,必须在被中断线程检测到 interrupt 调用以前执行。
  • 终结器规则:对象的构造方法必须在启动对象的终结器以前完成。
  • 传递性:若是操做 A 在操做 B 以前执行,而且操做 B 在操做 C 以前执行,那么操做 A 必须在操做 C 以前执行。

volatile

它是 Java 中的一个关键字,当一个变量是共享变量,同时被 volatile 修饰当值被更改的时候,其余线程再读取该变量的时候能够保证能获取到修改后的值,经过 JMM 屏蔽掉各类硬件和操做系统的内存访问差别 以及 CPU 多级缓存等致使的数据不一致问题。

须要注意的是,volatile 修饰的变量对全部线程是当即可见的,关键字自己就包含了禁止指令重排的语意,可是在非原子操做的并发读写中是不安全的,好比 i++ 操做一共分三步操做。

相比 synchronised Lock volatile 更加轻量级,不会发生上下文切换等开销,接着跟着「码哥字节」来分析下他的适用场景,以及错误使用场景。

volatile 的做用

  • 保证可见性:Happens-before 关系中对于 volatile 是这样描述的:对一个 volatile 变量的写操做 happen-before 后面对该变量的读操做。

    这就表明了若是变量被 volatile 修饰,那么每次修改以后,接下来在读取这个变量的时候必定能读取到该变量最新的值。

  • 禁止指令重排:先介绍一下 as-if-serial 语义:无论怎么重排序,(单线程)程序的执行结果不会改变。在知足 as-if-serial 语义的前提下,因为编译器或 CPU 的优化,代码的实际执行顺序可能与咱们编写的顺序是不一样的,这在单线程的状况下是没问题的,可是一旦引入多线程,这种乱序就可能会致使严重的线程安全问题。用了 volatile 关键字就能够在必定程度上禁止这种重排序。

volatile 正确用法

boolean 标志位

共享变量只有被赋值和读取,没有其余的多个复合操做(好比先读数据再修改的复合运算 i++),咱们就可使用 volatile 代替 synchronized 或者代替原子类,由于赋值操做是原子性操做,而 volatile 同时保证了 可见性,因此是线程安全的。

以下经典场景 volatile boolean flag,一旦 flag 发生变化,全部的线程当即可见。

volatile boolean shutdownRequested;

...

public void shutdown() {
    shutdownRequested = true;
}

public void doWork() {
    while (!shutdownRequested) {
        // do stuff
    }
}

线程 1 执行 doWork() 的过程当中,可能有另外的线程 2 调用了 shutdown,线程 1 里吗读区到修改的值并中止执行。

这种类型的状态标记的一个公共特性是:一般只有一种状态转换shutdownRequested 标志从false 转换为true,而后程序中止。

双重检查(单例模式)

class Singleton{
    private volatile static Singleton instance = null;

    private Singleton() {
    }

    public static Singleton getInstance() {
        if(instance==null) { // 1
            synchronized (Singleton.class) {
                if(instance==null)
                    instance = new Singleton();  //2
            }
        }
        return instance;
    }
}

在双重检查锁模式中为何须要使用 volatile 关键字?

假如 Instance 类变量是没有用 volatile 关键字修饰的,会致使这样一个问题:

在线程执行到第 1 行的时候,代码读取到 instance 不为 null 时,instance 引用的对象有可能尚未完成初始化。

形成这种现象主要的缘由是建立对象不是原子操做以及指令重排序。

第二行代码能够分解成如下几步:

memory = allocate();  // 1:分配对象的内存空间
ctorInstance(memory); // 2:初始化对象
instance = memory;  // 3:设置instance指向刚分配的内存地址

根源在于代码中的 2 和 3 之间,可能会被重排序。例如:

memory = allocate();  // 1:分配对象的内存空间
instance = memory;  // 3:设置instance指向刚分配的内存地址
// 注意,此时对象尚未被初始化!
ctorInstance(memory); // 2:初始化对象

这种重排序可能就会致使一个线程拿到的 instance 是非空的可是还没初始化彻底。

img

面试官可能会问你,“为何要 double-check?去掉任何一次的 check 行不行?”

咱们先来看第二次的 check,这时你须要考虑这样一种状况,有两个线程同时调用 getInstance 方法,因为 singleton 是空的 ,所以两个线程均可以经过第一重的 if 判断;而后因为锁机制的存在,会有一个线程先进入同步语句,并进入第二重 if 判断 ,而另外的一个线程就会在外面等待。

不过,当第一个线程执行完 new Singleton() 语句后,就会退出 synchronized 保护的区域,这时若是没有第二重 if (singleton == null) 判断的话,那么第二个线程也会建立一个实例,此时就破坏了单例,这确定是不行的。

而对于第一个 check 而言,若是去掉它,那么全部线程都会串行执行,效率低下,因此两个 check 都是须要保留的。

volatile 错误用法

volatile 不适合运用于须要保证原子性的场景,好比更新的时候须要依赖原来的值,而最典型的就是 a++ 的场景,咱们仅靠 volatile 是不能保证 a++ 的线程安全的。代码以下所示:

public class DontVolatile implements Runnable {
    volatile int a;
    public static void main(String[] args) throws InterruptedException {
        Runnable r =  new DontVolatile();
        Thread thread1 = new Thread(r);
        Thread thread2 = new Thread(r);
        thread1.start();
        thread2.start();
        thread1.join();
        thread2.join();
        System.out.println(((DontVolatile) r).a);
    }
    @Override
    public void run() {
        for (int i = 0; i < 1000; i++) {
            a++;
        }
    }
}

最终的结果 a < 2000。

synchronised

互斥同步是常见的并发正确性保障方式。同步就好像在公司上班,厕所只有一个,如今一帮人同时想去「带薪拉屎」占用厕所,为了保证厕所同一时刻只能一个员工使用,经过排队互斥实现。

互斥是实现同步的一种手段,临界区、互斥量(Mutex)和信号量(Semaphore)都是主要互斥方式。互斥是因,同步是果。

监视器锁(Monitor 另外一个名字叫管程)本质是依赖于底层的操做系统的 Mutex Lock(互斥锁)来实现的。每一个对象都存在着一个 monitor 与之关联,对象与其 monitor 之间的关系有存在多种实现方式,如 monitor 能够与对象一块儿建立销毁或当线程试图获取对象锁时自动生成,但当一个 monitor 被某个线程持有后,它便处于锁定状态。

mutex 的工做方式

在 Java 虚拟机 (HotSpot) 中,Monitor 是基于 C++ 实现的,由 ObjectMonitor 实现的, 几个关键属性:

  • \_owner:指向持有 ObjectMonitor 对象的线程
  • \_WaitSet:存放处于 wait 状态的线程队列
  • \_EntryList:存放处于等待锁 block 状态的线程队列
  • \_recursions:锁的重入次数
  • count:用来记录该线程获取锁的次数

ObjectMonitor 中有两个队列,\_WaitSet 和 \_EntryList,用来保存 ObjectWaiter 对象列表( 每一个等待锁的线程都会被封装成 ObjectWaiter 对象),\_owner 指向持有 ObjectMonitor 对象的线程,当多个线程同时访问一段同步代码时,首先会进入 \_EntryList 集合,当线程获取到对象的 monitor 后进入 \_Owner 区域并把 monitor 中的 owner 变量设置为当前线程同时 monitor 中的计数器 count 加 1。

若线程调用 wait() 方法,将释放当前持有的 monitor,owner 变量恢复为 null,count 自减 1,同时该线程进入 WaitSet 集合中等待被唤醒。若当前线程执行完毕也将释放 monitor(锁)并复位变量的值,以便其余线程进入获取 monitor(锁)。

在 Java 中,最基本的互斥同步手段就是 synchronised,通过编译以后会在同步块先后分别插入 monitorenter, monitorexit 这两个字节码指令,而这两个字节码指令都须要提供一个 reference 类型的参数来指定要锁定和解锁的对象,具体表现以下所示:

  • 在普通同步方法,reference 关联和锁定的是当前方法示例对象;
  • 对于静态同步方法,reference 关联和锁定的是当前类的 class 对象;
  • 在同步方法块中,reference 关联和锁定的是括号里制定的对象;

Java 对象头

synchronised 用的锁也存在 Java 对象头里,在 JVM 中,对象在内存的布局分为三块区域:对象头、实例数据、对其填充。

对象头

  • 对象头:MarkWord 和 metadata,也就是图中对象标记和元数据指针;
  • 实例对象:存放类的属性数据,包括父类的属性信息,若是是数组的实例部分还包括数组的长度,这部份内存按 4 字节对齐;
  • 填充数据:因为虚拟机要求对象起始地址必须是 8 字节的整数倍。填充数据不是必须存在的,仅仅是为了字节对齐;

对象头是 synchronised 实现的关键,使用的锁对象是存储在 Java 对象头里的,jvm 中采用 2 个字宽(一个字宽表明 4 个字节,一个字节 8bit)来存储对象头(若是对象是数组则会分配 3 个字宽,多出来的 1 个字宽记录的是数组长度)。其主要结构是由 Mark Word 和 Class Metadata Address 组成。

Mark word 记录了对象和锁有关的信息,当某个对象被 synchronized 关键字当成同步锁时,那么围绕这个锁的一系列操做都和 Mark word 有关系。

虚拟机位数 对象结构 说明
32/64bit Mark Word 存储对象的 hashCode、锁信息或分代年龄或 GC 标志等信息
32/64bit Class Metadata Address 类型指针指向对象的类元数据,JVM 经过这个指针肯定该对象是哪一个类的实例。
32/64bit Array length 数组的长度(若是当前对象是数组)

其中 Mark Word 在默认状况下存储着对象的 HashCode、分代年龄、锁标记位等。Mark Word 在不一样的锁状态下存储的内容不一样,在 32 位 JVM 中默认状态为下:

锁状态 25 bit 4 bit 1 bit 是不是偏向锁 2 bit 锁标志位
无锁 对象 HashCode 对象分代年龄 0 01

在运行过程当中,Mark Word 存储的数据会随着锁标志位的变化而变化,可能出现以下 4 种数据:

锁标志位的表示意义:

  1. 锁标识 lock=00 表示轻量级锁
  2. 锁标识 lock=10 表示重量级锁
  3. 偏向锁标识 biased_lock=1 表示偏向锁
  4. 偏向锁标识 biased_lock=0 且锁标识=01 表示无锁状态

到目前为止,咱们再总结一下前面的内容,synchronized(lock) 中的 lock 能够用 Java 中任何一个对象来表示,而锁标识的存储实际上就是在 lock 这个对象中的对象头内。

Monitor(监视器锁)本质是依赖于底层的操做系统的 Mutex Lock(互斥锁)来实现的。Mutex Lock 的切换须要从用户态转换到核心态中,所以状态转换须要耗费不少的处理器时间。因此 synchronized 是 Java 语言中的一个重量级操做。

为何任意一个 Java 对象都能成为锁对象呢?

Java 中的每一个对象都派生自 Object 类,而每一个 Java Object 在 JVM 内部都有一个 native 的 C++对象 oop/oopDesc 进行对应。
其次,线程在获取锁的时候,实际上就是得到一个监视器对象(monitor) ,monitor 能够认为是一个同步对象,全部的 Java 对象是天生携带 monitor。

多个线程访问同步代码块时,至关于去争抢对象监视器修改对象中的锁标识, ObjectMonitor 这个对象和线程争抢锁的逻辑有密切的关系。

总结讨论

JMM 总结

JVM 内存结构和 Java 虚拟机的运行时区域有关;

Java 内存模型和 Java 的并发编程有关。JMM 是并发编程的基础,它屏蔽了硬件于系统形成的内存访问差别,保证了 一致性、原子性、并禁止指令重排保证了安全访问。经过总线嗅探机制使得缓存数据失效, 保证 volatile 内存可见性。

JMM 是一个抽象概念,因为 CPU 多核多级缓存、为了优化代码会发生指令重排的缘由,JMM 为了屏蔽细节,定义了一套规范,保证最终的并发安全。它抽象出了工做内存于主内存的概念,而且经过八个原子操做以及内存屏障保证了原子性、内存可见性、防止指令重排,使得 volatile 能保证内存可见性并防止指令重排、synchronised 保证了内存可见性、原子性、防止指令重排致使的线程安全问题,JMM 是并发编程的基础。

synchronized 原理

提到了锁的几个概念,偏向锁、轻量级锁、重量级锁。在 JDK1.6 以前,synchronized 是一个重量级锁,性能比较差。从 JDK1.6 开始,为了减小得到锁和释放锁带来的性能消耗,synchronized 进行了优化,引入了偏向锁和轻量级锁的概念。

因此从 JDK1.6 开始,锁一共会有四种状态,锁的状态根据竞争激烈程度从低到高分别是: 无锁状态->偏向锁状态->轻量级锁状态->重量级锁状态。这几个状态会随着锁竞争的状况逐步升级。为了提升得到锁和释放锁的效率,锁能够升级可是不能降级。

同时为了提高性能,还带来了锁消除、锁粗化、自旋锁和自适应自旋锁…...

鉴于篇幅缘由关于线程状态、锁的同步过程「码哥字节」下回分解,分别介绍加锁、解锁以及锁升级过程当中 Mark Word 如何变化。如何正确使用 wait()、 notify() 实现生产-消费模式,讲解如何避免常见的易错知识点,防止掉坑。

敬请期待......

后台回复 “加群” 进入专属技术群一块儿成长

码哥字节

往期推荐

从面试角度一文学完 Kafka

Tomcat 架构原理解析到架构设计借鉴

终极解密输入网址按回车到底发生了什么

相关文章
相关标签/搜索