应该没人比我更细了:17张图带你秒杀synchronized关键字!

synchronized关键字引入

咱们知道,在多线程程序中每每会出现这么一个状况:多个线程同时访问某个线程间的共享变量。来举个例子吧:算法

假设银行存款业务写了两个方法,一个是存钱 store() 方法 ,一个是查询余额 get() 方法。假设初始客户小明的帐户余额为 0 元。(PS:这个例子只是个 toy demo,为了方便你们理解写的,真实的业务场景不会这样。)数组

// account 客户在银行的存款 
    public void store(int money){
        int newAccount=account+money;
        account=newAccount;
    }
    public void get(){
        System.out.print("小明的银行帐户余额:");
        System.out.print(account);
    }

若是小明为本身存款 1 元,咱们指望的线程调用状况以下:安全

  1. 首先会启动一个线程调用 store() 方法,为客户帐户余额增长 1;
  2. 再启动一个线程调用 get() 方法,输出客户的新余额为 1。

但实际状况可能因为线程执行的前后顺序,出现如图所示的错误:多线程

小明会惊奇的觉得本身的钱没存上。这就是一个典型的由共享数据引起的并发数据冲突问题并发

解决方式也很简单,让并发执行会产生问题的代码段不并发行了。函数

若是 store() 方法 执行完,才能执行 get() 方法,而不是像上图同样并发执行,天然不会出现这个问题。那如何才能作到呢?布局

答案就是使用 synchronized 关键字。学习

咱们先从直觉上思考一下,若是要实现先执行 store() 方法,再执行 get() 方法的话该怎么设计。spa

咱们能够设置某个锁,锁会有两种状态,分别是上锁解锁。在 store() 方法执行以前,先观察这个锁的状态,若是是上锁状态,就进入阻塞,代码不运行;操作系统

若是这把锁是解锁状态,那就先将这把锁状态变为上锁,以后接着运行本身的代码。运行完成以后再将锁状态设置为解锁。

对于 get() 方法也是如此。

Java 中的 synchronized 关键字就是基于这种思想设计的。在 synchronized 关键字中,锁就是一个对象。

synchronized 一共有三种使用方法:

  • 直接修饰某个实例方法。像上文代码同样,在这种状况下多线程并发访问实例方法时,若是其余线程调用同一个对象的被 synchronized 修饰的方法,就会被阻塞。至关于把锁记录在这个方法对应的对象上。
// account 客户在银行的存款 
    public synchronized void store(int money){
        int newAccount=account+money;
        account=newAccount;
    }
    public synchronized void get(){
        System.out.print("小明的银行帐户余额:");
        System.out.print(account);
    }
  • 直接修饰某个静态方法。在这种状况下进行多线程并发访问时,若是其余线程也是调用属于同一类的被 synchronized 修饰的静态方法,就会被阻塞。至关于把锁信息记录在这个方法对应的类上。
public synchronized static void get(){
        ···
    }
  • 修饰代码块。若是此时有别的线程也想访问某个被synchronized(对象0)修饰的同步代码块时,也会被阻塞。
public static void get(){
        synchronized(对象0){
            ···
        }
    }

A问:我看了很多参考书还有网上资料,都说 synchronized 的锁是锁在对象上的。关于这句话,你能深刻讲讲吗?

B回答道:别急,我先讲讲 Java 对象在内存中的表示。

Java 对象在内存中的表示

讲清 synchronized 关键字的原理前须要理清 Java 对象在内存中的表示方法。

上图就是一个 Java 对象在内存中的表示。咱们能够看到,内存中的对象通常由三部分组成,分别是对象头、对象实际数据和对齐填充。

对象头包含 Mark Word、Class Pointer和 Length 三部分。

  • Mark Word 记录了对象关于锁的信息,垃圾回收信息等。
  • Class Pointer 用于指向对象对应的 Class 对象(其对应的元数据对象)的内存地址。
  • Length只适用于对象是数组时,它保存了该数组的长度信息。

对象实际数据包括了对象的全部成员变量,其大小由各个成员变量的大小决定。

对齐填充表示最后一部分的填充字节位,这部分不包含有用信息。

咱们刚才讲的锁 synchronized 锁使用的就是对象头的 Mark Word 字段中的一部分。

Mark Word 中的某些字段发生变化,就能够表明锁不一样的状态。

因为锁的信息是记录在对象里的,有的开发者也每每会说锁住对象这种表述。

无锁状态的 Mark Word

这里咱们以无锁状态的 Mark Word 字段举例:

若是当前对象是无锁状态,对象的 Mark Word 如图所示。

咱们能够看到,该对象头的 Mark Word 字段分为四个部分:

  1. 对象的 hashCode ;
  2. 对象的分代年龄,这部分用于对对象的垃圾回收;
  3. 是否为偏向锁位,1表明是,0表明不是;
  4. 锁标志位,这里是 01。

synchronized关键字的实现原理

讲完了 Java 对象在内存中的表示,咱们下一步来说讲 synchronized 关键字的实现原理。

从前文中咱们能够看到, synchronized 关键字有两种修饰方法

  1. 直接做为关键字修饰在方法上,将整个方法做为同步代码块:
public synchronized static void `get()`{
        ···
    }
  1. 修饰在同步代码块上
public static void `get()`{
        synchronized(对象0){
            ···
        }
    }

针对这两种状况,Java 编译时的处理方法并不相同。

对于第一种状况,编译器会为其自动生成了一个 ACC_SYNCHRONIZED 关键字用来标识。

在 JVM 进行方法调用时,当发现调用的方法被 ACC_SYNCHRONIZED 修饰,则会先尝试得到锁。

对于第二种状况,编译时在代码块开始前生成对应的1个 monitorenter 指令,表明同步块进入。2个 monitorexit 指令,表明同步块退出。

这两种方法底层都须要一个 reference 类型的参数,指明要锁定和解锁的对象。

若是 synchronized 明确指定了对象参数,那就是该对象。

若是没有明确指定,那就根据修饰的方法是实例方法仍是类方法,取对应的对象实例或类对象(Java 中类也是一种特殊的对象)做为锁对象。

每一个对象维护着一个记录着被锁次数的计数器。当一个线程执行 monitorenter,该计数器自增从 0 变为 1;

当一个线程执行 monitorexit,计数器再自减。当计数器为 0 的时候,说明对象的锁已经释放。

A问:为何会有两个 monitorexit 指令呢?

B答:正常退出,得用一个 monitorexit 吧,若是中间出现异常,锁会一直没法释放。因此编译器会为同步代码块添加了一个隐式的 try-finally 异常处理,在 finally 中会调用 monitorexit 命令最终释放锁。

重量级锁

A问:那么问题来了,以前你说锁的信息是记录在对象的 Mark Word 中的,那如今冒出来的 monitor 又是什么呢?

B答:咱们先来看一下重量级锁对应对象的 Mark Word。

在 Java 的早期版本中,synchronized 锁属于重量级锁,此时对象的 Mark Word 如图所示。

咱们能够看到,该对象头的 Mark Word 分为两个部分。第一部分是指向重量级锁的指针,第二部分是锁标记位。

而这里所说的指向重量级锁的指针就是 monitor

英文词典翻译 monitor 是监视器。Java 中每一个对象会对应一个监视器。

这个监视器其实也就是监控锁有没有释放,释放的话会通知下一个等待锁的线程去获取。

monitor 的成员变量比较多,咱们能够这样理解:

咱们能够将 monitor 简单理解成两部分,第一部分表示当前占用锁的线程,第二部分是等待这把锁的线程队列

若是当前占用锁的线程把锁释放了,那就须要在线程队列中唤醒下一个等待锁的线程。

可是阻塞或唤醒一个线程须要依赖底层的操做系统来实现,Java 的线程是映射到操做系统的原生线程之上的。

而操做系统实现线程之间的切换须要从用户态转换到核心态,这个状态转换须要花费不少的处理器时间,甚至可能比用户代码执行的时间还要长。

因为这种效率过低,Java 后期作了改进,我再来详细讲一讲。

CAS算法

在讲其余改进以前,咱们先来聊聊 CAS 算法。CAS 算法全称为 Compare And Swap。

顾名思义,该算法涉及到了两个操做,比较(Compare)和交换(Swap)。

怎么理解这个操做呢?咱们来看下图:

咱们知道,在对共享变量进行多线程操做的时候,不免会出现线程安全问题。

对该问题的一种解决策略就是对该变量加锁,保证该变量在某个时间段只能被一个线程操做。

可是这种方式的系统开销比较大。所以开发人员提出了一种新的算法,就是大名鼎鼎的 CAS 算法。

CAS 算法的思路以下:

  1. 该算法认为线程之间对变量的操做进行竞争的状况比较少。
  2. 算法的核心是对当前读取变量值 E 和内存中的变量旧值 V 进行比较。
  3. 若是相等,就表明其余线程没有对该变量进行修改,就将变量值更新为新值 N
  4. 若是不等,就认为在读取值 E 到比较阶段,有其余线程对变量进行过修改,不进行任何操做。

当线程运行 CAS 算法时,该运行过程是原子操做,原子操做的含义就是线程开始跑这个函数后,运行过程当中不会被别的程序打断。

咱们来看看实际上 Java 语言中如何使用这个 CAS 算法,这里咱们以 AtomicInteger 类中的 compareAndSwapInt() 方法举例:

public final native boolean compareAndSwapInt
(Object var1, long var2, int var3, int var4)

能够看到,该函数原型接受四个参数:

  1. 第一个参数是一个 AtomicInteger 对象。
  2. 第二个参数是该 AtomicInteger 对象对应的成员变量在内存中的地址。
  3. 第三个参数是上图中说的线程以前读取的值 P
  4. 第四个参数是上图中说的线程计算的新值 V

偏向锁

JDK 1.6 中提出了偏向锁的概念。该锁提出的缘由是,开发者发现多数状况下锁并不存在竞争,一把锁每每是由同一个线程得到的。

若是是这种状况,不断的加锁解锁是没有必要的。

那么能不能让 JVM 直接负责在这种状况下加解锁的事情,不让操做系统插手呢?

所以开发者设计了偏向锁。偏向锁在获取资源的时候,会在资源对象上记录该对象是否偏向该线程。

偏向锁并不会主动释放,这样每次偏向锁进入的时候都会判断该资源是不是偏向本身的,若是是偏向本身的则不须要进行额外的操做,直接能够进入同步操做。

下图表示偏向锁的 Mark Word结构:

能够看到,偏向锁对应的 Mark Word 包含该偏向锁对应的线程 ID、偏向锁的时间戳和对象分代年龄。

偏向锁的申请流程

咱们再来看一下偏向锁的申请流程:

  1. 首先须要判断对象的 Mark Word 是否属于偏向模式,若是不属于,那就进入轻量级锁判断逻辑。不然继续下一步判断;
  2. 判断目前请求锁的线程 ID 是否和偏向锁自己记录的线程 ID 一致。若是一致,继续下一步的判断,若是不一致,跳转到步骤4;
  3. 判断是否须要重偏向,重偏向逻辑在后面一节批量重偏向和批量撤销会说明。若是不用的话,直接得到偏向锁;
  4. 利用 CAS 算法将对象的 Mark Word 进行更改,使线程 ID 部分换成本线程 ID。若是更换成功,则重偏向完成,得到偏向锁。若是失败,则说明有多线程竞争,升级为轻量级锁。

值得注意的是,在执行完同步代码后,线程不会主动去修改对象的 Mark Word,让它重回无锁状态。

因此通常执行完 synchronized 语句后,若是是偏向锁的状态的话,线程对锁的释放操做多是什么都不作。

匿名偏向锁

在 JVM 开启偏向锁模式下,若是一个对象被新建,在四秒后,该对象的对象头就会被置为偏向锁。

通常来讲,当一个线程获取了一把偏向锁时,会在对象头和栈帧中的锁记录里不只说明目前是偏向锁状态,也会存储锁偏向的线程 ID。

在 JVM 四秒自动建立偏向锁的状况下,线程 ID 为0。

因为这种状况下的偏向锁不是由某个线程求得生成的,这种状况下的偏向锁也称为匿名偏向锁。

批量重偏向和批量撤销

生产者消费者模式下,生产者线程负责对象的建立,消费者线程负责对生产出来的对象进行使用。

当生产者线程建立了大量对象并执行加偏向锁的同步操做,消费者对对象使用以后,会产生大量偏向锁执行和偏向锁撤销的问题。

Russell K和 Detlefs D在他们的文章提出了批量重偏向和批量撤销的过程。

在上图情景下,他们探讨了能不能直接将偏向的线程换成消费者的线程。

替换不是一件容易事,须要在 JVM 的众多线程中找到相似上文情景的线程。

他们最后提出的解决方法是:

以类为单位,为每一个类维护一个偏向锁撤销计数器,每一次该类的对象发生偏向撤销操做时,该计数器计数 +1,当这个计数值达到重偏向阈值时,JVM 就认为该类可能不适合正常逻辑,适合批量重偏向逻辑。这就是对应上图流程图里的是否须要重偏向过程。

以生产者消费者为例,生产者生产同一类型的对象给消费者,而后消费者对这些对象都须要执行偏向锁撤销,当撤销过程过多时就会触发上文规则,JVM 就注意到这个类了。

具体规则是:

  1. 每一个类对象会有一个对应的 epoch 字段,每一个处于偏向锁状态对象的 Mark Word 中也有该字段,其初始值为建立该对象时,类对象中的 epoch 的值。
  2. 每次发生批量重偏向时,就将类对象的 epoch 字段 +1,获得新的值 epoch_new
  3. 遍历 JVM 中全部线程的栈,找到该类对象,将其 epoch 字段改成新值。根据线程栈的信息判断出该线程是否锁定了该对象,将如今偏向锁还在被使用的对象赋新值 epoch_new
  4. 下次有线程想得到锁时,若是发现当前对象的 epoch 值和类的 epoch 不相等,不会执行撤销操做,而是直接经过 CAS 操做将其 Mark Word 的 Thread ID 改为当前线程 ID。

批量撤销相对于批量重偏向好理解得多,JVM 也会统计重偏向的次数。

假设该类计数器计数继续增长,当其达到批量撤销的阈值后(默认40),JVM 就认为该类的使用场景存在多线程竞争,会标记该类为不可偏向,以后对于该类的锁升级为轻量级锁。

轻量级锁

轻量级锁的设计初衷在于并发程序开发者的经验“对于绝大部分的锁,在整个同步周期内都是不存在竞争的”。

因此它的设计出发点也在线程竞争状况较少的状况下。咱们先来看一下轻量级锁的 Mark Word 布局。

若是当前对象是轻量级锁状态,对象的 Mark Word 以下图所示。

咱们能够看到,该对象头Mark Word分为两个部分。第一部分是指向栈中的锁记录的指针,第二部分是锁标记位,针对轻量级锁该标记位为 00。

A问:那这指向栈中的锁记录的指针是什么意思呢?

B答:这得结合轻量级锁的上锁步骤来慢慢讲。

若是当前这个对象的锁标志位为 01(即无锁状态或者轻量级锁状态),线程在执行同步块以前,JVM 会先在当前的线程的栈帧中建立一个 Lock Record,包括一个用于存储对象头中的 Mark Word 以及一个指向对象的指针。

而后 JVM 会利用 CAS 算法对这个对象的 Mark Word 进行修改。若是修改为功,那该线程就拥有了这个对象的锁。咱们来看一下若是上图的线程执行 CAS 算法成功的结果。

固然 CAS 也会有失败的状况。若是 CAS 失败,那就说明同时执行 CAS 操做的线程可不止一个了, Mark Word 也作了更改。

首先虚拟机会检查对象的 Mark Word 字段指向栈中的锁记录的指针是否指向当前线程的栈帧。若是是,那就说明可能出现了相似 synchronized 中套 synchronized 状况:

synchronized (对象0) {
    synchronized (对象0) {
        ···
    }
}

固然这种状况下当前线程已经拥有这个对象的锁,能够直接进入同步代码块执行。

不然说明锁被其余线程抢占了,该锁还须要升级为重量级锁。

和偏向锁不一样的是,执行完同步代码块后,须要执行轻量级锁的解锁过程。解锁过程以下:

  1. 经过 CAS 操做尝试把线程栈帧中复制的 Mark Word 对象替换当前对象的 Mark Word。
  2. 若是 CAS 算法成功,整个同步过程就完成了。
  3. 若是 CAS 算法失败,则说明存在竞争,锁升级为重量级锁。

咱们来总结一下轻量级锁升级过程吧:

总结

此次咱们了解了 synchronized 底层实现原理和对应的锁升级过程。最后咱们再经过这张流程图来回顾一下 synchronized 锁升级过程吧。

写在最后

欢迎你们关注个人公众号【风平浪静如码】,海量Java相关文章,学习资料都会在里面更新,整理的资料也会放在里面。

以为写的还不错的就点个赞,加个关注呗!点关注,不迷路,持续更新!!!

相关文章
相关标签/搜索