锁的优化及注意事项(读书笔记)
有助于提高锁性能的几点建议
- 减少锁的持有时间 真正需要同步加锁的时候在加锁,减少锁的持有时间有助于减低锁冲突的可能性,进而提升系统的并发能力,
- 减少颗粒度,所谓减少颗粒度就是指缩小锁定对象的范围,从而减少锁冲突的可能性,进而提升系统的并发能力,问题在于类似于size()获取全局信息的方法调用并不频繁时,这种减少颗粒度的方法才能真正意义上提高系统吞吐量.(分割数据结构实现)
- 读写分离锁替换独占锁,读写锁是对系统功能点的分割 ReadWriteLock 在读多写少的场合,读写锁对系统性能是很有好吃的,因为如果系统在读写数据时均只使用独占锁,那么读操作和写操作间,读操作和读操作间,写操作和写操作间均不能做到真正的并发,并且需要互相等待.而读操作本身不会影响数据的完整性和一致性,因此 理论上讲,在大部分情况下,应该允许多线程同时读,
锁分离
如果将读写锁的思想做进一步的延伸,就是锁分离,读写锁根据读写操作功能上的不同,进行了有效的锁分离,依据应用程序的功能特点,使用类似的分离思想,也可以对独占锁进行分离,一个典型案例就是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();//take锁 /** Wait queue for waiting takes */ private final Condition notEmpty = takeLock.newCondition();//生成一个take锁的绑定Condition实例 /** Lock held by put, offer, etc */ private final ReentrantLock putLock = new ReentrantLock();//put锁 /** Wait queue for waiting puts */ private final Condition notFull = putLock.newCondition();//put锁
以上的代码片段,定义了takeLock和putLock,他们分别在take()操作和put()操作中使用,因此,take()函数和put()函数就此互相独立,他们之间不存在锁竞争关系,只需要在take()和take()间,put()和put()间分别对takeLock和putLock进行竞争,从而 削弱了锁竞争的可能性.
函数take()的实现下:
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();//数量减1,原子操作,因为会和put()函数同时访问count.注意:变量c是count减1前的值 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();//更新数据,变量c是count加1前的值 if (c + 1 < capacity) notFull.signal();//有足够空间,通知其他线程 } finally { putLock.unlock();//释放锁 } if (c == 0) signalNotEmpty();//插入成功后,通知take操作取数据 }
通过takeLock和putLock两把锁,LinedBlockingQueue实现了取数据和写数据的分离,是两者在真正意义上成为可并发的操作
- 锁粗化
通常情况下 为了保证多线程间的有效并发,会要求每个线程持有锁的时间尽量的短,就是使用完公共资源后,立即释放锁,只有这样,等待爱这个锁上的其他线程才能今早的获得资源执行任务,但是,凡是都要有一个度,如果对用一个锁不停的请求,同步,释放,其本身也会消耗系统宝贵的资源,反而不利于性能的优化,
为此,虚拟机在遇到一连串的连续第对同一个锁不断进行请求和释放的操作时,便会把所有的锁操作整合成对锁的一次请求,从而减少对锁的请求同步次数,这个操作叫做锁的粗化,
在开发过程中,我们应该有意识地在合适的场合进行锁的粗化,尤其是在循环内请求锁时,
性能优化就是根据运行时的真实情况对各个资源点进行权衡折中的过程,锁粗化的思想和减少所持有时间是相反的,但在不同的场合,他们的效果并不相同,所以大家需要根据实际情况,进行权衡.