JUC并发编程之Synchronized关键字详解

共 15834字,需浏览 32分钟

 ·

2021-07-03 16:43

点击上方蓝字 关注我吧



1
设计同步器的意义

在多线程中,有可能会出现多个线程同时访问同一个共享、可变资源的情况,这个资源我们称之其为临界资源;这种资源可能是:对象、变量、文件等。
共享:资源可以由多个线程同时访问
可变:资源可以在其生命周期内被修改
根据这段话引出来一个问题,由于线程在CPU中执行的过程是不可控的,所以我们需要采取同步机制来协同对对象的可变状态的访问。


2
什么是Synchronized


synchronized关键字,它是java内置锁,在程序界中俗称同步,在多线程下能够保证线程安全问题,是解决高并发的一种解决方案。其实synchronized在1.6这个临界版本有一段故事的,在1.6版本前synchronized的效率是非常低的,因为在多线程下无论线程竞争锁是否激烈它都会创建重量级锁,直接对CPU操作是比较浪费时间的,因此国外的一位并发大佬"Doug Lea",觉得这个同步锁很low太浪费程序资源了,所以它自己独自开发了一套并发框架"AQS",其中ReenLock锁就是代替synchronized的,因此在1.5版本后,开发synchronized团队的技术人员对该关键字进行了一个较大的优化,后续文章中会讲到优化的几点,后来ReenLock和synchronized两者的性能都相差不大,因此我们在程序中这两个锁也都用的比较多。


对了,后续我也会对ReenLock锁进行一个源码分析,大家伙可以敬请期待。



3
Synchronized原理详解


synchronized内置锁是一种对象锁(锁的是对象而非引用),作用粒度是对象,可以用来实现对临界资源的同步互斥访问,是可重入的。
它的加锁方式分为以下几种:

第一种方式:静态方法上加同步块(demo比较简陋,且锁在哪里代码中也详细说到了)
public class StaticTest01 {    /**     * 静态方法加锁,它的锁就是加在 StaticTest01.class 上     * 因为是静态方法,所以是通过类进行调用的,那么锁就加在类上面     */    public synchronized static void decrStock() {        System.out.println("上锁");    }}


第二种方式:非静态方法上加同步块(demo比较简陋,且锁在哪里代码中也详细说到了)
public class StaticTest02 {    /**     * 非静态方法加锁,因为非静态,所以需要进行 new对象,然后才能使用该方法     * 那么锁就是加在 new 对象的这个对象上,例如:StaticTest02 syn = new StaticTest02();     * 那么锁就是加在 syn 这个对象上     */    public synchronized void decrStock() {        System.out.println("上锁");    }}


第三种方式:方法内的代码块加同步块(demo比较简陋,且锁在哪里代码中也详细说到了)
public class StaticTest03 {    private static Object object = new Object();     /**     * 非静态方法代码块加锁,那么锁就是加在 object 这个成员变量上     * 可以针对一部分代码块,而非整个方法     */    public void decrStock() {        synchronized (object) {            System.out.println("上锁");        }    }}


Synchronized底层原理
synchronized是基于JVM内置锁实现,通过内部对象Monitor(监视器锁)实现,基于进入与退出Monitor对象实现方法与代码块同步,监视器锁的实现依赖底层操作系统的Mutex lock(互斥锁)实现,它是一个重量级锁性能较低。当然,JVM内置锁在1.5之后版本做了重大的优化,如锁粗化(Lock Coarsening)、锁消除(Lock Elimination)、轻量级锁(Lightweight Locking)、偏向锁(Biased Locking)、适应性自旋(Adaptive Spinning)等技术来减少锁操作的开销。


需要注意的是:synchronized关键字被编译成字节码后会被翻译成monitorenter monitorexit 两条指令分别在同步块逻辑代码的起始位置与结束位置。


synchronized在JVM里的实现都是 基于进入和退出Monitor对象来实现方法同步和代码块同步,虽然具体实现细节不一样,但是都可以通过成对的MonitorEnter和MonitorExit指令来实现。


基于字节码文件,来看看同步块代码与同步方法它们之间的区别

同步块代码:
public class StaticTest03 {    private static Object object = new Object();    /**     * 非静态方法代码块加锁,那么锁就是加在 object 这个成员变量上     * 只不过代码块,可以针对一部分代码块,而非整个方法     */    public void decrStock() {        synchronized (object) {            System.out.println("上锁");        }    }}


反编译后的结果


看到我上图中所标记的三个方块,分别是对对象上锁和两次释放锁操作,来详细分析它们是什么。
每个对象有一个监视器锁(monitor)。当monitor被占用时就会处于锁定状态,线程执行monitorenter指令时尝试获取monitor的所有权,过程如下:
1.monitorenter
1.如果monitor的进入数为0,则该线程进入monitor,然后将进入数设置为1,该线程即为monitor的所有者;
2.如果线程已经占有该monitor,只是重新进入,则进入monitor的进入数加1;
3.如果其他线程已经占用了monitor,则该线程进入阻塞状态,直到monitor的进入数为0,再重新尝试获取monitor的所有权;
2.monitorexit
1.执行monitorexit的线程必须是objectref所对应的monitor的所有者。
2.指令执行时,monitor的进入数减1,如果减1后进入数为0,那线程退出monitor,不再是这个monitor的所有者。其他被这个monitor阻塞的线程可以尝试去获取这个 monitor 的所有权。


monitorexit,指令出现了两次,第1次为同步正常退出释放锁;第2次为发生异步退出释放锁


通过上面两段描述,我们应该能很清楚的看出Synchronized的实现原理,Synchronized的语义底层是通过一个monitor的对象来完成,其实wait/notify等方法也依赖于monitor对象,这就是为什么只有在同步的块或者方法中才能调用wait/notify等方法,否则会抛出java.lang.IllegalMonitorStateException的异常的原因


接着来看同步方法:

public class StaticTest02 {    /**     * 非静态方法加锁,因为非静态,所以需要进行 new对象,然后才能使用该方法     * 那么锁就是加在 new 对象的 这个 对象上,例如:StaticTest02 syn = new StaticTest02();     * 那么锁就是加在 syn 这个对象上     */    public synchronized void decrStock() {        System.out.println("上锁");    }}


反编译后的结果


从上图编译的结果来看,方法的同步并没有通过指令 monitorentermonitorexit 来完成(理论上其实也可以通过这两条指令来实现),不过相对于普通方法,其常量池中多了 ACC_SYNCHRONIZED 标示符。JVM就是根据该标示符来实现方法的同步的


当方法调用时,调用指令将会检查方法的 ACC_SYNCHRONIZED 访问标志是否被设置,如果设置了,执行线程将先获取monitor,获取成功之后才能执行方法体,方法执行完后再释放monitor。在方法执行期间,其他任何线程都无法再获得同一个monitor对象。


两种同步方式本质上没有区别,只是方法的同步是一种隐式的方式来实现,无需通过字节码来完成。两个指令的执行是JVM通过调用操作系统的互斥原语mutex来实现,被阻塞的线程会被挂起、等待重新调度,会导致“用户态和内核态”两个态之间来回切换,对性能有较大影响。


我们前面也有说到,同步锁它是加在对象上的,那他在对象里面是如何存储的呢?来分析分析


HotSpot虚拟机中,对象在内存中存储的布局可以分为三块区域:对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)。

1.对象头:比如 hash码,对象所属的年代,对象锁,锁状态标志,偏向锁(线程)ID,偏向时间,数组长度(数组对象)等。Java对象头一般占有2个机器码(在32位虚拟机中,1个机器码等于4字节,也就是32bit,在64位虚拟机中,1个机器码是8个字节,也就是64bit),但是如果对象是数组类型,则需要3个机器码,因为JVM虚拟机可以通过Java对象的元数据信息确定Java对象的大小,但是无法从数组的元数据来确认数组的大小,所以用一块来记录数组长度。
2.实例数据:存放类的属性数据信息,包括父类的属性信息;
3.对齐填充:由于虚拟机要求 对象起始地址必须是8字节的整数倍。填充数据不是必须存在的,仅仅是为了字节对齐;


但看这段文字估计还有点懵,我在这里放上一张图片。


synchronized在jdk1.6版本后,在其内部加了偏向锁、轻量锁等锁技术,那么这些锁它们是怎么进化的呢?又或者说这些锁的信息都存放在对象哪里呢?来,往下看。


前面也说到了对象的组成部分,结合上图进行分析,在HotSpot虚拟机的对象头包括两部分信息,第一部分是“Mark Word”,用于存储对象自身的运行时数据, 如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等等,它是实现轻量级锁和偏向锁的关键,这部分数据的长度在32位和64位的虚拟机(暂不考虑开启压缩指针的场景)中分别为32个和64个Bits,为了节省内存,如果我们机器是64位系统,则jvm会自动开启指针压缩,将它压缩成32位,所以本文就基于32位来进行分析。


再继续放上,基于32位虚拟机的一个对象头表格

锁状态
25bit
4bit
1bit
2bit
23bit
2bit
是否偏向锁(是否禁用偏向)
锁标志位
无锁态
对象的hashCode
分代年龄
0
01
轻量级锁
指向栈中锁记录的指针
00
重量级锁
指向Monitor的指针
10
GC标记
11
偏向锁
线程ID
Epoch
分代年龄
1
01



在32位的HotSpot虚拟机中,对象未被锁定的状态下,Mark Word的32个Bits空间中的25Bits用于存储对象哈希码(HashCode),4Bits用于存储对象分代年龄,1Bit固定为0,2Bits用于存储锁标志位,在其他状态(轻量级锁定、重量级锁定、GC标记、可偏向)下对象的存储内容如上表所示。


注意:对象头信息是与对象自身定义的数据无关的额外存储成本,但是考虑到虚拟机的空间效率,Mark Word被设计成一个非固定的数据结构以便在极小的空间内存存储尽量多的数据,它会根据对象的状态复用自己的存储空间,也就是说,Mark Word会随着程序的运行发生变化


说到这,前面我有说到jvm它默认开启了指针压缩,其实我们也可以手动将其关闭,主要看场景决定吧

手动设置-XX:+UseCompressedOops


那么哪些信息会被压缩呢?
1.对象的全局静态变量(即类属性)
2.对象头信息:64位平台下,原生对象头大小为16字节,压缩后为12字节
3.对象的引用类型:64位平台下,引用类型本身大小为8字节,压缩后为4字节
4.对象数组类型:64位平台下,数组类型本身大小为24字节,压缩后16字节


有了以上内容的铺垫,我们就可以来聊一聊偏向锁、轻量级锁、自旋锁,它们是什么东西,然后再来分析它们在对象头中产生了什么样的差异。

偏向锁:
偏向锁是Java 6之后加入的新锁,它是一种针对加锁操作的优化手段,经过研究发现,在大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,因此为了减少同一线程获取锁(会涉及到一些CAS操作,耗时)的代价而引入偏向锁。偏向锁的核心思想是,如果一个线程获得了锁,那么锁就进入偏向模式,此时Mark Word 的结构也变为偏向锁结构,当这个线程再次请求锁时,无需再做任何同步操作,即获取锁的过程,这样就省去了大量有关锁申请的操作,从而也就提供程序的性能。所以,对于没有锁竞争的场合,偏向锁有很好的优化效果,毕竟极有可能连续多次是同一个线程申请相同的锁。但是对于锁竞争比较激烈的场合,偏向锁就失效了,因为这样场合极有可能每次申请锁的线程都是不相同的,因此这种场合下不应该使用偏向锁,否则会得不偿失,需要注意的是,偏向锁失败后,并不会立即膨胀为重量级锁,而是先升级为轻量级锁。下面我们接着了解轻量级锁。(偏向锁一般只有一个线程访问,超过一个线程以上则会晋升为轻量级锁,注意偏向锁在JVM启动后4秒才会生效,因为jvm底层默认会启动多个线程,也就是底层启动执行的方法有synchronized方法,内部会存在CPU竞争行为,所以为了效率原因(去掉偏向锁转到轻量级锁的过程),后4秒才启动偏向锁)主要也是为了提高效率


轻量级锁

倘若偏向锁失败,虚拟机并不会立即升级为重量级锁,它还会尝试使用一种称为轻量级锁的优化手段(1.6之后加入的),此时Mark Word 的结构也变为轻量级锁的结构。轻量级锁能够提升程序性能的依据是“对绝大部分的锁,在整个同步周期内都不存在竞争”,注意这是经验数据。需要了解的是,轻量级锁所适应的场景是线程交替执行同步块的场合,如果存在同一时间访问同一锁的场合,就会导致轻量级锁膨胀为重量级锁。(超过两个以上的线程访问,轻量级锁会晋升到重量级锁)


自旋锁

轻量级锁失败后,虚拟机为了避免线程真实地在操作系统层面挂起,还会进行一项称为自旋锁的优化手段。这是基于在大多数情况下,线程持有锁的时间都不会太长,如果直接挂起操作系统层面的线程可能会得不偿失,毕竟操作系统实现线程之间的切换时需要从用户态转换到核心态,这个状态之间的转换需要相对比较长的时间,时间成本相对较高,因此自旋锁会假设在不久将来,当前的线程可以获得锁,因此虚拟机会让当前想要获取锁的线程做几个空循环(这也是称为自旋的原因),一般不会太久,可能是50个循环或100循环,在经过若干次循环后,如果得到锁,就顺利进入临界区。如果还不能获得锁,那就会将线程在操作系统层面挂起,这就是自旋锁的优化方式,这种方式确实也是可以提升效率的。最后没办法也就只能升级为重量级锁了。(自旋锁是在重量级锁中的,重量级锁性能开销会很大,所以一般多线程部分业务执行会很快,CPU没必要释放,所以弄个空循环占用CPU执行,如果超过自旋锁的次数后,就退出挂着,等待锁的释放,在进行抢占锁资源)


锁的对象头分析:

分析对象头我们需要引入以下依赖,该依赖是OpenJdk开源的工具包,用于分析对象头锁(从对象头中分析锁的晋升过程)
<dependency>    <groupId>org.openjdk.jol</groupId>    <artifactId>jol-core</artifactId>    <version>0.10</version></dependency>


如下这段代码,通过开源的工具,将无锁对象的一些对象头信息打印出来
public class StaticTest04 {    public static void main(String[] args) {        Object o = new Object();        System.out.println(ClassLayout.parseInstance(o).toPrintable());    }}


运行结果图


上图中,有很多信息,而属于对象头中的mark word,就在我标记的这块区域中,在操作系统中分为了大端和小端,而在大部分操作系统中都是小端,而通讯协议是大端,所以我们查看的数据要倒序查看。


以上图为例,将信息头反过来后,我们结合上面的表格查看,在最后的两位是 "01" ,是01则就是无所状态的标识

//对象头信息00000001 00000000 00000000 00000000//将信息返回来后00000000 00000000 00000000 00000001


接着,我们在这段代码的基础上,在加一个同步块操作,锁的对象则是方法中的局部变量,然后再来看看效果
public class StaticTest04 {    public static void main(String[] args) {        Object o = new Object();        System.out.println(ClassLayout.parseInstance(o).toPrintable());        synchronized (o) {            System.out.println(ClassLayout.parseInstance(o).toPrintable());        }    }}


运行结果图


上图我输出了两次该对象头的信息,第一次无锁状态是 "01" 这个是没问题,奇怪的是,第二次输出的对象头,它居然是 "00",结合上面表格不就是轻量级锁了么?根据前面我锁讲的锁晋升,当只有一个线程访问时,就进入偏向锁,怎么程序中输出的对象头信息是轻量级锁呢?因为偏向锁是在jvm启动的4秒后生效,因为jvm在刚开始启动的时候,会有多个线程进行初始化数据,放了防止一开始就造成锁的膨胀,所以jvm延缓了偏向锁的使用。
//加同步块后对象头信息01001000 11110010 11001110 00000010//倒序转换后的对象头信息00000010 11001110 11110010 01001000 


根据以上这段话的分析,那我们是不是可以在程序启动后,先让线程暂停5秒,然后再让它去执行同步块操作,然后再看看我的这段话是否说的正确
public class StaticTest04 {    public static void main(String[] args) throws InterruptedException {        TimeUnit.SECONDS.sleep(5);        Object o = new Object();        System.out.println(ClassLayout.parseInstance(o).toPrintable());        synchronized (o) {            System.out.println(ClassLayout.parseInstance(o).toPrintable());        }    }}


运行结果图


当我们将程序暂停5秒后,再去查看对象头信息,发现怎么对象头又变成 "01" 无锁状态了呢?我们再看到表格中,有一个1bit是专门标识该对象是无锁还是偏向锁,0为无锁,1为偏向锁,那我们就直接看后三位啦,下面的后三位是不是101,那么101对着表格不就是我们的偏向锁了么。
//加同步块后对象头信息00000101 01010000 01101110 00000011//倒序转换后的对象头信息00000011 01101110 01010000 00000101 


接下来演示,从无锁状态的对象 晋升为偏向锁 然后晋升为轻量级锁,看下方这段代码
@Slf4jpublic class StaticTest05 {    public static void main(String[] args) throws InterruptedException {        TimeUnit.SECONDS.sleep(5);        Object o = new Object();        log.info(ClassLayout.parseInstance(o).toPrintable());        new Thread(() -> {            synchronized (o){                log.info(ClassLayout.parseInstance(o).toPrintable());            }        }).start();        TimeUnit.SECONDS.sleep(2);        new Thread(() -> {            synchronized (o){                log.info(ClassLayout.parseInstance(o).toPrintable());            }        }).start();    }}


运行结果图


从上图中,首先我们看到主线程和第一个线程打印的对象头信息,它们的头信息都是 "101" 为偏向锁,因为该对象现在还只被一个线程所占用(主线程没加锁打印,所以不算),然后接着第二个线程也对该对象进行加锁,实现同步块代码操作,那么这个时候就存在多个线程进行访问锁对象,从而将锁的等级从偏向锁晋升为轻量级锁啦


最后再来看看,如何晋升成的重量级锁的,先看代码

@Slf4jpublic class StaticTest06 {    public static void main(String[] args) throws InterruptedException {        TimeUnit.SECONDS.sleep(5);        Object o = new Object();        Thread threadA = new Thread(() -> {            synchronized (o) {                log.info(ClassLayout.parseInstance(o).toPrintable());                try {                    //让线程晚点死亡                    TimeUnit.SECONDS.sleep(2);                } catch (InterruptedException e) {                    e.printStackTrace();                }            }        });        Thread threadB = new Thread(() -> {            synchronized (o) {                log.info(ClassLayout.parseInstance(o).toPrintable());                try {                    //让线程晚点死亡                    TimeUnit.SECONDS.sleep(2);                } catch (InterruptedException e) {                    e.printStackTrace();                }            }        });        //两个线程同时启动,模拟并发同时请求        threadA.start();        threadB.start();    }}


运行结果图,从图中我们看到,我们的对象头中的锁是不是已经成了重量级锁了,那么再来看看这段代码它是怎么模拟的,首先我们需要知道重量级锁它是在锁竞争非常激烈的时候才成为的,这段代码模拟的是,我启动两个线程,第一个线程对对象进行加锁,然后睡眠两秒,模拟程序在处理业务,然后第二个线程一直在等待第一个线程释放锁,在等待的过程中,会触发自旋锁,如果自旋锁达到了阈值,则会直接让第二个线程进行阻塞,从而线程2晋升为重量级锁


这以上这几段代码,我们是否就能够了解到,锁是如何晋升的,以及也验证了上面我介绍了几个锁的特性(什么时机进行触发)。


当然synchronized在1.6版本优化中还加了两个细节点的优化,例如锁粗化、锁消除这两个点。


锁粗化:

通常情况下,为了保证多线程间的有效并发,会要求每个线程持有锁的时间尽可能短,但是某些情况下,一个程序对同一个锁不间断、高频地请求、同步与释放,会消耗掉一定的系统资源,因为锁的请求、同步与释放本身会带来性能损耗,这样高频的锁请求就反而不利于系统性能的优化了,虽然单次同步操作的时间可能很短。锁粗化就是告诉我们任何事情都有个度,有些情况下我们反而希望把很多次锁的请求合并成一个请求,以降低短时间内大量锁请求、同步、释放带来的性能损耗。


例如以下这段代码的极端情况

public class Test06 {    public static void main(String[] args) {        Object o = new Object();        synchronized (o) {            //业务逻辑处理            System.out.println("锁粗化1");        }        synchronized (o) {            //业务逻辑处理            System.out.println("锁粗化2");        }        synchronized (o) {            //业务逻辑处理            System.out.println("锁粗化3");        }    }}


上面的代码是有三块需要同步操作的,但在这三块需要同步操作的代码之间,需要做业务逻辑的工作,而这些工作只会花费很少的时间,那么我们就可以把这些工作代码放入锁内,将三个同步代码块合并成一个,以降低多次锁请求、同步、释放带来的系统性能消耗,合并后的代码如下:

public class Test06 {    public static void main(String[] args) {        Object o = new Object();        synchronized (o) {            //业务逻辑处理            System.out.println("锁粗化1");            //业务逻辑处理            System.out.println("锁粗化2");            //业务逻辑处理            System.out.println("锁粗化3");        }    }}


锁消除
消除锁是虚拟机另外一种锁的优化,这种优化更彻底,Java虚拟机在JIT编译时(可以简单理解为当某段代码即将第一次被执行时进行编译,又称即时编译),通过对运行上下文的扫描,去除不可能存在共享资源竞争的锁,通过这种方式消除没有必要的锁,可以节省毫无意义的请求锁时间。锁消除的依据是逃逸分析的数据支持。

例如下面这段代码
public class Test07 {    public static void main(String[] args) {        method();    }    public static void method() {        Object o = new Object();        synchronized (o) {            System.out.println("锁消除");        }    }}


先简单说一下逃逸分析是什么?然后再来分析上面这段就好理解啦
逃逸分析:
逃逸分析是jvm的一种优化机制,它是默认开启的,我们知道线程都有属于自己的栈帧,在java中我们所创建对象都是存放在堆中,当线程用完该对象之后,该对象不会立刻被回收掉,而是当堆空间满了,才会被垃圾回收器进行回收,然而我们开启逃逸分析后,当线程调用一个方法,会进行分析该方法内的对象是否会被其他线程所共享,如果不会则创建对象将存储在自己线程的栈帧中,而不会存储在堆中,这样当线程调用结束对象也会随着线程结束一同销毁掉,这样就减少了gc回收次数了,提高的程序的性能。


分析上面这段代码,前面说到过锁消除的依据是逃逸分析,当线程在调用我们的方法的时候,会对该方法进行逃逸分析,发现该方法里的对象不会被其他线程所共享,那么它会认为在里面进行加synchronized没有任何用处,所以最后会底层会将进行优化,将synchronized进行删除。那么这就是锁消除啦。

我是黎明大大,我知道我没有惊世的才华,也没有超于凡人的能力,但毕竟我还有一个不屈服,敢于选择向命运冲锋的灵魂,和一个就是伤痕累累也要义无反顾走下去的心。


如果您觉得本文对您有帮助,还请关注点赞一波,后期将不间断更新更多技术文章


扫描二维码关注我
不定期更新技术文章哦



JUC并发编程之MESI缓存一致协议详解

JUC并发编程之Volatile关键字详解

JUC并发编程之JMM内存模型详解

深入Hotspot源码与Linux内核理解NIO与Epoll

基于Python爬虫爬取有道翻译实现翻译功能

JAVA集合之ArrayList源码分析

Mysql几种join连接算法



发现“在看”和“赞”了吗,因为你的点赞,让我元气满满哦
浏览 53
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报
评论
图片
表情
推荐
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报