当前位置 : 主页 > 编程语言 > 其它开发 >

【Java并发编程】Synchronized关键字实现原理

来源:互联网 收集:自由互联 发布时间:2022-06-07
目录 1、Synchronized之保障线程安全 1.1、原子性 1.2、可见性 1.2.1、为什么会存在可见性问题? 1.2.1.1、高速缓存 1.2.2、缓存一致性问题 1.2.2.1、总线锁 1.2.2.2、缓存锁 1.2.2.3、缓存一致性

目录
  • 1、Synchronized之保障线程安全
    • 1.1、原子性
    • 1.2、可见性
      • 1.2.1、为什么会存在可见性问题?
        • 1.2.1.1、高速缓存
      • 1.2.2、缓存一致性问题
          • 1.2.2.1、总线锁
          • 1.2.2.2、缓存锁
          • 1.2.2.3、缓存一致性
      • 1.2.3、Happens-Before
    • 1.3、有序性
      • 1.3.1、as-if-serial语义
  • 2、Synchronized原理
    • 2.1、Synchronized修饰方法
    • 2.2、Synchronized修饰代码块
    • 2.3、ObjectMonitor源码
  • 3、Synchronized锁对象
    • 3.1、Java对象内存布局
    • 3.2、Java锁结构信息
  • 4、Synchronized锁升级
    • 4.1、无锁
    • 4.2、无锁升级偏向锁
    • 4.3、偏向锁流程
    • 4.4、偏向锁升级轻量级锁
    • 4.5、轻量级锁流程
    • 4.6、轻量级锁升级重量级锁
    • 4.7、重量级锁流程
  • 5、Synchronized锁消除
  • 6、Synchronized锁粗化
  • 7、锁的优缺点对比

它的主要特性是同步锁、非公平锁、阻塞锁、可以保证线程安全(可见性、原子性、有序性)

JDK1.6之后对Synchronized有优化,有个锁升级过程

1、Synchronized之保障线程安全

多线程情况下保障线程安全的方法有很多,一般都是通过加锁去竞争同一个资源,来达到互斥的效果,那么Synchronized是如何保障线程安全的呢

1.1、原子性

它的主要含义是要么全部成功要么全部失败,不允许部分成功部分失败,多线程中原子性是指一个或者多个操作在CPU中执行的过程中出现了被中断的情况

原子性产生的原因主要是有两个:

  • CPU时间切换

    CPU处于空闲状态就会把时间片分配给其他线程进行处理,有两个线程对变量进行修改,会有一个A、线程先得到CPU的执行权,它将变量加载到寄存器后CPU切换为另一个B线程执行,B线程同样加载变量到寄存器,最后把结果写回内存,这时候两个变量值可能会一致

  • 程序本身执行不具备原子性

    这个可以用常见的i++来说明,i++本身不具备原子性,因为它分为了三个操作,先获取值,加一,赋值。这里每一步都是原子性,可是组合在一起就不具备原子性

解决原子性的办法有两个

  • 通过一个互斥条件来达到同时一刻只有一个线程执行

  • 使操作具有原子性,不允许执行过程被中断

为了保证原子性可以在方法上加上Synchronized关键字

1.2、可见性 1.2.1、为什么会存在可见性问题? 1.2.1.1、高速缓存

它的本质是因为,CPU是计算机的核心,它在做运算的时候无法避免从内存中读取数据和指令,即使存储在磁盘的数据也要加载到内存中CPU才能访问,CPU与内存之间无法避免IO操作。CPU向内存发起读取操作,需要等待内存返回结果,此时CPU处于等待状态,如果等待返回之后CPU再执行其他指令会浪费CPU资源。因此在硬件、操作系统、编译器都做了不少优化,正因为这些优化导致出现了可见性问题

例如加入了CPU高速缓存,高速的缓存的作用就是CPU在读取数据的时候会先从高速缓存中读取,如果高速缓存中没有就会从内存中读取

高速缓存又分为三部分:L1、L2、L3

image-20220529174835137

每块CPU里有多个内核,而每个内核都维护了自己的缓存

L1和2属于CPU核内私有缓存,L3属于共享缓存,三块缓存从存储的数据大小排序来说L3>L2>L1,从访问速度来说L1>L2>L3

访问数据从L1中开始查找,然后是L2,最后访问L3如果还没有命中就会从内存中加载数据,加载数据会从L3到L2最后到L1

1.2.2、缓存一致性问题

虽然有高速缓存提高了访问速度,但是一个CPU有多核,每一个线程可能运行在不同的CPU核内,如果多个线程同时访问数据,那么同一份数据就有可能被缓存到多个CPU核内,就会存在缓存一致性问题:两个线程同时加载一块数据到CPU高速缓存中时,如何保证一个数据被修改后在其他缓存中的值也能保持一致,而不是获取到的初始值。CPU解决这个问题使用到了

1.2.2.1、总线锁

操作系统使用总线锁可以解决这个缓存一致性问题,它的原理就是在CPU与内存传输的通道上加了一个LOCK#信号,这个信号确保同一时刻只有当前CPU才能访问共享内存,使得其他处理器对内存的操作请求都会被阻塞,但是这样又会使CPU的使用效率下降。

1.2.2.2、缓存锁

为了CPU使用效率下降解决这个问题,引入了缓存锁,当数据已经存在高速缓存中的某个CPU核内私有区域,不使用总线锁而使用缓存一致性解决问题

1.2.2.3、缓存一致性

缓存锁就是通过缓存一致性协议来保证一致性的,不同的CPU支持的缓存一致性协议不同,比较常见就是MSIMESIMOSIMESIF,最常用的就是MESI(Modify Exclusive Shared Invalid),它表示四种状态:

  • M(Modify) 表示共享数据只缓存在当前 CPU 缓存中, 并且是被修改状态,也就是缓存的数据和主内存中的数据不一致
  • E(Exclusive) 表示缓存的独占状态,数据只缓存在当前 CPU 缓存中,并且没有被修改
  • S(Shared) 表示数据可能被多个 CPU 缓存,并且各个缓存中的数据和主内存数据一致
  • I(Invalid) 表示缓存已经失效

这四种状态会基于缓存行的状态而变化, 不同的状态会有不同的监听任务

  • 一个处于M状态的缓存行,必须时刻监听所有试图读取该缓存行对应的主存地址的操作,如果监听到,则必须在此操作执行前把其缓存行中的数据写回内存
  • 一个处于S状态的缓存行,必须时刻监听使该缓存行无效或者独享该缓存行的请求,如果监听到,则必须把其缓存行状态设置为I
  • 一个处于E状态的缓存行,必须时刻监听其他试图读取该缓存行对应的主存地址的操作,如果监听到,则必须把其缓存行状态设置为S

监听过程使基于嗅探协议完成的,该协议要求每个CPU都可以监听到总线上的数据事件变化并作出反应,这个缓存一致性原理就是

  1. 首先CPU0发送一个指令从主内存中读取x变量,然后加载到了高速缓存中,这时候缓存的状态为E

    image-20220529174800719

  2. 如果CPU1这时候也要读取x变量的值,就会检测到本地含有该缓存发生冲突,CPU0会通过嗅探协议里面的Read Response消息响应给CPU1,这时候x变量存在于CPU0和CPU1中,缓存的状态变成了S

    image-20220529174738475

  3. CPU0拿到x变量的值以后进行修改为x=20,写入主内存中,这时候缓存的状态变为了E,缓存行变为共享状态,同时还需要发送一个Invalidate消息给其他缓存,其他缓存CPU1收到后缓存状态变为Invaild,CPU1里面的x变量值缓存失效,需要从主内存中重新获取值

    image-20220529175627202

这个就是基于缓存一致性保证缓存的一致性原理

synchronized就是基于该原理,对进入同一个锁(监视器)的线程保证可见性,在修改了本地内存中的变量后,解锁前会将本地内存修改的内容刷新到主内存中,确保了共享变量的值是最新的,也就保证了可见性

1.2.3、Happens-Before

在JMM内存模型中还定义了一个Happens-Before模型用来保证可见性,这个模型主要描述的就是两个指令操作之间的关系,如果 A happens-before B,意思就是A发生在B之前,那么A的结果对B可见,它主要有如下常见6种规则。

  • 程序顺序规则:一个线程中,按照程序顺序,前面的操作 Happens-Before 于后续的任意操作
  • 传递性规则:如果A happens-before B,且B happens-before C,那么A happens-before C
  • Volatile变量规则:对一个volatile域的写,happens-before于任意后续对这个volatile域的读
  • 监视器锁规则:对一个锁的解锁,happens-before于随后对这个锁的加锁
  • start规则:这条是关于线程启动的。它是指主线程 A 启动子线程 B 后,子线程 B 能够看到主线程在启动子线程 B 前的操作
  • join规则:如果线程A执行操作ThreadB.join()并成功返回,那么线程B中的任意操作happens-before于线程A从ThreadB.join()操作成功返回

我们只需要理解Happens-Before规则,既可以编写线程安全的程序了

1.3、有序性

CPU为了提升性能会对编译器、处理器以及代码指令重排序,这种排序在单线程下没问题结果不会受到影响,但如果是多线程操作下就不一定了,可能出现脏数据

就拿最容易复原的指令重排序如何影响程序执行的结果

int x=0;
int y=0;

void handleA(){
  int x=10;
  int y=20;
}

void handleB(){
  if(y==20){
     assert(x==10);
  }
}

指令重排序就是程序的执行顺序和代码的编写顺序不一定一致,两个线程同时执行可能会出现在handleB方法里面y== 20的情况,但是x==10断言失败,出现这种情况的原因是因为在执行handleA方法的时候,因为x、y没有依赖关系,有可能先赋值y=20,这时候刚好handleB()方法判断成功,而x这时候还没有赋值,导致断言失败,这就是多线程环境下的重排序问题,也会导致可见性问题

1.3.1、as-if-serial语义

它表示所有的程序指令都可以因为优化而被重排序,但是要保证在单线程环境下,重排序之后的运行结果和程序代码本身的执行结果一致,CPU指令重排序、Java编译器都需要保证在单线程环境下as-if-serial语义是正确的。存在依赖关系的不会被排序

int x=10;  //1
int y=20;  //2
int c=x+y; //3

按照正常执行顺序就是1、2、3,经过重排序之后可能是2、1、3,但绝对不会是3、2、1,因为as-if-serial语义可以保证排序后和之前结果一致

synchronized能够保证有序性的是因为单线程独占CPU,根据as-if-serial语义,无论编译器和处理器怎么优化或指令重排,单线程下的运行结果一定是正确的。

2、Synchronized原理

Synchronized有两种加锁方式:修饰方法、代码块,这两种方式实现的底层有些不同,但同样的是monitor对象头是实现Synchronized的关键

2.1、Synchronized修饰方法
public class Teacher {

    public static int i = 0;

    public static synchronized void syncLock() {
        i++;
    }
}

通过Java- v Teacher.class反编译之后

image-20220603223722970

发现这个方法有三个标识,其中比较醒目的就是ACC_SYNCHRONIZED,它是用来标记当前方法为同步方法

2.2、Synchronized修饰代码块
public class Teacher {
    public static int i = 0;
  
    public static void main(String[] args) {
        synchronized (Teacher.class){
        }
    }
}

通过Java- v Teacher.class反编译之后

image-20220603224237786

发现会在同步块的前后分别形成monitorentermonitorexit这两个指令包裹起来,后面还多一个monitorexit,这个作用是防止代码块里面有异常无法释放锁,所有会用第二个monitorexit指令来保证释放,这两个指令都是属于Monitor对象,底层是C ++的ObjectMonitor实现,也是依赖于操作系统的mutex lock来实现的。

倘若线程获取不到锁,通过一定次数的自旋最后阻塞升级为重量级锁,未抢占到锁的线程进入等待队列,那么这里又是如何实现的呢,参考ObjectMonitor的实现

2.3、ObjectMonitor源码
ObjectMonitor::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 ;  
}  

里面主要参数是:

  • WaitSet:阻塞后等待唤醒的队列,为双向循环链表
  • EntryList:准备获取锁的线程队列
  • owner:标识拥有锁的线程
  • count:线程重入次数

1、首先线程会进入EntryList,然后尝试获取Monitor对象,获取成功后把owner标记为当前线程,然后count次数+1,执行完毕后会释放Monitor对象,并把owner设置为null,然后count减1,只有当count等于0才能够获取到锁

2、假如线程进入EntryList后获取Monitor失败,就会进入WaitSet的尾部节点中,等待Monitor对象释放后,会根据操作唤醒一个或全部线程进入EntryList中,处于EntryList中的线程都会抢占锁

Monitor对象存在于每个Java对象的对象头Mark Word中(存储的指针的指向),它也被称之为“监视器锁”,这就是为什么任意对象都可以作为锁的原因

3、Synchronized锁对象 3.1、Java对象内存布局

一个对象初始化之后会被存储在堆内存中,一个对象又分为三部分:对象头实例数据对其填充

image-20220530214749295

对象头

其中对象头又分为三部分

  • Mark Word:记录了对象和锁相关的信息,主要包含了GC分代年龄、锁的状态标记、HashCode、epoch等等信息
  • Klass Pointer:代表指向类的指针,通过指针来找到具体的实例
  • Length:表示数组的长度,只有数组对象才会有这个属性值

实例数据

实例数据表示一个类里面所有的成员的变量

public class Student{
  int age=0;
  boolean state=false;
}

例如这些成员变量就存储在实例数据里面,实例数据占用的空间是由成员变量的类型决定的比如int占4个字节

对齐填充

对齐填充没有什么实际含义,主要是使得当前实例变量占用空间是8的倍数,这样做的目的是为了减少CPU访问内存的频率,为什么会频繁的访问内存?这个问题涉及到了高速缓存中的缓存行,CPU每次从内存加载8个字节的的数据到缓存行中,也意味着高速缓存存储的是连续的数据,每个缓存行大小是64位,意思如果是一个8个字节的变量,需要存储8个才能把该缓存行占满

image-20220530221355529

但是存在这样一种情况,两个线程同时去读取该缓存上不同的值Long2、Long8,就会使同时缓存该缓存行,为了保证缓存一致性就会使一部分缓存失效,导致一个线程需要重新去获取,重新加载到缓存行,如果线程访问频繁就会使缓存反复失效,形成伪共享问题,为了减少CPU访问内存的频率,那么必须要变量不在于同一缓存行中,使用对其填充使两个变量分开,在一个变量前后填充7个填充变量,就可以使两个值分布于不同缓存行,比如在Long2前后填充七个

image-20220530225444281

之所以要做前后填充是为了使无论Long2处于什么位置都可以保证它处于不同的缓存行,避免出现伪共享问题

还有一种作用,假如需要读取Long类型的数据的时候,它分布在两个缓存行中,如果没有对其填充需要读取缓存行A和缓存行B才可以获得真正的数据

image-20220530223953114

使用对其填充之后,在缓存行B中可以直接读取到全部数据,减少了CPU访问次数

image-20220530224508765

在对齐填充的布局中,虽然做了无效填充,但是访问内存次数少了,本质上来说是一种时间换空间的设计方式。

一个类对象在JVM中对象存储的布局

public  class Student(){
    private  String name;
    
    public static void main(String[] args) {
        Student stu=new Student();
        System.out.println(ClassLayout.parseInstance(stu).toPrintable());
    }
}

image-20220601232609202

使用ClassLayout查看对象布局

  • OFFSET:偏移地址
  • SIZE:占用内存大小
  • TYPE DESCRIPTION:类型描述
  • value:内存中存储的值

对象字段代表为:

image-20220602231921534

  • 对象头:TYPE DESCRIPTION(object header)类型的,总共3个占用12字节的内存,前两行SIZE加起来为8字节的代表Mark Word,第三行4字节的代表类型指针Klass Pointer,不压缩会占用8个字节
  • 对齐填充:TYPE DESCRIPTION((loss due to the next object alignment))类型的,本身4个字节,填充了12个字节总共16字节,主要为了保证是8的倍数
  • 实例数据:Instance size,总共16个字节

image-20220601231734526

3.2、Java锁结构信息

image-20220603122059576

Java锁包含在对象头里面,Mark Word中记录了对象和锁的信息,锁的标记和相关信息都存储在里面

锁状态 偏向锁标记 锁标记 无锁 0 01 偏向锁 1 01 轻量级锁 00 重量级锁 10 GC标记 11

Mark Word使用2bit来存储锁的标记,也就是两位数最多只能存储4个数:00、01、10、11。而锁的状态有五种,超出一种就多使用了1个bit的偏向锁来表达。

4、Synchronized锁升级

在JDK1.6之前Synchronized只有重量级锁,没有获得锁的线程会阻塞,直到被唤醒才能再次获得锁,JDK1.6之后对锁做了很多优化引入了偏向锁、轻量级锁、重量级锁

4.1、无锁
public class Student {
    public static void main(String[] args) {
        Student stu=new Student();
        System.out.println("10机制hashCode:"+stu.hashCode());
        System.out.println("16机制hashCode:"+Integer.toHexString(stu.hashCode()));
        System.out.println("2机制hashCode:"+Integer.toBinaryString(stu.hashCode()));
        System.out.println(ClassLayout.parseInstance(stu).toPrintable());
        }
}

image-20220604113447827

两行Value里面存储了8个字节的Mark Word,相当于如下两行数据

01 f2 b2 8d (00000001 11110010 10110010 10001101) (-1917652479)
56 00 00 00 (01010110 00000000 00000000 00000000) (86)

这里实际上包含了同样结果的两种数据格式二进制和16进制,去掉括号外面的数据,两行整合成一行

二进制

(00000001 11110010 10110010 10001101)  (01010110 00000000 00000000 00000000)

16进制

01 f2 b2 8d  56 00 00 00

因为是小端存储,所以需要倒过来观看,数据顺序应该反过来

二进制

(00000000 00000000 00000000 01010110) (10001101 10110010 11110010 00000001) 

16进制

00 00 00 56 8d b2 f2 01

56 8d b2 f2就是代表16机制hashCode

这样才是一个方便阅读的Mark Word结构,根据64位虚拟机的Mark Word结构示意图

image-20220606221428144

最后三位为【001】,0代表偏向锁标记为,01表示锁标记

发现hashCode部分刚好等于打印出来的二进制HashCode:1010110 10001101 10110010 11110010,HashCode之所以不为空是因为调用了HashCode方法才显示出来

4.2、无锁升级偏向锁
    public static void main(String[] args) {
        Student stu=new Student();
        System.out.println("=====加锁之前======");
        System.out.println(ClassLayout.parseInstance(stu).toPrintable());
        synchronized (stu){
            System.out.println("=====加锁之后======");
            System.out.println(ClassLayout.parseInstance(stu).toPrintable());
        }
}

按照上诉步骤找到锁标记

image-20220603131907108

最后三位为【000】,其中最后两位是【00】,按照之前的存储状态定义这是轻量级锁,本身没有存在锁竞争很明显不对,原因是因为JVM开启了偏向锁延迟加载,我们启动程序的时候偏向锁还没开启,在程序启动时添加参数:

-XX:+UseBiasedLocking -XX:BiasedLockingStartupDelay=0 //关闭偏向锁的延迟

image-20220603132438444

再次查看程序运行结果

image-20220603132636508

加锁之后锁标记是【101】表示偏向锁是符合预期的,但发现没加锁之前锁也就是偏向锁,本应该是无锁

image-20220603141631976

发现没加锁之前没有线程ID,加锁之后才有线程ID, thread指针 和 epoch 都是0,说明当前并没有线程获得锁,表示可偏向的状态,所以无锁也是一个特殊的偏向锁,当有线程获取到时才会真正变为偏向锁

image-20220606221443232

偏向锁的主要作用就是当同步代码块被一个线程多次访问,只有第一次访问的时候需要记录线程的ID,后续就会一直持有着锁而不需要再次加锁释放锁,因为只有一个线程那么该线程在后续多次访问就会自动获得锁,为了提高一个线程执行的性能,而不需要每次都去修改对象头的线程ID还有锁标志才能够获得锁

4.3、偏向锁流程

image-20220605103823530

偏向锁获取流程:

  1. 首先查看Mark Word中的锁标记以及线程ID是否为空,如果锁标记是101代表是可偏向状态
  2. 如果是可偏向状态,再查看线程ID是当前的线程,直接执行同步代码块
  3. 如果是可偏向状态但是线程ID为空或者线程ID已被其他线程持有,那么就需要通过CAS操作去修改Mark Word中线程ID为当前线程还有锁标记,然后执行同步代码块
  4. CAS修改失败的话,就会开始撤销偏向锁,撤销偏向锁需要达到全局安全点,然后检查线程的状态
  5. 如果线程还存活检查线程是否在执行同步代码块中的代码,如果是升级为轻量级锁进行CAS竞争
  6. 如果没有线程存活,直接把偏向锁撤销到无锁状态,然后另一个线程会升级到轻量级锁

偏向锁撤销:

一种出现竞争出现才释放锁的机制,另外有线程来竞争锁,不能再使用偏向锁了,需要升级为轻量级锁,原来的偏向锁需要撤销,就会出现两种情况:

  1. 线程还没有执行完,其他线程就来竞争,导致需要撤销偏向锁,此时当前线程升级为持有轻量级锁,继续执行代码
  2. 线程执行完毕退出了同步代码块,将对象头设置为无锁并且撤销偏向锁重新偏向

偏向锁批量重偏向:

当一个线程创建了大量对象并执行了初始的同步操作,后来另一个线程也来将这些对象作为锁对象进行操作,会导偏向锁重偏向的操作,过程比较耗时,所以当撤销次数达到20次以上的时候,20这个值可以修改,会触发重偏向,直接把偏向锁偏向线程2

偏向锁就是一段时间内,只由一个线程来获得和释放锁,加锁的方式就是通过把线程ID保存到锁对象的Mark Word中

4.4、偏向锁升级轻量级锁
public class Student {
    public static void main(String[] args) {
        Student stu=new Student();
        System.out.println("=====加锁之前======");
        System.out.println(ClassLayout.parseInstance(stu).toPrintable());
        synchronized (stu){
            System.out.println("=====加锁之后======");
            System.out.println(ClassLayout.parseInstance(stu).toPrintable());
        }

        Thread thread=new Thread(){
            @Override
            public void run() {
                synchronized (stu){
                    System.out.println("====轻量级锁====");
                    System.out.println(ClassLayout.parseInstance(stu).toPrintable());

                }
            }
        };
        thread.start();
    }
}

image-20220603142836434

很明显由特殊状态的无锁->偏向锁->轻量级锁

偏向锁在不影响性能的情况下获得了锁,这时候如果还有一个线程来获取锁,如果没有抢占到就会自旋一定的次数,这个次数可以通过JVM参数控制,抢占到了锁就不需要阻塞,轻量级锁也称为自旋锁

image-20220606221500096

这个自旋也是有代价的,如果线程数过多,一直都在使用自旋抢占线程会浪费CPU性能,所以自旋的次数必须要有个限制,JDK1.6中默认是10次,JDK1.6之后使用的自适应自旋锁,意味着自旋的次数并不是固定的,而是根据同一个锁上次自旋的时间,如果很少自旋成功,那么下次会减少自旋的次数甚至不自旋,如果自旋成功,会认为下次也可以自旋成功,会增加自旋的次数

4.5、轻量级锁流程

image-20220605120022714

轻量级锁获取流程:

  1. 一个线程进入同步代码块,JVM会给每一个线程分配一个Lock Record,官方称之为“Dispalced Mark Word”,用于存储锁对象的Mark Word,可以理解为缓存一样存储了锁对象
  2. 复制锁对象的Mark Word到Lock Record中去
  3. 使用CAS将锁对象的Mark Word替换为指向Lock Record的指针,如果成功表示轻量级锁占锁成功,执行同步代码块
  4. 如果CAS失败,说明当前lock锁对象已经被占领,当前线程就会使用自旋来获取锁

轻量级锁释放:

  1. 会把Dispalced Mark Word存储锁对象的Mark Word替换到锁对象的Mark Work中,会使用CAS完成这一步操作
  2. 如果CAS成功,轻量级锁释放完成
  3. 如果CAS失败,说明释放锁的时候发生了竞争触发锁膨胀,膨胀完之后调用重量级的释放锁方法

轻量级锁加锁的原理就是,JVM会为每一个线程分配一个栈帧用于存储锁的空间,里面有个Lock Record数据结构,也就是BaseObjectLock对象,会把锁对象里面的Mark Word复制到自己的BaseObjectLock对象里面,然后使用CAS把对象的Mark Word更新为指向Lock Record的指针,如果成功就获取锁,如果失败表示已经有其他线程获取到了锁,然后继续使用自旋来获取锁

轻量级锁每次都需要释放锁,而偏向锁只有存在竞争的时候才释放锁为了避免反复切换

4.6、轻量级锁升级重量级锁
package com.ylc;

import org.openjdk.jol.info.ClassLayout;

public class Student {
    public static void main(String[] args) {
        Student stu=new Student();
        System.out.println("=====加锁之前======");
        System.out.println(ClassLayout.parseInstance(stu).toPrintable());
        synchronized (stu){
            System.out.println("=====加锁之后======");
            System.out.println(ClassLayout.parseInstance(stu).toPrintable());
        }

        Thread thread=new Thread(){
            @Override
            public void run() {
                synchronized (stu){
                    System.out.println("====轻量级锁====");
                    System.out.println(ClassLayout.parseInstance(stu).toPrintable());

                }
            }
        };
        thread.start();

        for (int i=0;i<3;i++){
            new Thread(()->{
                synchronized (stu){
                    System.out.println("====重量级锁====");
                    System.out.println(ClassLayout.parseInstance(stu).toPrintable());
                }
            }).start();
        }
    }
}

image-20220603144712306

锁的标志为【010】代表重量级锁

image-20220606221519936

倘若通过自旋重试一定次数还获取不到锁,那么就只能阻塞等待线程唤醒了,最后升级为重量级锁

4.7、重量级锁流程

image-20220605191403392

重量级锁获取流程

  1. 首先会进行锁膨胀
  2. 然后会创建一个ObjectMonitor对象,通过该把该对象的指针保存到锁对象里面
  3. 如果获取锁失败或者对象本身就处于锁定状态,会进入阻塞状态,等待CPU唤醒线程重新竞争锁
  4. 如果对象无锁就会获取锁

重量级锁释放流程

  1. 会把ObjectMonitor中的的持有锁对象owner置为null
  2. 然后从阻塞队列里面唤醒一个线程
  3. 唤醒的线程重新竞争锁,如果没有抢占到继续等待

由此可以发现Synchronized底层的锁机制是通过JVM层面根据线程竞争情况来实现的

5、Synchronized锁消除

Java虚拟机在JIT编译时会去除没有竞争的锁,消除没有必要的锁,可以节省锁的请求时间

public class Student {
    public static void main(String[] args) {
       for (int i=0;i<=10;i++){
           new Thread(()->{
               Student.lock();
           }).start();
        }
    }
  
      public  static void lock(){
        Object o=new Object();
            synchronized (o){
                System.out.println("hashCode:"+o.hashCode());
            }
        }
    
}

每次都加了锁,可是都不是同一把锁,无法产生竞争这样的锁没有意义,相当于会无视synchronized (o)的存在

6、Synchronized锁粗化

锁粗化就是将多次连接在一起的加锁、解锁操作合并为一次操作。将多个联系的锁扩展为一个范围更大的锁

public class Student {
    public static void main(String[] args) {
        Object o=new Object();
        new Thread(()->{
            synchronized (o){
                System.out.println("加一次锁");
            }
            synchronized (o){
                System.out.println("加两次锁");
            }
            synchronized (o){
                System.out.println("加三次锁");
            }
            synchronized (o){
                System.out.println("加四次锁");
            }
        }).start();
    }
}

把小锁范围扩大,优化后变成

public class Student {
    public static void main(String[] args) {
              Object o=new Object();
        new Thread(()->{
            synchronized (o){
                System.out.println("加一次锁");
            }
        }).start();
    }
}
7、锁的优缺点对比 锁 优点 缺点 适用场景 偏向锁 加锁和解锁不需要额外的消耗,和执行非同步方法仅有纳米级的差距 如果线程间存在锁的竞争,会带来额外的锁撤销的消耗 适用于只有一个线程访问的同步块场景 轻量级锁 竞争的线程不会阻塞,提高了程序的相应速度 如果始终得不到锁竞争的线程,使用自旋会消耗CPU 追求响应时间 同步响应非常快 重量级锁 线程竞争不使用自旋,不会消耗CPU 线程阻塞,响应时间缓慢 追求吞吐量 同步块执行速度较长
网友评论