Java高效并发(四)----锁优化、ConcurrentHashMap、LinkedBlockingQueue锁分离

有助于提高锁性能的几点建议


 

 减少锁的持有时间

只有在必要的时候进行同步,明显减少锁的持有时间,降低锁冲突的可能性,提高并发能力 

比如,使用synchronize同步锁,尽量加到要对象需要共享变量状态的时候,不是一味的对整个方法前加synchronize,直接给调用这个方法的对象上锁,增大了锁竞争的概率


 读写锁分离替换独占锁

 之前讲过使用ReadWriteLock读写分离提高效率,

 再引申就是锁分离策略

对独占锁分离这种技术典型的引用场景就是LinkedBlockingQueue任务队列,前面我们说过它是无界的任务队列,基于链表实现,它的take()方法和put()方法分别是作用于队列的前端和尾端,互补影响,所以在jdk的实现中,为这两个操作提供了两把锁,

 那比如多线程执行put()操作最需要竞争putLock,take操作竞争takeLock

 /** 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();

 锁粗化

对于一直不断申请和释放同一个资源的锁可以整合成对锁的一次性请求,从而减少申请释放动作的消耗。

比如:

for(int i=0;i<n;i++){
		synchronized(lock){}
		
	}

优化为

 synchronized(lock){
        for(int i=0;i<n;i++){}
    }

减小锁粒度

这种技术典型的应用场景就是ConcurrentHashMap的实现,相比HashMap,他是线程安全,相比HashTable它是高效并发

 

ConcurrentHashMap实现原理

ConcurrentHashMap在jdk1.7和jdk1.8的实现有很大的区别

 ConcurrentHashMap底层结构是一个Segment数组,默认大小是16,每个Segment数组又可以看成是一个小的HashMap,也就是说Segment数组使用链表加数组实现的。

 

  • jdk 1.7的实现基于分段锁的ConcurrentHashMap

那比如我们需要给Map插入一个新的键值对,首先根据key的hashcode找到它应该插入到哪个段,然后对这一段进行加锁完成put操作,这里segment就充当锁的作用,因为Segment 类继承于 ReentrantLock 类,获取锁时,并不直接使用lock来获取,因为该方法获取锁失败时会挂起。事实上,它使用了自旋锁,如果tryLock获取锁失败,说明锁被其它线程占用,此时通过循环再次以tryLock的方式申请锁。那么在多线程中,只要插入的数据定位不在一个段中,也不会发生锁竞争导致阻塞,线程间就可以做到真正的并发。

问题:当需要进行跨段操作的时候,也就是当系统需要获得全局锁的时候,就比较麻烦了,它要查看每个段的锁情况,只有顺利取得全部段的锁才能获取全局信息。比如ConcurrentHashMap的size()方法,就要获得所有子段的锁

 

 

 

  • jdk 1.8的实现基于CAS的ConcurrentHashMap

jdk1.7的ConcurrentHashMap最大的并发性与分段的段数相等,jdk 1.8为进一步提高并发性,摒弃了分段锁的方案,而是直接使用一个大的数组。同时为了提高哈希碰撞下的寻址性能,Java 8在链表长度超过一定阈值(8)时将链表(寻址时间复杂度为O(N))转换为红黑树(寻址时间复杂度为O(log(N)))。同样是通过Key的哈希值与数组长度取模确定该Key在数组中的索引

 

对于put操作,如果Key对应的数组元素为null,则通过CAS操作将其设置为当前值。如果Key对应的数组元素(也即链表表头或者树的根元素)不为null,则对该元素使用synchronized关键字申请锁,然后进行操作。如果该put操作使得当前链表长度超过一定阈值,则将该链表转换为树,从而提高寻址效率。总结如下

第一种:table[index]中没有任何其他元素,即此元素没有发生碰撞,这种情况直接存储就好了哈。

第二种,table[i]存储的是一个链表,如果链表不存在key则直接加入到链表尾部即可,如果存在key则更新其对应的value。

第三种,table[i]存储的是一个树,则按照树添加节点的方法添加就好

对于读操作,由于数组被volatile关键字修饰,因此不用担心数组的可见性问题。同时每个元素是一个Node实例(Java 7中每个元素是一个HashEntry),它的Key值和hash值都由final修饰,不可变更,无须关心它们被修改后的可见性问题。而其Value及对下一个元素的引用由volatile修饰,可见性也有保障。

size操作:大的数组每个都有维护一个计数器,put方法和remove方法都会通过addCount方法维护Map的size。size方法通过sumCount获取由addCount方法维护的Map的size。

特别需要注意的是,之所以在每个数组中包含一个计数器,而不是在 ConcurrentHashMap 中使用全局的计数器,是对 ConcurrentHashMap 并发性的考虑:因为这样当需要更新计数器时,不用锁定整个ConcurrentHashMap特别需要注意的是,count是volatile的,这使得对count的任何更新对其它线程都是立即可见的

可以看到concurrentmap在1.8的实现和hashmap在jdk1.8的实现是非常相近的

 

阅读更多
换一批

没有更多推荐了,返回首页