要是面试官再问我synchronized,我就这么答

Posted 默辨

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了要是面试官再问我synchronized,我就这么答相关的知识,希望对你有一定的参考价值。

回答synchronized关键字步骤

加锁的本质是,序列化访问临界资源



①管程概念开始

管程的概念:管程是指管理共享变量以及对共享变量操作的过程,让它们支持并发。这是整个Java锁机制设计的理论基础。

可以补充一手操作系统相关概念,xxx




②引出MESA模型

在管程的发展史上:先后出现过三种不同的管程模型,分别是Hasen模型、Hoare模型和MESA模型。现在正在广泛使用的是MESA模型。

可以稍微说一下Hasen模型和Hoare模型的概念。xxx




③解释MESA模型

MESA模型:需要有入口等待队列起到互斥作用,多个条件队列起到线程数据同步、线程唤醒的作用




④解释Java中MESA模型的实现

Java参考了MESA模型,语言内置的管程(synchronized)对MESA模型进行了精简。MESA模型中,条件变量可以有多个,Java语言内置的管程里只有一个条件变量。


Java依靠下列核心组件完成对应的管程机制:

1、ContentionList:默认的存储所有并发线程的任务队列(阻塞)一个后进先出(LIFO)的队列

2、EntryList:候选队列(阻塞)

3、OnDeck:候选队列中的某一个被指定的线程

4、Owner:正在执行的线程

5、WaitSet:执行的线程调用wait方法后的阻塞队列



当遇到多个线程访问相同的资源时,将所有来不急处理的并发线程都放到ContentionList队列,它是一个先进后出的队列,每个线程通过CAS去竞争获得队列的头节点。为了降低并发压力,又出现了一个EntryList,它是用来分ContentionList队列竞争头节点的压力,它与ContentionList数据结构相同。当执行的线程释放锁以后,会在EntryList中选择一个线程标记为onDesk线程(名义上的执行线程继承人),一般是选择EntryList头部的线程,然后这个名义上的继承人就和EntryList中的其他线程一起竞争。当然,这里也有可能出现EntryList中为空的情况,那么它将ContentionList中的线程移动到EntryList中,然后继续上面的操作。


在这个过程有三个非公平:
1、新来的线程添加到ContentionList的时候是头部追加,由于队列是先进后出,所以是非公平的
2、新来的线程在最开始CAS的时候,直接就进入EntryList集合,直接跳过了由ContentionList进入EntryList这一个过程
3、线程调用wait方法以后,进入WaitSet的线程随机唤醒到EntryList中,这个过程也是非公平的





⑤解释synchronized关键字背后的锁升级和锁降级

1)同步方法是通过方法中的access_flags中设置ACC_SYNCHRONIZED标志来实现;

2)同步代码块是通过monitorenter和monitorexit来实现。

两个指令的执行是JVM通过调用操作系统的互斥原语mutex来实现,被阻塞的线程会被挂起、等待重新调度,会导致“用户态和内核态”两个态之间来回切换,对性能有较大影响。



于是在JDK1.6以后,synchronized关键字进行了优化,且引入了无锁、偏向锁、轻量级锁、重量级锁等概念。由无锁变为重量级锁的一个过程叫做锁升级、锁膨胀、锁粗化,反之则为锁降级。重量级锁是内核态的线程的处理,其他三种状态都是在用户态,都是操作markword

无锁:我们刚实例化一个对象

偏向锁:单个线程的时候,会开启偏向锁。可以使用-XX:-UseBiasedLocking来禁用偏向锁。

轻量级锁:当多个线程来竞争的时候,偏向锁会进行一个升级,升级为轻量级锁

重量级锁:在锁竞争激烈的时候,会升级为我们重量级锁,即创建一个monitor对象,设置对应的标志来加锁解锁。



粗略理解就是上面的理解,但是这是不准确的,不过具体的锁状态的转化情形过于复杂,个人觉得没必要,有兴趣的小伙伴可以参考下图。锁状态切换又可以扩展延申到JVM栈帧中Mark Word对象数据的变化





补充:

1)轻量级锁和无锁的状态转换,轻量级锁在内存分布上是没有办法存储hashcode值的,但是在第一次启动轻量级锁的时候(升级),它会把对应的锁记录(无锁状态的mark word)存放到栈空间里面。如果出现了轻量级锁转化成无锁,那么就获取到栈空间的mark word值,完成对应的赋值。

2)重量级锁是生成一个monitor对象,如果出现锁降解,那么对应的对象会被GC清理

3)锁撤销出现的时间一定是安全点(方法结束的点、循环结束的点、抛出异常的点…),否则会出现其他线程安全的问题





⑥解释Object对象的内存分布(重点)

涉及到锁,那么就需要知道我们的锁的状态和标记,需要记录我们的锁状态信息

在Hotspot虚拟机中,对象在内存中存储的布局可以分为三块区域:对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)。

对象 = 对象头 + 对象实例数据(instance data) + 对齐填充(padding)



一、对象头:比如 hash码,对象所属的年代(和GC有关),对象锁,锁状态标志,偏向锁(线程)ID,偏向时间,数组长度(数组对象才有)等;

对象头 = 对象运行时数据(Mark Word8个字节) + 对象类型指针(Class Pointer由8个字节压缩为4个字节)+ (如果是数组就还需要4个字节)

  1. Mark Word:用于存储对象自身的数据,如哈希码(HashCode)GC分代年龄(age)锁标志位(0、1)线程持有的锁偏向线程ID(ThreadId)、**偏向时间戳(Epoch)**等,这部分数据的长度在32位和64位的虚拟机中分别为32bit和64bit,官方称它为“Mark Word”,下图为64位机器的具体字节分布

    32位JVM下的对象结构描述:

    64位JVM下的对象结构描述

  2. Class Pointer:对象头的另外一部分是klass类型指针,即对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。 32位4字节,64位开启指针压缩或最大堆内存<32g时4字节,否则8字节。jdk1.8默认开启指针压缩后为4字节,当在JVM参数中关闭指针压缩(-XX:-UseCompressedOops)后,长度为8字节。

  3. 数组长度(只有数组对象有):如果对象是一个数组, 那在对象头中还必须有一块数据用于记录数组长度。 4字节



二、实例数据(instance data):存放类的属性数据信息,包括父类的属性信息;

int age = 1;



三、对齐填充(padding):由于虚拟机要求对象起始地址必须是8字节的整数倍。填充数据不是必须存在的,仅仅是为了字节对齐。

长度不是8的整数倍的时候用来填充为8的整数倍数据。



补充:

Object obj = new Object(),obj对象占用几个字节?

对象头 + 实例数据 + 对齐填充

= (Mark Word + Class Pointer + 数组长度) + 实例数据 + 对齐填充

= (8 + 4)+ 0 +4

= 16 (字节)





⑦锁升级和锁降级的一些优化场景

根据具体的并发场景。对锁的状态进行优化


批量撤销:

如:并发大的场景下,由偏向锁变为轻量级锁可以修改为由无锁变成轻量级锁。前者在偏向锁状态下,会不断的CAS自旋,然后进行偏向锁撤销,然后变成轻量级锁;后者直接由无锁变成轻量级锁,更快更方便(流程图才看第⑤点中的图)


原理:以class为单位,为每个class维护一个偏向锁撤销计数器,每一次该class的对象发生偏向撤销操作时,该计数器+1,当这个值达到重偏向阈值(默认20)时,JVM就认为该class的偏向锁有问题,因此会进行批量重偏向。

每个class对象会有一个对应的epoch字段,每个处于偏向锁状态对象的Mark Word中也有该字段,其初始值为创建该对象时class中的epoch的值。每次发生批量重偏向时,就将该值+1,同时遍历JVM中所有线程的栈,找到该class所有正处于加锁状态的偏向锁,将其epoch字段改为新值。下次获得锁时,发现当前对象的epoch值和class的epoch不相等,那就算当前已经偏向了其他线程,也不会执行撤销操作,而是直接通过CAS操作将其Mark Word的Thread Id 改成当前线程Id。

当达到重偏向阈值(默认20)后,假设该class计数器继续增长,当其达到批量撤销的阈值后(默认40),JVM就认为该class的使用场景存在多线程竞争,会标记该class为不可偏向,之后,对于该class的锁,直接走轻量级锁的逻辑。




自旋优化:
重量级锁竞争的时候,还可以使用自旋来进行优化,如果当前线程自旋成功(即这时持锁线程已经退出了同步块,释放了锁),这时当前线程就可以避免阻塞。

  1. 自旋会占用 CPU 时间,单核 CPU 自旋就是浪费,多核 CPU 自旋才能发挥优势。
  2. 在 Java 6 之后自旋是自适应的,比如对象刚刚的一次自旋操作成功过,那么认为这次自旋成功的可能性会高,就多自旋几次;反之,就少自旋甚至不自旋,比较智能。
  3. Java 7 之后不能控制是否开启自旋功能

注意:自旋的目的是为了减少线程挂起的次数,尽量避免直接挂起线程(挂起操作涉及系统调用,存在用户态和内核态切换,这才是重量级锁最大的开销)


锁粗化:
假设一系列的连续操作都会对同一个对象反复加锁及解锁,甚至加锁操作是出现在循环体中的,即使没有出现线程竞争,频繁地进行互斥同步操作也会导致不必要的性能损耗。如果JVM检测到有一连串零碎的操作都是对同一对象的加锁,将会扩大加锁同步的范围(即锁粗化)到整个操作序列的外部。



锁消除:
锁消除即删除不必要的加锁操作。锁消除是Java虚拟机在JIT编译期间,通过对运行上下文的扫描,去除不可能存在共享资源竞争的锁,通过锁消除,可以节省毫无意义的请求锁时间。

  • 逃逸分析(Escape Analysis):方法逃逸+线程逃逸。是一种可以有效减少Java 程序中同步负载和内存堆分配压力的跨函数全局数据流分析算法。通过逃逸分析,Java Hotspot编译器能够分析出一个新的对象的引用的使用范围从而决定是否要将这个对象分配到堆上。逃逸分析的基本行为就是分析对象动态作用域。
  • 方法逃逸(对象逃出当前方法):当一个对象在方法中被定义后,它可能被外部方法所引用,例如作为调用参数传递到其他地方中。
  • 线程逃逸((对象逃出当前线程):这个对象甚至可能被其它线程访问到,例如赋值给类变量或可以在其它线程中访问的实例变量。

jdk6才开始引入该技术,jdk7开始默认开启逃逸分析。在Java代码运行时,可以通过JVM参数指定是否开启逃逸分析:

-XX:+DoEscapeAnalysis  //表示开启逃逸分析 (jdk1.8默认开启)
-XX:-DoEscapeAnalysis //表示关闭逃逸分析。
-XX:+EliminateAllocations   //开启标量替换(默认打开)
-XX:+EliminateLocks  //开启锁消除(jdk1.8默认开启)



其他优化…

以上是关于要是面试官再问我synchronized,我就这么答的主要内容,如果未能解决你的问题,请参考以下文章

浅谈AQS同步队列(含ReentrantLock加锁和解锁源码分析)

面试官再问我如何保证 RocketMQ 不丢失消息,这回我笑了!

如果面试官再问你volatile 你这样跟他说

面试官再问单点登录,把这篇发给他!

面试官再问数据库事务,把这篇文章发给他!

面试官再问你 HashMap 底层原理,就把这篇文章甩给他看