Java并发编程系列(二):深入理解synchronized

由单例模式的延伸,再来看一下synchronize的奥秘

synchronized 使用

最开始我们接触Java并发编程的时候,就知道如果想要实现同步,synchronized一直是元老级角色,synchronized是Java中的关键字,是一种同步锁,而Java中的每一个对象都可以作为锁。它修饰的对象有以下几种:

  1. 修饰一个代码块,被修饰的代码块称为同步语句块,其作用的范围是大括号{}括起来的代码,作用的对象是调用这个代码块的对象;
  2. 修饰一个方法,被修饰的方法称为同步方法,其作用的范围是整个方法,作用的对象是调用这个方法的对象;
  3. 修饰一个静态的方法,其作用的范围是整个静态方法,作用的对象是这个类的所有对象;
  4. 修饰一个类,其作用的范围是synchronized后面括号括起来的部分,作用的对象是这个类的所有对象,一般同静态的方法一起考虑。

结合上面的修饰类型它又有如下的特性:

  1. 当修饰一个代码块或者方法时,不同的对象锁(即一个对象的不同实例)的执行是互相不影响的,同一对象锁的多线程只能同步阻塞执行;
  2. 当访问一个对象的非同步方法时,不需要获取对象锁,也即不会被阻塞执行;
  3. 一个静态方法的对象锁是该类的Class对象,因此一个类的不同实例都具有同样的锁对象,对同步方法的执行均需要同步阻塞。 如下所示:

1、当两个线程使用同一个对象锁并发访问时,将会阻塞执行

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
package edu.sevenvoid.jvm.thread;
/**
* @author sevenvoid
*
* 2016年11月17日
*/
public class SynchronizedTest implements Runnable{
private long i = 0;
public synchronized long inCrementWithSynchronizedMethod() {
i++;
System.out.println("The thread name : " + Thread.currentThread().getName() + " i = " + i);
return i;
}
@Override
public void run() {
for(int i=0; i<2; i++) {
inCrementWithSynchronizedMethod();
}
}
public static void main(String arg[]) {
SynchronizedTest synchronizedTest = new SynchronizedTest();
Thread thread1 = new Thread(synchronizedTest);
Thread thread2 = new Thread(synchronizedTest);
thread1.start();
thread2.start();
}
}

输出结果如下所示:表明使用同一个对象锁时,多线程将会同步执行。

1
2
3
4
The thread name : Thread-0  j = 1
The thread name : Thread-0 j = 2
The thread name : Thread-1 j = 3
The thread name : Thread-1 j = 4

2、当两个线程使用不同的实例对象访问时,将互相不受影响,将main方法修改成如下所示:

1
2
3
4
5
6
SynchronizedTest synchronizedTest1 = new SynchronizedTest();
SynchronizedTest synchronizedTest2 = new SynchronizedTest();
Thread thread1 = new Thread(synchronizedTest1);
Thread thread2 = new Thread(synchronizedTest2);
thread1.start();
thread2.start();

输出结果如下所示:表明使用不同对象锁时,多线程将不会同步执行。

1
2
3
4
The thread name : Thread-0  i = 1
The thread name : Thread-0 i = 2
The thread name : Thread-1 i = 1
The thread name : Thread-1 i = 2

对于同步代码块,结论是相同的,区别就在于同步代码块时可以显示的传入一个锁对象,并不一定是当前的类对象。

3、当两个线程并发访问同步的静态方法时,由于锁对象是同一个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
package edu.sevenvoid.jvm.thread;
/**
* @author sevenvoid
*
* 2016年11月17日
*/
public class SynchronizedTest implements Runnable{
private static long j = 0;
public synchronized static long inCrementWithSynchronizedStatic() {
j++;
System.out.println("The thread name : " + Thread.currentThread().getName() + " j = " + j);
return j;
}
@Override
public void run() {
for(int i=0; i<2; i++) {
inCrementWithSynchronizedStatic();
}
}
public static void main(String arg[]) {
SynchronizedTest synchronizedTest1 = new SynchronizedTest();
SynchronizedTest synchronizedTest2 = new SynchronizedTest();
Thread thread1 = new Thread(synchronizedTest1);
Thread thread2 = new Thread(synchronizedTest2);
thread1.start();
thread2.start();
}
}

输出结果如下所示:表明对于同步静态方法,锁对象将会是Class对象,所有线程访问均需要同步执行

1
2
3
4
The thread name : Thread-0  j = 1
The thread name : Thread-0 j = 2
The thread name : Thread-1 j = 3
The thread name : Thread-1 j = 4

synchronized原理

数据同步需要依赖锁,那锁的同步又依赖谁?synchronized给出的答案是在软件层面依赖JVM。

对象监视器(monitor)

Java中同步是通过监视器模型来实现的,JAVA中的监视器实际是一个代码块,这段代码块同一时刻只允许被一个线程执行。线程要想执行这段代码块的唯一方式是获得监视器。监视器有两种同步方式:互斥与协作。多线程环境下线程之间如果需要共享数据,需要解决互斥访问数据的问题,监视器可以确保监视器上的数据在同一时刻只会有一个线程在访问。什么时候需要协作?比如:一个线程向缓冲区写数据,另一个线程从缓冲区读数据,如果读线程发现缓冲区为空就会等待,当写线程向缓冲区写入数据,就会唤醒读线程,这里读线程和写线程就是一个合作关系。JVM通过Object类的wait方法来使自己等待,在调用wait方法后,该线程会释放它持有的监视器,直到其他线程通知它才有执行的机会。一个线程调用notify方法通知在等待的线程,这个等待的线程并不会马上执行,而是要通知线程释放监视器后,它重新获取监视器才有执行的机会。如果刚好唤醒的这个线程需要的监视器被其他线程抢占,那么这个线程会继续等待。object类中的notifyAll方法可以解决这个问题,它可以唤醒所有等待的线程,总有一个线程执行。

如上图所示,一个线程通过1号门进入Entry Set(入口区),如果在入口区没有线程等待,那么这个线程就会获取监视器成为监视器的owner,然后执行监视区域的代码。如果在入口区中有其它线程在等待,那么新来的线程也会和这些线程一起等待。线程在持有监视器的过程中,有两个选择,一个是正常执行监视器区域的代码,释放监视器,通过5号门退出监视器;还有可能等待某个条件的出现,于是它会通过3号门到Wait Set(等待区)休息,直到相应的条件满足后再通过4号门进入重新获取监视器再执行。
注意:当一个线程释放监视器时,在入口区和等待区的等待线程都会去竞争监视器,如果入口区的线程赢了,会从2号门进入;如果等待区的线程赢了会从4号门进入。只有通过3号门才能进入等待区,在等待区中的线程只有通过4号门才能退出等待区,也就是说一个线程只有在持有监视器时才能执行wait操作,处于等待的线程只有再次获得监视器才能退出等待状态。

对象锁

JVM中的一些数据,比如堆和方法区会被所有线程共享。Java中每个对象和类实际上都有一把锁(监视器)与之相关联,对于对象来说,监视的是这个对象的实例变量,对于类来说,监视的是类变量,如果一个对象没有实例变量,就什么也不监视。当虚拟机装载类时,会创建一个Class类的实例,锁住一个类实际上锁住的是这个类对应的Class类的实例。对象锁是可重入的,也就是说对一个对象或者类上的锁可以累加。当然,必须是同一线程才可重入

在Java中有两种监视区域:同步方法和同步块,这两种监视区域都和一个引入对象相关联,当到达这个监视区域时,JVM就会锁住这个引用对象,不论它是怎么离开的,都会释放这个引用对象上的锁。Java程序员不能自己加对象锁,对象锁是JVM内部机制,只需要编写同步方法或者同步块即可,操作监视区域时JVM会自动帮你上锁或者释放锁。

从JVM规范中可以看到synchronized在JVM里的实现原理,在字节码层面上,JVM基于进入和退出Monitor对象来实现方法同步和代码块同步,但两者的实现细节不一样。代码块同步是使用monitorenter和monitorexit指令实现的,而方法同步是使用另外一种方式实现的,细节在JVM规范里并没有详细说明。但是,方法的同步同样可以使用这两个指令来实现。monitorenter指令是在编译后插入到同步代码块的开始位置,而monitorexit是插入到方法结束处和异常处,JVM要保证每个monitorenter必须有对应的monitorexit与之配对。任何对象都有一个monitor与之关联,当且一个monitor被持有后,它将处于锁定状态。线程执行到monitorenter指令时,将会尝试获取对象所对应的monitor的所有权,即尝试获得对象的锁。
具体的例子如上所示。

对象头

实际上锁(monitor)是存在Java对象头里的。如果对象是数组类型,则虚拟机用3个Word(字宽)存储对象头,如果对象是非数组类型,则用2字宽存储对象头。在32位虚拟机中,一字宽等于四字节,即32bit。

Java对象头里的Mark Word里默认存储对象的HashCode,分代年龄和锁标记位。32位JVM的Mark Word的默认存储结构如下:

在运行期间Mark Word里存储的数据会随着锁标志位的变化而变化。Mark Word可能变化为存储以下4种数据:

在64位虚拟机下,Mark Word是64bit大小的,其存储结构如下:对象头占8字节,除此之外对象数据类型指针占8字节,开启指针压缩占4字节。

锁升级与优化

在Java SE1.6之前synchronized加锁同步是很耗性能的,Java SE1.6为了减少获得锁和释放锁带来的性能消耗,引入了“偏向锁”和“轻量级锁”,在Java SE1.6中,锁一共有4种状态,级别从低到高依次是:无锁状态、偏向锁状态、轻量级锁状态和重量级锁状态,这几个状态会随着竞争情况逐渐升级。锁可以升级但不能降级,意味着偏向锁升级成轻量级锁后不能降级成偏向锁。这种锁升级却不能降级的策略,目的是为了提高获得锁和释放锁的效率。同时也引入了自旋自适应锁,锁粗化,锁消除的措施来提升锁的性能,经过优化之后的synchronized的使用建议是,能使用synchronized实现的同步,推荐使用synchronized关键字来实现。

轻量级锁

轻量级锁是JDK 1.6之中加入的新型锁机制, 它名字中的“ 轻量级” 是相对于使用操作系统互斥量来实现的传统锁而言的, 因此传统的锁机制就称为“ 重量级” 锁。 首先需要强调一点的是, 轻量级锁并不是用来代替重量级锁的, 它的本意是在没有多线程竞争的前提下, 减少传统的重量级锁使用操作系统互斥量产生的性能消耗。

要理解轻量级锁,就需要首先知道前面所说的对象头的数据结构,以及在代码执行过程中Mark Word结构的变化:对象头信息是与对象自身定义的数据无关的额外存储成本, 考虑到虚拟机的空间效率, Mark Word被设计成一个非固定的数据结构以便在极小的空间内存储尽量多的信息, 它会根据对象的状态复用自己的存储空间。 例如, 在32位的HotSpot虚拟机中对象未被锁定的状态下, Mark Word的32bit空间中的25bit用于存储对象哈希码( HashCode) , 4bit用于存储对象分代年龄, 2bit用于存储锁标志位, 1bit固定为0, 在其他状态( 轻量级锁定、重量级锁定、 GC标记、 可偏向) 下对象的存储内容如上图所示。
轻量级锁加锁:在代码进入同步块的时候, 如果此同步对象没有被锁定( 锁标志位为“ 01” 状态) , 虚拟机首先将在当前线程的栈帧中建立一个名为锁记录( Lock Record) 的空间, 用于存储锁对象目前的Mark Word的拷贝( 官方把这份拷贝加了一个Displaced前缀, 即Displaced Mark Word) , 这时候线程堆栈与对象头的状态如图所示:

然后, 虚拟机将使用CAS操作尝试将对象的Mark Word更新为指向Lock Record的指针。 如果这个更新动作成功了, 那么这个线程就拥有了该对象的锁, 并且对象Mark Word的锁标志位( Mark Word的最后2bit) 将转变为“00” , 即表示此对象处于轻量级锁定状态, 这时候线程堆栈与对象头的状态如图所示:

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

轻量级锁解锁:上面描述的是轻量级锁的加锁过程, 它的解锁过程也是通过CAS操作来进行的, 如果对象的Mark Word仍然指向着线程的锁记录, 那就用CAS操作把对象当前的Mark Word和线程中复制的Displaced Mark Word替换回来, 如果替换成功, 整个同步过程就完成了。 如果替换失败, 说明有其他线程尝试过获取该锁, 并且在释放锁的同时, 唤醒被挂起的线程。

因为自旋会消耗CPU,为了避免无用的自旋(比如获得锁的线程被阻塞住了),一旦锁升级成重量级锁,就不会再恢复到轻量级锁状态。当锁处于这个状态下,其他线程试图获取锁时,都会被阻塞住,当持有锁的线程释放锁之后会唤醒这些线程,被唤醒的线程就会进行新一轮的夺锁之争。

轻量级锁能提升程序同步性能的依据是“ 对于绝大部分的锁, 在整个同步周期内都是不存在竞争的” , 这是一个经验数据。 如果没有竞争, 轻量级锁使用CAS操作避免了使用互斥量的开销, 但如果存在锁竞争, 除了互斥量的开销外, 还额外发生了CAS操作, 因此在有竞争的情况下, 轻量级锁会比传统的重量级锁更慢。

偏向锁

偏向锁也是JDK 1.6中引入的一项锁优化, 它的目的是消除数据在无竞争情况下的同步原语, 进一步提高程序的运行性能。 如果说轻量级锁是在无竞争的情况下使用CAS操作去消除同步使用的互斥量, 那偏向锁就是在无竞争的情况下把整个同步都消除掉, 连CAS操作都不做了。当一个线程访问同步块并获取锁时,会在对象头和栈帧中的锁记录里存储锁偏向的线程ID,以后该线程在进入和退出同步块时不需要花费CAS操作来加锁和解锁,而只需简单的测试一下对象头的Mark Word里是否存储着指向当前线程的偏向锁,如果测试成功,表示线程已经获得了锁,如果测试失败,则需要再测试下Mark Word中偏向锁的标识是否设置成1(表示当前是偏向锁),如果没有设置,则使用CAS竞争锁,如果设置了,则尝试使用CAS将对象头的偏向锁指向当前线程(这里就出现了竞争了)。当有另外一个线程去尝试获取这个锁时, 偏向模式就宣告结束。 根据锁对象目前是否处于被锁定的状态, 撤销偏向( Revoke Bias) 后恢复到未锁定( 标志位为“01”) 或轻量级锁定( 标志位为“00” )的状态(这里有个疑问即两张情况会如何选择?), 后续的同步操作就如上面介绍的轻量级锁那样执行。

偏向锁的撤销:偏向锁使用了一种等到竞争出现才释放锁的机制,所以当其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁。偏向锁的撤销,需要等待全局安全点(在这个时间点上没有字节码正在执行)(也就是需要等待同步代码块执行完之后才可以),它会首先暂停拥有偏向锁的线程,然后检查持有偏向锁的线程是否活着,如果线程不处于活动状态,则将对象头设置成无锁状态,如果线程仍然活着,拥有偏向锁的栈会被执行,遍历偏向对象的锁记录,栈中的锁记录和对象头的Mark Word要么重新偏向于其他线程,要么恢复到无锁或者标记对象不适合作为偏向锁,最后唤醒暂停的线程。下图中的线程1演示了偏向锁初始化的流程,线程2演示了偏向锁撤销的流程。

偏向锁、 轻量级锁的状态转化及对象Mark Word的关系如图所示。

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

自旋锁与自适应自旋

互斥同步对性能最大的影响是阻塞的实现, 挂起线程和恢复线程的操作都需要转入内核态中完成, 这些操作给系统的并发性能带来了很大的压力。 同时, 虚拟机的开发团队也注意到在许多应用上, 共享数据的锁定状态只会持续很短的一段时间, 为了这段时间去挂起和恢复线程并不值得。 如果物理机器有一个以上的处理器, 能让两个或以上的线程同时并行执行, 我们就可以让后面请求锁的那个线程“ 稍等一下”, 但不放弃处理器的执行时间, 看看持有锁的线程是否很快就会释放锁。 为了让线程等待, 我们只需让线程执行一个忙循环( 自旋) , 这项技术就是所谓的自旋锁。 可以使用-XX: +UseSpinning参数来开启, 在JDK1.6中就默认开启了。 自旋等待不能代替阻塞, 且先不说对处理器数量的要求, 自旋等待本身虽然避免了线程切换的开销, 但它是要占用处理器时间的, 因此, 如果锁被占用的时间很短, 自旋等待的效果就会非常好, 反之, 如果锁被占用的时间很长, 那么自旋的线程只会白白消耗处理器资源, 而不会做任何有用的工作, 反而会带来性能上的浪费。 因此, 自旋等待的时间必须要有一定的限度, 如果自旋超过了限定的次数仍然没有成功获得锁, 就应当使用传统的方式去挂起线程了。 自旋次数的默认值是10次, 用户可以使用参数-XX:PreBlockSpin来更改。在JDK 1.6中引入了自适应的自旋锁。 自适应意味着自旋的时间不再固定了, 而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。 如果在同一个锁对象上, 自旋等待刚刚成功获得过锁, 并且持有锁的线程正在运行中, 那么虚拟机就会认为这次自旋也很有可能再次成功, 进而它将允许自旋等待持续相对更长的时间, 比如100个循环。 另外, 如果对于某个锁, 自旋很少成功获得过, 那在以后要获取这个锁时将可能省略掉自旋过程, 以避免浪费处理器资源。 有了自适应自旋, 随着程序运行和性能监控信息的不断完善, 虚拟机对程序锁的状况预测就会越来越准确, 虚拟机就会变得越来越“ 聪明” 了。

锁消除

锁消除是指虚拟机即时编译器在运行时, 对一些代码上要求同步, 但是被检测到不可能存在共享数据竞争的锁进行消除。 锁消除的主要判定依据来源于逃逸分析的数据支持 , 如果判断在一段代码中, 堆上的所有数据都不会逃逸出去从而被其他线程访问到, 那就可以把它们当做栈上数据对待, 认为它们是线程私有的, 同步加锁自然就无须进行。

锁粗化

如果虚拟机探测到有这样一串零碎的操作都对同一个对象加锁, 将会把加锁同步的范围扩展( 粗化) 到整个操作序列的外部。

锁的优缺点对比

参考

JAVA并发编程学习笔记之synchronized
深入理解Java虚拟机
Java并发编程的艺术