锁的优化及注意事项
锁优化的思路及方法
一旦用到锁,就说明这是阻塞式的,所以在并发度上来说,一般都会比无锁的情况低一些。
这里所讲的锁优化,是指在阻塞式的情况下,通过优化让性能不会变得太差。当然,无论怎样优化,理论上来说性能都会比无锁的情况差一点。
总结来说,锁优化的思路和方法有如下几种:
- 减少锁的持有时间
- 减小锁粒度
- 锁分离
- 锁粗化
- 锁消除
减少锁的持有时间
只对需要同步的代码加锁!
只有在真正需要同步加锁的时候才加锁,以此减少锁的持有时间,有助于减低锁冲突的可能性,进而提升系统的并发能力。
public synchronized void syncMethod(){ otherCode1(); mutextMethod(); otherCode2(); } |
像以上这代代码,在进入方法之前就需要获取锁,此时其他线程就要在方法外等待。
这里优化的一点在于,要减少其他线程等待的时间,所以,在在有线程安全要求的程序上加锁。优化后的代码如下:
public void syncMethod(){ otherCode1(); synchronized (this) { mutextMethod(); } otherCode2(); } |
比如JDK中的处理正则表达式的java.util.regex.Pattern类
/** * Creates a matcher that will match the given input against this pattern. * * @param input * The character sequence to be matched * * @return A new matcher for this pattern */ public Matcher matcher(CharSequence input) { if (!compiled) { synchronized(this) { if (!compiled) compile(); } } Matcher m = new Matcher(this, input); return m; } |
减少锁粒度
缩小锁定对象的范围,从而减少冲突的可能性。
下面以HashTable和ConcurrentHashMap为例进行说明:
HashTable的数据结构看起来如下所示:
HashTable是使用了锁来保证线程安全的,并且所有同步操作使用的都是用一个锁对象。这样若有n个线程同时要执行get,这n个线程要串行等待来获取锁,此时加锁锁住的是整个HashTable对象。
而JDK1.7中,ConcurrentHashMap采用了Segment + HashEntry的方式进行实现,结构如下:
对于ConcurrentHashMap来说,它减少锁粒度就是将其内部结构再次分成多个Segment,其中Segment
在实现上继承了ReentrantLock
,这样就自带了锁的功能。put时,只需要对key所在的Segment加锁,而其他Segment可以并行读写,因此在保证线程安全的同时,兼顾了效率。只有在需要修改全局信息时,才需要对全部Segment加锁。
锁分离
根据功能将独占锁分离!
锁分离最常见的例子就是ReadWriteLock。与ReentrantLock独占所相比,它根据功能将独占锁分离成读锁和写锁,这样可以做到读读不互斥,读写互斥,写写互斥,既保证了线程安全,又提高了性能。类似的还有LinkedBlockingQueue,take和put方法就是使用了takeLock、putLock两把锁实现,以此使得take和put操作可以并发执行。
锁粗化
为了提高并发效率,我们要减小持有锁的时间,然后再释放锁。但凡事都有一个度,反复对锁进行请求也会浪费资源,降低性能。如果遇到一连串连续对同一锁进行请求,那么我们就需要把所有锁请求整合成对锁的一次请求,这就是锁的粗化。
synchronized (this) { for(int i = 0; i < 10000; i++) { count++; } } for(int i = 0; i < 10000; i++) { synchronized (this) { count++ } } |
虚拟机内的锁优化
Java虚拟机针对锁优化,提供了偏向锁、轻量级锁、自旋锁和锁消除四种机制。
偏向锁
所谓的偏向,就是偏心,即锁会偏向于当前已经占有锁的线程 。
核心思想是:如果一个线程获得了锁,那么所就进入偏向模式。当这个线程再次请求锁时,无需再做任何同步操作,就可以直接获得锁。这样就节省了有关锁申请的操作,从而提高了程序的性能。因此,对于几乎没有锁竞争的场合,偏向锁有比较好的优化效果,因为连续多次极有可能是同一个线程请求相同的锁。而对于锁竞争比较激烈的场合,其效果不佳,因为在竞争激烈的场合,最有可能的情况是每次都是不同的线程来请求相同的锁。这种场景下,偏向模式会时效,还不如不启用偏向锁(每次都要加一次是否偏向的判断)。
启用偏向锁:-XX:+UseBiasedLocking
轻量级锁
java的多线程安全是基于Lock机制实现的,而Lock的性能往往不如人意。原因是,monitorenter与monitorexit这两个控制多线程同步的bytecode原语,是JVM依赖操作系统互斥(mutex)来实现的。
互斥是一种会导致线程挂起,并在较短的时间内又需要重新调度回原线程的,较为消耗资源的操作。
为了优化Java的Lock机制,从Java6开始引入了轻量级锁的概念。
轻量级锁(Lightweight Locking)本意是为了减少多线程进入互斥的几率,并不是要替代互斥。
如果偏向锁失败,那么系统会利用CPU原语Compare-And-Swap(CAS)来尝试加锁的操作,尝试在进入互斥前,进行补救。它存在的目的是尽可能不用动用操作系统层面的互斥,因为那个性能会比较差。
如果轻量级锁失败,表示存在竞争,升级为重量级锁(常规锁),就是操作系统层面的同步方法。在没有锁竞争的情况,轻量级锁减少传统锁使用OS互斥量产生的性能损耗。在竞争非常激烈时(轻量级锁总是失败),轻量级锁会多做很多额外操作,导致性能下降。
自旋锁
锁膨胀之后,虚拟机为了避免线程真实的系统层面挂起线程,虚拟机还会做最后的尝试——自旋锁。
由于当前线程暂时无法获取锁,但是什么时候能够获得锁也是一个未知数,也许在未来的几个CPU周期之后就可以获得锁,这种情况下,简单粗暴的把线程挂起可能是一种得不偿失的操作。因此,基于这种假设,虚拟机会让当前线程做几个空循环(这也是自旋的含义),并且不停地尝试拿到这个锁。如果经过若干次循环后,可以获得到,那么就顺利的进入临界区。如果还不能获得锁,此时自旋锁就会膨胀为重量级锁,真实的在OS层面挂起线程。
所以在每个线程对于锁的持有时间很少时,自旋锁能够尽量避免线程在OS层被挂起,这也是自旋锁提升系统性能的关键所在。
JDK1.7中,自旋锁为内置实现。
偏向锁、轻量级锁、自旋锁总结
偏向锁、轻量级锁和自旋锁锁不是Java语言层面的锁优化方法,是内置在JVM当中的。
偏向锁是为了避免某个线程反复获得/释放同一把锁时的性能消耗,如果仍然是同个线程去获得这个锁,尝试偏向锁时会直接进入同步块,不需要再次获得锁。
轻量级锁和自旋锁都是为了避免直接调用操作系统层面的互斥操作,因为挂起线程是一个很耗资源的操作。
为了尽量避免使用重量级锁(操作系统层面的互斥),首先会尝试轻量级锁,轻量级锁会尝试使用CAS操作来获得锁,如果轻量级锁获得失败,说明存在竞争。但是也许很快就能获得锁,就会尝试自旋锁,将线程做几个空循环,每次循环时都不断尝试获得锁。如果自旋锁也失败,那么只能升级成重量级锁。
可见偏向锁,轻量级锁,自旋锁都是乐观锁。
锁消除
锁消除是在编译器级别做的事情。在即时编译时,通过对运行上下文进行扫描,去除不可能存在共享资源竞争的锁。通过锁消除,可以节省无意义的请求锁时间。
也许你会觉得奇怪,既然有些对象不可能被多线程访问,那为什么要加锁呢?写代码时直接不加锁不就好了?
其实有时候,这些锁并不是程序员所写的,有的是JDK实现中就有锁的,比如Vector和StringBuffer这样的类,它们中的很多方法都是有锁的。当我们在一些不会有线程安全的情况下使用这些类的方法时,达到某些条件时,编译器会将锁消除来提高性能。
下面以StringBuffer为例说明:
@Override public synchronized StringBuffer append(CharSequence s) { toStringCache = null; super.append(s); return this; } /** * @throws IndexOutOfBoundsException {@inheritDoc} * @since 1.5 */ @Override public synchronized StringBuffer append(CharSequence s, int start, int end) { toStringCache = null; super.append(s, start, end); return this; } @Override public synchronized StringBuffer append(char[] str) { toStringCache = null; super.append(str); return this; } /** * @throws IndexOutOfBoundsException {@inheritDoc} */ @Override public synchronized StringBuffer append(char[] str, int offset, int len) { toStringCache = null; super.append(str, offset, len); return this; } @Override public synchronized StringBuffer append(boolean b) { toStringCache = null; super.append(b); return this; } @Override public synchronized StringBuffer append(char c) { toStringCache = null; super.append(c); return this; } @Override public synchronized StringBuffer append(int i) { toStringCache = null; super.append(i); return this; } |
可见,StringBuffer的append方法都使用了synchronize关键字修饰,都是同步的,使用时都需要获得锁。
public String createString() { StringBuffer sb = new StringBuffer(); for (int i=0; i<10000; i++) { sb.append("aaa"); sb.append("bbb"); } return sb.toString(); } |
上述StringBuffer对象,只在createString方法中使用,因此它是一个局部变量。局部变量是在线程栈上分配的,属于线程的私有资源,因此不可能被其他线程访问,这种情况下,StringBuffer内部的所有加锁同步都是没必要的,如果虚拟机检测到这种情况,就会将这些不用的锁去除。
锁消除涉及到的一箱关键技术叫做逃逸分析。逃逸分析就是观察一个变量是否会逃出某个作用域。
逃逸分析必须在-server模式下运行,使用-XX:+DoEscapeAnalysis打开,使用-XX:+EliminateLocks参数打开锁消除。
ThreadLocal
ThreadLocal是解决线程安全问题一个很好的思路,它通过为每个线程提供一个独立的变量副本解决了变量并发访问的冲突问题。在很多情况下,ThreadLocal比直接使用synchronized同步机制解决线程安全问题更简单,更方便,且结果程序拥有更高的并发性。
在同步机制中,通过对象的锁机制保证同一时间只有一个线程访问变量。这时该变量是多个线程共享的。
ThreadLocal则从另一个角度来解决多线程的并发访问。ThreadLocal会为每一个线程提供一个独立的变量副本,从而隔离了多个线程对数据的访问冲突。因为每一个线程都拥有自己的变量副本,从而也就没有必要对该变量进行同步了。ThreadLocal提供了线程安全的共享对象,在编写多线程代码时,可以把不安全的变量封装进ThreadLocal。
package com.lixiuyu.demo; import java.text.SimpleDateFormat; /** * Created by lixiuyu on 2017/6/20. */ import java.text.ParseException; import java.util.Date; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; public class SimpleDateFormatDemo { private static final SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"); public static class ParseDate implements Runnable { int i = 0; public ParseDate(int i) { this.i = i; } public void run() { try { Date t = sdf.parse("2016-02-16 17:00:" + i % 60); System.out.println(i + ":" + t); } catch (ParseException e) { e.printStackTrace(); } } } public static void main(String[] args) { ExecutorService es = Executors.newFixedThreadPool(10); for (int i = 0; i < 10000; i++) { es.execute(new ParseDate(i)); } } } |
由于SimpleDateFormat是非线程安全的,因此某些情况下可能会出现类似如下的异常:
上述代码的一种可行的优化方案是在sdf.parse()前后加锁。这里我们使用ThreadLocal来优化上述代码:
public class SimpleDateFormatDemo { private static ThreadLocal<SimpleDateFormat> tl = new ThreadLocal<SimpleDateFormat>(); public static class ParseDate implements Runnable { int i = 0; public ParseDate(int i) { this.i = i; } public void run() { try { if (tl.get() == null) { tl.set(new SimpleDateFormat("yyyy-MM-dd HH:mm:ss")); } Date t = tl.get().parse("2017-06-20 17:00:" + i % 60); System.out.println(i + ":" + t); } catch (ParseException e) { e.printStackTrace(); } } } } |
从这里也可以看出,为每一个线程分配不同的私有对象的工作并不是ThreadLocal来完成的,而是需要再应用层面保证,ThreadLocal只是起到了简单的容器作用。
无锁
锁是一种悲观的策略,它总是假设每一次对临界区的操作都会产生冲突,因此必须对操作小心翼翼。如果有多个线程同时需要访问临界区,处于安全考虑,宁可牺牲性能让线程排队等待,所以说锁会阻塞线程执行。
与锁相比,无锁是一种乐观的策略,他假设会资源的访问是没有冲突的,既然没有冲突,自然无需等待,所以所有的线程都可以在不停顿的状态下执行。
当然,冲突是不可能避免发生的,那么遇到冲突怎么办呢?无锁策略使用了一种叫做比较交换的技术(CAS、Compare And Set)在鉴别线程冲突,一旦检测到冲突,就重试当前操作直至没有冲突为止。
CAS指令是个原子化的操作,它包含三个参数:CAS(param, expectValue, newValue);
param:要更新的变量
expectValue:预期值
newValue:新值
当且仅当变量param的值等于expectValue时,才将param的值改为newValue。如果param的值跟expectValue不同,表示已经有其他线程做了更新,当前线程什么都不做。
jdk并发包中的atomic包,里面实现了一些直接使用CAS操作的线程安全的类型,如AtomicInteger、AtomicLong等。
private volatile int value;// 初始化值 /** * 创建一个AtomicInteger,初始值value为initialValue */ public AtomicInteger(int initialValue) { value = initialValue; } /** * 创建一个AtomicInteger,初始值value为0 */ public AtomicInteger() { } /** * 返回value */ public final int get() { return value; } /** * 为value设值(基于value),而其他操作是基于旧值<--get() */ public final void set(int newValue) { value = newValue; } public final boolean compareAndSet(int expect, int update) { return unsafe.compareAndSwapInt(this, valueOffset, expect, update); } /** * 基于CAS为旧值设定新值,采用无限循环,直到设置成功为止 * * @return 返回旧值 */ public final int getAndSet(int newValue) { for (;;) { int current = get();// 获取当前值(旧值) if (compareAndSet(current, newValue))// CAS新值替代旧值 return current;// 返回旧值 } } /** * 当前值+1,采用无限循环,直到+1成功为止 * @return the previous value 返回旧值 */ public final int getAndIncrement() { for (;;) { int current = get();//获取当前值 int next = current + 1;//当前值+1 if (compareAndSet(current, next))//基于CAS赋值 return current; } } /** * 当前值-1,采用无限循环,直到-1成功为止 * @return the previous value 返回旧值 */ public final int getAndDecrement() { for (;;) { int current = get(); int next = current - 1; if (compareAndSet(current, next)) return current; } } /** * 当前值+delta,采用无限循环,直到+delta成功为止 * @return the previous value 返回旧值 */ public final int getAndAdd(int delta) { for (;;) { int current = get(); int next = current + delta; if (compareAndSet(current, next)) return current; } } /** * 当前值+1, 采用无限循环,直到+1成功为止 * @return the updated value 返回新值 */ public final int incrementAndGet() { for (;;) { int current = get(); int next = current + 1; if (compareAndSet(current, next)) return next;//返回新值 } } /** * 当前值-1, 采用无限循环,直到-1成功为止 * @return the updated value 返回新值 */ public final int decrementAndGet() { for (;;) { int current = get(); int next = current - 1; if (compareAndSet(current, next)) return next;//返回新值 } } /** * 当前值+delta,采用无限循环,直到+delta成功为止 * @return the updated value 返回新值 */ public final int addAndGet(int delta) { for (;;) { int current = get(); int next = current + delta; if (compareAndSet(current, next)) return next;//返回新值 } } /** * 获取当前值 */ public int intValue() { return get(); } |
参考资料
http://www.importnew.com/21353.html
http://www.10tiao.com/html/194/201703/2651478260/1.html
http://www.cnblogs.com/ten951/p/6212285.html
https://sakuraffy.github.io/intercurrent_lock_majorizing/
http://www.cnblogs.com/java-zhao/p/5140158.html
《Java高并发程序设计》
《Java并发编程的艺术》