Synchronized锁原理与锁升级

1,313 阅读10分钟

作用

保证在同一时刻最多有一个线程能执行Synchroized修饰的代码,被修饰的代码就会以原子的形式运行,不会存在并发问题,从而达到并发安全的效果。

Synchronized 的具体用法

  • Synchronized修饰普通方法时,锁对象默认为this,锁住的是该类的实例对象
public synchronized void testMethod(){}
  • Synchronized修饰静态方法时,锁住的类对象
 public static synchronized void testStaticMethod(){}
  • 同步代码块,this时,锁住的是类的实例
  public void test(){
        synchronized(this){
        }
    }
  • 同步代码块,Demo.class,锁住的是类对象
public class Demo{
  public void test(){
        synchronized(Demo.class){
        }
    }
}

Synchroinzed 原理

synchronized的实现依赖虚拟机, HotSpot虚拟机中,对象的内存布局可以分为3块区域:对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)

对象头中主要包括两部分数据:类型指针和标记字段, 通过类型指针可以知道该对象是什么类型的, 标记字段用来存储对象运行时的数据,其中就有锁对象的指针,
标记字段中的锁对象指针指向了一个monitor对象,每个对象都有一个对应的monitor对象,
代码块同步是使用monitorenter和monitorexit指令实现,而方法同步是使用ACC_SYNCHRONIZE实现的 我们先通过反编译下面的代码来看看Synchronized是如何实现对代码块进行同步的:

yokIw8.md.jpg

monitorenter/monitorexit

上面说到每个对象都有一个monitor,线程执行monitorenter指令来尝试获取monitor的所有权,

  1. 如果monitor的进入数为0,则该线程进入monitor,然后将进入数+1,该线程即为monitor的所有者。

  2. 如果线程已经占有该monitor,只是重新进入,则进入monitor的进入数+1.

  3. 当一个线程尝试获取monitor时,其他线程占用了monitor,则该线程进入阻塞状态,直到monitor的进入数为0时,再尝试获取monitor。

  4. monitorexit指令执行时,monitor的进入数-1,如果-1后进入数为0,那线程退出monitor,不再是这个monitor的所有者。 其他被这个monitor阻塞的线程可以尝试去获取这个 monitor 的所有权。

为什么反编译出来有两个monitorexit? 第一个monitorexit指令是同步代码块正常释放锁, 如果同步代码块出现异常,则会调用第二个monitorexit来保证释放锁。

ACC_SYNCHRONIZE

从反编译的结果来看,方法的同步并没有通过指令monitorenter和monitorexit来完成 而常量池中多了ACC_SYNCHRONIZED标示符。 yoAZm6.md.jpg

JVM根据这个ACC_SYNCHRONIZE来实现方法的同步的: 方法调用时,调用指令将会检查方法的 ACC_SYNCHRONIZED 访问标志是否被设置,如果设置了, 执行线程将先获取monitor,获取成功之后才能执行方法体, 而在退出该方法时,不论正常返回,还是向调用者抛异常返回,JVM都进行 monitorexit 操作。 方法执行完后再释放monitor。 在方法执行期间,其他任何线程无法获得monitor对象。

monitor和 ACC_SYNCHRONIZE本质上没有区别,ACC_SYNCHRONIZE是一种隐式的方式来实现。

锁的实现原理

JVM 中的同步是基于进入和退出Monitor实现的。每个对象实例都会有一个 Monitor,Monitor 可以和对象一起创建、销毁。Monitor 是由 ObjectMonitor 实现,而 ObjectMonitor 是由 C++ 的 ObjectMonitor.hpp 文件实现

 ObjectMonitor() {
   _header = NULL;
  _count = 0; // 线程获取锁的次数
  _waiters = 0,
  _recursions = 0; // 递归,锁的重入次数
  _object = NULL;
  _owner = NULL;   // 持有monitor的线程
  _WaitSet = NULL; // 处于 wait 状态, 即等待monitor的线程,会被加入到 _WaitSet
  _WaitSetLock = 0 ; // 自旋
   _Responsible = NULL ;
   _succ = NULL ;
   _cxq = NULL ;
   FreeNext = NULL ;
   _EntryList = NULL ; // 处于等待锁 block 状态的线程,会被加入到该列表
   _SpinFreq = 0 ;
   _SpinClock = 0 ;
   OwnerIsThread = 0 ;
  }

如图所示 y4j5pF.jpg

  1. 当Thread1、Thread2访问同步代码块时Thread1,Thread2会先进入ObjectMonitor的EntryList中等待;
  2. 接下来当Thread1获取到对象的 Monitor 时,Monitor 是依靠底层操作系统的 Mutex Lock 来实现互斥的,Thread1申请 Mutex 成功,则持 有该 Mutex,其它线程将无法获取到该 Mutex。ObjectMonitor的owner属性指向Thread1,EntryList中还剩Thread2在等待
  3. 如果Thread1调用了wait()方法,就会释放当前持有的 Mutex,Thread1进入WaitSet并释放锁,ObjectMonitor的owner属性等于null。 如果Thread1执行完毕,也会释放所持有的Mutex。
  4. Thread2获取到锁进入同步代码块,ObjectMonitor owner属性指向Thread2,任务执行完退出同步代码之前调用notifyAll, Thread1被唤醒,从WaitSet转到EntryList中等待锁,Thread2退出同步代码块,ObjectMonitor owner属性为null;

所以,Monitor依赖操作系统实现,存在用户态和内核态的切换,增加了性能开销。

锁升级过程

java1.6之后Synchronized进行了一些优化,引入了偏向锁、轻量级锁、重量级锁。 前面说到 Java 对象头由 Mark Word、指向类的指针组成(如果是数组对象,则还有数组长度) Mark Word 记录了对象和锁有关的信息。以64位JVM为例,存储结构如下

锁状态存储内容偏向锁标识位(1bit)锁标识位(2bit)
无锁hash code(31bit),年龄(4bit),(25bit unused)001
偏向锁线程ID(54bit),时间戳Epoch(2bit),分代年龄(4bit)101
轻量级锁指向轻量级锁的指针00
重量级锁指向重量级锁的指针10

锁升级就是 Mark Word 中的锁标志位和释放偏向锁标志位的变化,从偏向锁开始的,偏向锁升级到轻量级锁,再升级到重量 级锁的过程。锁只能升级,不能降级。

偏向锁 -> 轻量级锁 -> 重量级锁

偏向锁

在大多情况下,虽然我们加了Synchronized关键字,保证了它的线程安全性, 但是在实际当中多数情况下只有一个线程运行这段代码,并没有其它线程来和它竞争。 这一个线程每次执行代码都需要获取锁和释放锁,每次操作都会发生用户态与内核态的切换,这个开销是没有必要的。 所以引入了偏向锁。 偏向锁的意思就是偏向第一个获得锁的线程。偏向锁在一个线程第一次访问的时候将该线程的id记录下来, 当一个线程再次访问这个同步代码块或方法时,该线程只需去对象头的 Mark Word 中去判断一下是否有偏向锁指向它的 ID,如果有,就无需再进入 Monitor 去竞争对象了。 当对象被当做同步锁并有一个线程抢到了锁时,锁标志位还是 01,“是否偏向锁”标 志位设置为 1,并且记录抢到锁的线程 ID,表示进入偏向锁状态。 如果有另一个线程也来访问它,说明有可能出现线程并发。此时偏向锁就会升级为轻量级锁。

在高并发场景下,当大量线程同时竞争同一个锁时,偏向锁就会被撤销,可以通过添加 JVM 参数UseBiasedLocking关闭偏向锁来调优性能

参数 -XX:-UseBiasedLocking 关闭偏向锁,默认开启。

偏向锁适用于只有一个线程访问同步块场景。

轻量级锁

如果有其它线程也来访问同步代码时,由于该锁已经是偏向锁,当发现对象头 Mark Word 中的线程 ID 不是自己,就会进行 CAS 操作获取锁,如果获取成功,就替换Mark Word 中的线程 ID,该锁会保持偏向锁状态; 如果获取锁失败,代表当前锁有一定的竞争,偏向锁会升级为轻量级锁。 轻量级锁是如何加锁的呢? 线程在执行同步块时,JVM会先在当前线程的栈桢中创建用于存储锁记录的空间Lock Record, 并将对象头中的Mark Word复制到锁记录中,官方称为Displaced Mark Word。 然后线程尝试使用CAS将对象头中的Mark Word替换为指向锁记录的指针。 如果成功,当前线程获得锁,如果失败,表示其他线程竞争锁,当前线程便尝试使用自旋来获取锁。

轻量级锁适用于线程交替执行同步块,不存在长时间竞争的场景

重量级锁

轻量级锁 CAS 抢锁失败,线程将会通过自旋方式不断尝试获取锁,从而避免线程被挂起阻塞。 这是基于大多数情况下,线程持有锁的时间都不会太长, JDK1.7后,自旋锁默认开启,自旋次数由 JVM 设置决定,如果自旋锁重试之后抢锁还是失败,轻量级锁就会升级至重量级锁。

重量级锁也就是文章开始提到的通过monitor对象来实现的,我们先看下重量级锁的执行过程, 当一段代码被上锁以后同一时间只能有一个线程执行,如果有其他线程也要执行 其他线程都会进入 Monitor,之后会被阻塞在 _WaitSet,等线程执行完后,队列中其他线程还会被唤醒, 这些动作都是操作系统级别的处理,比较消耗资源。

在锁竞争不激烈且锁占用时间非常短的场景下,自旋锁可以提高系统性能。重量级锁适用于同步块执行速度较长的场景。

Synchronized锁升级过程

综上所述,Synchronized锁升级过程

  1. 检测Mark Word里面是否是当前线程的ID,如果是,表示当前线程处于偏向锁
  2. 如果不是,则使用CAS将当前线程的ID替换Mark Word,如果成功则表示当前线程获得偏向锁,如果失败,则说明发生竞争,撤销偏向锁,进而升级为轻量级锁。
  3. 当前线程使用CAS将对象头的Mark Word替换为锁记录指针,如果成功,当前线程获得锁,如果失败,表示其他线程竞争锁,当前线程便尝试使用自旋来获取锁。
  4. 如果自旋成功则依然处于轻量级状态,如果自旋失败,则升级为重量级锁。

锁优化

除了上面说的锁升级优化,Java 还使用了编译器对锁进行优化。

锁消除

及时编译器在动态编译同步块的时,如果坚持到不可能存在共享数据竞争的问题,就会进行锁消除。锁消除是依赖逃逸分析技术的。在一段代码中,数据不会逃逸出去被其他线程访问到。 例如下代码

public String concat(String s1, String s2, String s3){
    return s1 + s2 + s3;
}

JDK5之前,会转换成StringBuffer进行连续的append操作。JDK5之后会转化成StringBuilder的append操作。 对于StringBuffer而言,每次append都会上锁。

public String concat(String s1, String s2, String s3){
    StringBuffer sb = new StringBuffer();
    sb.append(s1);
    sb.append(s2);
    sb.append(s3);
    return sb.toString();
}

StringBuffer是线程安全的,append()方法是同步方法,但是StringBuffer的作用域在concat方法中,没有发生逃逸, 所以这里的append上的synchronized锁会被消除掉。

锁粗化

锁粗化是在编译器动态编译时,如果发现连续几个相邻的同步块使用的是同一个锁 实例,那么编译器将会把这几个同步块合并为一个大的同步块,比如上面StringBuffer的例子中,连续调用了三次append(), 编译器优化会将同步锁放在第一个append开始和最后一个append结束。 避免一个线程反复获取、释放同一个锁所带来的性能开销。

Synchronized与Lock

JDK1.5 之后,Java 提供了 Lock 同步锁,它与Synchronized有何不同呢?

  • 实现方式不同:Synchronized是依赖JVM底层实现,Lock是Java代码底层AQS实现。
  • 锁的获取和释放:Synchronized是隐式获取和释放,Lock需要通过调用lock(),tryLock()获取锁,unlock()释放锁,显示的获取和释放。
  • 是否可中断,Synchronuzed不可中断,Lock可中断。