Java锁的开销和优化

2019独角兽企业重金招聘Python工程师标准>>> hot3.png

#1,避免死锁

死锁问题是多线程的特有问题,它可以认为是线程间切换消耗系统性能的一种极端情况。

在死锁时,线程间相互等待资源,而又不释放自身的资源,导致无穷无尽的等待,其结果是系统任务永远无法执行完成。

死锁问题是在多线程开发中应该坚决避免和杜绝的问题。

一般来说,要出现死锁问题需要满足以下条件:

@互斥条件:一个资源每次只能被一个线程使用。

@请求与保持条件:一个线程因请求资源而阻塞时,对已获得的资源保持不放。

@不剥夺条件:线程已获得的资源,在未使用完之前,不能强行剥夺。

@循环等待条件:若干线程之间形成一种头尾相接的循环等待资源关系。


只要破坏死锁4个必要条件中的任何一个,死锁问题就能得以解决。


#2,减小锁持有时间

对于使用锁进行并发控制的应用程序而言,在锁竞争过程中,单个线程对锁的持有时间与系统性能有着直接的关系。

如果线程持有锁的时间很长,那么相对地,锁的竞争程度也就越激烈。因此,在程序开发过程中,应该尽可能地减少对某个锁的占有时间,以减少线程间互斥的可能。

public synchronized void syncMethod(){
        method1();//比较耗时
        coreMethod();
        method2();//比较耗时
    }

在syncMethod()方法中,假设只有coreMethod()方法是有同步需要的,而method1()和method2()分别是重量级的方法,则会花费较长的CPU时间。此时,如果并发量大,使用这种对整个方法做同步的方案,会导致等待线程大量增加。因为一个线程,在进入该方法时获得内部锁,只有再所有任务都执行完成后,才会释放锁。

一个较为优化的解决方法时,只在必要时进行同步,这样就能明显减少线程持有锁的时间,提高系统的吞吐量。

public void syncMethod(){
        method1();//比较耗时
        synchronized(this){
            coreMethod();
        }            
        method2();//比较耗时
    }

    在改进的代码中,只针对coreMethod()方法做了同步,锁占用的时间相对较短,因此能有更高的并行度。

    减少锁的持有时间有助于降低锁冲突的可能性,进而提升系统的并发能力。


#3.减小锁粒度

    减小锁粒度也是一种削弱多线程锁竞争的一种有效手段,这种技术典型的使用场景就是ConcurrentHashMap类的实现。

作为JDK并发包中重要的成员类,很好地使用了拆分锁对象的方式提高ConcurrentHashMap的吞吐量。ConcurrentHashMap将整个HashMap分成若干个段(Segment),每个段都是一个子HashMap。

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

默认情况下,ConcurrentHashMap拥有16个段,因此,如果够幸运的话,ConcurrentHashMap可以同时接受16个线程同时插入(如果都插入不同的段中),从而大大提高其吞吐量。

但是,减少锁粒度会引入一个新的问题,即:当系统需要取得全局锁时,其消耗的资源会比较多。仍然以ConcurrentHashMap类为例,虽然其put()方法很好地分离了锁,但是当试图访问ConcurrentHashMap全局信息时,就需要同时取得所有段的锁方能顺利实施。比如ConcurrentHashMap的size()方法,它将返回ConcurrentHashMap的有效表项的数量,即ConcurrentHashMap的全部有效表项之和。要获取这个信息需要取得所有字段的锁,因此,可参考其size()的代码如下:

public int size() {
        final Segment[] segments = this.segments;
        long sum = 0;
        long check = 0;
        int[] mc = new int[segments.length];
        // Try a few times to get accurate count. On failure due to
        // continuous async changes in table, resort to locking.
        for (int k = 0; k < RETRIES_BEFORE_LOCK; ++k) {
            check = 0;
            sum = 0;
            int mcsum = 0;
            for (int i = 0; i < segments.length; ++i) {
                sum += segments[i].count;
                mcsum += mc[i] = segments[i].modCount;
            }
            if (mcsum != 0) {
                for (int i = 0; i < segments.length; ++i) {
                    check += segments[i].count;
                    if (mc[i] != segments[i].modCount) {
                        check = -1; // force retry
                        break;
                    }
                }
            }
            if (check == sum)
                break;
        }
        if (check != sum) { // Resort to locking all segments
            sum = 0;
            for (int i = 0; i < segments.length; ++i)
                segments[i].lock();
            for (int i = 0; i < segments.length; ++i)
                sum += segments[i].count;
            for (int i = 0; i < segments.length; ++i)
                segments[i].unlock();
        }
        if (sum > Integer.MAX_VALUE)
            return Integer.MAX_VALUE;
        else
            return (int)sum;
    }

    可以看到代码在后面计算总数时,先要获得所有段的锁,然后再求和。但是,ConcurrentHashMap的size()方法并不总是这样执行,事实上,size()方法会先使用无锁的方式求和,如果失败才会尝试这种加锁方法。但不管怎么说,在高并发场合ConcurrentHashMap的size()的性能依然要差于同步的HashMap.

    因此,只有再类似于size()获取全局信息的方法调用并不频繁时,这种减小锁粒度的方法才能真正意义上提高系统吞吐量。

    所谓减小锁粒度,就是指缩小锁定对象的范围,(可以锁定实例对象的,就不锁定class类对象)从而减少锁冲突的可能性,进而提高系统的并发能力。

#4,读写分离锁类替换独占锁

    使用读写分离锁来替代独占锁是减小锁粒度的一种特殊情况。如果说上面的减少锁粒度是通过分割数据结构实现的,那么,读写锁则是对系统功能点的分割。

    因为读操作本身不会影响数据的完整性和一致性,因此,理论上讲,在大部分情况下,应该可以允许多线程同时读。

    在读多写少的场合,使用读写锁可以有效提升系统的并发能力。

#5,锁分离

    读写锁思想的延伸就是锁分离,读写锁根据读写操作功能上的不同,进行了有效的锁分离。依据应用程序的功能特点,使用类似的分离思想,也可以对独占锁进行分离。

    以LinkedBlockingQueue来说,take()和put()分别实现了从队列中取得数据和往队列中增加数据的功能。虽然两个函数都对当前队列进行了修改操作,但由于LinkedBlockingQueue是基于链表的,因此,两个操作分别作用于队列的前端和尾端,从理论上说,两者并不冲突。

    如果使用独占锁,则要求在两个操作进行时获取当前队列的独占锁,那么take()和put()操作就不可能真正的并发,在运行时,它们会彼此等待对方释放锁资源。在这种情况下,锁竞争会相对比较激烈,从而影响程序在高并发时的性能。

    因此,在JDK实现中,并没有采用这样的方式,取而代之的是两把不同的锁分离了take()和put()操作。

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

    以上代码片段定义了takeLock和putLock,因此take()和put()函数相互独立,它们之间不存在锁竞争关系。

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();
                }
            x = dequeue();
            c = count.getAndDecrement();
            if (c > 1)
                notEmpty.signal();
        } finally {
            takeLock.unlock();
        }
        if (c == capacity)
            signalNotFull();
        return x;
    }

    

 /**
     * Inserts the specified element at the tail of this queue, waiting if
     * necessary for space to become available.
     *
     * @throws InterruptedException {@inheritDoc}
     * @throws NullPointerException {@inheritDoc}
     */
    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;
        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(e);
            c = count.getAndIncrement();
            if (c + 1 < capacity)
                notFull.signal();
        } finally {
            putLock.unlock();
        }
        if (c == 0)
            signalNotEmpty();
    }

    通过takeLock和putLock两把锁,LinkedBlockingQueue实现了取数据和写数据的分离,使两者在真正意义上成为可并发的操作。


#6,重入锁(ReentrantLock)和内部锁(synchronized)

内部锁和重入锁有功能上的重复,所有使用内部锁实现的功能,使用重入锁都可以实现。从使用上看,内部锁使用简单,因此得到了广泛的使用,重入锁使用略微复杂,必须在finally代码中,显示释放重入锁,而内部锁可以自动释放。

从性能上看,在高并发量的情况下,内部锁的性能略逊于重入锁,但是JVM对内部锁实现了很多优化,并且有理由相信,在将来的JDK版本中,内部锁的性能会越来越好。

从功能上看,重入锁有着更为强大的功能,比如提供了锁等待时间,支持锁终端和快读锁轮询,这些技术有助于避免死锁的产生,从而提高系统的稳定性。

同时,冲如梭还提供了一套Condition机制,通过Condition,重入锁可以进行复杂的线程控制功能,而类似的功能,内部锁需要通过Object的wait()和notify()方法实现。


#7,锁粗化

通常情况下,为了保证多线程间的有效并发,会要求每个线程尺有所短寸有所长的时间尽量短,即在使用完公共资源后,应该立即释放锁。只有这样,等待在这个锁上的其他线程才能尽早地获得资源执行任务。但是,凡事都有一个度,如果堆同一个锁不停地进行请求,同步和释放,其本身也会消耗系统宝贵的资源,反而不利于性能的优化。

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

public void syncMethod(){
        synchronized(lock){
            //TODO
        }
        
        synchronized(lock){
            //TODO
        }

    上面的代码会被整合为如下形式:

public void syncMethod(){
        synchronized(lock){
            //TODO
            //TODO
        }
    }

    在开发当中,我们也应该有意识地在合理的场合进行锁的粗化,尤其当在循环内请求锁时。

for (int i = 0; i < CIRCLE; i++) {
            synchronized(lock){
                //TODO
            }
        }

    将上面的代码粗化为:

synchronized(lock){
        for (int i = 0; i < CIRCLE; i++) {
                //TODO
            }
        }

    

#8,自旋锁

在上面已经提到,线程的状态和上下文切换是要消耗系统资源的。在多线程比并发时,频繁的挂起和恢复线程的操作会给系统带来极大的压力。特别是当访问共享资源仅需花费很小一段CPU时间时,锁的等待可能只需要很短的时间,这段时间可能要比将线程挂起并恢复的时间还要短,因此,为了这段时间去做重量级的线程切换是不值得的。

为此,JVM引入了自旋锁。自旋锁可以使线程在没有取得锁时,不被挂起,而转而去执行一个空循环,在若干个空循环后,线程如果获得了锁,则继续执行。若线程依然不能获得锁,才会被挂起。

使用自旋锁后,线程被挂起的几率相对减少,线程执行的连贯性相对加强。因此,对于那些锁竞争不是很激烈,锁占用时间很短的并发线程,是有一定的积极意义,但对于锁竞争激烈,单线程锁占用时间长的并发程序,自旋锁在自旋等待后,往往依然无法获得对应的锁,不仅仅白白浪费了CPU时间,最终还是免不了执行被挂起的操作,反而浪费了系统资源。

JVM虚拟机提供-XX:+UseSpinning参数来开启自旋锁,使用-XX:PreBlockSpin参数来设置自旋锁的等待次数。


#9,锁消除

锁消除是JVM在即时编译时,通过对运行上下文的扫描,去除不可能存在共享资源竞争的锁。通过锁消除,可以节省毫无意义的请求锁时间。

在Java软件开发过程中,开发人员必然会使用一些JDK的内置API,比如StringBuffer,Vector等。这些常用的工具类可能会被大面积地使用。虽然这些工具类本身可能有对应的非同步版本,但是开发人员也很有可能在完全没有多线程竞争的场合使用他们。

在这种情况下,这些工具类内部的同步方法就是不必要的。JVM虚拟机可以在运行时,基于逃逸分析技术,捕获到这些不可能存在竞争却有申请锁的代码段,并消除这些不必要的锁,从而提高系统性能。

    public void testStringBuffer(String a, String b){
        
        StringBuffer sb = new StringBuffer();
        sb.append(a);
        sb.append(b);
        return sb.toString();
    }

    sb变量的作用域仅限于方法体内部,不可能逃逸出该方法,一次它就不可能被多个线程同时访问。

    逃逸分析和锁消除分别可以使用-XX:+DoEscapeAnalysis和-XX:+EliminateLocks开启(锁消除必须工作再-server模式下)。

    对锁的请求和释放是要消耗系统资源的。使用锁消除即使可以去掉那些不可能存在多线程访问的锁请求,从而提高系统性能。


#10,锁偏向

    锁偏向是JDK1.6提出的一种锁优化方式。其核心思想是,如果程序没有竞争,则取消之前已经取得锁的线程同步操作。也就是说,若某一锁被线程获取后,便进入偏向模式,当线程再次请求这个锁时,无需再进行相关的同步操作,从而节省了操作时间,如果在此之间有其他线程进行了锁请求,则锁退出偏向模式。在JVM中使用-XX:+UseBiasedLocking可以设置启用偏向锁。

    偏向锁在锁竞争激烈的场合没有优化效果,因为大量的竞争会导致持有锁的线程不停地切换,锁也很难一致保持在偏向模式,此时,使用锁偏向不仅得不到性能的提升,反而有损系统性能。因此,在激烈竞争的场合,使用-XX:-UseBiasedLocking参数禁用锁偏向反而能提升系统吞吐量。


转载于:https://my.oschina.net/u/617909/blog/348358

你可能感兴趣的:(Java锁的开销和优化)