多线程与高并发(一)—— 自顶向下理解Synchronized实现原理
一、 什么是锁?
在多线程中,多个线程同时对某一个资源进行访问,容易出现数据不一致问题,为保证并发安全,通常会采取线程互斥的手段对线程进行访问限制,这个互斥的手段就可以称为锁。锁的本质是状态+指针,当一个线程进入临界区前需要先修改状态,表明已加锁,并且指针指向加锁的线程。后续线程在进入临界区时同样需要尝试修改状态,修改状态前首先检查指针是否为空,如果不为空且指向其他线程则表明已经有其他线程占用了锁,则无法进行状态修改,也就是此线程获取锁失败。
二、 Synchronized 锁原理
Synchronized 关键字如何实现同步互斥?
一、生成字节码
首先了解 Synchronized 的三种用法:
- 锁对象实例
- 锁方法实例
以上三种不同的使用方式,JVM 生成的字节码也不同,具体如下:
- 锁对象实例
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
- 锁方法实例
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
二、字节码如何执行?
- 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 了解对象头
对象结构
Java 对象包含以下三部分:对象头、对象体、对齐字节
(ps: 图片引用自:https://juejin.cn/post/6993308982081224711)
此处我们只关注对象头,其余 JVM 知识可自行查阅对象头结构
对象头主要分两部分(忽略数组对象), 一般占有两个机器码,在32位虚拟机中一个机器码是4个字节,即32bit,64位机是8个字节;如图所示,以32位 HotSpot虚拟机为例,高 32 位是 Mark Word (下面详细介绍);低 32 位为Class Word,这部分是类指针,即表明该对象是哪个类的实例。
Mark Word 结构
如图所示为无锁状态下的 Mark Word, 为节省对象存储空间,Mark Word 被设计成可复用的,在不同的对象状态下,Mark Word的内容和结构会随之变化; Synchronized 锁优化后,锁的状态有不同种类,不同种类锁的状态下 Mark Word 也不同,具体内容在加锁解锁的时候一并介绍。
- HashCode 25位
- age gc分代年龄,每经历一次垃圾回收还存活的对象年龄加一
- biased_lock 表明是否为偏向锁
- lock_state 加锁状态
3.3 了解Monitor 机制
- 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 可用
- Monitor 的语义[2]
Mesa 语义
第一个线程获取资源后,不能第一时间进入 Monitor ,需要先进入entry queue, 第二个线程得到执行,当第二个线程执行完毕后,第一个线程从entry queue 中出队得到执行。
Hoare 语义
第一个线程的资源得到满足的话,就应当立即执行;第二个线程放入 signal queue,等待第一个线程执行完毕离开 Monitor 后,通知signal queque 中的线程,此时第二个线程才被执行。
Brinch Hanson 语义
该语义简单一些,当通知线程离开了 Monitor 之后,被通知的线程才能得到执行;注意与 Hoare 的区别,Hoare 是线程离开 Monitor 之后才通知,Brinch Hanson 先通知后离开。
- 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加锁解锁流程
- Java 对象的 Mark Word 中的 HashCode、Age 等信息保存至 ObjectMonitor 的 _header字段
- Java 对象中的Mark Word 如流程图,高30位保存的是 Monitor 的地址,低2位锁标志设置为10
- 第一个线程A进入临界区时,owner此时还未指向任何线程,那么 owner 指向线程A,线程A即加锁成功。
- 后续线程B进入临界区时,同样先判断owner是不是自己这个线程,发现不是指向自己,那么线程B就进入 EntryList 等待,同理其他线程 Thread C 也进行相同操作进入队列等待;
- 进入 monitor 的线程如果调用了 wait() 方法,那么进入 waitSet 队列等待,当线程准备就绪后再次进入 entrylist 重新竞争锁
6.当 Thread A 执行完临界区代码后,owner 置为 null 释放锁对象,接着调用 unpark 方法唤醒 EntryList 队列中所有线程 - 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 锁状态的总结
重量级锁的资源消耗主要就是阻塞线程和唤醒线程导致的内核态切换,所以需要尽可能的避免这两个操作,优化方法有两个:
- 一是尽可能地避免使用重量级锁,因而出现了轻量级锁,针对轻量级锁又优化产生了偏向锁
- 二是减少重量级锁情况下的系统调用,也就是使用锁自旋
网上一些博客说锁的状态切换是无锁到偏向锁到轻量级锁到重量级锁,这是一种错误的说法。在竞争激烈的时候,是可以无锁直接到重量级锁状态的,另外如果竞争不激烈,也是无锁状态到轻量级锁,偏向锁适用的场景实际上是没有竞争。
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实现原理的更多相关文章
- 多线程与高并发(二)—— Synchronized 加锁解锁流程
前言 上篇主要对 Synchronized 的锁实现原理 Monitor 机制进行了介绍,由于 Monitor 基于操作系统调用,上下文切换导致开销大,在竞争不激烈时性能不算很好, 在 jdk6 之后 ...
- 互联网大厂高频重点面试题 (第2季)JUC多线程及高并发
本期内容包括 JUC多线程并发.JVM和GC等目前大厂笔试中会考.面试中会问.工作中会用的高频难点知识.斩offer.拿高薪.跳槽神器,对标阿里P6的<尚硅谷_互联网大厂高频重点面试题(第2季) ...
- 多线程与高并发(三)synchronized关键字
上一篇中学习了线程安全相关的知识,知道了线程安全问题主要来自JMM的设计,集中在主内存和线程的工作内存而导致的内存可见性问题,及重排序导致的问题.上一篇也提到共享数据会出现可见性和竞争现象,如果多线程 ...
- 多线程深入:让你彻底理解Synchronized(转)
原文:https://www.jianshu.com/p/d53bf830fa09 1. synchronized简介 在学习知识前,我们先来看一个现象: public class Synchroni ...
- java高并发----个人学习理解汇总记录
1.首先,需要理解几个概念 1.同步(Synchronous):同步方法调用一旦开始,调用者必须等到前面的方法调用返回后,才能继续后续的行为,依次直到完成所有. 2.异步(Asynchronous): ...
- 一篇博客带你轻松应对java面试中的多线程与高并发
1. Java线程的创建方式 (1)继承thread类 thread类本质是实现了runnable接口的一个实例,代表线程的一个实例.启动线程的方式start方法.start是一个本地方法,执行后,执 ...
- 使用Redis中间件解决商品秒杀活动中出现的超卖问题(使用Java多线程模拟高并发环境)
一.引入Jedis依赖 可以新建Spring或Maven工程,在pom文件中引入Jedis依赖: <dependency> <groupId>redis.clients< ...
- java后端知识点梳理——多线程与高并发
进程与线程 进程是一个"执行中的程序",是系统进行资源分配和调度的一个独立单位 线程是进程的一个实体,一个进程中一般拥有多个线程. 线程和进程的区别 进程是操作系统分配资源的最小单 ...
- C#网络编程 多线程和高并发
在任何 TCP Server 的实现中,一定存在一个 Accept Socket Loop,用于接收 Client 端的 Connect 请求以建立 TCP Connection. 在任何 TCP S ...
随机推荐
- 这些 Shell 分析服务器日志命令集锦,收藏好
关注「开源Linux」,选择"设为星标" 回复「学习」,有我为您特别筛选的学习资料~ 自己的小网站跑在阿里云的ECS上面,偶尔也去分析分析自己网站服务器日志,看看网站的访问量.看看 ...
- 超越OpenCV速度的MorphologyEx函数实现(特别是对于二值图,速度是CV的4倍左右)。
最近研究了一下opencv的 MorphologyEx这个函数的替代功能, 他主要的特点是支持任意形状的腐蚀膨胀,对于灰度图,速度基本和CV的一致,但是 CV没有针对二值图做特殊处理,因此,这个函数对 ...
- wait 和async,await一起使用引发的死锁问题
在某个项目开发过程中,偶然间发现在UI线程中async,await,wait三者一起使用会引发一个必然性的死锁问题. 一个简单的实例,代码很简单,在界面上放置一个Button,并在Button的cli ...
- JavaScript与函数式编程
JavaScript与函数式编程 绝大多数编程语言都会有函数的概念(或者说所有的?我不太确定),他们都可以做出类似的操作: function(x) { return x * x } 但是Javascr ...
- 【Java分享客栈】未来迈向高级工程师绕不过的技能:JMeter压测
前言 因为工作需要,久违的从自己的有道云笔记中去寻找压测相关的内容,翻开之后发现还不错,温故一遍后顺便整理出来分享给大家. 题外话,工作8年多,有道云笔记不知不觉都6G多了,扫一眼下来尽是云烟过往,竟 ...
- JavaScript数组操作常用方法
@ 目录 数组基础遍历方法. for for of for in 数组的基础操作方法. push:尾部追加元素 pop:尾部移出元素 unshift:头部追加元素 shift:头部移出元素 splic ...
- Hbase——API操作
1.判断表是否存在 public static boolean isTableExit(String tableName) throws IOException { // //获取配置文件信息 // ...
- OpenWrt 20.02.2 小米路由器3G配置CP1025网络打印
家里的施乐 CP116w 工作快五年了终于罢工了. 黑粉报错, 自己也不会拆, 只能搁置了. 后来换了个 HP CP1025. 这个打印机也不错, 墨盒便宜没什么废粉, 就是启动慢一点, 而且 -- ...
- 面试常问的dubbo的spi机制到底是什么?
前言 dubbo是一款微服务开发框架,它提供了 RPC通信 与 微服务治理 两大关键能力.作为spring cloud alibaba体系中重要的一部分,随着spring cloud alibaba在 ...
- SAP Web Dynpro-使用服务调用
创建服务调用后,功能模块可用于组件. 现在可以选择一个视图,以便在浏览器中显示数据库表的元素. 如果全局控制器不是组件控制器,则必须为所选视图的控制器输入全局控制器的使用页面. 之后,应该有该节点的映 ...