Java并发编程之synchronized底层实现原理

 

一、为什么出现synchronized

对于程序员而言,不管是在平常的工作中还是面试中,都会经常用到或者被问到synchronized。在多线程并发编程中,synchronized早已是元老级的角色了,很多人都称其为重量级锁,但是随着Java SE 1.6对其进行各种优化之后,便显得不再是那么的重了。

也正是因为多线程并发的出现,便产生了线程安全这样的问题,对于线程安全的主要原因如下:

  • 存在共享数据(也称临界资源)
  • 存在多条线程共同操作这些共享数据

而对于解决这样的一个问题的办法是:同一时刻有且只有一条线程在操作共享数据,其他线程必须等待该线程处理完数据后再对共享数据进行操作

此时便产生了互斥锁,互斥锁的特性如下:

  • 互斥性:即在同一时刻只允许一个线程持有某个对象锁,通过这种特性来实现多线程协调机制,这样在同一时刻只有一个线程对所需要的同步的代码块(复合操作)进行访问。互斥性也成为了操作的原子性。
  • 可见性:必须确保在锁释放之前,对共享变量所做的修改,对于随后获得该锁的另一个线程可见(即在获得锁时应获得最新共享变量的值),否则另一个线程可能是在本地缓存的某个副本上继续操作,从而引起数据不一致。

对于Java而言,synchronized关键字满足了以上的要求。


二、实现原理

首先我们要知道synchronized锁的不是代码,锁的是对象。

根据获取的锁的分类:获取对象锁和获取类锁:

  • 获取对像锁的两种方法
  1. 1.同步代码块(synchronized(this),synchronized(类实例对象)),锁是小括号()的实例对象

  2. 2.同步非静态方法(synchronized method),锁是当前对象是实例对象

  • 获取类锁的两种方法
  1. 1.同步代码块(synchronized(类.class)),锁是小括号()中的类对象(Class对象)

  2. 2.同步静态方法(synchronized static method),锁是当前对象的类对象(Class对象)

对象锁和类锁的总结:

  • 有线程访问对象的同步代码块时,另外的线程可以访问该兑对象的非同步代码块
  • 若锁住的是同一个对象,一个线程在访问对象的 同步代码块时,另一个访问对象的同步代码块的线程会被阻塞
  • 若锁住的是同一个对象,一个线程在访问对象的 同步方法时,另一个访问对象的同步方法的线程会被阻塞
  • 若锁住的是同一个对象,一个线程在访问对象的 同步代码块时,另一个访问对象的同步方法的线程会被阻塞
  • 同一个类的不同对象的对象锁互不干扰
  • 类锁由于是一种特殊的对象锁,因此表现和上述1,2,3,4一致,由于一个只有一把对象锁,所以同一个类的不同对象使用类锁,将是同步的
  • 类锁和对象锁互不干扰

当一个线程试图访问同步代码块时,它首先必须得到锁,退出或者抛出异常时必须释放锁,那么锁存在哪里呢?我们先来看一段代码:

public class SyncBlockTest {
    public void syncsTask() {
        synchronized (this) {
            System.out.println("Hello");
        }
    }

    public synchronized void syncTask() {
        System.out.println("Hello Baby");
    }
}

在使用javac工具把上面代码变异成class,然后使用javap工具查看编译好的class文件,如下:

  public com.interview.javabasic.thread.SyncBlockTest();
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
      stack=1, locals=1, args_size=1
         0: aload_0
         1: invokespecial #1                  // Method java/lang/Object."<init>":()V
         4: return
      LineNumberTable:
        line 8: 0

  public void syncsTask();
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
      stack=2, locals=3, args_size=1
         0: aload_0
         1: dup
         2: astore_1
         3: monitorenter
         4: getstatic     #2                  // Field java/lang/System.out:Ljava/io/PrintStream;
         7: ldc           #3                  // String Hello
         9: invokevirtual #4                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
        12: aload_1
        13: monitorexit
        14: goto          22
        17: astore_2
        18: aload_1
        19: monitorexit
        20: aload_2
        21: athrow
        22: return
      Exception table:
         from    to  target type
             4    14    17   any
            17    20    17   any
      LineNumberTable:
        line 10: 0
        line 11: 4
        line 12: 12
        line 13: 22
      StackMapTable: number_of_entries = 2
        frame_type = 255 /* full_frame */
          offset_delta = 17
          locals = [ class com/interview/javabasic/thread/SyncBlockTest, class java/lang/Object ]
          stack = [ class java/lang/Throwable ]
        frame_type = 250 /* chop */
          offset_delta = 4

  public synchronized void syncTask();
    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           #5                  // String Hello Baby
         5: invokevirtual #4                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
         8: return
      LineNumberTable:
        line 16: 0
        line 17: 8
}

同步代码块:从上面的字节码中可以看出,同步语句块的实现是使用monitorentermonitorexit指令的,monitorenter指向同步代码块的开始位置,它首先去获取PrintStream这个类,然后传入“Hello”这个参数,然后再调用PrintStream中的println()方法去打印,monitorexit指明同步代码块的结束位置,当执行到monitorenter时,当前线程将试图获取对象锁所对应的monitor的持有权。

同步方法:从上面代码的syncTask()方法字节码中看,这里面并没monitorenter和monitorexit,且字节码较短,其实这里方法的同步是隐式的,是无需通过字节码指令控制,在上面可以看到一个“ACC_SYNCHRONIZED”这样的一个访问标志,用来区分一个方法是否是同步方法。当方法调用时,调用指令将会检查ACC_SYNCHRONIZED是否被设置,如果被设置,当前线程将会持有monitor,然后再执行方法,最后不管方法是否正常完成都会释放monitor。


三、实现synchronized的基础

Java对象头和monitor是实现synchronized的基础,下面将会说说关于Java对象头和monitor。

Java对象头:

hotspot虚拟机中,对象在内存的布局分布分为3个部分:对象头,实例数据,和对齐填充。

对象头的结构如下:

虚拟机位数 头对象结构 说明
32/64 bit Mark Word 默认存储对象的hashCode,分代年龄,锁类型,锁标志位等信息
32/64 bit Class Metadata 类型指针指向对象的类元数据,JVM通过这个指针确定该对象是哪个类型的数据
32/64 bit Array length

数组的长度(如果当前的对象是数组

mark word 被设计为非固定的数据结构,以便在极小的空间内存储更多的信息,它会根据对象的状态复用自己的存储空间。例如,在32位的Hotspot虚拟机中,如果对象处于未被锁定的情况下。那么Mark Word 的32bit空间中有25bit用于存储对象的哈希码、4bit用于存储对象的分代年龄、2bi用于t存储锁的标记位、1bit固定为0,而在其他的状态下(轻量级锁定、重量级锁定、GC标记、可偏向)下对象的存储结构如下:

存储内容 标志位 状态
对象哈希吗、对象分代年龄 01 未锁定
指向锁记录的指针 00 轻量级锁定
指向重量级锁的指针 10 膨胀(重量级锁定)
空,不需要记录信息 11 GC标记
偏向线程ID、偏向时间戳、对象分代年龄 01 可偏向

monitor:

每个Java对象天生就自带了一把看不见的锁,它可以视为是一种同步工具或者是一种同步机制,monitor还是线程私有的数据结构,每一个线程都有一个可用monitor 列表,同时还有一个全局的可用列表,如上面所说每一个被锁住的对象都会持有一个monitor。monitor结构如下:

  • Owner:初始时为NULL表示当前没有任何线程拥有该monitor record,当线程成功拥有该锁后保存线程唯一标识,当锁被释放时又设置为NULL;
  • EntryQ:关联一个系统互斥锁(semaphore),阻塞所有试图锁住monitor record失败的线程。
  • RcThis:表示blocked或waiting在该monitor record上的所有线程的个数。
  • Nest:用来实现重入锁的计数。
  • HashCode:保存从对象头拷贝过来的HashCode值(可能还包含GC age)。
  • Candidate:用来避免不必要的阻塞或等待线程唤醒,因为每一次只有一个线程能够成功拥有锁,如果每次前一个释放锁的线程唤醒所有正在阻塞或等待的线程,会引起不必要的上下文切换(从阻塞到就绪然后因为竞争锁失败又被阻塞)从而导致性能严重下降。Candidate只有两种可能的值0表示没有需要唤醒的线程1表示要唤醒一个继任线程来竞争锁。

四、锁优化

Java6以后,对锁进行了大量的优化,例如:AdaptiveSpinning(自适应自旋)、Lock Eliminate(锁消除)、Lock Coarsening(锁粗化)、Lightweight Locking(轻量级锁)、Biased Locking(偏向锁)等等

自旋锁

  • 许多情况下,共享数据的锁定状态持续时间较短,切换线程不值得
  • 通过让线程执行忙循环等待锁的释放,不让出CPU
  • 缺点:若锁被其他线程长时间占用,会带来许多性能上的开销

定义:所谓的自旋锁就是让没有获取到锁的线程继续等待一会儿,但不放弃CPU的执行时间,这是的等一会和不放弃CPU的时间即是自旋锁

自适应自旋锁

  • 自旋的次数不再固定
  • 由前一次在同一锁上的自旋时间及锁拥有者的状态来决定

锁消除

锁消除是对锁更彻底的优化,JIT编译时,对运行上下文进行扫描,去除不可能存在竞争的锁

public class StringBufferWithoutSync {
    public void add(String str1, String str2) {
        //StringBuffer是线程安全,由于sb只会在append方法中使用,不可能被其他线程引用
        //因此sb属于不可能共享的资源,JVM会自动消除内部的锁
        StringBuffer sb = new StringBuffer();
        sb.append(str1).append(str2);
    }

    public static void main(String[] args) {
        StringBufferWithoutSync withoutSync = new StringBufferWithoutSync();
        for (int i = 0; i < 1000; i++) {
            withoutSync.add("aaa", "bbb");
        }
    }

}

例如Java中的StringBuffer 它是线程安全的,由于其append()方法只会在内部使用的,也就不可能被其他线程引用,因此对于这个变量sb而言,便不属于共享资源了,JVM会自动消除内部的锁。

锁粗化

原则上我们都知道,在加同步锁的时候,尽可能将同步块的作用范围先知道尽量小的范围,及只在共享数据的实际工作范围中进行同步,这样是为了需要同步的数量尽可能的变小,在存在锁同步竞争中,使得等待锁的时间减小。

上述情况,大部分时候是正确的,但是如果存在一系列频繁的操作,对同一个对象反复的加锁、解锁, 甚至加锁时是在循环体中操作的,这样即使没有线程竞争,频繁的进行互斥锁操作,也是导致不必要的性能开销。

对于解决这样的问题,我们只有尽可能的扩大加锁的范围,例如下面的循环100次append,JVM会自己检测到这样的一个问题,就会将加锁的次数减至一次。

    public static String copyString(String target){
        int i = 0;
        StringBuffer sb = new StringBuffer();
        while (i<100){
            sb.append(target);
        }
        return sb.toString();
    }

五、synchronized锁的状态

synchronized锁有四种状态分别为:无锁、偏向锁、轻量级锁、重量级锁

锁的膨胀方向:无锁——>偏向锁——>轻量级锁——>重量级锁

偏向锁

作用:减少同一线程获取锁的代价

引入偏向锁是因为大多数情况下,锁并不存在多线程竞争,总是由同一线程多次获得

以下内容引自:【死磕Java并发】—–深入分析synchronized的实现原理

获取锁

  1. 检测Mark Word是否为可偏向状态,即是否为偏向锁1,锁标识位为01;
  2. 若为可偏向状态,则测试线程ID是否为当前线程ID,如果是,则执行步骤(5),否则执行步骤(3);
  3. 如果线程ID不为当前线程ID,则通过CAS操作竞争锁,竞争成功,则将Mark Word的线程ID替换为当前线程ID,否则执行线程(4);
  4. 通过CAS竞争锁失败,证明当前存在多线程竞争情况,当到达全局安全点,获得偏向锁的线程被挂起,偏向锁升级为轻量级锁,然后被阻塞在安全点的线程继续往下执行同步代码块;
  5. 执行同步代码块

释放锁

偏向锁的释放采用了一种只有竞争才会释放锁的机制,线程是不会主动去释放偏向锁,需要等待其他线程来竞争。偏向锁的撤销需要等待全局安全点(这个时间点是上没有正在执行的代码)。其步骤如下:

  1. 暂停拥有偏向锁的线程,判断锁对象石是否还处于被锁定状态;
  2. 撤销偏向苏,恢复到无锁状态(01)或者轻量级锁的状态;

轻量级锁

获取锁

  1. 判断当前对象是否处于无锁状态(hashcode、0、01),若是,则JVM首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的Mark Word的拷贝(官方把这份拷贝加了一个Displaced前缀,即Displaced Mark Word);否则执行步骤(3);
  2. JVM利用CAS操作尝试将对象的Mark Word更新为指向Lock Record的指正,如果成功表示竞争到锁,则将锁标志位变成00(表示此对象处于轻量级锁状态),执行同步操作;如果失败则执行步骤(3);
  3. 判断当前对象的Mark Word是否指向当前线程的栈帧,如果是则表示当前线程已经持有当前对象的锁,则直接执行同步代码块;否则只能说明该锁对象已经被其他线程抢占了,这时轻量级锁需要膨胀为重量级锁,锁标志位变成10,后面等待的线程将会进入阻塞状态;

释放锁

轻量级锁的释放也是通过CAS操作来进行的,主要步骤如下:

  1. 取出在获取轻量级锁保存在Displaced Mark Word中的数据;
  2. 用CAS操作将取出的数据替换当前对象的Mark Word中,如果成功,则说明释放锁成功,否则执行(3);
  3. 如果CAS操作替换失败,说明有其他线程尝试获取该锁,则需要在释放锁的同时需要唤醒被挂起的线程。

对于轻量级锁,其性能提升的依据是“对于绝大部分的锁,在整个生命周期内都是不会存在竞争的”,如果打破这个依据则除了互斥的开销外,还有额外的CAS操作,因此在有多线程竞争的情况下,轻量级锁比重量级锁更慢;

重量级锁

重量级锁通过对象内部的监视器(monitor)实现,其中monitor的本质是依赖于底层操作系统的Mutex Lock实现,操作系统实现线程之间的切换需要从用户态到内核态的切换,切换成本非常高。

六、参考资料

版权声明:尊重博主原创文章,转载请注明出处:https://blog.csdn.net/zfy163520

发布了41 篇原创文章 · 获赞 8 · 访问量 4269

猜你喜欢

转载自blog.csdn.net/zfy163520/article/details/89138218