面试干货9——synchronized的底层原理
Posted LuckyWangxs
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了面试干货9——synchronized的底层原理相关的知识,希望对你有一定的参考价值。
synchronized底层原理
推荐:在准备面试的同学可以看看这个系列
面试干货3——基于JDK1.8的HashMap(与Hashtable的区别、数据结构、内存泄漏…)
面试干货4——你对Java类加载器(自定义类加载器)有了解吗?
面试干货6——输入网址按下回车后发生了什么?三次握手与四次挥手原来如此简单!
在多线程并发编程中,synchronized大家肯定都并不陌生,但我听到过很多声音,说synchronized效率很低,性能很差,诸如此类,但又听过不少说后来Java已经对synchroized优化了,基本的并发用它都是能够满足的,但具体Java做了什么优化,synchroized到底是怎么实现的,到底是如何工作的,我却一概不知,今天我怀着对并发编程的恐惧以及对底层实现的渴望,深度学习并总结了一下所学内容。
一、synchronized的用法
synchronized是Java内置关键字,其作用是达到同步的效果,其关键字只能作用与方法(静态、非静态)与代码块,不可作用于变量
同步代码块:
public void test() {
synchronized(this) {
// 此处代码线程安全
}
}
同步代方法:
public synchronized void test() {
// 此方法线程安全
}
问题1:synchronized为什么只能锁引用类型?
同步代码块同步的是当前对象,简单点说就是锁的是当前对象,作用于方法上,那么锁的就是调用当前方法的对象,其实二者本质上没有区别,不过我们可以发现他们都是引用类型,而且我也尝试锁基本类型,但是毫无疑问,编译错误,那么带着这个小小的问题继续研究。
synchronized为何不能锁基本类型呢?为什么只能锁引用类型?原来synchronized的实现基于对象的内部结构。所以要想明白synchronized是如何实现的,就必须要搞清楚对象的结构。
二、对象在内存中的布局究竟是什么样的
1. 所有引用类型对象都有如下布局:
mark word 相当于对象的标记词,包含一些分代年龄以及锁标志(synchronized的实现就依赖于此)
Class Pointer 指向方法区/元数据区的类模板信息
Instance Data 实例的各个字段的数据
Padding 是为了将不足8字节(byte)的倍数的对象补全成8字节的整数倍,跟cpu有关,8字节(byte)整数倍计算效率最高
2. 数组类型有如下布局:
与引用类型一样,只不过对象头多了一个length来标记数组长度
3. 详细的布局图如下:
三、synchronized的底层原理
1. JDK1.6之前的synchronized是怎样的?
如果前面的文章有认真阅读,你应该知道synchronized的实现基于Java的对象,且跟对象布局中的Mark Word部分有关
开头提到过,经常听到不少声音说synchronized性能太低,不好用之类的,其实是因为在JDK1.6之前,synchronized只有一种锁模式——重量级锁,重量级锁是有一个等待队列的,想要抢夺锁的线程都先要进入一个等待队列,进入阻塞状态,那么一个线程被挂起,其所需要的数据就需要被临时保存,这样就占用了资源,其次,当锁被释放,等待队列的线程需要被唤醒,这是需要由操作系统内核去调度的,这个唤醒的过程是很慢的,所以经常会说synchorized性能太低。
但事实上也区分与业务场景,在并发量不高的情况下,重量级锁性能确实很低,但是高并发下,重量级锁也是不得不用的。
2. JDK1.6及以后synchronized是如何实现的
在JDK1.6引入了偏向锁,轻量级锁(自适应自旋锁)
① 偏向锁
上图为对象内存布局的Mark Word部分,那什么时候对象处于偏向锁状态呢?当同步代码块或者同步方法只有一个线程访问,并不存在多线程竞争的情况,或者说有95%的情况下同步代码块只有一个线程访问,那么此种情况,共享资源实例对象会被标记为偏向锁,即锁标志位为01,是否偏向锁标志位为1,也存储了线程ID,也就是说只有对象的mark word里存储的那个线程可以访问同步代码块的内容,通俗的说,被存储的那个线程获取了锁!
实际上在jvm启动了以后便会将对象设置为偏向锁,但不是启动了立马设为偏向锁,这里做了优化,因为在jvm启动,new 对象时,所有对象共享堆内存,此时必然会多个对象抢夺同一块内存,那明知多线程会竞争,就没必要打开偏向锁。所以做了优化4s后默认给对象开启偏向锁,所有对象都可能成为共享资源,所以没必要先搞成普通的,再转成偏向锁。
② 轻量级锁
轻量级锁常规情况下都是由偏向锁升级而来的,当平时只有一个线程访问的同步代码块,突然多出来一个线程访问,而且平时经常访问的线程并没有释放锁 又或者 之前的线程不再存活且未设置可重新偏向,那么偏向锁会自动升级为轻量级锁。
升级为轻量级锁后,此时竞争的线程会在线程独享的栈内存开辟一个 Lock Record
的空间,通过CAS的方式将锁标志位改为00,然后将当前markword中的数据copy到 Lock Record
中,并指向栈中的锁记录地址,争夺的线程谁的CAS操作成功,谁就获得锁,未争夺成功的,则进行自适应自旋的方式重试
什么叫自适应自旋?
首先想个问题,如果两个线程争抢锁,A抢到了,B进入阻塞状态,数据被临时存储,当A执行完,B被唤醒,拿到锁,然后去执行,那么如果A只占用了锁很短很短的时间,B有必要进入阻塞状态吗?我们知道阻塞线程被唤醒是很耗时的。那肯定是不进入阻塞状态的好,所以有了自旋锁。
自旋锁:
在线程抢夺锁时,为了让线程以不阻塞的方式等待,即让线程执行一个死循环(自旋),如果10次都没拿到锁,就挂起
自适应自旋锁:
当线程T1尝试获取锁时,发现已经被T2线程占用,就执行自旋。
T1自旋了一段时间后获得了这把锁,就开始执行任务。
T3线程竞争锁时发现刚刚的T1线程通过自旋获得过锁,并且持有锁的线程正在执行(不一定是T1线程),那么就认为下次通过自旋的方式也可以获得锁,就会自旋更长的时间;如果自旋很少成功获得过,那么下次就会忽略掉自旋过程,以免浪费处理器资源。
③ 重量级锁
在轻量级锁中,等待的线程会进行自适应自旋,假如某个线程运气很差,或者高并发环境下,线程自旋了很久都没抢到锁,那便放弃自旋,因为自旋是要消耗cpu资源的,cpu资源很昂贵,我们不能过多浪费,这也是为什么有时候必须使用重量级锁的原因。如果在自适应自旋中没拿到锁,则会给jvm系统发出系统通知,告知需要将当前共享对象升级为重量级锁。
在重量级锁中,未获得锁的线程会直接进入阻塞队列,此时将不再消耗cpu,等待拥有锁的线程释放锁并唤醒其他线程,再次抢夺锁,此时对象内存布局的Mark Word的锁标记位将被设置为10,前30位将指向该加锁对象的ObjectMonitor对象,该对象可以理解为互斥量。
说到这里你可能不能特别理解synchronized的重量级锁是如何实现的,那么下面就来说说重量级锁的关键ObjectMonitor
任何使用synchronized修饰的对象都会创建一个与之共存的ObjectMonitor对象,这个对象跟共享资源对象一样,所有线程共享,下面说几个ObjectMonitor中核心的元素
//结构体如下
ObjectMonitor::ObjectMonitor() {
_header = NULL;
_count = 0;
_waiters = 0,
_recursions = 0; //线程的重入次数
_object = NULL;
_owner = NULL; //标识拥有该monitor的线程
_WaitSet = NULL; //等待线程组成的集合
_WaitSetLock = 0 ;
_Responsible = NULL ;
_succ = NULL ;
_cxq = NULL ;
FreeNext = NULL ;
_EntryList = NULL ; //_owner从该队列中唤醒线程节点
_SpinFreq = 0 ;
_SpinClock = 0 ;
OwnerIsThread = 0 ;
}
其中有三个元素为核心:
_owner: 当前获取锁执行的线程
_WaitSet: 等待线程组成的集合
_EntryList: _owner从该队列中唤醒线程节点
抢到锁的线程将获得owner角色,即owner将标记可以执行的线程,其余线程会进入Entry List队列,等待同步锁,正在执行的线程如果调用wait()方法,则进入Wait Set集合等待,当对象调用notify()方法后,进入Entry List等待同步锁。synchronized是非公平锁,线程争抢时不一定先到先得Owner,且当某一个线程抢到锁后,其余等待同步锁的队列也非有序的,而执行过的线程调用了wait方法后进入等待集合,后续再进入Entry List队列,其都是没有规律可循的,没有公平可言。
上述边是synchronized的底层原理,可能有些地方表达的不是那么容易理解,如果耐心阅读,相信会对你有帮助的。祝面试顺利~~
以上是关于面试干货9——synchronized的底层原理的主要内容,如果未能解决你的问题,请参考以下文章