锁优化思路
最好的方式不加锁,如果必须加锁,可以从如下几个方面入手进行锁优化:
1. 减少锁持有时间
2. 减小锁粒度
3. 读写锁替代独占锁
4. 锁分离
5. 锁粗化
减少锁的持有时间
减少锁的持有时间,即减少锁内代码执行时间,可以通过减少锁内代码量实现,例如避免给整个方法加锁、将不需要加锁的代码移出去,例如:
public synchronized void doSomething() {
System.out.println("before");
needLockCode();
System.out.println("after");
}
改为:
public void doSomething() {
System.out.println("before");
synchronized(this){
needLockCode();
}
System.out.println("after");
}
或:
public void doSomething() {
synchronized(this){
System.out.println("before");
needLockCode();
System.out.println("after");
}
}
改为:
public void doSomething() {
System.out.println("before");
synchronized(this){
needLockCode();
}
System.out.println("after");
}
减小锁的粒度
减小锁的粒度,这个偏向于减小被锁住代码涉及的影响范围的减小,降低锁竞争的几率,例如jdk5的ConcurrentHashMap,ConcurrentHashMap不会为整个hash表加锁,而是将Hash表划分为多个分段,对每个段加锁,这样减小了锁粒度,提升了并发处理效果。
再如假设有对象object,如果加锁后,不允许对object操作,此时锁粒度相当于object对象,如果实际上object只有一个名为needLock
字段可能会出现并发问题,此时将锁加在这个字段上即可。
读写锁替代独占锁
ReentrantLock和synchronized使用的是独占锁,无论是读或写都保证同时只有一个线程执行被锁代码。但是单纯的读实际上不会引起并发问题。尤其是对于读多写少的场景,可以将读和写的锁分离开来,可以有效提升系统的并发能力。
读写锁在同一时刻可以允许多个线程访问,但是在写线程访问时,所有的读线程和其他写线程都会被阻塞。读写锁维护了一对锁:读锁和写锁。一般情况下,读写锁的性能都会比排它锁好,因为大多数场景读是多于写的。
当执行读操作的时候,需要获取读锁,在并发访问的时候,读锁不会被阻塞;在执行写操作时线程必须要获取写锁,当已经有线程持有写锁的情况下,所有的线程都会被阻塞。读锁和写锁关系:
读锁与读锁可以共享;
读锁与写锁互斥;
写锁与写锁互斥。
ReentrantReadWriteLock是提供了读锁和写锁:
public class ReentrantReadWriteLock implements ReadWriteLock, java.io.Serializable {
...
/** Inner class providing readlock */
private final ReentrantReadWriteLock.ReadLock readerLock;
/** Inner class providing writelock */
private final ReentrantReadWriteLock.WriteLock writerLock;
...
}
锁分离
在读写锁的思想上做进一步的延伸,如果对两个上下文互相不依赖、互相不影响的操作使用了同一把锁,这时候可以把锁进行拆分,根据不同的功能拆分不同的锁, 进行有效的锁分离。
一个典型的示例便是LinkedBlockingQueue,在它内部,take()和put()分别实现了从队列中取得数据和往队列中增加数据的功能,虽然两个方法都对当前队列进行了修改操作,但由于当前队列为链表实现,两个操作分别作用于队列的前端和尾端,从理论上说,两者并不冲突。
如果使用独占锁,那么同一时间两个操作不能同时进行,会因为等待锁资源而阻塞。但是两个操作实际上是不冲突的,这时候可以使take()和put()各自使用一把锁,提高并发效率。LinkedBlockingQueue中为两个操作分别准备了takeLock和putLock:
1 /** Lock held by take, poll, etc */
2 private final ReentrantLock takeLock = new ReentrantLock();
3
4 /** Wait queue for waiting takes */
5 private final Condition notEmpty = takeLock.newCondition();
6
7 /** Lock held by put, offer, etc */
8 private final ReentrantLock putLock = new ReentrantLock();
9
10 /** Wait queue for waiting puts */
11 private final Condition notFull = putLock.newCondition();
锁粗化
必要的时候,将被锁住的代码量变多、锁持有时间更长也是锁优化的方式,但优化结果一定要使整体的执行效率变的更好,例如:
for(int i = 0; i < 100; i++) {
synchronized(lock) {
needLockCode();
}
}
改为:
synchronized(lock) {
for(int i = 0; i < 100; i++) {
needLockCode();
}
}
改造后,尽管每个线程每次持有锁的时间变长了,但减少了每个线程请求和释放锁的次数,而请求和释放锁也是要消耗资源的。
虚拟机的锁优化
1、自旋锁与自适应自旋
由于挂起线程和恢复线程都需要转入内核态完成,给系统带来很大压力,同时,共享数据的锁定状态只会持续很短的一段时间,因此去挂起和恢复线程很不值得。因此,可以使线程执行一个自我循环,因为对于执行时间短的代码这一会可能就会释放锁,而线程就不需要进行一次阻塞与唤醒。
自旋等待不能代替阻塞,自旋本身虽然避免了线程切换的开销,但是会占用处理器时间,如果锁被占用时间短,自旋等待效果好;反之,自旋的线程只会白白浪费处理器资源;因此,要限制自旋等待时间,自旋次数默认值是10次,超过次数仍然没有成功获取锁,就挂起线程,进入同步阻塞状态。
自适应自旋更智能一些,它根据前一次在同一个锁上的自旋时间以及锁的拥有者的状态来决定自旋次数,如果对于某个锁的自旋很少有成功获得过锁,就不自旋了,避免浪费CPU资源。如果自旋等待刚刚成功获得过锁,并且持有锁的线程在运行,则认为此次自旋很有可能成功,就允许自旋更多的次数。
2. 锁消除
锁消除是指虚拟机即时编译器在运行时,对一些代码上要求同步,但是被检测到不可能存在共享数据竞争的锁进行消除。锁消除的目的主要是判定依据来源于逃逸分析的数据支持,如果判断在一段代码中,堆上的所有数据都不会逃逸出去从而被其他线程访问到,那就可以把他们当作栈数据对待,认为它们是线程私有的,同步加锁自然就无需进行。
有时候锁是开发者无意中涉及到的,例如对于下面代码:
public static String getStr(String s1, String s2) {
return s1 + s2;
}
只进行了字符串的拼接,但其中的s1 + s2
可能被虚拟机优化为:
StringBuffer sb = new StringBuffer();
sb.append(s1);
sb.append(s2);
return sb.toString();
而append()涉及了synchronized:
@Override
public synchronized StringBuffer append(String str) {
toStringCache = null;
super.append(str);
return this;
}
append()中的锁就是sb对象,如果该对象在方法中new的话,sb对象就不会逃逸到方法以外,jvm认为此时不必要加锁,此处的锁就被安全的消除了。
3. 锁粗化
原则上,我们在编写代码的时候,总是推荐将同步块的作用范围限制得尽量小——只在共享数据的实际作用域中才进行同步,这样是为了使需要同步的操作数量尽可能变小,如果存在锁竞争,那等待锁的线程也能尽快拿到锁。
但如果一系列操作频繁对同一个对象加锁解锁,或者加锁操作再循环体内,会耗费性能,这时虚拟机会扩大加锁范围来减少获取锁、释放锁的操作。具体可以看上文示例。
4. 轻量级锁
轻量级锁是JDK6之中加入的新型锁机制,它名字中的“轻量级”是相对于使用操作系统互斥量来实现的传统锁而言的,因此传统的锁机制就称为“重量级”锁。首先需要强调一点的是,轻量级锁并不是用来代替重量级锁的,它的本意是在没有多线程竞争的前提下减少传统的重量级锁使用操作系统互斥量产生的性能消耗。
在代码进入同步块的时候,如果同步对象没有被锁定,虚拟机首先将在当前线程的栈帧中建立一个名为锁记录( Lock Record)的空间,用于存储锁对象目前的Mark Word的拷贝,虚拟机将使用CAS操作尝试将对象的Mark Word更新为指向Lock Record的指针。如果这个更新动作成功了,那么这个线程就拥有了该对象的锁,并且对象 Mark Word的锁标志位(Mark Word的最后2bit)将转变为“00”,即表示此又对象处于轻量级锁定状态。
如果这个更新操作失败了,虚拟机首先会检查对象的Mark Word是否指向当前线程的栈帧,如果是说明当前线程已经拥有了这个对象的锁,那就可以直接进入同步块继续执行,否则说明这个锁对象已经被其他线程抢占了,如果有两条以上的线程争用同一个锁,那轻量级锁就不再有效,自旋失败后要膨胀为重量级锁,Mark Word中存储的就是指向重量级锁的指针,后面等待锁的线程也要进入阻塞状态。
轻量级锁能提升程序同步性能的依据是“对于绝大部分的锁,在整个同步周期内都是不存在竞争”,这是一个经验数据。如果没有竞争,轻量级锁使用CAS操作避免了使用互斥量的开销,但如果存在锁竞争,除了互斥量的开销外,还额外发生了CAS操作,因此在有竞争的情况下,轻量级锁会比传统的重量级锁更慢。
5. 偏向锁
大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,为了让线程获得锁的代价更低而引入了偏向锁。如果说轻量级锁是在无竞争的情况下使用CAS操作去消除同步使用的互斥量,那偏向锁就是在无竞争的情况下把整个同步都消除掉,连CAS操作都不做了。
当锁对象第一次被线程获取的时候,虚拟机将会把对象头中的标志位设为“01”,即偏向模式。同时使用CAS操作把获取到这个锁的线程的ID记录在对象的Mark Word之中,如果CAS操作成功,持有偏向锁的线程以后每次进入这个锁相关的同步块时,虚拟机都可以不再进行任何同步操作(例如Locking、Unlocking及对Mark Word的Update等)。当有另外一个线程去尝试获取这个锁时,偏向模式就宣告结束。
也就是说,偏向锁会偏向第一个获得它的线程,只有当其它线程尝试竞争偏向锁时,偏向模式才会失效。偏向锁是为了避免某个线程反复执行获取、释放同一把锁时的性能消耗,即如果仍是同个线程去获得这个锁,偏向锁模式会直接进入同步块,不需要再次获得锁。
锁的作用效果
偏向锁是为了避免某个线程反复执行获取、释放同一把锁时的性能消耗,而轻量级锁和自旋锁是为了避免重量级锁,因为重量级锁属于操作系统层面的互斥操作,挂起和唤醒线程是非常消耗资源的操作。
锁获取过程
最终,锁的获取过程会是,首先会尝试轻量级锁,轻量级锁会使用CAS操作来获得锁,如果轻量级锁获得失败,说明存在多线程对锁资源的竞争。此时会会尝试自旋锁,如果自旋失败,最终只能膨胀为重量级锁。
除重量级锁外,以上锁均为乐观锁。
注:本文内容参考自:《Java高并发程序设计》、《深入理解Java虚拟机》