Java多线程系列 基础篇06 synchronized(同步锁)

时间:2023-03-10 05:46:12
Java多线程系列 基础篇06   synchronized(同步锁)

转载

http://www.cnblogs.com/paddix/ 作者:liuxiaopeng

http://www.infoq.com/cn/articles/java-se-16-synchronized?utm_source=infoq&utm_campaign=user_page&utm_medium=link 方腾飞

一、重量级锁

  上篇文章中向大家介绍了Synchronized的用法及其实现的原理。现在我们应该知道,Synchronized是通过对象内部的一个叫做监视器锁(monitor)来实现的。但是监视器锁本质又是依赖于底层的操作系统的Mutex Lock来实现的。而操作系统实现线程之间的切换这就需要从用户态转换到核心态,这个成本非常高,状态之间的转换需要相对比较长的时间,这就是为什么Synchronized效率低的原因。因此,这种依赖于操作系统Mutex Lock所实现的锁我们称之为“重量级锁”。JDK中对Synchronized做的种种优化,其核心都是为了减少这种重量级锁的使用。JDK1.6以后,为了减少获得锁和释放锁所带来的性能消耗,提高性能,引入了“轻量级锁”和“偏向锁”。

二、轻量级锁

  锁的状态总共有四种:无锁状态、偏向锁、轻量级锁和重量级锁。随着锁的竞争,锁可以从偏向锁升级到轻量级锁,再升级的重量级锁(但是锁的升级是单向的,也就是说只能从低到高升级,不会出现锁的降级)。JDK 1.6中默认是开启偏向锁和轻量级锁的,我们也可以通过-XX:-UseBiasedLocking来禁用偏向锁。锁的状态保存在对象的头文件中,以32位的JDK为例:

锁状态

25 bit

4bit

1bit

2bit

23bit

2bit

是否是偏向锁

锁标志位

轻量级锁

指向栈中锁记录的指针

00

重量级锁

指向互斥量(重量级锁)的指针

10

GC标记

11

偏向锁

线程ID

Epoch

对象分代年龄

1

01

无锁

对象的hashCode

对象分代年龄

0

01

  “轻量级”是相对于使用操作系统互斥量来实现的传统锁而言的。但是,首先需要强调一点的是,轻量级锁并不是用来代替重量级锁的,它的本意是在没有多线程竞争的前提下,减少传统的重量级锁使用产生的性能消耗。在解释轻量级锁的执行过程之前,先明白一点,轻量级锁所适应的场景是线程交替执行同步块的情况,如果存在同一时间访问同一锁的情况,就会导致轻量级锁膨胀为重量级锁。

1、轻量级锁的加锁过程

  (1)在代码进入同步块的时候,如果同步对象锁状态为无锁状态(锁标志位为“01”状态,是否为偏向锁为“0”),虚拟机首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的Mark Word的拷贝,官方称之为 Displaced Mark Word。这时候线程堆栈与对象头的状态如图2.1所示。

  (2)拷贝对象头中的Mark Word复制到锁记录中。

  (3)拷贝成功后,虚拟机将使用CAS操作尝试将对象的Mark Word更新为指向Lock Record的指针,并将Lock record里的owner指针指向object mark word。如果更新成功,则执行步骤(3),否则执行步骤(4)。

  (4)如果这个更新动作成功了,那么这个线程就拥有了该对象的锁,并且对象Mark Word的锁标志位设置为“00”,即表示此对象处于轻量级锁定状态,这时候线程堆栈与对象头的状态如图2.2所示。

  (5)如果这个更新操作失败了,虚拟机首先会检查对象的Mark Word是否指向当前线程的栈帧,如果是就说明当前线程已经拥有了这个对象的锁,那就可以直接进入同步块继续执行。否则说明多个线程竞争锁,轻量级锁就要膨胀为重量级锁,锁标志的状态值变为“10”,Mark Word中存储的就是指向重量级锁(互斥量)的指针,后面等待锁的线程也要进入阻塞状态。 而当前线程便尝试使用自旋来获取锁,自旋就是为了不让线程阻塞,而采用循环去获取锁的过程。

                 图2.1 轻量级锁CAS操作之前堆栈与对象的状态

  

                  图2.2 轻量级锁CAS操作之后堆栈与对象的状态

2、轻量级锁的解锁过程:

  (1)通过CAS操作尝试把线程中复制的Displaced Mark Word对象替换当前的Mark Word。

  (2)如果替换成功,整个同步过程就完成了。

  (3)如果替换失败,说明有其他线程尝试过获取该锁(此时锁已膨胀),那就要在释放锁的同时,唤醒被挂起的线程。

三、偏向锁

  引入偏向锁是为了在无多线程竞争的情况下尽量减少不必要的轻量级锁执行路径,因为轻量级锁的获取及释放依赖多次CAS原子指令,而偏向锁只需要在置换ThreadID的时候依赖一次CAS原子指令(由于一旦出现多线程竞争的情况就必须撤销偏向锁,所以偏向锁的撤销操作的性能损耗必须小于节省下来的CAS原子指令的性能消耗)。上面说过,轻量级锁是为了在线程交替执行同步块时提高性能,而偏向锁则是在只有一个线程执行同步块时进一步提高性能。

1、偏向锁获取过程:

  (1)访问Mark Word中偏向锁的标识是否设置成1,锁标志位是否为01——确认为可偏向状态。

  (2)如果为可偏向状态,则测试线程ID是否指向当前线程,如果是,进入步骤(5),否则进入步骤(3)。

  (3)如果线程ID并未指向当前线程,则通过CAS操作竞争锁。如果竞争成功,则将Mark Word中线程ID设置为当前线程ID,然后执行(5);如果竞争失败,执行(4)。

  (4)如果CAS获取偏向锁失败,则表示有竞争。当到达全局安全点(safepoint)时获得偏向锁的线程被挂起,偏向锁升级为轻量级锁,然后被阻塞在安全点的线程继续往下执行同步代码。

  (5)执行同步代码。

2、偏向锁的释放:

  偏向锁的撤销在上述第四步骤中有提到。偏向锁只有遇到其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁,线程不会主动去释放偏向锁。偏向锁的撤销,需要等待全局安全点(在这个时间点上没有字节码正在执行),它会首先暂停拥有偏向锁的线程,判断锁对象是否处于被锁定状态,撤销偏向锁后恢复到未锁定(标志位为“01”)或轻量级锁(标志位为“00”)的状态。

3、重量级锁、轻量级锁和偏向锁之间转换

                                    图 2.3三者的转换图

  该图主要是对上述内容的总结,如果对上述内容有较好的了解的话,该图应该很容易看懂。

四、其他优化

1、适应性自旋(Adaptive Spinning):从轻量级锁获取的流程中我们知道,当线程在获取轻量级锁的过程中执行CAS操作失败时,是要通过自旋来获取重量级锁的。问题在于,自旋是需要消耗CPU的,如果一直获取不到锁的话,那该线程就一直处在自旋状态,白白浪费CPU资源。解决这个问题最简单的办法就是指定自旋的次数,例如让其循环10次,如果还没获取到锁就进入阻塞状态。但是JDK采用了更聪明的方式——适应性自旋,简单来说就是线程如果自旋成功了,则下次自旋的次数会更多,如果自旋失败了,则自旋的次数就会减少。

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

复制代码

1 package com.paddx.test.string;

2

3 public class StringBufferTest {

4 StringBuffer stringBuffer = new StringBuffer();

5

6 public void append(){

7 stringBuffer.append("a");

8 stringBuffer.append("b");

9 stringBuffer.append("c");

10 }

11 }

复制代码

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

3、锁消除(Lock Elimination):锁消除即删除不必要的加锁操作。根据代码逃逸技术,如果判断到一段代码中,堆上的数据不会逃逸出当前线程,那么可以认为这段代码是线程安全的,不必要加锁。看下面这段程序:

复制代码

1 package com.paddx.test.concurrent;

2

3 public class SynchronizedTest02 {

4

5 public static void main(String[] args) {

6 SynchronizedTest02 test02 = new SynchronizedTest02();

7 //启动预热

8 for (int i = 0; i < 10000; i++) {

9 i++;

10 }

11 long start = System.currentTimeMillis();

12 for (int i = 0; i < 100000000; i++) {

13 test02.append("abc", "def");

14 }

15 System.out.println("Time=" + (System.currentTimeMillis() - start));

16 }

17

18 public void append(String str1, String str2) {

19 StringBuffer sb = new StringBuffer();

20 sb.append(str1).append(str2);

21 }

22 }

复制代码

虽然StringBuffer的append是一个同步方法,但是这段程序中的StringBuffer属于一个局部变量,并且不会从该方法中逃逸出去,所以其实这过程是线程安全的,可以将锁消除。下面是我本地执行的结果:

  为了尽量减少其他因素的影响,这里禁用了偏向锁(-XX:-UseBiasedLocking)。通过上面程序,可以看出消除锁以后性能还是有比较大提升的。

  注:可能JDK各个版本之间执行的结果不尽相同,我这里采用的JDK版本为1.6。

五、总结

  本文重点介绍了JDk中采用轻量级锁和偏向锁等对Synchronized的优化,但是这两种锁也不是完全没缺点的,比如竞争比较激烈的时候,不但无法提升效率,反而会降低效率,因为多了一个锁升级的过程,这个时候就需要通过-XX:-UseBiasedLocking来禁用偏向锁。下面是这几种锁的对比:

优点

缺点

适用场景

偏向锁

加锁和解锁不需要额外的消耗,和执行非同步方法比仅存在纳秒级的差距。

如果线程间存在锁竞争,会带来额外的锁撤销的消耗。

适用于只有一个线程访问同步块场景。

轻量级锁

竞争的线程不会阻塞,提高了程序的响应速度。

如果始终得不到锁竞争的线程使用自旋会消耗CPU。

追求响应时间。

同步块执行速度非常快。

重量级锁

线程竞争不使用自旋,不会消耗CPU。

线程阻塞,响应时间缓慢。

追求吞吐量。

同步块执行速度较长。

1. synchronized原理

 在java中,每一个对象有且仅有一个同步锁。这也意味着,同步锁是依赖于对象而存在。当一个线程访问对象中的同步方法或同步代码块时,会先尝试获取了该对象的同步锁 ,当其他线程已经获得了该对象的同步锁时,会被阻塞。例如,线程A和线程B,它们都会访问“对象obj的同步锁”。在某一时刻,线程A获取到“obj的同步锁”并在执行一些操作;而此时,线程B也尝试获取“obj的同步锁” 时,线程B会获取失败并被阻塞,直到线程A释放了“该对象的同步锁”之后线程B才能获取到“obj的同步锁”从而才可以运行。

2. synchronized的三种使用方式

1.修饰实例方法: 锁是当前实例对象,进入同步代码前要获得当前实例的锁(阻塞访问本实例中所有的synchronized方法 或 synchronized代码块的线程)

2.修饰静态方法: 锁是当前类的Class对象,进入同步代码前要获得当前类对象(类的class对象)的锁(阻塞访问所有的synchronized静态方法的线程)

3.修饰代码块:锁是Synchronized括号里的对象,进入同步代码库前要获得给指对象的锁 (阻塞访问本实例中使用指定对象锁的synchronized代码块的线程)

3. synchronized底层原理

 Java 虚拟机中的同步(Synchronization)基于持有和退出锁(Monitor)对象实现, 无论是同步代码块(有明确的 monitorenter 和 monitorexit 指令),还是同步方法(由方法调用指令读取运行时常量池中方法的 ACC_SYNCHRONIZED 标志)都是如此。

4.Java对象头与对象锁(Monitor)

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

  实例变量:存放类的属性数据信息,包括父类的属性信息,如果是数组的实例部分还包括数组的长度,这部分内存按4字节对齐。

  填充数据:由于虚拟机要求对象起始地址必须是8字节的整数倍。填充数据不是必须存在的,仅仅是为了字节对齐,这点了解即可。

  对象头:它是实现synchronized的锁对象的基础,其主要结构是由Mark Word 和 Class Metadata Address 两部分组成,Mark Word 中存储自身运行时的数据,例如hashCode、分代年龄、锁状态等信息; Class Metadata Address 存放指向方法区类静态数据的指针。

对于Java对象头,锁存在Java对象头里,如果对象是数组类型,则JVM用3个Word(字宽)存储对象头,如果对象是非数组类型,则用2字宽存储对象头。在32位JVM中,一字宽等4字节。

长度 内容 说明

Java对象头里的Mark Word 里默认存储对象的HashCode、分代年龄、锁状态等信息;

锁状态 25bit 4bit 1bit是否是偏向锁 2bit锁标志位
无锁状态 对象的HashCode 对象的分代年龄 0 01

5. Monitor Object 设计模式

 在线程执行中,将每一个被客户端线程并发访问的对象定义为一个monitor对象。客户端线程仅仅通过monitor对象的同步方法才能访问monitor对象定义的服务(例如共享数据等)。为了防止monitor对象的状态陷入竞争条件,在一个时刻只能有一个monitor的同步方法被执行。每一个monitor对象只包含一个monitor锁,被同步方法用于串行访问对象的行为和状态。此外,同步方法可以根据一个或多个与monitor对象相关的monitor conditions来决定在何种环境下挂起或恢复他们的执行。

结构

在一个monitor对象模式中存在四个参与者:

1、monitor对象。一个monitor对象向客户暴露一个或多个接口方法。为了保护monitor对象的内部状态不受任意修改和竞争条件的破坏,所有的客户必须通过这些方法访问monitor对象。因为monitor本身不包含自己的控制线程,所以每个方法在调用它的客户线程上执行。

2、同步方法。同步方法实现线程安全的被monitor对象暴露的服务。为了防止竞争条件,无论是否同时有多个线程并发调用同步方法,还是monitor对象类含有多个同步方法,在一个monitor对象内,在任意时间点只有一个同步方法能够被执行。

3、monitor锁。每一个monitor对象包含自己的monitor锁。同步方法使用这个monitor锁来实现每个对象基础上的方法调用串行化。当方法进入/离开对象时,每个同步方法必须分别的获取/释放monitor锁。这个协议保证无论什时候一个方法访问或修改对象的状态时都应该先获取monitor锁。

4、monitor条件。运行在分离线程上的多个同步方法可以经由monitor条件来相互等待和通知以实现协同地调度它们执行的顺序。同步方法可以使用monitor 条件来决定在何种环境下挂起或恢复他们的执行。

动态特征

在monitor对象模式中,在参与者之间将发生如下的协作过程:

1、同步方法的调用和串行化。当一个客户调用monitor对象的同步方法时,这个方法必须首先获取monitor对象的monitor锁。只要在monitor对象中有其他同步方法正在被执行,获取monitor锁便不会成功。在这种情况下,客户线程将被阻塞直到它获取monitor锁,在这个点上同步方法将获取monitor锁,进入临界区,执行方法实现的服务。一旦同步方法完成执行,monitor锁必须被释放,目的是使其他同步方法可以访问monitor对象。

2、同步方法线程挂起。如果一个同步方法必须被阻塞或是有其他原因不能立刻进行,它能够在一个monitor条件上等待,这将导致同步方法暂时"离开"monitor对象。当一个同步方法离开monitor对象,被同步方法获取的monitor锁将自动被释放,客户调用线程将被挂起在monitor条件上。

3、方法条件通知。一个同步方法能够通知一个monitor条件,目的是为了让一个前期使自己挂起在一个monitor条件上的同步方法线程恢复运行。此外,一个同步方法能够通知所有的前期使自己挂起在一个monitor条件上的同步方法线程。

4、同步方法线程恢复。一旦一个早先被挂起在monitor条件上的同步方法线程获取通知,它将继续在最初的等待monitor条件的点上执行。在被通知线程"重入"monitor对象,恢复执行同步方法之前,monitor锁将自动被获取。

Java多线程系列 基础篇06   synchronized(同步锁)

6. synchronized底层实现

对于下面代码

package concurrent;

/**
* @Description:
* @Author: lizhouwei
* @CreateDate: 2018/5/20 22:38
* @Modify by:
* @ModifyDate:
*/
public class SynDemo {
public Object objlock = new Object(); public synchronized void synMethod() {
System.out.println("synMethod");
} public static synchronized void synStaticMethod() {
System.out.println("synStaticMethod");
} public void synCodeBlock() {
synchronized (this) {
System.out.println("synCodeBlock"); }
} public static void main(String[] args) {
SynDemo synDemo = new SynDemo();
synDemo.synMethod();
synDemo.synStaticMethod();
synDemo.synCodeBlock(); }
}

我们通过反编译查看其字节码


E:\IdeaProjects\myproject\target\classes\concurrent>javap -c SynDemo.class
Compiled from "SynDemo.java"
public class concurrent.SynDemo {
public java.lang.Object objlock; public concurrent.SynDemo();
Code:
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: aload_0
5: new #2 // class java/lang/Object
8: dup
9: invokespecial #1 // Method java/lang/Object."<init>":()V
12: putfield #3 // Field objlock:Ljava/lang/Object;
15: return public synchronized void synMethod();
Code:
0: getstatic #4 // Field java/lang/System.out:Ljava/io/PrintStream;
3: ldc #5 // String synMethod
5: invokevirtual #6 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
8: return public static synchronized void synStaticMethod();
Code:
0: getstatic #4 // Field java/lang/System.out:Ljava/io/PrintStream;
3: ldc #7 // String synStaticMethod
5: invokevirtual #6 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
8: return public void synCodeBlock();
Code:
0: aload_0
1: dup
2: astore_1
3: monitorenter
4: getstatic #4 // Field java/lang/System.out:Ljava/io/PrintStream;
7: ldc #8 // String synCodeBlock
9: invokevirtual #6 // 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 public static void main(java.lang.String[]);
Code:
0: new #9 // class concurrent/SynDemo
3: dup
4: invokespecial #10 // Method "<init>":()V
7: astore_1
8: aload_1
9: invokevirtual #11 // Method synMethod:()V
12: aload_1
13: pop
14: invokestatic #12 // Method synStaticMethod:()V
17: aload_1
18: invokevirtual #13 // Method synCodeBlock:()V
21: return
}

在synCodeBlock方法中我们重点关注 3和13 行

public void synCodeBlock();
. . .
3: monitorenter
. . .
13: monitorexit
. . .

从字节码中可知同步语句块的实现使用的是monitorenter 和 monitorexit 指令。其中monitorenter指令指向同步代码块的开始位置,monitorexit指令则指明同步代码块的结束位置,当执行monitorenter指令时,当前线程将试图获取 objectref(即对象锁) 所对应的 monitor 的持有权,当 objectref 的 monitor 的进入计数器为 0,那线程可以成功取得 monitor,并将计数器值设置为 1,取锁成功。如果当前线程已经拥有 objectref 的 monitor 的持有权,那它可以重入这个 monitor (关于重入性稍后会分析),重入时计数器的值也会加 1。倘若其他线程已经拥有 objectref 的 monitor 的所有权,那当前线程将被阻塞,直到正在执行线程执行完毕,即monitorexit指令被执行,执行线程将释放 monitor(锁)并设置计数器值为0 ,其他线程将有机会持有 monitor 。值得注意的是编译器将会确保无论方法通过何种方式完成,方法中调用过的每条 monitorenter 指令都有执行其对应 monitorexit 指令,而无论这个方法是正常结束还是异常结束。为了保证在方法异常完成时 monitorenter 和 monitorexit 指令依然可以正确配对执行,编译器会自动产生一个异常处理器,这个异常处理器声明可处理所有的异常,它的目的就是用来执行 monitorexit 指令。从字节码中也可以看出多了一个monitorexit指令,它就是异常结束时被执行的释放monitor 的指令。

在synMethod 和 synStaticMethod 方法中我们重点关注 5 行

public synchronized void synMethod();
...
5: invokevirtual #6 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
... public static synchronized void synStaticMethod();
...
5: invokevirtual #6 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
...

从字节码中可以看出,synchronized修饰的方法并没有monitorenter指令和monitorexit指令,取得代之的确实是ACC_SYNCHRONIZED标识,该标识指明了该方法是一个同步方法,JVM通过该ACC_SYNCHRONIZED访问标志来辨别一个方法是否声明为同步方法,从而执行相应的同步调用。这便是synchronized锁在同步代码块和同步方法上实现的基本原理。同时我们还必须注意到的是在Java早期版本中,synchronized属于重量级锁,效率低下,因为监视器锁(monitor)是依赖于底层的操作系统的Mutex Lock来实现的,而操作系统实现线程之间的切换时需要从用户态转换到核心态,这个状态之间的转换需要相对比较长的时间,时间成本相对较高,这也是为什么早期的synchronized效率低的原因。

7.参考

http://docs.huihoo.com/ace_tao/ace_monitor_object.html

https://blog.csdn.net/javazejian/article/details/72828483#t5