日常中我们都会用到Synchronized关键字,但是面试就喜欢问这些,你说不重要吧,面试就不问了,你说重要吧,工作中除了高并发之外,很少能在业务代码中使用到的。所以笔者顶着风险,写下此篇对Synchronized的深入剖析,看完你会有收获!
1、抛砖引玉
多线程编程中,有可能会出现多个线程同时访问同一个共享、可变资源的情况,这个资源我们称之其为临界资源;这种资源可能是: 对象、变量、文件等。
共享:资源可以由多个线程同时访问
可变:资源可以在其生命周期内被修改
由于线程执行的过程是不可控的,所以需要采用同步机制来协同对对象可变状态的访问!那么如何解决线程并发安全所带来的的数据不一致的问题?
实际上,所有的并发模式在解决线程安全问题时,采用的方案都是序列化访问临界资源。即在同一时刻,只能有一个线程访问临界资源,也称作同步互斥访问。
Java 中,提供了两种方式来实现同步互斥访问:synchronized 和 Lock
这两个方式也被称为同步器,因为本质都是以序列化访问的方式进行。
同步器的本质就是加锁 ,加锁目的是为了序列化访问临界资源,即同一时刻只能有一个线程访问临界资源(同步互斥访问);
不过有一点需要区别的是:当多个线程执行一个方法时,该方法内部的局部变量并不是临界资源,因为这些局部变量是在每个线程的私有栈中,因此不具有共享性,不会导致线程安全问题。
下面我就带大家认识下这两个方式中的Synchronized。
2、synchronized原理详解
synchronized是一种锁机制,称为内置锁,为啥称为内置锁,就是他的加锁解锁不受人为控制,由JVM帮我们控制,也是一种对象锁(锁的是对象而非引用),作用粒度是对象,可以用来实现对临界资源的同步互斥访问,是可重入的。这里有几个关键的点,一个是对象锁,一个是可重入的。
早在jdk1.6以前呢,synchronized依赖于对象进行加锁,而对象是怎么去加锁的?在创建每个对象的时候,jvm会天然的去给每个对象维护一个管程Monitor对象,管程是一种进程同步互斥工具,synchronized怎么保证只有一个线程对该对象加锁成功呢?管程会依赖底层的操作系统的Mutex lock(互斥锁)实现,这玩意是由操作系统去维护的,保证了同一时刻只有一个进程在管程内活动,而Jvm去调用操作系统里的pThread库里的很多关于线程阻塞等方法,Jvm运行在用户态,而如果想要调用底层操作系统的东西,cpu就必须转换状态为内核态,这就会造成性能较低。
在1.6之后,oracle的大佬们觉得synchronized在底层调用Mutex场景,并不应该每次都去做这个事情,应该是在锁竞争比较激烈的时候才适合去调用互斥,正常的时候锁竞争并不激烈,也就意味我每次不应该都去调用操作系统底层,使得这个性能开销大,为了弥补这种场景,于是加了一个锁的膨胀升级优化过程:
从无锁——>偏向锁——>轻量级锁——>重量级锁(调动底层的Mutex)
oracle公司也证明了自己的能力,没被李二狗的ReentranLock给干倒...
先说下synchronized对象锁,加锁的方式有下面三种:
- 实例方法
锁的是当前的实例对象,就是下图的new ThreadDemo(),demo就是实例对象。
- 类方法
锁是当前类对象,即TheadDemo.class这个对象,称为类对象。
作用在静态方法上,我们知道静态方法是类的方法,不是属于实例方法,这不要混淆。
- 代码块
锁的是括号内的对象。
上面我举例synchronized的三种使用方式
那区别是什么呢?很明显是看你自己需要加锁的粒度控制,加在类锁上或者方法锁上,锁的就比较广,但是如果写在代码体锁住的就是某段代码,其他的地方是不会锁的。
如果一个类中有两个静态方法上了锁,那么实际上加的锁是同一个锁,都是Class上,对性能消耗是非常大的,这个要注意。
平时我们在代码里经常会写这样的代码:
System.out.println();
悉心的同学会发现这个方法是单例且被synchronized关键字修饰的,那么大量的出现就会影响到性能。这里要注意项目中不要写,记得删除。
解释下,synchronized内置锁是一种对象锁(锁的是对象而非引用),作用粒度是对象,可以用来实现对临界资源的同步互斥访问,是可重入的。这句话很多新手不能理解,其实就认为是,把一个对象做成一把锁,哪个线程拿到这个对象,等于拿到这个锁,虽然这样解释不准确,但是有助于对这块的记忆!
synchronized是基于JVM内置锁实现,通过内部对象Monitor(监视器锁)实现,基于进入与退出Monitor对象实现方法与代码块同步,监视器锁的实现依赖底层操作系统的Mutex lock(互斥锁)实现,它是一个重量级锁性能较低。当然,JVM内置锁在1.6之后版本做了重大的优化,如锁粗化(Lock Coarsening)、锁消除(Lock Elimination)、轻量级锁(Lightweight Locking)、偏向锁(Biased Locking)、适应性自旋(Adaptive Spinning)等技术来减少锁操作的开销,内置锁的并发性能已经基本与Lock持平。
synchronized关键字被编译成字节码后会被翻译成monitorenter 和 monitorexit 两条指令分别在同步块逻辑代码的起始位置与结束位置。
每个同步对象都有一个自己的Monitor(监视器锁),加锁过程如下图所示:
2.1、Monitor监视器锁
任何一个对象都有一个Monitor与之关联,当且一个Monitor被持有后,它将处于锁定状态。Synchronized在JVM里的实现都是基于进入和退出Monitor对象来实现方法同步和代码块同步,虽然具体实现细节不一样,但是都可以通过成对的MonitorEnter和MonitorExit指令来实现。所以要注意的是,MonitorEnter和MonitorExit是成对出现的!!
- Monitorenter:每个对象都是一个监视器锁(monitor)。当monitor被占用时就会处于锁定状态,线程执行Monitorenter指令时尝试获取monitor的所有权,过程如下:
a. 如果monitor的进入数为0,则该线程进入monitor,然后将进入数设置为1,该线程即为monitor的所有者;
b. 如果线程已经占有该monitor,只是重新进入,则进入monitor的进入数加1;
c. 如果其他线程已经占用了monitor,则该线程进入阻塞状态,直到monitor的进入数为0,再重新尝试获取monitor的所有权;
- Monitorexit:执行Monitorexit的线程必须是对象的引用所对应的monitor的所有者,也就是持有这个Monitor的线程。指令执行时,monitor的进入数减1,如果减1后进入数为0,那线程退出monitor,不再是这个monitor的所有者。其他被这个monitor阻塞的线程可以尝试去获取这个 monitor 的所有权。
但是我们来看一份代码:
很明显是为什么这里多了一个MonitorExit?不是说成对存在吗?
这个也是面试的高频点,这里是第1次为同步正常退出释放锁;第2次为发生异常的话退出,需要释放锁;重点是第2点,因为有可能会存在这样的代码
这里就不多做解释了。
通过上面两段描述,我们应该能很清楚的看出Synchronized的实现原理,Synchronized的语义底层是通过一个monitor的对象来完成,其实wait/notify等方法也依赖于monitor对象,这就是为什么只有在同步的块或者方法中才能调用wait/notify等方法,否则会抛出java.lang.IllegalMonitorStateException的异常的原因。
那如果是同步方法呢?
实际上方法的同步并没有通过指令 monitorenter 和 monitorexit 来完成(理论上其实也可以通过这两条指令来实现),不过相对于普通方法,其常量池中多了 ACC_SYNCHRONIZED 标示符。
JVM就是根据该标示符来实现方法的同步的:当同步方法被调用时,调用指令将会检查方法的 ACC_SYNCHRONIZED访问标志是否被设置,如果设置了,执行线程将先获取monitor,获取成功之后才能执行方法体,方法执行完后再释放monitor。因为方法调用是通过new对象调用的方式,在方法执行期间,其他任何线程都无法再获得同一个monitor对象。这种同步方式和代码块,本质上没有区别,只是方法的同步是一种隐式的方式来实现,无需通过字节码指令MonitorEnter和MonitorExit来完成。
但是两个相同的是JVM通过调用操作系统的互斥原语mutex来实现,被阻塞的线程会被挂起、等待重新调度,会导致“用户态和内核态”两个态之间来回切换,对性能有较大影响。
2.2、什么是monitor
可以把它理解为 一个同步工具,也可以描述为是一种同步机制,它通常被描述为一个对象。与一切皆对象一样,所有的Java对象是天生的Monitor,每一个Java对象都有成为Monitor的潜质,因为在Java的设计中 ,每一个Java对象自打娘胎里出来就带了一把看不见的锁,它叫做内部锁或者Monitor锁。也就是通常说Synchronized的对象锁,MarkWord锁标识位为10,其中指针指向的是Monitor对象的起始地址。在Java虚拟机(HotSpot)中,Monitor是由ObjectMonitor实现的,其主要数据结构如下(位于HotSpot虚拟机源码ObjectMonitor.hpp文件,C++实现的):
ObjectMonitor中有两个队列属性,_WaitSet 和 _EntryList,用来保存ObjectWaiter对象列表( 每个等待锁的线程都会被封装成ObjectWaiter对象 ),_owner指向持有ObjectMonitor对象的线程,当多个线程同时访问一段同步代码时:
1. 首先会进入 _EntryList集合,当线程获取到对象的monitor后,进入 _owner区域并把monitor中的_owner变量设置为当前线程,同时monitor中的计数器count加1;
2. 若线程调用 wait() 方法,将释放当前持有的monitor,_owner变量恢复为null,count自减1,同时该线程进入 WaitSet集合中等待被唤醒;
3. 若当前线程执行完毕,也将释放monitor(锁)并复位count的值,以便其他线程进入获取monitor(锁);同时,Monitor对象存在于每个Java对象的对象头Mark Word中(存储的指针的指向,这后面介绍对象布局再说),Synchronized锁便是通过这种方式获取锁的,也是为什么Java中任意对象可以作为锁的原因,同时notify/notifyAll/wait等方法会使用到Monitor锁对象,所以必须在同步代码块中使用。监视器Monitor有两种同步方式:互斥与协作。多线程环境下线程之间如果需要共享数据,需要解决互斥访问数据的问题,监视器可以确保监视器上的数据在同一时刻只会有一个线程在访问。
那么有个问题来了,我们知道synchronized加锁加在对象上,对象是如何记录锁状态的呢?答案是锁状态是被记录在每个对象的对象头(Mark Word)中,下面我们一起认识一下对象的内存布局!!
3、对象的内存布局
在HotSpot虚拟机中,对象在内存中存储的布局可以分为三块区域:对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)。
- 对象头:比如 hash码,对象所属的年代,对象锁,锁状态标志,偏向锁(线程)ID,偏向时间,数组长度(数组对象)等。Java对象头一般占有2个机器码(在32位虚拟机中,1个机器码等于4字节,也就是32bit,在64位虚拟机中,1个机器码是8个字节,也就是64bit),但是如果对象是数组类型,则需要3个机器码,因为JVM虚拟机可以通过Java对象的元数据信息确定Java对象的大小,但是无法从数组的元数据来确认数组的大小,所以用一块来记录数组长度。
- 实例数据:存放类的属性数据信息,包括父类的属性信息;
- 对齐填充:由于虚拟机要求对象起始地址必须是8字节的整数倍。填充数据不是必须存在的,仅仅是为了字节对齐;
如图
对象头包括两部分信息,第一部分是“Mark Word”,用于存储对象自身的运行时数据, 如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等等,它是实现轻量级锁和偏向锁的关键,后面我会重点介绍。
例如在32位的HotSpot虚拟机 中对象未被锁定的状态下,Mark Word的32个Bits空间中的25Bits用于存储对象哈希码(HashCode),4Bits用于存储对象分代年龄,2Bits用于存储锁标志位,1Bit固定为0,在其他状态(轻量级锁定、重量级锁定、GC标记、可偏向)下对象的存储内容如下表所示,我们通常用最后两位表示锁的类型,重点在于偏向锁和无锁的后两位记录都为01,而倒数第三位是区分偏向还是无锁,加起来是001——无锁,101——偏向锁。
而64Bits的表示如下:
现在我们虚拟机基本是64位的,而64位的对象头有点浪费空间,JVM默认会开启指针压缩(‐XX:+UseCompressedOops),所以基本上也是按32位的形式记录对象头的,这个地方大家只需要记得32是长什么样就好。
理论说完我们来几个实践说说常见的问题:
先看一段代码,我打印对象的大小,MarkWord占用4个字节也就是0-4,32Bits
拷出来我们可以对比对象结构表看下
00001001 00000000 00000000 00000000
咋一看后两位是轻量级锁?我代码里很明显只有一个对象new出来,怎么会是轻量级锁呢?不应该是无锁吗。
这里注意下,操作系统一般都会存在两种模式,大端模式:低位(字节/比特)放在高地址中,高位(字节/比特)放在低地址中。而小端模式:低位(字节/比特)放在低地址中,高位(字节/比特)放在高地址中。
操作系统中这两个模式其实可以切换,但是一般的操作系统包括linux或者windows都是以小端模式,这个作为常识了解下就行。
既然是0-4字节的偏移量,那么意味着左往右是低至高,那么这个数值就需要倒过来才是真正数据的存放效果,应该是
00001001 00000000 00000000 00000000——小端模式(左低右高)
00000000 00000000 00000000 00001001——正常模式(低位在右边)
如果这个不能理解,看下百度百科这块内容,重点我们看到最后两位是01,对照表看,是无锁状态!发现一个点,为什么hashCode为什么是00000...?
我查过相关资料,说是hashcode在底层C语言实现的时候,通过懒加载的方式去生成的,这个具体我也没研究透,但是先暂时略过吧,继续看,如果我此时加了锁:
为什么我加了锁后两位之间变成00,参考表就变成了轻量级锁,锁的升级过程不应是从无锁——》偏向锁——》轻量级锁?查了相关资料后,实际上JVM启动会延迟启动偏向锁,大概4s左右,因为JVM启动会加载一些Map,List,或者一些对象,本身这些对象在初始的时候会存在一些锁的竞争,目的为了避免无谓的锁竞争和升级过程造成的开销所以直接升级成了轻量级锁。如果想看到效果,我们可以延迟启动下,这样偏向锁就可以看到了。
很明显延迟后变成了101,查表得知是偏向锁。
4、锁的膨胀升级过程
锁的状态总共有四种,无锁状态、偏向锁、轻量级锁和重量级锁。随着锁的竞争,锁可以从偏向锁升级到轻量级锁,再升级的重量级锁,但是锁的升级是单向的,也就是说只能从低到高升级,不会出现锁的降级。从JDK 1.6 中默认是开启偏向锁和轻量级锁的,可以通过-XX:-UseBiasedLocking来禁用偏向锁。下图为锁的升级全过程:
偏向锁:是Java 6之后加入的新锁,它是一种针对加锁操作的优化手段,经过研究发现,在大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,因此为了减少同一线程获取锁(会涉及到一些CAS操作,耗时)的代价而引入偏向锁。偏向锁的核心思想是,如果一个线程获得了锁,那么锁就进入偏向模式,此时Mark Word 的结构也变为偏向锁结构,当这个线程再次请求锁时,无需再做任何同步操作,即获取锁的过程,这样就省去了大量有关锁申请的操作,从而也就提供程序的性能。所以,对于没有锁竞争的场合,偏向锁有很好的优化效果,毕竟极有可能连续多次是同一个线程申请相同的锁。但是对于锁竞争比较激烈的场合,偏向锁就失效了,因为这样场合极有可能每次申请锁的线程都是不相同的,因此这种场合下不应该使用偏向锁,否则会得不偿失,需要注意的是,偏向锁失败后,并不会立即膨胀为重量级锁,而是先升级为轻量级锁。下面我们接着了解轻量级锁。 (默认开启偏向锁开启偏向锁:-XX:+UseBiasedLocking -XX:BiasedLockingStartupDelay=0关闭偏向锁:-XX:-UseBiasedLocking)
轻量级锁:倘若偏向锁失败,虚拟机并不会立即升级为重量级锁,它还会尝试使用一种称为轻量级锁的优化手段(1.6之后加入的),此时Mark Word 的结构也变为轻量级锁的结构。轻量级锁能够提升程序性能的依据是“对绝大部分的锁,在整个同步周期内都不存在竞争”,注意这是经验数据。需要了解的是,轻量级锁所适应的场景是线程交替执行同步块的场合,如果存在同一时间访问同一锁的场合,就会导致轻量级锁膨胀为重量级锁。
自旋锁:轻量级锁失败后,虚拟机为了避免线程真实地在操作系统层面挂起,还会进行一项称为自旋锁的优化手段。这是基于在大多数情况下,线程持有锁的时间都不会太长,如果直接挂起操作系统层面的线程可能会得不偿失,毕竟操作系统实现线程之间的切换时需要从用户态转换到核心态,这个状态之间的转换需要相对比较长的时间,时间成本相对较高,因此自旋锁会假设在不久将来,当前的线程可以获得锁,因此虚拟机会让当前想要获取锁的线程做几个空循环(这也是称为自旋的原因),一般不会太久,可能是50个循环或100循环,在经过若干次循环后,如果得到锁,就顺利进入临界区。如果还不能获得锁,那就会将线程在操作系统层面挂起,这就是自旋锁的优化方式,这种方式确实也是可以提升效率的。最后没办法也就只能升级为重量级锁了。
大概以上就是关于Synchronized的面试点。应该够大家应付面试了