一、 什么是锁?

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

二、 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

多线程与高并发(一)—— 自顶向下理解Synchronized实现原理的更多相关文章

  1. 多线程与高并发(二)—— Synchronized 加锁解锁流程

    前言 上篇主要对 Synchronized 的锁实现原理 Monitor 机制进行了介绍,由于 Monitor 基于操作系统调用,上下文切换导致开销大,在竞争不激烈时性能不算很好, 在 jdk6 之后 ...

  2. 互联网大厂高频重点面试题 (第2季)JUC多线程及高并发

    本期内容包括 JUC多线程并发.JVM和GC等目前大厂笔试中会考.面试中会问.工作中会用的高频难点知识.斩offer.拿高薪.跳槽神器,对标阿里P6的<尚硅谷_互联网大厂高频重点面试题(第2季) ...

  3. 多线程与高并发(三)synchronized关键字

    上一篇中学习了线程安全相关的知识,知道了线程安全问题主要来自JMM的设计,集中在主内存和线程的工作内存而导致的内存可见性问题,及重排序导致的问题.上一篇也提到共享数据会出现可见性和竞争现象,如果多线程 ...

  4. 多线程深入:让你彻底理解Synchronized(转)

    原文:https://www.jianshu.com/p/d53bf830fa09 1. synchronized简介 在学习知识前,我们先来看一个现象: public class Synchroni ...

  5. java高并发----个人学习理解汇总记录

    1.首先,需要理解几个概念 1.同步(Synchronous):同步方法调用一旦开始,调用者必须等到前面的方法调用返回后,才能继续后续的行为,依次直到完成所有. 2.异步(Asynchronous): ...

  6. 一篇博客带你轻松应对java面试中的多线程与高并发

    1. Java线程的创建方式 (1)继承thread类 thread类本质是实现了runnable接口的一个实例,代表线程的一个实例.启动线程的方式start方法.start是一个本地方法,执行后,执 ...

  7. 使用Redis中间件解决商品秒杀活动中出现的超卖问题(使用Java多线程模拟高并发环境)

    一.引入Jedis依赖 可以新建Spring或Maven工程,在pom文件中引入Jedis依赖: <dependency> <groupId>redis.clients< ...

  8. java后端知识点梳理——多线程与高并发

    进程与线程 进程是一个"执行中的程序",是系统进行资源分配和调度的一个独立单位 线程是进程的一个实体,一个进程中一般拥有多个线程. 线程和进程的区别 进程是操作系统分配资源的最小单 ...

  9. C#网络编程 多线程和高并发

    在任何 TCP Server 的实现中,一定存在一个 Accept Socket Loop,用于接收 Client 端的 Connect 请求以建立 TCP Connection. 在任何 TCP S ...

随机推荐

  1. 跟我学Python图像处理丨获取图像属性、兴趣ROI区域及通道处理

    摘要:本篇文章主要讲解Python调用OpenCV获取图像属性,截取感兴趣ROI区域,处理图像通道. 本文分享自华为云社区<[Python图像处理] 三.获取图像属性.兴趣ROI区域及通道处理 ...

  2. Vulnhub-DC-4靶机实战

    前言 靶机下载地址:https://www.vulnhub.com/entry/dc-4,313/ KALI地址:192.168.75.108 靶机地址:192.168.75.207 一.信息发现 1 ...

  3. Vue报错: Uncaught (in promise) TypeError: Cannot read properties of undefined (reading 'protocol')

    Vue报错: Uncaught (in promise) TypeError: Cannot read properties of undefined (reading 'protocol') 报错信 ...

  4. 使用grabit分析mysql数据库中的数据血缘关系

    使用grabit分析mysql数据库中的数据血缘关系 Grabit 是一个辅助工具,用于从数据库.GitHub 等修订系统.bitbucket 和文件系统等各种来源收集 SQL 脚本和存储过程,然后将 ...

  5. 命令行参数 getopt模块

    getopt中的函数: getopt.getopt(sys.argv[1:], shortopts, longopts=[]) args指的是当前脚本接收的参数,它是一个列表,可以通过sys.argv ...

  6. 服务器上详细前后端分离项目搭建(springboot+vue)

    介绍:本文用的经典的前后端分离开源项目ruoyi Gitee链接地址:https://gitee.com/y_project/RuoYi 一.拉取项目: 利用Git把项目拉取到本地,也可以直接利用id ...

  7. 原创工具14Finger-全能web指纹识别与分享平台

    14Finger 功能齐全的Web指纹扫描和分享平台,基于vue3+django前后端分离的web架构,并集成了长亭出品的rad爬虫的功能,内置了一万多条互联网开源的指纹信息. Github:http ...

  8. JS倒计时(刷新页面不影响)的实现思路

    最近在做一个项目,用到了点击按钮实现倒计时,这个用js来实现很简单.但是遇到了一个问题 页面刷新后js重新加载导致 倒计时重新开始,或者直接初始化了 后来通过 cookie 保存来实现了js倒计时,关 ...

  9. 记一次 JDK SPI 配置不生效的问题 → 这么简单都不会,还是回家养猪吧

    开心一刻 今天去幼儿园接小侄女,路上聊起了天 小侄女:小叔,今天我吃东西被老师发现了 我:老师说了什么 小侄女:她说拿出来,跟小朋友一起分享 我:那你拿出来了吗 小侄女一脸可怜的看向我,说道:没有,我 ...

  10. 一个ES设置操作引发的“血案”

    背景说明 ES版本 7.1.4 在ES生产环境中增加字段,一直提示Setting index.mapper.dynamic was removed after version 6.0.0错误.但是我只 ...