本站消息

站长简介/公众号


站长简介:逗比程序员,理工宅男,前每日优鲜python全栈开发工程师,利用周末时间开发出本站,欢迎关注我的微信公众号:程序员总部,程序员的家,探索程序员的人生之路!分享IT最新技术,关注行业最新动向,让你永不落伍。了解同行们的工资,生活工作中的酸甜苦辣,谋求程序员的最终出路!

  价值13000svip视频教程,java大神匠心打造,零基础java开发工程师视频教程全套,基础+进阶+项目实战,包含课件和源码

  出租广告位,需要合作请联系站长


+关注
已关注

分类  

暂无分类

标签  

暂无标签

日期归档  

2021-05(16)

2021-06(58)

2021-07(11)

2021-08(50)

2021-09(37)

Synchronized应用及其底层原理解析

发布于2021-05-29 19:23     阅读(1045)     评论(0)     点赞(22)     收藏(1)


一、前言

在涉及到多线程同步的问题时,可能第一时间会想到用Synchronized,在jdk1.6以前Synchronized属于重量锁,就是同一时间只允许一个线程获取锁,其他线程都要阻塞等待,但这对于多线程效率问题有很大影响。在jdk1.6之后,Java团队对Synchronized做了优化,进行了锁升级,升级方向:无锁—>偏向锁—>轻量级锁—>重量级锁。

下面我们来详细解析下Synchronized的使用和源码。

二、正文

1.Synchronized的使用场景?

Synchronized一般有以下三种使用场景:

  • 修饰实例方法,对当前实例对象this加锁(this关键字所代表的意思是该对象的实例)
public class SynchronizedDemo() {
    // 修饰实例方法
    public synchronized void test() {
        ...
    }
}

// 下面的方法与上面是等价的
public class SynchronizedDemo() {
    public void test() {
        // this代表该对象的实例
        synchronized(this) {
            ...
        }
    }
}
  • 修饰静态方法,对当前类的Class对象加锁,下面三种方法是相等的
public class SynchronizedDemo() {
    public void test() {
        synchronized(SynchronizedDemo.class) {
           ...
        }
    }
}

public class SynchronizedDemo() {
    public synchronized static void test() {
        ...
    }
}

public class SynchronizedDemo() {
    public void test() {
        synchronized(this.getClass()) {
           ...
        }
    }
}
  • 修饰代码块,指定一个加锁对象,给对象加锁
public class SynchronizedDemo() {
    public void test() {
        synchronized(new AddDemo()) {
           ...
        }
    }
}

上面的synchronized(new AddDemo())是给AddDemo这个对象实例加锁,若是我们没有一个明确的对象作为锁,只想让一段代码同步,可以创建一个特殊的对象来充当锁,如下:

public class SynchronizedDemo() {
    private final Object lock = new Object();
    public void test() {
        synchronized(lock) {
           ...
        }
    }
}

这个lock不具体指锁住具体的对象,只是为了执行一段同步代码。

Tip:这里有一个优化的小细节,new byte[0]作为锁对象比new Object()更好,因为会减少字节码的操作次数。

我们通过反编译得出的字节码可以看出,new byte [0] 确实比 new Object () 少 4 条字节码操作。再计算一下内存占用,在 64 位 jvm 默认开启 UseCompressedOops 的情况下(Java 1.6.0_23 版本开始就默认开启了),一个空对象,不包含任何成员变量,大小 16 字节,一个 byte [0] 数组,大小也是 16 字节,是相等的。

于是验证了上面的结论:用 new byte [0] 作为锁对象是优于 new Object () 的。在日常的 java 开发中,可以注意到这个细节的点来优化代码。

那么synchronized是怎么实现加锁的呢?

在介绍这个之前,我们首先去了解下Java对象构成,因为前面提到过,Java的锁都是基于对象的。

2.在JVM中,Java对象构成?

在 JVM 中,对象在内存中分为三块区域:

  • 对象头

    Mark Word(标记字段):默认存储对象的HashCode,分代年龄和锁标志位信息。它会根据对象的状态复用自己的存储空间,也就是说在运行期间Mark Word里存储的数据会随着锁标志位的变化而变化。

    Klass Point(类型指针):对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。

  • 实例数据

    这部分主要是存放类的数据信息,父类的信息。

  • 对其填充

    由于虚拟机要求对象起始地址必须是8字节的整数倍,填充数据不是必须存在的,仅仅是为了字节对齐。

    Tip:不知道大家有没有被问过一个空对象占多少个字节?就是8个字节,是因为对齐填充的关系哈,不到8个字节对其填充会帮我们自动补齐。

    问题延申:

    (1)在32为操作系统或者在 64 位 jvm 默认开启 UseCompressedOops 的情况下,Object o = new Object()一共占用多少个字节?

    java空对象占8个字节,对象的引用占4个字节。
    所以上面那条语句所占的空间是4byte+8byte=12byte.
    java中的内存是以8字节的倍数来分配的,所以分配的内存是16byte.

    (2)UseCompressedOops是做什么用的?

    拿新建一个对象来说:Object o = new Object()

    如果不开启普通对象指针压缩,-UseCompressedOops,会在内存中消耗24个字节,o 指针(klass pointer)占8个字节,Object对象占16个字节。

    如果开启普通对象指针压缩,+UseCompressedOops,会在内存中消耗20个字节,o指针(klass pointer)占4个字节,Object对象占16个字节。

    这样一看,好像UseCompressedOops 对Object的内存并没有影响,其实不然,Object对象在内存中的布局,包括markword 、
    klass pointer、实例数据和填充对其,开启UseCompressedOops,默认会开启UseCompressedClassPointers,会压缩klass pointer 这部分的大小,由8字节压缩至4字节,间接的提高内存的利用率。

在这里插入图片描述

3.我们常说的有序性、可见性、原子性,可重入性、不可中断性,Synchronized是如何保证的?

  • 有序性

我在Volatile章节已经说过了CPU会为了优化我们的代码,会对我们程序进行重排序。

as-if-serial

不管编译器和CPU如何重排序,必须保证在单线程情况下程序的结果是正确的,还有就是有数据依赖的也是不能重排序的。

就比如:

int a = 1;
int b = a;

这两段是怎么都不能重排序的,b的值依赖a的值,a如果不先赋值,那就为空了。

当我们使用Synchronized会禁止指令重新排序,这样就保证了有序性。

  • 可见性

同样在Volatile章节我介绍到了现代计算机的内存结构,以及JMM(Java内存模型),这里我需要说明一下就是JMM并不是实际存在的,而是一套规范,这个规范描述了很多java程序中线程共享变量的访问规则,以及在JVM中将变量存储到内存和从内存中读取变量这样的底层细节,Java内存模型是对共享数据的可见性、有序性、和原子性的规则和保障。

当某一线程进入synchronized代码块前后,线程会获取锁,清空工作内存,从主内存拷贝共享变量最新值到工作内存成为副本,执行代码,将修改后的副本的值刷新回主内存,线程释放锁。

而获取不到锁的线程会阻塞等待,所以变量的值肯定一直都是最新的。这样synchronized就保证可见性。

  • 原子性

其实他保证原子性很简单,确保同一时间只有一个线程能拿到锁,能够进入代码块这就够了。

这几个是我们使用锁经常用到的特性,那synchronized他自己本身又具有哪些特性呢?

  • 可重入性

synchronized锁对象的时候有个计数器,他会记录下线程获取锁的次数,在执行完对应的代码块之后,计数器就会-1,直到计数器清零,就释放锁了。

那可重入有什么好处呢?

可以避免一些死锁的情况,也可以让我们更好封装我们的代码。

synchronized可重复锁实现原理

synchronized底层的实现原理是利用计算机系统的mutex Lock实现。每一个可重入锁都会关联一个线程ID和一个锁状态status。
当一个线程请求方法时,会去检查锁状态,如果锁状态是0,代表该锁没有被占用,直接进行CAS操作获取锁,将线程ID替换成自己的线程ID。如果锁状态不是0,代表有线程在访问该方法。此时,如果线程ID是自己的线程ID,并且是可重入锁,会将status自增1,然后获取到该锁,进而执行相应的方法。如果是非重入锁,就会进入阻塞队列等待。
释放锁时,可重入锁,每一次退出方法,就会将status减1,直至status的值为0,最后释放该锁。
释放锁时,非可重入锁,线程退出方法,直接就会释放该锁。

所以,从一定程度上来说,可重入锁可以避免死锁的发生。

  • 不可中断性

不可中断就是指,一个线程获取锁之后,另外一个线程处于阻塞或者等待状态,前一个不释放,后一个也一直会阻塞或者等待,不可以被中断。

值得一提的是,Lock的tryLock方法是可以被中断的。

4.Synchronized底层实现原理?

这里看实现很简单,我写了一个简单的类,分别有锁方法和锁代码块,我们反编译一下字节码文件,就可以了。

先看看我写的测试类:

public class SynchronizedDemo {
    public static void main(String[] args) {

    }
    Object lock = new Object();
    public synchronized void methodA(){
        synchronized(lock){

        }
    }
}

编译完成,我们去对应目录执行 javap -p -v -c xxx.class 命令查看反编译的文件:

$ javap -p -v -c SynchronizedDemo.class
Classfile /D:/javaProjectTest/leetcode/target/classes/com/example/demo/testSolution/SynchronizedDemo.class
  Last modified 2021-5-14; size 694 bytes
  MD5 checksum b34d7d0723f3a99882e83da7c5a90247
  Compiled from "SynchronizedDemo.java"
public class com.example.demo.testSolution.SynchronizedDemo
  minor version: 0
  major version: 52
  flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
   #1 = Methodref          #2.#25         // java/lang/Object."<init>":()V
   #2 = Class              #26            // java/lang/Object
   #3 = Fieldref           #4.#27         // com/example/demo/testSolution/SynchronizedDemo.lock:Ljava/lang/Object;
   #4 = Class              #28            // com/example/demo/testSolution/SynchronizedDemo
   #5 = Utf8               lock
   #6 = Utf8               Ljava/lang/Object;
   #7 = Utf8               <init>
   #8 = Utf8               ()V
   #9 = Utf8               Code
  #10 = Utf8               LineNumberTable
  #11 = Utf8               LocalVariableTable
  #12 = Utf8               this
  #13 = Utf8               Lcom/example/demo/testSolution/SynchronizedDemo;
  #14 = Utf8               main
  #15 = Utf8               ([Ljava/lang/String;)V
  #16 = Utf8               args
  #17 = Utf8               [Ljava/lang/String;
  #18 = Utf8               methodA
  #19 = Utf8               StackMapTable
  #20 = Class              #28            // com/example/demo/testSolution/SynchronizedDemo
  #21 = Class              #26            // java/lang/Object
  #22 = Class              #29            // java/lang/Throwable
  #23 = Utf8               SourceFile
  #24 = Utf8               SynchronizedDemo.java
  #25 = NameAndType        #7:#8          // "<init>":()V
  #26 = Utf8               java/lang/Object
  #27 = NameAndType        #5:#6          // lock:Ljava/lang/Object;
  #28 = Utf8               com/example/demo/testSolution/SynchronizedDemo
  #29 = Utf8               java/lang/Throwable
{
  java.lang.Object lock;
    descriptor: Ljava/lang/Object;
    flags:

  public com.example.demo.testSolution.SynchronizedDemo();
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
      stack=3, locals=1, args_size=1
         0: aload_0
         1: invokespecial #1                  // Method java/lang/Object."<init>":()V
         4: aload_0
         5: new           #2                  // class java/lang/Object
         8: dup
         9: invokespecial #1                  // Method java/lang/Object."<init>":()V
        12: putfield      #3                  // Field lock:Ljava/lang/Object;
        15: return
      LineNumberTable:
        line 9: 0
        line 13: 4
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0      16     0  this   Lcom/example/demo/testSolution/SynchronizedDemo;

  public static void main(java.lang.String[]);
    descriptor: ([Ljava/lang/String;)V
    flags: ACC_PUBLIC, ACC_STATIC
    Code:
      stack=0, locals=1, args_size=1
         0: return
      LineNumberTable:
        line 12: 0
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0       1     0  args   [Ljava/lang/String;

  public synchronized void methodA();
    descriptor: ()V
    flags: ACC_PUBLIC, ACC_SYNCHRONIZED // 这里
    Code:
      stack=2, locals=3, args_size=1
         0: aload_0
         1: getfield      #3                  // Field lock:Ljava/lang/Object;
         4: dup
         5: astore_1
         6: monitorenter // 这里
         7: aload_1
         8: monitorexit  // 这里
         9: goto          17
        12: astore_2
        13: aload_1
        14: monitorexit // 这里
        15: aload_2
        16: athrow
        17: return
      Exception table:
         from    to  target type
             7     9    12   any
            12    15    12   any
      LineNumberTable:
        line 15: 0
        line 17: 7
        line 18: 17
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0      18     0  this   Lcom/example/demo/testSolution/SynchronizedDemo;
      StackMapTable: number_of_entries = 2
        frame_type = 255 /* full_frame */
          offset_delta = 12
          locals = [ class com/example/demo/testSolution/SynchronizedDemo, class java/lang/Object ]
          stack = [ class java/lang/Throwable ]
        frame_type = 250 /* chop */
          offset_delta = 4
}
SourceFile: "SynchronizedDemo.java"

同步代码

大家可以看到几处我标记的,我在最开始提到过对象头,他会关联到一个monitor对象。

  • 当我们进入一个人方法的时候,执行monitorenter,就会获取当前对象的一个所有权,这个时候monitor进入数为1,当前的这个线程就是这个monitor的owner。
  • 如果你已经是这个monitor的owner了,你再次进入,就会把进入数+1.
  • 同理,当他执行完monitorexit,对应的进入数就-1,直到为0,才可以被其他线程持有。

所有的互斥,其实在这里,就是看你能否获得monitor的所有权,一旦你成为owner就是获得者。

同步方法

不知道大家注意到方法那的一个特殊标志位没,ACC_SYNCHRONIZED

同步方法的时候,一旦执行到这个方法,就会先判断是否有标志位,然后,ACC_SYNCHRONIZED会去隐式调用刚才的两个指令:monitorenter和monitorexit。

所以归根究底,还是monitor对象的争夺。

monitor

我说了这么多次这个对象,大家是不是以为就是个虚无的东西,其实不是,monitor监视器源码是C++写的,在虚拟机的ObjectMonitor.hpp文件中。

我看了下源码,他的数据结构长这样:

  ObjectMonitor() {
    _header       = NULL;
    _count        = 0;
    _waiters      = 0,
    _recursions   = 0;  // 线程重入次数
    _object       = NULL;  // 存储Monitor对象
    _owner        = NULL;  // 持有当前线程的owner
    _WaitSet      = NULL;  // wait状态的线程列表
    _WaitSetLock  = 0 ;
    _Responsible  = NULL ;
    _succ         = NULL ;
    _cxq          = NULL ;  // 多线程竞争锁进入时的单向链表
    FreeNext      = NULL ;
    _EntryList    = NULL ;  // 处于等待锁状态block状态的线程列表
    _SpinFreq     = 0 ;
    _SpinClock    = 0 ;
    OwnerIsThread = 0 ;
    _previous_owner_tid = 0;
  }

synchronized底层的源码就是引入了ObjectMonitor。

大家说熟悉的锁升级过程,其实就是在源码里面,调用了不同的实现去获取获取锁,失败就调用更高级的实现,最后升级完成。

5.重量级锁

大家在看ObjectMonitor源码的时候,会发现Atomic::cmpxchg_ptr,Atomic::inc_ptr等内核函数,对应的线程就是park()和upark()。

这个操作涉及用户态和内核态的转换了,这种切换是很耗资源的,所以知道为啥有自旋锁这样的操作了吧,按道理类似死循环的操作更费资源才是对吧?其实不是,大家了解一下就知道了。

先解释下用户态和内核态:

Linux系统的体系结构大家大学应该都接触过了,分为用户空间(应用程序的活动空间)和内核。

我们所有的程序都在用户空间运行,进入用户运行状态也就是(用户态),但是很多操作可能涉及内核运行,比我I/O,我们就会进入内核运行状态(内核态)。
在这里插入图片描述

这个过程是很复杂的,也涉及很多值的传递,我简单概括下流程:

  1. 用户态把一些数据放到寄存器,或者创建对应的堆栈,表明需要操作系统提供的服务。
  2. 用户态执行系统调用(系统调用是操作系统的最小功能单位)。
  3. CPU切换到内核态,跳到对应的内存指定的位置执行指令。
  4. 系统调用处理器去读取我们先前放到内存的数据参数,执行程序的请求。
  5. 调用完成,操作系统重置CPU为用户态返回结果,并执行下个指令。

所以大家一直说,1.6之前是重量级锁,没错,但是他重量的本质,是ObjectMonitor调用的过程,以及Linux内核的复杂运行机制决定的,大量的系统资源消耗,所以效率才低。

还有两种情况也会发生内核态和用户态的切换:异常事件和外围设备的中断 大家也可以了解下。

6.锁优化升级

由于1.6之前Synchronized一直是重量级锁,在多线程中效率堪忧,所以1.6之后对其进行了锁优化升级,升级方向如下:

无锁——>偏向锁——>轻量级锁——>重量级锁

大致的过程如下图:

在这里插入图片描述

几种锁会随着竞争情况逐渐升级,锁的升级很容易发生,但是锁降级发生的条件会比较苛刻,锁降级发生在Stop The World期间,当JVM进入安全点的时候,会检查是否有闲置的锁,然后进行降级。

关于锁降级有两点说明:

1.不同于大部分文章说锁不能降级,实际上HotSpot JVM 是支持锁降级的,文末有链接。

2.上面提到的Stop The World期间,以及安全点,这些知识是属于JVM的知识范畴,本文不做细讲。

6.1偏向锁

之前我提到过了,对象头是由Mark Word和Klass pointer 组成,锁争夺也就是对象头指向的Monitor对象的争夺,一旦有线程持有了这个对象,标志位修改为1,就进入偏向模式,同时会把这个线程的ID记录在对象的Mark Word中。

这个过程是采用了CAS乐观锁操作的,每次同一线程进入,虚拟机就不进行任何同步的操作了,对标志位+1就好了,不同线程过来,CAS会失败,也就意味着获取锁失败。

偏向锁在1.6之后是默认开启的,1.5中是关闭的,需要手动开启参数是xx:-UseBiasedLocking=false。

在这里插入图片描述

偏向锁关闭,或者多个线程竞争偏向锁怎么办呢?

6.2 轻量级锁

还是跟Mark Work 相关,如果这个对象是无锁的,jvm就会在当前线程的栈帧中建立一个叫锁记录(Lock Record)的空间,用来存储锁对象的Mark Word 拷贝。

然后线程尝试用CAS将锁的Mark Word替换为指向Lock Record的指针,成功就说明加锁成功,改变锁标志位,执行相关同步操作。

如果失败了,就会判断当前对象的Mark Word是否指向了当前线程的栈帧,是则表示当前的线程已经持有了这个对象的锁,否则说明被其他线程持有了,继续锁升级,修改锁的状态,之后等待的线程也阻塞。

在这里插入图片描述

6.3自旋锁

我不是在上面提到了Linux系统的用户态和内核态的切换很耗资源,其实就是线程的等待唤起过程,那怎么才能减少这种消耗呢?

自旋,过来的现在就不断自旋,防止线程被挂起,一旦可以获取资源,就直接尝试成功,直到超出阈值,自旋锁的默认大小是10次,-XX:PreBlockSpin可以修改。

但是JDK采用了更聪明的方式——适应性自旋,简单来说就是线程如果自旋成功了,则下次自旋的次数会更多,如果自旋失败了,则自旋的次数就会减少。

自旋也不是一直进行下去的,如果自旋到一定程度(和JVM、操作系统相关),依然没有获取到锁,称为自旋失败,那么这个线程会阻塞。同时这个锁就会升级成重量级锁。

在这里插入图片描述

7.锁升级流程?

每一个线程在准备获取共享资源时:
第一步,检查MarkWord里面是不是放的自己的ThreadId ,如果是,表示当前线程是处于 “偏向锁” 。

第二步,如果MarkWord不是自己的ThreadId,锁升级,这时候,用CAS来执行切换,新的线程根据MarkWord里面现有的ThreadId,通知之前线程暂停,之前线程将Markword的内容置为空。

第三步,两个线程都把锁对象的HashCode复制到自己新建的用于存储锁的记录空间,接着开始通过CAS操作,
把锁对象的MarKword的内容修改为自己新建的记录空间的地址的方式竞争MarkWord。

第四步,第三步中成功执行CAS的获得资源,失败的则进入自旋 。

第五步,自旋的线程在自旋过程中,成功获得资源(即之前获的资源的线程执行完成并释放了共享资源),则整个状态依然处于 轻量级锁的状态,如果自旋失败 。

第六步,进入重量级锁的状态,这个时候,自旋的线程进行阻塞,等待之前线程执行完成并唤醒自己。

8.各种锁的优缺点?

在这里插入图片描述

9.Synchronized和Lock比较?

我们先看看他们的区别:

  • synchronized是关键字,是JVM层面的底层啥都帮我们做了,而Lock是一个接口,是JDK层面的有丰富的API。
  • synchronized会自动释放锁,而Lock必须手动释放锁。
  • synchronized是不可中断的,Lock可以中断也可以不中断。
  • 通过Lock可以知道线程有没有拿到锁,而synchronized不能。
  • synchronized能锁住方法和代码块,而Lock只能锁住代码块。
  • Lock可以使用读锁提高多线程读效率。
  • synchronized是非公平锁,ReentrantLock可以控制是否是公平锁。

两者一个是JDK层面的一个是JVM层面的,我觉得最大的区别其实在,我们是否需要丰富的api。

三、总结

开始我们首先介绍了Synchronized的使用场景,之后介绍了Java对象在JVM中的构成、Synchronized如保证有序性,可见性,原子性,可重入性,不可中断性、Synchronized的底层原理和锁升级原理,最后介绍各个锁的优缺点。

可见1.6之后Oracle公司对Synchronized做了极大的优化,更好的提升了锁的性能。

我们不能光只是会调用API,而是应该知道其底层原理,应知其然知其所以然,学习其源码的设计思想,这会潜移默化的改变我们以后设计代码的思路,让我设计出更好更高效的代码,并且在遇到问题的时候快速定位问题所在。不要只做CRUD boy or girl啊,这样又如何提升我们自己呢?

最后引用我很佩服的一个人经常说的话:你知道的越多,你不知道的越多!

文章参考:

https://mp.weixin.qq.com/s/2ka1cDTRyjsAGk_-ii4ngw

https://www.bookstack.cn/read/RedSpider1-concurrent/article-02-9.md

Java锁优化—JVM锁降级



所属网站分类: 技术文章 > 博客

作者:niceboty

链接:http://www.javaheidong.com/blog/article/207108/888fc5406c0920c63884/

来源:java黑洞网

任何形式的转载都请注明出处,如有侵权 一经发现 必将追究其法律责任

22 0
收藏该文
已收藏

评论内容:(最多支持255个字符)