线程同步-synchronized-底层原理

Synchronized-底层原理

:::primary

① Synchronized 等价于操作系统中的管程,所以首先理解操作系统中的管程是非常重要的

② Java 中提供的所有锁机制几乎都是基于管程的概念实现的

③ 想要知道 Synchronized 是如何实现管程的,需要了解以下的知识

参考笔记:

堆空间 - 对象创建

进程同步-信号量

:::

什么是管程?

:::primary

先来了解下管程的基本机制和基本概念,不涉及具体的过程细节,具体细节放在之后再讲述

参考书籍:《现代操作系统》

参考博客:管程的理解

:::

  • 名称:Monitor

    • 被翻译成管程或者监视器(Java 中还有个概念叫监听器,不过这玩意不重要
  • 定义:[管理共享变量和共享变量的函数实现的一种用于实现同步的高级 抽象数据结构 ]{.red}

    • [管程中主要包含线程需要使用的共享变量,以及操作共享变量的函数]{.pink}
    • [抽象数据结构就意味着它不是具体的实现,所以不同语言可以实现自己的管程,是编译器层面的同步方式]{.pink}
  • 组成:[共享变量 + 函数 + 信号量 + 条件变量]{.red}

    • [管程作为高级抽象数据结构,其底层通常采用信号量实现同步]{.pink}
    • [编译器通常确保操作系统能够识别到管程,进而使用信号量进行同步]{.pink}
    /*这样的抽象数据结构就称为管程*/
    monitor monitor_name
    {
    /*共享变量*/
    int count;
    /*信号量: 实现互斥使用的(由编译器负责,程序员不用管)*/
    Semaphor semaphor;
    /*条件变量: 实现线程阻塞和唤醒使用的*/
    Condition condition;
    /*共享变量的函数实现*/
    void function1(){};
    void function2(){};
    /*初始化代码*/
    ...
    }
  • 机制:

    • 互斥:[管程机制本质采用信号量等同步机制确保线程在管程中的互斥,即管程中仅存在唯一活跃的线程]{.red}
      • [信号量是由编译器在生成编译文件的时候自动添加而不是由程序员添加,也就是管程是由编译器实现的]{.pink}
      • [所以管程可以认为是 软件层面 实现的同步方式,而此前的信号量通常都是 硬件层面 实现的]{.pink}
    • 条件变量:[管程机制允许活跃线程条件不足主动放弃管程的使用权,等待条件满足后重新获取管程的使用权]{.red}
      • 为何引入条件变量?
        • 条件不足可能是因为线程需要的某种资源没有获取到,诸如 IO 读取的数据、缓冲区数据不足等等
        • 线程无法得到资源就无法向下执行,就会无限期持有锁,导致其余线程永远获取不到锁
      • 如何使用条件变量?
        • 核心:线程放弃使用管程时就会自行调用 wait、其余线程唤醒这些等待线程时就会使用 signal
        • wait 方法会从条件变量中减少一个信号,同时将线程添加到等待队列中
        • signal 方法会向条件变量发送一个信号,唤醒等待队列中的线程
      • 本质:[条件变量类似于二元信号量 + 指针]{.red}
        • [每个条件变量仅能够存储一个信号]{.pink}
          • 多次调用 signal 方法而不调用 wait 方法会导致信号无法存储而丢失,导致唤醒失去意义
          • 也就是说 wait 方法必须在 signal 方法前使用,毕竟没有等待的线程再怎么唤醒也是没有意义的
        • [每个条件变量还指向一个等待队列,用于记录当前正在等待的线程]{.pink}
  • 特点:

    • [Pascal、C 语言都是不支持管程这种高级同步原语的,Java、C++ 是支持的]{.red}
      • Java 管程不同于 OS 定义的经典管程
        • [OS 管程默认可以定义包含多个不同的条件变量(定义)]{.pink}
        • [Java 管程默认仅包含一个条件变量,Java ReentrantLock 管程可以创建多个条件变量(实现)]{.pink}
    • [进程或者线程不可以直接访问管程中包含的共享变量,只能够借助管程中的函数访问]{.red}

为什么要使用管程?

:::primary

参考书籍:《操作系统概念》

:::

  • 核心:[采用信号量实现同步可能存在许多难以避免的问题]{.red}

  • 示例:(采用 C 语言描述)

    • [同时执行两次减少信号量的操作显然会导致死锁的发生,这完全可能由程序员自己造成]{.aqua}

      Semaphore mutex = 1;

      wait(mutex);
      // 临界区
      wait(mutex);
    • [先执行增加信号量的操作,后执行减少信号量的操作,会导致临界区中多于一个活跃线程]{.aqua}

      Semaphore mutex = 1;

      signal(mutex);
      // 临界区
      wait(mutex);
  • 总结:
    • 程序员需要自己确定 wait、signal 操作放置在何处,容易不小心放错位置
    • 管程通常借由编译器保证这些 PV 操作到底添加在何处,比程序员自己添加要安全得多
    • [Java 采用管程的另一个原因是因为管程比较方便实现(为啥?)]{.grey}

管程如何实现?

:::info

① 上述内容先简单讲述了 OS 中定义的管程概念,因为管程是一种抽象的概念,所以没有在刚才的内容讲述更加具体的内容

② 接下来就会讲述 Java 如何实现管程这一概念,但是需要优先了解对象是如何组成的

参考笔记:对象是如何组成的?

:::

  • 前提:

    • 对象头的组成
      • Mark Word:包含哈希码、年龄计数器、锁记录等相关信息
      • 类型指针(指向 Klass 对象)
      • 数组长度(只有数组才有的部分)
    • [阻塞队列:等待当前线程释放锁的线程会进入阻塞队列]{.red}
    • [等待队列:调用 wait 方法后等待被唤醒的线程进入等待队列]{.red}
  • 图解 synchronized 原理

    • 整体过程

      管程
    • 线程获取锁:

      1. 线程判断管程当前是否存在使用者
      • 如果管程当前不存在使用者,那么还要考虑阻塞队列中是否存在等待线程

        • [如果不存在等待线程,那么当前线程就可以直接获取管程的使用权]{.pink}

          获取管程的使用权(1)
        • [如果存在等待线程,线程就需要和阻塞队列中的线程竞争获取对象锁]{.pink}

          获取管程的使用权(2)
      • [如果管程当前存在使用者,那么线程就需要进入阻塞队列等待]{.pink}

        获取管程的使用权(3)
      1. [对象头 的 Mark Word 的锁记录会从未上锁改为重量级锁状态,同时重量级锁指针指向当前的管程]{.pink}

      2. [管程 在线程获取到使用权之后将内部的 Owner 属性设置为当前线程]{.pink}

        // Java 虚拟机内部的管程源码
        ObjectMonitor() {
        _header = NULL;
        _count = 0; //记录个数
        _waiters = 0,
        _recursions = 0;
        _object = NULL;
        _owner = NULL; // 持有锁的当前线程
        _WaitSet = NULL; // 调用 wait 方法后等待被唤醒的线程进入等待队列
        _WaitSetLock = 0 ;
        _Responsible = NULL ;
        _succ = NULL ;
        _cxq = NULL ;
        FreeNext = NULL ;
        _EntryList = NULL ; // 等待当前线程释放锁的线程会进入阻塞队列
        _SpinFreq = 0 ;
        _SpinClock = 0 ;
        OwnerIsThread = 0 ;
        }
        更新-Mark-Word
    • 线程退出管程(锁)的过程

      1. [对象头的 Mark Word 又从重量级锁的状态变为未上锁状态,重量级指针指向空(null)]{.pink}
      2. [管程会将内部的 Owner 属性重新置为空(null),即现在没有线程使用管程]{.pink}
    • 线程进入和退出等待队列

      • [线程自行调用 wait 方法放弃管程的使用权,从而进入等待队列中等待被唤醒]{.pink}
      • [其余线程才可以调用 notify / notifyAll 方法唤醒等待队列中的线程,让等待的线程退出等待队列]{.pink}
  • 字节码解释 synchronized 原理

    • synchronized 锁对象

      • [编译器会在进入临界区和退出临界区之前添加两条字节码指令:monitorenter、monitorexit]{.aqua}
        • 这两条指令就实现了管程中始终只存在唯一的活跃线程,实现了线程的互斥
      • [monitorenter 指令会尝试获取对象锁,如果获取成功就会进入临界区,获取失败就会进入等待队列]{.aqua}
      • [monitorexit 指令会确保对象锁能够正常被释放,确保其他线程能够获取对象锁]{.aqua}
      • [额外的 monitorexit:确保临界区中发生异常后依然能够正确释放对象锁]{.pink}
      public static void main(java.lang.String[]);
      descriptor: ([Ljava/lang/String;)V
      flags: (0x0009) ACC_PUBLIC, ACC_STATIC
      Code:
      stack=2, locals=3, args_size=1
      0: getstatic #2 // Field LOCK:Ljava/lang/Object;
      3: dup
      4: astore_1
      5: monitorenter // 编译器在字节码文件中插入 monitor 原语确保实现同步
      6: getstatic #3 // 临界区:操作共享变量
      9: iconst_1
      10: iadd
      11: putstatic #3
      14: aload_1 // 临界区
      15: monitorexit // 编译器在字节码问题进中插入 monitor 原语确保退出同步块
      16: goto 24
      19: astore_2
      20: aload_1
      21: monitorexit // 编译器确保代码抛出异常时依然能够释放锁的使用权, 从而确保不会死锁
      22: aload_2
      23: athrow
      24: return

    • synchronized 锁方法

      • [方法锁不再添加字节码来确保线程间的互斥而是通过设置访问权限让虚拟机意识到需要进行同步]{.aqua}
      public static synchronized void increment();
      descriptor: ()V
      // 锁方法就不会再字节码文件中添加字节码指令来确保同步而是设置访问权限
      // 设置 ACC_SYNCHRONIZED 同步的权限
      flags: (0x0029) ACC_PUBLIC, ACC_STATIC, ACC_SYNCHRONIZED
      Code:
      stack=2, locals=0, args_size=0
      0: getstatic #2 // Field count:I
      3: iconst_1
      4: iadd
      5: putstatic #2 // Field count:I
      8: return
      LineNumberTable:
      line 12: 0
      line 13: 8

Author: Fuyusakaiori
Link: http://example.com/2021/10/28/juc/多线程基础/线程同步-synchronized-底层原理/
Copyright Notice: All articles in this blog are licensed under CC BY-NC-SA 4.0 unless stating additionally.