Java并发之synchronized详解

Java并发之synchronized详解

一、synchronized

synchronized是Java中的关键字,是一种同步锁,他修饰的对象有以下几种:

  • 修饰一个代码块,被修饰的代码块称为同步方法块,其作用的范围是使用大括号括起来的代码,作用的对象是调用这个代码块的对象。
  • 修饰一个方法,被修饰的方法称为同步方法,其作用的范围是整个方法,作用的对象是调用这个方法的对象。
  • 修饰一个静态的方法,其作用的范围是整个静态方法,作用的对象是这个类的所有对象。
  • 修饰一个类,其作用的范围是synchronized后面括起来的部分,作用的对象是这个类的所有对象。

无论synchronized关键字加在方法上还是对象上,

  • 如果它作用的对象是非静态的,则它获取的是对象;
  • 如果它作用的对象是一个静态方法或一个类,则它获取的锁是对于类的,该类的所有对象持有同一把锁。

实现同步是要很大的系统开销作为代价的,甚至可能造成死锁,所以尽量避免不必要的同步控制。

二、synchronized原理

(1)对象头

在JVM中,对象在内存中存储的布局可以分为三个区域,分别是对象头示例数据以及填充数据

  • 对象头:在HotSpot虚拟机中,对象头被分为两部分,分别为:Mark Word(标记字段)、Class Pointer(类型指针),如果是数组,那么还会有数组长度。
  • 实例数据:存储类的属性数据信息,包括父类的属性信息,这部分内存按4字节填充。
  • 填充数据:由于虚拟机要求对象起始地址必须是8字节的整数倍,所以填充数据不是必要存在的,仅仅是为了字节对齐。

而Java对象头则是实现synchronized的锁对象的基础。

(2)Monitor

synchronized 用的锁是存在 Java 对象头里的。如果对象是数组类型,则虚拟机用 3 个字宽(Word)存储对象头,如果对象是非数组类型,则用 2 字宽存储对象头。在 32 位虚拟机中,1 字宽等于 4 字节,即 32bit.

image-20230513000148893

Monitor的结构

Java 对象头里的 Mark Word 里默认存储对象的 HashCode、分代年龄和锁标记位。另外,数组还会有长度的标识。

在运行期间,Mark Word里存储的数据会随着锁的标志位的变化而变化。

image-20230513000405151

(3)工作流程

1、开始时Monitor中的owner为null

2、当线程1执行synchronized(obj)时就会将Monitor的所有者owner置为线程1,Monitor中只能有1个Owner,obj对象中的Mark Word指向Monitor,把对象原有的Mark Word存储线程栈中的锁记录中。

3、线程1上锁的过程中,如果有其他线程来执行synchronized(obj),就会进入EntryList BLOCKED

4、线程1执行完同步代码块的内容,根据 obj 对象头中 Monitor 地址寻找,设置 Owner 为空,把线程栈的锁记录中的对象头的值设置回 MarkWord

5、唤醒 EntryList 中等待的线程来竞争锁,竞争是非公平的,如果这时有新的线程想要获取锁,可能直接就抢占到了,阻塞队列的线程就会继续阻塞

(4)字节码层面分析

public static void main(String[] args) {
Object lock = new Object();
synchronized (lock) {
System.out.println("ok");
}
}
 0 new #2 <java/lang/Object>	// new Object
3 dup
4 invokespecial #1 <java/lang/Object.<init> : ()V> // invokespecial <init>:()V,非虚方法
7 astore_1 // lock引用 -> lock
8 aload_1 // lock (synchronized开始)
9 dup // 一份用来初始化,一份用来引用
10 astore_2 // lock引用 -> slot 2
11 monitorenter // 【将 lock对象 MarkWord 置为 Monitor 指针】
12 getstatic #3 <java/lang/System.out : Ljava/io/PrintStream;>
15 ldc #4 <ok>
17 invokevirtual #5 <java/io/PrintStream.println : (Ljava/lang/String;)V>
20 aload_2 // slot 2(lock引用)
21 monitorexit // 【将 lock对象 MarkWord 重置, 唤醒 EntryList】
22 goto 30 (+8)
25 astore_3 // any -> slot 3
26 aload_2 // slot 2(lock引用)
27 monitorexit // 【将 lock对象 MarkWord 重置, 唤醒 EntryList】
28 aload_3
29 athrow
30 return

实现原理:同步代码块通过moniterenter、moniterexit关联到一个monitor对象,进入时设置Owner为当前线程,计数+1,退出-1,除了正常入口的moniterenter,还在异常入口的地方加入了moniterexit指令。

三、synchronized优化

(1)锁升级

synchronized 是可重入、不公平的重量级锁,所以可以对其进行优化。

在 Java SE 1.6 中,锁一共有 4 种状态,级别从低到高依次是:无锁状态、偏向锁状态、轻量级锁状态和重量级锁状态,这几个状态会随着竞争情况逐渐升级。

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

无锁 -> 偏向锁 -> 轻量级锁 -> 重量级锁	// 随着竞争的增加,只能锁升级,不能降级

image-20230513000606548

① 偏向锁

解决问题:大多数情况下,线程不仅不存在多线程竞争,而且锁总是由同一线程多次获得,在没有其他线程竞争锁时,线程每次重入锁仍然需要进行CAS操作,造成性能的损耗。为了让多线程获得锁的代价更低而引入了偏向锁。

偏向锁加锁:

偏向锁的思想是偏向于让第一个获取锁对象的线程,这个线程之后重新获取该锁不再需要同步操作:

  • 当一个线程访问同步块并获取锁时,会在对象头(标志位是否是01)和栈帧中的锁记录里存储锁偏向的线程 ID,以后该线程在进入和退出同步块时不需要进行 CAS 操作来加锁和解锁,只需简单地测试一下对象头的 Mark Word 里是否存储着指向当前线程的偏向锁。
  • 如果测试成功,表示线程已经获得了锁。
  • 如果测试失败,则需要再测试一下 Mark Word 中偏向锁的标识是否设置成 1(表示当前是偏向锁):如果没有设置,则使用 CAS 竞争锁;如果设置了,则尝试使用CAS 将对象头的偏向锁指向当前线程。

偏向锁撤销:

偏向锁使用了一种等到竞争出现才释放锁的机制,所以当其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁。

偏向锁的撤销,需要等待全局安全点(在这个时间点上没有正在执行的字节码)。

  • 它会首先暂停拥有偏向锁的线程,然后检查持有偏向锁的线程是否活着,如果线程不处于活动状态,则将对象头设置成无锁状态;
  • 如果线程仍然活着,拥有偏向锁的栈会被执行,遍历偏向对象的锁记录,栈中的锁记录和对象头的Mark Word 要么重新偏向于其他线程,要么恢复到无锁或者标记对象不适合作为偏向锁,最后唤醒暂停的线程,从安全点继续执行代码。

偏向锁关闭:

// 偏向锁是默认是延迟的,不会在程序启动时立即生效,如果想避免延迟,可以加 VM 参数
-XX:BiasedLockingStartupDelay=0 // 关闭偏向锁的延迟
-XX:-UseBiasedLoacking=false // 关闭偏向锁

JDK8延迟4每秒开启偏向锁的原因:当程序刚开始执行时,会有很多的线程来争抢锁,如果开启偏向锁效率反而会降低。

撤销偏向锁的场景

  • 调用对象的hashCode:偏向锁的对象MarkWord中存储的是线程的id,调用hashCode导致偏向锁被撤销;
  • 当有其他线程来竞争锁的时候,会将偏向锁升级为轻量锁;
  • 调用wait/notify,需要申请Monitor,进入WaitSet。

批量撤销和批量重偏向:

从偏向锁的加锁解锁过程中就可以看出,当只有一个线程反复获取锁的是皇后,偏向锁带来的性能开销可以忽略,但是当有其他线程尝试获得锁时,就需要等到safe point时,再将偏向锁撤销为无锁状态或升级为轻量级,会消耗一定的性能,所以在多线程竞争频繁的情况下,偏向锁不仅不能提高性能,还会导致性能下降。

批量撤销(解决场景):在明显多线程竞争剧烈的场景下使用偏向锁是不合适的。

批量重偏向(解决场景):一个线程创建了大量对象并执行了初始的同步操作,后来另一个线程也来将这些对象作为锁对象进行操作,这样会导致大量的偏向锁撤销操作。

原理:以class为单位,为每个class维护一个偏向锁撤销计数器,每一次该class的对象发生偏向撤销操作时,该计数器+1,当这个值达到重偏向阈值(默认20)时,JVM就认为该class的偏向锁有问题,因此会进行批量重偏向。每个class对象会有一个对应的epoch字段,每个处于偏向锁状态对象的Mark Word中也有该字段,其初始值为创建该对象时class中的epoch的值。

每次发生批量重偏向时,就将该值+1,同时遍历JVM中所有线程的栈,找到该class所有正处于加锁状态的偏向锁,将其epoch字段改为新值。下次获得锁时,发现当前对象的epoch值和class的epoch不相等,那就算当前已经偏向了其他线程,也不会执行撤销操作,而是直接通过CAS操作将其Mark Word的Thread Id 改成当前线程Id。
当达到重偏向阈值后,假设该class计数器继续增长,当其达到批量撤销的阈值后(默认40),JVM就认为该class的使用场景存在多线程竞争,会标记该class为不可偏向,之后,对于该class的锁,直接走轻量级锁的逻辑。

② 轻量级锁

加锁:线程在执行同步块之前,JVM 会先在当前线程的栈桢中创建用于存储锁记录的空间,并将对象头中的 Mark Word 复制到锁记录中,官方称为 Displaced Mark Word。然后线程尝试使用 CAS 将对象头中的 Mark Word 替换为指向锁记录的指针。如果成功,当前线程获得锁,如果失败,表示其他线程竞争锁,当前线程便尝试使用自旋来获取锁。

  • 创建锁记录对象(Lock Record)对象,每个线程的栈帧都会包含一个锁记录的结构,存储锁定对象的 Mark Word
  • 让锁记录中 Object reference 指向锁住的对象,并尝试用 CAS 替换 Object 的 Mark Word,将 Mark Word 的值存入锁记录
  • 如果 CAS 替换成功,对象头中存储了锁记录地址和状态 00(轻量级锁) ,表示由该线程给对象加锁
  • 如果 CAS 失败,有两种情况:
    • ​ 如果是其它线程已经持有了该 Object 的轻量级锁,这时表明有竞争,进入锁膨胀过程
    • 如果是线程自己执行了 synchronized 锁重入,就添加一条 Lock Record 作为重入的计数

解锁:轻量级解锁时,会使用原子的 CAS 操作将 Displaced Mark Word 替换回到对象头,如果成功,则表示没有竞争发生。如果失败,表示当前锁存在竞争,锁就会膨胀成重量级锁。

  • 当退出 synchronized 代码块(解锁时)
    • 如果有取值为 null 的锁记录,表示有重入,这时重置锁记录,表示重入计数减 1
    • 如果锁记录的值不为 null,这时使用 CAS 将 Mark Word 的值恢复给对象头
      • 成功,则解锁成功
      • 失败,说明轻量级锁进行了锁膨胀或已经升级为重量级锁,进入重量级锁解锁流程

③ 锁膨胀

在尝试加轻量级锁的过程中,CAS 操作无法成功,可能是其它线程为此对象加上了轻量级锁(有竞争),这时需要进行锁膨胀,将轻量级锁变为重量级锁

  • 当 Thread-1 进行轻量级加锁时,Thread-0 已经对该对象加了轻量级锁
  • Thread-1 加轻量级锁失败,进入锁膨胀流程:为 Object 对象申请 Monitor 锁,通过 Object 对象头获取到持锁线程,将 Monitor 的 Owner 置为 Thread-0,将 Object 的对象头指向重量级锁地址,然后自己进入 Monitor 的 EntryList BLOCKED
  • 当 Thread-0 退出同步块解锁时,使用 CAS 将 Mark Word 的值恢复给对象头失败,这时进入重量级解锁流程,即按照 Monitor 地址找到 Monitor 对象,设置 Owner 为 null,唤醒 EntryList 中 BLOCKED 线程

④ 重量级锁

重量级锁是指当有一个线程获取锁之后,其余所有等待获取该锁的线程都会处于阻塞状态。

重量级锁通过对象内部的监视器(monitor)实现,而其中 monitor 的本质是依赖于底层操作系统的 Mutex Lock 实现,操作系统实现线程之间的切换需要从用户态切换到内核态,切换成本非常高。

(2) 锁优化

① 自旋锁

当一个线程尝试获取锁(重量级锁)时,如果锁已经被其他线程持有,该线程会进入自旋状态,不断地重试获取锁,直到获取到为止。自旋锁避免了线程切换带来的开销,但是如果锁被持有的时间较长,自旋锁可能会导致CPU资源的浪费。

② 锁消除

锁消除是一种编译器优化技术,编译器可以在编译过程中分析代码,并根据程序的特性来消除一些不必要的锁操作。

比如在单线程程序中使用锁,锁会变成多余的开销,编译器可以消除这些锁操作,从而提高程序的性能。

锁消除主要是通过逃逸分析来支持,如果堆上的共享数据不可能逃逸出去被其它线程访问到,那么就可以把它们当成私有数据对待,也就可以将它们的锁进行消除(同步消除:JVM 逃逸分析)

③ 锁粗化

锁粗化是一种优化技术,可以将多个连续的锁操作合并成一个锁操作。例如,如果程序中存在多个连续的对同一个锁的加锁和解锁操作,锁粗化可以将它们合并为一个锁操作,从而减少锁的竞争和开销,提高程序的性能。