【并发编程笔记】 ---- 深入分析Synchronized以及锁升级案例

文章目录

1. 实现原理
2. Monitor
3. 锁的优化
4. 锁的升级
5. 锁升级案例分析

1. 实现原理

Synchronized可以保证方法或者代码块在运行时,同一时刻只有一个方法可以进入到临界区,同时它还可以保证共享变量的内存可见性
Synchronized作用范围

  1. 普通同步方法,锁是当前实例对象
  2. 静态同步方法,锁是当前类的class对象
    Class的相关数据存储在永久戴PermGen(jdk1.8则是metaspace),永久带是全局共享的,因此静态方法锁相当于类的一个全局锁,会锁所有调用该方法的线程
  3. 同步方法块,锁是括号里面的对象

当一个线程访问同步代码块时,它首先是需要得到锁才能执行同步代码,当退出或者抛出异常时必须释放锁,如下所示:

public class SynchronizedTest {
     
	public synchronized void test1(){
     

	}

	public void test2(){
     
		synchronized (this) {
     

		}
	}
}

利用Javap工具查看生成的class文件信息来分析synchronized的实现
【并发编程笔记】 ---- 深入分析Synchronized以及锁升级案例_第1张图片
从上面可以看出:

  • 同步代码块是使用monitorentermonitorexit指令实现的
  • 同步方法(在这里看不出需要看JVM底层实现)依靠的是方法修饰上的ACC_SYNCHRONIZED实现
  • 同步代码块: monitorenter指令插入到同步代码块的开始位置,monitorexit指令插入到同步代码块的结束位置,JVM需要保证每一个monitorenter都有一个monitorexit与之相对应。任何对象都有一个Monitor与之相关联,当且一个Monitor被持有之后,它将处于锁定状态。线程执行到monitorenter指令时,将会尝试获取对象所对应的Monitor所有权,即尝试获取对象的锁

2. Monitor

可以把它理解成一个同步工具,也可以描述为一种同步机制,它通常被描述为一个对象。

所有的Java对象是天生的Monitor,每一个Java对象都有成为Monitor的潜质,自带了一把看不见的锁,它叫做内部锁或者Monitor锁

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

JDK对Synchronized做的种种优化,其核心都是为了减少这种重量级锁的使用,JDK1.6以后,为了减少获得锁和释放锁所带来的性能消耗,提高性能,引入了自旋锁、锁消除、锁细化、 “轻量级锁” 和 **“偏向锁”**等技术来减少锁操作的开销

synchronized实现过程

  • java代码: synchronized
  • monitorenter monitorexit
  • 执行过程中自动升级
  • lock comxchg

3. 锁的优化

3.1 自旋锁

由来
线程的阻塞和唤醒,需要CPU从用户态转为核心态。频繁的阻塞和唤醒对CPU来说是一件负担很重的工作,给系统的并发性带来了很大的压力。在许多应用上面,对象锁的锁状态只会持续很短一段时间。为了这一段很短的时间,频繁地阻塞和唤醒线程是非常不值得地。所以引入了自旋锁。

定义
所谓自旋锁,就是让线程等待一段时间,不会被立即挂起,看持有锁的线程是否很快释放锁,如果等待?执行一段无意义的循环即可(自旋)

自旋锁需要消耗CPU的,说白了就是让CPU做无用功,如果一直获取不到锁,那线程也不能一直占用cpu自旋做无用功,所以需要设定一个自旋等待的最大时间(自旋的次数)必须有一个限度,如果自旋超过了定义的时间仍然没有获取到锁,则应该被挂起。

3.1.1 自旋锁的优缺点

优点:
自旋锁尽可能地减少线程的阻塞,这对于锁的竞争不激烈,且占用锁时间非常短的代码块来说性能得到大幅度的提升,因为自旋的消耗小于线程阻塞挂起再唤醒的操作的消耗,这些操作会导致线程发生两次上下文切换,因为线程的阻塞和唤醒,需要CPU从用户态转为核心态

缺点:
如果锁的竞争激烈,或者持有锁的线程长时间占用锁执行同步块,这时候就不适合自旋锁了,因为自旋锁在获取锁前一直都是占用cpu做无用功,同时有大量线程在竞争一个锁,会导致获取锁的时间很长,线程自旋的消耗大于线程阻塞挂起操作的消耗,其它需要CPU的线程又不能获取到CPU,造成CPU的浪费。

3.2 适应自旋锁

JDK1.6后引入的锁

所谓自适应就意味着自旋的次数不再是固定的,它是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定

  • 线程如果自旋成功了,那么下次自旋的次数会更加多,因为虚拟机认为既然上次成功了,那么此次自旋也很有可能会再次成功,那么它就会允许自旋等待持续的次数更多。
  • 反之,如果对于某个锁,很少有自旋能够成功的,那么在以后要或者这个锁的时候自旋的次数会减少甚至省略掉自旋过程,以免浪费处理器资源。

3.3 锁消除

由来
为了保证数据的完整性,我们在进行操作时需要对这部分操作进行同步控制。但是,在有些情况下,JVM检测到不可能存在共享数据竞争,这是JVM会对这些同步锁进行锁消除。如果不存在竞争,为什么还需要加锁呢?所以锁消除可以节省毫无意义的请求锁的时间。

定义
锁消除的依据是逃逸分析的数据支持。变量是否逃逸,对于虚拟机来说需要使用数据流分析来确定,但是对于我们程序员来说这还不清楚么?我们会在明明知道不存在数据竞争的代码块前加上同步吗?但是有时候程序并不是我们所想的那样?我们虽然没有显示使用锁,但是我们在使用一些 JDK 的内置 API 时,如 StringBuffer、Vector、HashTable 等,这个时候会存在隐性的加锁操作。比如 StringBuffer 的 #append(..)方法,Vector 的 add(...) 方法:

public void vectorTest(){
     
    Vector<String> vector = new Vector<String>();
    for (int i = 0 ; i < 10 ; i++){
     
    	vector.add(i + "");
    }
    System.out.println(vector);
}

在运行这段代码时,JVM 可以明显检测到变量 vector 没有逃逸出方法 #vectorTest() 之外,所以 JVM 可以大胆地将 vector 内部的加锁操作消除。

public void add(String str1, String str2) {
     
		StringBuffer sb = new StringBuffer();
		sb.append(str1).append(str2);
}

我们都知道 StringBuffer 是线程安全的,因为它的关键方法都是被 synchronized 修饰过的,但我们看上面这段代码,我们会发现,sb 这个引用只会在 add 方法中使用,不可能被其它线程引用(因为是局部变量,栈私有),因此 sb 是不可能共享的资源,JVM 会自动消除 StringBuffer 对象内部的锁。

3.4 锁粗化

由来
我们知道在使用同步锁的时候,需要让同步块的作用范围尽可能小:仅在共享数据的实际作用域中才进行同步。这样做的目的,是为了使需要同步的操作数量尽可能缩小,如果存在锁竞争,那么等待锁的线程也能尽快拿到锁。

定义
将多个连续的加锁、解锁操作连接在一起,扩展成一个范围更大的锁。

public String test(String str){
     
       
       int i = 0;
       StringBuffer sb = new StringBuffer():
       while(i < 100){
     
           sb.append(str);
           i++;
       }
       return sb.toString():
}

JVM 会检测到这样一连串的操作都对同一个对象加锁(while 循环内 100 次执行 append,没有锁粗化的就要进行 100 次加锁/解锁),此时 JVM 就会将加锁的范围粗化到这一连串的操作的外部(比如 while 虚幻体外),使得这一连串操作只需要加一次锁即可。

4. 锁的升级

锁主要存在四种状态,依次是:无锁状态、偏向锁状态、轻量级锁状态、重量级锁状态。它们会随着竞争的激烈而逐渐升级。注意,锁可以升级不可降级,这种策略是为了提高获得锁和释放锁的效率。

无锁-偏向锁-轻量级锁-重量级锁, 这种升级过程称为锁膨胀

4.1 偏向锁

为了在无多线程竞争的情况下,尽量减少不必要的轻量级锁执行路径

轻量级锁的获取及释放依赖多次CAS原子指令,而偏向锁只需要在置换ThreadID的时候依赖一次CAS原子指令

Mark Word的数据结构:线程ID、Epoch(偏向锁的时间戳)、对象分代年龄、是否是偏向锁(1)、锁标识位(01),如下图64位系统所示
在这里插入图片描述

获取偏向锁步骤如下:

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

撤销偏向锁:

偏向锁的释放采用了一种只有竞争才会释放锁的机制,线程是不会主动去释放偏向锁,需要等待其他线程来竞争。

偏向锁的撤销需要等待全局安全点(这个时间点是上没有正在执行的代码)。其步骤如下:

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

最后唤醒暂停的线程

偏向锁的获取和释放流程图
【并发编程笔记】 ---- 深入分析Synchronized以及锁升级案例_第2张图片

关闭偏向锁

偏向锁在 JDK 1.6 以上,默认开启。开启后程序启动几秒后才会被激活,可使用 JVM 参数
-XX:BiasedLockingStartupDelay = 0 来关闭延迟。

如果确定锁通常处于竞争状态,则可通过JVM参数 -XX:-UseBiasedLocking=false 关闭偏向锁,那么默认会进入轻量级锁。

4.2 轻量级锁

在没有多线程竞争的前提下,减少传统的重量级锁使用产生的性能消耗。

轻量级锁所适应的场景是线程交替执行同步块的情况,如果同一时间访问同一锁的情况,就会导致轻量级锁膨胀为重量级锁

获取锁的步骤如下:

  1. 判断当前对象是否处于无锁状态?若是,则 JVM 首先将在当前线程的栈帧中,建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的 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 操作替换失败,说明有其他线程尝试获取该锁,则需要在释放锁的同时需要唤醒被挂起的线程。

【并发编程笔记】 ---- 深入分析Synchronized以及锁升级案例_第3张图片

注意事项

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

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

5. 锁升级案例分析

5.1 查看JVM默认参数配置信息
【并发编程笔记】 ---- 深入分析Synchronized以及锁升级案例_第4张图片
5.2 代码演示

public class T01 {
     
	public static void main(String[] args){
     
		Object o = new Object();
		System.out.println(ClassLayout.parseInstance(o).toPrintable());
	}
}
锁状态 偏向锁位 锁标志位
无锁态(new) 0 01
偏向锁 1 01
轻量级锁(自旋锁) 00
重量级锁 10

在这里插入图片描述
问: JVM不是默认打开偏向锁嘛?只有一个线程,那么不应该一开始就上偏向锁嘛?
答: JVM默认4秒后开启偏向锁,所以一开始没有上锁,属于无锁状态~
JVM虚拟机自己有一些默认启动的线程,里面有好多sync代码,这些sync代码启动时就知道肯定会有竞争,如果使用偏向锁,就会造成偏向锁不断的进行锁撤销和锁升级的操作,效率较低。

所以可以设置 -XX:BiasedLockingStartupDelay=0 或者 让主线程先休眠5秒后开启

在这里插入图片描述

5.3 锁升级过程
无锁状态->轻量级锁

public class T01 {
     
	public static void main(String[] args) throws InterruptedException {
     

		Object o = new Object();
		// 无锁状态
		System.out.println(ClassLayout.parseInstance(o).toPrintable());

		// 升级为轻量级锁	
		synchronized (o) {
     
			System.out.println(ClassLayout.parseInstance(o).toPrintable());
		}
	}
}

【并发编程笔记】 ---- 深入分析Synchronized以及锁升级案例_第5张图片

匿名偏向锁->偏向锁

public class T01 {
     
	public static void main(String[] args) throws InterruptedException {
     

		TimeUnit.SECONDS.sleep(5);

		Object o = new Object();
		// 匿名偏向锁状态
		System.out.println(ClassLayout.parseInstance(o).toPrintable());

		// 偏向锁
		synchronized (o) {
     
			System.out.println(ClassLayout.parseInstance(o).toPrintable());
		}
	}
}

【并发编程笔记】 ---- 深入分析Synchronized以及锁升级案例_第6张图片
总结
无锁 - 偏向锁 - 轻量级锁 (自旋锁,自适应自旋)- 重量级锁

synchronized优化的过程和markword息息相关

用markword中最低的三位代表锁状态 其中1位是偏向锁位 两位是普通锁位

  1. Object o = new Object()
    锁 = 0 01 无锁态

  2. 默认synchronized(o)
    00 -> 轻量级锁
    默认情况 偏向锁有个时延,默认是4秒

  3. 如果设定 -XX:BiasedLockingStartupDelay=0 参数
    new Object () - > 101 偏向锁 ->线程ID为0 -> Anonymous BiasedLock
    打开偏向锁,new出来的对象,默认就是一个可偏向匿名对象101

  4. 如果有线程上锁
    上偏向锁,指的就是,把markword的线程ID改为自己线程ID的过程
    偏向锁不可重偏向 批量偏向 批量撤销

  5. 如果有线程竞争
    撤销偏向锁,升级轻量级锁
    线程在自己的线程栈生成LockRecord ,用CAS操作将markword设置为指向自己这个线程的LR的指针,设置成功者得到锁

  6. 如果竞争加剧
    竞争加剧:有线程超过10次自旋, -XX:PreBlockSpin, 或者自旋线程数超过CPU核数的一半, 1.6之后,加入自适应自旋 Adapative Self Spinning , JVM自己控制
    升级重量级锁:-> 向操作系统申请资源,linux mutex , CPU从3级-0级系统调用,线程挂起,进入等待队列,等待操作系统的调度,然后再映射回用户空间

【并发编程笔记】 ---- 深入分析Synchronized以及锁升级案例_第7张图片
偏向锁 轻量级锁 重量级锁之间的转换关系图
【并发编程笔记】 ---- 深入分析Synchronized以及锁升级案例_第8张图片

参考

芋道源码

你可能感兴趣的:(并发)