百万架构师第四十七课:并发编程的原理(二)|JavaGuide

原文链接
JavaGuide

《并发编程的艺术》

并发编程的实现原理

目标

  • 上节课内容回顾
  • synchronized 原理分析
  • waitnotify
  • 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 资源的一种优化手段,它的最终目的是提升性能。它有一个标准:不会改变我们指令的语义。

​ 我们定义了一个程序,不管我怎么改变,他的结果是不会去改变的。

  1. 编译器的优化重排序
  2. CPU 的指令重排序
  3. 内存系统的重排序

'编译器的乱序

  • 不改变单线程语义的前提下。

    java 复制代码
    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 ,等待同步去加载。它会导致我们完成的顺序是不确定的。造成一些不同的结果。

java 复制代码
x = 1; // 写
y = x; // 读 , 写

​ CPU 层面上引入了内存屏障的功能。

store barrier / load barrier / full barrier

​ 内存屏障的意思就是我们 CPU 或者编译器在对我们内存随机访问的操作里边,它在某个地方加入了同步的点。同步点,使得我们屏障之前的读写操作全部执行完才能执行屏障之后的指令。同步点,CPU 写入数据的时候是异步的。他必须等待其他 CPU 确认以后才会去同步这个数据。不同 CPU 架构的内存屏障是不一样的。(x86)

内存屏障的作用
  • 保证数据的可见性
  • 防止指令之间的重排序

有些 CPU 架构对内存屏障的支持是不一样的,有些 CPU 是支持强一致性的,就不需要内存屏障了。

多线程在 JAVA 中的体现

我们关心在 JVM 层面如何解决这些问题就好了

JVM 提供了四种内存屏障来解决 CPU 的指令重排序和编译器的指令重排序的问题。

c 复制代码
inline void OrderAccess::loadload()   { compiler_barrier(); }
inline void OrderAccess::storestore() { compiler_barrier(); }
inline void OrderAccess::loadstore()  { compiler_barrier(); }
inline void OrderAccess::storeload()  { fence();            }

每一个屏障都定义了一个方法。功能最全,性能比较低。

c 复制代码
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 大部分都是用 缓存锁。

  1. read -> 获取
  2. modity-> 变化
  3. write -> 写入

fence 就是用的内存屏障,并没有用到 CPU 层面的内存屏障。不同的 CPU 架构,实现不太一样。它是用 Lock 来实现我们内存屏障的效果。

x86 是强一致性的。

Volatile 关键字

  • 可见性
    • 内存屏障
      • 编译器的内存屏障
      • 处理器的内存屏障
java 复制代码
int a = 1;
volatile int b = a;
// 对于 volatile 写来说,
// storesotre() 指令(release)
// b = a;
// storeload  强制加入这个指令。固定的加了 storeload 
复制代码
 `J.U.C` 里边思路是一样的。storestore 是让写全部同步到主内存。storeload 是让所有的读和写全部写入到内存。
java 复制代码
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 一直是元老级角色,很多人都会称呼它为重量级锁。但是,随着 Java SE 1.6synchronized 进行了各种优化之后,有些情况下它就并不那么重了,Java SE 1.6中为了减少获得锁和释放锁带来的性能消耗而引入的偏向锁和轻量级锁,以及锁的存储结构和升级过程。我们仍然沿用前面使用的案例,然后通过 synchronized 关键字来修饰在inc的方法上。再看看执行结果。

java 复制代码
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有三种方式来加锁,分别是

  1. 修饰实例方法,作用于当前实例加锁,进入同步代码前要获得当前实例的锁

  2. 静态方法,作用于当前类对象加锁,进入同步代码前要获得当前类对象的锁

  3. 修饰代码块,指定加锁对象,对给定对象加锁,进入同步代码库前要获得给定对象的锁。

java 复制代码
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++;
}

问题

  1. synchronized 是如何实现锁的。
  2. 为什么任何一个对象都可以成为锁
  3. 锁存在哪个地方

synchronized括号后面的对象

synchronized 括号后面的对象是一把锁,在 JAVA 中任意一个对象都可以成为锁,简单来说,我们把 Object 比喻是一个 key ,拥有这个 key 的线程才能执行这个方法,拿到这个 key 以后在执行方法过程中,这个 key 是随身携带的,并且只有一把。如果后续的线程想访问当前方法,因为没有 key 所以不能访问,只能在门口等着,等之前的线程把 key 放回去。所以, synchronized 锁定的对象必须是同一个,如果是不同对象,就意味着是不同的房间的钥匙,对于访问者来说是没有任何影响的。

java 复制代码
Object lock = new Object();
public void demo3(){
    synchronized(lock){
        
    }
}

synchronized的字节码指令

​ 通过 javap -v 来查看对应代码的字节码指令,对于同步块的实现使用了 monitorentermonitorexit 指令,前面我们在讲 JMM 的时候,提到过这两个指令,他们隐式地执行了 LockUnLock 操作,用于提供原子性保证。 monitorenter 指令插入到同步代码块开始的位置、monitorexit指令插入到同步代码块结束位置,jvm需要保证每个 monitorenter 都有一个 monitorexit 对应。

​ 这两个指令,本质上都是对一个对象的监视器 ( monitor ) 进行获取,

​ 这个过程是排他的,也就是说同一时刻只能有一个线程获取到由 synchronized 所保护对象的监视器线程执行到 monitorenter 指令时,会尝试获取对象所对应的 monitor 所有权,也就是尝试获取对象的锁;而执行 monitorexit ,就是释放 monitor 的所有权。

​ 所有的 JAVA 对象天生带有 monitor

c 复制代码
  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:

获得一把锁,就需要去释放

两个 moitorexit 一个是异常的时候,会释放锁。

synchronized的锁的原理

jdk1.6 以后对 synchronized 锁进行了优化,包含偏向锁、轻量级锁、重量级锁;在了解 synchronized 锁之前,我们需要了解两个重要的概念,一个是对象头、另一个是 monitor

  • 偏向锁
  • 轻量级锁
  • 重量级锁

Java对象头

对象在内存三个区域

  • 对象头

  • 实例数

  • 对齐填充

    复制代码
      在 Hotspot 虚拟机中,对象在内存中的布局分为三块区域:对象头、实例数据和对齐填充;Java对象头是实现synchronized的锁对象的基础,一般而言,synchronized使用的锁对象是存储在Java对象头里。它是轻量级锁和偏向锁的关键。

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
c 复制代码
class oopDesc {
  friend class VMStructs;
  friend class JVMCIVMStructs;
 private:
  volatile markOop _mark;   // 就是对象头
  union _metadata {
    Klass*      _klass;
    narrowKlass _compressed_klass;
  } _metadata;

markOop.hpp

Monitor

​ 什么是 Monitor ?我们可以把它理解为一个同步工具,也可以描述为一种同步机制。所有的Java对象是天生的 Monitor ,每个object的对象里 markOop->monitor() 里可以保存 ObjectMonitor 的对象。从源码层面分析一下monitor对象。

  • oop.hpp下的 oopDesc 类是JVM对象的顶级基类,所以每个object对象都包含markOop

    oop.hpp

  • markOop.hppmarkOopDesc 继承自 oopDesc ,并扩展了自己的 monitor 方法,这个方法返回一个ObjectMonitor 指针对象
  • objectMonitor.hpp,在hotspot虚拟机中,采用ObjectMonitor类来实现monitor,

总结

​ 任何对象在我们 JVM 层面有一个 oop 和 oopDesc 的对应。oop.hpp 会存在一个 mark 的对象头,对象头用来存储锁标志的。这个锁标志,是用来存储对应的偏向锁、轻量锁等的标志。

​ 锁是存在对象头里边的。

QA:

​ 对象锁之间不相互干扰。全局锁意味着不管你多少个实例,我都能够锁定你。锁的范围,取决于你的对象的生命周期。这个对象的生命周期有多大,那你的锁的作用域就有多大。

synchronized的锁升级和获取过程

​ 了解了对象头以及 monitor 以后,接下来去分析 synchronized 的锁的实现,就会非常简单了。前面讲过 synchronized 的锁是进行过优化的,引入了偏向锁、轻量级锁;锁的级别从低到高逐步升级, 无锁->偏向锁->轻量级锁->重量级锁。

  • 无锁
    • 偏向锁
      • 轻量级锁
        • 重量级锁

自旋锁(CAS)

​ 自旋锁就是让不满足条件的线程等待一段时间,而不是立即挂起。看持有锁的线程是否能够很快释放锁。怎么自旋?其实就是一段没有任何意义的循环。虽然它通过占用处理器的时间来避免线程切换带来的开销,但是如果持有锁的线程不能很快释放锁,那么自旋的线程就会浪费处理器的资源,因为它不会做任何有意义的工作。所以,自旋等待的时间或者次数是有一个限度的,如果自旋超过了定义的时间仍然没有获取到锁,则该线程应该被挂起。

java 复制代码
// 自旋
for(;;){
    // 不断地获取锁
} // 1.7 之前可以自己配置 1.7 之后,JVM 去控制

耗费不耗费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 的资源,但是相比于后者,它的性能反而是提升了。但是它不可能一直持续下去。

c 复制代码
ObjectWaiter * volatile _next;
ObjectWaiter * volatile _prev;

​ 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 是用来让线程进入等待状态以及使得线程唤醒的两个操作

java 复制代码
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 ");
        }
    }
}
java 复制代码
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 ");
        }
    }
}
java 复制代码
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();
    }
}

#wait#notify 的原理

  1. Waitnotify 为什么要先获取锁?
  2. waitsleep 的区别?

​ 调用 #wait 方法,首先会获取监视器锁,获得成功以后,会让当前线程进入等待状态进入等待队列并且释放锁;然后当其他线程调用 #notify 或者 notifyall 以后,会选择从等待队列中唤醒任意一个线程,而执行完 #notify 方法以后,并不会立马唤醒线程,原因是当前的线程仍然持有这把锁,处于等待状态的线程无法获得锁。必须要等到当前的线程执行完按 monitorexit 指令以后,也就是锁被释放以后,处于等待队列中的线程就可以开始竞争锁了。

wait 方法的时候 ObjectMonitor::wait(jlong milllis, boo...){}

  1. 会把当前的线程包装成 ObjectWaiter对象,
  2. 并设置成 TS_WAIT 状态,就是一个 waiting 的
  3. Self _ParkEvent->park(); 挂起!

notify

  1. unpark
  2. 获取 ObjectWaiter
  3. 唤醒

wait 和 notify 为什么需要在 synchronized 里面

#wait方法的语义有两个,一个是释放当前的对象锁、另一个是使得当前线程进入阻塞队列, 而这些操作都和监视器是相关的,所以 #wait 必须要获得一个监视器锁而对于 #notify 来说也是一样,它是唤醒一个线程,既然要去唤醒,首先得知道它在哪里?所以就必须要找到这个对象获取到这个对象的锁,然后到这个对象的等待队列中去唤醒一个线程。

java 复制代码
public ThreadNotify(Object lock) {
    this.lock = lock;
}

@Override
public void run() {
    synchronized (lock){
        System.out.println("开始执行 thread notify");
        lock.notify();
        System.out.println("执行结束 thread notify");
    }
}

java.lang.Thread#join() join 就是调用的 wait 方法。

java 复制代码
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 <= 0) {
                break;
            }
            wait(delay);
            now = System.currentTimeMillis() - base;
        }
    }
}

wait 我们在实际过程中用得很少,我们可能不会彻底地掌控到线程的状态。线程一般都是用线程池,用 Thread 去实现 wait 用得比较少。

有 AQS ,就不用 wait notify 了

synchronized 是如何实现锁的?

​ 轻量级锁到偏向锁到重量级锁。

为什么任何一个任何对象都可以成为锁?

​ 每个 JAVA 对象在我们 JVM 里边都会有一个对象头,对象头会存储锁的一些标志,当前是轻量级锁,还是重量级锁。ObjectMonitor 做一个监视器,去实现锁竞争一种机制。

JavaGuide

来源于: https://javaguide.net

微信公众号:不止极客