前言

上篇主要对 Synchronized 的锁实现原理 Monitor 机制进行了介绍,由于 Monitor 基于操作系统调用,上下文切换导致开销大,在竞争不激烈时性能不算很好, 在 jdk6 之后进了系列优化。前文对优化措施进行了简单介绍,下面将一一介绍这些优化的细节,行文思路大致如下:

  1. 从重量级锁的优化开始讲,一是自旋锁,二是尽量避免进入 Monitor ,即使用轻量级锁
  2. 讲解轻量级锁及加锁解锁流程
  3. 轻量级锁在没有竞争时,每次重入仍然需要执行cas操作,为解决这个问题,因而产生了偏向锁
  4. 详细介绍偏向锁

Synchronized 锁的细节

一、自旋锁

自旋锁比较简单,逻辑在上篇也已经进行过阐述,这一篇章我们着重看下它的性能如何?

在竞争度较小的时候,重量级锁的上下文切换导致的开销相对于 CPU 处理任务的时间占比较重,此种情况下,自旋锁的性能有优势,因自旋而导致的 CPU 浪费在可接受范围内;当竞争激烈的时候,继续使用自旋锁则得不偿失,性能上比直接使用重量级锁要差,大量的等待锁的时间被浪费。

根据任务处理时间不同,自旋锁表现也不一,在任务持续时间长的情况下,自旋太久显然是对 CPU 时间片的浪费,且因任务持续时间长,在 10 此默认自旋次数的情况下,易出现自旋结束也无法获取到锁,那么此次空转就是毫无收益的性能浪费。在任务处理时间较短的情况下,显然自旋获得锁的几率要大,因此如果对要执行的任务有很明确的处理时长认知,可以根据情况适当的调整初始自旋次数,JVM 参数为:-XX:PreBlockSpin。

二、轻量级锁

根据观察,多线程中并不总是存在着竞争,使用轻量级锁避免了锁 Monitor 这繁重的数据结构,轻量级锁通常只锁一个字段(锁记录),在 HotSpot 中的实现是在当前线程的栈帧中创建锁记录结构(Lock Record)。

1.轻量级锁加锁流程

  1. 在当前线程的栈帧中创建 Lock Record
  2. 构建一个无锁状态的 Displaced Mark Word
  3. 将 Displaced Mark Word 存储到 Lock Record 中的 _displaced_header 属性
  4. CAS 更新 Displaced Mark Word 指针,注意【3】是将 Lock record 的 header 的值设置成一个 displaced mark word,【4】这一步是将当前对象头的 Mark Word 中的高30 位(全文都是只针对 32 位虚拟机来谈)指向 Lock Record 中的 header。

    4.1 CAS 成功,执行同步代码块

    4.2 CAS 失败,存在两种情况

    3.2.1 判断是否为锁重入(关于轻量级锁的可重入有疑问,见下文)

    3.2.2 锁被其他线程占有,需要竞争锁,进入锁膨胀过程
  5. 加锁成功的话,当前对象的 Mark Word 后两位锁标志位置为 00,余下高位作为指针存储 Lock Record 的地址

轻量级锁加锁源码如下:

          // traditional lightweight locking
if (!success) {
// markOop就是对象头结构, 生成对象头,这个对象头的状态设置为无锁,生成的这个对象头就是displaced Mark word
markOop displaced = lockee->mark()->set_unlocked();
// 将 displaced Mark word 设置到 lock record 的 _displaced_header 字段
entry->lock()->set_displaced_header(displaced);
// 判断JVM参数-XX:+UseHeavyMonitors 是否设置了只有重量级锁
bool call_vm = UseHeavyMonitors;
// cmpxchg_ptr即 cas 交换指令,将当前对象头的 Mark Word 中的高30 位指向 Lock Record 中的 header 使用重量级锁或者CAS 失败进入这个if块
if (call_vm || Atomic::cmpxchg_ptr(entry, lockee->mark_addr(), displaced) != displaced) {
// Is it simple recursive case? 是否为锁重入
if (!call_vm && THREAD->is_lock_owned((address) displaced->clear_lock_bits())) {
entry->lock()->set_displaced_header(NULL);
} else {
CALL_VM(InterpreterRuntime::monitorenter(THREAD, entry), handle_exception);
}
}
}

2.轻量级锁重入的疑问



关于轻量级锁的重入,实现方式主要有两种,一是如 Monitor 一样通过一个变量来计数,二是每重入一次都生成一个 Lock Record,对Lock Record 的个数计数来隐士计数。在查找资料的过程中发现,大部分的说法是 HotSpot 选择第二种实现方式。

  1. 第一个疑问是为何会选择第二种实现方式,是否对空间造成了一定的浪费,生成 Lock Record 相比整型加一操作性能消耗应该也更大,不知道 HotSpot 作何考量选择此种方式。下图为此种实现方式下的轻量级锁结构。

轻量级锁结构
2. 第一点提出的是多次生成锁记录,但是在代码中没有看到如何重新生成锁记录(如有大佬解惑,不胜感激),是否 lock() 函数创建了一个新的 Lock Record?

3.轻量级锁解锁流程如下(基于使用lock record重入计数的情况):

  1. 遍历栈的Lock Record,如果_displaced_header 为 NULL,表明锁是可重入的,跳过不作处理
  2. 如果_displaced_header 不为 NULL,即最后一个锁记录,调用 CAS 将 _displaced_header 恢复到当前对象头,解锁成功

偏向锁

It also follows the principle of optimizing com- mon cases. The observation exploited is the biased distri- bution of lockers called thread locality. That is, for a given object, the lock tends to be dominantly acquired and re- leased by a specific thread, which is obviously the case in single-threaded applications [2]

根据观察结果来看,多线程下很多时候会出现以下情况:一个线程在频繁的释放和加锁;即多线程实际上已经退化成了单线程线性运行,在这种情况下,减少 CAS 这种原子操作,也能提高性能。偏向锁的原理是为线程保留锁,Mark Word 中存储 ThreadId,只有第一次需要进行 CAS 操作将这个字段设置为当前线程的线程ID,后续加锁的时候只需要查看 ThreadId 是否指向自己,而轻量级锁每次锁字段都需要进行 CAS 操作。

1.偏向锁加锁流程如下:

  1. 检查锁是否可偏向,对象头低位倒数第三位为1(即后三位的值为 0x5)表明可偏向
  2. 如果可偏向,首先判断 Mark Word 的内容是否是当前线程ID,

    2.1 是,执行同步代码

    2.2 不是,执行 CAS 将 Mark Word 的高位设置为当前线程ID, CAS 执行分以下情况:

    2.2.1 执行成功,则加锁成功

    2.2.2 执行失败,说明此锁已经偏向了其他线程,因为产生了竞争所以撤销偏向锁,进入轻量级锁加锁流程

偏向锁加锁流程

2.偏向锁与 HashCode 的关系

由于Hash码必须是唯一的,即 hashcode() 方法只能被调用一次,因此产生了以下规则来保证hash code 的唯一性:

  1. HashCode 是懒加载,当调用 hashcode() 方法的时候,生成的 hash code 才会保存到对象头指定位置
  2. 当一个对象已经调用过 hashcode() 方法,那么偏向锁状态会置为0,无法进入偏向锁状态,直接进入轻量级锁
  3. 如果一个对象现在已经处于偏向锁状态,在同步代码块中需要执行 hashcode() 方法,则偏向锁会撤销,进入重量级锁

3.偏向锁状态下 Mark World 的情况

在无锁状态下,如果没有调用 hashcode() ,高 25 位未使用,如果调用过 hashcode(),则保存的是 hash code,只有当高 25 位未使用时,才能进入偏向锁,mark word 保存获取到锁的线程ID,当锁撤销的时候,恢复为无锁状态,即高 25 位为 NULL。

偏向锁

4.偏向锁的可重入

在一些博客中发现以下说法:使用偏向锁时,每重入一次创建一个Lock Record。这个说法毫无疑问是错误的,偏向锁在重入的时候只检查 ThreadId,是自己的线程 Id 就可以执行同步代码块;解锁则只需要看是否是偏向模式,因此完全没有必要进行重入计数,生成 Lock Record 来计数就更没有这个必要了。

5.什么时候偏向锁不可用

  1. 升级为轻量级锁之后,当一个线程持有锁,另一个线程来竞争锁的时候 CAS 失败,就会将低三位 101 设置为 001,即不可偏向,这也是锁可升级不可降级的原因。
  2. 发生了批量撤销后,就不会再进入偏向锁了

6.JDK 15 偏向锁已经被禁用

JDK 15 开始默认不使用偏向锁,且相关的命令行指令被标记为过时

Biased locking introduced a lot of complex code into the synchronization subsystem and is invasive to other HotSpot components as well. This complexity is a barrier to understanding various parts of the code and an impediment to making significant design changes within the synchronization subsystem. To that end we would like to disable, deprecate, and eventually remove support for biased locking.[3]

偏向锁在同步子系统中引入了许多复杂的代码,并且还侵入了其他 HotSpot 组件。这种复杂性造成了对代码各个部分的理解障碍,也阻碍了同步子系统进行重大设计更改。为此,我们希望禁用、弃用并最终移除对偏向锁的支持。

偏向锁的撤销

1.何为撤销?

撤销是指当对象处于偏向锁模式的时候,不再使用偏向锁,且标记不可偏向(低位 001);注意撤销不是常规意义上的解锁,偏向锁的解锁是当锁处于偏向锁状态时,同步块执行完毕,需要对锁进行释放,只需要检查是否处于已偏向(此处检查两个参数,一是偏向位即低位倒数第三位为1,为 1 即表示可偏向也可表示已偏向,故还需要检查 ThreadID 不为空),如果处于偏向锁模式,则直接 return 释放锁成功。撤销与解锁的区别是撤销需要将偏向锁标识位置为0,标记该对象不可偏向。

撤销操作不是必须在安全点操作,首先会尝试在不安全点使用 CAS 操作修改 Mark Word 为无锁状态,如果尝试失败会等待在安全点(JVM 概念)撤销,等待安全点的操作开销很大,即需要STW。

2.触发撤销的条件

  1. 线程 A 首先获取了偏向锁,此时来了线程 B 尝试对锁偏向,发现锁已经被偏向 A 线程,B 线程会触发锁的偏向撤销并进一步膨胀成轻量级锁。
  2. 触发了批量撤销
  3. 调用 wait()/notify() 触发重量级锁

3. 什么是批量重偏向

当一个类产生了大量对象,在线程 A 访问这些对象时,所有对象偏向A,线程 A 释放锁后,线程 B 访问这些所有对象,每个对象都会触发锁的撤销升级成轻量级锁,这个撤销的次数达到一定阈值(默认20次),JVM 就会把该类产生的所有对象的偏向状态偏向到 B,这就是批量重偏向。批量重偏向的重点即避免进入轻量级锁,由于 A B的竞争导致多个对象都进入了轻量级锁,而通过撤销的阈值判断发现大多数线程都偏向了 B,那么只需要将此类的所有对象都修改成偏向 B 就可以大概率的避免进入轻量级锁。

举个例子:一个类一共生成了30个对象,A 线程访问了 30 个,这 30 个对象都偏向 A,接着 B 只访问了 25 个对象,前20个对象都由于竞争升级成了轻量级锁,由于超过阈值 20 触发了批量重偏向,后续 10 个对象的偏向线程 ID 也被修改为线程 B,线程 B 访问第 21 个之后的对象都只需要使用偏向锁,无需使用轻量级锁。

4. 批量撤销

与批量重偏向同理,都是某个类的对象频繁撤销锁偏向,撤销次数达到一定阈值(默认40次),就会触发以下操作:将次类的对象的偏向锁标记置为 0,即锁不再可偏向,新建的对象也是不可偏向的,若再次发生锁竞争,直接进入轻量级锁。

可以看到,批量重偏向和批量撤销的操作是都是对撤销操作的优化,批量重偏向是第一阶段的优化,批量撤销是在第一阶段优化没有奏效的情况下第二阶段的优化,所以很明显,批量撤销的阈值应该设置的比批量重偏向的大。

三、结语

Synchronized 的原理和优化就暂且讲到这,两篇文章主要都是对概念的介绍、各个状态的锁的结构介绍和阐述简化后的流程。还有诸多细节没有进行叙述,例如重量级锁就没有讲到重入、锁膨胀的过程、偏向撤销的流程、串联起来整个加锁的流程,如此种种细节皆略过了,一是精力有限,二是水平有限(个人拙见,要理清这些细节和流程必须自己亲自看源码,然现阶段读 C ++ 源码略为吃力),故而等后续有更多的理解后再补充。文中必然有疏漏或是错误,若有发现,还请海涵并指正。

Reference

[1] Evaluating and improving biased locking in the HotSpot virtual machine.

[2] Lock Reservation: Java Locks Can Mostly Do Without Atomic Operations

[3] [JEP 374: Deprecate and Disable Biased Locking]: https://openjdk.org/jeps/374

多线程与高并发(二)—— Synchronized 加锁解锁流程的更多相关文章

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

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

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

    一. 什么是锁? 在多线程中,多个线程同时对某一个资源进行访问,容易出现数据不一致问题,为保证并发安全,通常会采取线程互斥的手段对线程进行访问限制,这个互斥的手段就可以称为锁.锁的本质是状态+指针,当 ...

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

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

  4. 【多线程与高并发原理篇:4_深入理解synchronized】

    1. 前言 越是简单的东西,在深入了解后发现越复杂.想起了曾在初中阶段,语文老师给我们解说<论语>的道理,顺便给我们提了一句,说老子的无为思想比较消极,学生时代不要太关注.现在有了一定的生 ...

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

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

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

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

  7. 多线程与高并发(三)—— 源码解析 AQS 原理

    一.前言 AQS 是一个同步框架,关于同步在操作系统(一)-- 进程同步 中对进程同步做了些概念性的介绍,我们了解到进程(线程同理,本文基于 JVM 讲解,故下文只称线程)同步的工具有很多:Mutex ...

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

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

  9. 多线程与高并发(五)—— 源码解析 ReentrantLock

    一.前言 ReentrantLock 是基于 AQS 实现的同步框架,关于 AQS 的源码在 这篇文章 已经讲解过,ReentrantLock 的主要实现都依赖AQS,因此在阅读本文前应该先了解 AQ ...

随机推荐

  1. Go语言学习——map

    map 映射关系容器 内部使用散列表(hash)实现 map是引用类型 必须初始化才能使用 无序的基于key-value的数据结构 map定义 map的定义语法: map[KeyType]ValueT ...

  2. [笔记] K-D Tree

    一种可以 高效处理 \(k\) 维空间信息 的数据结构. 在正确使用的情况下,复杂度为 \(O(n^{1-\frac{1}{k}})\). K-D Tree 的实现 建树 随机一维选择最中间的点为当前 ...

  3. 了解mybatis

    什么是mybatis? MyBatis 是一款优秀的持久层框架,它支持自定义 SQL.存储过程以及高级映射.MyBatis 免除了几乎所有的 JDBC 代码以及设置参数和获取结果集的工作.MyBati ...

  4. 关于前端ajax请求获取数据成功之后无法操作数据的原因及解决方法

    前言:做项目的时候我用ajax请求json数据,遍历使用数据时却发现页面无响应.关于这个问题今天有个朋友又问了我一次,记录一下.由于我没有记录,这里用我朋友的图片. 代码现象: 这里他是使用alert ...

  5. 使用用户名密码或ssl证书获取zabbix-api token,批量操作管理zabbix-server

    概述 Zabbix API 允许你以编程方式检索和修改 Zabbix 的配置,并提供对历史数据的访问.它广泛用于: 创建新的应用程序以使用Zabbix: 将Zabbix与第三方软件集成: 自动执行常规 ...

  6. 【算法】基数排序(Radix Sort)(十)

    基数排序(Radix Sort) 基数排序是按照低位先排序,然后收集:再按照高位排序,然后再收集:依次类推,直到最高位.有时候有些属性是有优先级顺序的,先按低优先级排序,再按高优先级排序.最后的次序就 ...

  7. VS.NET启动显示ID为XXXX的进程当前未运行

    解决办法:在启动项目根目录下用文本编辑器打开Web项目下的{X}.csproj文件,然后查找 <WebProjectProperties>,将这一对标签之间的内容全部删除,然后再打开项目就 ...

  8. 127_Power Pivot&Power BI DAX计算订单商品在库时间(延伸订单仓储费用)

    博客:www.jiaopengzi.com 焦棚子的文章目录 请点击下载附件 一.背景 前面已经写过一个先进先出的库龄案例,在业务发生又有这样一个需求:先进先出前提,需要按照订单计算每个商品在库时间, ...

  9. 聊聊C#中的Mixin

    写在前面 Mixin本意是指冰淇淋表面加的那些草莓酱,葡萄干等点缀物,它们负责给冰淇淋添加风味.在OOP里面也有Mixin这个概念,和它的本意相似,OOP里面的Mixin意在为类提供一些额外功能--在 ...

  10. C#/VB.NET 在PDF表格中添加条形码

    条码的应用已深入生活和工作的方方面面.在处理条码时,常需要和各种文档格式相结合.当需要在文档中插入.编辑或者删除条码时,可借助于一些专业的类库工具来实现.本文,以操作PDF文件为例,介绍如何在编辑表格 ...