多线程进阶学习04------Synchronized详解

基本使用之八锁问题

①. 标准访问有ab两个线程,请问先打印邮件还是短信
②. sendEmail方法暂停3秒钟,请问先打印邮件还是短信
③. 新增一个普通的hello方法,请问先打印邮件还是hello
④. 有两部手机,请问先打印邮件还是短信
⑤. 两个静态同步方法,同1部手机,请问先打印邮件还是短信
⑥. 两个静态同步方法, 2部手机,请问先打印邮件还是短信
⑦. 1个静态同步方法,1个普通同步方法,同1部手机,请问先打印邮件还是短信
⑧. 1个静态同步方法,1个普通同步方法,2部手机,请问先打印邮件还是短信

class Phone{ //资源类
    public static synchronized void sendEmail() {
        //暂停几秒钟线程
        try { TimeUnit.SECONDS.sleep(3); } catch (InterruptedException e) { e.printStackTrace(); }
        System.out.println("-------sendEmail");
    }

    public synchronized void sendSMS()
    {
        System.out.println("-------sendSMS");
    }

    public void hello()
    {
        System.out.println("-------hello");
    }
}
public class Lock8Demo{
    public static void main(String[] args){//一切程序的入口,主线程
        Phone phone = new Phone();//资源类1
        Phone phone2 = new Phone();//资源类2

        new Thread(() -> {
            phone.sendEmail();
        },"a").start();

        //暂停毫秒
        try { TimeUnit.MILLISECONDS.sleep(300); } catch (InterruptedException e) { e.printStackTrace(); }

        new Thread(() -> {
            //phone.sendSMS();
            //phone.hello();
            phone2.sendSMS();
        },"b").start();

    }
}
/**
 *
 * ============================================
 *  1-2
 *  *  一个对象里面如果有多个synchronized方法,某一个时刻内,只要一个线程去调用其中的一个synchronized方法了,
 *  *  其它的线程都只能等待,换句话说,某一个时刻内,只能有唯一的一个线程去访问这些synchronized方法
 *  *  锁的是当前对象this,被锁定后,其它的线程都不能进入到当前对象的其它的synchronized方法
 *
 *  3-4
 *  *  加个普通方法后发现和同步锁无关
 *  *  换成两个对象后,不是同一把锁了,情况立刻变化。
 *
 *  5-6 都换成静态同步方法后,情况又变化
 *  三种 synchronized 锁的内容有一些差别:
 * 对于普通同步方法,锁的是当前实例对象,通常指this,具体的一部部手机,所有的普通同步方法用的都是同一把锁——实例对象本身,
 * 对于静态同步方法,锁的是当前类的Class对象,如Phone.class唯一的一个模板
 * 对于同步方法块,锁的是 synchronized 括号内的对象
 *
 *  7-8
 *    当一个线程试图访问同步代码时它首先必须得到锁,退出或抛出异常时必须释放锁。
 *  *
 *  *  所有的普通同步方法用的都是同一把锁——实例对象本身,就是new出来的具体实例对象本身,本类this
 *  *  也就是说如果一个实例对象的普通同步方法获取锁后,该实例对象的其他普通同步方法必须等待获取锁的方法释放锁后才能获取锁。
 *  *
 *  *  所有的静态同步方法用的也是同一把锁——类对象本身,就是我们说过的唯一模板Class
 *  *  具体实例对象this和唯一模板Class,这两把锁是两个不同的对象,所以静态同步方法与普通同步方法之间是不会有竞态条件的
 *  *  但是一旦一个静态同步方法获取锁后,其他的静态同步方法都必须等待该方法释放锁后才能获取锁。
 **/

javap

javap到底是做什么的
通过反编译生成的字节码文件,我们可以深入的了解java代码的工作机制。但是,自己分析类文件结构太麻烦了!除了使用第三方的jclasslib工具之外,oracle官方也提供了工具:javap。

javap是jdk自带的反解析工具。它的作用就是根据class字节码文件,反解析出当前类对应的code区 (字节码指令)、局部变量表、异常表和代码行偏移量映射表、常量池等信息。

通过局部变量表,我们可以查看局部变量的作用域范围、所在槽位等信息,甚至可以看到槽位复用等信息。

在这里插入图片描述
一般常用的是-v -l -c -p四个选项。
javap -l :会输出行号和本地变量表信息;
javap -c :会对当前class字节码进行反编译生成汇编代码;
javap -v: class字节码文件中除了包-c参数包含的内容外,还会输出行号、局部变量表信息、常量池等信息;
java -p:显示所有类和成员

使用javap查看synchronized

作用于实例方法,当前实例加锁,进入同步代码前要获得当前实例的锁

public class Main {

    Object object = new Object();

    public void m1(){
        synchronized (object){
            System.out.println("synchronized ----- demo");
        }
    }

    public static void main(String[] args) {

    }
}

使用 javap -c .\Main.class > Main_c.txt 命令反编译

Compiled from "Main.java"
public class com.test.Main {
  java.lang.Object object;

  public com.test.Main();
    Code:
       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 object:Ljava/lang/Object;
      15: return

  public void m1();
    Code:
       0: aload_0                           // aload_0 表示对this的操作,在static 方法中,aload_0表示对方法的第一参数的操作
       1: getfield      #3                  // Field object:Ljava/lang/Object; getfield指令表示获取指定类的实例域,并将其值压入栈顶
       4: dup                               // dup指令可以复制操作数栈栈顶的一个字,再将这个字压入栈。也就是对栈顶的内容做了个备份,此时操作数栈上有连续相同的两个对象地址。
       5: astore_1                          // astore_1指令,JVM从操作数栈顶部弹出一个引用类型或者returnAddress类型值,然后将该值存入由索引1指定的局部变量中,即将引用类型或者returnAddress类型值存入局部变量1。
       6: monitorenter                      // 线程执行monitorenter指令时尝试获取monitor的所有权
       7: getstatic     #4                  // Field java/lang/System.out:Ljava/io/PrintStream; 获取指定类的静态域,并将其值压入栈顶
      10: ldc           #5                  // String synchronized ----- demo ldc指令将int、float、或者一个类、方法类型或方法句柄的符号引用、还可能是String型常量值从常量池中推送至栈顶。ldc指令可以加载String、方法类型或方法句柄的符号引用,但是如果要加载String、方法类型或方法句柄的符号引用,则会在类连接过程中重写ldc字节码指令为虚拟机内部使用的字节码指令_fast_aldc。
      12: invokevirtual #6                  // Method java/io/PrintStream.println:(Ljava/lang/String; 指令用于调用对象的实例方法,根据对象的实际类型进行分派(虚方法分派),这也是Java语言中最常见的方法分派方式。
      15: aload_1                           // 将第二个引用类型本地变量推送至栈顶
      16: monitorexit                       // 释放锁
      17: goto          25                  // 目前主要的无条件跳转指令为 goto
      20: astore_2                          
      21: aload_1
      22: monitorexit                       //再次释放锁,如果方法出现异常,就会导致上面一个monitorexit无法执行
      23: aload_2
      24: athrow
      25: return
    Exception table:
       from    to  target type
           7    17    20   any
          20    23    20   any

  public static void main(java.lang.String[]);
    Code:
       0: return
}

线程执行monitorenter指令时尝试获取monitor的所有权,通过monitorexit释放所有权

注:目前有两个monitorexit,第一个是方法执行完正常释放锁,另一个是如果方法出现异常系统级别的释放锁。

作用于代码块,对括号里配置的对象加锁

public class Main {

    public synchronized void m1() {
        System.out.println("synchronized ----- demo");
    }

    public static void main(String[] args) {

    }
}

使用 javap -v -p .\Main.class > Main_vp.txt 命令
在这里插入图片描述
调用指令将会检查方法的ACC_SYNCHRONIZED访问标志是否被设置,如果设置了,执行线程会将先持有monitor然后再执行方法,最后再方法完成(无论是正常完成还是非正常完成)时释放minotor

作用于静态方法,当前类加锁,进去同步代码前要获得当前类对象的锁

public class Main {

    public static synchronized void m1() {
        System.out.println("synchronized ----- demo");
    }

    public static void main(String[] args) {

    }
}

使用 javap -v -p .\Main.class > Main_vp.txt 命令
在这里插入图片描述
ACC_STATIC、ACC_SYNCHRONIZED访问标志区分该方法是否静态同步方法

深入源码看synchronized

任何一个对象都可以成为一个锁,在HotSpot虚拟机中,monitor采用ObjectMonitor实现

HotSpot虚拟机源码下载
https://hg.openjdk.org/jdk8u/jdk8u60/hotspot/

在这里插入图片描述
涉及文件:ObjectMonitor.java — ObjectMonitor.cpp — ObjectMonitor.hpp

ObjectMonitor.hpp(底层源码解析)

在这里插入图片描述
关键的属性:
_owner:指向持有ObjectMonitor对象的线程
_WaitSet:存放处于wait状态的线程队列
_EntryList:存放处于等待锁block状态的线程队列
_recursions:锁的重入次数
_count:用来记录该线程获取锁的次数

具体执行流程:

当多个线程同时访问一段同步代码时,首先会进入_EntryList队列中,当某个线程获取到对象的monitor后进入_Owner区域并把monitor中的_owner变量设置为当前线程,同时monitor中的计数器_count加1。即获得对象锁。

若持有monitor的线程调用wait()方法,将释放当前持有的monitor,_owner变量恢复为null,_count自减1,同时该线程进入_WaitSet集合中等待被唤醒。若当前线程执行完毕也将释放monitor(锁)并复位变量的值,以便其他线程进入获取monitor(锁)。如下图所示
在这里插入图片描述
在这里插入图片描述

原理剖析

上述大致了解了synchronized的执行流程,那么真如上述这么简单吗?答案是否定的。上述讲解了synchronized重量级锁的执行流程,这种方式是很影响性能的,对于jvm而言却做了大量的优化。

java线程阻塞的代价

java的线程是映射到操作系统原生线程之上的,如果要阻塞或唤醒一个线程就需要操作系统介入,需要在用户态与核心态之间切换,这种切换会消耗大量的系统资源,因为用户态与内核态都有各自专用的内存空间,专用的寄存器等,用户态切换至内核态需要传递给许多变量、参数给内核,内核也需要保护好用户态在切换时的一些寄存器值、变量等,以便内核态调用结束后切换回用户态继续工作。

如果线程状态切换是一个高频操作时,这将会消耗很多CPU处理时间;
如果对于那些需要同步的简单的代码块,获取锁挂起操作消耗的时间比用户代码执行的时间还要长,这种同步策略显然非常糟糕的。
synchronized会导致争用不到锁的线程进入阻塞状态,所以说它是java语言中一个重量级的同步操纵,被称为重量级锁,为了缓解上述性能问题,JVM从1.5开始,引入了轻量锁与偏向锁,默认启用了自旋锁,他们都属于乐观锁。

在学习什么是无锁、偏向锁、轻量锁、重量锁,以及怎么进行锁升级的前提,需要补充在JVM中对象基本组成的知识

对象组成

Java 虚拟机规范定义了对象类型的数据在内存中的存储格式,一个对象由 对象头 + 实例数据 + 对齐填充数据 三个部分共同组成

  • 对象头:包括了堆对象的类型、GC状态、锁状态和哈希码等基本信息,Java 对象和 JVM 内部对象的对象头格式一致
  • 实例数据:主要是存放对象的类自身属性信息以及父类的属性信息,如果一个类没有字段属性,就不需要实例数据域
  • 对齐填充数据:虚拟机规范要求每个对象所占内存字节数必须是 8N,对齐填充的存在就是为了满足规范要求

这里我们主要讲对象头,对象头的数据总共包含了 3 个部分,以下是各个部分的用途:

1、Mark Word:包含一系列的标识,例如锁的标记、对象年龄等。在32位系统占4字节,在64位系统中占8字节
2、Class Pointer:指向对象所属的 Class 在方法区的内存指针,通常在32位系统占4字节,在64位系统中占8字节,64位 JVM 在 1.6 版本后默认开启了压缩指针,那就占用4个字节
3、Length:如果对象是数组,还需要一个保存数组长度的空间,占 4 个字节

其中 Mark Word 64位是对象头中非常关键的一部分,其在 JVM 中结构如下图所示:
在不同锁状态下,这64位存储的东西都不一样
在这里插入图片描述
hashcode

哈希code很容易理解,将对象存储到一些map或者set里时,都需要hashcode来确认插入位置。
但markword里的hashcode,和我们平时经常覆写的hashCode()还是有区别的。

markword中的hashcode是哪个方法生成的?

很多人误以为,markword中的hashcode是由我们经常覆写的hashcode()方法生成的。

实际上, markword中的hashcode只由底层 JDK C++ 源码计算得到(java侧调用方法为 System.identityHashCode() ), 生成后固化到markword中,

如果你覆写了hashcode()方法, 那么每次都会重新调用hashCode()方法重新计算哈希值。

根本原因是因为你覆写hashcode()之后,该方法中很可能会利用被修改的成员来计算哈希值,所以jvm不敢将其存储到markword中。

因此,如果覆写了hashcode()方法,对象头中就不会生成hashcode,而是每次通过hashcode()方法调用

markword中的hashcode是什么时候生成?

很容易误以为会是对象一创建就生成了。

实际上,是采用了延迟加载技术,只有在用到的时候才生成。

毕竟有可能对象创建出来使用时,并不需要做哈希的操作。

hashcode在其他锁状态中去哪了?

这个问题会在后面锁升级的3个阶段中,解释hashcode的去向

gc分代年龄(4bit)

在jvm垃圾收集机制中, 决定年轻代什么时候进入老年代的根据之一, 就是确认他的分代年龄是否达到阈值,分代年龄只有4bit可以看出,最大值只能是15。因此我们设置的进入老年代年龄阈值 -XX:MaxTenuringThreshold 最大只能设置15。

cms_free

在无锁和偏向锁中,还可以看到有1bit的cms_free。

实际上就是只有CMS收集器用到的。但最新java11中更多用的是G1收集器了,这一位相当于不怎么常用,因此提到的也非常少。

从上述可以看出, 只有锁状态标记位、 hashcode、 分代年龄、cms_free是必有的, 但是从markword最初的示意图来看, hashcode、 分代年龄、cms_free似乎并非一直存在,那么他们去哪了呢?会在后面的锁升级过程进行详细解释。

无锁001

在这里插入图片描述
处于无锁状态的条件或者时机是什么?

无锁状态用于对象刚创建,且还未进入过同步代码块的时候

这一点很重要, 意味着如果你没有同步代码块或者同步方法, 那么将是无锁状态。

无锁验证

<dependency>
     <groupId>org.openjdk.jol</groupId>
     <artifactId>jol-core</artifactId>
     <version>0.9</version>
</dependency>
Object obj = new Object();
System.out.println(ClassLayout.parseInstance(obj).toPrintable(obj));

在这里插入图片描述
这时候obj的锁状态标志位001代表无锁

hashcode

上述例子中对象头markword中并没有显示hashcode值,而是一堆0,需要调用hashcode()显示hashcode
在这里插入图片描述
匿名偏向锁
在这里插入图片描述
jvm默认开启偏向锁,且有4s延迟

这其实是jvm后面加入的一种优化, 对每个新对象,预置了一个**“可偏向状态”,也叫做匿名偏向状态**,是对象初始化中,JVM 帮我们做的。

注意此时 markword中高位是不存在ThreadID的, 都是0, 说明此时并没有线程偏向发生,因此也可以理解成是无锁。

好处在于后续做偏向锁加锁时,无需再去改动偏向锁标记位,只需要对线程id做cas即可。

偏向锁101

一旦代码第一次进入sync同步方法块,就可能从无锁状态进入偏向锁状态。

另外很多人应该都知道, 偏向锁只存储了当前偏向的线程id, 只有线程id不同的才会触发升级。

但这是非常简化的说法, 实际上中间的细节和优化非常之多!这里将为你详细讲述。

为什么要有偏向锁?

理解这个才能理解偏向锁中的各种设计。 假设我们new出来的对象带有同步代码块方法,但在整个生命周期中只被一个线程访问,那么是否有必要做消耗消耗的竞争动作,甚至引入额外的内存开销?没有必要。

因此针对的是 对象有同步方法调用,但是实际不存在竞争的场景

偏向锁的markword详解
在这里插入图片描述
这个markword和无锁对比, 偏向标志位变成了1, hashcode没了,多了个epoch和线程id。

markword中的当前线程id

这个id就是在进入了对象同步代码块的线程id。

java的线程id是一个long类型, 按理说是64位,但为什么之类的线程id只有54位?

具体没有找到解释,可能是jvm团队认为54位线程id足够用了,不至于会创建2^54那么多的线程,真的有需要创建这么频繁的程序,也会优先采用线程池池才对

线程id如何写入?

线程id是直接写入markword吗? 不对, 一定要注意到这时候是存在同时写的可能的。

因此会采用CAS的方式进行线程id的写入。 简而言之, 就是先取原线程id后,再更新线程id,更新后检查一下是否和预期一致,不一致则说明被人改动过,则线程id写入失败,说明存在竞争,升级为轻量级锁。

哈希code去哪了

我们注意到无锁时的hashcode不见了。

对于偏向锁而言, 一旦在对象头中设置过hashcode, 那么进入同步块时就不会进入偏向锁状态,会直接跳到轻量级锁,毕竟偏向锁里没有存放hashcode的地方(下文的轻量级锁和重量级锁则有存储的地方)

因此凡是做过类似hashmap.put(k,v)操作且没覆写hashcode的k对象, 以后加锁时,都会直接略过偏向锁。

epoch是什么?

这个属性很多人叫它“偏向时间戳”, 却鲜有人进行详细解释。

主要是因为它涉及到了偏向锁中非常重要的2个优化(批量重偏向和批量撤销)

对于这个epoch,放到下文的偏向锁解锁过程进行解释。

你可以先简单理解为,通过epoch,jvm可以知道这个对象的偏向锁是否过期了,过期的情况下允许直接试图抢占,而不进行撤销偏向锁的操作。

偏向锁运作详解

我们知道偏向锁其实就是将线程id设置了进去,但是如果存在冲突怎么办?

因此,jmv会通过CAS来设置偏向线程id,一旦设置成功那么这个偏向锁就算挂上了。

后面每次访问时,检查线程id一致,就直接进入同步代码块执行了。

CAS概念补充:

CAS是一个原子性操作, 调用者需要给定修改变量的期望值 和 最终值

当内存中该变量的值和期望值相等时,才更新为最终值, 这个相等的比较和更新的操作是原子操作

对于到偏向锁加锁过程, 其实就是先取出线程id部分, 如果为空, 则进行(期望值:空 , 最终值:当前线程id)的CAS操作, 如果发现期望值不匹配,就说明被抢先了 。

离开同步代码块时, markword中的线程id会重新变为0吗?

并不会,这个偏向锁线程id会一直挂着, 后面只要识别到id一致,就不用做特殊处理。

偏向锁发生竞争时的切锁或者升级操作。

但当有其他线程来访问时,之前设置的偏向锁就有问题了,说明存在多线程访问同一个对象的情况。

注意!!!这里并非像很多资料里说的那样, 一旦发生多线程调用, 偏向锁就升级成轻量级锁,而是做了很多的细节处理,来尽可能避免轻量级锁这种耗费CPU的操作。

撤销偏向锁:

  1. 当线程B发现是偏向锁,且线程id不为自己时,开始撤销操作
  2. 首先,线程B会一直等待 对象obj 到达jvm安全点。
  3. 到达安全点后, 线程B检查线程A是否正处在obj的同步代码块内。
  4. 如果线程A正在同步代码块中, 则没得商量了,直接升级为轻量级锁。
  5. 如果线程A不在同步代码块中, 那么线程B还有机会, 它先把偏向锁改成无锁状态,然后再用CAS的方式尝试重新竞争,如果能竞争到,那么就会偏向自己。

为什么要等待安全点,才能做撤销操作?

这是为了保证撤销操作的安全性。否则可能出现jvm正在撤销的时候, 另一个线程又开始对该对象做操作,引发错误

为什么要先退化成无锁状态,再试图竞争成偏向锁?不能直接偏向吗?

因为你无法预测A是否会卷土重来,置成无锁后, A和B可以公平竞争。

为什么原偏向线程在同步代码块中时,就必须升级为轻量级锁?能否同样撤销无锁来竞争?

不可以,因为同步代码块还在执行的话,那B线程此时是注定无法立刻得到锁的,注定了它必须升级为轻量级锁,通过轻量级锁中的循环能力来做获取锁的操作。

批量重偏向,以及epoch的应用

上文提到, 线程B重新抢偏向锁时,会试图等待安全点,撤销成无锁,再做公平抢占。 这个动作还是比较费时的。

假设有一个场景, 我们new 了30个obj对象, 最初都是由A线程使用,后面通过for循环都由B线程使用,那么会发现在很短的时间内,连续发生了偏向锁撤销为无锁,且未因同步块竞争而发生轻量升级的情况。

那么,jvm猜测此时后面都是类似的情况,于是B线程调用obj对象时,不再撤销了,直接CAS竞争threadId,因为jvm预测A不会来抢了,具体步骤如下所示:

  1. jvm会在obj对象的类class对象中, 定义了一个偏向撤销计数器以及epoch偏向版本。
  2. 每当有一个对象被撤销偏向锁, 都会让偏向撤销计数器+1。
  3. 一旦加到20, 则认为出现大规模的锁撤销动作。 于是class类对象中的epoch值+1(但是epoch一般只有2位即0~3)。
  4. 接着, jvm会找到所有正处在同步代码块中的obj对象, 让他的epoch等于class类对象的epoch。
  5. 其他不在同步代码块中的obj对象,则不修改epoch。
  6. 当B线程来访问时,发现obj对象的epoch和class对象的epoch不相等,则不再做撤销动作,直接CAS抢占。 因为当epoch不等时,这说明该obj对象之前一直没被原主人使用, 但它的兄弟们之前纷纷投降倒戈了, 那我应该直接尝试占用就好,没必要那么谨慎了!

批量撤销

但如果短时间内该类的撤销动作超过40个, jvm会认为这个数量太多了, 不保险,数量一多,预测就不准了。

jvm此时会将 obj对象的类class对象中的偏向标记**(注意是类中的偏向锁开启标记,而不是对象头中的偏向锁标记)**设置为禁用偏向锁。 后续该对象的new操作将直接走轻量级锁的逻辑。

偏向锁在进程一开始就启用了吗

即使你开启了偏向锁,但是这个偏向锁的启用是有延迟,大概 4s左右。

即java进程启动的4s内,都会直接跳过偏向锁,有同步代码块时直接使用轻量级锁。

原因是 JVM 初始化的代码有很多地方用到了synchronized,如果直接开启偏向,产生竞争就要有锁升级,会带来额外的性能损耗,jvm团队经过测试和评估, 选择了启动速度最快的方案, 即强制4s内禁用偏向锁,所以就有了这个延迟策略 (当然这个延迟时间也可以通过参数自己调整)

偏向锁的重要演变历史和思考

偏向锁在JDK6引入, 且默认开启偏向锁优化, 可通过JVM参数-XX:-UseBiasedLocking来禁用偏向锁。

jdk的演变过程中, 为偏向锁做了如上所述的批量升级、撤销等诸多动作。

但随着时代发展,发现偏向锁带来的维护、撤销成本, 远大于轻量级锁的少许CAS动作。

官方说明中有这么一段话: since the introduction of biased locking into HotSpot also change the amount of uncontended operations needed for that relation to remain true。

即随着硬件发展,原子指令成本变化,导致轻量级自旋锁需要的原子指令次数变少(或者cas操作变少 个人理解),所以自旋锁成本下降,故偏向锁的带来的优势就更小了。

于是jdk团队在Jdk15之后, 再次默认关闭了偏向锁。

也许你会问,那前面学习了那么一堆还有啥意义,都不推荐使用了。

但大部分java应用还是基于jdk8开发的, 并且偏向锁里的思想还是值得借鉴的。

还有就是奥卡姆剃刀原理, 如果增加的内容带来很大的成本,不如大胆的废除掉,接受一点落差,将精力放在提升度更大的地方。

轻量级锁10

轻量级锁的markword如下所示,可以看到除了锁状态标记位,其他的都变成了一个栈帧中lockRecord记的地址。

在这里插入图片描述
原先markword中的信息都去哪里了?

之前提到markword中有分代年龄、cms_free、hashcode等固有属性。

这些信息会被存储到对应线程栈帧中的lockRecord中。

另外注意, 当轻量级锁未上锁时, 对象头中的markword存储的还是markword内容,并没有变成指针,只有当上锁过程中,才会变成指针。

解锁过程同理,通过CAS,将对象头替换回去。

轻量级锁如何处理线程重入问题?

对于同一个线程,如果反复进入同步块,在sync语义上来说是支持重入的(即持有锁的线程可以多次进入锁区域), 对轻量级锁而言,必须实现这个功能。

因此线程的lockRecord并非单一成员,他其实是一个lockRecord集合,可以存储多个lockRecord。

每当线程离开同步块,lockRecord减少1个, 直到这个lockReocrd中包含指针,才会做解锁动作。

在这里插入图片描述
轻量级锁加锁过程

根据上述CAS和重入相关,可以得到进入同步代码块时的加锁过程:

  1. 进入同步块前,检查是否已经储存了lockRecord地址,且地址和自己当前线程一致 。如果已经存了且一致,说明正处于重入操作,走重入逻辑,新增lockRecord
  2. 如果未重入,检查lockRecord是否被其他线程占用,如果被其他线程占用,则自旋等待,自旋超限后升级重量级锁
  3. 如果未重入,且也没被其他线程占用,则取出lockRecord中存的指针地址,然后再用自己的markword做CAS替换
  4. 替换失败,则尝试自旋重新CAS,失败次数达到上限,也一样升级

在这里插入图片描述

轻量级锁的解锁流程

在这里插入图片描述
自旋次数的上限一定是10次吗?

在JDK 6中对自旋锁的优化,引入了自适应的自旋。

自适应意味着自旋的时间不再是固定的了,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定的。如果在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也很有可能再次成功,进而允许自旋等待持续相对更长的时间,比如持续100次忙循环。

另一方面,如果对于某个锁,自旋很少成功获得过锁,那在以后要获取这个锁时将有可能直接省略掉自旋过程,以避免浪费处理器资源。有了自适应自旋,随着程序运行时间的增长及性能监控信息的不断完善,虚拟机对程序锁的状况预测就会越来越精准,虚拟机就会变得越来越“聪明”了

重量级锁10

每个对象会有一个objectMonitor的C++对象生成, 通过地址指向对方,后面的逻辑都是通过C++来实现。
在这里插入图片描述
升级为重量级锁的条件

  1. 从轻量级锁升级为重量级锁的条件: 自旋超过10次 或者达到自适应自旋上限次数
  2. 从无锁/偏向锁直接升级为重量级锁的条件:调用了object.wait()方法,则会直接升级为重量级锁!
    第二个条件容易被忽略的

markword去哪了

对象头中的markwod,和轻量级锁中的处理类似, 被存入了objectMonitor对象的header字段中了。

当多个线程同时访问一段同步代码时,首先会进入_EntryList队列中,当某个线程获取到对象的monitor后进入_Owner区域并把monitor中的_owner变量设置为当前线程,同时monitor中的计数器_count加1。即获得对象锁。

若持有monitor的线程调用wait()方法,将释放当前持有的monitor,_owner变量恢复为null,_count自减1,同时该线程进入_WaitSet集合中等待被唤醒。若当前线程执行完毕也将释放monitor(锁)并复位变量的值,以便其他线程进入获取monitor(锁)。如下图所示
在这里插入图片描述
在这里插入图片描述

总结

锁升级之后,本来存储hashcode的位置被占用了,那么hashcode 去哪里了

  • 无锁状态:当调用hashcode()之后,将永远不会升级为偏向锁,会跳过偏向锁升级为轻量级锁
  • 偏向锁状态:收到hashcode()请求之后,会立刻撤销偏向状态,膨胀为重量级锁
  • 轻量级锁状态:会存储在lock record空间中
  • 重量级锁状态:会存储在ObjectMonitor对象中

在这里插入图片描述
synchronized锁升级过程总结:一句话,就是先自旋,不行再阻塞。

实际上是把之前的悲观锁(重量级锁)变成在一定条件下使用偏向锁以及使用轻量级(自旋锁CAS)的形式

锁消除和锁粗化

JIT(Just In Compiler)即时编译器对锁的优化

锁消除

分析synchronized锁对象是不是只可能被一个线程加锁,不存在其他线程来竞争加锁的情况。这时就可以消除该锁了,提升执行效率。

锁消除指的是在某些情况下,JVM 虚拟机如果检测不到某段代码被共享和竞争的可能性,就会将这段代码所属的同步锁消除掉,从而到底提高程序性能的目的。

例如:StringBuffer 的 append()

锁粗化

JIT编译时,发现一段代码中频繁的加锁释放锁,会将前后的锁合并为一个锁,避免频繁加锁释放锁。

参考资料

https://bbs.huaweicloud.com/blogs/363553?utm_source=zhihu&utm_medium=bbs-ex&utm_campaign=other&utm_content=content

https://www.bilibili.com/video/BV1ar4y1x727/?spm_id_from=333.337.search-card.all.click

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值