对于单任务或者单线程的应用程序而言,主要资源消耗都花在任务本身,它既不需要维护并行数据结构间的一致性,也不需要为线程的切换和调度花费时间。但对于多线程的应用来说,系统出了处理功能需求外,还需要额外维护多线程环境的特有信息,如线程本身的元数据、线程的调度、线程上下文的切换等。在高并发环境下,激烈的锁今早会导致程序性能下降,自然有必要讨论一些有关锁的性能问题以及相关的一些注意事项
一,有助于提高锁性能的几点建议
1.减小锁持有时间
如果线程持有锁的时间很长,那么相对地,锁的竞争程度也就越激烈,eg:
public synchronized void syncMethos(){
othercode1();
mutextMethos();
othercode2();
}
上述代码中,假设只有mutextMethos()方法是需要同步的,其他两个无需同步且是重量级的方法,则会花费较长的CPU时间,此时,如果并发量大,使用这种对整个方法做同步的方案,会导致等待线程大量增加,因为一个线程在进入该方法时获得内部锁,只有在所有任务都执行完后才会释放锁。so,较为优化的方案,只在必要时进行同步,这样就能明显减少线程持有锁的时间,提高系统吞吐量
public void syncMethos(){
othercode1();
synchronized(this){
mutextMethos();
}
othercode2();
}
2.减小锁粒度
典型的使用场景就是ConcurrentHashMap
对于HashMap来说,最重要的两个方法即get和put,最自然的想法就是对整个HashMap加锁,必然可以得到一个线程安全的对象,但是这样的锁的粒度太大。ConcurrentHashMap,它内部进一步细分了若干个小的HashMap,称为段(segment),默认情况下,一个ConcurrentHashMap被进一步细分为16个段
如果需要在ConcurrentHashMap中增加一个表项,并不是将整个HashMap加锁,而是首先根据hashCode得到该表项应该被放在哪个段中,然后对该段加锁,并完成put操作,在多线程环境中,如果多个线程同时进行put操作,只要被加入的表项不放在同一个段中,则线程间便可以做到真正的并行
3.读写分离锁来替代独占锁
读写锁ReadWriteLock之前在写Lock的时候提到过,使用读写分离锁是减小锁粒度的一种特殊情况,是通过对系统功能点分割提高系统性能,应用在读多写少的场合
4.锁分离
典型的例子即LinkedBlockingQueue的实现,take函数和put函数分别实现了从队列中取数据和增加数据的功能,虽然两个函数都对队列进行修改操作,但由于LinkedBlockQueue基于链表,两个操作分别在队头和队尾,理论上说并不冲突
若使用独占锁,则take和put操作就不能真正并发,在运行时,它们会彼此等待释放锁资源,在JDK中,使用两把不同的锁:
/** 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();
take操作时,如果队列为空,则让当前线程等待在notEmpty上,新元素入队列时,则进行一次notEmpty上的通知
public E take() throws InterruptedException {
E x;
int c = -1;
final AtomicInteger count = this.count;
final ReentrantLock takeLock = this.takeLock;
takeLock.lockInterruptibly();
try {
while (count.get() == 0) {//如果当前没有可用数据,一直等待
notEmpty.await();//等待put操作的通知
}
x = dequeue();
c = count.getAndDecrement();
if (c > 1)
notEmpty.signal();//通知其他take操作
} finally {
takeLock.unlock();
}
if (c == capacity)
signalNotFull();//通知put操作,已有空余空间
return x;
}
相应的put
public void put(E e) throws InterruptedException {
if (e == null) throw new NullPointerException();
// Note: convention in all put/take/etc is to preset local var
// holding count negative to indicate failure unless set.
int c = -1;
Node<E> node = new Node<E>(e);
final ReentrantLock putLock = this.putLock;
final AtomicInteger count = this.count;
putLock.lockInterruptibly();
try {
/*
* Note that count is used in wait guard even though it is
* not protected by lock. This works because count can
* only decrease at this point (all other puts are shut
* out by lock), and we (or some other waiting put) are
* signalled if it ever changes from capacity. Similarly
* for all other uses of count in other wait guards.
*/
while (count.get() == capacity) {//如果队列已经满了,等待
notFull.await();
}
enqueue(node);
c = count.getAndIncrement();
if (c + 1 < capacity)
notFull.signal();
} finally {
putLock.unlock();
}
if (c == 0)
signalNotEmpty();//插入成功后,通知take操作取数据
}
5.锁粗化
虚拟机在遇到一连串连续地对同一锁不断进行请求和释放的操作时,便会把所有的锁操作整合成对锁的一次请求,从而减少对锁的请求同步次数
public void demoMethod(){
synchronized(lock){
//do sth.
}
//做其他不需要的同步的工作,但很快能执行完毕
synchronized(lock){
//do sth.
}
}
优化整合
public void demoMethod(){
//整合为一次请求
synchronized(lock){
//do sth.
//做其他不需要的同步的工作,但很快能执行完毕
}
}
二,锁优化
(本来想记个笔记再次翻的时候比较方便,结果昨天写了很久忘记保存。。。今天补上,今后希望不再犯这样的错误)
1.锁偏向
JDK1.6中引入的一项锁优化。当一个线程访问同步块并获得锁时,会在对象头和栈帧中的锁记录里存储锁偏向的线程ID,以后该线程在进入和退出同步块时不需要进行CAS操作来加锁和解锁,只需要简单测试一下对象头的Mark Word里是否存储着指向当前线程的偏向锁,如果测试成功,表示当前线程已经获得了锁,若测试失败,需测试下Mark Word中偏向锁的标识是否设置为1(表示当前是偏向锁),如果没有设置,则使用CAS竞争锁,如果设置了,则尝试使用CAS将对象头的偏向锁指向当前线程
2.轻量级锁
JDK1.6之中加入的新型锁机制。如果偏向锁失败,虚拟机并不会立即挂起线程,使用一种称为轻量锁的优化手段,即将对象头部作为指针,指向持有锁的线程堆栈的内部,来判断一个线程是否持有对象锁,如果线程获得轻量锁成功,则可顺利进入临界区,如果轻量级锁加锁失败,则表示其他线程抢先争夺到了锁,那么当前线程的锁请求就会膨胀为重量级锁
3.自旋锁
锁膨胀后,虚拟机为了避免线程真实的在操作系统层面挂起,还会做最后的努力,即自旋。系统会进行一次赌注:假设在不仅将来,线程可以得到这把锁,因此,虚拟机让当前线程做几个空循环(即自旋含义),在经过若干次循环后,如果可以得到锁,那么就进入临界区,如果还不能得到锁,才会真实地将线程在操作系统层面挂起
自旋锁在JDK1.4.2中就已经引入,不过默认是关闭的,可以使用-XX:+UseSpinning参数来开启,在JDK1.6中就已经改为默认开启了,自旋次数的默认值是10次,可以使用参数-XX:PreBlockSpin来更改
在JDK1.6中引入了自适应的自旋锁,自适应意味着自旋的时间不再固定,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。如果在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也很有可能再次成功,进而它将允许自旋等待持续相对更长的时间,比如100个循环
4.锁消除
即通过对运行上下文的扫描,取出不可能存在共享资源竞争的锁,so,可能会有个问题,如果不可能存在竞争,为什么程序员还要加上锁?看个程序
public String [] createStrings(){
Vector<String> v=new Vector<>();
for(int i=0;i<100;i++){
v.add(Integer.toString(i));
}
return v.toArray(new String[]{});
}
代码中的Vector,由于变量v只在函数中使用,因此它只是一个单纯的局部变量,局部变量是在线程栈上分配的,属于线程私有的数据,因此不可能被其他线程访问,so在这种情况下加锁同步是没有必要的,如果虚拟机检测到这种情况,就会将这些无用的锁操作去除
锁消除涉及到的关键技术为逃逸分析,即观察某一个变量是否会逃出某一个作用于,在本例中,变量v显然没有逃出函数之外,以此为基础虚拟机才可以大胆地将v内部的加锁操作去除,如果函数返回的不是String数组而是v本身,则认为变量v逃逸出了当前函数,也就是说v有可能被其他线程访问,若是这样,虚拟机就不能消除v中的锁操作
逃逸分析必须在-server模式下运行,可以使用-XX:+DoEscapeAnalysis参数打开逃逸分析,使用-XX:+EliminateLocks参数可以打开锁消除
持续更新。。。
参考:
《深入理解java虚拟机》
《java高并发程序设计实战》