zoukankan      html  css  js  c++  java
  • 深入理解synchronized

    上一篇博客虽然题目叫内置锁的基本使用,但其实也是讲synchronized关键字的使用的。这篇博客是在看了许多大佬的博客记录后总结出的synchronized更底层的知识和原理。

    一、synchronized的原理

    同步块的monitor指令

    我们先通过反编译下面的代码来看看Synchronized是如何实现对代码块进行同步的:

    public class SynchronizedDemo {
        public void method() {
            synchronized (this) {
                System.out.println("Method 1 start");
            }
        }
    }

    反编译结果:

     关于这两条指令的作用,我们直接参考JVM规范中描述:(翻译成中文)

    monitorenter指令:

    每个对象有一个监视器锁(monitor)。当monitor被占用时就会处于锁定状态,线程执行monitorenter指令时尝试获取monitor的所有权,过程如下:

    1、如果monitor的进入数为0,则该线程进入monitor,然后将进入数设置为1,该线程即为monitor的所有者。

    2、如果线程已经占有该monitor,只是重新进入,则进入monitor的进入数加1.

    3.如果其他线程已经占用了monitor,则该线程进入阻塞状态,直到monitor的进入数为0,再重新尝试获取monitor的所有权。

    monitorexit指令:

    执行monitorexit的线程必须是objectref所对应的monitor的所有者。

    指令执行时,monitor的进入数减1,如果减1后进入数为0,那线程退出monitor,不再是这个monitor的所有者。其他被这个monitor阻塞的线程可以尝试去获取这个 monitor 的所有权。 

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

    同步方法的同步标识符

    再来看看同步方法的标识符,就是用synchronized修饰的方法,看例子:

    public class SynchronizedMethod {
        public synchronized void method() {
            System.out.println("Hello World!");
        }
    }

    反编译结果:

    这里就没看到刚刚的monitorenter等字节码指令了。

    不过相对于普通方法,其常量池中多了ACC_SYNCHRONIZED标示符。JVM就是根据该标示符来实现方法的同步的:当方法调用时,调用指令将会检查方法的 ACC_SYNCHRONIZED 访问标志是否被设置,如果设置了,执行线程将先获取monitor,获取成功之后才能执行方法体,方法执行完后再释放monitor。在方法执行期间,其他任何线程都无法再获得同一个monitor对象。 其实本质上没有区别,只是方法的同步是一种隐式的方式来实现,无需通过字节码来完成。

    下图表现了对象,对象监视器,同步队列以及执行线程状态之间的关系:

    该图可以看出,任意线程对Object的访问,首先要获得Object的监视器,如果获取失败,该线程就进入同步状态,线程状态变为BLOCKED,当Object的监视器占有者释放后,在同步队列中得线程就会有机会重新获取该监视器。(注意和wait区分下哦,wait后也是阻塞,但在没有相关锁对象notify的情况下是没有机会重新获取该监视器的哦)

    二、synchronized天生是可重入的锁

    啥是可重入锁

    广义上的可重入锁指的是可重复可递归调用的锁,在外层使用锁之后,在内层仍然可以使用,并且不发生死锁(前提得是同一个对象或者class),这样的锁就叫做可重入锁。ReentrantLock和synchronized都是可重入锁。

    synchronized是可重入的

    来看个简单的例子理解下:

    public class SynchronizedDemo {
        public static void main(String[] args) {
            synchronized (SynchronizedDemo.class) {
                method();
            }
            
        }
    
        private static synchronized void method() {
        }
    }

    上面的demo中在执行完同步代码块之后紧接着再会去执行一个静态同步方法,而这个方法锁的对象依然就这个类对象,那么这个正在执行的线程还需要获取该锁吗?答案是不必的,从上图中就可以看出来,执行静态同步方法的时候就只有一条monitorexit指令,并没有monitorenter获取锁的指令。这就是锁的重入性,即在同一锁程中,线程不需要再次获取同一把锁。Synchronized先天具有重入性。

    synchronized拥有强制原子性的内部锁机制,是一个可重入锁。因此,在一个线程使用synchronized方法时调用该对象另一个synchronized方法,即一个线程得到一个对象锁后再次请求该对象锁,是永远可以拿到锁的。
    在Java内部,同一个线程调用自己类中其他synchronized方法/块时不会阻碍该线程的执行,同一个线程对同一个对象锁(类锁也是)是可重入的,同一个线程可以获取同一把锁多次,也就是可以多次重入。原因是Java中线程获得对象锁的操作是以线程为单位的,而不是以调用为单位的。

     之前谈到过,每个锁关联一个线程持有者和一个计数器。当计数器为0时表示该锁没有被任何线程持有,那么任何线程都都可能获得该锁而调用相应方法。当一个线程请求成功后,JVM会记下持有锁的线程,并将计数器计为1。此时其他线程请求该锁,则必须等待。而该持有锁的线程如果再次请求这个锁,就可以再次拿到这个锁,同时计数器会递增。当线程退出一个synchronized方法/块时,计数器会递减,如果计数器为0则释放该锁。

    三、synchronized的底层优化

    3.1 锁(对象)的状态

    看过《深入理解Java虚拟机》的同学应该知道,在HotSpot虚拟机中,对象在内存中存储的布局可以分为3块区域:对象头、实例数据和对齐填充。

    而这个对象头中,又分为两部分,一部分是用于存储对象自身的运行时数据,这部分数据长度在34位和64位的虚拟机(未启动压缩指针)的长度分别为32bit和64bit,官方称为“Mark Word”。但这部分数据是与对象自身定义的数据无关的额外存储成本,考虑到虚拟机的空间效率,Mark Word被设计成一个非固定的数据结构,以便在极小的空间内尽量存储多的信息,它会根据对象的状态复用自己的存储空间;对象头还有另一部分是类型指针,即对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是属于哪个类的实例。

    那么对象又有哪些状态呢,不同状态又存些什么信息呢?

    看下图这个表格:

    锁状态

    25 bit

    4bit

    1bit

    2bit

    23bit

    2bit

    是否是偏向锁

    锁标志位

    轻量级锁

    指向栈中锁记录的指针

    00

    重量级锁

    指向互斥量(重量级锁)的指针

    10

    GC标记

    11

    偏向锁

    线程ID

    Epoch

    对象分代年龄

    1

    01

    无锁

    对象的hashCode

    对象分代年龄

    0

    01

    这个对象的状态其实某一方面来说就是jvm中锁的几种状态。

    那为什么Java要搞那么多种锁呢,用来装逼吗??

    Synchronized是通过对象内部的一个叫做监视器锁(monitor)来实现的。但是监视器锁本质又是依赖于底层的操作系统的Mutex Lock来实现的。而操作系统实现线程之间的切换这就需要从用户态转换到核心态,这个成本非常高,状态之间的转换需要相对比较长的时间,这就是为什么Synchronized效率低的原因。因此,这种依赖于操作系统Mutex Lock所实现的锁我们称之为“重量级锁”。JDK中对Synchronized做的种种优化,其核心都是为了减少这种重量级锁的使用。JDK1.6以后,为了减少获得锁和释放锁所带来的性能消耗,提高性能,引入了“轻量级锁”和“偏向锁”。

    没错,就是为了优化。

    在介绍上面的各种锁之前,先引入下CAS操作。

    3.2 CAS操作

    介绍CAS

    使用锁时,线程获取锁是一种悲观锁策略,即假设每一次执行临界区代码都会产生冲突,所以当前线程获取到锁的时候同时也会阻塞其他线程获取该锁。而CAS操作(又称为无锁操作)是一种乐观锁策略,它假设所有线程访问共享资源的时候不会出现冲突,既然不会出现冲突自然而然就不会阻塞其他线程的操作。因此,线程就不会出现阻塞停顿的状态。那么,如果出现冲突了怎么办?无锁操作是使用CAS(compare and swap)又叫做比较交换来鉴别线程是否出现冲突,出现冲突就重试当前操作直到没有冲突为止。
     

    CAS比较交换的过程可以通俗的理解为CAS(V,O,N),包含三个值分别为:V 内存地址存放的实际值;O 预期的值(旧值);N 更新的新值。当V和O相同时,也就是说旧值和内存中实际的值相同表明该值没有被其他线程更改过,即该旧值O就是目前来说最新的值了,自然而然可以将新值N赋值给V。反之,V和O不相同,表明该值已经被其他线程改过了则该旧值O不是最新版本的值了,所以不能将新值N赋给V,返回V即可(或者什么都不做)。当多个线程使用CAS操作一个变量是,只有一个线程会成功,并成功更新,其余会失败。失败的线程会重新尝试(通常是个自旋操作,即不断尝试),当然也可以选择挂起线程。

    这里的替换、比较操作都是原子操作。

    CAS的实现需要硬件指令集的支撑,在JDK1.5后虚拟机才可以使用处理器提供的CMPXCHG指令实现。


    元老级的Synchronized(未优化前)最主要的问题是:在存在线程竞争的情况下会出现线程阻塞和唤醒锁带来的性能问题,因为这是一种互斥同步(阻塞同步)。而CAS并不是武断的间线程挂起,当CAS操作失败后会进行一定的尝试,而非进行耗时的挂起唤醒的操作,因此也叫做非阻塞同步。这是两者主要的区别。

    用白话来说就是:现在,我们用一个叫CAS的操作来实现对一个变量的同步。V是什么,是这个变量的内存指针、或者说是内存位置,这个东西因为没有锁,是大家都可以去访问,去改变他的。现在我这个线程也想改变它哦,想把它改成N值。那O是什么,O是我拿到这个变量的时候,这个变量的旧值,比如一个int变量a = 3,我拿到它的时候是3嘛,那这个O就是3。然后,我要替换V内存所存储的值的时候,先看V中的数字还是不是O,即有没被人改,没被改就可以直接换呗,要是被改,就不等于O了,说明并发错误了呗,那就要么一直重新尝试这个CAS改值操作,要么就挂起拜拜。
    上个类C的伪代码来理解下:
    int compare_and_swap (int* reg, int oldval, int newval) 
    {
      ATOMIC();
      int old_reg_val = *reg;
      if (old_reg_val == oldval) 
         *reg = newval;
      END_ATOMIC();
      return old_reg_val;
    }
    //代码来自:https://www.cnblogs.com/plxx/p/4539918.html

    CAS的应用场景

    在J.U.C包中利用CAS实现类有很多,可以说是支撑起整个concurrency包的实现,在Lock实现中会有CAS改变state变量,在atomic包中的实现类也几乎都是用CAS实现,关于这些具体的实现场景在之后会详细聊聊,现在有个印象就好了(微笑脸)。

    CAS的问题

    1. ABA问题
    因为CAS会检查旧值有没有变化,这里存在这样一个有意思的问题。比如一个旧值A变为了成B,然后再变成A,刚好在做CAS时检查发现旧值并没有变化依然为A,但是实际上的确发生了变化。解决方案可以沿袭数据库中常用的乐观锁方式,添加一个版本号可以解决。原来的变化路径A->B->A就变成了1A->2B->3C。java这么优秀的语言,当然在java 1.5后的atomic包中提供了AtomicStampedReference来解决ABA问题,解决思路就是这样的。


    2. 自旋时间过长
    使用CAS时非阻塞同步,也就是说不会将线程挂起,会自旋(无非就是一个死循环)进行下一次尝试,如果这里自旋时间过长对性能是很大的消耗。如果JVM能支持处理器提供的pause指令,那么在效率上会有一定的提升。


    3. 只能保证一个共享变量的原子操作
    当对一个共享变量执行操作时CAS能保证其原子性,如果对多个共享变量进行操作,CAS就不能保证其原子性。有一个解决方案是利用对象整合多个共享变量,即一个类中的成员变量就是这几个共享变量。然后将这个对象做CAS操作就可以保证其原子性。比如有两个共享变量i=2,j=a,合并一下ij=2a,然后用CAS来操作ij。atomic中提供了AtomicReference来保证引用对象之间的原子性。

    3.3 现在我们来依次介绍下这些锁。

    重量级锁

    emm就是刚刚说的,以前的monitor机制,需要线程阻塞去实现同步的锁。

    偏向锁

    顾名思义,就是偏向某个线程的锁。这是主要针对,并没有多线程在竞争但又用了synchronized的情况下的优化,如果只有一个线程在使用同步变量,还像重量级锁一样拿锁、释放锁,那就太影响效率了。

    引入偏向锁是为了在无多线程竞争的情况下尽量减少不必要的轻量级锁执行路径,因为轻量级锁(下面介绍)的获取及释放依赖多次CAS原子指令,而偏向锁只需要在置换ThreadID的时候依赖一次CAS原子指令(由于一旦出现多线程竞争的情况就必须撤销偏向锁,所以偏向锁的撤销操作的性能损耗必须小于节省下来的CAS原子指令的性能消耗)。

    偏向锁的获取过程

    (1)访问Mark Word中偏向锁的标识是否设置成1,锁标志位是否为01——确认为可偏向状态。

    (2)如果为可偏向状态,则测试线程ID是否指向当前线程,如果是,进入步骤(5),否则进入步骤(3)。

    (3)如果线程ID并未指向当前线程,则通过CAS操作竞争锁。如果竞争成功,则将Mark Word中线程ID设置为当前线程ID,然后执行(5);如果竞争失败,执行(4)。

    (4)如果CAS获取偏向锁失败,则表示有竞争。当到达全局安全点(safepoint)时获得偏向锁的线程被挂起,偏向锁升级为轻量级锁,然后被阻塞在安全点的线程继续往下执行同步代码。

    (5)执行同步代码。

    后续补充: 

    (线程获得偏向锁后,还会在线程的栈帧里创建lock record,这里也会存线程的id,和对象头的id一样,以后进入就不需要CAS操作了)

    偏向锁的释放

    偏向锁的撤销在上述第四步骤中有提到。偏向锁只有遇到其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁,线程不会主动去释放偏向锁。可以理解成,默认平时就只有一个线程在访问这个同步变量 ,所以哪里要放,一直偏向就好。


    当遇到有其它线程也来同步竞争的时候,偏向锁的撤销,需要等待全局安全点(在这个时间点上没有字节码正在执行),它会首先暂停拥有偏向锁的线程,判断锁对象是否处于被锁定状态,若没有被锁定,则撤销偏向锁后恢复到未锁定(标志位为“01”,是否为偏向锁“0”);若被锁定了,则升级到轻量级锁(标志位为“00”)的状态。

    下图线程1展示了偏向锁获取的过程,线程2展示了偏向锁撤销的过程。

    后面补充:

    1. 偏向锁的释放一直有地方卡住,获取过程中不是说,当锁对象是可偏向状态,但threadId又不是指向自己的时候,要进行CAS操作嘛,那如果偏向的那个线程正在运行,然后做CAS不是一定成功??

    后面看了知乎有个人说,这个CAS的原始值是匿名的偏向值——NULL,也就是说,除了第一个去CAS获取偏向锁的那个线程,其他线程去CAS都会失败!!

    2. 其他线程CAS失败之后,如果偏向线程不是在临界区的代码中,也就是没有在synchronized块或方法中,那么就会撤销偏向锁,同时把这个锁对象的markword变为——标志01,是否偏向锁0.。emmm然后按照上面的流程,偏向锁的获取是需要01,1的,那么是不是可以理解成这个时候,已经撤销了偏向锁了,如果有人来竞争的话,就直接是轻量级锁了。

     偏向锁的关闭

    偏向锁在Java 6和Java 7里是默认启用的,但是它在应用程序启动几秒钟之后才激活,如有必要可以使用JVM参数来关闭延迟:-XX:BiasedLockingStartupDelay=0。如果你确定应用程序里所有的锁通常情况下处于竞争状态,可以通过JVM参数关闭偏向锁:-XX:-UseBiasedLocking=false,那么程序默认会进入轻量级锁状态



     
    轻量级锁
    加锁过程

    (1)在代码进入同步块的时候,如果同步对象锁状态为无锁状态(锁标志位为“01”状态,是否为偏向锁为“0”),虚拟机首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的Mark Word的拷贝,官方称之为 Displaced Mark Word。这时候线程堆栈与对象头的状态如图2.1所示。

    (2)拷贝对象头中的Mark Word复制到锁记录中。

    (3)拷贝成功后,虚拟机将使用CAS操作尝试将对象的Mark Word更新为指向Lock Record的指针,并将Lock record里的owner指针指向object mark word。如果更新成功,则执行步骤(3),否则执行步骤(4)。

    (4)如果这个更新动作成功了,那么这个线程就拥有了该对象的锁,并且对象Mark Word的锁标志位设置为“00”,即表示此对象处于轻量级锁定状态,这时候线程堆栈与对象头的状态如下面第二张图所示。

    CAS之前的栈与对象的状态:

    轻量级锁CAS后的栈与对象的状态:

    (5)如果这个更新操作失败了,虚拟机首先会检查对象的Mark Word是否指向当前线程的栈帧,如果是就说明当前线程已经拥有了这个对象的锁,那就可以直接进入同步块继续执行。否则说明多个线程竞争锁,轻量级锁就要膨胀为重量级锁,锁标志的状态值变为“10”,Mark Word中存储的就是指向重量级锁(互斥量)的指针,后面等待锁的线程也要进入阻塞状态。

    (这里要说明一下,更新失败后,好像不是直接就去升级这个轻量级锁为重量级锁的,而是失败了直接自旋。期望在自旋的时间内获得锁,如果还是不能获得,那么开始膨胀,修改锁的MarkWord改为重量级锁的指针,并且阻塞自己。)

    (噢噢噢还有,这个自旋好像还是个适应性自旋,简单来说就是线程如果自旋成功了,则下次自旋的次数会更多,如果自旋失败了,则自旋的次数就会减少。)


    解锁

     (1)通过CAS操作尝试把线程中复制的Displaced Mark Word对象替换当前的Mark Word。

     (2)如果替换成功,整个同步过程就完成了。

     (3)如果替换失败,说明有其他线程尝试过获取该锁(此时锁已膨胀),那就要在释放锁的同时,唤醒被挂起的线程。

    看个图理解:

    (图片来自:https://www.cnblogs.com/plxx/p/4539918.html)

     这里解锁的时候,如果替换失败为什么要唤醒呢?因为失败说明已经有线程尝试用CAS来获取锁了,但却检测到锁被其它线程占领了(从代码角度就是 对象头的锁标志位的含义,至于被哪个线程占用,其他线程并不知道,就是指向锁记录的指针,只知道指针地址,但并不知道线程),这个时候锁就会被升级成重量级的。标志位改为10,对象头指针会变成 指向重量级锁监视器ObjectMonitor的指针。。

    改成重量级锁后,竞争必然要执行阻塞 那么未持有锁的线程就会进入阻塞状态。
    所以锁升级后,本来占领锁的那个线程完成了它临界区的代码后,就要进行重量级锁的释放过程,这时候会进行额外操作,比如唤醒 被挂起的线程。

    附:

    • 也有说锁的膨胀是在释放锁的时候发生的,百度看了下好像有大佬说两种时候都有可能发生。。emmm反正上面那个情况挺好理解的,先这样理解吧,主要是理解机制。升级 heavyweight lock时,锁对象的markword存储的是对应ObjectMonitor的指针,但是依然持有 原始的对象头分代年龄 hash 是否偏向的信息。是通过ObjectMonitor类中有一个markOop类型的_head成员变量,而这个值,就是在锁膨胀的过程中,由on-stack lock record复制过来的。
    • 其实查看Object类的hashCode 源码过程 就能体会到,对象在不同锁环境下,如何提取到hashCode。

    3.4 各个锁的比较

    首先要注意:

    锁可以升级但不能降级!!!

    意味着偏向锁升级成轻量级锁后不能降级成偏向锁。这种锁升级却不能降级的策略,目的是为了提高获得锁和释放锁的效率。

    然后是各个所的比较和使用场景:

     3.5 各个锁之间的转换关系图

     

    参考文章:

    https://www.jianshu.com/p/d53bf830fa09——《让你彻底理解Synchronized》还有讲什么synchronized的happens-before关系

    http://www.cnblogs.com/paddix/p/5405678.html——《Java并发编程:Synchronized底层优化(偏向锁、轻量级锁)》除了这几种锁,还讲了jdk中锁的另几个优化。

    https://www.cnblogs.com/plxx/p/4539918.html——《java -- 轻量级锁》介绍轻量级锁的文章。

    附加一个——https://www.zhihu.com/question/52116998/answer/133400077——《当Java处在偏向锁、重量级锁状态时,hashcode值存储在哪?》

  • 相关阅读:
    JDK所有版本
    application.yml配置log日志
    eclipse配置lombok
    Eclipse配置springboot
    java 连接mongodb
    MongoDB shell操作
    mysql插入一万条数据
    Web设计精髓(转)
    SyntaxHighlighter -- 代码高亮插件
    input之placeholder与行高的问题。
  • 原文地址:https://www.cnblogs.com/wangshen31/p/10440548.html
Copyright © 2011-2022 走看看