基于synchronized锁的深度解析



1. 问题引入


小伙伴们都接触过线程,也都会使用线程,今天咱们要讲的是线程安全相关的内容,在这以前咱们先来看一个简单的代码案例。


代码案例:java

/**
 * @url: i-code.online
 * @author: AnonyStar
 * @time: 2020/10/14 15:39
 */
public class ThreadSafaty {
    //共享变量
    static int count = 0;

    public static void main(String[] args) {

        //建立线程
        Runnable runnable = () -> {
            for (int i = 0; i < 5; i++) {
                count ++;
                try {
                    Thread.sleep(1);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        };

        for (int i = 0; i < 100; i++) {
            new Thread(runnable,"Thread-"+i).start();
        }

        try {
            Thread.sleep(5000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println("count = "+ count);
    }
}

执行结果:



问题说明:
在上面的代码中咱们能够看到,定义了一个线程 runnable 里面对公共成员变量进行 ++ 操做,并循环五次,每次睡眠一毫秒,以后咱们在主线程 main 方法中建立一百个线程而且启动,而后主线程睡眠等待五秒以此来等全部的线程执行结束。咱们预期结果应该是 500 。可是实际执行后咱们发现 count 的值是不固定的 ,是小于 500 的,这里就是多线程并行致使的数据安全性问题!
c++

经过上述案例咱们能够清楚的看到线程安全的问题,那么咱们想一想是否有什么办法来避免这种安全问题尼 ?咱们能够想到致使这种安全问题的缘由是由于咱们访问了共享数据,那么咱们是否能将线程访问共享数据的过程变成串行的过程那么不就是不存在这个问题了。这里咱们能够想到以前说的 ,咱们知道锁是处理并发的一种同步方式,同时他也具有互斥性,在Java中实现加锁是经过 synchronized 关键字


2. 锁的基本认识

2.1 Synchronized 的认识

Java 中咱们知道有一个元老级的关键字 synchronized ,它是实现加锁的关键,可是咱们一直都认为它是一个重量级锁,其实早在 jdk1.6 时就对其进行了大量的优化,让它已经变成很是灵活。也再也不一直是重量级锁了,而是引入了 偏向锁 轻量级锁。 关于这些内容咱们将详细介绍。
数组

synchronized的基础使用

  • synchronized 修饰实例方法,做用于当前实例加锁
  • synchronized 修饰静态方法,做用于当前类对象加锁,
  • synchronized 修饰代码块,指定加锁对象,对给定对象加锁,
在上述状况中,咱们要进入被 synchronized 修饰的同步代码前,必须得到相应的锁,其实这也体现出来针对不一样的修饰类型,表明的是锁的控制粒度
  • 咱们修改一下前面咱们写的案例,经过使用 synchronized 关键字让其实现线程安全
//建立线程
        Runnable runnable = () -> {
            synchronized (ThreadSafaty.class){
                for (int i = 0; i < 5; i++) {
                    count ++;
                    try {
                        Thread.sleep(1);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
            }

        };
只须要添加 synchronized (ThreadSafaty.class) 的修饰,将操做的内容放入代码块中,那么就会实现线程安全
  • 经过上面的实践咱们能够直观感觉 synchronized 的做用,这是咱们平时开发中常规使用,你们有没有过疑问,这个锁究竟是怎么存储实现的?那么下面咱们将对探索其中的奥秘

Java中锁的实现

  • 咱们知道锁是具备互斥性(Mutual Exclusion)的 ,那么它是在什么地方标记存在的尼?
  • 咱们也知道多个线程均可以获取锁,那么锁必然是能够共享的
  • 咱们最熟悉的 synchronized 它获取锁的过程究竟是怎么样的呢?它的锁是如何存储的呢?
  • 咱们能够注意观察 synchronized 的语法,能够看到 synchronized(lock) 是基于 lock 的生命周期来实现控制锁粒度的,这里必定要理解,咱们得到锁时都时一个对象,那么锁是否是会和这个对象有关系呢?
  • 到这里为止,咱们将全部的关键信息都指向了对象,那么咱们有必要以此为切入点,来首先了解对象在 jvm 中的分布形式,再来看锁是怎么被实现的。

对象的内存布局

  • 这里咱们只谈论对象在 Heap 中的布局,而不会涉及过多的关于对象的建立过程等细节,这些内容咱们再单独文章详细阐述,能够关注 i-code.online 博客或wx "云栖简码"
  • 在咱们最经常使用的虚拟机 hotspot 中对象在内存中的分布能够分为三个部分:对象头(Header)、实列数据(Instance Data)、对其填充(Padding


  • 经过上述的图示咱们能够看到,对象在内存中,包含三个部分,   其中对象头内分为 对象标记与类元信息,在对象标记中主要包含如图所示 hashcode、GC分代年龄、锁标记状态、偏向锁持有线程id、线程持有的锁(monitor)等六个内容,这部分数据的长度在 32 位和64位的虚拟机中分别为32bit 和 64bit,在官方将这部分称为 Mark Word
  • Mark Word 实际是一中能够动态定义的数据结构,这样可让极小的空间存储尽可能多的数据,根据对象的状态复用本身的内存空间,好比在32位的虚拟机中,若是对象未被同步锁锁定的状态下, Mark Word 的32个比特存储单元中,25个用于存储哈希码,4个用于存储GC分代年龄,2个存锁标记位,1个固定位0,针对各个状态下的分布能够直观的参看下面的图表


32位HotSpot虚拟机对象头Mark Word安全

锁状态 25bit 4bit 1bit
(是不是偏向锁)
2bit
(锁标志位)
23bit 2bit
无锁 对象的HashCode 分代年龄 0 01
偏向锁 线程ID Epoch(偏向时间戳) 分代年龄 1 01
轻量级锁 指向栈中锁记录的指针 00
重量级锁 指向重量级锁的指针 10
GC标记 11
上述说的是32位虚拟机,须要注意。关于对象头的另外一部分是类型指针,这里咱们不展开再细说了,想了解的关注 i-code.online ,会持续更新相关内容😁😊
  • 下面内容会涉及到源码的查看,须要提早下载源码,若是你不知道如何来下载,能够参看《下载JDK 与 Hotspot 虚拟机源码》这篇文章,或者关注 云栖简码
  • 在咱们熟悉的虚拟机 Hotspot 中实现 Mark Word 的代码在 markOop.cpp 中,咱们能够看下面片断,这是描述了虚拟机中MarkWord 的存储布局:


  • 当咱们在 new 一个对象时,虚拟机层面实际会建立一个 instanceOopDesc 对象,咱们熟悉的 Hotspot 虚拟机采用了 OOP-Klass 模型来描述 Java 对象实例,其中 OOP 就是咱们熟悉的普通对象指针,而 Klass 则是描述对象的具体类型,在Hotspot 中分别用 instanceOopDescarrayOopDesc 来描述,其中arrayOopDesc 用来描述数组类型,
  • 对于 instanceOopDesc 的实现咱们能够从 Hotspot 源码中找到。对应在 instanceOop.hpp 文件中,而相应的 arrayOopDescarrayOop.hpp 中,下面咱们来看一下相关的内容:


  • 咱们能够看到 instanceOopDesc 继承了 oopDesc,而 oopDesc 则定义在 oop.hpp 中,


  • 上述图示中咱们能够看到相关信息,具体也注释了文字,那么接下来咱们要探索一下 _mark 的实现定义了,以下,咱们看到它是markOopDesc


  • 经过代码跟进咱们能够在找到 markOopDesc 的定义在 markOop.hpp 文件中,以下图所示:

  • 在上述图片中咱们能够看到,内部有一个枚举。记录了 markOop 中存储项,因此在咱们实际开发时,当 synchronized 将某个对象做为锁时那么以后的一系列锁的信息都和 markOop 相关。如上面表格中 mark word的分布记录所示具体的各个部分的含义
  • 由于咱们建立对象时实际在jvm层面都会生成一个nativec++ 对象 oop/oopdesc 来映射的,而每一个对象都带有一个 monitor 的监视器对象,能够在 markOop.hpp 中看到,其实在多线程中抢夺锁就是在争夺 monitor 来修改相应的标记


Synchronized 的深刻

  • Javasynchronized 是实现互斥同步最基本的方法,它是一个块结构(Block Structured)的同步语法,在通过javac 编译后会在块的先后分别造成 monitorrentermonitorexit 两个字节码指令,而它们又都须要一个 reference 类型的参数来指明锁对象,具体锁对象取决于 synchronized 修饰的内容,上面已经说过不在阐述。
《深刻理解Java虚拟机》中有这样的描述:
根据《Java虚拟机规范》的要求,在执行monitorenter指令时,首先要去尝试获取对象的锁。若是 这个对象没被锁定,或者当前线程已经持有了那个对象的锁,就把锁的计数器的值增长一,而在执行 monitorexit指令时会将锁计数器的值减一。一旦计数器的值为零,锁随即就被释放了。若是获取对象 锁失败,那当前线程就应当被阻塞等待,直到请求锁定的对象被持有它的线程释放为止
  • 因此被 synchronized 修饰的代码块对同一个线程是可重入的,这也就避免了同线程反复进入致使死锁的可能
  • synchronized 修饰的代码块直接结束释放锁以前,会阻塞后面的其余线程

为何说synchronized是重量级锁

  • 从执行成原本说,持有锁是一个重量级(Heavy-Weight)的操做过程,由于在Java中线程都是映射到操做系统的原生内核线程上的,若是要阻塞和唤醒某一个线程都须要通过操做系统来调度,而这就不可避免的会进行用户态和内核态的转换,可是这种转换是很是耗费处理器时间的,尤为对于自己业务代码简单的程序,可能在这里耗费的时间比业务代码自身执行的时间还长,因此说synchronized 是一个重量级的操做,不过在 jdk6 后对其作了大量的优化,让它再也不显得那么重

锁的优化

  • JDK5 升级到 JDK6 后进行一系列关于锁的改进,经过多种技术手段来优化锁,让 synchronized 再也不像之前同样显的很重,这其中涉及到适应性自旋(Adaptive Spinning)、锁消除(Lock Elimination)、锁膨胀(Lock Coarsening)、轻量级锁(LightWeight Locking)、偏向锁(Biased Locking)等,这些都是用来优化和提升多线程访问共享数据的竞争问题。

锁消除

  • 锁消除是虚拟机在即时编译器运行时对一些代码要求同步,可是被检测到不可能存在共享数据竞争的锁进行消除,其中主要的断定依据是基于逃逸分析技术来实现的,关于这块内容不在这里展开,后续相关文章介绍。这里咱们简单理解就是,若是一段代码中,在堆上的数据都不会逃逸出去被其余线程访问到,那么就能够把它们看成栈上的数据来对来,认为它们都是线程私有的,从而也就不须要同步加锁了,
  • 关于代码中变量是否逃逸,对虚拟机来讲须要经过复杂分析才能获得,可是对咱们开发人员来讲仍是相对直观的,那可能有人会疑惑既然开发人员能清楚还为何要多余的加锁同步呢?,其实实际上,程序上很是多的同步措施并非咱们开发人员本身加入的,而是 java 内部就有大量的存在,好比下面这个典型的例子,下面展现的是字符串的相加
private String concatString(String s1,String s2,String s3){
        return s1 + s2 + s3;
    }
  • 咱们知道String 类是被 final 修饰的不可变类,因此对于字符串的相加都是经过生成新的String 对象来试试先的,所以编译器会对这种操做作优化处理,在JDK5 以前会转换为 StringBuffer 对象的append() 操做,而在JDK5 及其以后则转换为StringBuilder 对象来操做。因此上述代码在jdk5可能会变成以下:
private String concatString(String s1,String s2,String s3){
        StringBuffer sb = new StringBuffer();
        sb.append(s1);
        sb.append(s2);
        sb.append(s3);
        return sb.toString();
    }
  • 这时候,就能够看到,对于StringBuffer。append() 方法是一个同步方法,带有同步快,锁对象就是 sb ,这时候虚拟机经过分析发现 sb 的做用域被限制在方法内部,而不可能逃逸出方法外让其余线程访问到,因此这是在通过服务端编译器的即时编译后,这段代码的全部同步措施都会失效而直接执行。
上述代码是为了方便演示而选择了String,实际来讲在jdk5以后都是转换为Stringbuilder ,也就不存在这个问题了,可是在jdk中相似这种仍是很是多的。

锁粗化

  • 关于锁的粗话其实也是很简单的理解,咱们在开发时老是推荐同步代码块要做用范围尽可能小,尽可能只在共享数据的实际做用域中才进行同步,这样的目的是为了尽量减小同步的操做,让其余线程能更快的拿到锁
  • 这是多大多数状况,可是总有一些特殊状况,好比在某个系列连续操做的都是对同一个对象反复的加锁和解锁,那么这会致使没必要要的性能损耗
  • 也如同上面String 的案例,在连续的append 操做都是零碎的同步块,并且都是同一个锁对象,这时候会将锁的范围扩展,到整个操做序列外部,也就是第一个append 以前到最后一个append 操做以后,将这些所有放入一个同步锁中就能够了,这样就避免了屡次的锁获取和释放。

自旋锁


  • 经过以前的了解,咱们知道挂起线程和恢复线程都是会涉及到用户态和内核态的转换,而这些都是很是耗时的,这会直接影响虚拟机的并发性能。
  • 在咱们平时开发中,若是共享数据的锁定状态只会持续很短的时间,那么为了这很短的时间而去挂起阻塞线程是很是浪费资源的。尤为如今的电脑都基本是多核处理器,因此在这种前提下,咱们是是否可让另外一个请求锁对象的线程不去挂起,而是稍微等一下,这个等待并不会放弃CPU 的执行时间。等待观察持有锁的线程是否能很快的释放锁,其实这个等待就比如是一个空的循环,这种技术就是一个所谓的自旋锁
  • 自旋锁在JDK6 中及已是默认开启的了,在jdk4时就引入了。自旋锁并非阻塞也代替不了阻塞。
  • 自旋锁对处理器数量有必定的要求,同时它是会占用CPU 时间的,虽然它避免了线程切换的开销,可是这之间时存在平衡关系的,假如锁被占用的时间很短那么自旋就很是有价值,会节省大量的时间开销,可是相反,若是锁占用的时间很长,那么自旋的线程就会白白消耗处理器资源,形成性能的浪费。
  • 因此自旋锁必须有一个限度,也就是它自旋的次数,规定一个自旋次数,若是超过这个次数则再也不自旋转而用传统方式挂起线程,
  • 自旋的次数默认时十次。可是咱们也能够经过 -XX: PreBlockSpin 参数来自定义设置

自适应自旋锁

  • 在前面咱们知道能够自定义自旋次数,可是这个很难有个合理的值,毕竟在程序中怎么样的状况都有,咱们不可能经过全局设置一个。因此在JDK6 以后引入了自适应自旋锁,也就是对原有的自旋锁进行了优化
  • 自适应自旋的时间再也不是固定的,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态决定的,若是在同一个锁对象上,自旋等待刚刚成功得到过锁,而且支持有锁的线程正在运行中,那么虚拟机就会任务此次自旋也极有再次得到锁,那么就会容许自旋的持续时间更长
  • 相应的 ,若是对于某个锁,自旋得到锁的次数很是少,那么在以后要获取锁的时候将直接忽略掉自旋的过程进而直接阻塞线程避免浪费处理器资源

轻量级锁

  • 轻量级锁也是 JDK6 时加入的新的锁机制,它的轻量级是相对于经过操做系统互斥量来实现的传统锁而言的,轻量级锁也是一种优化,而不是能替代重量级锁,轻量级锁的涉及初衷就是在没有多线程竞争下减小传统重量级锁使用操做系统互斥量产生的性能消耗。
  • 要想了解轻量级锁咱们必须对对象在Heap 中的分布了解,也就是上面说到的内容。

轻量级锁加锁

  • 当代码执行到同步代码块时,若是同步对象没有被锁定也就是锁标志位为01 状态,那么虚拟机首先将在当前线程的栈帧中创建一个名为锁记录Lock Record 的空间
  • 这块锁记录空间用来存储锁对象目前的 Mark Word 的拷贝,官方给其加了个Displaced 的前缀,即 Displaced Mark Word ,以下图所示,这是在CAS 操做以前堆栈与对象的状态

CAS操做以前堆栈与对象的状态

  • 当复制结束后虚拟机会经过CAS 操做尝试把对象的Mark Word 更新为指向Lock Record 的指针,若是更新成功则表明该线程拥有了这个对象的锁,而且将Mark Word 的锁标志位(最后两个比特)转变为 “00”,此时表示对象处于轻量级锁定状态,此时的堆栈与对象头的状态以下:

堆栈与对象头的状态

  • 若是上述操做失败了,那说明至少存在一条线程与当前线程竞争获取该对象的锁,虚拟机会首先检查对象的Mark Word 是否指向当前线程的栈帧,若是是,则说明当前线程已经拥有了这个对象的锁,那么直接进入同步代码块执行便可。不然则说明这个对象已经被其余线程抢占了。
  • 若是有超过两条以上的线程争夺同一个锁的状况,那么轻量级锁就再也不有效,必须膨胀为重量级锁,锁的标记位也变为“10”,此时Mark Word 中存储的就是指向重量级锁的指针,等待的线程也必须进入阻塞状态

轻量级锁的解锁

  • 轻量级锁的解锁一样是经过CAS 操做来进行的
  • 若是对象的 Mark Word 仍然指向线程的锁记录,那么就用CAS 操做把对象当前的 Mark Word 和线程中复制的 Displaced Mark Word 替换回来
  • 若是替换成功则整个同步过程结束,若失败则说明有其余线程正在尝试获取该锁,那就要在释放锁的同时,唤醒被挂起的线程


轻量级锁适用的场景是对于绝大部分锁在整个同步周期内都是不存在竞争的,由于若是没有竞争,轻量级锁即可以经过 CAS 操做成功避免了使用互斥量的开销,可是若是确实存在锁竞争,那么除了互斥量自己的开销外还得额外发生了 CAS 操做的开销,这种状况下反而比重量级锁更慢
  • 下面经过完整的流程图来直观看一下轻量级锁的加锁解锁及膨胀过程



偏向锁

  • 偏向锁也是JDK6 引入的一种锁优化技术,若是说轻量级锁是在无竞争状况下经过CAS 操做消除了同步使用的互斥量,那么偏向锁则是再无竞争状况下把整个同步都给消除掉了,连CAS 操做都再也不去作了,能够看出这比轻量级锁更加轻
  • 从对象头的分布上看,偏向锁中是没有哈希值的而是多了线程ID与Epoch 两个内容
  • 偏向锁的意思就是锁会偏向第一个得到它的线程,若是接下来的执行过程当中该锁一直没有被其余线程获取,那么只有偏向锁的线程将永远不须要再进行同步

偏向锁的获取和撤销

  • 当代码执行到同步代码块时,在第一次被线程执行到时,锁对象是第一次被线程获取,此时虚拟机会将对象头中的锁标志改成“01”,同时把偏向锁标志位改成“1”,表示当前锁对象进入偏向锁模式。
  • 接下来线程经过CAS 操做来将这个帧的线程ID记录到对象头中,若是CAS 成功了。则持有锁对象的线程再以后进入同步代码再也不进行任何同步操做(如获取锁解锁等操做)。每次都会经过判断当前线程与锁对象中记录的线程id是否一致。
  • 若是 上述的 CAS 操做失败了,那说明确定存在另一个线程在获取这个锁,而且获取成功了。这种状况下说明存在锁竞争,则偏向模式立刻结束,偏向锁的撤销,须要等待全局安全点(在这个时间点上没有正在执行的字节码)。它会首先暂停拥有偏向锁的线程,会根据锁对象是否处于锁定状态来决定是否撤销偏向也就是将偏向锁标志位改成“0”,若是撤销则会变为未锁定(“01”)或者轻量级锁(“00”)
  • 若是锁对象未锁定,则撤销偏向锁(设置偏向锁标志位为“0”),此时锁处于未锁定不能够偏向状态,由于具备哈希值,进而变为轻量级锁
  • 若是锁对象还在锁定状态则直接进入轻量级锁状态


偏向锁的开关

  • 偏向锁在JDK6 及其以后是默认启用的。因为偏向锁适用于无锁竞争的场景,若是咱们应用程序里全部的锁一般状况下处于竞争状态,能够经过JVM参数关闭偏向锁:-XX:-UseBiasedLocking=false,那么程序默认会进入轻量级锁状态。
  • 若是要开启偏向锁能够用: -XX:+UseBiasedLocking -XX:BiasedLockingStartupDelay=0

重量级锁

  • 重量级锁也就是上述几种优化都无效后,膨胀为重量级锁,经过互斥量来实现,咱们先来看下面的代码


  • 上面代码是一个简单使用了synchronized 的代码,咱们经过字节码工具能够看到右侧窗口。咱们发现,在同步代码块的先后分别造成了monitorentermonitorexit 两条指令
  • 在Java对现中都会有一个monitor 的监视器,这里的monitorenter 指令就是去获取一个对象的监视器。而相应的monitorexit 则表示释放监视器monitor 的全部权,容许被其余线程来获取
  • monitor 是依赖于系统的 MutexLock (互斥锁) 来实现的,当线程阻塞后进入内核态事,就会形成系统在用户态和内核态之间的切换,进而影响性能

总结

  • 上面是阐述了关于synchronized 锁的一些优化与转换,在咱们开启偏向锁和自旋时,锁的转变是 无锁 -> 偏向锁 -> 轻量级锁 -> 重量级锁,
  • 自旋锁实际是一种锁的竞争机制,而不是一种状态。在偏向锁和轻量级锁中都使用到了自旋
  • 偏向锁适用于无锁竞争的场景,轻量级锁适合无多个线程竞争的场景
  • 偏向锁和轻量级锁都依赖与CAS操做,可是偏向锁中只有在第一次时才会CAS操做
  • 当一个对象已经被计算过一致性哈希值时,那么这个对象就不再没法进入到偏向锁状态了,若是对象正处于偏向锁状态,而接收到计算哈希值的请求,那么他的偏向锁状态会被当即撤销,而且会膨胀为重量级锁。这要是为何偏向锁状态时MarkWord 中没有哈希值

本文由AnonyStar 发布,可转载但需声明原文出处。
欢迎关注微信公帐号 :云栖简码 获取更多优质文章
更多文章关注笔者博客 : 云栖简码 i-code.online
相关文章
相关标签/搜索