作者:马进
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),例如下述代码:
public class IncableInt {
private int value = 0;
public synchronized int incAndGet() {
return ++value;
}
}
上述代码实现了一个线程安全的自增函数,当不同线程进入incAndGet()方法体时,会竞争这个IncableInt对象上的锁,通过锁的互斥性保证了该方法不会被不同线程同时进入。
2. 可以用synchronized直接构建代码块,上述的自增整数可以也可以写成下面的形式:
public class IncableInt {
private int value = 0;
public int incAndGet() {
synchronized (this) {
return ++value;
}
}
}
上述代码可以达到同样的互斥效果,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(),例如:
synchronized (a) {
(1)
a.wait()
}
若其他线程在线程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中将互斥和同步分成两种不同原语,使用起来更加友好。
本文来自网易实践者社区,经作者马进授权发布