1 提高锁性能
1.1 减小锁持有时间
锁持有的时间会影响其他线程的等待时间,从而影响整体性能,如果一个锁持有时间较长,在并发量大的情况下,等待线程数量会大幅增加,导致竞争激烈,系统性能低下。
解决方案
:分析代码,在必要时进行同步,减小同步范围,也就是只在互斥代码部分才持有锁,减少锁持有时间,提高系统吞吐性能。
看个例子:
public synchronized void syncMethod(){
othercode1();
mutextMethod();
othercode2();
}
上面是一个同步方法,在这个方法上用了synchronized
关键字加锁,方法里面执行三个操作,只有mutextMethod();
是互斥的操作,也就是必须同步执行,其他两个方法可以异步执行,如果 othercode1()和othercode2()比较耗时的话,那这个方法持有锁的时间就会变长,外部就需要一直等待。
优化方法就是分析上面的方法真正需要进行同步的部分是哪里,不要全部加锁,而是局部加锁,减小锁持有时间,很显然,只要在执行到mutextMethod();
这一行的时候持有锁就行了,因此改成下面代码。
public void syncMethod(){
othercode1();
synchronized(this){
mutextMethod();
}
othercode2();
}
JDK源码里也使用了这个方法,看一个Pattern的案例:
public Matcher matcher(CharSequence var1) {
if (!this.compiled) {
synchronized(this) {
if (!this.compiled) {
this.compile();
}
}
}
Matcher var2 = new Matcher(this, var1);
return var2;
}
减少锁的持有时间有助于降低锁冲突的可能性,进而提升系统的并发能力
1.2 减小锁粒度
减小锁粒度典型使用场景:ConcurrentHashMap
因为HashMap不是线程安全的,如果想要变得线程安全,最简单的做法就是对整个HashMap加锁,但是这样显然锁的粒度太大。
JDK7
中ConcurrentHashMap内部细分了若干个小HashMap,叫做段(SEGMENT),默认16个。
在ConcurrentHashMap中,如果要进行put操作,会先根据需要put的hashcode得到该item应该被加到哪个SEGMENT中去,然后再对那个SEGMENT加锁,而不是对整个ConcurrentHashMap加锁,这样的话,如果一个ConcurrentHashMap对象有多个item需要执行put操作,而恰巧这几个item被加到不同的SEGMENT中,那就完全可以并行操作,因为默认有16个SEGMENT,因此默认情况下,最多可以并行16个put操作。
JDK8
中则不再采用SEGMENT的方案,而是用了CAS算法。
1.3 读写分离锁替换独占锁
读写锁也是一种局部加锁,在功能上做了拆分,对于读操作,不会对数据做修改,可以并发执行。
读多写少的场合,使用读写锁可以有效提升系统的并发能力。
1.4 锁分离
典型案例:LinkedBlockingQueue
这是一个链表实现的队列,既然是队列数据结构,那就是遵循FIFO先进先出,take()
和put()
是读取和添加操作,读取作用在链表的头部,添加是在尾部,两者没有数据冲突,那么使用独占锁的话,每次进行take
和put
操作都会获得队列的锁,导致同一时间只有一个操作可以执行,其他都要等待,影响了并发性能,所以在实现中没有使用独占锁
,而是使用分离锁
。take和put分别持有一把锁。
//take()持有takeLock
private final ReentrantLoc takeLock;
private final Condition notEmpty;
//put()持有putLock
private final ReentrantLock putLock;
private final Condition notFull;
//构造方法
public LinkedBlockingQueue(int var1) {
this.count = new AtomicInteger(;
this.takeLock = newReentrantLock();
this.notEmpty =this.takeLock.newCondition();
this.putLock = new ReentrantLoc();
this.notFull =this.putLock.newCondition();
if (var1 <= 0) {
throw newIllegalArgumentException();
} else {
this.capacity = var1;
this.last = this.head = newLinkedBlockingQueue.Node(Object)null);
}
}
接下来分析一下take()的源码:
public E take() throws InterruptedException {
boolean var2 = true;
AtomicInteger var3 = this.count;
ReentrantLock var4 = this.takeLock;
var4.lockInterruptibly();
Object var1;
int var8;
try {
//队列为空时持续等待
while(var3.get() == 0) {
this.notEmpty.await();
}
//队列不为空,获取数据
var1 = this.dequeue();
//队列数量减一,var8是减一前的值
var8 = var3.getAndDecrement();
//如果之前队列数量大于1,通知其他take操作
if (var8 > 1) {
this.notEmpty.signal();
}
} finally {
//释放锁
var4.unlock();
}
//有剩余空间,通知put操作可以添加
if (var8 == this.capacity) {
this.signalNotFull();
}
return var1;
}
put()源码类似,不做记录。
1.5 锁粗化
虚拟机对连续对同一锁进行请求和释放的操作,会把所有的锁操作合并成对锁的一次请求,从而减少对锁的请求同步次数,这种操作就是锁粗化。
for (int i = 0; i < count; i++){
synchronized (lock){
//do sth
}
}
//锁粗化
synchronized (lock){
for (int i = 0; i < count; i++){
//do sth
}
}
上面代码第一个循环每次都要请求锁完成一些操作,这样多次的请求释放同样消耗性能,比较合理的做法就是做一次锁粗化,在循环外层请求一次锁。
锁粗化和减少锁的持有时间是相反的操作,但最终想要达到的目的是相同的,就是为了提高并发的性能,至于如何选择,要具体情况具体分析。
2 JVM对锁的优化
2.1 锁偏向
核心思想:如果一个线程获得了锁,那么锁就进入偏向模式,当这个线程再次请求锁时,无需再做任何同步操作。
锁偏向适合用在锁竞争不是很激烈的情况,会有比较好的优化效果,设置JVM参数-XX:+UseBiasedLocking可以开启偏向锁。
2.2 轻量级锁
锁偏向失败,就会将线程升级为轻量级锁,具体加锁过程参见文章Java并发编程:Synchronized底层优化(偏向锁、轻量级锁), 如果加锁失败则会变为重量级锁。
2.3 自旋锁
当前线程不挂起,而是做空循环,希望在一段时间之后获得锁进入临界区,如果自旋锁阶段过后还不能得到锁,线程就会被挂起。
2.4 锁消除
JVM在JIT编译时,通过对上下文扫描,去除掉不存在竞争的锁请求代码,减少无用的锁请求时间。
举个例子: 在一个单线程方法里使用Vector去做数据集合的操作,然后我们都知道Vector是线程安全的,里面有很多关于锁的操作,然后单线程同步方法里根本不用关心竞争问题,这时候就会做很多无谓的锁请求,JVM会对其进行优化。
锁消除的关键技术:逃逸分析
,就是分析变量是否会越过作用域,被作用域外使用。
“逃逸分析必须在-server模式下进行,可以使用-XX:+DoEscapeAnalysis参数打开逃逸分析。使用-XX:+EliminateLocks参数可以打开锁消除。”
摘录来自: 葛一鸣/郭超/. “实战Java高并发程序设计。” iBooks.
参考资料
- 《实战Java高并发程序设计》第二版
- Java并发编程:Synchronized底层优化(偏向锁、轻量级锁)