深入并发关键字-synchronized

仅供学习交流,如有错误请指出,如要转载请加上出处,谢谢

前言

synchronized(同步)是java中在多处理器中实现线程安全最基本的手段,在java语言规范(第三版)中提到锁的同步机制,指在java中,线程之间通信的机制最基本的就是同步化,此方法是使用监视器(monitor,后面会讲到)实现的,每个对象与一个监视器关联,一个线程可以加锁和解锁此监视器,而且同一时间段只有一个线程持有监视器上的锁,其他线程就会被阻塞,直到他们可以在该监视器上获取锁

其实一个对象都可以看做一个锁,在java中,有三种对于synchronized的用法

  • 对于同步方法,锁是当前实例对象。
  • 对于静态同步方法,锁是当前对象的Class对象。
  • 对于同步方法块,锁是Synchonized括号里配置的对象。

当一个线程访问synchronized修饰的代码块时,他必须要获取一个锁,根据不同的修饰方式来获取所对应的对象的锁,退出或者是发生异常时释放锁,下面将一步一步深入锁的实现

锁的实现原理

我们先将通过一个简单的同步代码开始

1
2
3
4
5
6
7
8
public class SynchronizedTest {
public static Object object = new Object();
public static void main(String[] args){
synchronized(object) {
// synchronized test
}
}
}

上述代码main函数中获取了object的锁,我们用javap命令工具反编译该生成的class文件,信息如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
Compiled from "SynchronizedTest.java"
public class SynchronizedTest {
public static java.lang.Object object;
public SynchronizedTest();
Code:
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: aload_0
5: iconst_0
6: putfield #2 // Field a:I
9: return
public static void main(java.lang.String[]);
Code:
0: getstatic #3 // Field object:Ljava/lang/Object;
3: dup
4: astore_1
5: monitorenter
6: aload_1
7: monitorexit
8: goto 16
11: astore_2
12: aload_1
13: monitorexit
14: aload_2
15: athrow
16: return
Exception table:
from to target type
6 8 11 any
11 14 11 any
static {};
Code:
0: new #4 // class java/lang/Object
3: dup
4: invokespecial #1 // Method java/lang/Object."<init>":()V
7: putstatic #3 // Field object:Ljava/lang/Object;
10: return
}

由上述反编译class文件的执行代码可知,在main函数中执行synchronized代码块使用了monitorenter和monitorexit两个字节码指令,JVM通过monitorenter字节码指令来获取对象的锁,通过monitorexit字节码指令来释放该对象的锁,当执行monitorenter字节码指令时,首先会尝试获取对象的锁,如果该对象没有没有被锁定或者当前线程已经拥有了该锁,则锁的计数器加1,相应的执行monitorexit字节码指令释放锁的时候会减1,当计数器为0表示对象没有锁定,如果一个线程获取锁失败时,那当前线程就必须被阻塞等待,直到对象锁被另一对象释放,下面我们通过介绍对象头和monitor以及JVM对锁的优化措施进一步了解如何获取对象的锁和释放对象的锁。

对象头

在HotSpot虚拟机中,对象的内存布局分为三部分:对象头,实例数据和对齐填充,其中对象头是对象的内存布局中很重要的部分,他分为两个部分的信息,第一个部分存储的是对象本身运行时的数据,比如哈希码,GC分代年龄等,空间大小根据32位和64位的虚拟机分别为32bit和64bit,这还有另一个官方称号叫做“Mark Word“,他是实现轻量级锁和偏向锁的关键,这个后面会讲到,另一个部分用于存储指向方法区对象类型数据的指针,如果存储的是对象数组的话,还有一部分空间存储该数组的长度,默认的存储结构如下所示(32位虚拟机为例):


考虑到虚拟机的空间效率,Mark Word被设计成一个非固定的数据结构,以便在极小的空间内存储更多的信息,他会根据不同的状态复用自己的存储空间,如下所示

注意:其中偏向锁和轻量级锁这两个锁状态是JDK1.6之后引入的对锁的优化,之后会介绍

monitor

monitor是线程私有的数据结构,每一个线程都有一个可用的monitor record列表,同时还有一个全局的可用列表,每一个锁住的对象都会和一个monitor关联,下面是monitor的结构组成:

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

当一个线程进入同步代码块时,该代码块的同步对象通过Mark Work中的LockWord指向monitor的起始地址来关联monitor,由monitor来获取锁和释放锁

由于在java中,synchronized是一个重量级的锁,在多处理器并发中,效率总是不尽人意,JVM团队认为还有很大的改进空间,所以进行了锁的一系列的优化,下面将介绍锁的优化措施

偏向锁

偏向锁是JDK1.6引入的一项锁优化,他的目的是消除在无竞争的情况下的同步原语,进一步提高程序的运行性能,说白了就是将同步的操作都消除掉,下面介绍如何获取锁和释放锁

获取锁

当一个线程访问同步代码块时,会执行monitorenter字节码指令,对象会在Mark Word利用CAS操作记录关联的线程ID标识,操作成功就将标志位置为“01”,即偏向锁状态,再标记是否是偏向锁置为“1”,这样以后该线程,再次进入或者退出同步块时不需要其他的同步操作

释放锁

如果有另一个线程尝试获取这个锁,偏向锁就会失效,这时会等待全局安全点(在这个时间点上没有字节码正在执行)撤销偏向锁状态到无锁状态(标志位“01”)或者膨胀到轻量级锁状态(标志位“00”),把是否是偏向锁置为0,它会首先暂停拥有偏向锁的线程,然后检查持有偏向锁的线程是否活着,如果线程不处于活动状态,则将对象头设置成无锁状态,如果线程仍然活着,拥有偏向锁的栈会被执行,遍历偏向对象的锁记录,栈中的锁记录和对象头的Mark Word要么重新偏向于其他线程,要么恢复到无锁或者标记对象不适合作为偏向锁,最后唤醒暂停的线程。

偏向锁在Java 6和Java 7里是默认启用的,但是它在应用程序启动几秒钟之后才激活,如有必要可以使用JVM参数来关闭延迟-XX:BiasedLockingStartupDelay = 0。如果你确定自己应用程序里所有的锁通常情况下处于竞争状态,可以通过JVM参数关闭偏向锁-XX:-UseBiasedLocking=false,那么默认会进入轻量级锁状态。

轻量级锁

轻量级锁也是JDK1.6之中加入的新型锁机制,他的作用是在没有多线程的竞争下,减少传统的重量级锁使用操作系统互斥量产生的性能消耗

获取锁

当线程进入代码块时,该同步块的对象没有被锁定,当前线程会在自己的栈内创建一片空间来存储锁记录,然后再将Mark Word复制到锁记录中(官方在复制时会加一个Displaced前缀,就是Displaced Mark Word),然后尝试用CAS操作将Mark Word指向该线程的栈帧中的锁记录地址,如果成功了就会拥有该对象的锁,锁的标志位就变成了“00”,此时属于轻量级锁的状态,如果更新失败的话,说明当前对象存在竞争,那轻量级锁就会失效,膨胀成重量级锁,锁的标志位就变成了“10”,Mark Word指向的就是重量级锁的指针,后面的线程就会进程阻塞

释放锁

轻量级锁释放锁是通过CAS操作将当前的Mark Word和当前线程的栈帧中的锁记录替换回去,如果成功的话,表示访问完整个同步块了,如果失败的话,表示有竞争出现,那就要马上放弃该锁,唤醒被挂起的线程
在多核处理器的并发情况下,锁的状态会因为竞争的关系而变化,然而对于锁的状态会随着膨胀升级,从最开始的无锁,到偏向锁,再到轻量级锁,最后是重量级锁,锁只能是升级,不能降级,下面是三种锁状态的优缺点比较

JVM的团队在锁的优化下了很大的功夫,其实还有包括自旋锁和自适应自旋锁:以消耗CPU的代价换取加锁解锁的消耗,锁消除:最小的粒度消除加锁解锁的消耗,锁粗化:用一次加锁解锁的消耗来替代多次的加锁解锁的消耗,Synchronized的性能也越来越好,在合理的情况下使用不会比concurrent包下的lock机制性能差。

参考

http://ifeve.com/java-synchronized/

周志明《深入理解java虚拟机》第十三章

http://developer.51cto.com/art/201702/532564.htm