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

33次阅读

共计 6857 个字符,预计需要花费 18 分钟才能阅读完成。

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() 办法的话该怎么设计。

咱们能够设置某个锁,锁会有两种状态,别离是 上锁 解锁。在 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 相干文章,学习材料都会在外面更新,整顿的材料也会放在外面。

感觉写的还不错的就点个赞,加个关注呗!点关注,不迷路,继续更新!!!

正文完
 0