synchronized关键字

前言


在并发中一个常见的关键字就是synchronized,但是在jdk1.5(包含jdk1.5)之前synchronized是一个重量级锁,就显得十分笨重,所以慢慢地摒弃了它,但是在jdk1.6之后,对synchronized进行了各种优化, 它就显得不那么笨重,下面一起来探讨一下synchronized的基本使用方法,底层原理实现等等知识。

一、简单的用法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public class test {
synchronized public static void testMethod1() {
}

public void testMethod2() {
synchronized (test.class) {
}
}

synchronized public void testMethod3() {
}

public void testMethod4() {
synchronized (this) {
}
}

public void testMethod5() {
synchronized ("abc") {
}
}
}

上面的代码中基本涵盖了synchronized的各种写法,出现了三种类型的锁对象(注意:synchronized锁的是对象):

1.testMethod1()和testMethod2()持有的锁是同一个,即test.java对应的Class类的对象

2.testMethod3()和testMethod4持有的锁是同一个,即test.java类的对象

3.testMethod5()持有的锁是字符串abc

说明:testMethod1()和testMethod2()是同步关系,也就是竞争的是同一把锁,也就是要完整执行完testMethod1()或者完整执行完testMethod2()后才会执行另外一个。同样的,testMethod3()和testMethod4()也是同步关系。1和3、2和3、1和2之间是异步关系。

总结来说。a.对于普通同步的方法,锁是当前实例的对象。b.对于静态同步方法,锁是当前类的Class对象。c.对于同步方法块,锁是synchronized括号里配置的对象。

二、同步原理

数据同步需要锁,那么锁的同步是如何实现的?从软件层面来说,是依赖于JVM。从JVM规范中可以看到Synchonized在JVM里的实现原理,JVM基于进入和退出Monitor对象来实现方法同步和代码块同步,但是两者的实现细节不一样。代码块同步是使用monitorenter和monitorexit指令实现的,而方法同步是使用另外一种方式实现的,细节在JVM规范里没有详细说明,但是,方法的同步同样可以使用这两个指令来实现。

monitorenter指令是在编译后插入到同步代码块的开始位置,而monitorexit是插入到方法结束处和异常处,JVM要保证每个monitorenter必须要有对应的monitorexit与之配对。任何对象都有一个monitor与之关联,当且一个monitor被持有后,它将处于锁定状态。线程执行到monitorenter指令时,将会尝试获取对象所对应的monitor的所有权,即尝试获得对象的锁。这边举个例子说明。

1. 同步代码块

有这么一段java文件,首先将其编译成class文件,然后反编译class文件。

1
2
3
4
5
6
7
public class demo1 {
public void method(){
synchronized (this) {
System.out.println("Hello world");
}
}
}

编译成class文件并反编译class文件

反编译Class文件

可以清楚的看到,第3和13、19的位置分别是monitorenter和monitorexit,中间是其同步的代码段。具体来解释通过monitorenter和monitorexit如何来达到同步的效果。

1.monitorenter:每个对象都是一个监视器锁(monitor)。当monitor被占用时就会处于锁定状态,线程执行monitorenter指令时尝试获取monitor的所有权,过程如下:

  1. 如果monitor的进入数为0,则该线程进入monitor,然后将进入数设置为1,该线程即为monitor的所有者;
  2. 如果线程已经占有该monitor,只是重新进入,则进入monitor的进入数加1;
  3. 如果其他线程已经占用了monitor,则该线程进入阻塞状态,直到monitor的进入数为0,再重新尝试获取monitor的所有权。

2.monitorexit:执行monitorexit的线程必须是objectref所对应的monitor的所有者。指令执行时,monitor的进入数减1,如果减1后进入数为0,那线程退出monitor,不再是这个monitor的所有者。其他被这个monitor阻塞的线程可以尝试去获取这个 monitor 的所有权。

monitorexit指令出现了两次,第1次为同步正常退出释放锁;第2次为发生异常退出释放锁

2. 同步方法

继续修改上述的代码段

1
2
3
4
5
public class demo1 {
public synchronized void method(){
System.out.println("Hello world");
}
}

查看反编译的结果

反编译class2

我们可以看到上图,方法的同步不通过monitorenter和monitorexit这两个指令来完成。

反编译class3

这边其实常量池中多了 ACC_SYNCHRONIZED 标示符。JVM就是根据该标示符来实现方法的同步的。也就是上图的flags处。

当方法调用时,调用指令将会检查方法的 ACC_SYNCHRONIZED 访问标志是否被设置,如果设置了,执行线程将先获取monitor,获取成功之后才能执行方法体,方法执行完后再释放monitor。在方法执行期间,其他任何线程都无法再获得同一个monitor对象。

3.总结

两种同步方式本质上没有区别,只是方法的同步是一种隐式的方式来实现,无需通过字节码来完成。两个指令的执行是JVM通过调用操作系统的互斥原语mutex来实现,被阻塞的线程会被挂起、等待重新调度,会导致“用户态和内核态”两个态之间来回切换,对性能有较大影响。

三、同步概念

1.对象在内存中的分配

在JVM中,对象在内存中是如何分配的呢?这个问题在之前虚拟机的相关博客中可以参考。简单的说,对象在内存中可以分为三个部分,对象头、实例数据和对齐填充。synchronized使用的锁就是存在Java对象头里的,Hotspot虚拟机的对象头主要分成两部分,一个是Mark Word,另一个是类型指针,其中Mark Word是实现轻量级锁和偏向锁的关键。

JVM中对象的存储

其中下图是在无锁状态下Mark Word部分的存储结构(32位虚拟机)。

Mark Word存储结构

Mark Word被设计成一个非固定的数据结构以便在极小的空间内存储尽量多的数据,所以Mark Word会随着程序的运行发生变化,可能变化存储为以下四种结构。

Mark Word2

当在64位虚拟机的情况下,Mark Word是这样的

Mark Word 64

2.锁的升级与对比

jdk1.6为了减少获得锁和释放锁带来的性能消耗,引入了“偏向锁”和“轻量级锁”,在jdk1.6中,锁一共有4中状态,级别从低到高依次是:无锁状态、偏向锁状态、轻量级锁状态和重量级锁状态,这几个状态会随着竞争情况逐渐升级。锁可以升级,但不能降级,这样子主要是为了提高获得锁和释放锁的效率。并且从jdk1.6开始,就对synchronized的实现机制进行了较大调整,包括使用JDK5引进的CAS自旋之外,还增加了自适应的CAS自旋、锁消除、锁粗化、偏向锁、轻量级锁这些优化策略。性能获得了极大的提高,jdk1.6中默认是开启偏向锁和轻量级锁的。

在介绍偏向锁、轻量级锁和重量级锁之前,先介绍锁的基本操作。

自旋锁

线程的阻塞和唤醒需要CPU从用户态转化为核心态,频繁的阻塞和唤醒对CPU来说是一件负担很重的工作,而对象锁的锁状态只会持续很短一段时间,为了这段时间而频繁的阻塞和唤醒线程就非常不值得,所以要引入自旋锁的概念,就是当一个线程在尝试获得某个锁时,如果该锁已经被其他线程占用,就一直循环检测锁是否被释放,而不是进入线程挂起或者睡眠状态。虽然它可以避免线程切换带来的开销,但是它占用了CPU处理器的时间。如果持有锁的线程很快就释放了锁,那么自旋的效率就非常好,反之,自旋的线程就会白白消耗掉处理的资源,它不会做任何有意义的工作,典型的占着茅坑不拉屎,这样反而会带来性能上的浪费。所以说,自旋等待的时间(自旋的次数)必须要有一个限度,如果自旋超过了定义的时间仍然没有获取到锁,则应该被挂起,可以通过参数-XX:PreBlockSpin来调整自旋锁的自旋次数。

但是在实际上,很多线程都是在结束的时候就释放了锁,而不用白白多自旋多次,所以在jdk1.6中又引入了自适应的自旋锁。

适应性自旋锁

适应性自旋锁的自旋次数不再是固定的,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。具体是这样的:线程如果自旋成功了,那么下次自旋的次数会更加多,因为虚拟机认为既然上次成功了,那么此次自旋也很有可能会再次成功,那么它就会允许自旋等待持续的次数更多。反之,如果对于某个锁,很少有自旋能够成功,那么在以后要或者这个锁的时候自旋的次数会减少甚至省略掉自旋过程,以免浪费处理器资源。

锁消除

锁消除也是一种锁优化的方式。为了保证数据的完整性,我们通常会对这部分的操作进行加锁处理,但是在有些情况下是不存在共享数据的竞争的,所以也没有必要加锁,就需要锁消除(锁消除的依据是逃逸分析的数据支持)。其实在我们写代码的过程中,对哪些地方加锁,哪些不需要锁是明确的,但是在一些JDK的内置API中会存在隐形的加锁操作,比如StringBuffer、Vector、HashTable等,当检测到变量没有逃逸的情况下,就可以大胆的进行锁消除操作。

举个例子,在StringBuffer的append()函数源码实现是这样子的

1
2
3
4
5
6
>@Override
>public synchronized StringBuffer append(String str) {
toStringCache = null;
super.append(str);
return this;
>}

可以看到,这边是用synchronized来确保线程安全的。但是在一些情况下是不需要加锁的,比如StringBuffer是内部变量情况下。例子如下:

1
2
3
4
5
6
7
8
>public class test {
public static String getString(String s1, String s2) {
StringBuffer sb = new StringBuffer();
sb.append(s1);
sb.append(s2);
return sb.toString();
}
>}

getString()方法中的StringBuffer对象是函数的局部变量,仅作用于方法内部,不会逃逸出该方法,所以是没有必要加锁的,StringBuffer每次append()的时候都会进行锁的申请,从而浪费了大量的时间,在进行锁消除后大大优化了执行效率。下面用一个小实验来验证锁消除与否的执行效率。

1.逃逸分析和锁消除分别可以使用参数-XX:+DoEscapeAnalysis和-XX:+EliminateLocks(锁消除必须在-server模式下)开启。

1
>-XX:+DoEscapeAnalysis -XX:+EliminateLocks

2.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
>public class demo2 {
public void test(String str1, String str2){
StringBuffer sb = new StringBuffer();
sb.append(str1);
sb.append(str2);
}
public static void main(String[] args) {
demo2 d = new demo2();
long startTime = System.currentTimeMillis();
for (int i = 0 ; i < 1000000 ; ++i) {
d.test("HelloHelloHelloHelloHelloHello", "WorldWorldWorldWorldWorldWorldWorldWorld");
}
System.out.println("消耗时间为:" + String.valueOf(System.currentTimeMillis()-startTime) + "ms");
}
>}

在没有开启锁消除的情况下运行多次得到的平均时间大概是89ms,而开启了锁消除后,运行时间大概为79ms左右,可以看出开启锁消除之后性能得到了提高。

锁粗化

在使用同步锁的时候,需要让同步块的作用范围尽可能小一些,仅在共享数据的实际作用域中才进行同步,这样做的目的很简单,让线程占用锁的时间尽可能的短,这样另外线程获取这个锁所等待的时间也就短。但是,如果存在一系列的加锁操作,可能会导致不必要的性能损耗,所以引入锁粗化的概念(将多个连续的加锁、解锁操作连接在一起,扩展成一个更大范围的锁)。

比如有这么一段代码。每次进入循环都要进行锁的请求与释放,但是jdk内部会对它进行锁粗化的优化。

1
2
3
4
>for(int i=0;i<size;i++){
synchronized(lock){
}
>}

优化的结果是这样的

1
2
3
4
>synchronized(lock){
for(int i=0;i<size;i++){
}
>}

2.1偏向锁

大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,为了让线程获得锁的代价更低而引入了偏向锁。所以引入偏向锁主要目的是:为了在没有多线程竞争的情况下尽量减少不必要的轻量级锁执行路径。在JDK5中偏向锁默认是关闭的,而到了JDK6中偏向锁已经默认开启。

偏向锁的获得

当一个线程访问同步块并获取锁时,会在对象头和栈帧中的锁记录里存储锁偏向的线程ID,以后该线程进入和退出同步块时不需要花费CAS操作来争夺锁资源,只需要检查是否为偏向锁、锁标识为以及ThreadID即可,处理流程如下:

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

偏向锁竞争流程

偏向锁的撤销

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

1.暂停拥有偏向锁的线程。

2.检查持有偏向锁的线程是否活着,如果线程不处于活动状态,则将对象头设置为无锁状态,允许其余线程竞争;如果线程还活着,则挂起持有锁的当前线程,并将指向当前线程的锁记录地址的指针放入对象头Mark Word,升级为轻量级锁状态(00),然后恢复持有锁的当前线程,进入轻量级锁的竞争模式;

可能上述的解释有些抽象,我的理解是:首先一个前提是,偏向锁在执行完同步代码块的之后并不会释放锁。例如线程A第一次执行完同步代码块后,当线程B尝试获取锁的时候,发现是偏向锁,这时候需要判断线程A是否还活着。如果线程A还活着,就将线程A暂停,此时偏向锁升级为轻量级锁,之后线程A继续执行,线程B自旋。如果线程A不存在了,则线程B竞争得到锁,获得这个偏向锁,锁不升级。

整个过程用流程图来表示(线程1代表偏向锁的获得,线程2代表偏向锁的撤销)

偏向锁获得和撤销

2.2轻量级锁

多个线程在不同时间段请求同一把锁,也就是基本不存在锁竞争。针对此种情况,JVM采用轻量级锁来避免线程的阻塞以及唤醒。

轻量级锁加锁

线程在执行同步代码块之前,JVM先在当前线程的栈帧中创建用于存储锁记录的空间,并将对象头的mark word字段直接复制到此空间中。然后线程尝试使用CAS将对象头的mark word替换为指向锁记录的指针(指当前线程),如果成功表示获取到轻量级锁。如果失败,表示其他线程竞争轻量级锁,当前线程便使用自旋来不断尝试,当自旋结束时,还不能获得锁,则膨胀为重量级锁。

轻量级锁解锁

解锁时,会使用CAS将复制的mark word替换回对象头,如果成功,表示没有竞争发生,正常解锁,进入无锁状态。如果失败,表示当前锁存在竞争,进一步膨胀为重量级锁,在释放锁的同时,唤醒被挂起的线程。

整个流程图是这样的

轻量级锁过程

总结一下

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

2.3重量级锁

内置锁在Java中被抽象为监视器锁(monitor)。在JDK 1.6之前,监视器锁可以认为直接对应底层操作系统中的互斥量(mutex)。这种同步方式的成本非常高,包括系统调用引起的内核态与用户态切换、线程阻塞造成的线程切换等。因此,后来称这种锁为“重量级锁”。

四、总结

各种锁并不是相互代替的,而是在不同场景下的不同选择,绝对不是说重量级锁就是不合适的。

  1. 如果是单线程使用,那偏向锁毫无疑问代价最小,并且它就能解决问题,连CAS都不用做,仅仅在内存中比较下对象头就可以了;
  2. 如果出现了其他线程竞争,则偏向锁就会升级为轻量级锁;
  3. 如果其他线程通过一定次数的CAS尝试没有成功,则进入重量级锁;

锁的优缺点

参考:《Java并发编程的艺术》

https://www.jianshu.com/p/e62fa839aa41