优化Java锁性能的方法有哪些?

     锁是最常见的同步方法之一。在高并发环境中,激烈的锁争用会导致程序性能下降,因此有与锁相关的一些性能问题,以及避免死锁、降低锁粒度和锁分离等一些注意事项都是有必要讨论和研究清楚的。

在单核CPU上使用并行算法的效率通常低于原来的串行算法。而并行计算之所以能够提高系统的性能,并不是因为它“工作量小”,而是因为它能够更合理地调度任务,充分利用各个CPU资源。因此,合理的组合可以最大限度地提高多核CPU的性能。

  1. 提高锁性能的几点方法和建议

如果锁资源的竞争不加以合理的控制必然会导致程序的整体性能下降。为了将这种副作用降到最低,我总结了一些有关使用锁的建议,希望对大家有所帮助。

1.1 减少锁持有的时间

对于程序来说,在锁竞争过程中,单个线程对锁的持有时间越长,则锁的竞争程度也就越激烈。因此应该尽可能地减少对某个锁的占有时间,以此来减少程序间互斥的可能。

举个简单的例子:

public synchronized void syncMethod(){
    method1();
    method2();
    method3();
}

假如在这个syncMethod()方法中只有method2()方法是有同步需求的,而method1()和method2()方法不需要做同步控制。这时如果method1()和method2()都是重量级的方法,就会花费较长的CPU时间。如果在并发量较大的时候,使用这种方案,就会导致等待线程大量增加。针对这种问题一个比较好的解决方法就是,只在必要时进行同步,这样就能明显的减少线程持有锁的时间,提高系统的吞吐量。具体请看下面的代码调整:

public void syncMethod(){

    method1();
    synchronized (this){
        method2();
    }

    method3();
}

       在改进后的代码中只针对method2()方法做了同步,锁占用时间较短,因此有了更好的并行度。其实类似于这样的手段在JDK的源码中也是可以很容易的找到,比如处理正则表达式的Pattern类:

1.2 减小锁的粒度

       所谓减小锁粒度,就是指缩小锁定对象的范围,从而降低锁冲突的可能性,进而提高系统的并发能力。

       减小锁的粒度也能够很有效的多线程之间锁资源的竞争,ConcurrentHashMap就是一个很典型的例子。其实对于ConcurrentHashMap类,它的内部进一步的细分成了若干个小的HashMap,默认一个ConcurrentHashMap类是分为16小段。

       如果需要在ConcurrentHashMap类中增加一个新的表项,并不是将整个HashMap加锁,而是首先根据hashCode得到该表项应该被存放在哪个段中,然后对该段加锁,并完成put()方法操作。在多线程环境下,如果多个线程同时进行put操作,如果被加入的表项不在同一段中,就可以做到真正的并行。

       由于默认有16个段,如果足够幸运,则ConcurrentHashMap类可以接收16个线程同时插入,从而大大的提升吞吐量。一下的代码便显示了put()方法操作的过程。

在5-6行获取对应的段,然后再第9行得到段将数据插入给定的段中。

public V put(K key, V value) {

    Segment<K, V> s;
    if (value == null)
        throw new NullPointerException();

    int hash = hash(key);
    int j = (hash >>> segmentShift) & segmentMask;

    if((s =(Segment<K,V>)UNSAFE.getObject(segments,(j << SSHIFT) + SBASE) == nul1)
    s = ensureSegment(j);

    return s.put(key, hash, value, false);
}

但是,减小锁的粒度会带来一个新的问题,当系统需要取得全局锁时,其消耗的资源会比较多。以ConcurrentHashMap为例,虽然put()方法很好的分离了锁,但是当试图访问ConcurrentHashMap类的全局信息时,就需要同时获取所有端的锁,比如size()方法:

if (check != sum) {

    sum = 0L;
    for(i = 0; i < segments.length; ++i) {
        segments[i].lock();
    }

    for(i = 0; i < segments.length; ++i) {
        sum += (long)segments[i].count;
    }

    for(i = 0; i < segments.length; ++i) {
        segments[i].unlock();
    }
}

可以看出,需要先获取所有的段再求和。不过ConcurrentHashMap的类也不总是这样执行的,事实上,size() 方法会先使用无锁的方式求和,如果失败才会尝试这种加锁的方式。

1.3 用读写分离锁来替换独占锁

使用读写分离锁ReadWriteLock可以提供系统的性能。使用读写分离锁来替代独占锁是减小锁粒度的一种特殊情况。减小锁粒度是通过分割数据结构实现的,那么读写分离锁则是对系统功能点的分割。两者的维度不同。

读写锁在读多写少的情况下有利于系统性能。因为如果系统在读写数据时只使用独占锁,那么读写操作、读写操作、写写操作都不是真正的并发操作,需要相互等待。读取操作本身不会影响数据的完整性和一致性。因此,理论上,在大多数情况下,可以允许多个线程同时读取,这正是读写锁所做的。

1.4 锁分离

根据读写操作的功能,有效地分离读写锁。同理根据应用程序的功能特性,还可以使用类似的分离思想来分离独占锁。一个典型的例子是java.util.concurrent.LinkedBlockingQueue的实现。在LinkedBlockingQueue的实现中,take()和put分别实现了从队列中获取数据和向队列中添加数据的功能。虽然这两个函数都修改当前队列,但是由于LinkedBlockingQueue是基于链表的,因此这两个操作分别作用于队列的前面和尾端操作,理论上它们并不冲突。

如果使用独占锁,则要求在两个操作进行时获取当前队列的独占锁,那么take()方法和put()方法就不能真正的实现并发,在运行时他们会彼此等待对方释放资源,在这种情况下锁竞争会相对比较激烈。因此在JDK的实现中,并没有采用这样的方式,取而代之的是用两把不同的锁分离了take()和put()方法。

由于take()和put()方法使用了不同的锁,因此两个方法之间是互相独立的,不存在锁竞争关系的。

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();              //通知其他未中断的线程
    } finally {
        takeLock.unlock();                  //释放锁
    }
    if (c == capacity)
        signalNotFull();                    //通知put()方法操作,已有空余空间
    return x;
}

put()方法实现如下:给出部分说明

public void put(E e) throws InterruptedException {
    if (e == null) throw new NullPointerException();
    int c = -1;
    Node<E> node = new Node<E>(e);
    final ReentrantLock putLock = this.putLock;
    final AtomicInteger count = this.count;
    putLock.lockInterruptibly();             //加锁 不能有两个线程同时进行put操作
    try {
        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两把锁,LinkedBlockingQueue实现了取数据和写数据的分离,实现了真正意义上的可并发操作。

1.5 锁粗化

       通常情况下,为了保证多线程间的有效并发,会要求每个线程持有锁的时间越短越好,在使用完公共资源后,应该立马释放锁。只有这样,等待获取该锁资源的其他线程才能尽在的获取的锁资源。但如果对一个锁不停的进行请求、同步和释放,其本身也会消耗系统宝贵的资源,反而不利于性能的优化。

       为此,虚拟机在遇到一连串连续的对同一个锁不断进行请求和释放操作时,便会把所有的锁操作整合成对锁的一次性请求,从而减小对锁的请求同步的次数,这个操作叫锁的粗化。

例如一个简单的例子:

public void demoMethod(){
     synchronized (lock){
         //do sth
     }
     //其他不需要同步的工作,但是很快就能完成

    synchronized (lock){
         //do sth
    }
}

而上面的代码最终会被整合成为一次锁请求:

public void demoMethod(){
     synchronized (lock){
         //do sth
         //其他不需要同步的工作,但是很快就能完成
     }
}

而在开发的过程中,大家也应该有意识的在合理的场合进行锁的粗化,尤其当在循环内请求锁时,以下是一个循环内请求锁的例子,意味着每一次循环都要去请求一次锁资源。但是这种情况显然是没有必要的。

for (int i = 0; i < 50; i++) {
    synchronized (lock){
        // do sth
    }
}

所以更加合理的一种做法就是在外层请求一次锁:

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

    }
}

 

  • 2
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值