likes
comments
collection
share

面试上岸篇之一生之敌 synchronized

作者站长头像
站长
· 阅读数 6

前言

一篇文章了解 Java 工程师的一生之敌 synchronized,面试基本必问。简单一点会问如何使用,难一点直接解释其原理。下面详细一层层剥开 synchronized 的面纱。

什么是synchronized,有什么作用

synchronized可以理解它是一个互斥锁,用于控制多线程环境下的并发访问,防止多个线程同时访问某个共享资源,从而避免数据的不一致性,被synchronized修饰的代码块在同一时刻,最多只有一个线程能执行该段代码。

基础用法

  • synchronized 修饰代码块时,它会锁定指定的对象,只有获得该对象的锁才能执行该代码块中的内容。例如:

     synchronized (this) {}
     synchronized (Object) {}
     synchronized (类class) {}
    
  • synchronized 修饰方法时,它会锁定与对象关联的监视器(可以理解为锁定对象本身),锁定成功后才可以继续执行。例如:

     public synchronized void method() {...}
    
  • synchronized 修饰静态方法时,它会锁定类的 Class 对象(类锁),只有获得该 Class 对象的锁才能执行该静态方法。例如:

     public static synchronized void method() {...}
    

对象锁(monitor)机制

现在来进一步分析synchronized的具体底层实现,有如下一个简单的示例代码:

 public class SynchronizedDemo {
     public static void main(String[] args) {
         synchronized (SynchronizedDemo.class) {
             System.out.println("hello synchronized!");
         }
     }
 }

上述代码通过synchronized“锁住”当前类对象来进行同步,将java代码进行编译之后通过javap -v SynchronizedDemo.class来查看对应的main方法字节码如下:

 public static void main(java.lang.String[]);
 
     descriptor: ([Ljava/lang/String;)V
 
     flags: ACC_PUBLIC, ACC_STATIC
 
     Code:
 
       stack=2, locals=3, args_size=1
 
          0: ldc           #2                  // class com/codercc/chapter3/SynchronizedDemo
 
          2: dup
 
          3: astore_1
 
          4: **monitorenter**
 
          5: getstatic     #3                  // Field java/lang/System.out:Ljava/io/PrintStream;
 
          8: ldc           #4                  // String hello synchronized!
 
         10: invokevirtual #5                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
 
         13: aload_1
 
         14: monitorexit
 
         15: **goto**          23
 
         18: astore_2
 
         19: aload_1
 
         20: **monitorexit**
 
         21: aload_2
 
         22: **athrow**
 
         23: **return
  • 重要的字节码已经在原字节码文件中进行了标注,再进入到synchronized同步块中,需要通过monitorenter指令获取到对象的monitor(也通常称之为对象锁)后才能往下进行执行
  • 在处理完对应的方法内部逻辑之后通过monitorexit指令来释放所持有的monitor,以供其他并发实体进行获取。
  • 代码后续执行到第15行goto语句进而继续到第23行return指令,方法成功执行退出。
  • 另外当方法异常的情况下,如果monitor不进行释放,对其他阻塞对待的并发实体来说就一直没有机会获取到了,系统会形成死锁状态很显然这样是不合理。
  • 因此针对异常的情况,会执行到第20行指令通过monitorexit释放monitor锁,进一步通过第22行字节码athrow抛出对应的异常。
  • 从字节码指令分析也可以看出在使用synchronized是具备隐式加锁和释放锁的操作便利性的,并且针对异常情况也做了释放锁的处理。
 // initialize the monitor, exception the semaphore, all other fields
 ​
   // are simple integers or pointers
 ​
   ObjectMonitor() {
 ​
 •    _header       = NULL;
 ​
 •    _count        = 0;
 ​
 •    _waiters      = 0,
 ​
 •    _recursions   = 0;
 ​
 •    _object       = NULL;
 ​
 •    _owner        = NULL;
 ​
 •    **_WaitSet**      = NULL;
 ​
 •    _WaitSetLock  = 0 ;
 ​
 •    _Responsible  = NULL ;
 ​
 •    _succ         = NULL ;
 ​
 •    _cxq          = NULL ;
 ​
 •    FreeNext      = NULL ;
 ​
 •    **_EntryList**    = NULL ;
 ​
 •    _SpinFreq     = 0 ;
 ​
 •    _SpinClock    = 0 ;
 ​
 •    OwnerIsThread = 0 ;
 ​
 •    _previous_owner_tid = 0;
 ​
   }
  • 从ObjectMonitor的结构中可以看出主要维护WaitSet以及EntryList两个队列来保存ObjectWaiter 对象
  • 当每个阻塞等待获取锁的线程都会被封装成ObjectWaiter对象来进行入队,与此同时如果获取到锁资源的话就会出队操作。
  • 另外_owner则指向当前持有ObjectMonitor对象的线程。等待获取锁以及获取锁出队的示意图如下图所示:

面试上岸篇之一生之敌 synchronized

  • 当多个线程进行获取锁的时候,首先都会进行_EntryList队列,其中一个线程获取到对象的monitor后,对monitor而言就会将_owner变量设置为当前线程,并且monitor维护的计数器就会加1。
  • 如果当前线程执行完逻辑并退出后,monitor中_owner变量就会清空并且计数器减1,这样就能让其他线程能够竞争到monitor。
  • 另外,如果调用了wait()方法后,当前线程就会进入到_WaitSet中等待被唤醒,如果被唤醒并且执行退出后,也会对状态量进行重置,也便于其他线程能够获取到monitor。

从线程状态变化的角度来看,如果要想进入到同步块或者执行同步方法,都需要先获取到对象的monitor,如果获取不到则会变更为BLOCKED状态,具体过程如下图所示:

面试上岸篇之一生之敌 synchronized

从上图可以看出任意线程对Object的访问,首先要获得Object的monitor,如果获取失败,该线程就会进入到同步队列中,线程状态变为BLOCKED。当monitor持有者释放后,在同步队列中的线程才会有机会重新获取monitor,才能继续执行。

Java对象内存布局

  • 对象头(Object Header):对象头存储的是对象在运行时状态的相关信息、指向该对象所属类的元数据的指针,如果对象是数组对象那么还会额外存储对象的数组长度,内存大小相对固定
  • 实例数据(Instance Data):实例数据存储的是对象的真正有效数据,也就是各个属性字段的值,如果在拥有父类的情况下,还会包含父类的字段。字段的存储顺序会受到数据类型长度、以及虚拟机的分配策略的影响。
  • 对齐填充字节(Padding):在java对象中,需要对齐填充字节的原因是,64位的jvm中对象的大小被要求向8字节对齐,因此当对象的长度不足8字节的整数倍时,需要在对象中进行填充操作,如对象头➕实例数据的大小一共是30 个字节,那么 padding 就还要补 2 个字节,所以这个 Java 对象内存就占用 32 个字节 。

在计算机系统中,数据访问的基本单位通常是字节。但是,大多数机器的数据总线宽度都大于一个字节,一次能够读取或写入多个字节的数据。为了提高存取效率,系统往往在存取数据时都会尽可能地一次性存取尽可能多的数据。因此,系统在存取某类型的数据时,总是把它们存放在内存的某个地址处,而这个地址应该是该类型数据长度的整数倍,这就是对齐

对象头

  • Mark Word:Mark Word在64位JVM中占用8字节,32 位则是 4 个字节,主要用来存储对象的运行时信息,包括哈希码(HashCode)、GC年龄信息、锁的状态
  • 指向类的指针:称为klass pointer,这个指针指向对象所属类的元数据,JVM使用这个指针来确定此对象是哪个类的实例,占用的字节数取决于JVM是否开启了指针压缩。如果开启了指针压缩,那么这个指针占用4字节,否则占用8字节。
  • 数组长度:只有数组对象才有这个字段,用来存储数组的长度,占用4字节。

因此,对于非数组对象,如果开启了指针压缩,那么对象头占用的总字节数为12字节(8字节的Mark Word + 4字节的类指针)。

如果没有开启指针压缩,那么对象头占用的总字节数为16字节(8字节的Mark Word + 8字节的类指针)。

对于数组对象,如果开启了指针压缩,那么对象头占用的总字节数为16字节(8字节的Mark Word + 4字节的类指针 + 4字节的数组长度)。

如果没有开启指针压缩,那么对象头占用的总字节数为20字节(8字节的Mark Word + 8字节的类指针 + 4字节的数组长度)。

面试上岸篇之一生之敌 synchronized

举个例子

 public class MyClass {
     private int a;
     private double b;
 }
  • 当我们创建这个类的一个实例,比如MyClass myObject = new MyClass();,JVM在内存中为myObject分配空间。这个空间包含了klass pointer,Mark Word,以及实例变量a和b。
  • 其中,klass pointer指向了MyClass的类元数据,JVM通过这个指针知道myObject是MyClass的一个实例。Mark Word包含了锁的信息,GC的年龄等信息。实例变量a和b就是我们在类定义中声明的变量。

Mark Word

  • 无锁状态:哈希码(HashCode)占用31位,对象年龄(Age)占用4位,偏向锁标记占用1位,锁标记占用2位,所以最后三位是 001 则是无锁状态
  • 偏向锁状态:线程ID占用54位,偏向时间戳占用2位,对象年龄(Age)占用4位,偏向锁标记占用1位,锁标记占用2位,所以最后三位是 101 则是偏向锁状态
  • 轻量级锁状态:指向锁记录的指针占用62位,锁标记占用2位,所以最后两位是 00 则是轻量级锁状态
  • 重量级锁状态:指向重量级监视器的指针占用62位,锁标记占用2位,所以最后两位是 10 则是轻量级锁状态
  • GC标记状态:空,锁标记占用2位。

Mark Word占用8字节的原因是为了满足64位JVM的内存对齐要求。

注:在无锁状态下,Mark Word的哈希码(HashCode)占用31位。然而,当对象进入偏向锁、轻量级锁或重量级锁状态时,哈希码就不再存储在Mark Word中了。这是因为在这些状态下,Mark Word需要用来存储其他信息,如锁的信息和线程ID。

需要注意的是,对象在创建后,其哈希码并没有立即更新到Mark Word中,只有在调用了hashCode方法后,哈希码才会被写入到Mark Word中。

如果在调用hashCode方法之后对象的状态发生了改变(例如,对象被锁定),那么哈希码将被移出Mark Word,但仍然可以通过hashCode方法获取

面试上岸篇之一生之敌 synchronized

用户态内核态

在操作系统中,CPU有两种运行级别:用户态(User Mode)和内核态(Kernel Mode)。

  • 用户态(User Mode) :当程序运行在用户态时,处理器处于特权级最低的(3级)用户代码中运行。用户态下的程序只能执行一部分机器指令,不能直接访问操作系统内核数据结构和程序,也不能直接执行I/O命令或者影响机器控制的命令。大部分用户直接面对的程序都是运行在用户态。比如记事本或者Word,然后开始在里面输入文字,这个过程就是在用户态下运行的
  • 内核态(Kernel Mode) :当程序运行在内核态时,处理器处于特权级最高的(0级)内核代码中执行。内核态下的程序可以访问所有的硬件设备,也可以执行硬件上能够运行的各种指令。操作系统是运行在内核态的。当你点击“保存”按钮,准备把你刚才输入的文字保存到硬盘上时,这个过程就涉及到了内核态
用户态和内核态的转换主要有以下三种方式:
  • 系统调用:这是用户态进程主动要求切换到内核态的一种方式,用户态进程通过系统调用申请使用操作系统提供的服务程序完成工作。
  • 异常:当CPU在执行运行在用户态下的程序时,发生了某些事先不可知的异常,这时会触发由当前运行进程切换到处理此异常内核相关程序中,也就转到了内核态。
  • 外围设备的中断:当外围设备完成用户请求的操作后,会向CPU发出相应的中断信号,这时CPU会暂停执行下一条即将要执行的指令转而去执行与中断信号对应的处理程序,如果先前执行的指令是用户态下的程序,那么这个转换的过程自然也就发生了由用户态到内核态的切换。
为什么要区分用户态和内核态
  1. 安全性:用户态的程序只能访问有限的处理器指令,并且不能直接访问操作系统内核数据结构和程序。这样可以防止用户程序直接操作硬件设备或者随意修改系统数据,从而保护系统的安全。
  2. 稳定性:当用户态程序出现错误时,不会影响到内核,因此可以保证系统的稳定性。例如,一个用户态程序的崩溃不会导致整个系统的崩溃。
  3. 效率:用户态和内核态的切换存在一定的时间和资源开销。通过合理地划分用户态和内核态,可以尽可能地减少这种切换,从而提高系统的效率。

锁升级

锁升级是指在多线程环境中,为了保证数据的一致性和完整性,对数据进行访问控制的一种机制。在Java中,锁的状态有四种,从低到高依次为:

无锁、偏向锁、轻量级锁、重量级锁。这四种锁状态会随着竞争的情况逐渐升级,而且是不可逆的过程。

锁升级的过程:

  • 无锁状态:对象新创建出来,在没有线程竞争的情况下,对象处于无锁状态
  • 偏向锁:当一个线程访问同步块时,会在对象头栈帧中记录偏向的锁的线程ID,之后这个线程再进入同步块时,只需要比较对象头的记录的线程ID是否为当前线程,如果是,则直接进入同步块。
  • 轻量级锁:当有另一个线程尝试访问同步块时,偏向锁就会升级为轻量级锁。此时,竞争的线程不会阻塞,而是进行自旋(CAS),看持有锁的线程是否会快速释放锁。
  • 重量级锁:当自旋超过一定次数,或者有多于一定数量(一般是 CPU 核数的一半)的线程同时竞争同一个锁,轻量级锁就会膨胀为重量级锁。此时,未能获取锁的线程会进入阻塞状态,等待唤醒。

一般来说,锁的状态只能升级,不能降级,也不能跨级升级(但是听说有些复杂的情况会直接跨级,可以稍微去了解一下)。

但在HotSpot JVM中,重量级锁的降级是可能的,但这种降级发生在STW(Stop-The-World)阶段,降级对象为仅仅能被VMThread访问而没有其他JavaThread访问的对象。然而,这种降级机制的效率较低,如果频繁升降级的话对性能就会造成很大影响。

举例说明:

  • 假设有一个公共厕所,门口有位大爷在看门,初始状态下,公共厕所的门是开放的,任何人都可以进入,这就是无锁状态。
  • 后来,张三想去上厕所,跟大爷说了一声,大爷记住他了,下次张三过来就不用问大爷了,大爷认得他,他直接进去就是了,这就是偏向锁。
  • 随后,又有其他人来上厕所了,因为不知道里面的人什么时候出来,所以就一直在这里等,等里面的人出来了,大爷随机挑一个人进去,这就是轻量级锁。
  • 最后,后面人越来越多了,所有人都在这里等着也太浪费时间了,所以大爷拿出一个抽屉,叫他们把联系方式放进来,然后他们该干嘛干嘛去,里面的人出来了之后,大爷随机拿起一个联系方式一个电话过去叫他过来上厕所,这就是重量级锁。

为什么要有偏向锁 因为在大多数程序中并发量并不高,大多数都只是同一个线程在执行,但是共享数据又不得不锁,如果每次都是同一个线程获取同一个数据,又要锁又要释放锁,这是一个很大的开销,所以就一开始设置偏向锁,避免了在无竞争情况下的同步开销

为什么要有轻量级锁 当有竞争但是不是很激烈的情况下,当前线程可以选择等一下(自旋),因为线程切换也是一个很大的开销,涉及到阻塞、唤醒、就绪、调度等操作,有时等个 1毫秒就能到他了,结果一个线程切换就花了 100 毫秒,得不偿失。这也涉及到一个用户态转内核态偏向锁、轻量级锁都是属于用户态的锁,如果直接使用了重量级锁,就要从用户态转内核态了,这也是相当耗时的工作

为什么要有重量级锁 当等待的线程太多了,自旋花费时间太长了(轻量级锁使用的 CAS 可是会一直占用着 CPU 的),相当于一直在厕所门口等,其他事情也不能做,所以先留个手机号码(线程 id)在排队,自己先去玩,等锁释放了,大爷打电话过来到我了(CPU 重新调度分配了时间片),我就过来了。

Java 实现案例

     public static void main(String[] args) throws InterruptedException {
         Test t = new Test();
         System.out.println(ClassLayout.parseInstance(t).toPrintable());
         System.out.println("=======================================================");
         System.out.println(Integer.toHexString(t.hashCode()));
         System.out.println("=======================================================");
         System.out.println(ClassLayout.parseInstance(t).toPrintable());
 ​
     }
 ​

面试上岸篇之一生之敌 synchronized

  • OFFSET:偏移地址,单位字节,如第一行从 0 开始;
  • SIZE:占用的内存大小,单位为字节,如第一第二行,每行占4 个字节,共 8 个字节组成 mark work;
  • TYPE DESCRIPTION:类型描述,其中object header为对象头;
  • VALUE:对应内存中当前存储的值,其中位 16 进制、二进制、十进制;

先解释一下那个 value 值怎么看,根据上面 Mark word 的图,以及结合上面的分析

有个疑问:根据代码就能知道,对象是没有加锁的,锁状态应该是无锁(对象刚创建确实应该是无锁状态),并且一开始是没有调用 hascode 的,所以前 25 位 + 31 位应该都没有数据才对的,为什么前八位就已经有数据了呢 00000101?

大端序与小端序

这是因为计算机中的字节顺序问题,也被称为端序(Endianness)。有两种类型的端序:大端序(Big Endian)和小端序(Little Endian),这里是使用小端序的方式展示

  • 大端序:最高有效字节在前,最低有效字节在后。这是人类通常阅读数字和文字的方式,例如 00 00 00 05。
  • 小端序:最低有效字节在前,最高有效字节在后。例如 05 00 00 00。

所以上述的结果换成正常的读法就是 00000005,二进制的也同理,

分析
  • 所以 Mark word 的第一个字节则是第二行的最后一个字节,所以最后三个字节都是 0,前 25 位处于无使用状态
  • 一开始没有调用 hascode的方法,所以前 56 位都是没有数据的,所以在小端序中只有第一个字节是有数据的,这就对上了
  • 分析第一个字节可以拆成 0 0000 1 01,其中第一位无使用,然后 4 位是分代年龄,所以这对象的分代年龄位 0,下一位是偏向锁状态,为 1,加上最后两位锁状态是 01,所以最后三位是 101 表示该对象处于偏向锁。偏向锁????
  • 为什么前面都对了,按理说是处于无锁状态的呀,怎么会是偏向锁呢?
  • 再看一下第二次打印的第一个字节,00000001,怎么有变成无锁了?不是说一般情况下不会锁不会降级的吗?
  • 其实这里的偏向锁并不是真正的偏向锁,回想一下偏向锁,我一定需要把线程 id 存在 Mark word中我才知道我要偏向谁呀,按前面的数据前 56 位都是空的,不可能存了 Id 的,所以这个 101 只是表示可偏向状态,并不是真的偏向锁,因为下面又回到了无锁状态就很好的说明了
  • 再来看一下第二次打印,后三个字节还是无数据,从倒数第四个字节开始有数据了,因为在打印前调用了 hascode,所以会把 hascode 存在了 Mark word 中,按照大端序来排序的话则是 52 af 26 ee,这个 16 进制就表示 hascode,第一个字节就是表示无锁状态
转载自:https://juejin.cn/post/7338335869708861492
评论
请登录