synchronized简介
synchronized是Java中的关键字,是一种同步锁。在多线程编程中,有可能会出现多个线程同时争抢同一个共享资源的情况,这个资源一般被称为临界资源。这种共享资源可以被多个线程同时访问,且又可以同时被多个线程修改,然而线程的执行是需要CPU的资源调度,其过程是不可控的,所以需要采用一种同步机制来控制对共享资源的访问,于是线程同步锁——synchronized就应运而生了。
如何解决线程并发安全问题
多线程并发读写访问临界资源的情况下,是会存在线程安全问题的,可以采用的同步互斥访问的方式,就是在同一时刻,只能有同一个线程能够访问到临界资源。当多个线程执行同一个方法时,该方法内部的局部变量并不是临界资源,因为这些局部变量会在类加载的时候存在每个线程的私有栈的局部变量表中,因此不属于共享资源,所有不会导致线程安全问题。
synchronized用法
synchronized关键字最主要有以下3种使用方式:
- 修饰类方法,作用于当前类加锁,如果多个线程不同对象访问该方法,则无法保证同步。
- 修饰静态方法,作用于当前类对象加锁,进入同步代码前要获得当前类对象的锁,锁的是包含这个方法的类,也就是类对象,这样如果多个线程不同对象访问该静态方法,也是可以保证同步的。
- 修饰代码块,指定加锁对象,对给定对象加锁,进入同步代码库前要获得给定对象的锁。
synchronized原理分析
可以先通过一个简单的案例看一下同步代码块:
synchronized属于Java关键字,没办法直接看到其底层源码,所以只能通过class文件进行反汇编。
先通过javac SynchTestDemo.java指令直接SynchTestDemo.java文件编译成SynchTestDemo.class文件;再通过javap -v SynchTestDemo.class指令再对SynchTestDemo.class文件进行反汇编,可以得到下面的字节码指令:
这些反编译的字节码指令这里就不详细解释了,对照着JVM指令手册也能看懂是什么意思。通过上图反编译的结果可以看出,monitorexit指令实际上是执行了两次,第一次是正常情况下释放锁,第二次为发生异常情况时释放锁,这样做的目的在于保证线程不死锁。
monitorenter
首先可以看一下JVM规范中对于monitorenter的描述:
翻译过来就是:任何一个对象都有一个monitor与其相关联,当且有一个monitor被持有后,它将处于锁定的状态,其他线程无法来获取该monitor。当JVM执行某个线程的某个方法内部的monitorenter时,他会尝试去获取当前对应的monitor的所有权。
其过程如下:
- 如果monitor的进入数为0,则该线程进入monitor,然后将进入数设置为1,该线程即为monitor的所有者;
- 如果线程已经占有该monitor,只是重新进入,则进入monitor的进入数加1;
- 如果其他线程已经占用了monitor,则该线程进入阻塞状态,直到monitor的进入数为0,再重新尝试获取monitor的所有权。
monitorexit
也可以先看一下JVM规范中对monitorexit的描述:
翻译过来就是:
能执行monitorexit指令的线程一定是拥有当前对象的monitor的所有权的线程;
执行monitorexit时会将monitor的进入数减1。当monitor的进入数减为0时,当前线程退出monitor,不再拥有monitor的所有权,此时其他被这个monitor阻塞的线程可以尝试去获取这个monitor的所有权。
synchronized关键字被编译成字节码后会被翻译成monitorenter和monitorexit两条指令分别在同步块逻辑代码的起始位置与结束位置,如下图所示:
每个同步对象都有一个自己的Monitor(监视器锁),加锁过程如下图所示:
通过上面的描述可以看出synchronized的实现原理:synchronized的底层实际是通过一个monitor对象来实现的,其实wait/notify方法也是依赖于monitor对象来实现的,这就是为什么只有在同步代码块或者方法中才能调用该方法,否则就会抛出出java.lang.IllegalMonitorStateException的异常的原因。
下面可以再通过一个简单的案例看一下同步方法:
public class SynchTestDemo {
与上面同理可以查看到,该方法的字节码指令:
从字节码反编译的可以看出,同步方法并没有通过指令monitorenter和monitorexit来实现的,但是相对于普通方法来说,其常量池多了了 ACC_SYNCHRONIZED 标示符。JVM实际就是根据该标识符来实现方法的同步的。
当方法被调用时,会检查ACC_SYNCHRONIZED标志是否被设置,若被设置,线程会先获取monitor,获取成功才能执行方法体,方法执行完成后会再次释放monitor。在方法执行期间,其他线程都无法获得同一个monitor对象。
其实两种同步方式从本质上看是没有区别的,两个指令的执行都是JVM调用操作系统的互斥原语mutex来实现的,被阻塞的线程会被挂起、等待重新调度,会导致线程在“用户态”和“内核态”进行切换,就会对性能有很大的影响。
什么是monitor?
monitor通常被描述为一个对象,可以将其理解为一个同步工具,或者可以理解为一种同步机制。所有的Java对象自打new出来的时候就自带了一把锁,就是monitor锁,也就是对象锁,存在于对象头(Mark Word),锁标识位为10,指针指向的是monitor对象起始地址。
在Java虚拟机(HotSpot)中,Monitor是由其底层实际是由C++对象ObjectMonitor实现的:
- _owner:初始时为NULL。当有线程占有该monitor时,owner标记为该线程的唯一标识。当线程释放monitor时,owner又恢复为NULL。owner是一个临界资源,JVM是通过CAS操作来保证其线程安全的;
- _cxq:竞争队列,所有请求锁的线程首先会被放在这个队列中(单向链接)。cxq是一个临界资源,JVM通过CAS原子指令来修改cxq队列。修改前cxq的旧值填入了node的next字段,_cxq指向新值(新线程)。因此_cxq是一个后进先出的stack(栈);
- _EntryList:_cxq队列中有资格成为候选资源的线程会被移动到该队列中;
- _WaitSet:因为调用wait方法而被阻塞的线程会被放在该队列中。
举个例子具体分析一下_cxq队列与_EntryList队列的区别:
若多线程执行上面这段代码,刚开始t1线程第一次进同步代码块,能够获得锁,之后马上又有一个t2线程也准备执行这段代码,t2线程是没有抢到锁的,t2这个线程就会进入_cxq这个队列进行等待,此时又有一个线程t3准备执行这段代码,t3当然也会没有抢到这个锁,那么t3也就会进入_cxq进行等待。
接着,t1线程执行完同步代码块把锁释放了,这个时候锁是有可能被t1、t2、t3中的任何一个线程抢到的。
假如此时又被t1线程给抢到了,那么上次已经进入_cxq这个队列进行等待的线程t2、t3就会进入_EntryList进行等待,若此时来了个t4线程,t4线程没有抢到锁资源后,还是会先进入_cxq进行等待。
下面具体分析一下_WaitSet队列与_EntryList队列:
每个object的对象里 markOop->monitor() 里可以保存ObjectMonitor的对象。ObjectWaiter 对象里存放thread(线程对象) 和unpark的线程, 每一个等待锁的线程都会有一个ObjectWaiter对象,而objectwaiter是个双向链表结构的对象。
结合上图monitor的结构图可以分析出,当线程的拥有者执行完线程后,会释放锁,此时有可能是阻塞状态的线程去抢到锁,也有可能是处于等待状态的线程被唤醒抢到了锁。在JVM中每个等待锁的线程都会被封装成ObjectMonitor对象,_owner标识拥有该monitor的线程,而_EntryList和_WaitSet就是用来保存ObjectWaiter对象列表的,_EntryList和_WaitSet最大的区别在于前者是用来存放等待锁block状态的线程,后者是用来存放处于wait状态的线程。
当多个线程同时访问同一段代码时:
- 首先会进入_EntryList集合每当线程获取到对象的monitor后,会将monitor中的_ower变成设置为当前线程,同时会将monitor中的计数器_count加1
- 若线程调用wait()方法时,将释放当前持有的monitor对象,将_ower设置为null,_count减1,同时该线程进入_WaitSet中等待被唤醒
- 若当前线程执行完毕,也将释放monitor锁,并将_count值复原,以便于其他线程获取锁
monitor对象存在于每个Java对象的对象头(Mark Word)中,所以Java中任何对象都可以作为锁,由于notify/notifyAll/wait等方法会使用到monitor锁对象,所以必须在同步代码块中使用。
多线程情况下,线程需要同时访问临界资源,监视器monitor可以确保共享数据在同一时刻只会有一个线程在访问。
那么问题来了,synchronized是对象锁,加锁就是加在对象上,那对象时如何记录锁的状态的呢?答案就是锁的状态是记录在每个对象的对象头(Mark Word)中的,那什么是对象头呢?
什么是对象头
在JVM中,对象在内存中的布局分为三块区域:对象头、实例数据和对齐填充。
如下图所示:
对象头又包括两部分信息,第一部分用于存储对象自身的运行时数据(Mark Word),如HashCode、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等。
对象头的另外一部分是类型指针(Klass pointer),即对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。
Class synchClass = synchTestDemo.getClass();
值得注意的是:类元信息存在于方法区,类元信息有区别与堆中的synchClass字节码对象,synchClass可以理解为类加载完成后,JVM将类的信息存在堆中,然后使用反射去访问其全部信息(包括函数和字段),然而在JVM内部大多数对象都是使用C++代码实现的,对于JVM内部如果需要类信息,JVM就会通过对象头的类型指针去拿方法区中类元信息的数据。
实例数据:存放类的属性数据信息,包括父类的属性信息。
对齐填充:由于虚拟机要求 对象起始地址必须是8字节的整数倍。填充数据不是必须存在的,仅仅是为了字节对齐。
下面可以看一下对像头的结构:
在32位虚拟机下,Mark Word是32bit大小的,其存储结构如下:
在64位虚拟机下,Mark Word是64bit大小的,其存储结构如下:
现在虚拟机基本是64位的,而64位的对象头有点浪费空间,JVM默认会开启指针压缩,所以基本上也是按32位的形式记录对象头的。
也可以通过下面参数进行控制JVM开启和关闭指针压缩:
开启压缩指针(-XX:+UseCompressedOops) 关闭压缩指针(-XX:-UseCompressedOops)
那为什么JVM需要默认开启指针压缩呢?
原因在于在对象头上类元信息指针Klass pointer在32位JVM虚拟机中用4个字节存储,但是到了64位JVM虚拟机中Klass pointer用的就是8个字节来存储,一些对象在32位虚拟机用的也是4字节来存储,到了64位机器用的都是8字节来存储了,一个工程项目中有成千上万的对象,倘若每个对象都用8字节来存放的话,那这些对象无形中就会增加很多空间,导致堆的压力就会很大,堆很容易就会满了,然后就会更容易的触发GC,那指针压缩的最主要的作用就是压缩每个对象内存地址的大小,那么同样堆内存大小就可以放更多的对象。
这里刚好可以再说一个额外的小知识点:
对象头中有4个字节用于存放对象分代年龄的,4个字节就是2的四次方等于16,其范围就是0~15,所以也就很好理解对象在GC的时候,JVM对象由年轻代进入老年代的默认分代年龄是15了。
synchronized锁的优化
操作系统分为“用户空间”和“内核空间”,JVM是运行在“用户态”的,jdk1.6之前,在使用synchronized锁时需要调用底层的操作系统实现,其底层monitor会阻塞和唤醒线程,线程的阻塞和唤醒需要CPU从“用户态”转为“内核态”,频繁的阻塞和唤醒对CPU来说是一件负担很重的工作,这些操作给系统的并发性能 带来了很大的压力。
同这个时候CPU就需要从“用户态”切向“内核态”,在这个过程中就非常损耗性能而且效率非常低,所以说jdk1.6之前的synchronized是重量级锁。如下图所示:
然后有位纽约州立大学的教授叫Doug Lea看到jdk自带的synchronized性能比较低,于是他利用纯Java语言实现了基于AQS的ReentrantLock锁(底层当然也调用了底层的语言),如下图所示,可以说ReentrantLock锁的出现完全是为了弥补synchronized锁的各种不足。
由于synchronized锁性能严重不足,所以oracle官方在jdk1.6之后对synchronized锁进行了升级,如上图所示的锁升级的整个过程。
所以就有了以下的这些名词:
无锁
无锁没有对资源进行锁定,所有的线程都能访问并修改同一个资源,但同时只有一个线程能修改成功,其底层是通过CAS实现的。无锁无法全方位代替有锁,但无锁在某些场合下的性能是非常高的。
偏向锁(无锁 -> 偏向锁)
偏向锁的“偏”,就是偏心的“偏”、偏袒的“偏”,它的意思是这个锁会偏向于第一个获得它的线程,会在对象头存储锁偏向的线程ID,以后该线程进入和退出同步块时只需要检查是否为偏向锁、锁标志位以及 ThreadID即可。
一开始无锁状态,JVM会默认开启“匿名”偏向的一个状态,就是一开始线程还未持有锁的时候,就预先设置一个匿名偏向锁,等一个线程持有锁之后,就会利用CAS操作将线程ID设置到对象的mark word 的高23位上【32位虚拟机】,下次线程若再次争抢锁资源的时,多线程竞争的情况下尽量减少不必要的轻量级锁执行路径,因为轻量级锁的获取及释放依赖多次CAS原子指令,只需要在置换ThreadID的时候依赖一次CAS原子指令即可。
轻量级锁(偏向锁 -> 轻量锁)
当线程交替执行同步代码块时,且竞争不激烈的情况下,偏向锁就会升级为轻量级锁。
在大多数情况下,锁总是由同一线程多次获得,不存在多线程竞争,所以出现了偏向锁。
其目标就是在只有一个线程执行同步代码块时能够提高性能。
当一个线程访问同步代码块并获取锁时,会在Mark Word里存储锁偏向的线程ID。
在线程进入和退出同步块时不再通过CAS操作来加锁和解锁,而是检测Mark Word里是否存储着指向当前线程的偏向锁。
引入偏向锁是为了在无多线程竞争的情况下尽量减少不必要的轻量级锁执行路径,因为轻量级锁的获取及释放依赖多次CAS原子指令,而偏向锁只需要在置换ThreadID的时候依赖一次CAS原子指令即可。
撤销偏向锁后恢复到无锁(标志位为“01”)或轻量级锁(标志位为“00”)的状态。
自旋锁
在很多场景下,共享资源的锁定状态只会持续很短的一段时间,为了这段时间阻塞和唤醒线程并不值得。
如果物理机器有一个以上的处理器,能让两个或以上的线程同时并行执行,我们就可以让后面请求锁的那个线程“稍等一下”,但不放弃处理器的执行时间,看看持有锁的线程是否很快就会释放锁。
为了让线程等待,我们只需让线程执行一个忙循环(自旋) , 这就是自旋锁。
当一个线程t1、t2同事争抢同一把锁时,假如t1线程先抢到锁,锁不会立马升级成重量级锁,此时t2线程会自旋几次(默认自旋次数是10次,可以使用参数-XX : PreBlockSpin来更改),若t2自旋超过了最大自旋次数,那么t2就会当使用传统的方式去挂起线程了,锁也升级为重量级锁了。
自旋的等待不能代替阻塞,暂且不说对处理器数量的要求必须要两个核,自旋等待本身虽然避免了线程切换的开销,但它是要占用处理器时间的,所以如果锁被占用的时间很短,自旋等待的效果就会非常好,如果锁被占用的时间很长,那自旋的线程只会消耗处理器资源,而不会做任何有用的工作,反而会带来性能上的浪费。
自旋锁在jdk1.4中就已经引入,只不过默认是关闭的,可以使用-XX:+UseSpinning参数来开启,在jdk1.6之后自旋锁就已经默认是打开状态了。
重量级锁
升级为重量级锁时,锁标志的状态值变为“10”,此时Mark Word中存储的是指向重量级锁的指针,此时等待锁的线程都会进入阻塞状态。
锁消除
锁消除是指虚拟机即时编译器(JIT)在运行时,对一些代码上要求同步,但是被检测到不可能存在共享数据竞争的锁进行消除。锁消除的主要判定依据来源于逃逸分析的数据支持,如果判断在一段代码中,堆上的所有数据都不会逃逸出去从而被其他线程访问到,那就可以把它们当做栈上数据对待,认为它们是线程私有的,同步加锁自然就无须进行。
StringBuffer的append()是一个同步方法,锁就是this也就是sb对象。虚拟机发现它的动态作用域被限制在stringContact()方法内部。
也就是说, sb对象的引用永远不会“逃逸”到stringContact()方法之外,其他线程无法访问到它,因此,虽然这里有锁,但是可以被安全地消除掉,在即时编译之后,这段代码就会忽略掉所有的同步而直接执行了。
这里顺便说一个小的JVM知识点——“对象的逃逸分析”:就是分析对象动态作用域,当一个对象在方法中被定义后,它可能被外部方法所引用,例如作为调用参数传递到其他地方中。JVM通过逃逸分析确定该对象不会被外部访问。如果不会逃逸可以将该对象优先在栈上分配内存,这样该对象所占用的内存空间就可以随栈帧出栈而销毁,就减轻了垃圾回收的压力。
上面sb对象的就是不会逃逸出方法stringContact(),所以sb对象有可能优先分配在线程栈中,只是有可能哟,这里点到为止,需要了解可以自行学习哟~
锁粗化
JVM会探测到一连串细小的操作都使用同一个对象加锁,将同步代码块的范围放大,放到这串操作的外面,这样只需要加一次锁即可。
可以通过下面的例子来看一下:
StringBuffer的append()是一个同步方法,通过上面的代码可以看出,每次循环都要给append()方法加锁,这时系统会通过判断将其修改为下面这种,直接将原append()方法的synchronized的锁给去掉直接加在了for循环外。
通过对象头分析锁升级过程
可以通过对象头分析工具观察一下锁升级时对象头的变化:运行时对象头锁状态分析工具JOL,是OpenJDK开源工具包,引入下方maven依赖:
观察无锁状态下的对象头【无锁状态】:
运行结果:
这里先详细解释一下打印结果,后面就不做详细分析了:
OFFSET : 内存地址偏移量
SIZE : 本条信息对应的字节大小
Instance size: 16 bytes :本次new出的Object对象的大小
由于当前所使用的的机器是64位操作系统的机器,所以前两行代表的就是对象头MarkWord,已经在上述运行结果中标出,刚好是8字节,每个字节8位,刚好是64位;由上文中32位对象头与64位对象头的位数对比可知,分析对象头锁升级情况看第一行的对象头即可。
第三行指的是类型指针(上文中有说过,指向的是方法区的类元信息),已经在上述运行结果中标出,Klass Pointer在64位机器默认是8字节,这里由于指针压缩的原因当前是4字节。
第四行指的是对齐填充,有的时候有有的时候没有,JVM内部需要保证对象大小是8个字节的整数倍,实际上计算机底层通过大量计算得出对象时8字节的整数倍可以提高对象存储的效率。
可以观察到本次new出的Object对象的大小实际只有12字节,这里对象填充为其填充了4个字节,就是为了让Object对象大小为16字节是8字节的整数倍。
JVM采用的是小端模式,需要现将其转换成大端模式,具体转换如下图所示:
可以看出一开始对象没有加锁,通过最后三位的“001”也能观察到,前25位代表hashcode,那这里为什么前25位是0呢?
其实hashcode是通过C语言类似于“懒加载”的方式获取到的,所以看到该对象的高25位并没有hashcode。
观察有锁无竞争状态下的对象头【无锁->偏向锁】:
运行结果(JVM默认小端模式):
运行结果分析:
通过运行结果可以看到,先打印出来的是一个“001”无锁的状态,但是后打印出来的“000”并不是偏向锁的状态,查上面的表可以发现“000”直接就是轻量级锁的状态了。
JVM启动的时候内部实际上也是有很多个线程在执行synchronized,JVM就是为了避免无畏的锁升级过程(偏向锁->轻量级锁->重量级锁)带来的性能开销,所以JVM默认状态下会延迟启动偏向锁。
只要将代码前面加个延迟时间即可观察到偏向锁:
运行结果(JVM默认小端模式):
对未开启偏向锁与开启偏向锁的运行结果分析:
开启偏向锁之后的无锁状态,会加上一个偏向锁,叫匿名偏向(可偏向状态),表示该对象锁是可以加偏向锁的,从高23位的23个0可以看出暂时还没有偏向任何一个线程,代表已经做好了偏向的准备,就等着接下来的某个线程能拿到就直接利用CAS操作把线程id记录在高23位的位置。
观察有锁有竞争状态下的对象头【偏向锁->轻量级锁】:
运行结果(JVM默认小端模式):
运行结果分析:
一开始main线程打印出的object对象头可以看出是匿名偏向;
接着线程t1打印了object对象头,可以与第一个打印出来的对象头对比不难发现t1打印的也是偏向锁,但是t1打印的对象头已经把t1的线程id记录在了其对应的23位;
程序再次回到main线程,其还是打印出来刚刚t1的对象头数据,也就是说偏向锁一旦偏向了某个线程后,如果线程不能重新偏向的话,那么这个偏向锁还是会一直记录着之前偏向的那个线程的对象头状态;
接着线程t2又开始打印了object对象头,可以看出最后一次打印已经升级成了轻量级锁,因为这里已经存在两个线程t1、t2交替进入了object对象锁的同步代码块,并且锁的不激烈竞争,所以锁已经升级成了轻量级锁。
观察无锁升级成重量级锁状态下的对象头的整个过程【无锁->重量级锁】:
运行结果:
运行结果分析(JVM默认小端模式):
程序一开始就是设置了5秒钟的睡眠,目的在于让JVM优先加载完成后,让JVM默认状态下会延迟启动偏向锁,可以开出一开始main线程打印的是“101”就是默认的匿名偏向锁,但是并没有设置线程id;之后t0线程就立马打印了,此时只需利用CAS操作把t0的线程id设置进对象头即可,所以这个时候也是一个偏向锁状态;之后的程序睡眠5秒钟后,程序中t1、t2线程执行代码块时,有意的将其线程睡眠几秒钟,目的在于不管那个线程率先抢到锁,都能让另外一个线程在自旋等待中,所以t1线程打印的是“00”就已经是轻量级锁了,最后看程序执行结果,t2打印的是“10”就已经升级为重量级锁了,显然t2线程已经超过了自旋的最大次数,已经转成重量级锁了。
总结
那平时写代码如何对synchronized优化呢?
- 减少synchronized的范围,同步代码块中尽量短,减少同步代码块中代码的执行时间,减少锁的竞争。
- 降低synchronized锁的粒度,将一个锁拆分为多个锁提高并发度。这点其实可以参考HashTable与ConcurrentHashMap的底层原理。
HashTable加锁实际上锁的是整个hash表,一个操作进行的时候,其他操作都无法进行了。
然而ConcurrentHashMap是局部锁定,锁得并不是一整张表,ConcurrentHashMap锁得是一个segment,当前的segment被锁了,不影响其他segment的操作。