【JUC】Synchronized全解读

为什么需要使用锁?因为CPU在共享时,在临界区会由于操作指令交错而出现实际结果与目标结果不符的问题。

一个程序运行多个线程本身是没有问题的。问题出现在多个线程访问共享资源时,读写操作发生指令交错。

一段代码块中,如果存在对共享资源的多线程读写操作,这称这段代码为临界区,也叫同步代码块。

例如:

static int counter = 0;
static void increment() 
    // 临界区
{ 
    counter++; 
}

static void decrement() 
    // 临界区
{ 
    counter--; 
}

多个线程在临界区内执行,由于代码的执行序列不同而导致结果无法预测,称之为发生了竞态条件

为了避免临界区的竞态条件发生,有多种手段可以达到目的。

  • 阻塞式:synchronized(对象锁),lock
  • 非阻塞式:原子变量

1、Synchronized的使用方式

1.1 概述

synchronized,俗称【对象锁】,它采用互斥的方式让同一时刻最多只有一个线程能持有【对象锁】,其他线程再想获得这个【对象锁】时就会发生阻塞。这样就能保证拥有锁的线程可以安全的执行临界区内的代码,不用担心线程上下文的切换。

注意

虽然Java中互斥和同步都可以采用synchronized关键字来完成,但是它们还是有区别的:

  • 互斥时保证临界区的竞态条件发生时,同一时刻只能由一个线程执行临界区代码
  • 同步时由于线程执行的先后、顺序不同、需要一个线程等待其他线程运行到某个点

1.2 synchronized的使用

1.2.1 synchronized主要有三种用法

(1)修饰实例方法

作用于当前对象实例加锁,进入临界区前要获得当前对象实例的锁,即调用当前方法的实例对象为锁对象

class Test{
    public synchronized void test() {

    }
}
等价于
class Test{
    public void test() {
        synchronized(this) {

        }
    }
}

(2)修饰静态方法

即给当前的类加锁,会作用于该类的所有对象实例,进入临界区前要获得当前class的锁。

class Test{
    public synchronized static void test() {
    }
}
等价于
class Test{
    public static void test() {
        synchronized(Test.class) {

        }
    }
}

(3)修饰代码块

synchronized(对象) // 线程1, 线程2(blocked)
{
    临界区
}

1.2.2 解决临界区的竞态条件问题:

static int counter = 0;
static final Object room = new Object();
public static void main(String[] args) throws InterruptedException {
    Thread t1 = new Thread(() -> {
        for (int i = 0; i < 5000; i++) {
            synchronized (room) {
                counter++;
            }
        }
    }, "t1");
    Thread t2 = new Thread(() -> {
        for (int i = 0; i < 5000; i++) {
            synchronized (room) {
                counter--;
            }
        }
    }, "t2");
    t1.start();
    t2.start();
    t1.join();// 等待t1线程运行结束,之后再运行后面的语句
    t2.join();
    log.debug("{}",counter);
}

1.2.3 分析

image-20221009202249675

  • synchronized(对象)中的对象,可以想象为一个单坑位的厕所(room),有唯一一个入口(门),房间一次进入一个人,线程t1、t2是两个人
  • 当线程 t1 执行到synchronized(room)时,t1人进入了该厕所,并锁住了门拿走了钥匙,在门内执行counter++代码
  • 这时如果t2也运行到了synnchronized(room)时,发现门锁住了,只能再门外等待,发生了上下文切换,阻塞住了
  • 这中间及时t1的cpu时间片不幸用完了,被踢出了门外(不能错误地理解为锁住了对象就能一直执行下去),这时门还是锁住的,t1仍拿着钥匙,t2线程还处于阻塞的状态,只有下次轮到t1自己再次获得cpu时间片时才能开门进入
  • 当t1执行完synchronized()块内的代码,这时才会从obj房间出来并解开门上的锁,唤醒t2线程把钥匙给他。t2这时才可以进入obj房间,锁住了门拿上钥匙,执行它的count--代码

synchronized 实际上时用对象锁保证了临界区内代码的原子性,临界区内的代码对外时不可分割的,不会被线程切换所打断。

1.3 到底什么是synchronized

"锁”本身是个对象,synchronized这个关键字不是“锁”。硬要说的话,加synchronized仅仅是相当于“加锁”这个操作。

image-20221009204555490

image-20221009204634668

加锁,是对同步代码块设定一个进入的条件,即线程持有锁对象才能运行同步代码块,而方法的执行离不开对象,即只有线程中执行的是锁对象的方法,才能顺利执行

1.4 synchronized的三种用法的底层逻辑

代码:

package com.boot.jdk;

public class SyncUsingWay {
    // 普通方法- 锁对象:我们的对象(new 出来的,谁调用这个方法,锁作用于谁身上)
    public synchronized void SyncMethod() {
        System.out.println("SyncMethod");
    }
    // 静态方法- 锁对象:我们的对象所属的class,全局只有一个。(类型,放到方法区的
    // 包括我们的真正的.class文件的二进制文件都最终加载到了运行时数据区的方法区)
    public synchronized static void StaticSyncMethod(){
        System.out.println("StaticSyncMethod");
    }

    public void method(){
        // 静态代码块
        synchronized (this) {
            System.out.println("method");
        }
    }
}

通过Javap -v SyncUsingWay.class 反编译class文件

Classfile /E:/IdeaWorkspace/demo/target/classes/com/boot/jdk/SyncUsingWay.class
  Last modified 2022-5-8; size 917 bytes
  MD5 checksum 9a53c6cd6851b0895ead00ce639fde81
  Compiled from "SyncUsingWay.java"
public class com.boot.jdk.SyncUsingWay
  minor version: 0
  major version: 52
  flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
   #1 = Methodref          #8.#30         // java/lang/Object."<init>":()V
   #2 = Fieldref           #31.#32        // java/lang/System.out:Ljava/io/PrintStream;
   #3 = String             #16            // SyncMethod
   #4 = Methodref          #33.#34        // java/io/PrintStream.println:(Ljava/lang/String;)V
   #5 = String             #17            // StaticSyncMethod
   #6 = String             #18            // method
   #7 = Class              #35            // com/boot/jdk/SyncUsingWay
   #8 = Class              #36            // java/lang/Object
   #9 = Utf8               <init>
  #10 = Utf8               ()V
  #11 = Utf8               Code
  #12 = Utf8               LineNumberTable
  #13 = Utf8               LocalVariableTable
  #14 = Utf8               this
  #15 = Utf8               Lcom/boot/jdk/SyncUsingWay;
  #16 = Utf8               SyncMethod
  #17 = Utf8               StaticSyncMethod
  #18 = Utf8               method
  #19 = Utf8               StackMapTable
  #20 = Class              #35            // com/boot/jdk/SyncUsingWay
  #21 = Class              #36            // java/lang/Object
  #22 = Class              #37            // java/lang/Throwable
  #23 = Utf8               main
  #24 = Utf8               ([Ljava/lang/String;)V
  #25 = Utf8               args
  #26 = Utf8               [Ljava/lang/String;
  #27 = Utf8               MethodParameters
  #28 = Utf8               SourceFile
  #29 = Utf8               SyncUsingWay.java
  #30 = NameAndType        #9:#10         // "<init>":()V
  #31 = Class              #38            // java/lang/System
  #32 = NameAndType        #39:#40        // out:Ljava/io/PrintStream;
  #33 = Class              #41            // java/io/PrintStream
  #34 = NameAndType        #42:#43        // println:(Ljava/lang/String;)V
  #35 = Utf8               com/boot/jdk/SyncUsingWay
  #36 = Utf8               java/lang/Object
  #37 = Utf8               java/lang/Throwable
  #38 = Utf8               java/lang/System
  #39 = Utf8               out
  #40 = Utf8               Ljava/io/PrintStream;
  #41 = Utf8               java/io/PrintStream
  #42 = Utf8               println
  #43 = Utf8               (Ljava/lang/String;)V
{
  public com.boot.jdk.SyncUsingWay(); // 构造器
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
      stack=1, locals=1, args_size=1
         0: aload_0
         1: invokespecial #1                  // Method java/lang/Object."<init>":()V
         4: return
      LineNumberTable:
        line 3: 0
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0       5     0  this   Lcom/boot/jdk/SyncUsingWay;

  // synchronized修饰普通方法
  public synchronized void SyncMethod();
    descriptor: ()V
    flags: ACC_PUBLIC, 【ACC_SYNCHRONIZED】
    Code:
      stack=2, locals=1, args_size=1
         0: getstatic     #2                  // Field java/lang/System.out:Ljava/io/PrintStream;
         3: ldc           #3                  // String SyncMethod
         5: invokevirtual #4                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
         8: return
      LineNumberTable:
        line 5: 0
        line 6: 8
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0       9     0  this   Lcom/boot/jdk/SyncUsingWay;

  // synchronized修饰静态方法
  public static synchronized void StaticSyncMethod();
    descriptor: ()V
    flags: ACC_PUBLIC, ACC_STATIC, ACC_SYNCHRONIZED
    Code:
      stack=2, locals=0, args_size=0
         0: getstatic     #2                  // Field java/lang/System.out:Ljava/io/PrintStream;
         3: ldc           #5                  // String StaticSyncMethod
         5: invokevirtual #4                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
         8: return
      LineNumberTable:
        line 9: 0
        line 10: 8

  // synchronized修饰代码块
  public void method(); 
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
      stack=2, locals=3, args_size=1
         0: aload_0
         1: dup
         2: astore_1
         3: 【monitorenter】 // 进入同步代码块(进入临界范围内,锁的原子内部)
         4: getstatic     #2                  // Field java/lang/System.out:Ljava/io/PrintStream;
         7: ldc           #6                  // String method
         9: invokevirtual #4                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
        12: aload_1
        13: 【monitorexit】 // 正常退出同步代码块
        14: goto          22
        17: astore_2
        18: aload_1
        19: 【monitorexit】 //防止任何异常情况下,退出同步代码块。JVM 仍然可以释放锁
        20: aload_2
        21: athrow
        22: return
      【Exception table】: //配合了异常退出 monitorexit 
         from    to  target type
             4    14    17   any
            17    20    17   any
      LineNumberTable:
        line 13: 0
        line 14: 4
        line 15: 12
        line 16: 22
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0      23     0  this   Lcom/boot/jdk/SyncUsingWay;
      StackMapTable: number_of_entries = 2
        frame_type = 255 /* full_frame */
          offset_delta = 17
          locals = [ class com/boot/jdk/SyncUsingWay, class java/lang/Object ]
          stack = [ class java/lang/Throwable ]
        frame_type = 250 /* chop */
          offset_delta = 4

  
SourceFile: "SyncUsingWay.java"

2、Synchronized的特性

(1)原子性原子性指的是一个或多个操作,要么全部执行并且执行的过程不会受到任何因素打断,要么都不执行。被synchronized修饰的类或对象的所有操作都是原子性的,因为在执行操作之前必须先获得类或对象的锁,直到执行完才能释放。

(2)可见性:可见性是指多个线程访问一个资源时,该资源的状态、值信息等对于其他线程都是可见的。

synchronized和volatile都具有可见性,其中synchronized对一个类或对象加锁时,一个线程如果要访问该类或对象必须先获得它的锁,而这个锁的状态对于其他任何线程都是可见的,并且在释放锁之前会将对变量的修改刷新到共享内存当中,保证资源变量的可见性

(3)有序性有序性指的是程序执行的顺序按照代码先后执行

(读读、读写、写读、写写 互斥)

synchronized和volatile都具有有序性,Java允许编译器和处理器对指令进行重排,但是指令重排并不会影响单线程的顺序,它影响的是多线程并发执行的顺序性。synchronized保证了每个时刻都只有一个线程访问同步代码块,也就确定了线程执行同步代码块是分先后顺序的,保证了有序性。

(4) 可重入性 :可重入锁

(可重入性的代码示例 ThreadReIn.java

package com.boot.jdk;

public class ThreadReIn implements Runnable {
    static ThreadReIn instance = new ThreadReIn();
    static int i = 0;
    static int j = 0;

    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(instance);
        Thread t2 = new Thread(instance);
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println("i:"+i);
        System.out.println("j:"+j);
    }
    @Override
    public void run() {// Runnable的实例对象调用了run()方法,此处是instance对象
        for (int j = 0; j < 1000000; j++) {
            // this,当前实例对象锁
            synchronized (this) {
                i++;
                increase();// synchronized的可重入性
            }
        }
    }
    public synchronized void increase() {
        j++;
    }
}

3、Monitor(监视器)

3.1 Java对象头

在JVM中,对象在内存中的布局分为三块区域:对象头、实例数据和对齐填充

image-20221009212908162

synchronized用的锁是存在Java对象头里的。

对象头由两部分组成:

  • Mark Word:用于存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等
  • class Pointer:类型指针,即对象指向它的类型元数据的指针,Java虚拟机通过这个指针来确定该对象是哪个类的实例

其中,Mark Word 用于存储对象自身的运行时数据,它是实现轻量级锁和偏向锁的关键

(1)32位虚拟机 Mark Word

image-20221120165454239

(2)64 位虚拟机 Mark Word 是 64bit,在运行期间,Mark Word里存储的数据会随着锁标志位的变化而变化

image-20221009213350307

3.2 对象头中Mark Word与线程中的Lock Record

在线程进入同步代码块时,如果此时的锁对象还没是无锁状态,即它的锁标志位是01,则虚拟机首先在当前线程的栈帧中创建我们称之为“锁记录(Lock Record)”的空间,用于存储锁对象的 Mark Word 的拷贝,官方将这个拷贝称为“Displaced Mark Word”。Mark Word 及其拷贝至关重要。

Lock Record是线程私有的数据结构。每一个线程都有一个可用Lock Record列表,同时还有一个全局的可用列表。每一个锁对象的 Mark Word 都会和一个 Lock Record相关联,即锁对象的对象头中的 Mark Word 中的Lock Word 指向 Lock Record的起始地址。

同时,Lock Record中有一个Owner字段存放拥有该锁的线程的唯一标识,表示该锁被这个线程占用。

LockRecord用于轻量级锁优化,当解释器执行monitorenter字节码轻度锁住一个对象时,就会在获取锁的线程的栈上显式或者隐式分配一个LockRecord.这个LockRecord存储锁对象markword的拷贝(Displaced Mark Word),在拷贝完成后,首先会挂起持有偏向锁的线程,因为要进行尝试修改锁记录指针,MarkWord会有变化,所有线程会利用CAS尝试将MarkWord的锁记录指针改为指向自己(线程)的锁记录,然后lockrecord的owner指向对象的markword,修改成功的线程将获得轻量级锁。失败则升级为重量级锁。

释放时会检查markword中的lockrecord指针是否指向自己(获得锁的线程lockrecord),使用原子的CAS将Displaced Mark Word替换回对象头,如果成功,则表示没有竞争发生,如果替换失败则升级为重量级锁。整个过程中,LockRecord是一个线程内独享的存储,每一个线程都有一个可用Monitor Record列表。

3.2 监视器(Monitor)

3.2.1 概述

任何一个对象都有一个 Monitor 与之关联,当一个 Monitor (锁)被持有后,它将处于锁定状态。synchronized 在 jvm 里的实现都是基于进入和退出 Monitor 对象来实现方法同步和代码块同步,虽然具体实现细节不一样,但是都可以通过成对的 MonitorEnter 和 MonitorExit 指令来实现。

(1)MonitorEnter指令:插入在同步代码块的开始位置,当代码执行到该指令时,将会尝试获取该对象Monitor的所有权,即尝试获得该对象的锁;

(2)MonitorExit指令:插入在方法结束处和异常处,JVM保证每个MonitorEnter必须有对应的MonitorExit;

什么是Monitor呢?可以把它理解为一个同步工具,也可以描述为一种同步机制,它通常被描述为一个对象

与一切皆对象一样,所有的Java对象是天生的Monitor,每一个Java对象都有成为Monitor的潜质,因为在Java的设计中,每一个Java对象字打娘胎里出来就带了一把看不见的锁,叫做内部锁或者Monitor锁

也就是通常说Synchronized的对象锁,Markword锁标识位为10,其中指针指向的是Monitor对象的起始地址

3.2.2 ObjectMonitor的属性

在Java虚拟机中,Monitor是由ObjectMonitor实现的。

ObjectMonitor() {
    _header       = NULL;
    _count        = 0; // 记录个数
    _waiters      = 0,
    _recursions   = 0;
    _object       = NULL;
    _owner        = NULL;
    _WaitSet      = NULL; // 处于wait状态的线程,会被加入到_WaitSet
    _WaitSetLock  = 0 ;
    _Responsible  = NULL ;
    _succ         = NULL ;
    _cxq          = NULL ;
    FreeNext      = NULL ;
    _EntryList    = NULL ; // 处于等待锁block状态的线程,会被加入到该列表
    _SpinFreq     = 0 ;
    _SpinClock    = 0 ;
    OwnerIsThread = 0 ;
  }

image-20221009223610592

(1) header : 重量级锁保存markword的地方

(2)owner: 指向我们持有锁的线程;对象的markword里边也保存了指向monitor的指针;

(3)_cxq 队列: 竞争队列。 A线程持有锁没有释放; B和C线程同时过来争抢锁,都被block了,此时会将B和C线程加入到 该队列。

(4)EntryList队列:同步队列。A线程释放锁,把 cxq里的线程移动到EntrylIst中竞争

(5)waitset:等待队列。Object wait的线程。

同步队列存放着竞争同步资源的线程的引用(不是存放线程),而等待队列存放着待唤醒的线程的引用。

当多个线程同时访问一段同步代码时:

(1)首先会进入_EntryList队列中,当线程获取到锁对象的 monitor后,进入 _ Owner区域将monitor中的owner变量设置为当前线程,同时monitor中的计数器count+1;

(2)若线程调用wait()方法,将释放当前持有的monitor,owner变量恢复为null,count自减1,同时该线程进入 WaitSet队列中等待被唤醒;

(3)若当前线程执行完毕,也将释放monitor(锁)并服务count的值,以便其他线程进入获取monitor(锁)。

若锁已经被持有:

A线程持有锁,BC线程过来竞争失败,进入cxq -- 下轮竞争会把 cxq里的线程移动到EntrylIst中。假设B线程竞争到了锁,然后B线程调用了 Object.Wait方法,这时候B线程进入waitset,并释放锁。C线程拿到了锁,然后唤醒B线程。B线程会从waitset里边出来,直接竞争锁。如果竞争失败进入cxq,继续轮回,如果竞争成功,ok了

总结

Monitor对象存在于每个Java对象的对象头 Mark Word 中(存储的指针指向Monitor),Synchronized锁便是通过这种方式获取锁的,这也是为什么任意对象都可以作为锁的原因,同时notify()notifyAll()wait()等方法会使用到 Monitor 锁对象,所以必须在同步代码块中使用。

4、偏向锁

锁主要存在四种状态,依次是:无锁状态、偏向锁状态、轻量级锁状态、重量级锁状态,锁可以从偏向锁升级到轻量级锁,再升级到重量级锁。但是锁的升级的单向的,也就是说之能从低到高升级,不会出现锁的降级。

image-20221009220350797

4.1概述

偏向锁是JDK6中的重要引进,因为经过HotSpot作者经过研究实践发现,在大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,为了让线程获得锁的代价更低,引入了偏向锁。

偏向锁是在单线程执行代码块时使用的机制,如果在多线程并发的环境下(即线程A尚未执行完同步代码块,线程B发起了申请锁的申请),则一定会转化为轻量级锁或者重量级锁。

4.2 引入偏向锁的目的与意义

引入偏向锁主要目的是:为了在没有多线程竞争的情况下尽量减少不必要的轻量级锁执行路径。因为轻量级锁的加锁解锁操作是需要依赖多次CAS原子指令的,而偏向锁只需要在置换ThreadID的时候依赖一次CAS原子指令(由于一旦出现多线程竞争的情况就必须撤销偏向锁,所以偏向锁的撤销操作的性能损耗也必须小于节省下来的CAS原子指令的性能消耗)。

4.3 偏向锁如何减少不必要的CAS操作的

锁是可重入的,即已经获得锁的线程可以多次锁住/解锁监视对象,按照之前的HotSpot设计,每次加锁/解锁都会涉及到一些CAS操作(比如对等待队列的CAS操作),CAS操作会延迟本地调用,因此偏向锁的想法是 一旦线程第一次获得了监视对象,之后让监视对象“偏向”这个线程,之后的多次调用则可以避免CAS操作,说白了就是置个变量,如果发现为true则无需再走各种加锁/解锁流程。

4.4 偏向锁的使用前提

(1)至少JDK1.6 版本且开启了偏向锁配置。

偏向锁在Java 6和Java 7里是默认启用的,但是它在应用程序启动几秒钟之后才激活,如有必要可以使用JVM参数来关闭延迟:-XX:BiasedLockingStartupDelay=0。如果你确定应用程序里所有的锁通常情况下处于竞争状态,可以通过JVM参数关闭偏向锁:-XX:-UseBiasedLocking=false,那么程序默认会进入轻量级锁状态。

(2)被加锁的对象,没有真正、或者隐式的调用父类 Object 里边的hashcode方法

无锁状态下的hashcode存储在对象头中,如果一旦调用了 Object 的 hashcode 方法,那么对象头里面就有真正的 hashcode 值了,如果偏向锁来进行 markWord 的替换,至少要提供一个保存 hashcode 的地方。可惜的是,偏向锁并没有地方进行 markWord 的保存,只有轻量级锁才会有 "displaced mark word"

代码示例:

package com.boot.jdk;

import org.openjdk.jol.info.ClassLayout;

import java.io.IOException;
import java.io.PipedReader;
import java.io.PipedWriter;
import java.util.HashMap;
import java.util.concurrent.TimeUnit;

public class SyncLockFlag {
    // 当我们开启了偏向锁,并且没有延迟开启的时候,新创建的对象的mark word 默认就是偏向锁状态的markword。
    // 只不过这个时候,因为没有现成争抢,除了我们的锁标志位和是否为偏向锁标志位,其他的位数都是0
    static MyObject myobject = new MyObject();
    public static void main(String[] args) throws InterruptedException {
        System.out.println("=====================未偏向线程的偏向锁============================");
        System.out.println(ClassLayout.parseInstance(myobject).toPrintable());
        myobject.hashCode();// 显式得调用hashCode方法
        
        HashMap map = new HashMap();
        map.put(myobject,""); //隐式的调用了hashcode方法
        synchronized (myobject) {
            System.out.println("=====================偏向锁============================");
            System.out.println(ClassLayout.parseInstance(myobject).toPrintable());
        }
    }
    
    static class MyObject{
    }
}

image-20221009220442558

image-20221009220447410

4.5 偏向锁的初始化和撤销【重点】

4.5.1 偏向锁的初始化

当一个线程访问临界区并获取锁时,会在对象头(存储线程id)和栈帧中的锁记录里(线程有自己的栈帧,LOCK RECORD:存储当前拥有锁的线程id)存储锁偏向的线程ID,以后该线程在进入和退出临界区时,不需要进行CAS操作来加锁或解锁,只需要简单的测试一下对象头的Mark Word里是否存储着当前线程的ID(即锁对象对象头中存储的线程id是否与当前线程的id一致)

具体步骤如下:

(1)检测锁对象对象头 Mark Word 是否为可偏向状态,即是否为偏向锁1,锁标识符为01

(2)若为可偏向状态,则测试对象头中存储的锁偏向的线程ID是否为当前线程ID,如果是,则执行同步代码块;

(3)如果测试对象头中存储的锁偏向的线程ID不为当前线程ID,通过CAS操作竞争锁,竞争成功,则将 Mark Word 中存储的线程ID替换为当前线程ID;

(4)如果CAS竞争锁失败,证明当前存在多线程竞争的情况,当到达全局安全点,获得偏向锁的线程被挂起,偏向锁升级为轻量锁,然后被阻塞在安全点的线程继续往下执行同步代码块

4.5.2 偏向锁的撤销

偏向锁使用了一种等到竞争出现才释放锁的机制,所以当其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁

步骤如下:

(1)暂停拥有偏向锁的线程

(2)判断锁对象是否还处于被锁定的状态(即持有锁的线程是否还活跃),否,则恢复到无锁的状态01,允许其余线程竞争。是,则挂起持有锁的当前线程,并将指向当前持有锁的线程的Lock Record锁记录地址的指针放入锁对象对象头 Mark Word,升级为轻量级锁(00),然后恢复持有锁的当前线程,进入轻量级锁的竞争模式。

下图中的线 程1演示了偏向锁初始化的流程,线程2演示了偏向锁撤销的流程:

image-20221009222313145

5、轻量级锁

引入轻量级锁的主要目的是在没有多线程竞争的前提下,减少传统的重量级锁使用操作系统互斥产生的性能消耗。当关闭偏向锁功能或者多个线程竞争偏向锁导致偏向锁升级为轻量级锁,则会尝试获取轻量级锁。

轻量级锁是为了在线程交替执行同步代码块时提高性能,而偏向锁则是在只有一个线程执行同步代码块时进一步提高性能。

5.1 轻量级锁加锁

当关闭偏向锁功能或者多个线程竞争偏向锁导致偏向锁升级为轻量级锁,则会尝试获取轻量级锁,其步骤如下:

(1)在线程进入同步块时,如果锁对象处于无锁状态(锁标志位为01,是否为偏向锁为0),虚拟机首先将在当前的线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的Mark Word 的拷贝,官方称之为Displaced Mark Word。此时线程堆栈与对象头的状态如下图所示:

image-20221010230418267

(2)拷贝对象头中的Mark Word 复制到Lock Record中

(3)拷贝成功后,虚拟机将使用CAS操作尝试将对象Mark Word 中的Lock Word更新为指向当前线程 Lock Record的指针,并将 Lock Record里的 owner 指针指向 object mark word;

(4)如果更新成功,那么当前线程就拥有了该锁对象,并且对象 Mark Word 的锁标志设置为轻量锁00,即表示此对象处于轻量级锁定状态,此时线程栈与对象头的状态如下图所示:

image-20221010230838730

(5)如果更新失败,虚拟机首先会检查锁对象 Mark Word 中的 Lock Word 是否指向当前线程的栈帧,如果是,就说明当前线程已经拥有了这个对象的锁,那就可以直接进入同步块继续执行。否则说明多个线程竞争锁,进入自旋执行(3),若自旋计数时仍未获得锁,轻量级锁就要膨胀为重量级锁,锁标志的状态值为10,Mark Word中存储的就是指向重量级锁的指针,当前线程及后面等待锁的线程也要进入阻塞状态。

自旋:自旋锁不会放弃 CPU 时间片,而是通过自旋等待锁的释放,也就是说,它会不停地再次地尝试获取锁,如果失败就再次尝试,直到成功为止

为什么升级为轻量锁时要把对象头里的Mark Word复制到线程栈的锁记录中呢?

因为在申请对象锁时 需要以该值作为CAS的比较条件,同时在升级到重量级锁的时候,能通过这个比较判定是否在持有锁的过程中此锁被其他线程申请过,如果被其他线程申请了,则在释放锁的时候要唤醒被挂起的线程。

5.2 轻量级锁解锁

(1)通过CAS操作尝试把线程中复制的Displaced Mark Word(Lock Record记录的Mark Word拷贝)对象替换当前的Mark Word;

(2)如果替换成功,整个同步过程就完成了,恢复到无锁状态01

(3)如果替换失败,说明有其他线程尝试过获取该锁(此时锁已经膨胀为重量级锁),那就要在释放锁的同时,唤醒被挂起的线程

对于轻量级锁,其性能提升的依据是 “对于绝大部分的锁,在整个生命周期内都是不会存在竞争的”,如果打破这个依据则除了互斥的开销外,还有额外的CAS操作,因此在有多线程竞争的情况下,轻量级锁比重量级锁更慢。

image-20221009223204457

因为自旋会消耗CPU,为了避免无用的自旋(比如获得锁的线程被阻塞住了),一旦锁升级成重量级锁,就不会再恢复到轻量级锁状态。当锁处于这个状态下,其他线程试图获取锁时, 都会被阻塞住,当持有锁的线程释放锁之后会唤醒这些线程,被唤醒的线程就会进行新一轮的夺锁之争。

示例代码:

package com.boot.jdk;

import lombok.SneakyThrows;
import org.openjdk.jol.info.ClassLayout;

public class LightLock {
    public static void main(String[] args) throws InterruptedException {
        Object obj = new Object();
        System.out.println("====A 加锁前==="+ClassLayout.parseInstance(obj).toPrintable());
        Thread A = new Thread() {
            @SneakyThrows
            @Override
            public void run() {
                synchronized (obj) {
                    System.out.println("===A 加锁中==="+ClassLayout.parseInstance(obj).toPrintable());
                    Thread.sleep(2000);
                }

            }
        };
        A.start();
        Thread.sleep(500);
        System.out.println("====B加锁前==="+ClassLayout.parseInstance(obj).toPrintable());
        Thread B = new Thread() {
            @SneakyThrows
            @Override
            public void run() {
                synchronized (obj) {
                    System.out.println("====B加锁中==="+ClassLayout.parseInstance(obj).toPrintable());
                    Thread.sleep(1000);
                }
            }
        };
        B.start();
        Thread.sleep(5000);
        synchronized (obj) {
            System.out.println("====再次加锁中==="+ClassLayout.parseInstance(obj).toPrintable());
        }

        Object objnew = new Object();
        synchronized (objnew) {
            System.out.println("====新对象加锁中==="+ClassLayout.parseInstance(objnew).toPrintable());
        }
    }

}

5.3 如何理解轻量级锁

如何理解“轻量级”?“轻量级”是相对于使用操作系统互斥量来实现的传统锁而言的。但是,首先需要强调一点的是,轻量级锁并不是用来代替重量级锁的,它的本意是在没有多线程竞争的前提下,减少传统的重量级锁使用产生的性能消耗。

轻量级锁所适应的场景是线程交替执行同步块的情况,如果存在同一时间访问同一锁的情况,必然就会导致轻量级锁膨胀为重量级锁。

6、重量级锁

Synchronized是通过对象内部的一个叫做 监视器锁(Monitor)来实现的。但是监视器锁本质又是依赖于底层的操作系统的Mutex Lock来实现的。而操作系统实现线程之间的切换这就需要从用户态转换到核心态,这个成本非常高,状态之间的转换需要相对比较长的时间,这就是为什么Synchronized效率低的原因。因此,这种依赖于操作系统Mutex Lock所实现的锁我们称之为 “重量级锁”

轻量级锁 ---> 重量级锁: 释放锁(前四步)并唤醒等待线程

(1) 线程1 初始化monitor 对象;

(2)将状态设置为膨胀中(inflating);

(3) 将monitor里边的header属性,set成为对象的markword;(将自己lock record里边的存放的mark word的hashcode,分代年龄,是否为偏向锁 set 到 objectmonitor对象的header属性里)

(4) 设置对象头为重量级锁状态(标记位改为 10);然后将前30位指向第1步初始化的monitor 对象;(真正的锁升级是由线程1操控的)

(5) 唤醒线程2;

线程2 开始争抢重量级锁。(线程2就干了一件事儿,就是弄了一个临时的重量级锁指针吧?还不是最后的重量级锁指针。因为最后的重量级锁指针是线程1初始化的并且是线程1修改的。 而且,线程2被唤醒之后,还不一定能够抢到这个重量级锁。Sync是非公平锁。 线程2费力不讨好,但是线程2做了一件伟大的事情:他是锁升级的奠基者。)

7、锁的优缺点

各种锁并不是相互代替的,而是在不同场景下的不同选择,绝对不是说重量级锁就是不合适的。每种锁是只能升级,不能降级,即由偏向锁->轻量级锁->重量级锁,而这个过程就是开销逐渐加大的过程。

如果是单线程使用,那偏向锁毫无疑问代价最小,并且它就能解决问题,连CAS都不用做,仅仅在内存中比较下对象头就可以了;

如果出现了其他线程竞争,则偏向锁就会升级为轻量级锁;

如果其他线程通过一定次数的CAS尝试没有成功,则进入重量级锁;

锁的优缺点的对比如下表:

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

8、Markword对象头变化过程【重点】

创建一个对象,此时对象里面没有hashCode,所以该对象可以使用偏向锁,偏向锁不会考虑hashcode,会直接将持有自己的线程 id 放到锁对象的 markWord 中,不需要考虑后续的替换问题。所以一旦我们的对象主动调用了 Object 的 hashCode 方法,偏向锁就自动不可用了。

如果对象有了hashCode和分代年龄以及是否为偏向锁(30位)。在轻量级锁的状态下,这30位会被复制到持有该轻量级锁线程的栈帧里的 Lock Record 中记录起来。与此同时,轻量级锁对象的 MarkWord 中存放的是指向持有轻量级锁的线程的栈帧的 Lock Record 的指针。如果一直存在轻量级锁的竞争,在未发生锁膨胀的前提下,一直会保持轻量级锁,A线程释放的时候,会将A线程(锁的持有者)栈帧中锁记录空间中从锁对象中复制的MarkWord,替换回锁对象MarkWord中,B线程下次再重新走一遍 displaced mark word

一旦发生了轻量级锁膨胀为重量级锁。前提,A线程持有锁;B线程争抢。

B线程将锁对象对象头中 Mark Word 里指向A线程栈帧Lock Record的指针替换成一个临时的(过渡的)重量级指针,为了让A线程在CAS往回替换 Mark Word 的时候失败。

A线程替换回 Mark Word 失败后,会发起(1)初始化monitor对象;(2)将状态设置为膨胀中;(3)将替换失败的 Mark Word 放到 ObjectMonitor 的header属性里;(4)改变Mark Word 的锁标志为10;将 Mark Word 中的前30位设置为指向自己第一步初始化的 monitor对象;(5)唤醒B线程;(6)以后这个对象只能作为重量级锁

MarkWord从未丢失。

9、死锁

死锁产生的四个必要条件:

  • 互斥:一个资源每次只能被一个进程使用(资源独立)
  • 请求与保持:一个进程因请求资源而阻塞时,对已经获得的资源保持不放(不释放锁)
  • 不剥夺:进程已经获得资源,在未使用之前,不能强行剥夺(抢夺资源)
  • 循环等待:若干进程之间形成了一种头尾相接的循环等待的资源关闭(死循环)

如何避免死锁?

(1)避免一个线程同时获取多个锁

(2)避免一个线程在锁内同时占用多个资源,尽量保证每个锁只占用一个资源

(3)尝试使用定时锁,使用lock.tryLock(timeout)来替代使用内部锁机制

(4)对于数据库锁,加锁和解锁必须在同一数据库连接里,否则会出现解锁失败的情况

10、用户态与内核态【了解】

10.1什么是内核态和用户态

CPU的两种工作状态:内核态(管态)和用户态(目态)

内核态

(1) 系统中既有操作系统的程序,也有普通用户程序。为了安全性和稳定性,操作系统的程序不能随便访问,这就是内核态。即需要执行操作系统的程序就必须转换到内核态才能执行!

(2) 内核态可以使用计算机所有的硬件资源!

用户态:不能直接使用系统资源,也不能改变 CPU 的工作状态,并且只能访问这个用户程序自己的存储空间!

当一个进程在执行用户自己的代码时处于用户运行态(用户态),此时特权级最低,为 3 级,是普通的用户进程运行的特权级,大部分用户直接面对的程序都是运行在用户态。Ring3(3级的用户状态) 状态不能访问 Ring0 的地址空间,包括代码和数据;当一个进程因为系统调用陷入内核代码中执行时处于内核运行态(内核态),此时特权级最高,为 0 级。执行的内核代码会使用当前进程的内核栈,每个进程都有自己的内核栈。

用户运行一个程序,该程序创建的进程开始时运行自己的代码,处于用户态。如果要执行文件操作、网络数据发送等操作必须通过 write、send 等系统调用,这些系统调用会调用内核的代码。进程会切换到 Ring0,然后进入内核地址空间去执行内核代码来完成相应的操作。内核态的进程执行完后又会切换到 Ring3,回到用户态。这样,用户态的程序就不能随意操作内核地址空间,具有一定的安全保护作用。这说的保护模式是指通过内存页表操作等机制,保证进程间的地址空间不会互相冲突,一个进程的操作不会修改另一个进程地址空间中的数据。

10.2 用户态与内核态切换的触发条件

当在系统中执行一个程序时,大部分时间是运行在用户态下的,在其需要操作系统帮助完成一些用户态自己没有特权和能力完成的操作时就会切换到内核态。

用户态切换到内核态的 3 种方式

(1)系统调用

这是用户态进程主动要求切换到内核态的一种方式。用户态进程通过系统调用申请使用操作系统提供的服务程序完成工作。例如 fork()就是执行了一个创建新进程的系统调用。系统调用的机制是使用了操作系统为用户特别开放的一个中断来实现,如 Linux 的 int 80h 中断。

(2)异常

当 cpu 在执行运行在用户态下的程序时,发生了一些没有预知的异常,这时会触发由当前运行进程切换到处理此异常的内核相关进程中,也就是切换到了内核态,如缺页异常。

(3)外围设备的中断

当外围设备完成用户请求的操作后,会向 CPU 发出相应的中断信号,这时 CPU 会暂停执行下一条即将要执行的指令而转到与中断信号对应的处理程序去执行,如果前面执行的指令时用户态下的程序,那么转换的过程自然就会是 由用户态到内核态的切换。如硬盘读写操作完成,系统会切换到硬盘读写的中断处理程序中执行后边的操作等。

这三种方式是系统在运行时由用户态切换到内核态的最主要方式,其中系统调用可以认为是用户进程主动发起的,异常和外围设备中断则是被动的。从触发方式上看,切换方式都不一样,但从最终实际完成由用户态到内核态的切换操作来看,步骤有事一样的,都相当于执行了一个中断响应的过程。系统调用实际上最终是中断机制实现的,而异常和中断的处理机制基本一致。

10.3 用户态到内核态的切换步骤

(1)从当前进程的描述符中提取其内核栈的 ss0 及 esp0 信息。

(2)使用 ss0 和 esp0 指向的内核栈将当前进程的 cs,eip,eflags,ss,esp 信息保存起来,这个过程也完成了由用户栈到内核栈的切换过程,同时保存了被暂停执行的程序的下一条指令。

(3)将先前由中断向量检索得到的中断处理程序的 cs,eip 信息装入相应的寄存器,开始执行中断处理程序,这时就转到了内核态的程序执行了。

11、重量级锁、轻量级锁和偏向锁之间的转换

img

img

posted @ 2022-12-15 16:20  DarkSki  阅读(91)  评论(0编辑  收藏  举报