打击面试重灾区——Synchronized原理

兄弟们,你们好。时隔多天,我,终于来了。今天咱们来聊一下让人神魂颠倒的Synchronizedjava

不过呢,在读这篇文章以前,我但愿你真正使用过这个东东,或者了解它究竟是干吗用的,否则很难理解这篇文章讲解的东西。node

这篇文章的大致顺序是:从无锁-->偏向锁-->轻量级锁-->重量级锁讲解,其中会涉及到CAS对象内存布局,缓存行等等知识点。也是满满的干货内容。其中也夹杂了我的在面试过程当中出现的面试题,各位兄弟慢慢享受。c++

Synchronizedjdk1.6作了很是大的优化,避免了不少时候的用户态到内核态的切换,节省了资源的开销,而这一切的前提均来源于CAS这个理念。下面咱们先来聊一下CAS的一些基本理论。面试

1. CAS

CAS全称:CompareAndSwap,故名思意:比较并交换。他的主要思想就是:我须要对一个值进行修改,我不会直接修改,而是将当前我认为的值和要修改的值传入,若是此时内存中的确为我认为的值,那么就进行修改,不然修改失败。他的思想是一种乐观锁的思想。数组

一张图解释他的工做流程:缓存

知道了它的工做原理,咱们来听一个场景:如今有一个int类型的数字它等于1,存在三个线程须要对其进行自增操做。安全

通常来讲,咱们认为的操做步骤是这样:线程从主内存中读取这个变量,到本身的工做空间中,而后执行变量自增,而后回写主内存,但这样在多线程状态下会存在安全问题。而若是咱们保证变量的安全性,经常使用的作法是ThreadLocal或者直接加锁。(对ThreadLocal不了解的兄弟,看我这篇文章一文读懂ThreadLocal设计思想多线程

这个时候咱们思考一下,若是使用咱们上面的CAS进行对值的修改,咱们须要如何操做。架构

首先,咱们须要将当前线程认为的值传入,而后将想要修改的值传入。若是此时内存中的值和咱们的指望值相等,进行修改,不然修改失败。这样是否是解决了一个多线程修改的问题,并且它没有使用到操做系统提供的锁。工具

上面的流程其实就是类AtomicInteger执行自增操做的底层实现,它保证了一个操做的原子性。咱们来看一下源码。

public final int incrementAndGet() {
        return unsafe.getAndAddInt(this, valueOffset, 1) + 1;
    }

public final int getAndAddInt(Object var1, long var2, int var4) {
        int var5;
        do {
            //从内存中读取最新值
            var5 = this.getIntVolatile(var1, var2);
            //修改
        } while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4));

        return var5;
 }

实现CAS使用到了Unsafe类,看它的名字就知道不安全,因此JDK不建议咱们使用。对比咱们上面多个线程执行一个变量的修改流程,这个类的操做仅仅增长了一个自旋,它在不断获取内存中的最新值,而后执行自增操做。

可能有兄弟说了,那getIntVolatilecompareAndSwapInt操做如何保证原子性。

对于getIntVolatile来讲,读取内存中的地址,原本就一部操做,原子性显而易见。

对于compareAndSwapInt来讲,它的原子性由CPU保证,经过一系列的CPU指令实现,其C++底层是依赖于Atomic::cmpxchg_ptr实现的

到这里CAS讲完了,不过其中还有一个ABA问题,有兴趣能够去了解个人这篇文章多线程知识点小节。里面有详细的讲解。

咱们经过CAS能够保证了操做的原子性,那么咱们须要考虑一个东西,锁是怎么实现的。对比生活中的case,咱们经过一组密码或者一把钥匙实现了一把锁,一样在计算机中也经过一个钥匙即synchronized代码块使用的锁对象。

那其余线程如何判断当前资源已经被占有了呢?

在计算机中的实现,每每是经过对一个变量的判断来实现,无锁状态为0,有锁状态为1等等来判断这个资源是否被加锁了,当一个线程释放锁时仅仅须要将这个变量值更改成0,表明无锁。

咱们仅仅须要保证在进行变量修改时的原子性便可,而刚刚的CAS恰好能够解决这个问题

至于那个锁变量存储在哪里这个问题,就是下面的内容了,对象的内存布局

2. 内存布局

各位兄弟们,应该都清楚,咱们建立的对象都是被存放到堆中的,最后咱们得到到的是一个对象的引用指针。那么有一个问题就会诞生了,JVM建立的对象的时候,开辟了一块空间,那这个空间里都有什么东西?这个就是咱们这个点的内容。

先来结论:Java中存在两种类型的对象,一种是普通对象,另外一种是数组

对象内存布局

咱们来一个一个解释其含义。

白话版:对象头中包含又两个字段,Mark Word主要存储改对象的锁信息,GC信息等等(锁升级的实现)。而其中的Klass Point表明的是一个类指针,它指向了方法区中类的定义和结构信息。而Instance Data表明的就是类的成员变量。在咱们刚刚学习Java基础的时候,都听过老师讲过,对象的非静态成员属性都会被存放在堆中,这个就是对象的Instance Data。相对于对象而言,数组额外添加了一个数组长度的属性

最后一个对其数据是什么?

咱们拿一个场景来展现这个缘由:想像一下,你和女友周末打算出去玩,女友让你给她带上口红,那么这个时候你仅仅会带上口红嘛?固然不是,而是将全部的必用品通通带上,以防刚一出门就得回家拿东西!!!这种行为叫啥?未雨绸缪,没错,暖男行为。还不懂?再来一个案例。你准备创业了,资金很是充足,你须要注册一个域名,你仅仅注册一个嘛?不,而是将全部相关的都注册了,防止之后大价钱买域名。一个道理。

而对于CPU而言,它在进行计算处理数据的时候,不可能须要什么拿什么吧,那对其性能损耗很是严重。因此有一个协议,CPU在读取数据的时候,不只仅只拿须要的数据,而是获取一行的数据,这就是缓存行,而一行是64个字节

因此呢?经过这个特性能够玩一些诡异的花样,好比下面的代码。

public class CacheLine {
    private volatile Long l1 , l2;
}

咱们给一个场景:两个线程t1和t2分别操做l1l2,那么当t1l1作了修改之后,l2需不须要从新读取主内存种值。答案是必定,根据咱们上面对于缓存行的理解,l1和l2必然位于同一个缓存行中,根据缓存一致性协议,当数据被修改之后,其余CPU须要从新重主内存中读取数据。这就引起了伪共享的问题

那么为何对象头要求会存在一个对其数据呢?

HotSpot虚拟机要求每个对象的内存大小必须保证为8字节的整数倍,因此对于不是8字节的进行了对其补充。其缘由也是由于缓存行的缘由

对象=对象头+实例数据

3. 无锁

咱们在前面聊了一下,计算机中的锁的实现思路和对象在内存中的布局,接下来咱们来聊一下它的具体锁实现,为对象加锁使用的是对象内存模型中的对象头,经过对其锁标志位和偏向锁标志位的修改实现对资源的独占即加锁操做。接下来咱们看一下它的内存结构图。

上图就是对象头在内存中的表现(64位),JVM经过对对象头中的锁标志位和偏向锁位的修改实现“无锁”。

对于无锁这个概念来讲,在1.6以前,即全部的对象,被建立了之后都处于无锁状态,而在1.6以后,偏向锁被开启,对象在经历过几秒的时候(4~5s)之后,自动升级为当前线程的偏向锁。(不管经没通过synchronized)。

咱们来验证一下,经过jol-core工具打印其内存布局。注:该工具打印出来的数据信息是反的,即最后几位在前面,经过下面的案例能够看到

场景:建立两个对象,一个在刚开始的时候就建立,另外一个在5秒以后建立,进行对比其内存布局

Object object = new Object();
System.out.println(ClassLayout.parseInstance(object).toPrintable());//此时处于无锁态
try {TimeUnit.SECONDS.sleep(5);} catch (InterruptedException e) {e.printStackTrace();}
Object o = new Object();
System.out.println("偏向锁开启");
System.out.println(ClassLayout.parseInstance(o).toPrintable());//五秒之后偏向锁开启

咱们能够看到,线程已开启建立的对象处于无锁态,而在5秒之后建立的线程处于偏向锁状态。

一样,当咱们遇到synchronized块的时候,也会自动升级为偏向锁,而不是和操做系统申请锁。

说完这个,提一嘴一个面试题吧。解释一下什么是无锁。

从对象内存结构的角度来讲,是一个锁标志位的体现;从其语义来讲,无锁这个比较抽象了,由于在之前锁的概念每每是与操做系统的锁息息相关,因此新出现的基于CAS的偏向锁,轻量级锁等等也被成为无锁。而在synchronized升级的起点----无锁。这个东西就比较难以解释,只能说它没加锁。不过面试的过程当中从对象内存模型中理解可能会更加舒服一点。

4. 偏向锁

在实际开发中,每每资源的竞争比较少,因而出现了偏向锁,故名思意,当前资源偏向于该线程,认为未来的一切操做均来自于改线程。下面咱们从对象的内存布局下看看偏向锁

对象头描述:偏向锁标志位经过CAS修改成1,而且存储该线程的线程指针

当发生了锁竞争,其实也不算锁竞争,就是当这个资源被多个线程使用的时候,偏向锁就会升级。

在升级的期间有一个点-----全局安全点,只有处在这个点的时候,才会撤销偏向锁。

全局安全点-----相似于CMSstop the world,保证这个时候没有任何线程在操做这个资源,这个时间点就叫作全局安全点。

能够经过XX:BiasedLockingStartupDelay=0 关闭偏向锁的延迟,使其当即生效。

经过XX:-UseBiasedLocking=false 关闭偏向锁。

5.轻量级锁

在聊轻量级锁的时候,咱们须要搞明白这几个问题。什么是轻量级锁,什么重量级锁?,为何就重量了,为何就轻量了?

轻量级和重量级的标准是依靠于操做系统做为标准判断的,在进行操做的时候你有没有调用过操做系统的锁资源,若是有就是重量级,若是没有就是轻量级

接下来咱们看一下轻量级锁的实现。

  • 线程获取锁,判断当前线程是否处于无锁或者偏向锁的状态,若是是,经过CAS复制当前对象的对象头到Lock Recoder放置到当前栈帧中(对于JVM内存模型不清楚的兄弟,看这里入门JVM看这一篇就够了
  • 经过CAS将当前对象的对象头设置为栈帧中的Lock Recoder,而且将锁标志位设置为00
  • 若是修改失败,则判断当前栈帧中的线程是否为本身,若是是本身直接获取锁,若是不是升级为重量级锁,后面的线程阻塞

咱们在上面提到了一个Lock Recoder,这个东东是用来保存当前对象的对象头中的数据的,而且此时在该对象的对象头中保存的数据成为了当前Lock Recoder的指针

咱们看一个代码模拟案例,

public class QingLock {
    public static void main(String[] args) {
        try {
            //睡觉5秒,开启偏向锁,可使用JVM参数
            TimeUnit.SECONDS.sleep(5);} catch (InterruptedException e) {e.printStackTrace();}
        A o = new A();
        //让线程交替执行
        CountDownLatch countDownLatch = new CountDownLatch(1);
        new Thread(()->{
            o.test();
            countDownLatch.countDown();
        },"1").start();

        new Thread(()->{
            try {
                countDownLatch.await();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            o.test();
        },"2").start();


    }
}

class A{
    private Object object = new Object();
    public void test(){
        System.out.println("为进入同步代码块*****");
        System.out.println(ClassLayout.parseInstance(object).toPrintable());
        System.out.println("进入同步代码块******");
        for (int i = 0; i < 5; i++) {
            synchronized (object){
                System.out.println(ClassLayout.parseInstance(object).toPrintable());
            }
        }
    }
}

运行结果为两个线程交替先后

轻量级锁强调的是线程交替使用资源,不管线程的个数有几个,只要没有同时使用就不会升级为重量级锁

在上面的关于轻量级锁加锁步骤的讲解中,若是线程CAS修改失败,则判断栈帧中的owner是否是本身,若是不是就失败升级为重量级锁,而在实际中,JDK加入了一种机制自旋锁,即修改失败之后不会当即升级而是进行自旋,在JDK1.6以前自旋次数为10次,而在1.6又作了优化,改成了自适应自旋锁,由虚拟机判断是否须要进行自旋,判断缘由有:当前线程以前是否获取到过锁,若是没有,则认为获取锁的概率不大,直接升级,若是有则进行自旋获取锁。

6. 重量级锁

前面咱们谈到了无锁-->偏向锁-->轻量级锁,如今最后咱们来聊一下重量级锁。

这个锁在咱们开发过程当中很常见,线程抢占资源大部分都是同时的,因此synchronized会直接升级为重量级锁。咱们来代码模拟看一下它的对象头的情况。

代码模拟

public class WeightLock {
    public static void main(String[] args) {
        A a = new A();
        for (int i = 0; i < 2; i++) {
             new Thread(()->{
                a.test();
             },"线程"+ i).start();
        }
    }
}

未进入代码块以前,二者均为无锁状态

开始执行循环,进入代码块

在看一眼,对象头锁标志位

对比上图,能够发现,在线程竞争的时候锁,已经变为了重量级锁。接下来咱们来看一下重量级锁的实现

6.1 Java汇编码分析

咱们先从Java字节码分析synchronzied的底层实现,它的主要实现逻辑是依赖于一个monitor对象,当前线程执行遇到monitorenter之后,给当前对象的一个属性recursions加一(下面会详细讲解),当遇到monitorexit之后该属性减一,表明释放锁。

代码

Object o = new Object();
synchronized (o){

}

汇编码

上图就是上面的四行代码的汇编码,咱们能够看到synchronized的底层是两个汇编指令

  • monitoreneter表明synchronized块开始
  • monitorexit表明synchronized块结束

有兄弟要说了为何会有两个monitorexit?这也是我曾经遇到的一个面试题

第一个monitorexit表明了synchronized块正常退出

第二个monitorexit表明了synchronized块异常退出

很好理解,当在synchronized块中出现了异常之后,不能当前线程一直拿着锁不让其余线程使用吧。因此出现了两个monitorexit

同步代码块理解了,咱们再来看一下同步方法。

代码

public static void main(String[] args) {

}

public synchronized void test01(){

}

汇编码

咱们能够看到,同步方法增长了一个ACC_SYNCHRONIZED标志,它会在同步方法执行以前调用monitorenter,结束之后调用monitorexit指令。

6.2 C++代码

Java汇编码的讲解中,咱们提到了两个指令monitorentermonitorexit,其实他们是来源于一个C++对象monitor,在Java中每建立一个对象的时候都会有一个monitor对象被隐式建立,他们和当前对象绑定,用于监视当前对象的状态。其实说绑定也不算正确,其实际流程为:线程自己维护了两个MonitorList列表,分别为空闲(free)和已经使用(used),当线程遇到同步代码块或者同步方法的时候,会从空闲列表中申请一个monitor使用,若是当先线程已经没有空闲的了,则直接从全局(JVM)获取一个monitor使用

咱们来看一下C++对这个对象的描述

ObjectMonitor() {
    _header       = NULL;
    _count        = 0;
    _waiters      = 0,
    _recursions   = 0; // 重入次数
    _object       = NULL; //存储该Monitor对象
    _owner        = NULL; //拥有该Monitor对象的对象
    _WaitSet      = NULL; //线程等待集合(Waiting)
    _WaitSetLock  = 0 ;
    _Responsible  = NULL ;
    _succ         = NULL ;
    _cxq          = NULL ; //多线程竞争时的单向链表
    FreeNext      = NULL ;
    _EntryList    = NULL ; //阻塞链表(Block)
    _SpinFreq     = 0 ;
    _SpinClock    = 0 ;
    OwnerIsThread = 0 ;
    _previous_owner_tid = 0;
  }

线程加锁模型

加锁流程:

  • 最新进入的线程会进入_cxp栈中,尝试获取锁,若是当前线程得到锁就执行代码,若是没有获取到锁则添加到EntryList阻塞队列中
  • 若是在执行的过程的当前线程被挂起(wait)则被添加到WaitSet等待队列中,等待被唤醒继续执行
  • 当同步代码块执行完毕之后,从_cxp或者EntryList中获取一个线程执行

monitorenter加锁实现

  • CAS修改当前monitor对象的_owner为当前线程,若是修改为功,执行操做;
  • 若是修改失败,判断_owner对象是否为当前线程,若是是则令_recursions重入次数加一
  • 若是当前实现是第一次获取到锁,则将_recursions设置为一
  • 等待锁释放

阻塞和获取锁实现

  • 将当前线程封装为一个node节点,状态设置为ObjectWaiter::TS_CXQ
  • 将之添加到_cxp栈中,尝试获取锁,若是获取失败,则将当前线程挂起,等待唤醒
  • 唤醒之后,从挂起点执行剩下的代码

monitorexit释放锁实现

  • 让当前线程的_recursions重入次数减一,若是当前重入次数为0,则直接退出,唤醒其余线程

参考资料:

马士兵多线程技术详解书籍

HotSpot源码

往期推荐:

一文带你了解Spring MVC的架构思路

Mybatis你只会CRUD嘛

IOC的架构你了解嘛

相关文章
相关标签/搜索