美文网首页
并发 - Synchronized

并发 - Synchronized

作者: sunyelw | 来源:发表于2019-12-21 22:04 被阅读0次

每个对象的心中都有一把锁, 你没有对象的原因是你还没有找到那个钥匙

下面从几个方面来了解Synchronized的用法及底层实现

  • 初识Synchronized
  • 锁的本质 - 对象
  • 锁的种类
  • 锁升级
  • 锁的本质
  • SynchronizedJVM 字节码原语

一、初识Synchronized

官方给出的说明是

Synchronized关键字可以实现一个简单的策略来防止线程干扰和内存一致性错误, 如果一个对象对多个线程是可见的, 那么对该对象的所有读写都将通过同步的方式来进行.

具体表现如下

  • Synchronized关键字提供了一种锁机制, 能够确保共享变量的互斥访问, 从而防止数据不一致的问题的出现
  • Synchronized关键字包括monitorentermonitorexit 两个 JVM指令, 它能够保证在任何时候任何线程执行到monitorenter成功之前都必须从主存获取数据, 而不是从其他缓存中; 在monitorexit执行成功之后, 共享变量更新后的值必须刷回主存
  • Synchronized的指令严格遵守happens-before规则, 一个monitorexit之前必须要有个monitorenter

Synchronized的用法非常简单

  • 可以用于方法
  • 可以用于代码块
// 类锁 1
public static void show1(){
    synchronized (SyncClass.class) {
        // doSomeThing
    }
}
// 类锁 2
public synchronized static void show2(){
    // doSomeThing
}
// 对象锁 1
public void show3(){
    synchronized (this) {
        // doSomeThing
    }
}
// 对象锁 2
public synchronized void show4(){
    // doSomeThing
}
  • 锁住实例方法与锁住实例字段效果一样, 锁住静态方法与锁住Class对象效果一样

那么锁住的是什么?

不管 synchronized (SyncClass.class) 还是 synchronized (this), 括号内的都是一个对象, 锁就是与这个对象关联的一个monitor对象(又称 mutex 互斥量), 哪个线程持有这把锁, 谁就可以执行这把锁内部的同步代码

二、锁的本质 - 对象

引用<<java并发编程实战>>的一段话

每个java对象都可以用做一个实现同步的锁, 这些锁被称为内置锁(Intrinsic Lock)或者监视器锁(Monitor Lock). 线程在进入同步代码块之前会自动获得锁, 并且在退出同步代码块时自动释放锁.
获得内置锁的唯一途径就是进入由这个锁保护的同步代码块或方法.

既然每个对象都能成为一把锁, 那么与锁相关的monitor一定要有一个抽象出来的具有锁需要信息的类, 这个类就是Object

需要什么信息呢?

对象组成
  • 实例变量存储的是对象的属性信息,包括父类的属性信息,按照4字节对齐
  • 填充字符,因为虚拟机要求对象字节必须是8字节的整数倍,填充字符就是用于凑齐这个整数倍的

每个对象都有一个对象头, 那么很明显, 锁需要的信息都存在这里面了.

那么对象头里面又是什么样的呢?

所有对象都有一个对象头, 就好比一个请求都有请求头与请求体, 所有的请求其请求头的格式都是一样的, 同样的, 所有对象头的格式也是有规定的.

以下举例都是基于32JVM

对象头的组成

  • 普通对象头(64bit)
32 bits 32 bits
Mark Word Klass Word
  • 数组对象头(96bits)
32 bits 32 bits 32 bits
Mark Word Klass Word array length
  • Mark Word 对象头, 记录对象基本信息identity_hashcode/age
  • Klass Word 指向当前对象的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例
  • array length 数组对象的长度

如果对象是数组对象,那么对象头占用3个字宽(Word),如果对象是非数组对象,那么对象头占用2个字宽。(32位JVM一个字两个字节, 64位JVM一个字四个字节)

这里我们关心的就是 32 bitsMark Word部分

Mark Word< 32 bits > state
identity_hashcode:25 / age:4 / biased_lock:1 / lock:2 Normal
thread:23 / epoch:2 / age:4 / biased_lock:1 / lock:2 Biased
ptr_to_lock_record:30 / lock:2 Lightweight Locked
ptr_to_heavyweight_monitor:30 / lock:2 Heavyweight Locked
lock:2 Marked for GC

上表列出了对象的五种状态及其对应的Mark Word各部分值

  • identity_hashcode:25 25位的对象标识Hash码,采用延迟加载技术。调用方法System.identityHashCode()计算,并会将结果写到该对象头中。当对象被锁定时,该值会移动到管程Monitor
  • epoch 偏向时间戳
  • age:4 4位的GC分代年龄,在堆中对象每活过一次GC,即在Survivor之间复制一次,GC年龄加一,到达一定值晋升老年代,这个值就记录在对象头的Mark Wordage中,age只有4位,最大就是15,而PS + PO组合的默认阈值就是15, CMS的是6, 可通过虚拟机启动选项-XX:MaxTenuringThreshold进行指定老年代阈值
  • lock:2Mark Word 的最后两位, 用于表示锁状态, 它与倒数第三位的biased_lock:1 一起判断出当前对象的状态
  • GC标记 在垃圾回收过程中涉及到对象的复制,复制过程中的对象不允许操作,这种状态就是GC 标记
biased_lock lock State
0 01 无锁 Normal
1 01 偏向锁 Biased
0 00 轻量级锁 Lightweight Locked
0 10 重量级锁 Heavyweight Locked
0 11 GC标记 Marked for GC

剩余的两种 ptr_to_lock_record:30ptr_to_heavyweight_monitor:30 与对象如何在这些状态之间进行转换的在锁升级时细说

三、锁的种类

关于锁分类, 网上有一堆说法:乐观/悲观内置/显式自旋/挂起等。

其实只是从不同角度来看进行的分类,从源码实现来看我比较喜欢内置/显式这个组合。

加锁有几种方式

  • Synchronized
  • Synchronized以外,比如著名的可重入锁ReentrantLock <JDK1.5>

Synchronized是一种内置锁,又称监视器锁(monitor),使用非常简单,不需要显式的获取和释放,任何一个对象都能作为一把锁,故称之为内置锁

内置锁认为短时间内根本获取不到锁,所以来抢占锁时根本不等待直接就释放自己的CPU时间片, 这种是阻塞式等待。

而对应的显示锁是一种乐观锁,它们觉得很快就能获得锁,故在锁门口自旋等待锁释放(自旋锁由此得名),这种响应快,但耗CPU资源,后文会讲到其优化。

四、锁升级

对象总共有五种状态, 除GC 标记外以下四种

  • 无锁
  • 偏向锁
  • 轻量级锁
  • 重量级锁
对象五种状态

再补两张图


32位JVM markword 64位JVM markword

来分别看看各个状态对象头的Mark Word都是如何转化的.

  1. 创建对象 - NORMAL
名称
identity_hashcode <25> System.identityHashCode(Class<?>)
age <4> 0000
biased_lock <1> 0
lock <2> 01
  1. 线程 T1进行加锁
名称
thread <23> t1.getId()
epoch <2> 00
age <4> 0000
biased_lock <1> 1
lock <2> 01

偏向锁不会主动释放锁,所以偏向锁的加锁有点复杂

  • 加锁的过程需要进行判断
    • 如果不存在偏向锁,将identity_hashcode复制到栈中保存并用当前线程ID与epoch替代
    • 存在偏向锁,比较当前线程ID与锁关联的对象头中线程ID是否一致
      • 一致,重入值加一,无需CAS竞争直接进入同步代码执行
      • 不一致,需要查看对象头中的线程ID代表线程是否存活或是否仍需要持有偏向锁
        • 不存活或不需要持有,撤销偏向锁,对象置为无锁状态,当前线程进行偏向锁的获取
        • 如果存活且仍需要持有此偏向锁,那么暂停当前线程,撤销偏向锁,升级为轻量级锁
  1. 轻量级锁
    轻量级锁的获取需要不同线程间的竞争,比如T1T2
  • 虚拟机栈中新开辟一块内存Lock-Record
  • Mark word中除lock标志位外的其他信息复制一份
  • 存在虚拟机栈中的新开辟出来的Lock-Record
  • 然后进行CAS替换Mark Word中的信息为指向Lock-Record的指针LR-Pointer
  • 成功者获取轻量级锁,失败者自旋等待
名称
LR-Pointer <30> ---
lock <2> 00

Lock-Record 官方名称应该是 DisplacedMarkWord

  1. 重量级锁

轻量级锁时等待线程都在自旋,如果一把锁被持有时间太久或等待线程过多,那线程自旋所消耗的CPU都是一个不容小觑的损失。

所以在满足以下条件时,轻量级锁会升级为重量级锁

  • 自旋十次以上
  • 自旋线程数量超过内核数量二分之一

跟轻量级锁一样前面放的都是一个指针,不过重量级锁是一个互斥量

  • 生成或复用monitor对象
  • Mark Word指向monitor对象<mutex对象>
  • 自旋线程进入_EntrySet阻塞,不在自旋让出CPU资源
  • 升级重量级锁成功
名称
Mutex-Pointer <30> ---
lock <2> 10

重量级锁由操作系统发放

锁升级基本讲完了,有几个问题

  • 为什么要引入偏向锁?
    其实偏向锁就是可重入锁,同一个线程不用二次争抢资源才能获取锁,这是因为很多情况下,都是同一个线程对同一把锁进行加锁、解锁,引入偏向锁就是为了减少这种“无谓”开销。
  • 为什么要引入轻量级锁?
    轻量级锁考虑的是竞争锁对象的线程不多,而且线程持有锁的时间也不长的情景。因为阻塞线程需要CPU从用户态转到内核态,代价较大,如果刚刚阻塞不久这个锁就被释放了,那这个代价就有点得不偿失了,因此这个时候就干脆不阻塞这个线程,让它自旋这等待锁释放。
  • 为什么要引入重量级锁?
    设想一种极端情况,上亿个线程自旋等待一把轻量级锁或者一个线程等一把轻量级锁自旋上亿次,那么CPU资源都被这些自旋操作消耗完了,属于极大的浪费,就升级为重量级锁,让这些线程全部阻塞停止“无谓”的自旋。
三种锁的比较

注意几点

  • 锁可以升级不可以降级
  • 偏向锁状态可以被重置为无锁状态
  • 虽然提倡锁住的代码尽可能小,减小同步代码执行时间就是减小线程等待时间,但如果在一个大代码块中存在很多小的同步代码块造成的加锁、解锁损耗也是非常可观的,此时不如用一个大的同步代码块来避免过多的加锁、解锁,这就是 锁粗化
  • JIT在编译时通过对运行上下文的扫描,经过逃逸分析,去除不可能存在共享资源竞争的锁,通过这种方式消除没有必要的锁,可以节省毫无意义的请求锁时间, 这就是锁消除
-XX:BiasedLockingStartUpDelay=0
  • 偏向锁是默认开启的,而且开始时间一般是比应用程序启动慢几秒,该选项取消了这个延迟
-XX:-UseBiasedLocking = false
  • 撤销了偏向锁

五、锁的本质

现在我们应该知道了, Synchronized是一把重量级锁.

上面说到锁的本质其实是与锁住对象相关的那个monitor对象, 那么monitor对象又是个什么东东呢?

thread-monitor-object

在Java虚拟机(HotSpot)中,monitor是由ObjectMonitor <源码> 实现的

 ObjectMonitor() {
    _header       = NULL;
    _count        = 0;
    _waiters      = 0,
    _recursions   = 0;
    _object       = NULL;
    _owner        = NULL;
    _WaitSet      = NULL;
    _WaitSetLock  = 0 ;
    _Responsible  = NULL ;
    _succ         = NULL ;
    _cxq          = NULL ;
    FreeNext      = NULL ;
    _EntryList    = NULL ;
    _SpinFreq     = 0 ;
    _SpinClock    = 0 ;
    OwnerIsThread = 0 ;
    _previous_owner_tid = 0;
 }

看看几个字段

  • _count 记录该对象被线程获取锁的次数
  • _recursions 锁的重入次数
  • _owner 指向持有ObjectMonitor对象的线程
  • _WaitSet 处于wait状态的线程,会被加入到_WaitSet
  • _EntryList 处于等待锁block状态的线程,会被加入到该列表
线程状态转化

通过几种操作来看看重量级锁的玩法

  • 线程T1与线程T2竞争执行,T1成功,这把锁的_owner置为T1T2进入_EntryList等待<Blocking>
  • 线程T1执行wait()方法释放锁,进入_WaitSet,这把锁的_owner置空
  • _EntryList中线程开始竞争执行,假设T2竞争成功,这把锁的_owner置为T2,其他线程继续在_EntryList中阻塞等待
  • _WaitSet中线程等待时间到或者被唤醒,进入_EntryList中重新竞争
  • 最后一个线程执行完成,释放锁,_owner置空

六、SynchronizedJVM 字节码原语

写段代码

public class SynchronizedClass {

    public void show() {
        synchronized (SynchronizedClass.class) {
            System.out.println("xx");
        }
    }
}

只有一个同步块,反编译一下

sunyelw@windows:tst$ javap -c SynchronizedClass.class
Compiled from "SynchronizedClass.java"
public class com.hy.demo.tst.SynchronizedClass {
  public com.hy.demo.tst.SynchronizedClass();
    Code:
       0: aload_0
       1: invokespecial #1                  // Method java/lang/Object."<init>":()V
       4: return

  public void show();
    Code:
       0: ldc           #2                  // class com/hy/demo/tst/SynchronizedClass
       2: dup
       3: astore_1
       4: monitorenter
       5: getstatic     #3                  // Field java/lang/System.out:Ljava/io/PrintStream;
       8: ldc           #4                  // String xx
      10: invokevirtual #5                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
      13: aload_1
      14: monitorexit
      15: goto          23
      18: astore_2
      19: aload_1
      20: monitorexit
      21: aload_2
      22: athrow
      23: return
    Exception table:
       from    to  target type
           5    15    18   any
          18    21    18   any
}
sunyelw@windows:tst$

把同步相关的指令拎出来

4: monitorenter
....
14: monitorexit
...
20: monitorexit
  • 一个monitorexit至少对一个monitorenter
  • 之所以有两个monitorexit,是要保证哪怕执行异常也要保证锁的正常释放,这也是内部锁与显式锁最明显的区别,不用手动释放

再看看另一种写法

public class SynchronizedClass {

    public synchronized void show() {
        System.out.println("xx");
    }
}

继续反汇编一下

public synchronized void show();
    descriptor: ()V
    flags: ACC_PUBLIC, ACC_SYNCHRONIZED
    Code:
      stack=2, locals=1, args_size=1
         0: getstatic     #2                  // Field java/lang/System.out:Ljava/io/PrintStream;
         3: ldc           #3                  // String xx
         5: invokevirtual #4                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
         8: return
      LineNumberTable:
        line 14: 0
        line 15: 8
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0       9     0  this   Lcom/hy/demo/tst/SynchronizedClass;
}
  • 这种直接加在方法上就没有把JVM指令打出来,而是在flags上直接加了个ACC_SYNCHRONIZED

Synchronized方法同步不再是通过插入monitorentermonitorexit指令实现,而是由方法调用指令来读取运行时常量池中的ACC_SYNCHRONIZED标志隐式实现的,如果方法表结构(method_info Structure)中的ACC_SYNCHRONIZED标志被设置,那么线程在执行方法前会先去获取对象的monitor对象,如果获取成功则执行方法代码,执行完毕后释放monitor对象,如果monitor对象已经被其它线程获取,那么当前线程被阻塞。

参考文献
Java对象头详解
Synchronized详解
Java并发-Synchronized


今天写了一天,真的有点菜,自闭一会吃个饭再继续~~~

相关文章

网友评论

      本文标题:并发 - Synchronized

      本文链接:https://www.haomeiwen.com/subject/ykugnctx.html