关于网站建设的几点体会,贵阳网站建设q.479185700棒,黑色网站,什么是同ip网站在面试、并发编程、一些开源框架中总是会遇到 volatile 与 synchronized 。synchronized 如何保证并发安全#xff1f;volatile 语义的内存可见性指的是什么#xff1f;这其中又跟 JMM 有什么关系#xff0c;在并发编程中 JMM 的作用是什么#xff0c;为什么需要 JMM#… 在面试、并发编程、一些开源框架中总是会遇到 volatile 与 synchronized 。synchronized 如何保证并发安全volatile 语义的内存可见性指的是什么这其中又跟 JMM 有什么关系在并发编程中 JMM 的作用是什么为什么需要 JMM与 JVM 内存结构有什么区别
总结出里面的核心知识点以及面试重点图文并茂无畏面试与并发编程全面提升并发编程内功
JMM 与 JVM 内存结构有什么区别到底什么是 JMM (Java Memory Model) 内存模型JMM 的跟并发编程有什么关系内存模型最重要的内容指令重排、原子性、内存可见性。volatile 内存可见性指的是什么它的运用场景以及常见错误使用方式避坑指南。分析 synchronized 实现原理跟 monitor 的关系
JVM 内存与 JMM 内存模型
分别图解下 JVM 内存结构和 JMM 内存模型这里不会讲太多 JVM 相关的未来会有专门讲解 JVM 以及垃圾回收、内存调优的文章。敬请期待……
接下来我们通过图文的方式分别认识 JVM 内存结构和 JMM 内存模型DJ, trop the beat, lets’go!
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 指令则将操作数 1、2 出栈相加结果入栈。方法结束后「栈帧」出栈返回结果结束。 每个栈帧包含四个区域
局部变量表基本数据类型、对象引用、retuenAddress 指向字节码的指针操作数栈动态连接返回地址
这里有一个重要的地方敲黑板了
实际上有两层含义的栈第一层是「栈帧」对应方法第二层对应着方法的执行对应着操作数栈。所有的字节码指令都会被抽象成对栈的入栈与出栈操作。执行引擎只需要傻瓜式的按顺序执行就可以保证它的正确性。
每个线程拥有一个「虚拟机栈」每个「虚拟机栈」拥有多个「栈帧」而栈帧则对应着一个方法。每个「栈帧」包含局部变量表、操作数栈、动态链接、方法返回地址。方法运行结束则意味着该「栈帧」出栈。
如下图所示 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 指令地址。我们的程序在线程切换中运行那凭啥知道这个线程已经执行到什么地方呢
程序计数器是一块较小的内存空间它的作用可以看作是当前线程所执行的字节码的行号指示器。这里面存的就是当前线程执行的进度。
JMMJava Memory ModelJava 内存模型
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 和 doubleJava 内存模型规范允许虚拟机将没有被 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 定义了线程与主内存之间的抽象关系
线程之间的共享变量存储在主内存Main Memory中每个线程都有一个私有的本地内存Local Memory本地内存是 JMM 的一个抽象概念并不真实存在它涵盖了缓存、写缓冲区、寄存器以及其他的硬件和编译器优化。本地内存中存储了该线程以读/写共享变量的拷贝副本。从更低的层次来说主内存就是硬件的内存而为了获取更好的运行速度虚拟机及硬件系统可能会让工作内存优先存储于寄存器和高速缓存中。Java 内存模型中的线程的工作内存working memory是 cpu 的寄存器和高速缓存的抽象描述。而 JVM 的静态内存储模型JVM 内存模型只是一种对内存的物理划分而已它只局限在内存而且只局限在 JVM 的内存。
八个操作
为了支持 JMMJava 定义了 8 种原子操作Action用来控制主存与工作内存之间的交互
read 读取作用于主内存将共享变量从主内存传动到线程的工作内存中供后面的 load 动作使用。load 载入作用于工作内存把 read 读取的值放到工作内存中的副本变量中。store 存储作用于工作内存把工作内存中的变量传送到主内存中为随后的 write 操作使用。write 写入作用于主内存把 store 传送值写到主内存的变量中。use 使用作用于工作内存把工作内存的值传递给执行引擎当虚拟机遇到一个需要使用这个变量的指令就会执行这个动作。assign 赋值作用于工作内存把执行引擎获取到的值赋值给工作内存中的变量当虚拟机栈遇到给变量赋值的指令执行该操作。比如 int i 1;lock锁定 作用于主内存把变量标记为线程独占状态。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 Barriersload1 的加载优先于 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(instancenull) { // 1synchronized (Singleton.class) {if(instancenull)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);}Overridepublic 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() 方法将释放当前持有的 monitorowner 变量恢复为 nullcount 自减 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 有关系。 其中 Mark Word 在默认情况下存储着对象的 HashCode、分代年龄、锁标记位等。Mark Word 在不同的锁状态下存储的内容不同在 32 位 JVM 中默认状态为下 在运行过程中Mark Word 存储的数据会随着锁标志位的变化而变化可能出现如下 4 种数据 锁标志位的表示意义
锁标识 lock00 表示轻量级锁锁标识 lock10 表示重量级锁偏向锁标识 biased_lock1 表示偏向锁偏向锁标识 biased_lock0 且锁标识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 开始锁一共会有四种状态锁的状态根据竞争激烈程度从低到高分别是: 无锁状态-偏向锁状态-轻量级锁状态-重量级锁状态。这几个状态会随着锁竞争的情况逐步升级。为了提高获得锁和释放锁的效率锁可以升级但是不能降级。
最后感谢原作者的创作 链接https://zhuanlan.zhihu.com/p/271717200 来源知乎