-
减少锁持有时间
- 问题描述:锁竞争时,单个线程对锁的持有时间与系统性能有着直接的关系。线程持有锁的时间很长,相对的,锁的竞争程度也就越激烈。在程序开发中,应该尽可能减少对某个锁的占有时间,以减少线程间互斥的可能;
-
示例:
public synchronized void syncMethod(){ othercode1(); mutextMethod(); othercode2(); } | 假设mutextMethod()方法需要同步,而ordercode1()和ordercode2()并不需要做同步控制。如果othercode1()和othercode2()分别是重量级的方法,则会花费较长的CPU时间。如果在并发量较大,使用这种堆这个方法做同步的方案,会导致等待线程大量增加。因为一个线程,在进入方法时获得内部锁,只有在所有任务都执行完后,才会释放锁。 |
-
优化方案:在必要时进行同步,减少线程持有锁的时间,提高系统的吞吐量
public void syncMethod2(){ othercode1(); synchronized(this){ mutextMethod(); } othercode2(); } | 这一版的代码,只针对mutextMethod()方法做了同步,锁占用的时间相对较短,因此能有更高的并行度。 |
- 结论:减少锁的持有时间有助于降低锁冲突的可能性,进而提升系统的并发能力
-
减小锁粒度
- 减小锁粒度也是一种削弱多线程锁竞争的有效手段;
-
典型场景:ConcurrentHashMap类的实现就采用了这种手段
- 普通集合对象的多线程同步,最常使用的方式就是get()和add()方法进行同步。每当对集合进行这两种操作时,总是获得集合对象的锁。
- 事实上没有两个线程可以做到真正的并发,任何线程在执行同步方法时,总要等待前一个线程执行完毕;
- 在高并发时,锁竞争会影响系统的吞吐量;
-
ConcurrentHashMap内部实现机制:
- 将整个HashMap分成若干个段(Segment),每个段都是一个子HashMap;
- 如果要在ConcurrentHashMap中增加一个新的表项,并不会将整个HashMap加锁,会先根据hashcode得到该表项应该被存放的段,然后对该段加锁,并完成put操作;
- 如果多个线程同时进行put()操作,只要被加入的表项不存放在同一个段中,则线程间可以做到真正的并行;
-
ConcurrentHashMap拥有16个段,最高接受16个线程同时插入(如果都插入到不同的段中)。
- 上图显示6个线程同时对ConcurrentHashMap进行访问,此时,线程1,2,3分别访问段1,2,3,由于段1,2,3使用独立的锁保护,因此,3个线程可以同时访问ConcurrentHashMap,而线程4,5,6也需要访问段1,2,3,则必须等待前面的线程结束访问才能进入ConcurrentHashMap;
-
问题:
- 减少锁粒度时,如果系统需要取得全局锁,其消耗的资源会比较多;
-
示例:ConcurrentHashMap类的put()方法分离了锁,如果访问ConcurrentHashMap的全局信息时,需要同时取得所有段的锁方能顺利实施。
- 比如ConcurrentHashMap的size()方法,它返回ConcurrentHashMap的有效表项的数量。要获取这个信息需要取得所有子段的锁,size()方法的部分源代码如下:
| ||
结论:在高并发场合ConcurrentHashMap()的size()性能依然要差于同步的HashMap |
- 总结:所谓减少锁粒度,是指缩小锁定对象的范围,从而减少锁冲突的可能性,进而提高系统的并发能力;
-
锁分离
- 锁分离是减小锁粒度的一个特例,它依据应用程序的功能特点,将一个独占锁分成多个锁。
-
示例:LinkedBlockingQueue中,take()函数和put()函数分别实现了从队列中取得数据和往队列中增加数据的功能
- 两个函数对当前队列进行了修改操作,但LinkedBlockingQueue基于链表,两个操作分别作用于队列的前端和尾端,从理论上,两者并不冲突;
- 独占锁要求两个操作进行时获取当前队列的独占锁,那么take()与put()就不可能真正并发,在运行时,它们会彼此等待对方释放锁资源;
- 这种情况下,锁竞争会影响程序在高并发时的性能;
- 所以在JDK中分离了这两个函数;
- take()的实现
public class TakeTest { public E take() throws InterruptedException{ E x; int finally{ takeLock.unlock(); } = -1; final AtomicInteger count = this.count; final ReentrantLock takeLock = this.takeLock; takeLock.lockInterruptibly(); //不能有两个线程同时取数据 try{ try{ while(count.get() == 0){//如果当前没有可用数据,一直等待 notEmpty.await();//等待,put()操作的通知 } }catch(InterruptedException ie){ notEmpty.signla();//通知其他未中断的线程 throw ie; } x = extract();//取得第一个数据 c = count.getAndDecrement();//数量减1,原子操作,因为会和put() //函数同时访问count。注意:变量c是count减1前的值 if(c>1){ notEmpty.signal();//通知其他take()操作 } }finally{ takeLock.unlock();//释放锁 } if(c == capacity){ signalNotFull();//通知put()操作,已有空余空间 } return x; } } |
- put()的实现
public class TakeTest { public void put(E e) throws InterruptedException{ if(e==null) throws InterruptedException(); int c = -1; final ReentranLock putLock = this.putLock; final AtomicInteger count = this.count; putLock.lockInterruptibly();//不能有两个线程同时进行put() try{ try{ while(count.get()==capacity){//如果队列已经满了 notFull.await();//等待 } }catch(InterruptedException ie){ notFull.signal();//通知未中断的线程 throw ie; } insert(e);//插入数据 c = count.getAndIncrement();//更新总数,变量c是count加1前的值 if(c + 1<capacity){ notFull.signal();//有足够的空间,通知其他线程 } }finally{ putLock.unlock();//释放锁 } if(c==0){ signalNotEmpty();//插入成功后,通知take()操作取数据 } } } |
-
锁粗化
-
为了保证多线程间的并发,会要求线程在使用完公共资源后,立即释放锁;这样,等待在这个锁上的其他线程才能尽早地获得资源执行任务;
- 问题说明:如果一个锁不停地进行请求,同步和释放,本身也会消耗系统的资源,反而不利于性能的优化!
- 解决方案:虚拟机在遇到一连串连续地对同一锁不断进行请求和释放的操作时,便会把所有的锁操作整合成对锁的一次请求,从而减少对锁的请求同步次数。这个操作叫做锁的粗化:
-
public void demoMethod(){ synchronized(lock){ //do sth } //做其他不需要同步的工作,但能很快执行完毕 synchronized(lock){ //do sth } } |
上段代码会被整合为: |
public void demoMethod(){ //整合成一次锁请求 synchronized(lock){ //do sth //做其他不需要同步的工作,但能很快执行完毕 } } |
-
在开发中,应在合理的场合进行锁的初始化,尤其是在循环内;
- 示例:下面是一个循环内请求锁的例子
for (int i = 0; i < CIRCLE; i++) { synchronized(lock){ } } |
以上代码在每一个循环时,都对同一个对象申请锁。此时,应该将锁粗化成: |
synchronized(lock){ for (int i = 0; i < CIRCLE; i++) { } } |
第一种情况会对锁进行大量的请求,而第二种情况只进行一次锁请求,因此,后者的性能会远远高于前者,随着循环次数的增加,性能差距会越来越明显 |
-
总结
-
性能优化是根据运行时的真实情况对各个资源点进行权衡折中的过程。
- 锁粗化的思想和减少锁持有时间是相反的,但在不同的场合,它们的效果并不相同。应根据实际情况进行权衡;
- 偏向锁,自旋锁,作用虚拟机内部的锁优化策略,它们也不是绝对可以提高系统性能,对锁的优化,还是需要做更多的权衡和思考;
-