Java并发编程之synchronized底层实现原理分析
作者:寒山道杳
一、为什么出现synchronized
对于程序员而言,不管是在平常的工作中还是面试中,都会经常用到或者被问到synchronized。在多线程并发编程中,synchronized早已是元老级的角色了,很多人都称其为重量级锁,但是随着Java SE 1.6对其进行各种优化之后,便显得不再是那么的重了。
也正是因为多线程并发的出现,便产生了线程安全这样的问题,对于线程安全的主要原因如下:
- 存在共享数据(也称临界资源)
- 存在多条线程共同操作这些共享数据
而对于解决这样的一个问题的办法是:同一时刻有且只有一条线程在操作共享数据,其他线程必须等待该线程处理完数据后再对共享数据进行操作
此时便产生了互斥锁,互斥锁的特性如下:
- 互斥性:即在同一时刻只允许一个线程持有某个对象锁,通过这种特性来实现多线程协调机制,这样在同一时刻只有一个线程对所需要的同步的代码块(复合操作)进行访问。互斥性也成为了操作的原子性。
- 可见性:必须确保在锁释放之前,对共享变量所做的修改,对于随后获得该锁的另一个线程可见(即在获得锁时应获得最新共享变量的值),否则另一个线程可能是在本地缓存的某个副本上继续操作,从而引起数据不一致。
对于Java而言,synchronized关键字满足了以上的要求。
二、实现原理
首先我们要知道synchronized锁的不是代码,锁的是对象。
根据获取的锁的分类:获取对象锁和获取类锁:
获取对像锁的两种方法
- 1.同步代码块(synchronized(this),synchronized(类实例对象)),锁是小括号()的实例对象
- 2.同步非静态方法(synchronized method),锁是当前对象是实例对象
获取类锁的两种方法
- 1.同步代码块(synchronized(类.class)),锁是小括号()中的类对象(Class对象)
- 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 }
同步代码块:从上面的字节码中可以看出,同步语句块的实现是使用monitorenter和monitorexit指令的,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锁有四种状态分别为:无锁、偏向锁、轻量级锁、重量级锁
锁的膨胀方向:无锁——>偏向锁——>轻量级锁——>重量级锁
偏向锁
作用:减少同一线程获取锁的代价
引入偏向锁是因为大多数情况下,锁并不存在多线程竞争,总是由同一线程多次获得
获取锁
- 检测Mark Word是否为可偏向状态,即是否为偏向锁1,锁标识位为01;
- 若为可偏向状态,则测试线程ID是否为当前线程ID,如果是,则执行步骤(5),否则执行步骤(3);
- 如果线程ID不为当前线程ID,则通过CAS操作竞争锁,竞争成功,则将Mark Word的线程ID替换为当前线程ID,否则执行线程(4);
- 通过CAS竞争锁失败,证明当前存在多线程竞争情况,当到达全局安全点,获得偏向锁的线程被挂起,偏向锁升级为轻量级锁,然后被阻塞在安全点的线程继续往下执行同步代码块;
- 执行同步代码块
释放锁
偏向锁的释放采用了一种只有竞争才会释放锁的机制,线程是不会主动去释放偏向锁,需要等待其他线程来竞争。偏向锁的撤销需要等待全局安全点(这个时间点是上没有正在执行的代码)。
其步骤如下:
- 暂停拥有偏向锁的线程,判断锁对象石是否还处于被锁定状态;
- 撤销偏向苏,恢复到无锁状态(01)或者轻量级锁的状态;
轻量级锁
获取锁
- 判断当前对象是否处于无锁状态(hashcode、0、01),若是,则JVM首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的Mark Word的拷贝(官方把这份拷贝加了一个
- Displaced前缀,即Displaced Mark Word);否则执行步骤(3);
- JVM利用CAS操作尝试将对象的Mark Word更新为指向Lock Record的指正,如果成功表示竞争到锁,则将锁标志位变成00(表示此对象处于轻量级锁状态),执行同步操作;如果失败则执行步骤(3);
- 判断当前对象的Mark Word是否指向当前线程的栈帧,如果是则表示当前线程已经持有当前对象的锁,则直接执行同步代码块;否则只能说明该锁对象已经被其他线程抢占了,这时轻量级锁需要膨胀为重量级锁,锁
- 标志位变成10,后面等待的线程将会进入阻塞状态;
释放锁
轻量级锁的释放也是通过CAS操作来进行的,主要步骤如下:
- 取出在获取轻量级锁保存在Displaced Mark Word中的数据;
- 用CAS操作将取出的数据替换当前对象的Mark Word中,如果成功,则说明释放锁成功,否则执行(3);
- 如果CAS操作替换失败,说明有其他线程尝试获取该锁,则需要在释放锁的同时需要唤醒被挂起的线程。
对于轻量级锁,其性能提升的依据是“对于绝大部分的锁,在整个生命周期内都是不会存在竞争的”,如果打破这个依据则除了互斥的开销外,还有额外的CAS操作,因此在有多线程竞争的情况下,轻量级锁比重量级锁更慢;
重量级锁
重量级锁通过对象内部的监视器(monitor)实现,其中monitor的本质是依赖于底层操作系统的Mutex Lock实现,操作系统实现线程之间的切换需要从用户态到内核态的切换,切换成本非常高。
总结
以上为个人经验,希望能给大家一个参考,也希望大家多多支持脚本之家。