深入理解Synchronized底层实现

Synchronized在并发编程经常使用到,那么它到底是什么?实现原理又是什么?今天就来深入理解一下吧!
首先,synchronized关键字主要有以下用法:

  • 同步代码块 : synchronized(this) 、 synchronized(类对象实例) 锁的括号中的实例对象
  • 同步非静态方法 :synchronized methodName 锁的是当前对象的实例对象
  • 同步代码块 : synchronized(类.class) 锁的是括号中的类对象 (Class对象)
  • 同步静态方法 : synchronized static methodName 锁的是当前对象的类对象 (Class对象)

由此可以看出根据使用方法的不同,synchronized锁的对象也不相同。
那么让我们来synchronized在字节码层面又是如何实现的呢

我们先看看synchronized对实例对象加锁时的实现

public class CsdnSynchronizedTest {

    private int x = 10;

    public int getSum(int x){
        synchronized (this){
            return x++;
        }
    }
}

利用命令行 javac -v CsdnSynchronizedTest

descriptor: (I)I
    flags: ACC_PUBLIC   //ACC_PUBLIC指明这是public方法
    Code:
      stack=2, locals=3, args_size=1
         0: ldc           #3                  // class CsdnSynchronizedTest
         2: dup
         3: astore_1
         4: monitorenter
         5: iload_0
         6: iconst_1
         7: ishr
         8: aload_1
         9: monitorexit
        10: ireturn
        11: astore_2
        12: aload_1
        13: monitorexit
        14: aload_2
        15: athrow

我们可以看到synchronized关键字在同步代码块的前后分别形成了monitorenter和monitorexit这两个字节码指令:
	......
	4: monitorenter
	......	
    9: monitorexit
    ......
    13: monitorexit

那这些操作具体做了些什么呢?
这两个字节码指令都需要一个reference类型的参数来指明要锁定和解锁的对象。
如果Java源码中的synchronized明确指定了对象参数,那就以这个对象的应用作为reference;(指的是括号之中的对象)
如果没有明确指定,那将根据synchronized修饰的方法(如实例方法或类方法),来决定是取代码所在的对象实例还是取类型对应的Class对象来作为线程要持有的锁。(普通方法为取当前对象的对象实例,静态方法为取Class对象)

那我们再来看看修饰类方法时

public class CsdnSynchronizedTest {

    private int x = 10;

    public synchronized int getSum(int x){
            return x++;
    }
}

public synchronized int getSum(int);
    descriptor: (I)I
    flags: ACC_PUBLIC, ACC_SYNCHRONIZED
    Code:
      stack=1, locals=2, args_size=2
         0: iload_1
         1: iinc          1, 1
         4: ireturn
      LineNumberTable:
        line 8: 0

对比一下我们可以知道,
在修饰方法时,没有monitorenter和monitorexit
而标志位flags多了ACC_SYNCHRONIZED 来指明这是同步方法

浏览了上面的字节码之后,各位读者应该发现了Java对synchronized并没有进行具体的实现,只是简单的进行了标记,那么就是说它的实现是交给了底层的C++来完成。

在解读C++的实现之前,我们先了解一下JVM是如何来记录锁的吧。
synchronized既然是锁,那就得把锁的信息保存下来让JVM知道是谁的锁吧。
而这个地方就是对象的对象头之中。
一个实例对象的组成是对象头、实例变量和填充数据。参考下图:
在这里插入图片描述

实例变量:存放类的属性数据信息,包括父类的属性信息

填充数据:填充数据的存在是由于虚拟机要求对象所占内存必须是8字节的整数倍,如果对象头+实例变量字节数不是8字节的整数倍,填充数据会将其补齐至8字节的整数倍。一般用0来填充。(填充数据不是必须存在的)

对象头:对象头是实现synchronized的基础,就是因为synchronized使用的锁对象信息就存储在对象头中。

普通对象的对象头:
在这里插入图片描述

数组对象的对象头:
在这里插入图片描述
在32位操作系统中,Mark Word是32bit,64位操作系统中,Mark Word是64bit。
Klass Point :用于存储指向方法区对象类型数据的指针,64位操作系统中也是64bit,但是JVM对类型指针有进行压缩,为32bit,在JDK6之后默认开启。(参数 -XX:-UseCompressedOops)

了解了对象头的组成之后来看看Mark Word中具体是怎么回事吧:
以下是jvm中markOop.hpp 的解释:
// 32 bits:
// --------
// hash:25 ------------>| age:4 biased_lock:1 lock:2 (normal object)
// JavaThread*:23 epoch:2 age:4 biased_lock:1 lock:2 (biased object)
// size:32 ------------------------------------------>| (CMS free block)
// PromotedObject*:29 ---------->| promo_bits:3 ----->| (CMS promoted object)
//
// 64 bits:
// --------
// unused:25 hash:31 -->| unused:1 age:4 biased_lock:1 lock:2 (normal object)
// JavaThread*:54 epoch:2 unused:1 age:4 biased_lock:1 lock:2 (biased object)
// PromotedObject*:61 --------------------->| promo_bits:3 ----->| (CMS promoted object)
// size:64 ----------------------------------------------------->| (CMS free block)

咋一看有点晕吧,那么我们就用一张图概述吧
在这里插入图片描述
unused:不使用
hash:记录对象的hash值
age:分代年龄,占4bit,这也就是为什么分代年龄最多15的原因(1111=15)
biased_lock:是否可偏向标识
lock:锁的状态

这时候肯定有人有疑问,为什么图上相同的101状态还分无锁可偏向(未偏向)和偏向锁(已偏向)呢?
让我们通过代码分析一下:

先导入需要的依赖
<dependency>
        <groupId>org.openjdk.jol</groupId>
        <artifactId>jol-core</artifactId>
        <version>0.10</version>
</dependency>

首先我们先要了解在JDK1.6之后默认开启偏向锁,也就是说对象new出来的就是偏向锁状态,但是这个开始偏向锁有一个4s的延迟,在这个延迟之前new的对象是无锁状态。

public class CsdnSynchronizedTest {
    public static void main(String[] args) throws InterruptedException {
        CsdnSynchronizedTest test = new CsdnSynchronizedTest();
        System.out.println(ClassLayout.parseInstance(test).toPrintable());
    }
}

将Mark Word 打印出来

标题

JVM的对象头是用小端模式存储的,应从尾部向头部读取。
本文主要是将synchronized,对于小端模式和大端模式不做具体说明,有兴趣的读者可以自己查阅资料。
也就说本应在末尾的8bit现在在首位,00000001
上文中提到Mark Word末三位存储的是 是否偏向锁以及锁的状态
而此时锁的状态时001,也就是无锁

public class CsdnSynchronizedTest {
    public static void main(String[] args) throws InterruptedException {
    	Thread.sleep(5000);
        CsdnSynchronizedTest test = new CsdnSynchronizedTest();
        System.out.println(ClassLayout.parseInstance(test).toPrintable());
    }
}

在这里插入图片描述
开启偏向需要一点时间,所以我们让线程睡眠5s确保一定能开启偏向锁。
现在可以看到锁的状态是101,此时没有线程来获取锁,偏向的线程Id为null,所以对象是无锁可偏向状态。
我们也可以通过配置命令行参数来让关闭开启偏向锁的延迟时间
-XX:BiasedLockingStartupDelay=0

看到了偏向锁的状态之后笔者就来说明一下偏向锁的定义吧:

偏向锁的"偏"是偏心的意思,它的意思是这个锁会偏向于第一个获得它的线程,如果之后该锁没有被其他线程获取,那么持有偏向锁的对象将不需要进行同步。

了解之后让我们自己往下走吧。

对代码在进行修改,增加计算hashcode的值

public class CsdnSynchronizedTest {
    public static void main(String[] args) throws InterruptedException {
        Thread.sleep(5000);
        CsdnSynchronizedTest test = new CsdnSynchronizedTest();
        test.hashCode();
        System.out.println(ClassLayout.parseInstance(test).toPrintable());
    }
}

在这里插入图片描述
可以看到奇怪的一幕,此时锁的状态又变回了无锁态。
从上面Mark Word的布局可以看出偏向锁状态下,前52bit需要用来存储偏向线程的Id,但是由于我们计算了hashcode的值,以致于mark word存储了hashcode的值之后没有空间存储偏向线程Id,所以变成了无锁不可偏向状态。

对对象进行加锁测试:

public class CsdnSynchronizedTest {
    public static void main(String[] args) throws InterruptedException {
        Thread.sleep(5000);
        CsdnSynchronizedTest test = new CsdnSynchronizedTest();
//        test.hashCode();
        synchronized (test){
            System.out.println(ClassLayout.parseInstance(test).toPrintable());
        }
    }
}

在这里插入图片描述
可以看到此时对象已经是偏向锁(已偏向),线程Id为
00000000 00000000 00000000 00000000
00000010 10111010 010010

那么既然计算了hashcode之后,对象变成了不可偏向状态之后,将对象加锁又会发生什么呢?

public class CsdnSynchronizedTest {
    public static void main(String[] args) throws InterruptedException {
        Thread.sleep(5000);
        CsdnSynchronizedTest test = new CsdnSynchronizedTest();
        test.hashCode();
        synchronized (test){
            System.out.println(ClassLayout.parseInstance(test).toPrintable());
        }
    }
}

在这里插入图片描述
可以看到此时锁的状态变成了000,也就是此时从无锁状态直接升级成了轻量级锁。
那么除了计算hashcode之外还有办法让对象处于轻量级锁吗?
答案是有的:

public class CsdnSynchronizedTest {
    static CsdnSynchronizedTest test;
    public static void main(String[] args) throws InterruptedException {
        Thread.sleep(5000);
        test = new CsdnSynchronizedTest();
        Thread thread = new Thread(){
            @Override
            public void run() {
                lock();
            }
        };
        lock();
        thread.start();
    }
    public static void lock(){
        synchronized (test){
            System.out.println(Thread.currentThread().getName());
            System.out.println(ClassLayout.parseInstance(test).toPrintable());
        }
    }
}

在这里插入图片描述
在这里插入图片描述
当线程之间锁的获取不存在竞争,而是交替进行的时候,对象将会从偏向锁升级为轻量级锁。

线程获取轻量级锁的过程是虚拟机使用CAS(Compare and Swap)操作尝试将对象的Mark Word中记录的指针指向当前线程。
用通俗易懂的话就是:
你去公共厕所,但是厕所的门锁都坏了,你想了办法,在门上贴了一张纸,纸上写着你的名字,这样别人就知道这门里面有人。但是你真的锁门了吗?
实际上是没有的,也就说轻量级锁也并没有实际加锁,只是进行了一个声明,表明这是有人的。

同理,当对象之间存在竞争的时候,将会升级为重量级锁:

public class CsdnSynchronizedTest {
    static CsdnSynchronizedTest test;
    public static void main(String[] args) throws InterruptedException {
        Thread.sleep(5000);
        test = new CsdnSynchronizedTest();
        Thread thread = new Thread(){
            @Override
            public void run() {
                lock();
            }
        };
        thread.start();
        lock();

    }
    public static void lock(){
        synchronized (test){
            System.out.println(Thread.currentThread().getName());
            System.out.println(ClassLayout.parseInstance(test).toPrintable());
        }
    }
}

将线程的start()方法移动到lock()方法之前,让线程之间竞争获取锁
在这里插入图片描述
在这里插入图片描述
此时,对象变成了重量级锁。
当对象持有重量级锁的时候,这时候才是真正意义上的加锁。JVM将从用户态切换到内核态,向操作系统申请互斥量(mutex)。从用户态到内核态(也成为上下文切换)是相当消耗资源的,所以在synchronized优化之前,它的性能远不如ReentrantLock。因为ReentrantLock是基础JAVA实现的,加锁和释放锁的过程并没有进行上下文切换。
重量级锁的互斥量(mutex)有monitor对象进行监视,在Mark Word中的指针也就是指向这个monitor对象。
monitor对象是由C++实现的,现在我们 就来看看它是如何实现的吧:

ObjectMonitor类

// initialize the monitor, exception the semaphore, all other fields
  // are simple integers or pointers
  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 ;
    _previous_owner_tid = 0;
  }

ObjectMonitor中有两个队列_EntryList和_WaitSet,这两个队列用来存放ObjectWaiter对象,每一个请求锁的线程都会被封装成ObjectWaiter对象进入_EntryList队列中。
当_EntryList中的某一个对象申请到锁时,ObjectMonitor中的_owner将指向持有锁的线程。与此同时,_count+1。
而_WaitSet队列则是在线程调用wait()方法之后,当前线程释放持有的锁,_owner置空,_count-1。并进入_WaitSet队列中,变成阻塞状态,等待notify()或者notifyAll()的唤醒。

ObjectWaiter类

class ObjectWaiter : public StackObj {
 public:
  enum TStates { TS_UNDEF, TS_READY, TS_RUN, TS_WAIT, TS_ENTER, TS_CXQ } ;
  enum Sorted  { PREPEND, APPEND, SORTED } ;
  ObjectWaiter * volatile _next;
  ObjectWaiter * volatile _prev;
  Thread*       _thread;
  jlong         _notifier_tid;
  ParkEvent *   _event;
  volatile int  _notified ;
  volatile TStates TState ;
  Sorted        _Sorted ;           // List placement disposition
  bool          _active ;           // Contention monitoring is enabled
 public:
  ObjectWaiter(Thread* thread);

  void wait_reenter_begin(ObjectMonitor *mon);
  void wait_reenter_end(ObjectMonitor *mon);
};

最后我们在来看看ObjectMonitor的加锁实现吧

int ObjectMonitor::TryLock (Thread * Self) {
   for (;;) {
      void * own = _owner ;
      if (own != NULL) return 0 ;
      if (Atomic::cmpxchg_ptr (Self, &_owner, NULL) == NULL) {
         // Either guarantee _recursions == 0 or set _recursions = 0.
         assert (_recursions == 0, "invariant") ;
         assert (_owner == Self, "invariant") ;
         // CONSIDER: set or assert that OwnerIsThread == 1
         return 1 ;
      }
      // The lock had been free momentarily, but we lost the race to the lock.
      // Interference -- the CAS failed.
      // We can either return -1 or retry.
      // Retry doesn't make as much sense because the lock was just acquired.
      if (true) return -1 ;
   }
}

Atomic::cmpxchg_ptr (Self, &_owner, NULL)
我们可以看到加锁的核心就在于cmpxchg_ptr,而cmpxchg_ptr则是进行了CAS(Compare and Swap)操作。也就是说synchronized在最底层其实也是使用了CAS的方法进行加锁。

最后,JDK6之后,虚拟机还进行了各种锁优化技术。
自旋锁和自适应自旋锁
当线程申请的锁是重量级锁时,等待的线程将会处于阻塞状态,挂起线程和恢复线程的操作都需要转入内核态完成,这些操作带来了不小的资源开销。而实际上加锁进行共享数据操作的时间可能很短暂,为此进行线程的挂起和唤醒是不值得的。在多核CPU的情景下,可以让其他线程“稍等片刻”,进行自旋操作(一个空操作的忙循环)这就是所谓的自旋锁。
但是长时间的自旋操作会白白消耗处理器资源,所以自旋锁一般自旋字数为10次,可以通过-XX:PreBlockSpin修改次数。

锁粗化
如果存在一系列的操作都对同一个对象反复加锁和解锁,甚至加锁操作是出现在循环体之中的,那即使没有线程竞争,频繁的进行互斥同步操作也会导致不必要的性能损耗。
如果虚拟机探测到了有这样的一串零碎的操作都对同一个对象加锁,将会把加锁同步的范围扩展(粗化)到整个操作序列的外部。
锁清除
虚拟机即使编译器在运行时,对一些代码要求同步,但是对被检测到不可能存在共享数据竞争的锁进行消除。锁消除的主要判断依据来源于逃逸分析的数据支持,如果判断到一段代码中,在堆上的所有数据都不会逃逸出去被其他线程访问到,那就可以把他们当作栈上的数据对待,认为它们是线程私有的,同步加锁自然就无需进行。

本文至此,相信读者也对synchronized的实现有了一定的了解了吧。

文章参考自《深入理解Java虚拟机》

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值