Java并发基础八:深入理解Synchronized

前言

synchronized,是Java中用于解决并发情况下数据同步访问的一个很重要的关键字。当我们想要保证一个共享资源在同一时间只会被一个线程访问到时,我们可以在代码中使用synchronized关键字对类或者对象加锁。在JDK1.6之前,synchronized的实现直接调用ObjectMonitorenterexit,这种锁被称之为重量级锁(用户态和内核态切换)。JDK 1.6之后进行了一个重要改进,HotSpot虚拟机开发团队对Java中的锁进行优化,如适应性自旋、锁消除、锁粗化、轻量级锁和偏向锁等,提高了性能。那么,本文来介绍一下synchronized关键字的实现原理是什么。

1.synchronized使用

synchronized是Java提供的一个并发控制的关键字。主要有两种用法,分别是同步方法和同步代码块。也就是说,synchronized既可以修饰方法也可以修饰代码块。代码如下:被synchronized修饰的代码块及方法,在同一时间,只能被单个线程访问。

public class SynchronizedDemo {
     //同步方法
    public synchronized void doSth(){
        System.out.println("Hello World");
    }

    //同步代码块
    public void doSth1(){
        synchronized (SynchronizedDemo.class){
            System.out.println("Hello World");
        }
    }
}

2.synchronized的实现原理

synchronized 是JVM实现的一种锁,其依赖于JVM底层实现,当一个线程访问同步代码块时,首先是需要得到锁才能执行同步代码,当退出或者抛出异常时必须要释放锁。Synchronized的语义底层是通过一个monitor的对象来完成,我们先来使用Javap来反编译以上代码,结果如下(部分无用信息过滤掉了):

public synchronized void doSth();
    descriptor: ()V
    flags: ACC_PUBLIC, ACC_SYNCHRONIZED
    Code:
      stack=2, locals=1, args_size=1
         0: getstatic     #2                  // Field java/lang/System.out:Ljava/io/PrintStream;
         3: ldc           #3                  // String Hello World
         5: invokevirtual #4                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
         8: return

  public void doSth1();
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
      stack=2, locals=3, args_size=1
         0: ldc           #5                  // class com/hollis/SynchronizedTest
         2: dup
         3: astore_1
         4: monitorenter
         5: getstatic     #2                  // Field java/lang/System.out:Ljava/io/PrintStream;
         8: ldc           #3                  // String Hello World
        10: invokevirtual #4                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
        13: aload_1
        14: monitorexit
        15: goto          23
        18: astore_2
        19: aload_1
        20: monitorexit
        21: aload_2
        22: athrow
        23: return

反编译后,我们可以看到Java编译器为我们生成的字节码。在对于doSthdoSth1的处理上稍有不同。也就是说。JVM对于同步方法和同步代码块的处理方式不同。

实现原理:

1、对于同步方法,JVM采用ACC_SYNCHRONIZED标记符来实现同步。

当方法调用时,调用指令将会检查方法的 ACC_SYNCHRONIZED 访问标志是否被设置,如果设置了,执行线程将先获取monitor,获取成功之后才能执行方法体,方法执行完后再释放monitor。在方法执行期间,其他任何线程都无法再获得同一个monitor对象。当方法调用时,调用指令将会检查方法的 ACC_SYNCHRONIZED 访问标志是否被设置,如果设置了,执行线程将先获取monitor,获取成功之后才能执行方法体,方法执行完后再释放monitor。在方法执行期间,其他任何线程都无法再获得同一个monitor对象。

2、对于同步代码块。JVM采用monitorentermonitorexit两个指令来实现同步。

当执行同步代码块时,线程执行monitorenter指令时尝试获取monitor的所有权(即锁定状态),获取成功后才能执行代码,执行完同步代码后,线程调用monitorexit进行释放锁,其他被这个monitor阻塞的线程可以尝试去获取这个 monitor 的所有权。

执行过程:每个对象维护着一个记录着被锁次数的计数器。未被锁定的对象的该计数器为0,当一个线程获得锁后,该计数器自增变为 1 ,当同一个线程再次获得该对象的锁的时候,计数器再次自增。当同一个线程释放锁的时候,计数器再自减。当计数器为0的时候。锁将被释放,其他线程便可以获得锁。

无论是ACC_SYNCHRONIZED还是monitorentermonitorexit都是基于Monitor实现的,在Java虚拟机(HotSpot)中,Monitor是基于C++实现的,由ObjectMonitor实现。ObjectMonitor类中提供了几个方法,如enterexitwaitnotifynotifyAll等。sychronized加锁的时候,会调用objectMonitor的enter方法,解锁的时候会调用exit方法。

3.Java对象头

在JVM中,对象在内存中的布局分为三块区域:对象头、实例数据和对齐填充。如下图所示:

  • 实例数据:存放类的属性数据信息,包括父类的属性信息;
  • 对齐填充:由于虚拟机要求 对象起始地址必须是8字节的整数倍。填充数据不是必须存在的,仅仅是为了字节对齐;
  • 对象头

    Hotspot虚拟机的对象头主要包括两部分数据:Mark Word(标记字段)、Class Pointer(类型指针)。其中 Class Pointer是对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例,Mark Word用于存储对象自身的运行时数据,它是实现轻量级锁和偏向锁的关键

Mark Word用于存储对象自身的运行时数据,如:哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程 ID、偏向时间戳等。下图是Java对象头 无锁状态下Mark Word部分的存储结构(32位虚拟机):


synchronized实现的锁是存储在Java对象头里.

对象头的最后两位存储了锁的标志位,01是初始状态,未加锁,其对象头里存储的是对象本身的哈希码,随着锁级别的不同,对象头里会存储不同的内容。偏向锁存储的是当前占用此对象的线程ID而轻量级则存储指向线程栈中锁记录的指针。从这里我们可以看到,“锁”这个东西,可能是个锁记录+对象头里的引用指针(判断线程是否拥有锁时将线程的锁记录地址和对象头里的指针地址比较),也可能是对象头里的线程ID(判断线程是否拥有锁时将线程的ID和对象头里存储的线程ID比较)。

Mark Word里面存储的数据会随着锁标志位的变化而变化,Mark Word可能变化为存储以下5种情况:

锁标志位的表示意义:

  • 锁标识lock=00标识轻量级锁
  • 锁标识lock=10标识重量级锁
  • 偏向锁标识biased_lock=1标识偏向锁
  • 偏向锁标识biased_lock=0标识无锁状态

综上所述,synchronized(lock)中的lock可以用Java中任何一个对象来表示,而锁标识的存储实际上就是在lock这个对象中的对象头内。

其实前面只提到了锁标志位的存储,但是为什么任意一个Java对象都能成为锁对象呢?
 Java中的每个对象都派生自Object类,而每个Java Object在JVM内部都有一个native的C++对象oop/oopDesc进行对应。其次,线程在获取锁的时候,实际上就是获得一个监视器对象(monitor),monitor可以认为是一个同步对象,所有的Java对象是天生携带monitor。

oop/oopDesc解读:

每一个Java类,在被JVM加载的时候,JVM会给这个类创建一个instanceKlass,保存在方法区,用来在JVM层表示该Java类。当我们在Java代码中,使用new创建一个对象的时候,JVM会创建一个instanceOopDesc对象,这个对象中包含了对象头以及实例数据。

监视器(Monitor)解读:

任何一个对象都有一个Monitor与之关联,当且一个Monitor被持有后,它将处于锁定状态。Synchronized在JVM里的实现都是 基于进入和退出Monitor对象来实现方法同步和代码块同步,虽然具体实现细节不一样,但是都可以通过成对的MonitorEnter和MonitorExit指令来实现。

  • MonitorEnter指令:插入在同步代码块的开始位置,当代码执行到该指令时,将会尝试获取该对象Monitor的所有权,即尝试获得该对象的锁;
  • MonitorExit指令:插入在方法结束处和异常处,JVM保证每个MonitorEnter必须有对应的MonitorExit;

4.锁优化与升级

在JDK1.6之前,synchronized是一个重量级锁,性能比较差。在JDK1.6之后,为了减少获得锁和释放锁带来的性能消耗,synchronized进行了优化,引入了自旋锁、锁消除、锁粗化、偏向锁轻量级的概念。

概念解读:

①重量级锁

Synchronized是通过对象内部的一个叫做 监视器锁(Monitor)来实现的但是监视器锁本质又是依赖于底层的操作系统的Mutex Lock来实现的。而操作系统实现线程之间的切换这就需要从用户态转换到核心态,这个成本非常高,状态之间的转换需要相对比较长的时间,因此,这种依赖于操作系统Mutex Lock所实现的锁我们称之为 “重量级锁”

为什么重量级锁的开销比较大呢?

原因是当系统检查到是重量级锁之后,会把等待想要获取锁的线程阻塞,被阻塞的线程不会消耗CPU,但是阻塞或者唤醒一个线程,都需要通过操作系统来实现,也就是相当于从用户态转化到内核态,而转化状态是需要消耗时间的。

②偏向锁

Hotspot的作者经过研究发现,大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,为了让线程获得锁的代价更低而引入了偏向锁。偏向锁的意思是:如果一个线程获得了一个偏向锁,如果在接下来的一段时间中没有其他线程来竞争锁,那么持有偏向锁的线程再次进入或者退出同一个同步代码块,不需要再次进行抢占锁和释放锁的操作。

③轻量级锁

 当存在超过一个线程在竞争同一个同步代码块时,会发生偏向锁的撤销,而撤销偏向锁的时候就是当锁对象不适合作为偏向锁的时候会被升级为轻量级锁,JVM同样使用了CAS + 自旋的方式来实现轻量级锁。

轻量级锁加锁

  • 线程在执行同步代码块之前,JVM会先在当前线程的栈帧中创建用于存储锁记录的空间,并将对象头中的Mark Word复制到锁记录中(Displaced Mark Word - 即被取代的Mark Word)做一份拷贝
  • 拷贝成功后,线程尝试使用CAS将对象头的Mark Word替换为指向锁记录的指针(将对象头的Mark Word更新为指向锁记录的指针,并将锁记录里的Owner指针指向Object Mark Word)
  • 如果更新成功,当前线程获得轻量级锁,继续执行同步方法
  • 如果更新失败,表示其他线程竞争锁,当前线程便尝试使用自旋(CAS)来获取锁当自旋超过指定次数(可以自定义)时仍然无法获得锁,此时锁会膨胀升级为重量级锁

轻量级锁解锁

  • 尝试CAS操作将锁记录中的Mark Word替换会对象头中
  • 如果成功,表示没有竞争发生
  • 如果失败,表示当前锁存在竞争,锁会膨胀成重量级锁

一旦锁升级成重量级锁,就不会再恢复到轻量级锁状态。当锁处于重量级锁状态,其他线程尝试获取锁时,都会被阻塞,也就是 BLOCKED状态。当持有锁的线程释放锁之后会唤醒这些线程,被唤醒之后的线程会进行新一轮的竞争


④自旋锁

自旋锁顾名思义,就是循环尝试去获取锁。想进办法避免线程进入内核的阻塞状态是我们去分析和理解锁设计的关键钥匙。从轻量级锁获取的流程中我们知道,当线程在获取轻量级锁的过程中执行CAS操作失败时,是要通过自旋来获取重量级锁的。问题在于,自旋是需要消耗CPU的,如果一直获取不到锁的话,那该线程就一直处在自旋状态,白白浪费CPU资源。其中解决这个问题最简单的办法就是指定自旋的次数。

⑤锁粗化

锁粗化的概念应该比较好理解,就是将多次连接在一起的加锁、解锁操作合并为一次,将多个连续的锁扩展成一个范围更大的锁。

举个例子:

public class StringBufferTest {    StringBuffer stringBuffer = new StringBuffer();    public void append(){        stringBuffer.append("a");        stringBuffer.append("b");        stringBuffer.append("c");    }}

这里每次调用stringBuffer.append方法都需要加锁和解锁,如果虚拟机检测到有一系列连串的对同一个对象加锁和解锁操作,就会将其合并成一次范围更大的加锁和解锁操作,即在第一次append方法时进行加锁,最后一次append方法结束后进行解锁。

⑥ 锁消除​​​​​​​

锁消除即删除不必要的加锁操作。根据代码逃逸技术,如果判断到一段代码中,堆上的数据不会逃逸出当前线程,那么可以认为这段代码是线程安全的,不必要加锁,通俗讲:JVM认为这段代码是安全的,即使加锁了,也会进行消除操作。

 

文章参考

https://www.jianshu.com/p/e62fa839aa41

https://www.jianshu.com/p/f9b1159d4fde

https://mp.weixin.qq.com/s/5bp9nJaRPIqvIQ9O1gDxkA

https://mp.weixin.qq.com/s/tI_4nCIg1kkcf6_UW1aA5A

 


 

 

 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章