多线程与高并发(一)—— 自顶向下理解Synchronized实现原理

一、 什么是锁?

在多线程中,多个线程同时对某一个资源进行访问,容易出现数据不一致问题,为保证并发安全,通常会采取线程互斥的手段对线程进行访问限制,这个互斥的手段就可以称为锁。锁的本质是状态+指针,当一个线程进入临界区前需要先修改状态,表明已加锁,并且指针指向加锁的线程。后续线程在进入临界区时同样需要尝试修改状态,修改状态前首先检查指针是否为空,如果不为空且指向其他线程则表明已经有其他线程占用了锁,则无法进行状态修改,也就是此线程获取锁失败。

二、 Synchronized 锁原理

Synchronized 关键字如何实现同步互斥?

一、生成字节码

首先了解 Synchronized 的三种用法:

  • 锁对象实例
  • 锁方法实例
    以上三种不同的使用方式,JVM 生成的字节码也不同,具体如下:
  1. 锁对象实例
Synchronized(this) {
}

通过 反编译生成的字节码可看到,生成了字节码指令 monitorenter 和 monitorexit;当代码执行到monitorenter时加锁,执行monitorexit时解锁。Exception table 意为异常跳表, 如下,该异常表监测了7-13行的指令,也就是同步块,如果在同步块中出现了异常导致无法解锁,指令会跳转到 target 16 行执行,如此便能保证即使出现异常也不会导致永远无法退出锁。

public void test();
    Code:
       0: aload_0
       1: getfield      #3                  // private Object lock = new Object();
       4: dup
       5: astore_1
       
       6: monitorenter
       
       7: aload_0
       8: invokevirtual #4                  // Method foo:()V
       
      11: aload_1
      12: monitorexit
      13: goto          21
      
      16: astore_2
      17: aload_1
      18: monitorexit
      19: aload_2
      20: athrow
      21: return
    Exception table:
       from    to  target type
           7    13    16   any
          16    19    16   any

  1. 锁方法实例
synchronized public void test() {
}

方法级别的同步不会生成 monitorenter 和 monitorexit 指令,通过常量池中方法的 ACC_SYNCHRONIZED 标志来隐式实现,JVM在调用方法时,对方法的符号引用(flags)进行解析,ACC_PUBLIC 为公共方法,ACC_SYNCHRONIZED 为同步方法,如果此方法是同步方法则会进行加锁。

public synchronized void test();
descriptor: ()V
flags: ACC_PUBLIC, ACC_SYNCHRONIZED

二、字节码如何执行?

  1. JVM 初始化时会为每个字节码指令都创建一个模板,每个模板都关联到其对应的汇编代码生成函数。以 HotSpot jdk8 为例,该模板位于src/share/vm/interpreter/templateTable.cpp(源码地址:http://hg.openjdk.java.net/jdk8u/hs-dev/hotspot/file/ae5624088d86


2. 如图所示,字节码 monitorenter 和 monitorexit 分别对应的函数名就是其本身,当执行字节码的时候,就会调用到对应的函数
3. 这两个函数在 src/share/vm/runtime/objectMonitor.cpp 中

三、如何进行加锁解锁?

整理下现在的流程:多线程并发时,代码使用 Synchronized 关键字,JVM 在编译代码时,遇到此关键字按上【生成字节码】所述,要么生成字节码 monitorenter/monitorexit,要么判断方法是否为同步方法(ACC_SYNCHRONIZED),最终都会执行函数 monitorenter 和 monitorext,分别对应加锁和解锁。

3.1 了解ObjectMonitor

在了解加锁解锁流程之前,我们首先熟悉下 ObjectMonitor的结构:
ObjectMonitor 数据结构如下:

// are simple integers or pointers
ObjectMonitor() {
    _header       = NULL;
    _count        = 0;       // 记录个数
    _waiters      = 0,
    _recursions   = 0;       // 线程重入次数
    _object       = NULL;    // 存储关联此 Monitor 的对象
    _owner        = NULL;    // 指针,指向获得该 Monitor 对象的线程
    _WaitSet      = NULL;    // 处于wait状态的线程,会被加入到 _WaitSet
    _WaitSetLock  = 0 ;
    _Responsible  = NULL ;
    _succ         = NULL ;
    _cxq          = NULL ;   // 单向列表
    FreeNext      = NULL ;
    _EntryList    = NULL ;   // 处于等待锁block状态的线程,会被加入到该列表
    _SpinFreq     = 0 ;
    _SpinClock    = 0 ;
    OwnerIsThread = 0 ;
    _previous_owner_tid = 0;
}
  • Owner:指针,指向获取到 monitor 对象锁的线程;Owner 初始化时为 NULL, Owner 为唯一标识,即只能指向一个线程;开篇说锁的本质是状态+ 指针,在 ObjectMonitor 中,Owner 就是具象的体现;Owner 为空则表示未加锁,不为空则加锁成功,且标识了获得锁的线程。
  • cxq:所有请求锁的线程会进入此队列
  • EntryList:有资格获取锁资源的线程会进入此队列
  • WaitSet:调用了 wait 等会使线程进入 WAIT 状态的方法后,该线程会进入此队列

3.2 了解对象头

  1. 对象结构
    Java 对象包含以下三部分:对象头、对象体、对齐字节

    (ps: 图片引用自:https://juejin.cn/post/6993308982081224711
    此处我们只关注对象头,其余 JVM 知识可自行查阅

  2. 对象头结构
    对象头主要分两部分(忽略数组对象), 一般占有两个机器码,在32位虚拟机中一个机器码是4个字节,即32bit,64位机是8个字节;如图所示,以32位 HotSpot虚拟机为例,高 32 位是 Mark Word (下面详细介绍);低 32 位为Class Word,这部分是类指针,即表明该对象是哪个类的实例。

  3. Mark Word 结构
    如图所示为无锁状态下的 Mark Word, 为节省对象存储空间,Mark Word 被设计成可复用的,在不同的对象状态下,Mark Word的内容和结构会随之变化; Synchronized 锁优化后,锁的状态有不同种类,不同种类锁的状态下 Mark Word 也不同,具体内容在加锁解锁的时候一并介绍。

  • HashCode 25位
  • age gc分代年龄,每经历一次垃圾回收还存活的对象年龄加一
  • biased_lock 表明是否为偏向锁
  • lock_state 加锁状态

3.3 了解Monitor 机制

  1. Monitor 概述

A monitor is a software module consisting of one or more procedures, an initialization sequence, and local data[1]

Monitor是由一到多个程序和一个初始化的序列和数据组成的软件模块;简而言之,Monitor 并非是和 Semaphore 这样的互斥原语,Monitor 是由编程语言实现的一整套逻辑。Monitor 中不仅有方法,还涵盖了数据、变量。
Monitor 有以下特点:

  • 内部数据变量只有 Monitor 的内部函数可以调用,外界无法访问
  • 外部程序通过调用 Monitor 自身的函数进入 Monitor
  • 在某一时刻只能有一个程序调用Monitor, 其他访问 Monitor 的程序被阻塞住直到 Monitor 可用
  1. Monitor 的语义[2]
  • Mesa 语义
    第一个线程获取资源后,不能第一时间进入 Monitor ,需要先进入entry queue, 第二个线程得到执行,当第二个线程执行完毕后,第一个线程从entry queue 中出队得到执行。

  • Hoare 语义
    第一个线程的资源得到满足的话,就应当立即执行;第二个线程放入 signal queue,等待第一个线程执行完毕离开 Monitor 后,通知signal queque 中的线程,此时第二个线程才被执行。

  • Brinch Hanson 语义
    该语义简单一些,当通知线程离开了 Monitor 之后,被通知的线程才能得到执行;注意与 Hoare 的区别,Hoare 是线程离开 Monitor 之后才通知,Brinch Hanson 先通知后离开。

  1. HotSpot 实现的 Monitor

Each object in Java is associated with a monitor, which a thread can lock or unlock. Only one thread at a time may hold a lock on a monitor.[3]

每个对象都和一个 可以被加锁和解锁的 Monitor 与之关联,Monitor 在同一时刻只能被一个线程加锁成功。
(ps: Monitor 何时创建,是否随着 Java 对象的生命周期创建和销毁?这个问题暂时未找到答案)
Java 对 Monitor 的实现就是 Synchronized, Java 对 Mesa 语义进行了精简,Mesa 支持多个条件变量,在 Java 中,等待队列的支持的条件变量只有一个,也就是说只能有一个原因导致线程阻塞住,

3.4 Monitor加锁解锁流程

  1. Java 对象的 Mark Word 中的 HashCode、Age 等信息保存至 ObjectMonitor 的 _header字段
  2. Java 对象中的Mark Word 如流程图,高30位保存的是 Monitor 的地址,低2位锁标志设置为10
  3. 第一个线程A进入临界区时,owner此时还未指向任何线程,那么 owner 指向线程A,线程A即加锁成功。
  4. 后续线程B进入临界区时,同样先判断owner是不是自己这个线程,发现不是指向自己,那么线程B就进入 EntryList 等待,同理其他线程 Thread C 也进行相同操作进入队列等待;
  5. 进入 monitor 的线程如果调用了 wait() 方法,那么进入 waitSet 队列等待,当线程准备就绪后再次进入 entrylist 重新竞争锁
    6.当 Thread A 执行完临界区代码后,owner 置为 null 释放锁对象,接着调用 unpark 方法唤醒 EntryList 队列中所有线程
  6. Monitor 保存的 HashCode 等数据重新设置到Java 中的 Mark Word

整个加锁流程如下图:

三、锁的优化

1.Monitor 机制的弊端

可以看到 Monitor 机制依赖操作系统的 wait() 和 signal() 原语,线程进入队列阻塞需要调用 wait() 方法,被唤醒需要调用 signal() 方法;这两个方法都由操作系统内核提供,使用这两个方法 CPU 需要从用户态切换为内核态,多个线程竞争锁的时候,频繁的内核态转换,势必浪费了很多性能。jdk5之前 Synchronized 的实现只基于 Monitor机制, jdk6之后,对Synchronized 做了大量优化。

2. 锁的优化措施

  • 加入偏向锁、轻量级锁状态,不轻易使用重量级锁(Monitor)
  • 锁消除
  • 自旋锁
  • 锁粗化
    主要的优化措施有以上四个,下面一一介绍

2.1 锁划分状态

锁划分了不同种状态,在不同竞争程度下使用相对应的锁;不同状态锁对应竞争程度如下:

2.1.1 轻量级锁

在多线程中,并不一定存在着资源竞争, 如果一个对象虽然有多线程访问,但多线程访问的时间是错开的(即没有竞争),那么可以使用轻量级锁来优化。流程如下:

2.1.2 偏向锁

偏向锁是对轻量级锁的优化,轻量级锁在没有竞争时,每次重入仍然需要执行cas操作;java6之后引入偏向锁进行优化:只有第一次使用cas将线程Id设置到对象的mark word,之后发现这个线程Id是自己的就表示没有竞争,不用重新cas操作。

2.1.3 锁自旋(重量级锁)

重量级锁竞争的时候,当线程竞争锁失败的时候,在没有自旋锁优化之前,该线程会进入阻塞状态,也就是会引起内核态的切换。事实上,持有锁的线程很可能很快就能执行完任务,如果当前竞争锁失败的线程再等一会,在等待的期间持有锁的线程释放了锁,那么该线程就不用进入阻塞队列,直接获取锁资源,避免了一次阻塞和一次唤醒,大大提高了性能,这个等待的方式就是自旋。自旋就是在不访问共享资源的情况下,并不放弃 CPU 时间片,做循环空转任务,默认是10次。

锁自旋的自适应
java6之后自旋锁是适应的,自旋操作成功过,则认为自旋成功的可能性会高,就多自旋几次,反之就少自旋甚至不自旋。自旋会占用cpu时间,单核自旋就是浪费,多核cpu自旋才能发挥优势

ps:注意我将锁自旋列入【锁划分状态】下的章节,而不是和锁消除、锁粗化等做同一并列,这是因为自旋针对的是重量级锁,是对重量级锁的优化。

2.1.4 锁状态的总结

重量级锁的资源消耗主要就是阻塞线程和唤醒线程导致的内核态切换,所以需要尽可能的避免这两个操作,优化方法有两个:

  1. 一是尽可能地避免使用重量级锁,因而出现了轻量级锁,针对轻量级锁又优化产生了偏向锁
  2. 二是减少重量级锁情况下的系统调用,也就是使用锁自旋
    网上一些博客说锁的状态切换是无锁到偏向锁到轻量级锁到重量级锁,这是一种错误的说法。在竞争激烈的时候,是可以无锁直接到重量级锁状态的,另外如果竞争不激烈,也是无锁状态到轻量级锁,偏向锁适用的场景实际上是没有竞争。

2.2 锁消除

即时编译器在运行时,检测到不可能存在共享数据竞争,那么会对锁进行消除。判定依据来源于逃逸分析的数据支持

2.3 锁粗化

在编写代码时,通常我们将锁的范围限制的较小,但是如果一系列的操作对同一个对象反复加锁和解锁,甚至是出现在循环体中,那么jvm会将同步代码块的范围方法,放到这一系列操作之外,这样只需要一次加锁

四 结语

锁的优化在此文仅做简单阐述,这一块需要串联起来讲锁的整个加锁解锁流程,见下一篇章《多线程与高并发(二)—— Synchronized 加锁解锁流程》

Reference:

[1] 《Operating Systems - Internals and Design Principles 7th》
[2] [Monitors and Condition Variables]:https://cseweb.ucsd.edu/classes/sp16/cse120-a/applications/ln/lecture9.html
[3] [oracle 官方文档]:https://docs.oracle.com/javase/specs/jls/se7/html/jls-17.html

posted @ 2022-07-15 17:16  onAcorner  阅读(156)  评论(1编辑  收藏  举报