原文链接
JavaGuide
《并发编程的艺术》
并发编程的实现原理
目的
- 上节课内容回顾
- synchronized 原理分析
- wait 和 notify
- Lock 同步锁
回顾
JMM
JMM 是 JAVA 里边定义的内存模型。定义了多线程和我们内存交互的规范。屏蔽了硬件和操作体系访问内存的差异。它类似于 JVM 的一个作用。提供了统一的规范。解决多核心 CPU 里边的高速缓存和多线程并行访问内存的原子性,可见性,有序性问题。在不同的情况下的体现和解决办法。定义了一套 JMM 规范,让我们 JAVA 步伐在不同的平台下可以大概达到一致的内存访问效果。
- 在 JAVA 平台里边定义一套尺度和规范。在 JAVA 里边定义了 8 种操作。
- lock(锁定):作用于主内存的变量,把一个变量标识为一条线程独占状态。
- read(读取):作用于主内存变量,把一个变量值从主内存传输到线程的工作内存中,以便随后的load动作利用
- load(载入):作用于工作内存的变量,它把read操作从主内存中得到的变量值放入工作内存的变量副本中。
- use(利用):作用于工作内存的变量,把工作内存中的一个变量值传递给执行引擎,每当假造机遇到一个需要利用变量的值的字节码指令时将会执行这个操作。
- assign(赋值):作用于工作内存的变量,它把一个从执行引擎接收到的值赋值给工作内存的变量,每当假造机遇到一个给变量赋值的字节码指令时执行这个操作。
- store(存储):作用于工作内存的变量,把工作内存中的一个变量的值传送到主内存中,以便随后的write的操作。
- write(写入):作用于主内存的变量,它把store操作从工作内存中一个变量的值传送到主内存的变量中。
- unlock(解锁):作用于主内存变量,把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定。
这 8 种操作都是原子性的。它定义了和我们内存的交互的方式。
原子性、有序性、可见性。
解决的是 CPU 缓存、处理器优化、指令重排序。
限制处理器的优化以及利用内存屏蔽
CPU 处理器在执行的时候有一个优化执行的过程,利用内存屏蔽来防止我们的编译器和处理器的指令重排序。
线程在访问内存的时候,有一个工作内存。工作内存对于每一个 CPU 来说,是一块完全独立的缓存空间。如果一个线程去加载一个共享变量的话,它会首先去工作内存中去加载,如果不存在的话,它会去主内存去加载。Load 放到我们工作内存里边。 JMM 定义的一套抽象模型的统一规范。它是把底层的差异化通过 JMM 来进行规范。我们不管底层的 CPU 架构是什么样子,不管体系是什么样子,它都可以大概在 JMM 中做不同的处理。
- 处理器的优化
- 指令重排序(编译器的重排序和CPU 的内存重排序)
JAVA 层面如何解决我们的问题?
我们写的代码和编译的代码可能存在次序不一致的情况。重排序可以提拔我们步伐运行的性能。以及合理地利用我们操作体系底层 CPU 资源的一种优化手段,它的终极目的是提拔性能。它有一个尺度:不会改变我们指令的语义。
我们定义了一个步伐,不管我怎么改变,他的效果是不会去改变的。
- 编译器的优化重排序
- CPU 的指令重排序
- 内存体系的重排序
‘编译器的乱序
- 不改变单线程语义的前提下。
- int a = 1; //(1)
- int b = a; //(2)
- // 是不会乱序的,倒叙之后会错误
复制代码 重排序可能造成可见性
在多线程中可以并行执行多个线程。
CPU 的乱序是因为有寄存器、高速缓存。寄存器是为了存储我们本地的一些变量和一些本地的参数。CPU 里边还有一个叫高速缓存(L1、L2、L3)。是为了收缩CPU和内存访问速率的性能差异。
缓存离 CPU 越远,性能越低。寄存器 > L1 > L2 > L3 。只有 L3 在多核心的情况下是共享的。
缓存一致性(基于 MESI 协议去解决)
如果多个 CPU 都加载相同的数据加载到我们的缓存里边,会造成我们的缓存一致性问题。
CPU-0 S -> E -> M
CPU-2 S
如果此中一个CPU 读取了缓存,就代表当前的状态是 S。对这个值做一个更改的话, 变成 E (独占)。 M 更新 发出一个 失效的信号 Invlid。等到其他 CPU 复兴以后,就会进行更新。把我们当前的这个缓存更新到我们的主存里边。当我们的缓存的状态发生切换的时候。其他的缓存收到消息,而且需要完成各自的状态切换的时候。
这时候,就是一个 CPU 等候的状态。时间片等候就是一个性能问题。
为了更进一步地优化,引入了 storebuffer / loadbuffer 淘汰阻塞。它是去淘汰阻塞。如说说我处理器要去写入数据的时候。写入到 storebuffer 里边,对于 CPU-0 来说,它可以继续去干其他事情,不会等候。由同步变成了异步。 MESI 协议里边,如果写入的话,必须等候其他 CPU 给你一个相应。I 的状态,再去更新你的缓存,CPU 的阻塞是存在性能问题的。引入 storebuffer 和 loadbuffer 不会去阻塞。当收到所有的CPU 的复兴以后就会提交。(相称于 CPU 引入的一个异步机制。)
CPU-0 写入到 storebuffer 里边的时候,其他 CPU 是看不到的,它自己再去读取的时候,是可以从 storebuffer 里边去读取的。这是它的一个规则。storebuffer 什么时候把这个东西写入到主内存,它是不确定的。
Loadbuffer ,等候同步去加载。它会导致我们完成的次序是不确定的。造成一些不同的效果。- x = 1; // 写
- y = x; // 读 , 写
复制代码 CPU 层面上引入了内存屏蔽的功能。
store barrier / load barrier / full barrier
内存屏蔽的意思就是我们 CPU 或者编译器在对我们内存随机访问的操作里边,它在某个地方参加了同步的点。同步点,使得我们屏蔽之前的读写操作全部执行完才能执行屏蔽之后的指令。同步点,CPU 写入数据的时候是异步的。他必须等候其他 CPU 确认以后才会去同步这个数据。不同 CPU 架构的内存屏蔽是不一样的。(x86)
内存屏蔽的作用
有些 CPU 架构对内存屏蔽的支持是不一样的,有些 CPU 是支持强一致性的,就不需要内存屏蔽了。
多线程在 JAVA 中的体现
我们关心在 JVM 层面如何解决这些问题就好了
JVM 提供了四种内存屏蔽来解决 CPU 的指令重排序和编译器的指令重排序的问题。- inline void OrderAccess::loadload() { compiler_barrier(); }
- inline void OrderAccess::storestore() { compiler_barrier(); }
- inline void OrderAccess::loadstore() { compiler_barrier(); }
- inline void OrderAccess::storeload() { fence(); }
复制代码 每一个屏蔽都定义了一个方法。功能最全,性能比较低。- inline void OrderAccess::fence() {
- // always use locked addl since mfence is sometimes expensive
- #ifdef AMD64
- __asm__ volatile ("lock; addl $0,0(%%rsp)" : : : "cc", "memory");
- #else
- __asm__ volatile ("lock; addl $0,0(%%esp)" : : : "cc", "memory");
- #endif
- compiler_barrier();
- }
- static inline void compiler_barrier() {
- __asm__ volatile ("" : : : "memory");
- }
- template<>
- struct OrderAccess::PlatformOrderedStore<1, RELEASE_X_FENCE>
- {
- template <typename T>
- void operator()(T v, volatile T* p) const {
- __asm__ volatile ( "xchgb (%2),%0"
- : "=q" (v)
- : "0" (v), "r" (p)
- : "memory");
- }
- };
- template<>
- struct OrderAccess::PlatformOrderedStore<2, RELEASE_X_FENCE>
- {
- template <typename T>
- void operator()(T v, volatile T* p) const {
- __asm__ volatile ( "xchgw (%2),%0"
- : "=r" (v)
- : "0" (v), "r" (p)
- : "memory");
- }
- };
- template<>
- struct OrderAccess::PlatformOrderedStore<4, RELEASE_X_FENCE>
- {
- template <typename T>
- void operator()(T v, volatile T* p) const {
- __asm__ volatile ( "xchgl (%2),%0"
- : "=r" (v)
- : "0" (v), "r" (p)
- : "memory");
- }
- };
- #ifdef AMD64
- template<>
- struct OrderAccess::PlatformOrderedStore<8, RELEASE_X_FENCE>
- {
- template <typename T>
- void operator()(T v, volatile T* p) const {
- __asm__ volatile ( "xchgq (%2), %0"
- : "=r" (v)
- : "0" (v), "r" (p)
- : "memory");
- }
- };
复制代码 个人明白它是一个 1 2 4 8 核 和 AMD 架构的 不同策略。
volatile 是克制编译器对重排序的优化。
Lock
我们CPU 大部分都是用 缓存锁。
- read -> 获取
- modity-> 变化
- write -> 写入
fence 就是用的内存屏蔽,并没有用到 CPU 层面的内存屏蔽。不同的 CPU 架构,实现不太一样。它是用 Lock 来实现我们内存屏蔽的效果。
x86 是强一致性的。
Volatile 关键字
- int a = 1;
- volatile int b = a;
- // 对于 volatile 写来说,
- // storesotre() 指令(release)
- // b = a;
- // storeload 强制加入这个指令。固定的加了 storeload
复制代码- `J.U.C` 里边思路是一样的。storestore 是让写全部同步到主内存。storeload 是让所有的读和写全部写入到内存。
复制代码- public class SynchronizedDemo {
- private static int count = 0;
- public static void incr() {
- try {
- Thread.sleep(1);
- } catch (InterruptedException e) {
- e.printStackTrace();
- }
- count++;
- }
- public static void main(String[] args) throws InterruptedException {
- for (int i = 0; i < 1000; i++) {
- new Thread(() -> SynchronizedDemo.incr()).start();
- }
- Thread.sleep(4000);
- out.println("result: " + SynchronizedDemo.count); // <= 1000
- }
- }
复制代码 synchronized的三种应用方式
synchronized有三种方式来加锁,分别是
- 修饰实例方法,作用于当前实例加锁,进入同步代码前要得到当前实例的锁
- 静态方法,作用于当前类对象加锁,进入同步代码前要得到当前类对象的锁
- 修饰代码块,指定加锁对象,对给定对象加锁,进入同步代码库前要得到给定对象的锁。
- public class SynchronizedDemo {
- private static int count = 0;
- public synchronized static void incr() {
- try {
- Thread.sleep(1);
- } catch (InterruptedException e) {
- e.printStackTrace();
- }
- count++;
- }
- public static void main(String[] args) throws InterruptedException {
- for (int i = 0; i < 1000; i++) {
- new Thread(() -> SynchronizedDemo.incr()).start();
- }
- Thread.sleep(4000);
- out.println("result: " + SynchronizedDemo.count);
- }
- }
复制代码 问题
- synchronized 是如何实现锁的。
- 为什么任何一个对象都可以成为锁
- 锁存在哪个地方
synchronized括号后面的对象
synchronized 括号后面的对象是一把锁,在 JAVA 中恣意一个对象都可以成为锁,简朴来说,我们把 Object 比喻是一个 key ,拥有这个 key 的线程才能执行这个方法,拿到这个 key 以后在执行方法过程中,这个 key 是随身携带的,而且只有一把。如果后续的线程想访问当前方法,因为没有 key 以是不能访问,只能在门口等着,等之前的线程把 key 放回去。以是, synchronized 锁定的对象必须是同一个,如果是不同对象,就意味着是不同的房间的钥匙,对于访问者来说是没有任何影响的。- public void demo(){
- // 全局锁,多个对象是同一把锁
- synchronized (SynchronizedDemo.class){
- //.......
- }
- }
- public void demo1(){
- // 每个实例是不同的锁
- synchronized (this){
- //.......
- }
- }
- public synchronized static void incr() {
- try {
- Thread.sleep(1);
- } catch (InterruptedException e) {
- e.printStackTrace();
- }
- count++;
- }
复制代码 synchronized的字节码指令
通过 javap -v 来查看对应代码的字节码指令,对于同步块的实现利用了 monitorenter 和 monitorexit 指令,前面我们在讲 JMM 的时候,提到过这两个指令,他们隐式地执行了 Lock 和 UnLock 操作,用于提供原子性包管。 monitorenter 指令插入到同步代码块开始的位置、monitorexit指令插入到同步代码块结束位置,jvm需要包管每个 monitorenter 都有一个 monitorexit 对应。
这两个指令,本质上都是对一个对象的监督器 ( monitor ) 进行获取,
这个过程是排他的,也就是说同一时刻只能有一个线程获取到由 synchronized 所保护对象的监督器线程执行到 monitorenter 指令时,会实验获取对象所对应的 monitor 所有权,也就是实验获取对象的锁;而执行 monitorexit ,就是释放 monitor 的所有权。
所有的 JAVA 对象天生带有 monitor- Object lock = new Object();
- public void demo3(){
- synchronized(lock){
-
- }
- }
复制代码 得到一把锁,就需要去释放
两个 moitorexit 一个是异常的时候,会释放锁。
synchronized的锁的原理
jdk1.6 以后对 synchronized 锁进行了优化,包含方向锁、轻量级锁、重量级锁;在了解 synchronized 锁之前,我们需要了解两个重要的概念,一个是对象头、另一个是 monitor
Java对象头
对象在内存三个地区
- 对象头
- 实例数
- 对齐填充
- public void demo1();
- descriptor: ()V
- flags: (0x0001) ACC_PUBLIC
- Code:
- stack=2, locals=3, args_size=1
- 0: aload_0
- 1: dup
- 2: astore_1
- 3: monitorenter
- 4: aload_1
- 5: monitorexit
- 6: goto 14
- 9: astore_2
- 10: aload_1
- 11: monitorexit
- 12: aload_2
- 13: athrow
- 14: return
-
- public static synchronized void incr();
- descriptor: ()V
- flags: (0x0029) ACC_PUBLIC, ACC_STATIC, ACC_SYNCHRONIZED
- Code:
复制代码 Mark Word
Mark Word用于存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、方向线程 ID、方向时间戳等等。Java对象头一般占有两个机器码(在32位假造机中,1个机器码等于4字节,也就是32bit)
32位的
64位的
在源码中的体现
如果想更深入了解对象头在JVM源码中的定义,需要关心几个文件oop.hpp``/markOop.hpp
oop.hpp,每个 Java Object 在 JVM 内部都有一个 native 的 C++ 对象 oop / oopDesc 与之对应。先在 oop.hpp 中看 oopDesc 的定义。
_mark 被声明在 oopDesc 类的顶部,以是这个 _mark 可以认为是一个 头部, 前面我们讲过头部生存了一些重要的状态和标识信息,在 markOop.hpp 文件中有一些注释说明 markOop的内存布局。 age 分代年龄, epoch 方向锁的时间戳。
oop.hpp
- 在 Hotspot 虚拟机中,对象在内存中的布局分为三块区域:对象头、实例数据和对齐填充;Java对象头是实现synchronized的锁对象的基础,一般而言,synchronized使用的锁对象是存储在Java对象头里。它是轻量级锁和偏向锁的关键。
复制代码 markOop.hpp
Monitor
什么是 Monitor ?我们可以把它明白为一个同步工具,也可以描述为一种同步机制。所有的Java对象是天生的 Monitor ,每个object的对象里 markOop->monitor() 里可以生存 ObjectMonitor 的对象。从源码层面分析一下monitor对象。
- oop.hpp下的 oopDesc 类是JVM对象的顶级基类,以是每个object对象都包含markOop
oop.hpp
- markOop.hpp 中 markOopDesc 继续自 oopDesc ,并扩展了自己的 monitor 方法,这个方法返回一个ObjectMonitor 指针对象
- objectMonitor.hpp,在hotspot假造机中,采用ObjectMonitor类来实现monitor,
总结
任何对象在我们 JVM 层面有一个 oop 和 oopDesc 的对应。oop.hpp 会存在一个 mark 的对象头,对象头用来存储锁标志的。这个锁标志,是用来存储对应的方向锁、轻量锁等的标志。
锁是存在对象头里边的。
QA:
对象锁之间不相互干扰。全局锁意味着不管你多少个实例,我都可以大概锁定你。锁的范围,取决于你的对象的生命周期。这个对象的生命周期有多大,那你的锁的作用域就有多大。
synchronized的锁升级和获取过程
了解了对象头以及 monitor 以后,接下往复分析 synchronized 的锁的实现,就会非常简朴了。前面讲过 synchronized 的锁是进行过优化的,引入了方向锁、轻量级锁;锁的级别从低到高逐步升级, 无锁->方向锁->轻量级锁->重量级锁。
自旋锁(CAS)
自旋锁就是让不满足条件的线程等候一段时间,而不是立即挂起。看持有锁的线程是否可以大概很快释放锁。怎么自旋?其实就是一段没有任何意义的循环。固然它通过占用处理器的时间来避免线程切换带来的开销,但是如果持有锁的线程不能很快释放锁,那么自旋的线程就会浪费处理器的资源,因为它不会做任何故意义的工作。以是,自旋等候的时间或者次数是有一个限度的,如果自旋凌驾了定义的时间仍旧没有获取到锁,则该线程应该被挂起。
- class oopDesc {
- friend class VMStructs;
- friend class JVMCIVMStructs;
- private:
- volatile markOop _mark; // 就是对象头
- union _metadata {
- Klass* _klass;
- narrowKlass _compressed_klass;
- } _metadata;
复制代码 耗费不耗费CPU ,只是一个相对地概念。
方向锁
大多数情况下,锁不光不存在多线程竞争,而且总是由同一线程多次得到,为了让线程得到锁的代价更低而引入了方向锁。当一个线程访问同步块并获取锁时,会在对象头和栈帧中的锁记录里存储锁方向的线程 ID ,以后该线程在进入和退出同步块时不需要进行 CAS 操作来加锁和解锁,只需简朴地测试一下对象头的 Mark Word 里是否存储着指向当前线程的方向锁。如果测试成功,表现线程已经得到了锁。如果测试失败,则需要再测试一下 Mark Word 中方向锁的标识是否设置成 1 (表现当前是方向锁):如果没有设置,则利用 CAS 竞争锁;如果设置了,则实验利用 CAS 将对象头的方向锁指向当前线程。
( 就是头里边的 JavaThread. )
轻量级锁
引入轻量级锁的主要目的是在没有多线程竞争的前提下,淘汰传统的重量级锁利用操作体系互斥量产生的性能消耗。当关闭方向锁功能或者多个线程竞争方向锁导致方向锁升级为轻量级锁,则会实验获取轻量级锁。
重量级锁
重量级锁通过对象内部的监督器(monitor)实现,其 monitor 的本质是依赖于底层操作体系的 Mutex Lock 实现,操作体系实现线程之间的切换需要从用户态到内核态的切换,切换本钱非常高。前面我们在讲 JAVA对象头 的时候,讲到了monitor 这个对象,在 hotspot 假造机中,通过 ObjectMonitor 类来实现 monitor 。他的锁的获取过程会简朴很多。
类对象里边也有一个 ObjectMonitor , _owner // 指向得到 ObjectMonitor 的线程。
它的锁是一个全局的锁。多个线程同时去访问 ObjectMonitor 的时候,这个时候,它只会有一个线程来得到,但是对于多个实例来说,他只有一个实例来得到。每一个线程去得到一个对象都不一样。以是它可以大概实现锁的作用域。
自旋是肯定的时间,不停地自旋反而耗费了 CPU 资源,自旋的目的,(概率的说法)很多时候获取锁的时间比较短,自旋很短的时间就可以得到锁了。为什么要让线程挂起再去得到锁。固然,自旋消耗了一点 CPU 的资源,但是相比于后者,它的性能反而是提拔了。但是它不可能一直持续下去。- // 自旋
- for(;;){
- // 不断地获取锁
- } // 1.7 之前可以自己配置 1.7 之后,JVM 去控制
复制代码 JVM 为每一个实验进入 synchronized 代码块的 JavaThread 创建一个 ObjectWaiter 并添加到 _cxq 队列中。__next _prev 这是一个假造的队列,并没有存在个真正的数据布局,它是通过节点的方式去维护的。
_EntryList : 处于等候锁 block 状态的线程,由 ObjectWaiter 构成的双向量表, JVM 会从该链表中取出一个 ObjectWaiter 并唤醒对应的 JavaThread
_waitSet: 调用 wait 状态的线程的时候,会被参加到 waitSet
CXQ队列 : LIFO 后进先出的队列。
每一个线程进入以后都会有一个自旋,实验去得到锁。自旋失败,#park , #park 是挂起一个线程。如果没有自旋直接挂起的时候,从挂起到唤醒从用户态和内核态的切换,消耗会比较高。
出队列会用指针移动的操作,操作的时间会变得很少。
EnterList 队列 2Q ,两个队列的方式,用来淘汰竞争的频率。当我们在 CXQ 里边,可以移入 EntryList 里边,如果 EntryList 是空,CXQ 不为空的情况下。从 CXQ 末尾取出一个线程放到 EntryList 里边。
ownerThread 释放锁以后,让出队列,有资格去竞争锁。当竞争锁成功,就会被设置成 owner 。
wait 和 notify
Synchronized 支持重入,非公平锁。
#wait 和 #notify 是用来让线程进入等候状态以及使得线程唤醒的两个操作- ObjectWaiter * volatile _next;
- ObjectWaiter * volatile _prev;
复制代码- public class ThreadWait extends Thread {
- private Object lock;
- public ThreadWait(Object lock) {
- this.lock = lock;
- }
- @Override
- public void run() {
- synchronized (lock){
- System.out.println("开始执行 ThreadWait ");
- try {
- lock.wait();
- } catch (InterruptedException e) {
- e.printStackTrace();
- }
- System.out.println("执行结束 ThreadWait ");
- }
- }
- }
复制代码- public class ThreadNotify extends Thread {
- private Object lock;
- public ThreadNotify(Object lock) {
- this.lock = lock;
- }
- @Override
- public void run() {
- synchronized (lock) {
- System.out.println("开始执行 ThreadNotify ");
- lock.notify();
- System.out.println("执行结束 ThreadNotify ");
- }
- }
- }
复制代码 #wait 和 #notify 的原理
- Wait 和 notify 为什么要先获取锁?
- wait 和 sleep 的区别?
调用 #wait 方法,首先会获取监督器锁,得到成功以后,会让当前线程进入等候状态进入等候队列而且释放锁;然后当其他线程调用 #notify 或者 notifyall 以后,会选择从等候队列中唤醒恣意一个线程,而执行完 #notify 方法以后,并不会立马唤醒线程,原因是当前的线程仍旧持有这把锁,处于等候状态的线程无法得到锁。必须要等到当前的线程执行完按 monitorexit 指令以后,也就是锁被释放以后,处于等候队列中的线程就可以开始竞争锁了。
wait 方法的时候 ObjectMonitor::wait(jlong milllis, boo...){}
- 会把当前的线程包装成 ObjectWaiter对象,
- 并设置成 TS_WAIT 状态,就是一个 waiting 的
- Self _ParkEvent->park(); 挂起!
notify
- unpark
- 获取 ObjectWaiter
- 唤醒
wait 和 notify 为什么需要在 synchronized 里面
#wait方法的语义有两个,一个是释放当前的对象锁、另一个是使得当前线程进入阻塞队列, 而这些操作都和监督器是相关的,以是 #wait 必须要得到一个监督器锁而对于 #notify 来说也是一样,它是唤醒一个线程,既然要去唤醒,首先得知道它在那里?以是就必须要找到这个对象获取到这个对象的锁,然后到这个对象的等候队列中去唤醒一个线程。- public class Demo {
- public static void main(String[] args) {
- // 我将锁传进去,就可以实现对象同用一把锁。
- // 还可以控制锁的范围
- Object lock = new Object();
- ThreadWait threadWait = new ThreadWait(lock);
- ThreadNotify threadNotify = new ThreadNotify(lock);
- threadWait.start();
- threadNotify.start();
- }
- }
复制代码 java.lang.Thread#join() join 就是调用的 wait 方法。
[code]public final void join() throws InterruptedException { join(0);}public final synchronized void join(long millis) throws InterruptedException { long base = System.currentTimeMillis(); long now = 0; if (millis < 0) { throw new IllegalArgumentException("timeout value is negative"); } if (millis == 0) { while (isAlive()) { wait(0); } } else { while (isAlive()) { long delay = millis - now; if (delay |