synchronized

3/23/2022 线程

# 前言

  1. synchronized 是 Java 中的关键字,是 利用锁的机制来实现互斥同步的。

  2. synchronized 可以保证在同一个时刻,只有一个线程可以执行某个方法或者某个代码块。

  3. 如果不需要 Lock 、ReadWriteLock 所提供的高级同步特性,应该优先考虑使用 synchronized ,理由如下:

    • Java 1.6 以后,synchronized 做了大量的优化,其性能已经与 Lock 、ReadWriteLock 基本上持平 。从趋势来看,Java 未来仍将继续优化 synchronized ,而不是 ReentrantLock 。

    • ReentrantLock 是 Oracle JDK 的 API,在其他版本的 JDK 中不一定支持;而 synchronized 是 JVM 的内置特性,所有 JDK 版本都提供支持。

# synchronized原理

  1. synchronized 代码块是由一对 monitorenter 和 monitorexit 指令实现的,Monitor 对象是同步的基本实现单元 。 在 Java 6 之前,Monitor 的实现完全是依靠操作系统内部的互斥锁(Mutex),因为需要进行用户态到内核态的切换,所以同步操作是一个无差别的重量级操作 。

  2. 如果 synchronized 明确制定了对象参数,那就是这个对象的引用;如果没有明确指定,那就根据 synchronized 修饰的是实例方法还是静态方法,去对对应的对象实例或 Class 对象来作为锁对象。

  3. synchronized 同步块对同一线程来说是可重入的,不会出现锁死问题。

  4. synchronized 同步块是互斥的,即已进入的线程执行完成前,会阻塞其他试图进入的线程。

  5. synchronized 在修饰同步代码块时,是由 monitorenter 和 monitorexit 指令来实现同步的。进入 monitorenter 指令后,线程将持有 Monitor 对象,退出 monitorenter 指令后,线程将释放该 Monitor 对象。在执行monitorenter时,会尝试获取对象的锁,如果锁的计数器为 0 则表示锁可以被获取,获取后将锁计数器设为 1 也就是加 1。在执行 monitorexit 指令后,将锁计数器设为 0,表明锁被释放。如果获取对象锁失败,那当前线程就要阻塞等待,直到锁被另外一个线程释放为止。

  1. synchronized修饰同步方法时,会设置一个 ACC_SYNCHRONIZED 标志。当方法调用时,调用指令将会检查该方法是否被设置 ACC_SYNCHRONIZED 访问标志。如果设置了该标志,执行线程将先持有 Monitor 对象,然后再执行方法。在该方法运行期间,其它线程将无法获取到该 Mointor 对象,当方法执行完成后,再释放该 Monitor 对象。

# Monitor

  1. 每个对象实例都会有一个 Monitor ,Monitor 可以和对象一起创建、销毁。Monitor 是由 ObjectMonitor 实现,而 ObjectMonitor 是由 C++ 的 ObjectMonitor.hpp 文件实现。

  2. 当多个线程同时访问一段同步代码时,多个线程会先被存放在 EntryList 集合中,处于 block 状态的线程,都会被加入到该列表。 接下来当线程获取到对象的 Monitor 时 , Monitor 是依靠底层操作系统的 Mutex Lock 来实现互斥的 ,线程申请 Mutex 成功,则持有该 Mutex,其它线程将无法获取到该 Mutex。

  3. 如果线程调用 wait() 方法,就会释放当前持有的 Mutex,并且该线程会进入 WaitSet 集合 中,等待下一次被唤醒 。如果当前线程顺利执行完方法,也将释放 Mutex。

  4. wait/notify等方法也依赖于monitor对象,这就是为什么只有在同步的块或者方法中才能调用wait/notify等方法 ,否则会抛出java.lang.IllegalMonitorStateException的异常的原因。

# Synchronized特性

# 原子性

  1. 同步方法

    • ACC_SYNCHRONIZED 这是一个同步标识,对应的16进制值是 0x0020这10个线程进入这个方法时,都会判断该方法是否有此标识,然后开始竞争 Monitor 对象。
  2. 同步代码

    • monitorenter ,在判断拥有同步标识 ACC_SYNCHRONIZED 抢先进入此方法的线程会优先拥有 Monitor 的 owner ,此时计数器 +1 。

    • monitorexit ,当执行完退出后,计数器减 1 ,归 0 后被其他进入的线程获得。

# 可见性

  1. 线程解锁前,必须把共享变量的最新值刷新到主内存中。

  2. 线程加锁前,将清空工作内存中共享变量的值,从而使用共享变量时需要从主内存中重新读取最新的值。

  3. volatile 的可见性都是通过内存屏障( Memnory Barrier )来实现的。

  4. synchronized 靠操作系统内核互斥锁实现,相当于 JMM 中的 lock 、 unlock 。退出代码块时刷新变量到主内存。

# 有序性

  1. 为什么synchronized可以保证有序性,还要volatile关键字?如 单例模式中的双重校验锁 。

    • 加上synchronized后,依然会发生重排序,只不过,我们有同步代码块,可以保证只有一个线程执行同步代码快中的代码,从而保证有序性。

    • synchronized 的有序性,不是 volatile 的防止指令重排序。

# 可重入性

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

# synchronized优化

  1. Java 1.6 以后, synchronized 做了大量的优化,其性能已经与 Lock 、 ReadWriteLock 基本上持平 。

  2. Java 1.6 引入了偏向锁和轻量级锁 ,从而让 synchronized 拥有了四个状态:

    • 无锁状态(unlocked)

    • 偏向锁状态(biasble)

    • 轻量级锁状态(lightweight locked)

    • 重量级锁状态(inflated)

  3. 当 JVM 检测到不同的竞争状况时,会自动切换到适合的锁实现。

  4. 当没有竞争出现时, 默认会使用偏向锁 。JVM 会利用 CAS 操作(compare and swap),在对象头上的 Mark Word 部分(关于Mark Word详见:https://www.yuque.com/hanchanmingqi-zjjw3/kb/gn08dr (opens new window))设置线程 ID,以 表示这个对象偏向于当前线程,所以并不涉及真正的互斥锁 。这样做的假设是基于在很多应用场景中,大部分对象生命周期中最多会被一个线程锁定,使用偏向锁可以降低无竞争开销。

  5. 如果有另外的线程试图锁定某个已经被偏斜过的对象,JVM 就需要撤销(revoke)偏向锁,并切换到轻量级锁实现。 轻量级锁依赖 CAS 操作 Mark Word 来试图获取锁 ,如果重试成功,就使用普通的轻量级锁;否则,进一步升级为重量级锁。

Mark Word是对象实例的对象头的其中一个组成部分。

锁升级功能主要依赖于 Mark Word 中的锁标志位和释放偏向锁标志位,synchronized 同步锁就是从偏向锁开始的,随着竞争越来越激烈,偏向锁升级到轻量级锁,最终升级到重量级锁。

# 偏向锁

  1. 引入背景: 在大多实际环境下,锁不仅不存在多线程竞争,而且总是由同一个线程多次获取,那么在同一个线程反复获取所释放锁中,其中并还没有锁的竞争,那么这样看上去,多次的获取锁和释放锁带来了很多不必要的性能开销和上下文切换。

  2. 偏向锁的思想是偏向于 第一个获取锁对象的线程,这个线程在之后获取该锁就不再需要进行同步操作,甚至连 CAS 操作也不再需要 。

  3. 偏向锁的撤销: 偏向锁使用了一种 等待竞争出现才会释放锁的机制 。所以当其他线程尝试获取偏向锁时,持有偏向锁的线程才会释放锁。但是偏向锁的撤销需要等到全局安全点(就是当前线程没有正在执行的字节码)。它会首先暂停拥有偏向锁的线程,然后检查持有偏向锁的线程是否活着。如果线程不处于活动状态,直接将对象头设置为无锁状态。如果线程活着,JVM会遍历栈帧中的锁记录,栈帧中的锁记录和对象头要么偏向于其他线程,要么恢复到无锁状态或者标记对象不适合作为偏向锁。

# 轻量级锁

  1. 轻量级锁是相对于传统的重量级锁而言,它 使用 CAS 操作来避免重量级锁使用互斥量的开销 。对于绝大部分的锁,在整个同步周期内都是不存在竞争的,因此也就不需要都使用互斥量进行同步,可以先采用 CAS 操作进行同步, 如果 CAS 失败了再改用互斥量进行同步 。

  2. 在代码进入同步块的时候,如果同步对象锁状态为无锁状态(锁标志位为“01”状态,是否为偏向锁为“0”),JVM虚拟机首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于 存储锁对象目前的Mark Word的拷贝 ,官方称之为 Displaced Mark Word 。

  1. 当尝试获取一个锁对象时,如果锁对象标记为 0|01,说明锁对象的锁未锁定(unlocked)状态。此时虚拟机在当前线程的虚拟机栈中创建 Lock Record( Lock Record的空间用于存储锁对象目前的Mark Word拷贝 )并将标记字段Mark Word拷贝到锁记录中, 然后使用 CAS 操作将对象的 Mark Word 更新为指向 Lock Record 指针。如果 CAS 操作成功了,那么线程就获取了该对象上的锁,并且对象的 Mark Word 的锁标记变为 00,表示该对象处于轻量级锁状态 。

  1. 如果这个更新操作失败,JVM会检查当前的Mark Word中是否存在指向当前线程的栈帧的指针,如果有,说明该锁已经被获取,可以直接调用。如果没有,则说明该锁被其他线程抢占了, 如果有两条以上的线程竞争同一个锁,那轻量级锁就不再有效,直接膨胀位重量级锁,没有获得锁的线程会被阻塞 。此时,锁的标志位为10,Mark Word中存储的时指向重量级锁的指针。

  2. 轻量级解锁时,会使用原子的CAS操作将Displaced Mark Word替换回到对象头中,如果成功,则表示没有发生竞争关系。如果失败,表示当前锁存在竞争关系。锁就会膨胀成重量级锁。两个线程同时争夺锁,导致锁膨胀的流程图如下:

# 重量级锁

每个对象都有一个监视器monitor对象,重量级锁就是由对象监视器monitor来实现的,当多个线程同时请求某个重量级锁时,重量级锁会设置几种状态用来区分请求的线程:

Contention List 竞争队列 :所有请求锁的线程将被首先放置到该竞争队列,我也不知道为什么网上的文章都叫它队列,其实这个队列是先进后出的,更像是栈,就是当Entry List为空时,Owner线程会直接从Contention List的队列尾部(后加入的线程中)取一个线程,让它成为OnDeck线程去竞争锁。(主要是刚来获取重量级锁的线程是会进行自旋操作来获取锁,获取不到才会进入Contention List,所以OnDeck线程主要与刚进来还在自旋,还没有进入到Contention List的线程竞争)

Entry List 候选队列 :Contention List中那些有资格成为候选人的线程被移到Entry List,主要是为了减少对Contention List的并发访问,因为既会添加新线程到队尾,也会从队尾取线程。

Wait Set 等待队列 :那些调用wait()方法被阻塞的线程被放置到Wait Set。

OnDeck :任何时刻最多Entry List中只能有一个线程被选中,去竞争锁,该线程称为OnDeck线程。

Owner :获得锁的线程称为Owner。

!Owner :释放锁的线程。

# 重量级锁执行流程

流程图如下:

步骤1是线程在进入Contention List时阻塞等待之前,程会先尝试自旋使用CAS操作获取锁,如果获取不到就进入Contention List队列的尾部(所以不是公平锁)。

步骤2是Owner线程在解锁时,如果Entry List为空,那么会先将Contention List中队列尾部的部分线程移动到Entry List。(所以Contention List相当于是后进先出,所以也是不公平的)

步骤3是Owner线程在解锁时,如果Entry List不为空,从Entry List中取一个线程,让它成为OnDeck线程,Owner线程并不直接把锁传递给OnDeck线程,而是把锁竞争的权利交给OnDeck,OnDeck需要重新竞争锁,JVM中这种选择行为称为 “竞争切换”。(主要是与还没有进入到Contention List,还在自旋获取重量级锁的线程竞争)

步骤4就是OnDeck线程获取到锁,成为Owner线程进行执行。

等待和通知步骤(这是调用了wait()和notify()方法才有的步骤):

在同步块中,获得了锁的线程调用锁对象的Object.wait()方法,就是Owner线程调用锁对象的wait()方法进行等待,会移动到Wait Set中,并且会释放CPU资源,也同时释放锁,

就是当其他线程调用锁对象的Object.notify()方法,之前调用wait方法等待的这个线程才会从Wait Set移动到Entry List,等待获取锁。

# 优化后的锁的优缺点

偏向锁:加锁解锁不需要进行CAS操作,适合一个线程多次访问同步块的场景。

轻量级锁:加锁和解锁使用CAS操作,没有像重量级锁那样底层操作系统的互斥量来加锁解锁,不涉及到用户态和内核态的切换和线程阻塞唤醒造成的线程上下文切换。没有获得锁的线程会自旋空耗CPU,造成一些开销。适合多线程竞争比较少,但是会有多线程交替执行的场景。

重量级锁:使用到了底层操作系统的互斥量来加锁解锁,但是会涉及到用户态和内核态的切换和线程阻塞和唤醒造成的线程上下文切换,但是不会自旋空耗CPU。

优点 缺点 使用场景
偏向锁 加锁和解锁不需要CAS操作,没有额外的性能消耗,和执行非同步方法相比仅存在纳秒级的差距 如果线程间存在锁竞争,会带来额外的锁撤销的消耗 适用于只有一个线程访问同步快的场景
轻量级锁 竞争的线程不会阻塞,提高了响应速度 如线程成始终得不到锁竞争的线程,使用自旋会消耗CPU性能 追求响应时间,同步快执行速度非常快
重量级锁 线程竞争不适用自旋,不会消耗CPU 线程阻塞,响应时间缓慢,在多线程下,频繁的获取释放锁,会带来巨大的性能消耗 追求吞吐量,同步快执行速度较长

# 锁消除

  1. 除了锁升级优化,Java 还 使用了编译器对锁进行优化 。优化措施包括 锁消除和锁粗化 。

  2. 锁消除是指对于被检测出不可能存在竞争的共享数据的锁进行消除 。

  3. JIT 编译器在动态编译同步块的时候,借助了一种被称为逃逸分析的技术,来判断同步块使用的锁对象是否只能够被一个线程访问,而没有被发布到其它线程。

  4. 确认是的话,那么 JIT 编译器在编译这个同步块的时候不会生成 synchronized 所表示的锁的申请与释放的机器码,即消除了锁的使用。在 Java7 之后的版本就不需要手动配置了,该操作可以自动实现。

  5. 对于一些看起来没有加锁的代码,其实隐式的加了很多锁。例如下面的字符串拼接代码就隐式加了锁:

public static String concatString(String s1, String s2, String s3) {
    return s1 + s2 + s3;
}
  1. String 是一个不可变的类,编译器会对 String 的拼接自动优化。 在 Java 1.5 之前,会转化为 StringBuffer 对象的连续 append() 操作 。我们知道,StringBuffer是一线程安全的类,它的append方法都使用synchronized修饰了。对于上述代码, 在JDK 1.5及以后的版本中,会转化为StringBuidler对象的连续append()操作 。

  2. 众所周知,StringBuilder不是安全同步的,但是在上述代码中,JVM判断该段代码并不会逃逸,则将该代码带默认为线程独有的资源,并不需要同步,所以执行了锁消除操作。(还有Vector中的各种操作也可实现锁消除。在没有逃逸出数据安全防卫内)

# 锁粗化

  1. 原则上,我们都知道在加同步锁时,尽可能的将同步块的作用范围限制到尽量小的范围(只在共享数据的实际作用域中才进行同步,这样是为了使得需要同步的操作数量尽可能变小。在存在锁同步竞争中,也可以使得等待锁的线程尽早的拿到锁)。

  2. 大部分上述情况是完美正确的, 但是如果存在连串的一系列操作都对同一个对象反复加锁和解锁,甚至加锁操作时出现在循环体中的,那即使没有线程竞争,频繁地进行互斥同步操作也会导致不必要地性能操作 。

public static String test04(String s1, String s2, String s3) {
    StringBuilder sb = new StringBuilder();
    sb.append(s1);
    sb.append(s2);
    sb.append(s3);
    return sb.toString();
}

  1. 在上述地连续append()操作中就属于这类情况。JVM会检测到这样一连串地操作都是对同一个对象加锁,那么JVM会将加锁同步地范围扩展(粗化)到整个一系列操作的 外部,使整个一连串地append()操作只需要加锁一次就可以了。

# 自旋锁

  1. 大家都知道,在没有加入锁优化时,Synchronized是一个非常“胖大”的家伙。在多线程竞争锁时,当一个线程获取锁时,它会阻塞所有正在竞争的线程,这样对性能带来了极大的影响。 在挂起线程和恢复线程的操作都需要转入内核态中完成,这些操作对系统的并发性能带来了很大的压力 。同时HotSpot团队注意到在很多情况下, 共享数据的锁定状态只会持续很短的一段时间,为了这段时间去挂起和回复阻塞线程并不值得 。在如今多处理器环境下,完全可以 让另一个没有获取到锁的线程在门外等待一会(自旋),但不放弃CPU的执行时间 。等待持有锁的线程是否很快就会释放锁。为了让线程等待,我们只需要让线程执行一个忙循环(自旋),这便是自旋锁由来的原因。

  2. 自旋锁早在JDK1.4 中就引入了,只是当时默认时关闭的。在JDK 1.6后默认为开启状态。自旋锁本质上与阻塞并不相同,先不考虑其对多处理器的要求,如果锁占用的时间非常的短,那么自旋锁的新能会非常的好,相反,其会带来更多的性能开销(因为在线程自旋时,始终会占用CPU的时间片,如果锁占用的时间太长,那么自旋的线程会白白消耗掉CPU资源)。

  3. 因此自旋等待的时间必须要有一定的限度,如果自旋超过了限定的次数仍然没有成功获取到锁,就应该使用传统的方式去挂起线程了,在JDK定义中, 自旋锁默认的自旋次数为10次 ,用户可以使用参数-XX:PreBlockSpin来更改。

# 自适应自旋锁

  1. 出现了一个问题:如果线程锁在线程自旋刚结束就释放掉了锁,那么是不是有点得不偿失。所以这时候我们需要更加聪明的锁来实现更加灵活的自旋。来提高并发的性能。(这里则需要自适应自旋锁!)

  2. 在JDK 1.6中引入了自适应自旋锁。这就意味着自旋的时间不再固定了,而是由前一次在同一个锁上的自旋 时间及锁的拥有者的状态来决定的。 如果在同一个锁对象上,自旋等待刚刚成功获取过锁,并且持有锁的线程正在运行中,那么JVM会认为该锁自旋获取到锁的可能性很大,会自动增加等待时间 。比如增加到100此循环。相反,如果对于某个锁,自旋很少成功获取锁。那再以后要获取这个锁时将可能省略掉自旋过程,以避免浪费处理器资源。有了自适应自旋,JVM对程序的锁的状态预测会越来越准备,JVM也会越来越聪明。

Last Updated: 3/28/2022, 9:29:49 PM