Java多线程(五)——synchronized关键字原理

iwehdio的博客园:https://www.cnblogs.com/iwehdio/

学习自:

1、Java对象的内存表示

  • Java 对象在内存中的表示方法:

image-20210209170021782

  • 内存中的对象一般由三部分组成,分别是对象头、对象实际数据和对齐填充。

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

    • 标记字段Mark Word 记录了对象关于锁的信息,垃圾回收信息等。
    • Class Pointer 用于指向对象对应的 Class 对象(其对应的元数据对象)的内存地址。
    • Length只适用于对象是数组时,它保存了该数组的长度信息。
  • 对象实际数据包括了对象的所有成员变量,其大小由各个成员变量的大小决定。

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

  • synchronized 锁使用的就是对象头的 Mark Word 字段中的一部分。

    • Mark Word 中的某些字段发生变化,就可以代表锁不同的状态。
    • 由于锁的信息是记录在对象里的,也往往会说锁住对象这种表述。
  • 无锁状态的 Mark Word 字段:

    image-20210209170444269

    • 对象头的 Mark Word 字段分为四个部分:
      1. 对象的 hashCode ;
      2. 对象的分代年龄,这部分用于对对象的垃圾回收;
      3. 是否为偏向锁位,1代表是,0代表不是;
      4. 锁标志位,这里是 01。

2、synchronized原理

  • 直接作为关键字修饰在方法上,将整个方法作为同步代码块:
    • 编译器会为该方法自动生成了一个 ACC_SYNCHRONIZED 关键字用来标识。
    • 在 JVM 进行方法调用时,当发现调用的方法被 ACC_SYNCHRONIZED 修饰,则会先尝试获得锁。
    • 同步方法的时候,一旦执行到这个方法,就会先判断是否有标志位,然后,ACC_SYNCHRONIZED会去隐式调用monitorenter和monitorexit。
  • 修饰在同步代码块上:
    • 编译时在代码块开始前生成对应的1个 monitorenter 指令,代表同步块进入。2个 monitorexit 指令,代表同步块退出。
  • 这两种方法底层都需要一个 reference 类型的参数,指明要锁定和解锁的对象。
    • 如果 synchronized 明确指定了对象参数,那就是该对象。
    • 如果没有明确指定,那就根据修饰的方法是实例方法还是类方法,取对应的对象实例或类对象(Java 中类也是一种特殊的对象)作为锁对象。
    • 每个对象维护着一个记录着被锁次数的计数器。当一个线程执行 monitorenter,该计数器自增从 0 变为 1;
    • 当一个线程执行 monitorexit,计数器再自减。当计数器为 0 的时候,说明对象的锁已经释放。
  • 为什么会有两个 monitorexit 指令呢?
    • 正常退出,得用一个 monitorexit 吧,如果中间出现异常,锁会一直无法释放。所以编译器会为同步代码块添加了一个隐式的 try-finally 异常处理,在 finally 中会调用 monitorexit 命令最终释放锁。

重量级锁

  • 重量级锁对应对象的 Mark Word:

    image-20210209171048556

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

    • 指向重量级锁的指针就是所谓的同步监视器 monitor。这个监视器其实也就是监控锁有没有释放,释放的话会通知下一个等待锁的线程去获取。

      image-20210209171227328

    • 可以将 monitor 简单理解成两部分,第一部分表示当前占用锁的线程,第二部分是等待这把锁的线程队列。如果当前占用锁的线程把锁释放了,那就需要在线程队列中唤醒下一个等待锁的线程。

    • 是阻塞或唤醒一个线程需要依赖底层的操作系统来实现,Java 的线程是映射到操作系统的原生线程之上的。而操作系统实现线程之间的切换需要从用户态转换到核心态,这个状态转换需要花费很多的处理器时间,甚至可能比用户代码执行的时间还要长。由于这种效率太低,Java 后期做了改进。

  • CAS算法:

    1. 该算法认为线程之间对变量的操作进行竞争的情况比较少。
    2. 算法的核心是对当前读取变量值 E 和内存中的变量旧值 V 进行比较。
    3. 如果相等,就代表其他线程没有对该变量进行修改,就将变量值更新为新值 N
    4. 如果不等,就认为在读取值 E 到比较阶段,有其他线程对变量进行过修改,不进行任何操作。
    • 当线程运行 CAS 算法时,该运行过程是原子操作,原子操作的含义就是线程开始跑这个函数后,运行过程中不会被别的程序打断。

偏向锁

  • JDK 1.6 中提出了偏向锁的概念。该锁提出的原因是,开发者发现多数情况下锁并不存在竞争,一把锁往往是由同一个线程获得的。如果是这种情况,不断的加锁解锁是没有必要的。

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

  • 偏向锁并不会主动释放,这样每次偏向锁进入的时候都会判断该资源是否是偏向自己的,如果是偏向自己的则不需要进行额外的操作,直接可以进入同步操作。

  • 偏向锁的 Mark Word结构:

    image-20210209171909655

    • 偏向锁对应的 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。
    • 由于这种情况下的偏向锁不是由某个线程求得生成的,这种情况下的偏向锁也称为匿名偏向锁。
  • 批量重偏向和批量撤销:

    • 在生产者消费者模式下,生产者线程负责对象的创建,消费者线程负责对生产出来的对象进行使用。
    • 当生产者线程创建了大量对象并执行加偏向锁的同步操作,消费者对对象使用之后,会产生大量偏向锁执行和偏向锁撤销的问题。
    • 以类为单位,为每个类维护一个偏向锁撤销计数器,每一次该类的对象发生偏向撤销操作时,该计数器计数 +1,当这个计数值达到重偏向阈值时,JVM 就认为该类可能不适合正常逻辑,适合批量重偏向逻辑。

轻量级锁

  • 轻量级锁的设计初衷对于绝大部分的锁,在整个同步周期内都是不存在竞争的。所以它的设计出发点也在线程竞争情况较少的情况下。

  • 轻量级锁的 Mark Word:

    image-20210209174123641

    • 第一部分是指向栈中的锁记录的指针,第二部分是锁标记位,针对轻量级锁该标记位为 00。
  • 偏向锁升级为轻量级锁:

    • 如果当前这个对象的锁标志位为 01(即无锁状态或者偏向锁状态),线程在执行同步块之前,JVM 会先在当前的线程的栈帧中创建一个 Lock Record,包括一个用于复制对象头中的 Mark Word 以及一个指向对象的指针。
    • 然后 JVM 会利用 CAS 算法对这个对象的 Mark Word 进行修改。如果修改成功,那该线程就拥有了这个对象的锁。
    • 如果 CAS 失败,那就说明同时执行 CAS 操作的线程可不止一个了, Mark Word 也做了更改。
      • 首先虚拟机会检查对象的 Mark Word 字段指向栈中的锁记录的指针是否指向当前线程的栈帧。如果是,那就说明可能出现了类似 synchronized 中套 synchronized 情况。这种情况下当前线程已经拥有这个对象的锁,可以直接进入同步代码块执行。
      • 否则说明锁被其他线程抢占了,该锁还需要升级为重量级锁。
    • 但是不会直接升级为重量级锁,会先自旋的尝试获取轻量级锁,默认自旋10次后,在升级为重量级锁。

  • 和偏向锁不同的是,执行完同步代码块后,需要执行轻量级锁的解锁过程:

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

iwehdio的博客园:https://www.cnblogs.com/iwehdio/
来源与结束于否定之否定。
原文地址:https://www.cnblogs.com/iwehdio/p/14395062.html