面试干货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是Java内置关键字,其作用是达到同步的效果,其关键字只能作用与方法(静态、非静态)与代码块,不可作用于变量
同步代码块:
public void test() {
synchronized(this) {
// 此处代码线程安全
}
}
同步代方法:
public synchronized void test() {
// 此方法线程安全
}
同步代码块同步的是当前对象,简单点说就是锁的是当前对象,作用于方法上,那么锁的就是调用当前方法的对象,其实二者本质上没有区别,不过我们可以发现他们都是引用类型,而且我也尝试锁基本类型,但是毫无疑问,编译错误,那么带着这个小小的问题继续研究。
synchronized为何不能锁基本类型呢?为什么只能锁引用类型?原来synchronized的实现基于对象的内部结构。所以要想明白synchronized是如何实现的,就必须要搞清楚对象的结构。
mark word 相当于对象的标记词,包含一些分代年龄以及锁标志(synchronized的实现就依赖于此)
Class Pointer 指向方法区/元数据区的类模板信息
Instance Data 实例的各个字段的数据
Padding 是为了将不足8字节(byte)的倍数的对象补全成8字节的整数倍,跟cpu有关,8字节(byte)整数倍计算效率最高
与引用类型一样,只不过对象头多了一个length来标记数组长度
其中,对象头的mark word里,对象分代年龄用4位标记,所以,对象在垃圾回收中的最大年龄为15
如果前面的文章有认真阅读,你应该知道synchronized的实现基于Java的对象,且跟对象布局中的Mark Word部分有关
开头提到过,经常听到不少声音说synchronized性能太低,不好用之类的,其实是因为在JDK1.6之前,synchronized只有一种锁模式——重量级锁,重量级锁是有一个等待队列的,想要抢夺锁的线程都先要进入一个等待队列,进入阻塞状态,那么一个线程被挂起,其所需要的数据就需要被临时保存,这样就占用了资源,其次,当锁被释放,等待队列的线程需要被唤醒,这是需要由操作系统内核去调度的,这个唤醒的过程是很慢的,所以经常会说synchorized性能太低。
但事实上也区分与业务场景,在并发量不高的情况下,重量级锁性能确实很低,但是高并发下,重量级锁也是不得不用的。
在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
能够保证原子性、可见性与有序性。接下来重点说说可见性
可见性说的是一个线程对共享变量修改后,其他线程立马能够获取到。为什么这么说呢?实际上,内存是有不可见性的,在java内存模型中,存在工作内存与主内存(共享内存),当线程对变量进行操作时,会先把变量从主内存复制到自己工作内存,然后对变量进行操作,最后再更新到主内存,然而每个CPU都是有一级缓存的,有的还有二级缓存,线程在从主内存拉取数据时,会先从一级缓存拉取,再从二级缓存拉取,一级缓存是CPU私有的,二级缓存才是所有CPU共享的,在线程对变量操作后不仅会更新到主内存,还会更新到一级缓存、二级缓存,以备下次使用,如果两个线程恰好被两个CPU执行,那么他们的两个一级缓存就是有不可见性。
而synchornized
就可以解决这种不可见性,因为他的内存语义是,当进入synchornized
保护的代码前,将同步代码中用到的共享资源在缓存中清楚,在使用时,直接从主存拉取,在退出同步代码块前,直接将对共享资源的操作更新至主存,这样就保证了内存的可见性。其实锁也是这个原理。
上述边是synchronized的底层原理,可能有些地方表达的不是那么容易理解,如果耐心阅读,相信会对你有帮助的。祝面试顺利~~