《安琪拉与面试官二三事》系列文章
一个HashMap能跟面试官扯上半个小时
一个synchronized跟面试官扯了半个小时java
《安琪拉教鲁班学算法》系列文章c++
话说上回HashMap跟面试官扯了半个小时以后,二面迎来了没有削弱前的钟馗,法师的钩子让安琪拉有点绝望。钟馗穿着有些微微泛黄的格子道袍,站在安琪拉对面,开始发难,其中让安琪拉印象很是深入的是法师的synchronized 钩子。程序员
面试官: 你先自我介绍一下吧!github
安琪拉: 我是安琪拉,草丛三婊之一,最强中单(钟馗冷哼)!哦,不对,串场了,我是**,目前在--公司作--系统开发。面试
面试官: 刚才听一面的同事说大家上次聊到了synchronized,你借口说要回去补篮,如今能跟我讲讲了吧?算法
安琪拉: 【上来就丢钩子,都不寒暄几句,问我吃没吃】嗯嗯,是有聊到 synchronized。后端
面试官: 那你跟我说说为何会须要synchronized?什么场景下使用synchronized?数组
安琪拉: 这个就要说到多线程访问共享资源了,当一个资源有可能被多个线程同时访问并修改的话,须要用到锁,仍是画个图给您看一下,请看👇图:安全
安琪拉: 如上图所示,好比在王者荣耀程序中,咱们队有二个线程分别统计后裔和安琪拉的经济,A线程从内存中read 当前队伍总经济加载到线程的本地栈,进行 +100 操做以后,这时候B线程也从内存中取出经济值 + 200,将200写回内存,B线程刚执行完,后脚A线程将100 写回到内存中,就出问题了,咱们队的经济应该是300, 可是内存中存的倒是100,你说糟不糟心。
面试官: 那你跟我讲讲用 synchronized 怎么解决这个问题的?
安琪拉: 在访问竞态资源时加锁,由于多个线程会修改经济值,所以经济值就是竞态资源,给您show 一下吧?下图是不加锁的代码以及控制台的输出,请您过目:
二个线程,A线程让队伍经济 +1 ,B线程让经济 + 2,分别执行一千次,正确的结果应该是3000,结果获得的倒是 2845。
安琪拉: 👇这个就是加锁以后的代码和控制台的输出。
面试官: 我看你👆用synchronized 锁住的是代码块,synchronized 还有别的做用范围吗?
安琪拉: 嗯嗯,synchronized 有如下三种做用范围:
在静态方法上加锁;
在非静态方法上加锁;
在代码块上加锁;
示例代码以下
public class SynchronizedSample { private final Object lock = new Object(); private static int money = 0; //非静态方法 public synchronized void noStaticMethod(){ money++; } //静态方法 public static synchronized void staticMethod(){ money++; } public void codeBlock(){ //代码块 synchronized (lock){ money++; } } }
面试官: 那你了解 synchronized 这三种做用范围的加锁方式的区别吗?
安琪拉: 了解。首先要明确一点:锁是加在对象上面的,咱们是在对象上加锁。
重要事情说三遍:在对象上加锁 ✖️ 3 (这也是为何wait / notify 须要在锁定对象后执行,只有先拿到锁才能释放锁)
这三种做用范围的区别实际是被加锁的对象的区别,请看下表:
做用范围 | 锁对象 |
---|---|
非静态方法 | 当前对象 => this |
静态方法 | 类对象 => SynchronizedSample.class (一切皆对象,这个是类对象) |
代码块 | 指定对象 => lock (以上面的代码为例) |
面试官: 那你清楚 JVM 是怎么经过synchronized 在对象上实现加锁,保证多线程访问竞态资源安全的吗?
安琪拉: 【天啦撸, 该来的仍是要来】(⊙o⊙)…额,这个提及来有点复杂,我怕时间不够,要不下次再约?
面试官: 别下次了,今天我有的是时间,你慢慢讲,我慢慢👂你说。
安琪拉: 那要跟您好好说道了。分二个时间段来跟您讨论,先说到盘古开天辟地,女娲造石补天,咳咳,很差意思扯远了。。。。。。
面试官: 那你分别跟我讲讲JDK 6 之前 synchronized为何这么重? JDK6 以后的偏向锁和轻量级锁是怎么回事?
安琪拉: 好的。首先要了解 synchronized 的实现原理,须要理解二个预备知识:
第一个预备知识:须要知道 Java 对象头,锁的类型和状态和对象头的Mark Word息息相关;
synchronized 锁 和 对象头息息相关。咱们来看下对象的结构:
对象存储在堆中,主要分为三部份内容,对象头、对象实例数据和对齐填充(数组对象多一个区域:记录数组长度),下面简单说一下三部份内容,虽然 synchronized 只与对象头中的 Mard Word相关。
对象头:
对象头分为二个部分,Mard Word 和 Klass Word,👇列出了详细说明:
对象头结构 | 存储信息-说明 |
---|---|
Mard Word | 存储对象的hashCode、锁信息或分代年龄或GC标志等信息 |
Klass Word | 存储指向对象所属类(元数据)的指针,JVM经过这个肯定这个对象属于哪一个类 |
对象实例数据:
如上图所示,类中的 成员变量data 就属于对象实例数据;
对齐填充:
JVM要求对象占用的空间必须是8 的倍数,方便内存分配(以字节为最小单位分配),所以这部分就是用于填满不够的空间凑数用的。
第二个预备知识:须要了解 Monitor ,每一个对象都有一个与之关联的Monitor 对象;Monitor对象属性以下所示( Hospot 1.7 代码) 。
//👇图详细介绍重要变量的做用 ObjectMonitor() { _header = NULL; _count = 0; // 重入次数 _waiters = 0, // 等待线程数 _recursions = 0; _object = NULL; _owner = NULL; // 当前持有锁的线程 _WaitSet = NULL; // 调用了 wait 方法的线程被阻塞 放置在这里 _WaitSetLock = 0 ; _Responsible = NULL ; _succ = NULL ; _cxq = NULL ; FreeNext = NULL ; _EntryList = NULL ; // 等待锁 处于block的线程 有资格成为候选资源的线程 _SpinFreq = 0 ; _SpinClock = 0 ; OwnerIsThread = 0 ; }
对象关联的 ObjectMonitor 对象有一个线程内部竞争锁的机制,以下图所示:
面试官: 预备的二个知识我大致看了,后面给我讲讲 JDK 6 之前 synchronized具体实现逻辑吧。
安琪拉: 好的。【开始个人表演】
当有二个线程A、线程B都要开始给咱们队的经济 money变量 + 钱,要进行操做的时候 ,发现方法上加了synchronized锁,这时线程调度到A线程执行,A线程就抢先拿到了锁。拿到锁的步骤为:
- 1.1 将 MonitorObject
中的 _owner设置成 A线程;
- 1.2 将 mark word 设置为 Monitor 对象地址,锁标志位改成10;
- 1.3 将B 线程阻塞放到 ContentionList 队列;
JVM 每次从Waiting Queue 的尾部取出一个线程放到OnDeck做为候选者,可是若是并发比较高,Waiting Queue会被大量线程执行CAS操做,为了下降对尾部元素的竞争,将Waiting Queue 拆分红ContentionList 和 EntryList 二个队列, JVM将一部分线程移到EntryList 做为准备进OnDeck的预备线程。另外说明几点:
全部请求锁的线程首先被放在ContentionList这个竞争队列中;
Contention List 中那些有资格成为候选资源的线程被移动到 Entry List 中;
任意时刻,最多只有一个线程正在竞争锁资源,该线程被成为 OnDeck;
当前已经获取到所资源的线程被称为 Owner;
处于 ContentionList、EntryList、WaitSet 中的线程都处于阻塞状态,该阻塞是由操做系统来完成的(Linux 内核下采用 pthread_mutex_lock
内核函数实现的);
做为Owner 的A 线程执行过程当中,可能调用wait 释放锁,这个时候A线程进入 Wait Set , 等待被唤醒。
以上就是我想说的 synchronized 在 JDK 6以前的实现原理。
面试官: 那你知道 synchronized 是公平锁仍是非公平锁吗?
安琪拉: 非公平的。主要有如下二点缘由:
面试官: 你前面说到 JDK 6 以后synchronized 作了优化,跟我讲讲?
安琪拉: 不要着急! 容我点个治疗,再跟你掰扯掰扯。前面说了锁跟对象头的 Mark Word 密切相关,咱们把目光放到对象头的 Mark Word
上, Mark Word
存储结构以下图和源代码注释(以32位JVM为例,后面的讨论都基于32位JVM的背景,64位会特殊说明)。
Mard Word
会在不一样的锁状态下,32位指定区域都有不一样的含义,这个是为了节省存储空间,用4 字节就表达了完整的状态信息,固然,对象某一时刻只会是下面5 种状态种的某一种。
下面是简化后的 Mark Word
hash: 保存对象的哈希码 age: 保存对象的分代年龄 biased_lock: 偏向锁标识位 lock: 锁状态标识位 JavaThread*: 保存持有偏向锁的线程ID epoch: 保存偏向时间戳
安琪拉: 因为 synchronized 重量级锁有如下二个问题, 所以JDK 6 以后作了改进,引入了偏向锁和轻量级锁:
依赖底层操做系统的 mutex
相关指令实现,加锁解锁须要在用户态和内核态之间切换,性能损耗很是明显。
研究人员发现,大多数对象的加锁和解锁都是在特定的线程中完成。也就是出现线程竞争锁的状况几率比较低。他们作了一个实验,找了一些典型的软件,测试同一个线程加锁解锁的重复率,以下图所示,能够看到重复加锁比例很是高。早期JVM 有 19% 的执行时间浪费在锁上。
Thin locks are a lot cheaper than inflated locks, but their performance suffers from the fact that every compare-and-swap operation must be executed atomically on multi-processor machines, although most objects are locked and unlocked only by one particular thread.
It was reported that 19% of the total execution time was wasted by thread synchronization in an early version of Java virtual machine。
面试官: 你跟我讲讲 JDK 6 以来 synchronized 锁状态怎么从无锁状态到偏向锁的吗?
安琪拉: OK的啦!,咱们来看下图对象从无锁到偏向锁转化的过程(JVM -XX:+UseBiasedLocking 开启偏向锁):
Mark Word
当中;Mark Word
在这个过程当中的转化Mark Word
拷贝到线程栈的 Lock Record中,这个位置叫 displayced hdr,以下图所示:面试官: 看来对synchronized 颇有研究嘛。我钟馗不信难不倒你,那轻量级锁何时会升级为重量级锁, 请回答?
安琪拉: 当锁升级为轻量级锁以后,若是依然有新线程过来竞争锁,首先新线程会自旋尝试获取锁,尝试到必定次数(默认10次)依然没有拿到,锁就会升级成重量级锁。
面试官: 为何这么设计?
安琪拉: 通常来讲,同步代码块内的代码应该很快就执行结束,这时候线程B 自旋一段时间是很容易拿到锁的,可是若是不巧,没拿到,自旋其实就是死循环,很耗CPU的,所以就直接转成重量级锁咯,这样就不用了线程一直自旋了。
这就是锁膨胀的过程,下图是Mark Word 和锁状态的转化图
主要👆图我标注出来的,锁当前为可偏向状态,偏向锁状态位置就是1,看到不少网上的文章都写错了,把这里写成只有锁发生偏向才会置为1,必定要注意。
面试官: 既然偏向锁有撤销,还会膨胀,性能损耗这么大,还须要用他们呢?
安琪拉: 若是肯定竞态资源会被高并发的访问,建议经过-XX:-UseBiasedLocking
参数关闭偏向锁,偏向锁的好处是并发度很低的状况下,同一个线程获取锁不须要内存拷贝的操做,免去了轻量级锁的在线程栈中建Lock Record,拷贝Mark Down的内容,也免了重量级锁的底层操做系统用户态到内核态的切换,由于前面说了,须要使用系统指令。另外Hotspot 也作了另外一项优化,基于锁对象的epoch 批量偏向和批量撤销偏向,这样能够大大下降了单次偏向锁的CAS和锁撤销带来的损耗,👇图是研究人员作的压测:
安琪拉: 他们在几款典型软件上作了测试,发现基于epoch 批量撤销偏向锁和批量加偏向锁能大幅提高吞吐量,可是并发量特别大的时候性能就没有什么特别大的提高了。
面试官:能够能够,那你看过synchronized 底层实现源码没有?
安琪拉: 那固然啦,源码是个人二技能,高爆发的伤害能不能打出来就看它了,咱们一步一步来。
咱们把文章开头的示例代码编译成class 文件,而后经过javap -v SynchronizedSample.class
来看下synchronized 到底在源码层面如何实现的?
以下图所示:
安琪拉: synchronized 在代码块上是经过 monitorenter 和 monitorexit指令实现,在静态方法和 方法上加锁是在方法的flags 中加入 ACC_SYNCHRONIZED 。JVM 运行方法时检查方法的flags,遇到同步标识开始启动前面的加锁流程,在方法内部遇到monitorenter指令开始加锁。
monitorenter 指令函数源代码在 InterpreterRuntime::monitorenter
中
IRT_ENTRY_NO_ASYNC(void, InterpreterRuntime::monitorenter(JavaThread* thread, BasicObjectLock* elem)) #ifdef ASSERT thread->last_frame().interpreter_frame_verify_monitor(elem); #endif if (PrintBiasedLockingStatistics) { Atomic::inc(BiasedLocking::slow_path_entry_count_addr()); } Handle h_obj(thread, elem->obj()); assert(Universe::heap()->is_in_reserved_or_null(h_obj()), "must be NULL or an object"); //是否开启了偏向锁 if (UseBiasedLocking) { // 尝试偏向锁 ObjectSynchronizer::fast_enter(h_obj, elem->lock(), true, CHECK); } else { // 轻量锁逻辑 ObjectSynchronizer::slow_enter(h_obj, elem->lock(), CHECK); } assert(Universe::heap()->is_in_reserved_or_null(elem->obj()), "must be NULL or an object"); #ifdef ASSERT thread->last_frame().interpreter_frame_verify_monitor(elem); #endif IRT_END
偏向锁代码
// ----------------------------------------------------------------------------- // Fast Monitor Enter/Exit // This the fast monitor enter. The interpreter and compiler use // some assembly copies of this code. Make sure update those code // if the following function is changed. The implementation is // extremely sensitive to race condition. Be careful. void ObjectSynchronizer::fast_enter(Handle obj, BasicLock* lock, bool attempt_rebias, TRAPS) { //是否使用偏向锁 if (UseBiasedLocking) { // 若是不在全局安全点 if (!SafepointSynchronize::is_at_safepoint()) { // 获取偏向锁 BiasedLocking::Condition cond = BiasedLocking::revoke_and_rebias(obj, attempt_rebias, THREAD); if (cond == BiasedLocking::BIAS_REVOKED_AND_REBIASED) { return; } } else { assert(!attempt_rebias, "can not rebias toward VM thread"); // 在全局安全点,撤销偏向锁 BiasedLocking::revoke_at_safepoint(obj); } assert(!obj->mark()->has_bias_pattern(), "biases should be revoked by now"); } // 进轻量级锁流程 slow_enter (obj, lock, THREAD) ; }
偏向锁的实现具体代码在 BiasedLocking::revoke_and_rebias
中,由于函数很是长,就不贴出来,有兴趣的能够在Hotspot 1.8-biasedLocking.cpp去看。
轻量级锁代码流程
void ObjectSynchronizer::slow_enter(Handle obj, BasicLock* lock, TRAPS) { //获取对象的markOop数据mark markOop mark = obj->mark(); assert(!mark->has_bias_pattern(), "should not see bias pattern here"); //判断mark是否为无锁状态 & 不可偏向(锁标识为01,偏向锁标志位为0) if (mark->is_neutral()) { // Anticipate successful CAS -- the ST of the displaced mark must // be visible <= the ST performed by the CAS. // 保存Mark 到 线程栈 Lock Record 的displaced_header中 lock->set_displaced_header(mark); // CAS 将 Mark Down 更新为 指向 lock 对象的指针,成功则获取到锁 if (mark == (markOop) Atomic::cmpxchg_ptr(lock, obj()->mark_addr(), mark)) { TEVENT (slow_enter: release stacklock) ; return ; } // Fall through to inflate() ... } else // 根据对象mark 判断已经有锁 & mark 中指针指的当前线程的Lock Record(当前线程已经获取到了,没必要重试获取) if (mark->has_locker() && THREAD->is_lock_owned((address)mark->locker())) { assert(lock != mark->locker(), "must not re-lock the same lock"); assert(lock != (BasicLock*)obj->mark(), "don't relock with same BasicLock"); lock->set_displaced_header(NULL); return; } lock->set_displaced_header(markOopDesc::unused_mark()); // 锁膨胀 ObjectSynchronizer::inflate(THREAD, obj())->enter(THREAD);
作个假设,如今线程A 和B 同时执行到临界区if (mark->is_neutral()):
一、线程A和B都把Mark Word复制到各自的_displaced_header字段,该数据保存在线程的栈帧上,是线程私有的;
二、Atomic::cmpxchg_ptr 属于原子操做,保障了只有一个线程能够把Mark Word中替换成指向本身线程栈 displaced_header中的,假设A线程执行成功,至关于A获取到了锁,开始继续执行同步代码块;
三、线程B执行失败,退出临界区,经过ObjectSynchronizer::inflate方法开始膨胀锁;
面试官: synchronized 源码这部分能够了,👂不下去了。你跟我讲讲Java中除了synchronized 还有别的锁吗?
安琪拉: 还有ReentrantLock也能够实现加锁。
面试官: 那写段代码实现以前加经济的一样效果。
安琪拉: coding 如👇图:
面试官: 哦,那你跟我说说ReentrantLock 的底层实现原理?
安琪拉: 天色已晚,咱们能改日再聊吗?
面试官: 那你回去等通知吧。
安琪拉: 【心里是崩溃的】,看来此次面试就黄了,😔,心累。
未完,下一篇介绍ReentrantLock相关的底层原理,看安琪拉如何大战钟馗面试官三百回合。
补充说明:
在代码中查看对象头信息方法:
<dependency> <groupId>org.openjdk.jol</groupId> <artifactId>jol-core</artifactId> </dependency>
public static void main(String[] args) { Test obj = new Test(); ClassLayout layout = ClassLayout.parseInstance(obj); //打印空对象大小 System.out.println(layout.instanceSize()); System.out.println(layout.toPrintable()); synchronized (obj){ System.out.println("after lock"); System.out.println(layout.toPrintable()); } System.out.println("after re-lock"); System.out.println(layout.toPrintable()); }
控制台输出以下:
这个是反着的,这里是高地址位表示低位数据,低地址位表示高位数据, 👆能够看出对象后三位是001,0表明不可偏向状态,01表明无锁状态,第二个000,表示轻量级锁状态,最后释放锁,变回01状态无锁状态。
关注Wx公众号:【安琪拉的博客】 —揭秘Java后端技术,还原技术背后的本质
《安琪拉与面试官二三事》系列文章 持续更新中
一个HashMap能跟面试官扯上半个小时
一个synchronized跟面试官扯了半个小时