在高并发的环境下,锁是最常用的方法之一,激烈的锁竞争会导致系统出现性能方面的问题,这时就需要我们做一些锁的优化。
一.减小锁的持有时间
对于使用锁进行并发控制的应用程序而言,在锁竞争中,单个线程对锁持有时间与系统性能有直接的关系。如果线程持有时锁的时间很长那么相对的,锁的竞争也就越来越激烈。
举个例子: 如果要求20个人填写自我介绍,但是只有一支笔,如果有一个人抢到了笔,但是这个人没有想好怎么写,拿着笔去想,那么其他人只能等待,如果每个人都如此,无形中增加了许多时间。但是,如果没个人都想好了 再去拿笔去写,这样就能节省很长时间。
代码例子:
public synchronized void syncMethod(){
othercode1();
mutextMethod();
othercode2();
}
syncMethod方法中,假设只有mutextMethod()方法是要做同步控制,其他两个都不需要。如果其他两个是重量级方法,业务处理需要一些时间,那么这样就会加大CPU的消耗。
一个较为优化的解决方案是,只对mutextMethod()方法做同步控制。
public void syncMethod(){
othercode1();
synchronized(this){
mutextMethod();
}
othercode2();
}
在改进的代码中,只对mutextMethod()方法做同步控制,锁占用的时间相对较短,这样在高并发的情况下,能提高效率。
在JDK的源码中,也能找到类似的解决方案。比如,处理正则表达式的Patten类
public Match matcher(CharSequence input){
if(!compiled){
synchronized (this){
if(!compiled){
compile();
}
}
}
Matcher m = new Matcher(this,input);
return m;
}
二.减小锁的粒度
减小锁的粒度,也是一种削弱多线程竞争的手段。这种技术典型的使用场景就是ConcurrentHashMap类的实现。
synchronized (f) {
if (tabAt(tab, i) == f) {
if (fh >= 0) {
binCount = 1;
for (Node<K,V> e = f;; ++binCount) {
K ek;
if (e.hash == hash &&
((ek = e.key) == key ||
(ek != null && key.equals(ek)))) {
oldVal = e.val;
if (!onlyIfAbsent)
e.val = value;
break;
}
Node<K,V> pred = e;
if ((e = e.next) == null) {
pred.next = new Node<K,V>(hash, key,
value, null);
break;
}
}
}
else if (f instanceof TreeBin) {
Node<K,V> p;
binCount = 2;
if ((p = ((TreeBin<K,V>)f).putTreeVal(hash, key,
value)) != null) {
oldVal = p.val;
if (!onlyIfAbsent)
p.val = value;
}
}
}
}
这是 put时候,源码里并没有全局加锁,而是首先根据hashcode得到该表项应该存放到哪个字段中,然后对该段加锁,并完成put操作。
但是,减小锁的粒度会引入一个新的问题,就是当系统需要取得全局锁的时候,其消耗的资源会更多。还是以concurrentHashMap为例子,concurrentHashMap的size()方法,他将返回concurrentHashMap的所有有效表项的数量,即,concurrentHashMap的全部有效表项之和。要获取这个信息需要取得所有子段的锁。
sum = 0;
for (int i = 0; i < segments.length; ++i) {
segments[i].lock();
}
for (int i = 0; i < segments.length; ++i) {
sum += segments[i].count();
}
for (int i = 0; i < segments.length; ++i) {
segments[i].unLock();
}
concurrentHashMap也不总是这样执行,size()方法会先使用无锁的方式求和,执行失败了才会尝试这种加锁的方式。但是在高并发场合,concurrentHashMap的size()方法性能肯定要差于同步的HashMa()。
因此,只有在类似于size()获取全局信息的方法调用并不频繁时,这种减小锁粒度的方法才能真正意义上提高系统吞吐量。
三.锁分离
将读写锁的思想做一些延伸就是锁分离。 读写锁根据读写操作功能上的不同,进行了有效的锁分离。典型的案例就是 java.util.concurrent.LinkedBlockingQueue 的实现。
在LinkedBlockingQueue的实现中,take()函数和put()函数分别实现了从队列中取得数据和往队列中增加数据的功能。虽然两个函数都对列表进行了修改操作,但由于LinkedBlockingQueue是基于链表的,因此,两个操作分别作用于队列的前端和尾端,从理论上,互不冲突。
如果使用独占锁,那么在take()和put()操作时,就不可能真正的并发,两个操作需要互相等待彼此释放资源。在这种情况下,锁竞争比较激烈,从而影响程序在高并发时的性能。
因此,在JDK的实现中,并没有采用独占锁的方式,而是两把不同的锁,分离了take()和put()。
/** Lock held by take, poll, etc */
private final ReentrantLock takeLock = new ReentrantLock();
/** Wait queue for waiting takes */
private final Condition notEmpty = takeLock.newCondition();
/** Lock held by put, offer, etc */
private final ReentrantLock putLock = new ReentrantLock();
/** Wait queue for waiting puts */
private final Condition notFull = putLock.newCondition();
上述代码,定义了takeLock和putLock,分别在take()和put()操作中使用。只会在take()和take()间,put()和put()间进行锁的竞争,从而削弱了锁竞争的可能性。
public E take() throws InterruptedException {
E x;
int c = -1;
final AtomicInteger count = this.count;
final ReentrantLock takeLock = this.takeLock;
takeLock.lockInterruptibly(); //锁住,不能有两个线程同时take操作
try {
while (count.get() == 0) {
notEmpty.await(); //如果当前没有数据,一直等待put的操作通知
}
x = dequeue(); //取得第一个数据
c = count.getAndDecrement();//数量减1,原子操作,因为会和put()同时访问count。
if (c > 1)
notEmpty.signal();//通知其他tabke操作
} finally {
takeLock.unlock();//释放锁
}
if (c == capacity)
signalNotFull();//通知put操作已有空间。
return x;
}
同样,函数put()的操作也类似,就不在讲解。
四.锁粗化
虚拟机在遇到一连串连续地对同一锁不断进行请求和释放的操作时,便会把所有的锁操作整合成对锁的一次请求。从而减少锁的请求同步次数,这个操作叫做锁的粗粗化。 比如:
public void demoMethod() {
synchronized (lock){
//do.sh
}
//做其他不必要的工作很快能执行完毕
synchronized (lock){
}
}
会被整合成如下形式
public void demoMethod() {
synchronized (lock){
//do.sh
//做其他不必要的工作很快能执行完毕
}
}
在开发中我们合理进行锁的粗化,避免频繁的请求与释放锁,尤其是在循环内,应该将锁放到循环外面。在这就不一一举例子了。