从kernel层面分析synchronized、volatile,进大厂必备硬核小伎俩(中)

本篇是关于从kernel层面分析synchronized、volatile,进大厂必备硬核小伎俩系列的第二篇。在上一篇中主要介绍了一些关于synchronized、volatile相关的底层相关的概念。本文主要介绍内存屏障的概念、分析jvm锁升级的过程等。而关于volatile的实现细节及DCL单例是否需要加volatile修饰将在下一篇进行介绍。

内存屏障

内存屏障用白话说就是在两个指令之间加入一个屏障,前面的指令不执
行完,后面的指令是不允许执行的。内存屏障分为操作系统级别和jvm
级别的屏障。

在这里插入图片描述

OS内存屏障

操作系统级别的内存屏障在x86系统上是支持sfence、lfence、mfence原语来实现内存屏障功能的。

  • sfence:sfence之前的写操作必须在sfence之后的写操作执行之前完成。
  • lfence:lfence之前的读操作必须在sfence之后的读操作执行之前完成。
  • mfence:mfence之前的读写操作必须在mfence之后的读写操作之前完成。
这三个指令不是所有的操作系统都支持的,如果不支持此指令的操作
系统,那么在操作系统层面就是通过lock指令来用锁总线的方式。总
之支持指令的用指令来实现内存屏障,不支持指令的用锁总线的方式
来实现内存屏障。
jvm内存屏障

jvm级别的内存屏障分loadload,loadstore,storeload,storestore。

  • loadload:在对加volatile关键字的变量i进行读操作时,在读i之前的所有读操作必须全部完成,才能进行对i的读取。也就是在i之前加入了loadload指令。
  • loadstore:在对加volatile关键字的变量i进行写操作时,在给i赋值之前的所有读操作必须全部完成,才能进行对i赋值。也就是在i之前加入了loadstore指令。
  • storeload:在对加volatile关键字的变量i进行读操作时,在读取i之前的所有写操作必须全部完成,才能进行对i进行读取。也就是在i之前加入了storeload指令。
  • storestore:在对加volatile关键字的变量i进行写操作时,在给i赋值之前的所有写操作必须全部完成,才能进行对i进行赋值操作。也就是在i之前加入了storestore指令。

这样还很混乱,看下面的例子:

int a;
volatile int b;

a = 5;
// 插入storestore屏障
b = 6;
// 插入storeload屏障,保障b完成赋值完全可见,才执行if操作
 if  (b == 6) {
     // 插入loadload指令
 	a = 4;
 }

synchronized锁升级

synchronized在jdk1.6之前是重量级锁,也就是说没有锁升级的过程,只要给对象加锁,那么直接通过想内核空间申请锁,从用户态转内核态是很消耗资源的,所以jdk1.6之后采用了锁升级的机制。
锁升级过程:无锁-》给对象加synchronized,如果开启偏向锁,那么先会升级为偏向锁,否则直接升级为轻量级锁-》有线程竞争同一把锁的情况,那么如果当前是偏向锁,那么会升级为轻量级锁;-》当竞争到一定程度(有一个线程自旋10次或竞争的线程数超过内核数的2分之一,操作系统也会自己判断是否需要升级)时,就会升级为重量级锁。
锁升级
锁升级的过程会通过对象头的markword来记录,以下是对象的不同状态markword的变化情况(64位操作系统),重点可关注后两列(是否为偏向锁和锁标识位):
锁升级对应的markword
下面我们来通过JOL工具来来观察对象头,分别分析锁升级的过程。

<dependency>
     <groupId>org.openjdk.jol</groupId>
     <artifactId>jol-core</artifactId>
     <version>0.9</version>
 </dependency>
  • 无锁
public class TestNewObj {

    public static void main(String[] args) {
        TestNewObj testNewObj = new TestNewObj();
        System.out.println(ClassLayout.parseInstance(testNewObj).toPrintable());
    }
}

在这里插入图片描述
前两行8个字节共64位代码markword,可以看到无锁的情况下,锁标识位上为01,代表无锁状态。

  • 偏向锁
    在这里插入图片描述
    同样的代码,我们开启偏向锁(-XX:+UseBiasedLocking -XX:BiasedLockingStartupDelay=0),观察输出,后三位为101,代表偏向锁,也就是如果我们开启偏向锁,那么默认创建对象时就是偏向锁,其实这个是匿名偏向锁,因为可以观察到markword中没有记录线程id。

  • 存在竞争的偏向锁:

public class TestNewObj {

    public static void main(String[] args) {
        TestNewObj testNewObj = new TestNewObj();
        System.out.println(ClassLayout.parseInstance(testNewObj).toPrintable());

        synchronized (testNewObj) {
            System.out.println(ClassLayout.parseInstance(testNewObj).toPrintable());
        }
    }
}

在这里插入图片描述
仍然开启偏向锁,执行上面的代码段,分别输出两次改对象的对象头,首次实例化后markword锁标识位为101,其余位上都是0,当给该对象加锁后,markword锁标识位为101,但是markword中会记录线程id,这个就是正常的偏向锁。

  • 禁用偏向锁
public class TestNewObj {

    public static void main(String[] args) {
        TestNewObj testNewObj = new TestNewObj();
        System.out.println(ClassLayout.parseInstance(testNewObj).toPrintable());

        synchronized (testNewObj) {
            System.out.println(ClassLayout.parseInstance(testNewObj).toPrintable());
        }
    }
}

在这里插入图片描述
可以看到上面是禁用了偏向锁,分别输出两次对象头信息,观察到,初始化对象时,对象时无锁状态,当给对象加锁后,再次输出对象头,可以观察到该对象头中锁标识位上是00,代表轻量级锁,也就是说当禁用偏向锁后,存在给对象加锁,对象就直接升级为轻量级锁,不会再升级为偏向锁了。

  • 存在锁竞争,升级为重量级锁
public class TestNewObj {

    public static void main(String[] args) {
        final TestNewObj testNewObj = new TestNewObj();
        System.out.println(ClassLayout.parseInstance(testNewObj).toPrintable());

        new Thread(new Runnable() {
            @Override
            public void run() {
                synchronized (testNewObj) {
                    try {
                        System.out.println(ClassLayout.parseInstance(testNewObj).toPrintable());
                        Thread.sleep(2000);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
            }
        }).start();

        new Thread(new Runnable() {
            @Override
            public void run() {
                synchronized (testNewObj) {
                    System.out.println(ClassLayout.parseInstance(testNewObj).toPrintable());
                }
            }
        }).start();

    }
}

在这里插入图片描述
可以观察上面的代码段和输出,当存在两个线程同时竞争同一把锁后,第二个线程输出的对象头中,锁标记位为10,即重量级锁。也就是当存在竞争后,会随即升级为重量级锁。

  • 锁不能降级
public class TestNewObj {

    public static void main(String[] args) throws InterruptedException {
        final TestNewObj testNewObj = new TestNewObj();
        System.out.println(ClassLayout.parseInstance(testNewObj).toPrintable());

        new Thread(new Runnable() {
            @Override
            public void run() {
                synchronized (testNewObj) {
                    try {
                        System.out.println(ClassLayout.parseInstance(testNewObj).toPrintable());
                        Thread.sleep(2000);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
            }
        }).start();

        new Thread(new Runnable() {
            @Override
            public void run() {
                synchronized (testNewObj) {
                    System.out.println(ClassLayout.parseInstance(testNewObj).toPrintable());
                }
            }
        }).start();

        Thread.sleep(5000);
        System.out.println(ClassLayout.parseInstance(testNewObj).toPrintable());
    }

在这里插入图片描述
上面图输出结果是上面代码段的最后一个输出,应该说两个线程都执行结束后,已经释放对象锁后,观察对象头中的markword的锁标记位仍然为10(重量级锁),说明锁不可降级。

synchronized的jvm实现。

我们先看下加了synchronized修饰的两个代码片段及对应的汇编码:

public class TestLock {

    public synchronized void lock(){

    }
}

在这里插入图片描述

public class TestLock {

    Object o = new Object();

    public void lock(){
        synchronized (o) {

        }
    }
}

在这里插入图片描述
观察上面两个代码段和对应的汇编码,分别是给方法加锁和锁实例对象,汇编指令是不同的。

  • 给对象加synchronized:在汇编指令中会生成monitorenter和两个monitorexit指令,也就是说在jvm层面给对象加同步锁,是通过加入monitorenter来给代码块进行加锁控制的,monitorexit是执行完同步代码块后用于释放对象锁的指令,那么为什么会有两个monitorexit呢?那就是说第一个monitorexit是正常执行完同步代码块后的释放锁指令,第二个monitorexit是当同步代码块抛出异常时,用来释放锁的指令。也就是为什么synchronized修饰的同步代码块抛出异常时也会释放锁的原因了。

  • synchronized修饰方法:我们看到synchronized修饰方法时,会在汇编码的方法描述符中生成ACC_SYNCHRONIZED指令,这个指令其实也就是表明是在方法上加了synchronized修饰,其实底层也是调用了monitorenter和monitorexit指令来实现的加锁。

synchronized在OS上的实现

上面说了synchronized在jvm中的实现,那么在os上是如何实现的呢?在HotSpot虚拟机中它是由ObjectMonitor实现的(C++实现)。每个对象都存在着一个monitor与之关联,对象与其monitor之间的关系有存在多种实现方式,如monitor可以与对象一起创建销毁或当线程试图获取对象锁时自动生成,但当一个monitor被某个线程持有后,它便处于锁定状态。

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 ;
  }

ObjectMonitor中有两个队列_WaitSet和_EntryList,用来保存ObjectWaiter对象列表(每个等待锁的线程都会被封装ObjectWaiter对象),_owner指向持有ObjectMonitor对象的线程,当多个线程同时访问一段同步代码时,首先会进入_EntryList 集合,当线程获取到对象的monitor 后进入 _Owner 区域并把monitor中的owner变量设置为当前线程同时monitor中的计数器count加1,若线程调用 wait() 方法,将释放当前持有的monitor,owner变量恢复为null,count自减1,同时该线程进入 WaitSe t集合中等待被唤醒。若当前线程执行完毕也将释放monitor(锁)并复位变量的值,以便其他线程进入获取monitor(锁)。

最后

下一篇将接着本篇讲解,会介绍volatile底层实现原理及DCL单例是否需要加volatile修饰等相关底层实现。如果本篇对你有用,欢迎点赞、关注、转载,由于水平有限,如有问题请留言。

  • 2
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 2
    评论
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值