• 如果您觉得本站非常有看点,那么赶紧使用Ctrl+D 收藏吧

synchronized底层是怎么实现的?

开发技术 开发技术 2周前 (09-13) 17次浏览

前言

面试的时候有被问到,sync++hronized底层是怎么实现的,回答的比较浅,面试官也不是太满意,所以觉得要好好总结一下,啃啃这个硬骨头。

sync++hronized使用场景

我们在使用synchronized的时候都知道它是可以使用在方法上的也可以使用在代码块上的,那么使用在这两个地方有什么区别呢?

sync++hronized用在方法上

使用在静态方法上,synchronized锁住的是类对象。

public class SynchronizedTest {

    /**
     * synchronized 使用在静态方法上
     */
    public static synchronized void test1(){
        System.out.println("I am test1 method");
    }
}

使用在实例方法上,synchronized锁住的是实例对象。

public class SynchronizedTest {
   
    /**
     * synchronized 使用在实例方法上
     * @return
     */
    public synchronized String syncOnMethod(){
        return "a developer name Jimoer";
    }
}

sync++hronized用在代码块上

synchronized的同步代码块用在类实例的对象上,锁住的是当前的类的实例。
即执行buildName的时候,整个对象都会被锁住,直到执行完成buildName后释放锁。

public class SynchronizedTest {
    
    private String name;

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    /**
     * 带姓氏的名称
     * @param firstName 姓氏
     */
    public void buildName(String firstName){
        synchronized(this){
            this.setName(firstName+this.getName());
        }
    }
}

synchronized的同步代码块用在类对象上,锁住的是该类的类对象。

public class SynchronizedTest {
    private static String myName = "Jimoer";
    /**
     * 带姓氏的名称
     * @param firstName 姓氏
     */
    public static void buildName(String firstName){
        synchronized(SynchronizedTest.class){
            System.out.println(firstName+myName);
        }
    }
}

synchronized的同步代码块用在任意实例对象上,锁住的就是配置的实例对象。

public class SynchronizedTest {
    private String lastName;

    public String getLastName() {
        return lastName;
    }
    public void setLastName(String lastName) {
        this.lastName = lastName;
    }
    /**
     * 带姓氏的名称
     * @param firstName 姓氏
     */
    public void buildName(String firstName){
        synchronized(lastName){
            System.out.println(firstName+lastName);
        }
    }
}

synchronized的使用就介绍到这里,正常情况下会用了就可以了,能在实际场景中使用的时候知道锁住的范围就可以了,但是面试的时候可是要问原理的,而且在程序出现问题的时候,知道原理也是能快速定位问题的基础。

sync++hronized的原理

我们来看一下synchronized底层是怎么实现的吧。

例如:
下面一段代码,包含一个synchronized代码块和一个synchronized的同步方法。

public class SynchronizedTest {
    private static String myName = "Jimoer";
    public static void main(String[] args) {
        synchronized (myName){
            System.out.println(myName);
        }
    }
    /**
     * synchronized 使用在静态方法上
     */
    public static synchronized void test1(){
        System.out.println("I am test1 method");
    }
}

在编译完成后生成了class文件,我将class文件反编译出来,看看生成的class文件的内容。

javap -p -v -c SynchronizedTest.class 

反编译出来的字节码文件内容有点多,我只截取了关键部分来分析。

synchronized底层是怎么实现的?
注意上面我用红框标出来的地方,synchronized关键字在经过Javac编译之后,会在同步块的前后形成monitorentermonitorexit两个字节码指令。
根据《Java虚拟机规范》的要求

  • 在执行monitorenter指令的时候,首先要去尝试获取对象的锁(获取对象锁的过程,其实是获取monitor对象的所有权的过程)。
  • 如果这个对象没被锁定,或者当前线程已经持有了那个对象的锁,就把锁的计数器的值增加一。
  • 而在执行monitorexit指令时会将锁计数器减一。一旦计数器的值为零,锁随即就被释放了。
  • 如果获取对象锁失败,那当前线程就应当被阻塞等待,直到请求锁定的对象被持有它的线程释放为止。

同步方法

同步方法test1的反编译后的字节码文件部分如下:
synchronized底层是怎么实现的?
注意我用红框圈起来的部分,这个ACC_SYNCHRONIZED标志。代表的是当线程执行到方法后会检查是否有这个标志,如果有的话就会隐式的去调用monitorentermonitorexit两个命令来将方法锁住。

monitor对象

我在上面说了,获取对象锁的过程,其实是获取monitor对象的所有权的过程。哪个线程持有了monitor对象,那么哪个线程就获得了锁,获得了锁的对象可以重复的来获取monitor对象,但是同一个线程每获取一次monitor对象所有权锁计数就加一,在解锁的时候也是需要将锁计数减成0才算真的释放了锁。
monitor对象,我们其实在Java的反编译文件中并没有看到。这个对象是存放在对象头中的。

对象头

这里要介绍一下对象头,首先要说一下对象的内存布局,在HotSpot虚拟机里,对象在堆内存中的存储布局可以划分为三个部分:对象头(Header)实例数据(Instance Data)对齐填充(Padding)

  • 实例数据里面存储的是对象的真正有效数据,里面包含各种类型的字段内容,无论是自身的还是从父类继承来的。
  • 对齐填充这部分并不是必然存在的,只是为了占位。虚拟机自动管理内存系统要求对象的大小必须是8字节的整数倍,当整个对象的大小不是8字节的整数倍时,用来对齐填充补全。
  • 对象头部分包含两类信息。
    1、第一类是自身运行时数据,如何哈希码(hashcode)、GC分代年龄、锁状态标志线程持有的锁偏向线程ID等,这部分数据官方称它为“Mark Word”。
    2、第二类是类型指针,即对象指向它的类型元数据的指针,虚拟机通过它来确定对象是哪个类型的实例。

接着回到我们的monitor对象,monitor对象的源码是C++写的,在虚拟机的ObjectMonitor.hpp文件中。
数据结构长这个样子。

ObjectMonitor() {
    _header       = NULL;
    _count        = 0;
    _waiters      = 0,
    _recursions   = 0;  // 线程重入次数
    _object       = NULL;  // 存储Monitor对象
    _owner        = NULL;  // 持有当前线程的owner
    _WaitSet      = NULL;  // wait状态的线程列表
    _WaitSetLock  = 0 ;
    _Responsible  = NULL ;
    _succ         = NULL ;
    _cxq          = NULL ;  // 单向列表
    FreeNext      = NULL ;
    _EntryList    = NULL ;  // 处于等待锁状态block状态的线程列表
    _SpinFreq     = 0 ;
    _SpinClock    = 0 ;
    OwnerIsThread = 0 ;
    _previous_owner_tid = 0;
  }

有想对这个monitor对象更深入了解的可以去Java虚拟机的源码里看看。

重量级锁

在主流的Java虚拟机实现中,Java的线程是映射到操作系统的原生内核线程之上的,如果要阻塞或唤醒一条线程,则需要操作系统来帮忙完成,这就不可避免地陷入用户态到核心态的转换中,这种状态的转换要耗费很多的处理时间。
所以在ObjectMonitor文件中的调用过程和复杂的操作系统运行机制导致线程的阻塞或唤醒时是很耗费资源的。
这样在JDK1.6之前都称synchronized为重量级锁。

重量级锁的减重

高效并发是从JDK5升级到JDK6的一项重要的改进项,在JDK6版本上虚拟机开发团队花费了大量的资源去实现各种锁优化技术,来为重量级锁减重。
synchronized在升级后的整个加锁过程,大致如下图。
synchronized底层是怎么实现的?
这里要说明一下,锁升级的过程是不可逆的。

偏向锁

上面在介绍对象头的时候,说到了对象头中包含的内容了,其中有一个就是偏向锁的线程ID,它代表的意思就是说,如果当一个线程获取到了锁之后,锁的标志计数器就会+1,并且把这个线程的id存储在锁住的这个对象的对象头上面。
这个过程是通过CAS来实现的,每次线程进入都是无锁的,当执行CAS成功后,直接将锁的标志计数+1(持有偏向锁的线程以后每次进入锁时不做任何操作,标志计数直接+1),这个时候其他线程再进来时,执行CAS就会失败,也就是获取锁失败。
synchronized底层是怎么实现的?

偏向锁在JDK1.6是默认开启的,通过参数进行关闭xx:-UseBiasedLocking=false

偏向锁可以提高带有同步但无竞争的程序性能,但如果大多数的锁都总是被多个不同的线程访问,那偏向锁就是多余的。

轻量级锁

轻量级锁还是和对象头的第一部分(Mark Word)相关。

  • 在代码即将进入同步块的时候,如果此同步对象没有被锁定,虚拟机首先将当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用户存储锁对象目前的Mark Word的拷贝。
  • 然后JVM将使用CAS操作尝试把对象的Mark Word更新为指向Lock Record的指针。如果这个更新动作成功了,说明线程获取锁成功,并执行后面的同步操作。
  • 如果这个更新动作失败了,说明锁对象已经被其他线程抢占了,那轻量级锁不在有效,必须膨胀为重量级锁。此时被锁住的对象的标志变为重量级锁的标志。

synchronized底层是怎么实现的?

自旋锁

当轻量级锁获取失败后,就会升级为重量级锁,但是重量级锁之前也介绍了是很耗资源的,JVM开发团队注意到许多程序上,共享数据的二锁定状态只会持续很短一段时间,为了这段时间去挂起和恢复线程并不值得。
所以想到了一个策略,那就是当线程请求一个已经被锁住的对象时,可以让未获取锁的线程“稍等一会”,但不放弃处理器执行时间,只需要让线程执行一个忙循环(自旋),这就是所谓的自旋锁。
自旋锁在JDK1.4.2中引入,默认关闭,可以通过-XX:UserSpinning参数来开启,默认自旋次数是10次,用户可以自定义次数,配置参数是-XX:PreBockSpin。

无论是用户指定还是默认值的自旋次数,对JVM重所有的锁来说都是相同的。在JDK6中引入了自适应自旋,根据前一次在同一锁上的自旋时间及拥有者的状态来决定。如果上一次同一个对象自旋锁获得成功了,那么再次进行自旋时就会认为成功几率很大,那么自旋次数就会自动增加。反之如果自旋很少成功获得锁,那么以后这个自旋过程都有可能被省略掉。

这样在轻量级失败后,就会升级为自旋锁,如果自旋锁也失败了,那就只能是升级到重量级锁了。
synchronized底层是怎么实现的?
参考资料:《深入理解Java虚拟机》、死磕synchronized底层实现


喜欢 (0)