`
san_yun
  • 浏览: 2583022 次
  • 来自: 杭州
文章分类
社区版块
存档分类
最新评论

JVM锁实现探究:synchronized初探

 
阅读更多

原文:

http://www.majin163.com/2014/03/17/synchronized1/

http://www.majin163.com/2014/03/17/synchronized2/

JAVA是一门极易入门的语言,这一点尤其表现在JAVA中对象锁的使用和多线程编程上。所谓对象锁,就是可以直接在JAVA的任意Object加锁(synchronized),也可以在通过任意Object进行线程的阻塞(Object.wait())和唤醒(Object.notify() or Object.notifyAll()),这种面向对象的锁与C系中的Mutex和Semaphore相比,一来省去了创建锁对象的麻烦,二来这种更加抽象的封装使锁的使用更加人性化和便利。

然而这种便利带来了另外一个问题:说到C系中的mutext和Semaphore,都知道这是对操作系统中信号量的封装,其原理只要学过操作系统的人都会非常清楚,因此这种锁虽然使用起来略麻烦,但是原理透明,这就为后期锁调优提供了可能。而JAVA中的synchronized虽然提供了更加友好抽象的互斥原语,却很少有JAVA程序员了解synchronized背后的原理,甚至你会发现,JAVA面试官在考察你对JVM的了解程度时,基本上问的都是GC相关的问题。

拿我个人来说,JAVA开发做了四五年,对synchornized可以说驾轻就熟,但是当被问到这些问题时,我只能无言以对:

  • synchronized到底有多大开销?与CAS这样的乐观并发控制相比如何?
  • 怎样使用synchronized更加高效?
  • 与ReentrantLock(JDK1.5之后提供的锁对象)相比有什么优势劣势?
  • 程序员可以对synchronized做哪些优化?

要回答这些问题,就需要对synchrnonized背后的原理一探究竟,在查阅了一些资料后,我惊讶地发现synchornized实现远比我想象的复杂地多,一个简单的synchronized过程,可能会涉及到自旋锁(spinlock)、自适应自旋锁(adaptive spinlock)、轻量锁(lightweight lock)、偏向锁(biased lock)以及粗量锁(heavyweight lock),看起来synchronized是把各种复杂的锁过程封装在一起,帮助开发者无脑使用,在这一点上与C系可谓两个极端。

本文将分两个部分,第一部分初探篇,介绍synchronized的使用方法和原理。第二部分深探篇,将介绍synchronized背后的实现原理,带大家理解各种不同锁优化实现之间的转换,最后根据synchronized的实现原理,回答上文提到的四个问题。
另外,对于对象上的阻塞和唤醒,本文也会进行部分讲解。

本文内容很多来自互联网中的分享,由我进行了总结和发散性思考,对本文贡献较多的链接会贴在下一篇文章末尾。

初探篇

synchronized使用起来非常简单,有三种使用模式:

1. 作为修饰符加在方法声明上,synchronized修饰非静态方法时表示锁住了调用该方法的堆对象,修饰静态方法时表示锁住了这个类在方法区中的类对象(记住JAVA中everything is object),例如下述代码:

1 public class IncableInt {
2     private int value = 0;
3     public synchronized int incAndGet() {
4        return ++value;
5     }
6 }

上述代码实现了一个线程安全的自增函数,当不同线程进入incAndGet()方法体时,会竞争这个IncableInt对象上的锁,通过锁的互斥性保证了该方法不会被不同线程同时进入。

2. 可以用synchronized直接构建代码块,上述的自增整数可以也可以写成下面的形式:

1 public class IncableInt {
2     private int value = 0;
3     public int incAndGet() {
4         synchronized (this) {
5             return ++value;
6         }
7     }
8 }

上述代码可以达到同样的互斥效果,sychronized代码块竞争的是后面括号中的对象锁,我们常常可以在一些源码中看到用一个普通的Object作为synchronized对象,相当于C系中mutex的效果。

3. 在使用Object.wait()使当前线程进入该Object的阻塞队列时,以及用Object.notify()或Object.notifyAll()唤醒该Object的阻塞队列中一个或所有线程时,必须在外层使用synchronized (Object),这是JAVA中线程同步的最常见做法。之所以在这里要强制使用synchronized代码块,是因为在JAVA语义中,wait有出让Object锁的语义,要想出让锁,前提是要先获得锁,所以要先用synchronized获得锁之后才能调用wait,notify原因类似,另外,我们知道操作系统信号量的增减都是原子性的,而Object.wait()和notify()不具有原子性语义,所以必须用synchronized保证线程安全。

另外,在使用synchronized时有三个原则:

a) sychronized的对象最好选择引用不会变化的对象(例如被标记为final,或初始化后永远不会变),原因显而易见的,虽然synchronized是在对象上加锁,但是它首先要通过引用来定位对象,如果引用会变化,可能带来意想不到的后果,对于需要synchronized不同对象的情况,建议的做法是为每个对象构建一个Object锁来synchronized(不建议对同一个引用反复赋值)。当然将synchronized作为修饰符修饰方法就不会有引用变化的问题,但是这种做法在方法体较大时容易违反第二个原则。

b) 尽可能把synchronized范围缩小,线程互斥是以牺牲并发度为代价的,这点大家都懂。

c) 尽量不要在可变引用上wait()和notify(),例如:

1 synchronized (a) {
2     (1)
3     a.wait()
4 }

若其他线程在线程1进入(1)时更改了a值,那么线程1会直接抛出一个IllegalMonitorException,表示在a.wait()前没有获得a的对象锁。推荐的做法还是声明一个专门用于线程同步的Object,这个Object永远不变。

死锁与活锁

synchronized相当于C++中的mutex,也就是可重入的01信号量,JAVA通过这个关键字保证互斥语义,在synchronized过程中因为加锁失败而进入阻塞队列的线程,只能通过其他线程释放锁来唤醒,因此使用synchronized可能引发死锁,使用时需要留意。另外,synchronized也可能引发活锁,因为synchronized是不公平竞争,后来的线程可能先得到锁,进而可能导致先到的线程持续饥饿,非公平竞争在很大程度上提升了synchronized吞吐率(why?答案在下一篇中揭晓)。

虽然wait()和notify()也是阻塞和唤醒,看起来和synchronized有点类似,但实际上无论是wait()还是notify()的调用都是以获得锁为前提,因此不会在wait()或notify()上发生死锁,进一步讲,wait()或notify()没有互斥语义,没有互斥就没有竞争,没有竞争就不会有死锁。另外,wait()操作是可能被其他线程interrupt掉的(抛出中断异常)。

这里有个概念容易混淆,所谓死锁与互相等待还是有很大区别的,使用wait()和signal()是可能出现两个以上线程互相等待的情况,这种互相等待是可以通过加入新线程signal()来解开,造成这种互相等待大部分原因是业务逻辑使然,属于正常情况。而使用synchronized一旦出现两个线程互相等待,必然是死锁。

可以说wait()和notify()是专门用于线程同步的,对应C中的Semaphore,synchronized是专门用于线程互斥的,JAVA中将互斥和同步分成两种不同原语,使用起来更加友好。

 

这里我们来聊聊synchronized,以及wait(),notify()的实现原理。

在深入介绍synchronized原理之前,先介绍两种不同的锁实现。

阻塞锁

我们平时说的锁都是通过阻塞线程来实现的:当出现锁竞争时,只有获得锁的线程能够继续执行,竞争失败的线程会由running状态进入blocking状态,并被登记在目标锁相关的一个等待队列中,当前一个线程退出临界区,释放锁后,会将等待队列中的一个阻塞线程唤醒(按FIFO原则唤醒),令其重新参与到锁竞争中。

这里要区别一下公平锁和非公平锁,顾名思义,公平锁就是获得锁的顺序按照先到先得的原则,从实现上说,要求当一个线程竞争某个对象锁时,只要这个锁的等待队列非空,就必须把这个线程阻塞并塞入队尾(插入队尾一般通过一个CAS保持插入过程中没有锁释放)。相对的,非公平锁场景下,每个线程都先要竞争锁,在竞争失败或当前已被加锁的前提下才会被塞入等待队列,在这种实现下,后到的线程有可能无需进入等待队列直接竞争到锁。

非公平锁虽然可能导致活锁(所谓的饥饿),但是锁的吞吐率是公平锁的5-10倍,synchronized是一个典型的非公平锁,无法通过配置或其他手段将synchronized变为公平锁,在JDK1.5后,提供了一个ReentrantLock可以代替synchronized实现阻塞锁,并且可以选择公平还是非公平。

自旋锁

线程的阻塞和唤醒需要CPU从用户态转为核心态,频繁的阻塞和唤醒对CPU来说是一件负担很重的工作。同时我们可以发现,很多对象锁的锁定状态只会持续很短的一段时间,例如整数的自加操作,在很短的时间内阻塞并唤醒线程显然不值得,为此引入了自旋锁。

所谓“自旋”,就是让线程去执行一个无意义的循环,循环结束后再去重新竞争锁,如果竞争不到继续循环,循环过程中线程会一直处于running状态,但是基于JVM的线程调度,会出让时间片,所以其他线程依旧有申请锁和释放锁的机会。

自旋锁省去了阻塞锁的时间空间(队列的维护等)开销,但是长时间自旋就变成了“忙式等待”,忙式等待显然还不如阻塞锁。所以自旋的次数一般控制在一个范围内,例如10,100等,在超出这个范围后,自旋锁会升级为阻塞锁。

所谓自适应自旋锁,是通过JVM在运行时收集的统计信息,动态调整自旋锁的自旋上界,使锁的整体代价达到最优。

介绍了自旋锁和阻塞锁这两种基本的锁实现之后,我们来聊一聊synchronized背后的锁实现。

synchronized锁在运行过程中可能经过N次升级变化,首先可以想到的是:

自适应自旋锁—>阻塞锁

自适应自旋锁是JDK1.6中引入的,自旋锁的自旋上界由同一个锁上的自旋时间统计和锁的持有者状态共同决定。当自旋超过上界后,自旋锁就升级为阻塞锁。就像C中的Mutex,阻塞锁的空间和时间开销都比较大(毕竟有个队列),为此在阻塞锁中,synchronized又进一步进行了优化细分。阻塞锁升级变化过程如下:

偏向锁—>轻量锁—>重量锁

重量锁就是带着队列的锁,开销最大,它的实现和Mutex很像,但是多了一个waiting的队列,这部分实现最后介绍,我们先来看看轻量锁和偏向锁是什么玩意。
在进一步介绍锁实现之前,我们需要先了解一下JVM中对象的内存布局,JVM中每个对象都有一个对象头(Object header),普通对象头的长度为两个字,数组对象头的长度为三个字(JVM内存字长等于虚拟机位数,32位虚拟机即32位一字,64位亦然),其构成如下所示:

object_header

图1. JAVA对象头结构

ClassAddress是指向方法区中对象所属类对象的地址指针,ArrayLength标志了数组长度, MarkWord用于存储对象的各种标志信息,为了在极小的空间存储尽量多的信息,MarkWord会根据对象状态复用空间。MarkWord中有2位用于标志对象状态,在不同状态下MarkWord中存储的信息含义分别为:

mark_word

图2. MarkValue结构

看到这个表格多少会让人有些眼花缭乱,不急,我们在讲解下面几种锁的过程中会分别介绍这几种状态。

轻量锁(Light-weight lock)

首先需要明确的是,无论是轻量锁还是偏向锁,都不能代替重量锁,两者的本意都是在没有多线程竞争的前提下,减少重量锁产生的性能消耗。一旦出现了多线程竞争锁,轻量锁和偏向锁都会立即升级为重量锁。进一步讲,轻量锁和偏向锁都是重量锁的乐观并发优化。

对对象加轻量锁的条件是该对象当前没有被任何其他线程锁住。

先从对象O没有锁竞争的状态说起,这时候MarkWord中Tag状态为01,其他位分别记录了对象的hashcode,4位的对象年龄信息(新建对象年龄为0,之后每次在新生代拷贝一次就年龄+1,当年龄超过一个阈值之后,就会被丢入老年代,GC原理不是本文的主题,但至少我们现在知道了,这个阈值<=15),以及1位的偏向信息用于记录这个对象是否可用偏向锁。 当一个线程A在对象O上申请锁时,它首先检查对象O的Tag,若发现是01且偏向信息为0,表明当前对象还未加锁,或加过偏向锁(加过,注意是加过偏向锁的对象只能被同样的线程加锁,如果不同的线程想要获取锁,需要先将偏向锁升级为轻量锁,稍后会讲到),在判断对当前对象确实没有被任何其他线程锁住后(Tag为01或偏向线程不具有该对象锁),即可以在该对象上加轻量锁。

在判断可以加轻量锁之后,加轻量锁的过程为两步:

1. 在当前线程的栈(stack frame)中生成一个锁记录(lock record),这个锁记录并不是我们通常意义上说的锁对象(包含队列的那个),而仅仅是对象头MarkValue的一个拷贝,官方称之为displayed mark value。如图3所示:

lightweight lock

图3. 加轻量锁之前

2. 通过CAS操作将上一步生成的lock record地址赋给目标对象的MarkValue中(Tag同时改为00),保证在给MarkValue赋值时Tag不会动态修改,如果CAS成功,表明轻量锁申请成果,如果CAS不成功,且Tag变为00,则查看MarkValue中lock record address是否指向当前线程栈中的锁记录,若是,则表明是同样的线程锁重入,也算锁申请成果。如图4所示: 在第二步中,若不满足加锁成功的两种情况,说明目标锁已经被其他线程持有,这时不再满足加轻量锁条件,需要将当前对象上的锁状态升级为重量锁:将Tag状态改为10,并生成一个Monitor对象(重量锁对象),再将MarkValue值改为该Monitor对象的地址。最后将当前线程塞入该Monitor的等待队列中。

lightweight lock

图4.加轻量锁之后

轻量锁的解锁过程也依赖CAS操作: 通过CAS将lock record中的Object原MarkValue赋还给Object的MarkValue,若替换成功,则解锁完成,若替换不成功,表示在当前线程持有锁的这段时间内,其他线程也竞争过锁,并且发生了锁升级为重量锁,这时需要去Monitor的等待队列中唤醒一个线程去重新竞争锁。

当发生锁重入时,会对一个Object在线程栈中生成多个lock record,每当退出一个synchronized代码块便解锁一次,并弹出一个lock record。

一言以蔽之,轻量锁通过CAS检测锁冲突,在没有锁冲突的前提下,避免采用重量锁的一种优化手段。

加轻量锁的代价是数个指令外加一个CAS操作,虽然轻量锁的代价已经足够小,它依然有优化空间。 细心的人应该发现,轻量锁的每次锁重入都要进行一次CAS操作,而这个操作是可以避免的,这便是偏向锁的优化手段了。

偏向锁

所谓偏向,就是偏袒的意思,偏向锁的初衷是在某个线程获得锁之后,消除这个线程锁重入(CAS)的开销,看起来让这个线程得到了偏护。

偏向锁和轻量锁的加锁过程很类似,不同的是在第二步CAS中,set的值是申请锁的线程ID,Tag置为01(就初始状态来说,是不变),这点可以从图2中开出。当发生锁重入时,只需要检查MarkValue中的ThreadID是否与当前线程ID相同即可,相同即可直接重入,不相同说明有不同线程竞争锁,这时候要先将偏向锁撤销(revoke)为轻量锁,再升级为重量锁。 因为偏向锁的MarkValue为线程ID,可以直接定位到持有锁的线程,偏向锁撤销为轻量锁的过程,需要将持有锁的线程中与目标对象相关的最老的lock record地址替换到当前的MarkValue中,并将Tag置为00。

偏向锁的释放不需要做任何事情,这也就意味着加过偏向锁的MarkValue会一直保留偏向锁的状态,因此即便同一个线程持续不断地加锁解锁,也是没有开销的。 另一方面,偏向锁比轻量锁更容易被终结,轻量锁是在有锁竞争出现时升级为重量锁,而一般偏向锁是在有不同线程申请锁时升级为轻量锁,这也就意味着假如一个对象先被线程1加锁解锁,再被线程2加锁解锁,这过程中没有锁冲突,也一样会发生偏向锁失效,不同的是这回要先退化为无锁的状态,再加轻量锁,如图5:

Synchronization

图5. 偏向锁,以及锁升级

回到图2,我们发现出了Tag外还有一个01标志位,上文中提到,这位表示偏向信息,0表示偏向不可用,1表示偏向可用,这位信息同样记录在对象的类对象中,当JVM发现一类对象频繁发生锁升级,而锁升级本身需要一定的开销,这种情况下偏向锁反而成为一种负担,尤其在生产者消费者这类常态竞争锁的场景中,偏向锁是完全无意义的,当JVM搜集到足够的“证据”证明偏向锁不应当存在后,它就会将类对象中的相关标志置0,之后每次生成新对象其偏向信息都是0,都不会再加偏向锁。官网上称之为Bulk revokation。

另外,JVM对那种会有多线程加锁,但不存在锁竞争的情况也做了优化,听起来比较拗口,但在现实应用中确实是可能出现这种情况,因为线程之前除了互斥之外也可能发生同步关系,被同步的两个线程(一前一后)对共享对象锁的竞争很可能是没有冲突的。对这种情况,JVM用一个epoch表示一个偏向锁的时间戳(真实地生成一个时间戳代价还是蛮大的,因此这里应当理解为一种类似时间戳的identifier),对epoch,官方是这么解释的:

A similar mechanism, called bulk rebiasing, optimizes situations in which objects of a class are locked and unlocked by different threads but never concurrently. It invalidates the bias of all instances of a class without disabling biased locking. An epoch value in the class acts as a timestamp that indicates the validity of the bias. This value is copied into the header word upon object allocation. Bulk rebiasing can then efficiently be implemented as an increment of the epoch in the appropriate class. The next time an instance of this class is going to be locked, the code detects a different value in the header word and rebiases the object towards the current thread.

再次一言以蔽之,偏向锁是在轻量锁的基础上减少了减少了锁重入的开销。

重量锁

重量锁在JVM中又叫对象监视器(Monitor),它很像C中的Mutex,除了具备Mutex互斥的功能,它还负责实现了Semaphore的功能,也就是说它至少包含一个竞争锁的队列,和一个信号阻塞队列(wait队列),前者负责做互斥,后一个用于做线程同步。

这两天在网上找资料,发现一篇对重量锁不错的介绍,虽然个人觉得里面对轻量锁,偏向锁介绍的有点少,另外在锁的变化升级上有点含糊。不妨碍它在Monitor描述上的优质。为了尊重原作者,这里贴出它的博客链接:
http://blog.csdn.net/chen77716/article/details/6618779
从这篇博文中我们可以看到,在重量锁的调度过程中,可能有不同线程访问Monitor的队列,所以Monitor的队列必然都是并发队列,而并发队列的操作需要并发控制,是不是发现这又要依赖synchronized?哈哈,当然这种循环依赖是不可能出现的,因为Monitor中的队列都是通过CAS来保证其并发的正确性的。

写到这里,我自己都不由惊叹CAS的神奇,任何阅读到这里的读者都会发现,synchronized的实现中到处都有CAS的身影。那么CAS的代价到底有多大呢? 关于CAS的介绍推荐两篇介绍,和一个答疑:
http://en.wikipedia.org/wiki/Compare-and-swap
http://www.ibm.com/developerworks/library/j-jtp11234/
http://stackoverflow.com/questions/2538070/atomic-operation-cost
这里还需要说明一下自旋锁与阻塞锁三个过程之间的关系:自旋锁是在发生锁竞争时自旋等待,那么自旋锁的前提是发生锁竞争,而轻量锁,偏向锁的前提都是没有锁竞争,所以加自旋锁应当发生在加重量锁之前,准确地说,是在线程进入Monitor等待队列之前,先自旋一会,重新竞争,如果还竞争不到,才会进入Monitor等待队列。加锁顺序为:

偏向锁—>轻量锁—>自适应自旋锁—>重量锁

CAS具体的代价在不同硬件上有所区别,但从指令复杂度考虑,必然比普通赋值指令多很多时钟周期,但是在CAS和synchronized之间做选择时,依旧倾向CAS,因为synchronized背后布满了CAS,如果你对自己的coding有足够自信,那尝试自己CAS或许能有不错的收获。

最后回答我们最初提出的几个问题:

Q1: synchronized到底有多大开销?与CAS这样的乐观并发控制相比如何?

从上述四个锁的原理以及加速顺序我们不难发现,synchronzied在没有锁冲突的前提下最小开销为一个CAS+栈变量维护(lock record)+一个赋值指令,有锁冲突时需要维护一个Montor对象,通过Moinitor对象维护锁队列,这种情况下涉及到线程阻塞和唤醒,开销很大。

Synchronized大多数情况下没有CAS高效,因为synchronized的最小开销也至少包含一个CAS操作。CAS和synchronized实现的多线程自加操作性能对比见上一篇博客。

Q2:怎样使用synchronized更加高效?

使用synchronized要遵从上篇博客中提到的三个原则,另外如果业务场景允许使用CAS,倾向使用CAS,或者JDK提供的一些乐观并发容器(如ConcurrentLinkedQueue等),也可以先用synchronized将业务逻辑实现,之后做针对性的性能优化。

Q3:与ReentrantLock(JDK1.5之后提供的锁对象)一类的锁相比有什么优劣?

ReentrantLock代表了JDK1.5之后由JAVA语言实现的一系列锁的工具类,而synchronized作为JAVA中的关键字,是由native(根据平台有所不同,一般是C)语言实现的。ReentrantLock虽然也实现了 synchronized中的几种锁优化技术,但与synchronized相比,性能未必好,毕竟JAVA语言效率和native语言效率比大多数情况总有不如。ReentrantLock的优势在于为程序员提供了更多的选择和更好地扩展性,比如公平性锁和非公平性锁,读写锁,CountLatch等。

细心地人会发现,JDK1.6中的并发容器大多数都是用ReentrantLock一类的锁对象实现。例如LinkedBlockingQueue这样的生产者消费者队列,虽然也可以用synchronized实现,但是这种队列中存在若干个互斥和同步逻辑,用synchronized容易使逻辑变得混乱,难以阅读和维护。

总结一点,在业务并发简单清晰的情况下推荐synchronized,在业务逻辑并发复杂,或对使用锁的扩展性要求较高时,推荐使用ReentrantLock这类锁。

Q5:可以对synchronized做哪些优化?

通过介绍synchronized的背后实现,不难看出synchronized本身已经经过了高度优化,而且除了JVM运行时的锁优化外,JAVA编译器还会对synchronized代码块做一些额外优化,例如对肯定不会发生锁竞争的synchronized进行锁消除,或频繁对一个对象进行synchronized时可以锁粗化(如synchronzied写在for循环内时,可以优化到外面),因此程序员在使用synchronized时需要注意的就是上篇博客中提到的三点原则,尤其是控制synchronzied的代码量,将无需互斥执行的代码尽量移到synchronzed之外。

分享到:
评论

相关推荐

Global site tag (gtag.js) - Google Analytics