文章目录
(一)synchronized锁分析1、synchronized作用和使用?2、synchronized同步原理?3、synchronized结构?4、synchronized锁优化?(1)自旋锁(2)自适应的自旋锁(3)锁消除(5)偏向锁(核心)(6)轻量级锁(核心)(7)重量级锁(核心) 5、synchronized优劣势 (二)ReentrantLock锁分析(一)synchronized锁分析
1、synchronized作用和使用?
synchronized使用:
(1)当synchronized作用在实例方法时,监视器锁(monitor)便是对象实例(this);(2)当synchronized作用在静态方法时,监视器锁(monitor)便是对象的Class实例,因为Class数据存在于永久代(方法区),即静态方法锁相当于该类的一个全局锁;(3)当synchronized作用在某一个对象实例时,监视器锁(monitor)便是括号括起来的对象实例;
synchronized可以将任何一个非null对象作为锁即对象监视器(Object Monitor)。
synchronized内置锁是一种对象锁(锁的是对象而非引用变量),作用粒度是对象,可以用来实现对临界资源的同步互斥访问,是可重入的,其最大的作用是避免死锁。
synchronized作用:
(1)原子性:确保线程之间互斥访问同步代码;(2)可见性:保证多线程之间共享变量的修改能及时可见,与java内存模型一致,对变量加lock操作,则执行前会重新从主内存load或assign操作,反之则刷入主内存(unlock);(3)有序性:有效解决重排序问题即释放锁在加锁后,保证代码的有序性。
2、synchronized同步原理?
线程安全主要是依赖锁(synchronized、ReentrantLock等)来保证数据安全,锁依赖机制分为软件层面和硬件层次;
synchronized锁依赖JVM,JUC并发包下的锁依赖硬件(特殊的CPU指令)。
篇幅原因,只列举对象锁和普通实例锁,不列举静态方法锁(static synchronized)和类类对象锁(Test.class)。
synchronized的实现过程:
(1)同步代码块:
package com.algorithm.test;import java.util.*;public class Test {public void test(Map<String,Object> map){int sync = 12;synchronized(this){//锁对象sync = 14;System.out.println("synchronized 输出!");}}}
JVM编译后的字节码:
同步代码块原理:
(1)monitorenter:每个对象都是一个监视器锁(monitor)。
当monitor被占用时就会处于锁定状态,线程执行monitorenter指令时尝试获取monitor的所有权,过程如下:
1)如果monitor的进入数为0,则该线程进入monitor,然后将进入数设置为1,该线程即为monitor的所有者;2)如果线程已经占有该monitor,只是重新进入,则进入monitor的进入数加1;(可重入核心点)3)如果其他线程已经占用了monitor,则该线程进入阻塞状态,直到monitor的进入数为0,再重新尝试获取monitor的所有权;
(2)monitorexit:执行monitorexit的线程必须是object ref所对应的monitor的所有者。
指令执行时,monitor的进入数减1,如果减1后进入数为0,那线程退出monitor,不再是这个monitor的所有者。
其他被这个monitor阻塞的线程可以尝试去获取这个monitor的所有权。
//两次monitorexit命令作用:monitorexit指令出现了两次,第1次为同步正常退出释放锁;第2次为发生异步退出释放锁;
Synchronized的实现原理是通过一个monitor的对象来完成,其实wait/notify等方法也依赖于monitor对象,
这也是wait/notify等方法只有在同步的块或者方法中才能调用的原因,否则会抛出java.lang.IllegalMonitorStateException的异常。
(2)同步方法:
package com.algorithm.test;import java.util.*;public class Test {public synchronized void test(Map<String,Object> map){int sync = 12;sync = 14;System.out.println("synchronized 输出!");}}
JVM编译后的字节码:
同步方法原理:
(1)JVM就是根据ACC_SYNCHRONIZED标示符来同步的:
1)方法同步并没有通过指令monitorenter和monitorexit来完成(理论上也可以通过这两条指令来实现),不过相对于普通方法(无锁),其常量池中多了ACC_SYNCHRONIZED标示符。2)当方法被调用时,调用指令将会检查方法的ACC_SYNCHRONIZED访问标志是否被设置,如果设置了,执行线程将先获取monitor,获取成功之后才能执行方法体,方法执行完后再释放monitor。在方法执行期间,其他任何线程都无法再获得同一个monitor对象。
(2)两种同步方式本质上没有区别,只是方法同步是一种隐式的方式来实现,无需通过字节码来完成。两个指令的执行是JVM通过调用操作系统的互斥原语mutex来实现,被阻塞的线程会被挂起、等待重新调度,导致"用户态和内核态"之间来回切换,对性能有较大影响。
3、synchronized结构?
JVM中对象结构分为三块区域:对象头、实例数据和对齐填充。
结构说明:
1)对象头:Java对象头一般占有2个机器码(在32位虚拟机中,1个机器码->4字节即32bit,在64位虚拟机中,1个机器码->8字节即64bit);
PS:如果对象是数组类型,则需要3个机器码,因为JVM虚拟机可以通过Java对象的元数据信息确定Java对象的大小,无法从数组的元数据来确认数组的大小,所以用一块来记录数组长度。
2)实例数据:存放类的属性数据信息,包括父类的属性信息;
3)对齐填充:由于虚拟机要求对象起始地址必须是8字节的整数倍。填充数据非必须存在的,仅为了字节对齐。
对象头作用:
synchronized锁是存在Java对象头里的,Hotspot虚拟机的对象头主要包括两部分数据:Mark Word(标记字段)、Class Pointer(类型指针)。
Mark Word用于存储对象自身的运行时数据,它是实现轻量级锁和偏向锁的关键;
Class Pointer是对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。
(1)Mark Word
Mark Word用于存储对象自身的运行时数据如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程 ID、偏向时间戳等。32位HotSpot虚拟机对象头Mark Word结构如下:
64位HotSpot虚拟机对象头Mark Word结构如下:
对象头的最后两位存储了锁的标志位,01是初始状态,未加锁,其对象头里存储的是对象本身的哈希码,随着锁级别的不同,对象头里会存储不同的内容。
1)偏向锁存储的是当前占用此对象的线程ID;
2)轻量级存储指向线程栈中锁记录的指针。
锁可能是个锁记录+对象头里的引用指针(轻量级),也可能是对象头里的线程ID(偏向锁)。
(2)Lock Record
1)在线程进入同步代码块的时候,如果此同步对象没有被锁定,即它的锁标志位是01,则虚拟机首先在当前线程的栈中创建“锁记录(Lock Record)”,用于存储锁对象的Mark Word的拷贝,官方称为Displaced Mark Word。2)Lock Record是线程私有的数据结构,每一个线程都有一个可用Lock Record列表,同时还有一个全局的可用列表。每一个被锁住的对象Mark Word都会和一个Lock Record关联即对象头的MarkWord中的Lock Word指向Lock Record的起始地址,同时Lock Record中有一个Owner字段存放拥有该锁的线程的唯一标识(object mark word),表示该锁被这个线程占用。
Lock Record锁结构:
(3)Monitor(核心)
任何一个对象都有一个Monitor与之关联,每一个Java对象就带了一把看不见的锁,它叫做内部锁或者Monitor锁,
当且一个Monitor被持有后,它将处于锁定状态。
synchronized在JVM里的实现都是基于进入和退出Monitor对象来实现方法同步和代码块同步,虽然具体实现细节不一样,
但是都可以通过成对的MonitorEnter和MonitorExit指令来实现。
JVM字节码指令:
1)MonitorEnter指令:插入在同步代码块的开始位置,当代码执行到该指令时,将会尝试获取该对象Monitor的所有权,即尝试获取该对象的锁;2)MonitorExit指令:插入在方法结束处和异常处,JVM保证每个MonitorEnter必须有对应的MonitorExit;
synchronized的对象锁即Mark Word锁标识位为10(重量级)和00(轻量级),其中指针指向的是Monitor对象的起始地址。
在Java虚拟机(HotSpot)中,Monitor是由ObjectMonitor(.hpp文件,C++)实现的,结构如下:
ObjectMonitor() {_header = NULL;_count = 0; // 记录个数_waiters= 0,_recursions = 0;_object = NULL;_owner = NULL;_WaitSet= NULL; // 处于wait状态的线程,会被加入到_WaitSet_WaitSetLock = 0 ;_Responsible = NULL ;_succ = NULL ;_cxq= NULL ;FreeNext= NULL ;_EntryList = NULL ; // 处于等待锁block状态的线程,会被加入到该列表_SpinFreq= 0 ;_SpinClock = 0 ;OwnerIsThread = 0 ;}
ObjectMonitor中有两个重要的队列(_WaitSet和_EntryList):
_WaitSet:处于wait状态的线程,会被加入到_WaitSet,每个等待锁的线程都会被封装成ObjectWaiter对象;_EntryList:处于等待锁block状态的线程,会被加入到该列表;_owner:指向持有ObjectMonitor对象的线程。
多线程并发访问同步代码时(背景为已被其他锁持有):
1)先进入_EntryList集合,当线程获取到对象的monitor后,进入_Owner区域并把monitor中的owner变量设置为当前线程,同时monitor中的计数器count加1;2)若线程调用wait()方法,将释放当前持有的monitor,_owner变量恢复为null,count自减1,同时该线程进入WaitSet集合中等待被唤醒;3)若当前线程执行完毕,也将释放monitor(锁)并复位count的值,以便其他线程进入获取monitor(锁);
说明:
1)Monitor对象存在于每个Java对象的对象头Mark Word中(存储的指针的指向);
2)synchronized锁便是通过这种方式获取锁的,也是Java中任意对象可以作为锁的原因;
3)notify/notifyAll/wait等方法会使用到Monitor锁对象,所以必须在同步代码块中使用。
synchronized锁是不公平锁,原因如下:
当一个线程代码执行完毕正常释放监视器时,在入口区和等待区的等待线程都会去竞争监视器,如果入口区的线程获得,会从2号门进入;如果等待区的线程赢了会从4号门进入。只有通过3号门才能进入等待区,在等待区中的线程只有通过4号门才能退出等待区,即线程只有在持有监视器时才能执行wait操作,处于等待的线程只有再次获得监视器才能退出等待状态。
4、synchronized锁优化?
锁优化JDK版本:
1)jdk1.6版本对synchronized进行优化(分许重点,自适应的CAS自旋、锁消除、锁粗化、偏向锁、轻量级锁这些优化策略等);2)jdk1.5版本引入了CAS锁;synchronized锁四种状态:无锁状态、偏向锁状态、轻量级锁状态和重量级锁状态(单向升级,不能降级)。在JDK1.6中默认是开启偏向锁和轻量级锁的,可以通过来禁用-XX:-UseBiasedLocking偏向锁。
(1)自旋锁
线程的阻塞和唤醒需要CPU从用户态与核心态之间的转换,频繁切换是相当耗CPU性能。经研究表明,对象锁的锁状态只会持续很短,为了很短的时间频繁地转换是非常不值得的。
自旋锁就是指当一个线程尝试获取某个锁时,如果该锁已被其他线程占用,就一直循环检测锁是否被释放,而不是进入线程挂起或睡眠状态。
使用场景:
自旋锁适用于锁保护的临界区很小的情况,临界区很小的话,锁占用的时间就很短,性能就好。
说明:
自旋等待不能替代阻塞,虽然它避免线程切换带来的开销,但是它占用了CPU处理器的时间。如果持有锁的线程很快就释放了锁,那么自旋的效率就非常好,反之,自旋的线程就会白白消耗掉处理的资源,它不会做任何有意义的工作。即自旋等待的时间(自旋的次数)必须要要设置合适,如果自旋超过了定义的时间仍然没有获取到锁,则应该被挂起。
在JDK1.4默认关闭自旋锁,JDK1.6默认开启。-XX:+UseSpinning控制是否开启自旋锁;-XX:PreBlockSpin设置自旋次数(jdk1.6默认10次)。
为了解决自定义自旋次数导致实际业务不太方便,jdk1.6产生了自适应的自旋锁。
(2)自适应的自旋锁
在jdk1.6中产生了自适应的自旋锁,为了解决自定义自旋次数而产生的,意味着自旋的次数不再是固定的,由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。
自适应过程:
线程自旋成功了,下次自旋的次数会增加,虚拟机认为上次成功了,那么下次自旋很有可能会再次成功,就会允许自旋等待持续的次数更多。反之,如果对于某个锁,很少有自旋能够成功,那么在以后要或者这个锁的时候自旋的次数会减少甚至省略掉自旋过程,以免浪费处理器资源。
(3)锁消除
JVM检测到加锁的代码中不存在共享数据竞争,会对这些同步锁进行锁消除。
消除依据:逃逸分析的数据支持。
使用场景:
不存在竞争的场景,开发者可以认为不加锁,但是部分jdk内置的API是加了synchronized如StringBuffer、Vector、HashTable等会变相的隐形加锁。变量是否逃逸,对于虚拟机来说需要使用数据流分析来确定。
如StringBuffer的append方法:
StringBuffer类:
public synchronized StringBuffer append(String str) {toStringCache = null;super.append(str);return this;}
Test类:
//基于JVM的逃逸分析,sb变量未发生逃逸,JVM会对其进行锁消除。public static void test(String str){StringBuffer sb = new StringBuffer();for (int i = 0; i < 100; i++) {sb.append(str);}System.out.println(sb.toString());}
(5)偏向锁(核心)
产生背景:
在大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,为了让线程获得锁的代价更低,产生了偏向锁。
使用场景:
1)偏向锁是在单线程执行代码块时使用的机制,如果在多线程并发的环境下,则一定会转化为轻量级锁或者重量级锁。
2)多线程环境的话,可以使用参数-XX:-UseBiasedLocking禁止偏向锁,jdk1.5默认关闭,jdk1.6默认开启。
偏向锁原理:
1)偏向锁主要是为了解决在没有多线程竞争的环境下减少加锁、解锁带来的性能消耗,仅在置换ThreadID的时候使用一次CAS指令来避免加锁操作,撤销操作消耗的性能也小于CAS指令消耗的性能;(单线程最优,相对于获取锁的时候)
2)轻量级加锁、解锁操作是多次进行CAS指令,相对于偏向锁性能会降低太多。(多线程较优)
结论:轻量级锁是为了在线程交替执行同步块时提高性能,而偏向锁则是在只有一个线程执行同步块时进一步提高性能。
偏向锁的优势(过程):
cas操作会造成本地延迟,即cas操作实际上是依赖硬件的汇编指令,如多核处理器中,多个线程同时从主存中将共享变量load到工作内存中, 一个线程成功,另外一个线程需要重新load到自己内存中,此时多了一步重新获取消耗的性能(如果是更多线程就消耗更多了),当然此时变为多线程会升级为轻量级锁; 然而为了解决cas带来的延迟,在同一个线程(可能是多线程环境,但还未出现多线程竞争情况下)偶然性连续多次获得锁对象,那么为了减少cas带来的损耗,将锁标志位设置为01,01代表偏向锁。
(1)jvm会先判断是否为偏向,满足偏向;1)对比为是否为同一个ThreadID,是同一个线程ID,则直接进入同步代码;2)对比为是否为同一个ThreadID,不是同一个线程ID,则进行cas置换,对锁对象进行置换(成功置换),将自己的线程ID设置到锁对象中(置换不成功则进行锁升级);(2)jvm会先判断是否为偏向,不满足偏向(cas置换失败即多线程并发争抢);通过CAS竞争锁失败,证明当前存在多线程竞争情况,当到达全局安全点,获得偏向锁的线程被挂起,偏向锁升级为轻量级锁,然后被阻塞在安全点的线程继续往下执行同步代码块。
全局安全点:
(1)暂停拥有偏向锁的线程;(2)判断锁对象是否还处于被锁定状态;1)否,则恢复到无锁状态(01),便于其余线程竞争;2)是,则挂起持有锁的当前线程,并将指向当前线程的锁记录地址的指针放入对象头Mark Word,升级为轻量级锁状态(00),当前线程获得锁对象(轻量级),进入轻量级锁的竞争模式;
(6)轻量级锁(核心)
作用:
(1)轻量级锁并不是用来代替重量级锁的,它的本意是在没有多线程竞争的前提下,减少传统的重量级锁使用产生的性能消耗;
(2)当关闭偏向锁功能或者多个线程竞争偏向锁导致偏向锁升级为轻量级锁,则会尝试获取轻量级锁。
使用场景:
是线程交替执行同步块的情况,如果存在同一时间访问同一锁的情况,就会导致轻量级锁膨胀为重量级锁。
加锁过程:
发生背景:基于发生背景会升级到轻量级锁阶段的前提下,锁对象是无锁状态(01代表无锁或者偏向锁),发生了多线程竞争导致升级到轻量级锁。
(1)虚拟机首先在当前线程的栈帧中建立私有的锁记录(Lock Record),用于存储当前锁对象的Mark World;
(2)拷贝当前锁对象的Mark Word到锁记录中;
(3)拷贝成功后,执行cas将锁对象Mark World更新为指向当前线程锁记录Lock Record的指针并将Lock record里的owner指针指向object mark word。
(4)如果CAS操作成功,那么此线程就获得了锁对象且其Mark Word的锁标志位设置为“00”,即表示此对象处于轻量级锁定状态;
(5)如果CAS操作失败,JVM会先检查对象的Mark Word是否指向当前线程的栈帧,如果是就说明当前线程已经拥有了这个对象的锁,则直接进入同步块继续执行。否则说明还有其它线程竞争锁,轻量级锁就要膨胀为重量级锁,锁标志的状态值变为“10”,Mark Word中存储的就是指向重量级锁(互斥量)的指针,后面等待锁的线程也要进入阻塞状态。而当前线程便尝试使用自旋来获取锁,自旋就是为了不让线程阻塞,而采用循环去获取锁的过程。
CAS更新之前锁对象和锁记录结构:
CAS更新成功后锁对象和锁记录结构:
注意:
对于轻量级锁,其性能提升的依据是“对于绝大部分的锁,在整个生命周期内都是不会存在竞争的”,如果打破这个依据则除了互斥的开销外,还有额外的CAS操作,因此在有多线程竞争的情况下,轻量级锁比重量级锁更慢。
(7)重量级锁(核心)
synchronized是通过对象内部的一个叫做监视器锁(Monitor)来实现的。但是监视器锁本质依赖于操作系统的Mutex Lock来实现的,操作系统实现线程之间的切换需要从用户态转换到核心态,状态之间的转换需要相对比较长的时间,这也是synchronized效率低的原因。因此,这种依赖于操作系统Mutex Lock所实现的锁称为 “重量级锁”。
重量级锁过程:synchronized->>Monitor监视器->>操作系统的Mutex Lock(用户与核心态转换)->>此转换相当耗费时间。
synchronized锁状态转换:
5、synchronized优劣势
各种锁并不是相互代替的,而是在不同场景下的不同选择,绝对不是说重量级锁就是不合适的。
每种锁是只能升级,不能降级,即由偏向锁->轻量级锁->重量级锁,而这个过程就是开销逐渐加大的过程。
锁出现的场景:
(1)如果是单线程使用,那偏向锁代价最小且能解决问题,连CAS都不用做,仅在内存中比较下对象头(ThreadID)就行了;
(2)如果出现了其他线程竞争,则偏向锁就会升级为轻量级锁;
(3)如果其他线程通过一定次数(自旋)的CAS尝试没有成功,则进入重量级锁。
说明:要做偏向锁建立、偏向锁撤销、轻量级锁建立、升级到重量级锁,最终还是得靠重量级锁来解决问题,
那这样的代价就比直接用重量级锁要大不少(反而降低性能)。所以使用哪种锁技术,一定要看其所处的实际业务场景,在绝大多数的情况下,
偏向锁是有效的,这是基于HotSpot作者发现的“大多数锁只会由同一线程并发申请”的经验规律。
核心锁优缺点:
思考:
1、为什么升级为轻量锁时要把对象头里的Mark Word复制到线程栈的锁记录中呢?
因为在申请对象锁时需要以该值作为CAS的比较条件,同时在升级到重量级锁的时候,能通过这个比较判定是否在持有锁的过程中此锁被其他线程> 申请过,如果被其他线程申请了,则在释放锁的时候要唤醒被挂起的线程(重量级锁状态才存在唤醒)。
2、为什么会尝试CAS不成功以及什么情况下会不成功?
CAS本身是不带锁机制的,其是通过比较而来。
假设如下场景: 线程A和线程B都在对象头里的锁标识为无锁状态进入,那么如线程A先更新对象头为其锁记录指针成功之后,
线程B再用CAS去更新,就会发现此时的对象头已经不是其操作前的对象HashCode了,所以CAS会失败。也就是说,只有两个线程并发申请锁的时候会发生CAS失败。然后线程B进行CAS自旋,等待对象头的锁标识重新变回无锁状态或对象头内容等于对象HashCode(这是线程B做CAS操作前的值),即线程A执行结束> (只有线程A执行完毕撤销锁会重置对象头),此时线程B的CAS操作终于成功了,于是线程B获得了锁以及执行同步代码的权限。如果线程A的执行时间较长,线程B经过若干次CAS时钟没有成功,则锁膨胀为重量级锁,即线程B被挂起阻塞、等待重新调度。
3、如何理解“轻量级”?
“轻量级”是相对于使用操作系统互斥量来实现的传统锁而言的。但是,首先需要强调一点的是,轻量级锁并不是用来代替重量级锁的,它的本意是在没有多线程竞争的前提下,减少传统的重量级锁使用产生的性能消耗。
轻量级锁所适应的场景是线程交替执行同步块的情况,如果存在同一时间访问同一锁的情况,必然就会导致轻量级锁膨胀为重量级锁。
(二)ReentrantLock锁分析
请参考AQS原理分析——以ReentrantLock为例进行分析
如果觉得《可重入锁原理分析》对你有帮助,请点赞、收藏,并留下你的观点哦!