synchronized

synchronized

Java多线程的锁都是基于对象的,Java中的每一个对象都可以作为一个锁。

还有一点需要注意的是,我们常听到的类锁其实也是对象锁。

Java类只有一个Class对象(可以有多个实例对象,多个实例共享这个Class对象),而Class对象也是特殊的Java对象。所以我们常说的类锁,其实就是Class对象的锁。

  1. 用法

    // 关键字在实例方法上,锁为当前实例
    public synchronized void instanceLock() {
    // code
    }

    // 关键字在静态方法上,锁为当前Class对象
    public static synchronized void classLock() {
    // code
    }

    // 关键字在代码块上,锁为括号里面的对象
    public void blockLock() {
    Object o = new Object();
    synchronized (o) {
    // code
    }
    }

    所谓“临界区”,指的是某一块代码区域,它同一时刻只能由一个线程执行。在上面的例子中,如果synchronized关键字在方法上,那临界区就是整个方法内部。而如果是使用synchronized代码块,那临界区就指的是代码块内部的区域。

    // 等价情况1
    // 关键字在实例方法上,锁为当前实例
    public synchronized void instanceLock() {
    // code
    }

    // 关键字在代码块上,锁为括号里面的对象
    public void blockLock() {
    synchronized (this) {
    // code
    }
    }

    // 等价情况2
    // 关键字在静态方法上,锁为当前Class对象
    public static synchronized void classLock() {
    // code
    }

    // 关键字在代码块上,锁为括号里面的对象
    public void blockLock() {
    synchronized (this.getClass()) {
    // code
    }
    }

锁升级

在Java 6 及其以后,一个对象其实有四种锁状态,它们级别由低到高依次是:无锁状态,偏向锁状态,轻量级锁状态,重量级锁状态。

  1. 一个对象的“锁”的信息存放在什么地方? Java对象头

    Java对象头

    Mark Word的格式:

    锁状态 29bit/61bit 1bit是否为偏向锁 2bit锁标志位
    无锁 0 01
    偏向锁 线程ID 1 01
    轻量级锁 指向栈中锁记录的指针 此时这一位不用于标识偏向锁 00
    重量级锁 指向互斥量(重量级锁)的指针 此时这一位不用于标识偏向锁 10
    GC标记 此时这一位不用于标识偏向锁 11

    当对象状态为偏向锁时,Mark Word存储的是偏向的线程ID;当状态为轻量级锁时,Mark Word存储的是指向线程栈中Lock Record的指针;当状态为重量级锁时,Mark Word为指向堆中的monitor对象的指针。

    偏向锁

    Hotspot的作者经研究发现大多数情况下锁不仅不存在多线程竞争,而且总是由同一线程多次获得,于是引入了偏向锁。

    偏向锁会偏向于第一个访问锁的线程,如果在接下来的运行过程中,该锁没有被其他的线程访问,则持有偏向锁的线程将永远不需要触发同步。也就是说,偏向锁在资源无竞争情况下消除了同步语句,连CAS操作都不做了,提高了程序的运行性能。

    • 实现原理

      一个线程在第一次进入同步块时,会在对象头和栈帧中的锁记录里存储锁的偏向的线程ID。当下次该线程进入这个同步块时,会去检查锁的Mark Word里面是不是放的自己的线程ID。

      如果是,表明该线程已经获得了锁,以后该线程在进入和退出同步块时不需要花费CAS操作来加锁和解锁 ;如果不是,就代表有另一个线程来竞争这个偏向锁。这个时候会尝试使用CAS来替换Mark Word里面的线程ID为新线程的ID,这个时候要分两种情况:

      • 成功,表示之前的线程不存在了, Mark Word里面的线程ID为新线程的ID,锁不会升级,仍然为偏向锁;
      • 失败,表示之前的线程仍然存在,那么暂停之前的线程,设置偏向锁标识为0,并设置锁标志位为00,升级为轻量级锁,会按照轻量级锁的方式进行竞争锁。

      偏向锁操作流程 左:偏向模式 右:抢占模式

    • 撤销偏向锁

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

      偏向锁升级成轻量级锁时,会暂停拥有偏向锁的线程,重置偏向锁标识,大概过程如下:

      1. 在一个安全点(在这个时间点上没有字节码正在执行)停止拥有锁的线程;
      2. 遍历线程栈,如果存在锁记录的话,需要修复锁记录和Mark Word,使其变成无锁状态;
      3. 唤醒被停止的线程,将当前锁升级成轻量级锁;

      所以,如果应用程序里所有的锁通常处于竞争状态,那么偏向锁就会是一种累赘,对于这种情况,我们可以一开始就把偏向锁这个默认功能给关闭:

      -XX:UseBiasedLocking=false

    轻量级锁

    多个线程在不同时段获取同一把锁,即不存在锁竞争的情况,也就没有线程阻塞。针对这种情况,JVM采用轻量级锁来避免线程的阻塞与唤醒。

    • 轻量级锁的加锁

      JVM会为每个线程在当前线程的栈帧中创建用于存储锁记录的空间,Displaced Mark Word。如果一个线程获得锁时发现是轻量级锁,会把锁的Mark Word复制到自己的Displaced Mark Word里面。

      然后线程尝试用CAS将锁的Mark Word替换为指向锁记录的指针。如果成功,当前线程获得锁,如果失败,表示Mark Word已经被替换成了其他线程的锁记录,说明在与其它线程竞争锁,当前线程就尝试使用自旋来获取锁。

      JDK采用适应性自旋,简单来说就是线程如果自旋成功了,则下次自旋的次数会更多,如果自旋失败了,则自旋的次数就会减少。

      自旋也不是一直进行下去的,如果自旋到一定程度(和JVM、操作系统相关),依然没有获取到锁,称为自旋失败,那么这个线程会阻塞。同时这个锁就会升级成重量级锁

    • 轻量级锁的释放

      在释放锁时,当前线程会使用CAS操作将Displaced Mark Word的内容复制回锁的Mark Word里面。如果没有发生竞争,那么这个复制的操作会成功。如果有其他线程因为自旋多次导致轻量级锁升级成了重量级锁,那么CAS操作会失败,此时会释放锁并唤醒被阻塞的线程。

    重量级锁

    重量级锁依赖于操作系统的互斥量(mutex) 实现的,而操作系统中线程间状态的转换需要相对比较长的时间,挂起,唤醒这两个操作进行了两次上下文切换,所以重量级锁效率很低,但被阻塞的线程不会消耗CPU。

    锁的升级流程

    每一个线程在准备获取共享资源时:

    第一步,检查MarkWord里面是不是放的自己的ThreadId ,如果是,表示当前线程是处于 “偏向锁”;

    第二步,如果MarkWord不是自己的ThreadId,锁升级,这时候,用CAS来执行切换,新的线程根据MarkWord里面现有的ThreadId,通知之前线程暂停,之前线程将Markword的内容置为空;

    第三步,两个线程都把锁对象的HashCode复制到自己新建的用于存储锁的记录空间,接着开始通过CAS操作, 把锁对象的MarKword的内容修改为自己新建的记录空间的地址的方式竞争MarkWord;

    第四步,第三步中成功执行CAS的获得资源,失败的则进入自旋;

    第五步,自旋的线程在自旋过程中,成功获得资源(即之前获的资源的线程执行完成并释放了共享资源),则整个状态依然处于 轻量级锁的状态,如果自旋失败;

    第六步,进入重量级锁的状态,这个时候,自旋的线程进行阻塞,等待之前线程执行完成并唤醒自己;

    各种锁的优缺点对比

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

    底层原理

    同步方法

    方法级的同步是隐式的。

    同步方法的常量池中会有一个ACC_SYNCHRONIZED标志。当某个线程要访问某个方法的时候,会检查是否有ACC_SYNCHRONIZED,如果有设置,则需要先获得监视器锁,然后开始执行方法,方法执行之后再释放监视器锁。

    这时如果其他线程来请求执行方法,会因为无法获得监视器锁而被阻断住。

    如果在方法执行过程中发生异常,并且方法内部并没有处理该异常,那么在异常被抛到方法外面之前监视器锁会被自动释放。

    同步代码块

    使用monitorenter和monitorexit两个指令实现。可以把执行monitorenter指令理解为加锁,执行monitorexit理解为释放锁。

    每个对象维护着一个记录被锁次数的计数器。未被锁定的对象的该计数器为0,当一个线程获得锁(执行monitorenter)后,该计数器自增变为1,当同一个线程再次获得该对象的锁时,计数器再次自增。当同一个线程释放锁(执行monitorexit指令)的时候,计数器再自减。当计数器为0的时候。锁将被释放,其他线程便可以获得锁。

    思考

    1. 什么是上下文操作

      通常指CPU上下文,是CPU运行任何任务前,必须依赖的环境,包括CPU 寄存器和程序计数器。

      上下文切换:就是先把前一个任务的 CPU 上下文(也就是 CPU 寄存器和程序计数器)保存起来,然后加载新任务的上下文到这些寄存器和程序计数器,最后再跳转到程序计数器所指的新位置,运行新任务。

    2. synchronized和reentrantLock区别

      • 两者都是可重入锁
      • synchronized依赖于JVM ReentrantLock是JDK层面实现的;
      • ReentrantLock比synchronized增加了一些高级功能:等待可中断;可实现公平锁;可实现选择性通知(锁可以绑定多个条件)
Author: Jiayi Yang
Link: https://jiayiy.github.io/2020/07/14/synchronized/
Copyright Notice: All articles in this blog are licensed under CC BY-NC-SA 4.0 unless stating additionally.