看图学源码之 synchronized 源码分析一 : 锁的介绍和锁优化

从对象头开始

虚拟机中的对象

HotSpot虚拟机的对象头(ObjectHeader)分为两部分:

  1. 用于存储对象自身的运行时数据,如哈希码( HashCode)、GC 分代年龄 ( Generational GC Age)等。

    这部分数据的长度在32 位和 64 位的 Java 虚拟机中分别会占用 32 个或 64 个比特,官方称它为“Mark Word”。这部分是实现轻量级锁和偏向锁的关键

  2. 用于存储指向方法区对象类型数据的 指针,如果是数组对象,还会有一个额外的部分用于存储数组长度。

即:

头对象结构说明
Mark Word存储对象的hashCode、锁信息或分代年龄或GC标志等信息
Class Metadata Address类型指针指向对象的类元数据,JVM通过这个指针确定该对象是哪个类的实例。

由于对象头信息是与对象自身定义的数据无关的额外存储成本,考虑到 Java 虚拟机的空间使用效率,Mark Word 被设计成一个非固定的动态数据结构,以便在极小的空间内存储尽量多的信息。它会根据对象的状态复用自己的存储空间。

openjdk\hotspot\src\share\vm\oops路径下有markOop.hpp的C++头文件

//Bit-format of an object header (most significant first, big endian layout below):
//
//  32 bits:
//  --------
//             hash:25 ------------>| age:4    biased_lock:1 lock:2 (normal object)
//             JavaThread*:23 epoch:2 age:4    biased_lock:1 lock:2 (biased object)
//             size:32 ------------------------------------------>| (CMS free block)
//             PromotedObject*:29 ---------->| promo_bits:3 ----->| (CMS promoted object)
//
//  64 bits:
//  --------
//  unused:25 hash:31 -->| unused:1   age:4    biased_lock:1 lock:2 (normal object)
//  JavaThread*:54 epoch:2 unused:1   age:4    biased_lock:1 lock:2 (biased object)
//  PromotedObject*:61 --------------------->| promo_bits:3 ----->| (CMS promoted object)
//  size:64 ----------------------------------------------------->| (CMS free block)
//
//  unused:25 hash:31 -->| cms_free:1 age:4    biased_lock:1 lock:2 (COOPs && normal object)
//  JavaThread*:54 epoch:2 cms_free:1 age:4    biased_lock:1 lock:2 (COOPs && biased object)
//  narrowOop:32 unused:24 cms_free:1 unused:4 promo_bits:3 ----->| (COOPs && CMS promoted object)
//  unused:21 size:35 -->| cms_free:1 unused:7 ------------------>| (COOPs && CMS free block)

MarkWord在32位的JVM中是32bit,在64位中是64bit。但是对于锁状态的存储内容都是一致的。

在这里插入图片描述

说明:

2bit的锁标志位表示锁的状态

1bit的偏向锁标志位表示是否偏向。

Synchronized

基本介绍

介绍

在 Java 里面,最基本的互斥同步手段就是 synchronized关键字,这是一种块结构(Block Structured)的同步语法。

从字节码的角度来看的话synchronized 关键字经过Javac 编译之后,会在同步块的前后分别形成 monitorentermonitorexit 这两个字节码指令。

从执行成本的角度看,持有锁是一个重量级 ( Heavy-Weight) 的操作。

在主流 Java 虚拟机实现中,Java 的线程是映射到操作系统的原生内核线程之上的,如果要阻塞或唤醒一条线程,则需要操作系统来帮忙完成,这就不可避免地陷入用户态到核心态的转换中,进行这种状态转换需要耗费很多的处理器时间。尤其是对于代码特别简单的同步块(譬如被 synchronized 修饰的 getter()setter()方法),状态转换消耗的时间甚至会比用户代码本身执行的时间还要长。

所以 synchronized 是 Java语言中一个重量级的操作,有经验的程序员都只会在确实必要的情况下才使用这种操作。

而虚拟机本身也会进行一些优化,譬如在通知操作系统阻塞线程之前加入一段自旋等待过程以避免频繁地切入核心态之中。

synchronized 没有禁止指令重排

在synchronized 中 加了锁,只能有一个线程获得锁,获取不到锁的线程就要阻塞。所以同一时间只有一个线程执行,相当于单线程,而单线程的指令重排是没有问题的。

在Java中,不管怎么排序,都不能影响单线程程序的执行结果。这就是as-if-serial语义,所有硬件优化的前提都是必须遵守as-if-serial语义(as-if-serial语义的意思是:不管怎么重排序,单线程程序的执行结果不能被改变)。

因为有as-if-serial语义保证,单线程的有序性就天然存在了。

使用场景:

  1. 修饰实例方法,作用于当前实例加锁,进入同步代码前要获得当前实例的锁
  2. 修饰静态方法,作用于当前类对象加锁,进入同步代码前要获得当前类对象的锁
  3. 修饰代码块指定加锁对象,对给定对象加锁,进入同步代码库前要获得给定对象的锁

使用的注意事项:

  1. 若是对象锁,则每个对象都持有一把自己的独一无二的锁,且对象之间的锁互不影响 。若是类锁,所有该类的对象共用这把锁
  2. 一个线程获取一把锁,没有得到锁的线程只能排队等待
  3. synchronized 是可重入锁,避免很多情况下的死锁发生。
  4. synchronized 方法若发生异常,则JVM会自动释放锁
  5. 锁对象不能为空,否则抛出NullPointerException
  6. synchronized 本身是不具备继承性的:即父类的synchronized 方法,子类重写该方法,要是没有synchonized修饰,则该子类方法不是线程同步的。
  7. synchronized本身修饰的范围越小越好。毕竟是同步阻塞。
  8. 同时访问synchronized的静态和非静态方法,不能保证线程安全,因为两者的锁对象不一样。前者是类锁(XXX.class),后者是this
  9. 同时访问synchronized方法和非同步方法,不能保证线程安全,因为synchronized只会对被修饰的方法起作用。
  10. 两个线程同时访问两个对象的非静态同步方法,不能保证线程安全,因为每个对象都拥有一把锁。两个对象相当于有两把锁,导致锁对象不一致。(PS:如果是类锁,则所有对象共用一把锁)

底层分析

字节码角度分析:

package com.ry.learning.study.Synchronized_;


public class synchronized_1 {
    public  static  synchronized void m0(){

    }
    public synchronized void m1(){

    }
    Object o = new Object();
    public  void m2(){
        synchronized (o){

        }
    }
    public  void m3(){
        synchronized (synchronized_1.class){

        }
    }
}
{
  java.lang.Object o;
    descriptor: Ljava/lang/Object;
    flags:

  public com.ry.learning.study.Synchronized_.synchronized_1();
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
      stack=3, locals=1, args_size=1
         0: aload_0
         1: invokespecial #1                  // Method java/lang/Object."<init>":()V
         4: aload_0
         5: new           #2                  // class java/lang/Object
         8: dup
         9: invokespecial #1                  // Method java/lang/Object."<init>":()V
        12: putfield      #3                  // Field o:Ljava/lang/Object;
        15: return
      LineNumberTable:
        line 4: 0
        line 11: 4

  public static synchronized void m0();
    descriptor: ()V
    flags: ACC_PUBLIC, ACC_STATIC, ACC_SYNCHRONIZED   -- 修饰方法
    Code:
      stack=0, locals=0, args_size=0
         0: return
      LineNumberTable:
        line 7: 0

  public synchronized void m1();
    descriptor: ()V
    flags: ACC_PUBLIC, ACC_SYNCHRONIZED        -- 修饰方法
    Code:
      stack=0, locals=1, args_size=1
         0: return
      LineNumberTable:
        line 10: 0

  public void m2();
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
      stack=2, locals=3, args_size=1
         0: aload_0
         1: getfield      #3                  // Field o:Ljava/lang/Object;
         4: dup
         5: astore_1
         6: monitorenter                       -- 锁对象: 修饰代码块
         7: aload_1
         8: monitorexit
         9: goto          17
        12: astore_2
        13: aload_1
        14: monitorexit
        15: aload_2
        16: athrow
        17: return
      Exception table:
         from    to  target type
             7     9    12   any
            12    15    12   any
      LineNumberTable:
        line 13: 0
        line 15: 7
        line 16: 17
      StackMapTable: number_of_entries = 2
        frame_type = 255 /* full_frame */
          offset_delta = 12
          locals = [ class com/ry/learning/study/Synchronized_/synchronized_1, class java/lang/Object ]
          stack = [ class java/lang/Throwable ]
        frame_type = 250 /* chop */
          offset_delta = 4

  public void m3();
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
      stack=2, locals=3, args_size=1
         0: ldc           #4                  // class com/ry/learning/study/Synchronized_/synchronized_1
         2: dup
         3: astore_1
         4: monitorenter                        -- 锁类: 修饰代码块
         5: aload_1
         6: monitorexit
         7: goto          15
        10: astore_2
        11: aload_1
        12: monitorexit
        13: aload_2
        14: athrow
        15: return
      Exception table:
         from    to  target type
             5     7    10   any
            10    13    10   any
      LineNumberTable:
        line 18: 0
        line 20: 5
        line 21: 15
      StackMapTable: number_of_entries = 2
        frame_type = 255 /* full_frame */
          offset_delta = 10
          locals = [ class com/ry/learning/study/Synchronized_/synchronized_1, class java/lang/Object ]
          stack = [ class java/lang/Throwable ]
        frame_type = 250 /* chop */
          offset_delta = 4
}
SourceFile: "synchronized_1.java"
从上面可以得到的结论是:

Java 虚拟机中的同步(Synchronization)基于进入和退出管程(Monitor)对象实现。所以说:由synchronized的对象锁,指针指向的是monitor对象(也称为管程或监视器锁)的地址,所以每个对象都存在着一个 monitor 与之关联,monitor是由ObjectMonitor实现的,具体的内容见后面的源码角度分析: 的部分 。

  1. 修饰方法的时候:方法的访问标志 flags 中会有 flags: ACC_SYNCHRONIZED;JVM可以从方法常量池中的方法表结构中的 ACC_SYNCHRONIZED 访问标志区分一个方法是否同步方法。这个标记在我们的 HotSpot 里面也会隐式的调用 monitorentermonitorexit 。就是:方法调用指令读取运行时常量池中方法的 ACC_SYNCHRONIZED 标志来隐式实现的
  2. 修饰代码块:会产生mointerentermointerexit指令,由monitorenter指令进入,然后monitorexit释放锁

就是说:代码块同步是使用monitorentermonitorexit指令实现,但是方法同步是使用另外一种方式实现的,实现的细节也可以使用这两个指令来实现。

关于两个指令:

任何对象都有一个 monitor 与之关联,当一个monitor 被持有后,它将处于锁定状态。

  1. monitorenter指令是在编译后插入到同步代码块的开始位置,线程执行到 monitorenter 指令时,将会尝试获取对象所对应的 monitor 的所有权,即尝试获得对象的锁

  2. monitorexit插入到方法结束处和异常处JVM要保证每个monitorenter必须有对应的monitorexit与之配对。

  3. 这两个字节码指令都需要一个reference 类型的参数来指明要锁定和解锁的对象

    1. 如果 Java代码中的 synchronized 明确指定了对象参数,那就以这个对象的引用作为 reference ;
    2. 如果没有明确指定,那将根据synchronized 修饰的方法类型(如实例方法或类方法),来决定是取代码所在的对象实例 还是 类型对应的 Class 对象来作为线程要持有的锁。
两个指令的原理
  1. 加锁:当线程执行 monitorenter 的时候会 先 尝试 获取栈顶对象的监视器mintor,此时的 monitor没有被其他线程占有的话就会获取锁,并将 monitor 计数器设置为1,线程成功获得锁, monitorenter 会顺利执行后续步骤;随后,当线程再次执行到这段的时候,会判断monitor 的所有权是不是自己,是的话会对 monitor 计数器 + 1 。但是,要是已经其他线程占有的话就会阻塞,直到 monitor 计数器为 0 ,才会进行抢占式的获取锁。
  2. 解锁:执行monitorexit 的指令的时候,会对 monitor 计数器 - 1 ,直到 monitor 计数器为 0 才会释放,其他等待的线程可以尝试获得 monitor 的所有权。

monitorentermonitorexit 的行为描述我们可以得出两个关于 synchronized 的直接推论,这是使用它时需特别注意的:

  1. synchronized 修饰的同步块对同一条线程来说是可重入的。这意味着同一线程反复进入同步块也不会出现自己把自己锁死的情况
  2. 被 synchronized 修饰的同步块在持有锁的线程执行完毕并释放锁之前,会无条件地阻塞后面其他线程的进入。这意味着无法强制已获取锁的线程释放锁,也无法强制正在等待锁的线程中断等待或超时退出
总结:

当方法调用时,调用指令将会 检查方法的 ACC_SYNCHRONIZED 访问标志是否被设置;如果设置了,执行线程将先持有monitor, 然后再执行方法,最后在方法完成(无论是正常完成还是非正常完成 )时释放monitor

在同步方法执行期间,执行线程持有了monitor,其他任何线程都无法再获得同一个monitor。如果一个同步方法执行期间抛出了异常,并且在方法内部无法处理此异常,那这个同步方法所持有的monitor将在异常抛到同步方法之外时自动释放。

上述就是synchronized锁在同步方法上实现的基本原理

源码角度分析:见看图学源码之 synchronized源码解析二: HotSpot 的 源码解析

锁的优化:

在线程之间可以更加高效地共享数据并解决竞争的问题,从而提升程序的执行效率

锁的“相关状态”

1、自旋锁
基本介绍

为了让两个或以上的线程同时并行执行,我们就可以让后面请求等待一会(但是不放弃cpu 的执行时间)看看持有锁的线程是不是会很快的释放锁,释放的话再执行;为了实现等待一会的功能,我们只要让线程执行一个 忙循环(自旋)———这就是自旋锁。

其中:所谓自旋,就是让线程去执行一个无意义的循环,循环结束后再去重新竞争锁,如果竞争不到继续循环,循环过程中线程会一直处于running状态,但是基于JVM的线程调度,会出让时间片,所以其他线程依旧有申请锁和释放锁的机会

参数设定

自旋锁 默认是关闭的,因为要是自旋一直失败的话就会导致自旋锁 占用cpu 时间变得很长,即使自旋锁本身避免了线程的切换,但是仍然是有损性能的 ;所以自旋等待必须要有一定的限度,默认自旋10次,要是超过了这个次数仍然没有获得锁,那么就使用传统的方法挂起线程。

-XX:+UseSpinning 开启自旋锁

-XX:+PreBlockSpin更改自旋次数

2、自适应的自旋锁

在自旋锁的基础上,将自旋时间不再固定不变,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态决定。

  • 频繁获取锁:要是在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也很有可能再次成功,进而允许自旋等待持续相对更长的时间;比如持续 100 次忙循环。

  • 很少获取锁:如果对于某个锁,自旋很少成功获得过锁,那在以后要获取这个锁时将有可能直接省略掉自旋过程,以避免浪费处理器资源。

3、锁消除

锁消除是指虚拟机 JIT 在运行时,对一些代码要求同步,但是对被检测到不能存在共享数据竞争的锁进行消除

锁消除的主要判定依据来源于逃逸分析的数据支持,如果一段代码中,在堆上的所有数据都不会逃逸出去被其他线程访问到,那就可以把它们当作 栈上数据 对待,认为它们是线程私有的,同步加锁自然就无须再进行。

变量是否逃逸,对于虚拟机来说是需要使用复杂的过程间分析才能确定的,但是程序员自己应该是很清楚的,怎么会在明知道不存在数据争用的情况下还要求同步呢?

是因为:有许多同步措施并不是程序员自己加入的,同步的代码在 Java 程序中出现的频繁程度十分夸张。

4、锁粗化

原则上:总是推荐将同步块的作用范围限制得尽量小一一只在共享数据的实际作用域中才进行同步,这样是为了使得需要同步的操作数量尽可能变少,即使存在锁竞争,等待锁的线程也能尽可能快地拿到锁。

大多数情况下,上面的原则都是正确的,但是如果一系列的连续操作都对同一个对象反复加锁和解锁,甚至加锁操作是出现在循环体之中的,那即使没有线程竞争,频繁地进行互斥同步操作也会导致不必要的性能损耗。

如果虚拟机探测到有这样一串零碎的操作都对同一个对象加锁,将会把加锁同步的范围扩展(粗化)到整个操作序列的外部。

5、偏向锁
目的:

消除数据在无竞争情况下的同步原语,进一步提高程序的运行性能

如果说轻量级锁是在无竞争的情况下使用 CAS操作去消除同步使用的互斥量;那偏向锁就是在无竞争的情况下整个同步都消除掉,连CAS 操作都不去做了。

特点:
  1. 偏向锁的释放不需要做任何事情,这也就意味着加过偏向锁的MarkValue会一直保留偏向锁的状态,因此即便同一个线程持续不断地加锁解锁,也是没有开销的。
  2. 偏向锁比轻量锁更容易被终结,轻量锁是在有锁竞争出现时升级为重量锁,而一般偏向锁是在有不同线程申请锁时升级为轻量锁。这也就意味着假如一个对象先被线程1加锁解锁,再被线程2加锁解锁,这过程中没有锁冲突也一样会发生偏向锁失效,不同的是这回要先退化为无锁的状态,再加轻量锁。
  3. JVM对有多线程加锁,但不存在锁竞争的情况也做了优化,就是说:可能出现这种情况,因为线程之前除了互斥之外也可能发生同步关系,被同步的两个线程(一前一后)对共享对象锁的竞争很可能是没有冲突的。对这种情况,JVM用一个epoch表示一个偏向锁的时间戳(真实地生成一个时间戳代价还是蛮大的,因此这里应当理解为一种类似时间戳的identifier
含义:

偏向于第一个获得它的线程,如果在接下来的执行过程中,该锁一直没有被其他的线程获取,则持有偏向锁的线程将永远不需要再进行同步。

当对象进入偏向状态的时候,Mark Word大部分的空间(23 个比特)都用于存储持有锁的线程 ID 了,这部分空间占用了原有存储对象哈希码的位置,那原来对象的哈希码怎么办呢? ————对象的哈希码的值是一直保持不变的,当对象进入偏向状态时,原来对象的哈希码会被存储在对象头的其他位置中。在偏向锁状态下,由于大部分空间被用于存储持有锁的线程ID,所以对象的哈希码无法直接存储在Mark Word中。当需要使用对象的哈希码时,JVM会通过其他方式获取,比如从对象的元数据中获取。总之:对象的哈希码并没有丢失,仍然可以通过其他途径获取,不受对象进入偏向状态的影响

因为:在 Java 语言里面一个对象如果计算过哈希码,就应该一直保持该值不变(强烈推荐但不强制,因为用户可以重载 hashCode()方法 是按自己的意愿返回哈希码的),否则很多依赖对象哈希码的 API都可能存在出错风险。

而作为绝大多数对象哈希码来源的Object::hashCode()方法,返回的是对象的一致性哈希码 ( Identity Hash Code),这个值是能强制保证不变的,它通过在对象头中存储计算结果来保证第一次计算之后,再次调用该方法取到的哈希码值永远不会再发生改变。因此,当一个对象已经计算过一致性哈希码后,它就再也无法进入偏向锁状态了。

而当一个对象当前正处于偏向锁状态,又收到需要计算其一致性哈希码请求时,它的偏向状态会被立即撤销,并且锁会膨胀为重量级锁。 在重量级锁的实现中对象头指向了重量级锁的位置,代表重量级锁的 ObjectMonitor类里有字段可以记录非加锁状态(标志位为01)下的 Mark Word,其中自然可以存储原来的哈希码。

偏向锁可以提高带有同步但无竞争的程序性能,但它同样是一个带有效益权衡性质的优化,也就是说它并非总是对程序运行有利。如果程序中大多数的锁都总是被多个不同的线程访问,那偏向模式就是多余的。有时候使用参数 -XX:UseBiasedLocking来禁止偏向锁优化反而可以提升性能。

获取
  1. 初始状态:对象处于无锁状态,对象头中的Mark Word为默认值。
  2. 第一次加锁:当一个线程尝试获取对象的偏向锁时,JVM会检查对象头中的Mark Word。如果Mark Word的偏向锁标识位为0,表示对象没有被偏向过,JVM会尝试将当前线程的Thread ID记录在Mark Word中,并将偏向锁标识位置为1。这个过程是通过CAS(Compare and Swap)原子操作来完成的。
  3. 加锁状态:如果CAS操作成功,表示当前线程成功获取了偏向锁,并且对象的Mark Word中记录了持有锁的线程ID。此时,线程可以直接进入临界区执行操作,无需进一步同步操作。
  4. 后续加锁:如果其他线程也尝试获取同一个对象的偏向锁,JVM会检查对象头中的Mark Word。如果Mark Word的偏向锁标识位为1,并且记录的线程ID与当前线程的Thread ID相同,表示当前线程已经持有了偏向锁,可以直接进入临界区执行操作。
  5. 偏向撤销:当一个线程尝试获取偏向锁但失败时,JVM会检查对象头中的Mark Word。如果Mark Word的偏向锁标识位为1,但记录的线程ID与当前线程的Thread ID不同,表示当前线程无法获取偏向锁。JVM会撤销偏向锁的状态,将对象的Mark Word恢复为默认值,并尝试使用轻量级锁或重量级锁来保护临界区。

假设当前虚拟机启用了偏向锁(启用参数 -XX:+UseBiasedLocking),那么当锁对象第一次被线程获取的时候,虚拟机将会把对象头中的标志位设置为01、把偏向模式设置为1,表示进入偏向模式。同时使用 CAS 操作把获取到这个锁的线程的 ID 记录在对象的 Mark Word 之中。如果 CAS 操作成功,持有偏向锁的线程以后每次进入这个锁相关的同步块时,虚拟机可以不再进行任何同步操作

一旦出现另外一个线程去尝试获取这个锁的情况,偏向模式就马上宣告结束。根据锁对象目前是否处于被锁定的状态决定是否撤销偏向(偏向模式设置为0),撤销后标志位恢复到未锁定(标志位为01)或升级为轻量级锁定 (标志位为00)的状态,后续的同步操作就按照下面介绍的轻量级锁那样去执行。

撤销

偏向锁使用了一种等到有其他线程也在申请锁的时候才释放锁的机制,所以当其他线程尝试获取偏向锁时,持有偏向锁的线程才会释放锁。

偏向锁的撤销,需要等待全局安全点(STW)(在这个时间点上没有字节码正在执行),它会首先暂停拥有偏向锁的线程,然后检查持有偏向锁的线程是否活着,如果线程不处于活动状态,则将对象头设置成无锁状态;如果线程仍然活着,则锁升级为轻量级锁。拥有偏向锁的会被执行,遍历偏向对象的锁记录,栈中的锁记录和对象头的Mark Word要么重新偏向于其他线程,要么恢复到无锁或者标记对象不适合作为偏向锁,最后唤醒暂停的线程。

所以,如果某些临界区存在两个及两个以上的线程竞争,那么偏向锁反而会降低性能。在这种情况下,可以在启动 JVM 时就把偏向锁的默认功能关闭。这也就是上面我们 偏向锁延迟 的原因

特点:

偏向锁延迟偏向锁在升级为轻量级锁时,会涉及到偏向锁撤销,需要等到一个安全点(STW),才可以做偏向锁撤销,所以要是知道有并发的情况的时候,可以选择不开启或者延时开启偏向锁。JVM启动初期,有一个延迟4s开启偏向锁的操作

批量重偏向

某个类的实例化对象被多个线程访问(没有竞争)。

假设这些对象被线程1访问,访问之后,这些对象(偏向锁)的对象头的markWord中的 线程 都会指向线程T1的threadId;之后再用线程2去访问这些对象,这时偏向了线程1的这些对象就会被撤销偏向锁,并且升级成轻量级锁,如果线程2在一定时间内发起的偏向锁撤销已超过阈值,当(某类型对象)撤销偏向锁阈值超过 20 次后,jvm 会这样觉得,我是不是偏向错了呢,于是会在给(所有这种类型的状态为偏向锁的,之前升级成轻量级锁的肯定不行了,因为锁不能降级)对象加锁时重新偏向至新的加锁线程

但是:一段时间内当撤销偏向锁阈值超过40次后,jvm 会这样觉得,自己确实偏向错了,根本就不该偏向。于是整个类的所有对象都会变为不可偏向的(所有的,包括之前是偏向锁状态的),新建的该类型对象也是不可偏向的

匿名偏向锁

package com.ry.learning.study.Synchronized_;

import org.openjdk.jol.info.ClassLayout;

public class synchronized_1 {
    public static void main(String[] args) {
        Object o = new Object();

        System.out.println("=============加锁前==========");
        String printable = ClassLayout.parseInstance(o).toPrintable();
        System.out.println(printable);


        System.out.println("=============加锁时==========");
        synchronized(o){
            String printable1 = ClassLayout.parseInstance(o).toPrintable();
            System.out.println(printable1);
        }
        System.out.println("=============解锁后==========");
        String printable2 = ClassLayout.parseInstance(o).toPrintable();
        System.out.println(printable2);
    }
}

如果正常开启偏向锁了,那么不会出现无锁状态,对象会直接变为匿名偏向这里的匿名偏向锁是没有存储线程的

 Thread.sleep(5000);
        Object o = new Object();
        String printable1 = ClassLayout.parseInstance(o).toPrintable();
        System.out.println(printable1);
java.lang.Object object internals:
 OFFSET  SIZE   TYPE DESCRIPTION               VALUE         //也就是 101 偏向锁状态 
      0     4        (object header)           05 00 00 00 (00000101 00000000 00000000 00000000) (5)
      4     4        (object header)           00 00 00 00 (00000000 00000000 00000000 00000000) (0)
      8     4        (object header)           68 0f 00 00 (01101000 00001111 00000000 00000000) (3944)
     12     4        (loss due to the next object alignment)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total
6、轻量级锁

“轻量级”是相对于使用操作系统互斥量来实现的传统锁而言的,因此传统的锁机制就被称为“重量级”锁。

但是 轻量级锁 并不是用来代替重量级锁的,而是在没有多线程竞争的前提下,减少传统的重量级锁使用操作系统互斥量产生的性能消耗

工作过程
加锁

轻量级锁的加锁过程

  1. 在代码即将进入同步块的时候,如果此同步对象没有被锁定(锁标志位为“01”状态),虚拟机首先将在当前线程的栈帧中建立一个名为锁记录 (Lock Record)的空间,拷贝对象头中的 Mark Word 复制到锁记录中;

  2. 然后,虚拟机将使用CAS操作尝试把对象的 Mark Word 更新为指向 Lock Record 的指针

    1. 如果这个更新动作成功了,即代表该线程拥有了这个对象的锁,并且对象 Mark Word的锁标志位 (Mark Word 的最后两个比特)将转变为“00”,表示此对象处于轻量级锁定状态。

    2. 如果这个更新操作失败了,那就意味着至少存在一条线程与当前线程竞争获取该对象的锁。虚拟机首先会检查对象的 Mark Word 是否指向当前线程的栈帧;

      1. 如果是,说明当前线程已经拥有了这个对象的锁,那直接进入同步块继续执行就可以了

      2. 否则就说明这个锁对象已经被其他线程抢占了。

        如果出现两条以上的线程争用同一个锁的情况,那轻量级锁就不再有效,必须要膨胀为重量级锁,锁标志的状态值变为“10”;此时 Mark Word 中存储的就是指向重量级锁(互斥量)的指针,后面等待锁的线程也必须进入阻塞状态。

在这里插入图片描述

若当前只有一个等待线程,则该线程通过自旋进行等待

但是当自旋超过一定的次数,或者一个线程在持有锁另一个在自旋此时又有第三个来访时,轻量级锁升级为重量级锁

解锁

解锁过程也同样是通过 CAS 操作来进行的

如果对象的 Mark Word 仍然指向线程的锁记录,那就用 CAS 操作把对象当前的 Mark Word 和线程中复制的 Mark Word替换回来。

  1. 假如能够成功替换,那整个同步过程就顺利完成了
  2. 如果替换失败,则说明有其他线程尝试过获取该锁,就要在释放锁的同时唤醒被挂起的线程。

在这里插入图片描述

轻量级锁能提升程序同步性能依据是“对于绝大部分的锁,在整个同步周期内都是不存在竞争的”这一经验法则。

如果没有竞争,轻量级锁便通过 CAS 操作成功避免了使用互斥量的开销

但如果确实存在锁竞争,除了互斥量的本身开销外,还额外发生了 CAS 作的开销。因此在有竞争的情况下,轻量级锁反而会比传统的重量级锁更慢。

Lock Record

这里面会有两个参数:

  • owner:当前对象
  • Displaced Mark Word:当前对象的 Mark Word(这里会复制一份)

在这里插入图片描述

如果当前线程可以成功抢占该锁,对象的锁记录指针指向我们的 Lock Record,而 owner 则指向当前的 Mark Word

作用:

1、 在无锁的状态下,一个对象调用 hashCode() 函数的时候,JVM会把该对象的 HashCode 存储到对象头中,保证每次调用的时候都是一样的。

2、但是如果在偏向锁的状态下,hashCode在对象头没有空间存储,所以此时就在线程中开辟了 Lock Record 内存,存的内容就是 Mark Word,不断地cas ,以请求对象头的线程指向。cas 成功就将 Mark Word内的指针指向当前线程的Lock Record ,最终完成之后,对象头就撤销无锁状态,只要把Lock Record 赋值 过去就可以了。

7、重量级锁

又称为 对象监视器Monitor

由synchronized的对象锁,指针指向的是monitor对象(也称为管程或监视器锁)的地址,所以每个对象都存在着一个 monitor 与之关联,monitor是由ObjectMonitor实现的(C++实现的)

每个 Java 对象都可以关联一个 Monitor 对象,如果使用 synchronized 给对象上锁(重量级)之后,该对象头的Mark Word 中就被设置指向 Monitor 对象的指针

// 结构体
ObjectMonitor() {
  _header       = NULL;
  // 用来表示该线程的获取锁的次数
  _count        = 0;
  // 等待中的线程数
  _waiters      = 0,
  // 线程的重入次数
  _recursions   = 0;
  _object       = NULL;
  // 标识拥有该Monitor的线程
  _owner        = NULL;
  
  // 等待线程组成的双向循环链表
  _WaitSet      = NULL;
  _WaitSetLock  = 0 ;
  _Responsible  = NULL ;
  _succ         = NULL ;
  // 多线程竞争锁进入时的单向链表
  _cxq          = NULL ;
  FreeNext      = NULL ;
  // _owner从该双向循环链表中唤醒线程节点
  _EntryList    = NULL ;
  _SpinFreq     = 0 ;
  _SpinClock    = 0 ;
  OwnerIsThread = 0 ;
  _previous_owner_tid = 0;
}

主要的几个变量

  1. _cxq : 竞争队列,所有请求锁的线程首先被放在这个竞争队列

    所有线程会先 cas 的方式获取锁,但是要是获取不到就会进入cxq (竞争队列)

  2. _EntryList :_cxq中有资格成为候选资源的线程们进入到 EntryList

    Owner 线程释放锁的时候,JVM 会将 _cxq 中的线程放到 EntryList 的集合中,随后再将 EntryList 中的某个线程指定为 Ready Thread

  3. _WaitSet:某个拥有 ObjectMonitor 的线程在调用 Object.wait() 方法之后将被阻塞,然后该线程将被放置在 WaitSet 链表中。

    Owner 线程在调用 Object.wait() 方法之后将被阻塞,此时就会进入WaitSet,直到某个时刻通过 Object.notify() 或者Object.notifyAll() 唤醒,该线程就会重新进入 EntryList

  4. _owner:标识拥有该Monitor的线程

  5. _recursions:线程的重入次数

流程:

  1. 当线程刚进来时,会进入 cxq 的队列中
  2. 当多个线程同时访问一段同步代码时,首先会进入 _EntryList 集合。
  3. 当线程获取到对象的monitor 后进入 _Owner 区域,并把monitor中的owner变量设置为当前线程,同时monitor中的计数器count+1
  4. 当 owner 释放锁时,如果 EntryList 中有其他线程在等待获取锁,会选择其中一个线程作为新的 owner,并将其从 EntryList 移动到 Owner 区域。这个被选择的线程称为 OnDeck Thread
  5. 这个时候由 OnDeck Thread 去进行锁竞争,竞争失败的则继续留在 EntryList 中,等待下一次机会
  6. 如果 OnDeck Thread竞争锁成功,它会进入 Owner 区域,并将 monitor 中的 owner 变量设置为自己。
  7. 当调用 Object.wait() 会进入 _WaitSet 队列,owner的变量就会恢复成null,释放锁,count -1 ;只有被唤醒时,才会重新进入 EntryList 中,然后参与锁的竞争

值得注意的是:唤醒操作涉及到操作系统调度会有额外的开销

锁优化的四个过程

1、无锁状态

对象初始化之后,还没有任何线程竞争,此时为无锁状态,其中锁标志01 ,偏向锁标志位为 0

2、偏向锁

存在多线程,但是不存在多线程竞争,而且总是由同一线程多次获得。

有一个线程来竞争锁,锁对象第一次被线程获取时,锁标志位依然为01偏向锁标志位会被置为1,此时锁进入偏向模式。同时,使用CAS操作将此获取锁对象的线程ID设置到锁对象的Mark Word中,持有偏向锁,下次再可直接进入。

随后,线程B尝试获取锁,发现锁处于偏向模式,但Mark Word中存储的不是本线程ID。那么线程B使用CAS操作尝试获取锁,这时锁是有可能获取成功的,因为上一个持有偏向锁的线程不会主动释放偏向锁。如果线程B获取锁成功,则会将Mark Word 中的线程ID设置为本线程的ID。此时仍然是偏向锁!

在这里插入图片描述

3、轻量级锁

当锁竞争升级了后,有可能每次申请锁的线程都是不相同的,但时线程交替执行同步块的场合

但若线程B获取锁失败,则会执行下述操作:

  1. 偏向锁抢占失败,表明锁对象存在竞争,则会先撤销偏向模式,偏向锁标志位重新被置为0,准备升级轻量级锁。首先将在当前线程的帧栈中开辟一块锁记录空间(Lock Record),用于存储锁对象当前的Mark Word。然后,使用CAS操作尝试把锁对象的Mark Word更新为指向帧栈中Lock Record的指针,CAS操作成功,则代表获取到锁,同时将锁标志位设置为00,进入轻量级锁模式。
  2. 若CAS操作失败,则进入下述操作。
  3. 刚一出现CAS竞争轻量级锁失败时,不会立即膨胀为重量级锁,而是采用自旋的方式,不断重试,尝试抢锁。JDK1.6中,默认开启自旋,自旋10次,可通过-XX:PreBlockSpin更改自旋次数。JDK1.6对于只能指定固定次数的自旋进行了优化,采用了自适应的自旋,重试机制更加智能。
4、重量级锁

只有通过自旋依然获取不到锁的情况,表明锁竞争较为激烈,不再适合额外的CAS操作消耗CPU资源,则直接膨胀为重量级锁锁标志位设置为10。在此状态下,所有等待锁的线程都必须进入阻塞状态

小结

重量级锁是可以进行降级的——重量级锁降级发生于STW阶段,降级对象为仅仅能被虚拟机 Thread访问,而不能让其他Java Thread访问。

synchronized锁能不能降级?
GPT的解释是:
在Java中,synchronized关键字对应的锁是可以进行降级的。这里的“锁的降级”通常指的是从重量级锁(系统互斥量)降级到轻量级锁或偏向锁。这是JVM为了优化锁的性能而提供的一种机制。
锁的降级发生在以下情况下:
竞争减少:当原本使用重量级锁的对象,在一段时间内没有发生竞争,即没有其他线程尝试获取相同的锁,JVM可能会在特定的时机(如垃圾收集阶段)将其降级为轻量级锁或偏向锁。
Stop-The-World(STW)阶段:在垃圾收集等STW阶段,如果JVM发现某个锁只有虚拟机线程访问,没有其他Java线程访问,那么这个锁可能会被降级。
锁的优化:JVM会根据锁的使用模式和竞争状况进行优化,如果检测到锁可以安全地降级,它会这样做以提高性能。
需要注意的是,锁的降级并不是由开发人员控制的,而是由JVM自动进行的。开发人员无法直接干预这一过程。
然而,根据Java官方文档和JVM的实现,锁一旦升级为重量级锁,通常情况下不会自动在运行时降级。一旦锁升级,它会保持重量级状态,直到锁被释放。后续的锁获取操作会根据当时的竞争状况和锁的历史状态决定锁的实际状态。
总的来说,虽然锁的降级在理论上可行,但在实际运行时,锁一旦升级为重量级锁,通常不会在用户代码执行期间降级。降级更多地发生在JVM的内部优化过程中。

偏向锁是可以重偏向!!!

  1. 自旋锁:出现多个线程并行执行的时候,让后面的线程进行一个忙循环(自旋);但是要是一直失败的话就也会有性能的损耗,所以可以设置 自旋锁的开关 和 自旋次数
  2. 自适应自旋锁:自旋时间由前一次获取该 锁的对象 根据 锁的自旋时间、锁的拥有者决定;可能一直不会获取锁,就省略了自旋的过程;也可能因为刚刚获取过锁或者此线程正在运行,就会加长自旋时间。
  3. 锁消除:不能存在共享数据竞争的锁进行消除(要是堆上的数据都不会发生数据逃逸而被别的线程访问到,那么就视为线程私有的数据,当做栈数据进行处理,不需要加锁)
  4. 锁粗化:要是 对一个对象反复地进行加锁解锁,或者是在循环中加锁,那么即使没有竞争发生,也会导致性能下滑,针对这样的情况可将锁的范围扩大到一系列操作的外面。
  5. 无锁:对象初始化之后,没有任何线程竞争的时候。锁标志位 01 ,偏向锁标志位 0
  6. 偏向锁:锁对象第一次被线程获取的时候,此时锁就会偏向于第一个获得他的线程,cas 的方式将 获取到的这个锁的线程ID 记录到对象的Mork Word中,cas 成功,下次此线程就可以直接进入,后续此线程再次进入的时候将不会有任何同步操作。一旦有另一个线程尝试进行抢占锁的时候,要是这个线程 cas 得获取锁成功,那么就会将Mork Word 设置为本线程的ID,此时锁仍然为 偏向锁。锁标志位 01 ,偏向锁标志位 1

上述过程cas获取锁失败;表示此锁有竞争,先撤销偏向锁的标识,升级为轻量级锁;先在 当前线程的栈帧 中开辟一块锁记录空间,存储锁对象当前的Mark Word

  1. 轻量级锁(cas 操作消除 同步使用的互斥量)锁标志位 00 ,偏向锁标志位 0

    1. 加锁:cas的方式 把对象的mark word更新为指向 锁记录的指针,要是 更新成功 ,就表示该线程拥有了当前锁;但是cas失败,就表示有其他线程和当前线程竞争该锁的对象。随后,以自旋的方式获取锁,重试抢锁;即:JVM 会检查对象的Mark Word是否指向当前线程的栈帧。是的话 就 说明当前线程已经有此对象的锁了,要是不是的话,就说明这个锁对象已经被其他线程占有了。要是有两条以上的线程争抢一个锁时,就需要膨胀为重量级锁

    2. 解锁:cas 的方式把对象当前Mark Word线程中复制Mark Word 替换回来 ,成功替换,表示同步过程顺利完成,要是失败,表示有其他线程尝试过获取锁,释放的同时唤醒了被挂起的状态

  2. 重量级锁:要是上述过程仍然没有获取到锁,那么表示锁竞争激烈,不可再通过 cas 的方式获取到锁,直接进行锁膨胀,所以线程进入阻塞状态。锁标志位 10 ,偏向锁标志位 0

!!!!

重量级锁,会直接向 操作系统 申请资源,将等待线程挂起,进入锁池队列阻塞等待 ;等待操作系统的调度。但是 偏向锁和轻量级锁,本质上并未交由操作系统调度,依然处于用户态,依然消耗CPU资源,只是采用CAS无锁竞争的方式获取锁

CAS又是Java通过Unsafe类中compareAndSwap方法,jni调用jvm中的C++方法,最终通过下述汇编指令锁住cpu中的北桥信号(非锁住总线,锁住总线就什么都干不了了)实现。即: lock cmpxchg 指令

在这里插入图片描述

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

JOT

JOL工具:

Java对象内存布局查看工具——JOL

<dependency>
 <groupId>org.openjdk.jol</groupId>
 <artifactId>jol-core</artifactId>
 <version>0.9</version>
</dependency>
import org.openjdk.jol.info.ClassLayout;

public class synchronized_1 {
 public static void main(String[] args) {
     Integer o = new Integer(12);
     String printable = ClassLayout.parseInstance(o).toPrintable();
     System.out.println(printable);
 }
}
java.lang.Integer object internals:
OFFSET  SIZE  TYPE   DESCRIPTION                        VALUE
   0     4          (object header)         01 00 00 00 (00000001 00000000 00000000 00000000) (1)
   4     4          (object header)         00 00 00 00 (00000000 00000000 00000000 00000000) (0)
   8     4          (object header)         40 17 01 00 (01000000 00010111 00000001 00000000) (71488)
  12     4    int    Integer.value                            12
Instance size: 16 bytes
Space losses: 0 bytes internal + 0 bytes external = 0 bytes total

解释:

这是Java中 Integer 对象在内存中的表示

每个Java对象在内存中都有一个对象头,用来存储对象的元数据。上面说明,Integer对象的对象头占用了12个字节(3个4字节的字段)。

接下来是对象的实际数据,对于Integer对象,这就是它的value字段,这个字段是一个int类型,占用4个字节。

这个Integer对象的实例大小是16个字节,这包括了对象头的大小和value字段的大小。

"Space losses"表示的是由于内存对齐等原因造成的空间损失。在这个例子中,内部和外部的空间损失都是0字节,也就是说,这个对象没有浪费任何内存。

以下是每个字段的详细解释:

  • OFFSET:字段在对象中的偏移量(以字节为单位)
  • SIZE:字段的大小(以字节为单位)
  • TYPE:字段的类型
  • DESCRIPTION:字段的描述
  • VALUE:字段的值

在这个例子中,Integer对象的value字段的值是12
只有 Double 、 Long 是 24字节,别的6个包装类的实例都是16字节,Object 16字节 ,String 24字节

指针压缩:

JVM最初是32位的,随着64位系统的兴起,JVM也迎来了从32位到64位的转换,32位的JVM对比64位的内存容量比较有限。但是使用64位虚拟机的同时,
带来一个问题:
64位下的JVM中的对象指针占用内存会比32位的多1.5倍,这是我们不希望看到的。于是在JDK1.6时,引入了指针压缩。

-XX:+UseCompressedClassPointers参数启用类指针(类元数据的指针)压缩。

-XX:+UseCompressedOops参数启用普通对象指针压缩。Oops缩写于:ordinary object pointers

参考文章:

synchronized底层原理

Evaluating and improving biased locking in the HotSpot virtual machine

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值