并发编程-synchronized同步锁

ragnar 10月前 ⋅ 243 阅读

1 什么是synchronized同步锁?

synchronized,是同步的意思,是java中synchronized锁的关键字。
synchronized锁也就是同步锁,是JVM实现的,是一种对象锁(锁的是对象,而非引用), 可以用来实现对临界资源的互斥访问,保证线程并发安全。

临界资源:是在多线程编程中的一种共享的可变的的资源。比如:对象、变量、文件等等。

  • 共享,就是可以被多个线程访问;
  • 可变,就是可以被修改。

java的同步锁具有以下特点

  • 互斥、独享:当一个线程获取到了锁,其它线程也要获取就只能等待该线程释放锁。
  • 可重入:一个获得锁的线程,可以多次获得该锁。
    • 锁对象内有一个计数器,无锁状态是0,每获得一次锁就+1,每释放一次锁就-1。
  • 不可中断:等待锁的线程不能被中断。
    • 线程在等待synchronized同步锁时,调用interrupt方法,不能中断其阻塞状态被唤醒。
    • 等到线程拿到锁后,调用interrupted方法才能拿到这个中断信号。

2 怎么用synchronized?

  • 同步实例方法:加在类的成员方法上面,锁的当前的实例对象
  • 同步静态方法:加在类的静态方法上面,锁的是当前的类对象
  • 同步代码块:锁的是括号内的对象

Demo如下:

public class Demo {
  /**
   * 同步实例方法
   */
  public synchronized void test1() {
    System.out.println("同步实例方法");
  }

  /**
   * 同步静态方法
   */
  public static synchronized void test2() {
    System.out.println("同步静态方法");
  }

  /**
   * 同步代码块
   */
  public void test3() {
    synchronized (this) {
      System.out.println("同步代码块");
    }
  }
}

3 锁的实现原理

前面提到同步锁是JVM实现,那么JVM是通过什么判断我们的代码要加同步锁呢?

3.1 编译阶段

代码在被编译成字节码时,加入特殊的标记。

同步实例方法同步静态方法都差不多,我们用上面那个Demo,编译后通过javap命令来获取其编译后的附加信息。

javac -encoding utf8 Demo.java
javap -v Demo.class

输出的结果如下:

Classfile /D:/code/demo/Demo.class
  Last modified 2024-2-29; size 671 bytes
  MD5 checksum 5ba0185f8f0c63f2a3c36d5d4ad53190
  Compiled from "Demo.java"
public class Demo
  minor version: 0
  major version: 52
  flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
   #1 = Methodref          #8.#22         // java/lang/Object."<init>":()V
   #2 = Fieldref           #23.#24        // java/lang/System.out:Ljava/io/PrintStream;
   #3 = String             #25            // 同步实例方法
   #4 = Methodref          #26.#27        // java/io/PrintStream.println:(Ljava/lang/String;)V
   #5 = String             #28            // 同步静态方法
   #6 = String             #29            // 同步代码块
   #7 = Class              #30            // Demo
   #8 = Class              #31            // java/lang/Object
   #9 = Utf8               <init>
  #10 = Utf8               ()V
  #11 = Utf8               Code
  #12 = Utf8               LineNumberTable
  #13 = Utf8               test1
  #14 = Utf8               test2
  #15 = Utf8               test3
  #16 = Utf8               StackMapTable
  #17 = Class              #30            // Demo
  #18 = Class              #31            // java/lang/Object
  #19 = Class              #32            // java/lang/Throwable
  #20 = Utf8               SourceFile
  #21 = Utf8               Demo.java
  #22 = NameAndType        #9:#10         // "<init>":()V
  #23 = Class              #33            // java/lang/System
  #24 = NameAndType        #34:#35        // out:Ljava/io/PrintStream;
  #25 = Utf8               同步实例方法
  #26 = Class              #36            // java/io/PrintStream
  #27 = NameAndType        #37:#38        // println:(Ljava/lang/String;)V
  #28 = Utf8               同步静态方法
  #29 = Utf8               同步代码块
  #30 = Utf8               Demo
  #31 = Utf8               java/lang/Object
  #32 = Utf8               java/lang/Throwable
  #33 = Utf8               java/lang/System
  #34 = Utf8               out
  #35 = Utf8               Ljava/io/PrintStream;
  #36 = Utf8               java/io/PrintStream
  #37 = Utf8               println
  #38 = Utf8               (Ljava/lang/String;)V
{
  public Demo();
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
      stack=1, locals=1, args_size=1
         0: aload_0
         1: invokespecial #1                  // Method java/lang/Object."<init>":()V
         4: return
      LineNumberTable:
        line 1: 0

  public synchronized void test1();
    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 同步实例方法
         5: invokevirtual #4                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
         8: return
      LineNumberTable:
        line 6: 0
        line 7: 8

  public static synchronized void test2();
    descriptor: ()V
    flags: ACC_PUBLIC, ACC_STATIC, ACC_SYNCHRONIZED
    Code:
      stack=2, locals=0, args_size=0
         0: getstatic     #2                  // Field java/lang/System.out:Ljava/io/PrintStream;
         3: ldc           #5                  // String 同步静态方法
         5: invokevirtual #4                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
         8: return
      LineNumberTable:
        line 13: 0
        line 14: 8

  public void test3();
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
      stack=2, locals=3, args_size=1
         0: aload_0
         1: dup
         2: astore_1
         3: monitorenter
         4: getstatic     #2                  // Field java/lang/System.out:Ljava/io/PrintStream;
         7: ldc           #6                  // String 同步代码块
         9: invokevirtual #4                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
        12: aload_1
        13: monitorexit
        14: goto          22
        17: astore_2
        18: aload_1
        19: monitorexit
        20: aload_2
        21: athrow
        22: return
      Exception table:
         from    to  target type
             4    14    17   any
            17    20    17   any
      LineNumberTable:
        line 20: 0
        line 21: 4
        line 22: 12
        line 23: 22
      StackMapTable: number_of_entries = 2
        frame_type = 255 /* full_frame */
          offset_delta = 17
          locals = [ class Demo, class java/lang/Object ]
          stack = [ class java/lang/Throwable ]
        frame_type = 250 /* chop */
          offset_delta = 4
}
SourceFile: "Demo.java"

首先,我们可以看到同步实例方法同步静态方法上面都添加了ACC_SYNCHRONIZED的flags标识,根据这个JVM就知道要给调用的线程去获取同步锁了。

另外,同步静态方法上面还有ACC_STATIC标识,那么JVM读取到后,应该就知道要获取的是类对象的同步锁,而不是一个实例对象的。

最后看同步代码块的方法内,有一个monitorenter的指令,JVM执行到这里应该就是要去获取锁了。但这里有两个monitorexit指令,这是为什么呢? 看到那个异常表(Exception table)的了吧,那里target指向了第17行指令,那就清楚了。后一个monitorexit是异常退出时,要去释放锁。

小结

  • 同步实例方法,编译后的flagsACC_SYNCHRONIZED标记;
  • 同步静态方法,编译后的flagsACC_SYNCHRONIZEDACC_STATIC标记;
  • 同步代码块,编译后有一个monitorenter的指令和两个monitorexit指令。

3.2 运行阶段

3.2.1 Monitor对象

同步锁是一种对象锁,那是因为每个java的对象里面都有一个内置的Monitor对象, C++写的,底层依赖于操作系统的Mutex lock(互斥锁)实现的锁。 给个JVM原码感受一下:

ObjectMonitor() {
_header = NULL;
_count = 0; // 计数器,为0时为无锁状态
_waiters = 0,
_recursions = 0;
_object = NULL;
_owner = NULL; // 指向持有ObjectMonitor对象的线程
_WaitSet = NULL; // 处于wait状态的线程,会被加入到_WaitSet
_WaitSetLock = 0 ;
_Responsible = NULL ;
_succ = NULL ;
_cxq = NULL ;
FreeNext = NULL ;
_EntryList = NULL ; // 处于等待锁block状态的线程,会被加入到该列表
_SpinFreq = 0 ;
_SpinClock = 0 ;
OwnerIsThread = 0 ;
}

Monitor对象的主要属性有:

  • _count ---是一个计数器;
  • _owner ---指向持有Monitor对象的线程;
  • _WaitSet ---等待状态(Waiting 或 TimedWaiting)线程的集合,原持有Monitor对象的线程调用wait方法进入此集合;
  • _EntryList ---Blocking状态线程的集合,是在等待锁的;

3.2.2 对象头的 Mark Word

首先,我们看看对象的内存分布。一个对象在内存中可分为三块区域:

  • 对象头
    • Mark Word
      • 存储对象自身的运行时数据。比如:哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等。
      • 大小根据虚拟机位数而不同。32位虚拟机的就32bit,64位虚拟机的就64bit。
    • 元数据指针:指向类对象。
    • 数组长度:数组对象才有。
  • 实例数据:存放的是对象的属性数据。
  • 对齐填充:虚拟要求对象的大小是8字节的整数倍,不够就填充对齐。

32位虚拟机,对象头的Mark Word的内存区域(32 bit)是这划分的: 3_synchronized_对象头_Mark_Word_32.png

64位虚拟机,对象头的Mark Word的内存区域(64 bit)是这划分的: 3_synchronized_对象头_Mark_Word_64.png

另外,JVM默认会开启指针压缩。就是在堆内存不大(小于32GB)的情况下,会对以下信息进行压缩:

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

3.2.3 获取锁过程

线程执行monitorenter指令时,就会尝试去获取Monitor对象的所有权,也就是去获取同步锁:

  • 无锁状态下,线程成功将Monitor对象的计数器+1后,Monitor对象的持有者属性_owner就会指向当前当前线程,表示线程已成功获取了锁;
  • 如果线程已经是持有者,再次获取时,Monitor对象的计数器就会再+1;
  • 如果其它线程持有的锁,那么当前线程会进入(block)阻塞状态,等待锁被释放。
    • 同步锁是非公平锁,在线程进入阻塞状态之前可以尝试的去获取锁,竞争到就能成为持有者,这对已经在排队的线程是不公平的;
    • 没有获取到同步锁的线程,进入阻塞状态后,会放到一个等待队列(_EntryList)去排队等待;
    • 等待队列的线程在获取锁之前,还要和新来的线程去竞争锁;

此外,持有同步锁的线程:

  • 如果进入了等待状态(Waiting 或 TimedWaiting),都会释放持有的同步锁,进入Monitor对象的_WaitSet集合中;
  • _WaitSet集合的线程被唤醒了,就会进入到等待队列(_EntryList)中去获取;

3_synchronized_同步锁竞争.png

3.2.4 释放锁过程

线程执行monitorexit指令时,必是锁的持有者:

  • Monitor对象的计数器-1;
  • 如果Monitor对象的计数器减为0,表示释放锁,Monitor对象的持有者属性_owner就不再指向当前线程。

4 锁的优化

同步锁是一个重量级锁,因为线程获取锁失败就得进入阻塞(Blocking)状态,这里会涉及到线程上下文的“用户态”和“核心态”间的切换,是比较消耗性能。

所以java后来对同步锁做一些优化,引入了锁粗化锁消除自旋锁等技术来减少锁操作的开销。JDK6引入了偏向锁轻量级锁来应对并发不大的场景。

4.1 锁粗化 & 锁消除

锁粗化:线程在执行过程中多次去获取同一把锁的操作,JVM给优化成只去获取一次。

  • 比如:在用 StringBuffer 时的一连串 append 操作(append方法上加了 synchronized 关键字),JVM会在第一个 append 操作扩展到最后一个 append 操作,只加锁一次。

锁消除:是JVM经过逃逸分析后,认定不存在锁竞争,就自动把获取锁的操作优化掉了。

  • 比如: 还是 StringBuffer 的 append 方法。在很多情况下,StringBuffer 的引用只限于在一个普通方法内,其它线程根本无法访问到该引用。虽然 append 方法上有锁,但是被JVM安全的消除掉了。

什么是逃逸分析

逃逸分析:就是分析对象动态作用域,当一个对象在方法中被定义后,它可能被外部方法所引用,例如作为调用参数传递到其他地方中。

4.2 自旋锁

自旋锁这个锁优化是线程尝试获取锁(CAS操作)失败后,不会立刻进入阻塞状态。而是先自旋一下,再尝试获取锁,直到自旋次数达到了限制。

在锁竞争不激烈的情况,线程是可以很快获取到锁。这种自旋的机制可以避免线程从用户态核心态之间的切换,能很好的提高并发性能。

5 锁升级过程

5.1 偏向锁

什么是偏向锁?

  • 字面意思就是偏向某一个线程
  • 在锁对象头的MarkWord中:
    • 锁标志位为01;
    • 是否偏向锁为1;
    • 线程ID栏指向的是获得锁的线程。为空就是可偏向,是无锁状态。

为什么要用偏向锁呢?
据说,因为大佬们发现,大多数情况锁总是同一线程获得。 为了减少获得锁(CAS操作)的代价,偏向锁会在锁对象的MarkWord上记录了已获得锁的线程ID,下次这个线程再来获取锁时就不用做同步操作了。

那么下次来获取锁的不是这个已经记录的线程怎么办?
那就得升级为轻量级锁了。 当线程都到达安全点(就是JVM垃圾回收时,暂停掉所有用户线程的这段时间,也叫Stop The World,STW)时:

  • 锁对象头的MarkWord是否偏向锁的标识,由1设置为0,这就是偏向锁撤消
  • 锁标志位改为00;
  • 如果持有锁的线程还在同步代码中,那就升级为轻量级锁

其它

  • 对象头MarkWord在偏向锁状态有个Epoch,这个是干嘛用的呢?

    • 实例对象的Epoch初始值是其Class对象Epoch;
    • 每个Class对象维护一个偏向锁撤销计数器,每次它的实例对象发生偏向锁撤销,计数器就累加;
    • 当偏向锁撤销计数器累加到阈值,就给来一波批量重偏向
      • 对这个Class的所有正处于加锁状态的偏向锁对象,Epoch值累加1;
      • Epoch值改过后,那么线程就可以获取这些已经偏向其它线程的锁对象了。
  • 锁升级的条件。除了有其它线程来获取偏向锁会导致锁升级外,代码中调用锁对象的hashCode()也就导致锁升级。

  • JDK8,默认是开启偏向锁的,但有延时。想要立即开启需要加以下启动参数:

    -XX:BiasedLockingStartupDelay=0
    
  • 开启偏向锁 -XX:+UseBiasedLocking

  • 关闭偏向锁 -XX:-UseBiasedLocking

5.2 轻量级锁

什么是轻量级锁?

  • 加锁时,JVM会在当前线程的线程栈中开辟一块单独的空间,用CAS操作保存指向对象锁Mark Word的指针,同时在对象锁Mark Word中也用CAS操作保存指向这片空间的指针;
  • 加锁后,在锁对象头的MarkWord中:
    • 有指针指向了栈中锁记录;
    • 锁标志位为00;
  • 解锁时,通过 CAS 操作,把对象锁 Mark Word 和线程栈中的替换回来。

3_synchronized_轻量级锁.png

为什么用轻量级锁?

如果没开启偏向锁的话,那么线程先获取的是轻量级锁。 在线程竞争不激烈的情况下,线程交替执行同步块,那么用轻量级锁能获取不错的性能。

但如果有两个线程同时获取这个锁怎么办?

  • 因为获取轻级量级是用CAS操作,必有一个线程先拿到了锁;
  • 没有获取到锁的线程,会先自旋一下尝试获取锁,失败后就把锁升级为重量级锁:
    • 锁对象头的MarkWord的锁标志位为10;
    • 线程进入阻塞状态,被放到等待队列(_EntryList);
  • 获取到锁的线程执行完同步的代码后,CAS操作必失败,就知道锁已升级为重量级锁了,去正常释放重量级锁的流程。

5.3 总结

  • 偏向锁能应付无竞争场景;
  • 轻量级锁能应付多个线程交替获取锁的场景;
  • 重量级锁是专门处理多个线程同时获取锁的场景。

此外,锁升级是不可逆的


全部评论: 0

    我有话说:

    目录