面试干货9——synchronized的底层原理

Posted LuckyWangxs

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了面试干货9——synchronized的底层原理相关的知识,希望对你有一定的参考价值。

synchronized底层原理


推荐:在准备面试的同学可以看看这个系列

        面试干货1——请你说说Java类的加载过程

        面试干货2——你对Java GC垃圾回收有了解吗?

        面试干货3——基于JDK1.8的HashMap(与Hashtable的区别、数据结构、内存泄漏…)

        面试干货4——你对Java类加载器(自定义类加载器)有了解吗?

        面试干货5——请详细说说JVM内存结构(堆、栈、常量池)

        面试干货6——输入网址按下回车后发生了什么?三次握手与四次挥手原来如此简单!

        面试干货7——刁钻面试官:关于redis,你都了解什么?

        面试干货8——面试官:可以聊聊有关数据库的优化吗?

        面试干货9——synchronized的底层原理


        在多线程并发编程中,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. 详细的布局图如下:

        其中,对象头的mark word里,对象分代年龄用4位标记,所以,对象在垃圾回收中的最大年龄为15

三、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队列,其都是没有规律可循的,没有公平可言。

四、synchornized内存语义

        synchornized能够保证原子性、可见性与有序性。接下来重点说说可见性
        可见性说的是一个线程对共享变量修改后,其他线程立马能够获取到。为什么这么说呢?实际上,内存是有不可见性的,在java内存模型中,存在工作内存与主内存(共享内存),当线程对变量进行操作时,会先把变量从主内存复制到自己工作内存,然后对变量进行操作,最后再更新到主内存,然而每个CPU都是有一级缓存的,有的还有二级缓存,线程在从主内存拉取数据时,会先从一级缓存拉取,再从二级缓存拉取,一级缓存是CPU私有的,二级缓存才是所有CPU共享的,在线程对变量操作后不仅会更新到主内存,还会更新到一级缓存、二级缓存,以备下次使用,如果两个线程恰好被两个CPU执行,那么他们的两个一级缓存就是有不可见性。
        而synchornized就可以解决这种不可见性,因为他的内存语义是,当进入synchornized保护的代码前,将同步代码中用到的共享资源在缓存中清楚,在使用时,直接从主存拉取,在退出同步代码块前,直接将对共享资源的操作更新至主存,这样就保证了内存的可见性。其实锁也是这个原理。

上述边是synchronized的底层原理,可能有些地方表达的不是那么容易理解,如果耐心阅读,相信会对你有帮助的。祝面试顺利~~

以上是关于面试干货9——synchronized的底层原理的主要内容,如果未能解决你的问题,请参考以下文章

#yyds干货盘点# Java并发面试题第二弹

#yyds干货盘点# synchronize底层实现原理-重量级锁

面试必备:synchronized的底层原理?

#yyds干货盘点#Java并发机制的底层实现原理

synchronized原理是啥?

并发复习 ---- Synchronized底层原理深入分析