Java并发编程学习五:Synchronized的锁优化以及CAS

从前几章的学习当中,我们知道了volidate只能保证可见性以及部分的原子性,而针对大部分的并发场景而言,部分的原子性是满足不了项目需求的,因此使用了锁机制或者原子类操作来满足我们的开发需求。

在Java提供的锁中,主要有Synchronized以及ReetrantLock类。在Java1.5之前,Synchronized并不是同步最好的选择,由于并发时频繁的阻塞和唤醒线程,导致频繁的进行线程上下文切换,会造成Synhronized的并发效率在某些情况下是远不及ReentrantLock。而在Java1.6后,官方就对Synchronized进行了许多优化,极大的提高了Synchronized的性能。所以只要Synchronized能满足使用环境,建议使用Synchronized而不使用ReentrantLock。下面锁优化就针对Synchronized而言来进行讨论。

首先的明白一个问题,为什么需要进行锁优化呢?这个需要从线程的开销和阻塞讲起了。

如果应用程序或者服务是单线程模型,即主线程是唯一的线程,即不存在同步的开销,也不需要额外的同步操作。而在多线程中,在CPU调度切换不同线程时候会发生上下文切换,上下文切换时候,JVM需要去保存当前线程对应的寄存器使用状态,以及代码执行的位置等等,那么肯定是会有一定的开销的。而且当线程由于等待某个锁而被阻塞的时候,JVM通常将该线程挂起,挂起线程和恢复线程都是需要转到内核态中进行,频繁的进行用户态到内核态的切换对于操作系统的并发性能来说会造成不小的压力。因此如何去优化Synchronized造成阻塞的性能开销,就是JVM进行锁优化的过程。

Synchronized实际上是一种悲观的策略,这也是我们为什么称Synchronized为悲观锁的原因,即总是假设最坏的情况,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会阻塞直到它拿到锁。相比悲观锁,CAS技术则是一种乐观锁的概念,即总是假设最好的情况,每次去拿数据的时候都认为别人不会修改,所以不会上锁,但是在更新的时候会判断一下在此期间别人有没有去更新这个数据,采取在写时先读出当前版本号,然后加锁操作(比较跟上一次的版本号,如果一样则更新),如果失败则要重复读-比较-写的操作。


锁优化

从Java1.5到Java1.6,HotSpot团队实现了大量的锁优化的技术,其中包括自旋锁,轻量级锁和偏向锁等等,下面就以左边阐述的优化技术做个整理归纳。

自旋锁

自旋锁的原理比较简单,就是如果共享的资源锁定的状态持续时间很短,那么为了这段时间去挂起和恢复线程并不值得,我们只需要让后面请求锁的线程等一等(自旋),但是不放弃CPU的执行时间,等持有锁的线程释放锁后即可立即获取锁,这样就避免用户线程和内核的切换的性能消耗了。

自旋等待本身虽然避免了线程上下文切换的开销,但是由于不放弃CPU的执行时间,如果锁占用的时间很短,自旋的效果肯定就好,如果锁占用的时间长了,那么,这锁占用的时间内,CPU的消耗也会跟着增加往上涨。所以自旋等待需要有一定的时间限制,如果自旋超过了限定次数仍然获取锁失败,那么就恢复到传统挂起阻塞的状态执行。Java1.6中引入了自适应的自旋锁概念,意味着自适应的时间不再固定,JVM内部会通过一定的算法优化对应锁的自旋次数,或者优化掉自旋过程,一切由虚拟机决定。

JDK1.6中-XX:+UseSpinning开启;

-XX:PreBlockSpin=10 为自旋次数;

JDK1.7后,去掉此参数,由jvm控制;

偏向锁

偏向锁的目的在于消除数据在无竞争的情况下的同步原语,偏向锁会偏向于第一个获得它的线程,如果在接下来的运行过程该锁没有被其他线程获取,则持有偏向锁的线程永远不需要再进行同步;如果有另一个线程尝试获取这个锁的时候,偏向模式就宣告结束了。

偏向锁可以提高带有同步但是五竞争的程序的性能,如果程序中大多数锁总是被多个不同线程访问,那么偏向锁就是多余的了,因此是否使用偏向锁可以由开发者自主决定,使用-XX:+UseBiasedLocking开启,使用-XX:-UseBiaseLocking来禁止偏向锁优化。

轻量级锁

轻量级锁是由偏向锁升级来的,偏向锁运行在一个线程进入同步块的情况下,当第二个线程加入锁争用的时候,偏向锁就会升级为轻量级锁。使用轻量级锁能够同步性能的一句是“对于绝大部分的锁没在真个同步周期内都是不存在竞争的”。如果没有竞争,轻量级锁基于CAS执行,也就是避免了使用互斥操作的开销;但是如果存在竞争,那么除了互斥量的开销,还额外发生CAS开销,所以在存在竞争的条件下,轻量级锁会比传统的重量级锁更慢。

Sychronized的内部执行如下,摘自该篇文章:

synchronized的执行过程:

  1. 检测Mark Word(对象头里的东西)里面是不是当前线程的ID,如果是,表示当前线程处于偏向锁。
  2. 如果不是,则使用CAS将当前线程的ID替换Mard Word,如果成功则表示当前线程获得偏向锁,置偏向标志位1。
  3. 如果失败,则说明发生竞争,撤销偏向锁,进而升级为轻量级锁。
  4. 当前线程使用CAS将对象头的Mark Word替换为锁记录指针,如果成功,当前线程获得锁。
  5. 如果失败,表示其他线程竞争锁,当前线程便尝试使用自旋来获取锁。
  6. 如果自旋成功则依然处于轻量级状态。
  7. 如果自旋失败,则升级为重量级锁(Sychronized本身语义,使用互斥量来进行操作)。

需要注意,锁只能依照偏向锁->轻量级锁->重量锁升序升级,不能够降序。

上述为JVM内部优化的策略,我们需要理解即可,下面是我们能够在代码层次上对Synchronized使用进行优化的,如锁消除,锁粗化这两个概念。

锁消除和锁粗化

锁消除是指JVM在编译器运行期,对一些代码要求同步,但是JVM检测不可能造成共享数据竞争的锁的优化消除技术。举个例子:

    public String getStrings(String s1, String s2, String s3) {

        StringBuffer stringBuffer = new StringBuffer();
        stringBuffer.append(s1);
        stringBuffer.append(s2);
        stringBuffer.append(s3);
        return stringBuffer.toString();
    }

如果上述代码运行在多线程环境,由于该方法在多线程中不涉及到共享变量的问题,而StringBuffer本身内部保证线程安全性而加了Synchronized,所以在编译器运行时,就会进行锁消除的操作,上述代码会忽略掉所有的同步而直接执行。

锁粗化表示扩大锁的同步范围。一般而言我们是尽量缩小同步块的范围,旨在减少不必要的开销,但是如果存在一系列的操作都对同一个对象反复加锁和解锁,那么即使没有线程的竞争,频繁的进行互斥同步操作也会导致不必要的性能损耗。如如下代码:

    public void loop() {
        int k = 0;
        for (int i = 0; i < 100; i++) {
            synchronized (User.class) {
                k++;
            }
        }
        System.out.println(k);
    }

在循环体内每一次循环都伴随着加锁解锁的过程,势必伴随着不必要的性能消耗,上面的例子我们可以把Synchronized提到外面,只进行一次加锁解锁的操作:


    public void loop() {
        int k = 0;
        synchronized (User.class) {
            for (int i = 0; i < 100; i++) {

                k++;
            }
        }
        System.out.println(k);
    }

CAS

通过使用Synchronized进行同步的操作,从代码实操来看,确实是简单易行的,然后从虚拟机的角度来看,使用Synchronized意味这使用阻塞同步操作,那么上述也讲了阻塞同步会造成线程的挂起和重新唤起的过程,即使上面讲述了JVM层对Synchronizd的优化,却仍然不能化解阻塞带来的性能损耗。那么有没有一种非阻塞的同步操作呢?答案是有的,就是CAS,这也是Java中原子类能够表现出原子操作的根基。

首先介绍一下什么是非阻塞同步:

非阻塞同步是基于冲突检测的乐观并发策略,即先进行操作,如果没有其他线程争用共享数据,那么操作成功;如果有共享数据争用的情况,并且产生了冲突,那么采取其他的补偿策略(如不断尝试,知道成功)。非阻塞同步是不需要把线程挂起的。

非阻塞同步的关键在于如何保证检测冲突的原子性,Java线程的带来的问题与内存模型(JMM)说过JMM在交互协议中定义了8种操作为原子性的,除此之外在计算机发展直到今日,现代普遍的处理器都支持一些复杂的指令成为原子性操作,常见的有:

  • 测试并设置(Test-and-Set)
  • 获取并增加(Fetch-and-Increment)
  • 交换(Swap)
  • 比较并且交换(Compare-and-Swap,简称CAS)
  • 关联加载和条件存储(Load-Linked/Store-Conditional)

这里我们就主要研究CAS,CAS操作包含了三个操作数---需要读写的内存位置V,进行比较的值A和要写入的新值B。当且仅当V的值等于A时候,CAS才会通过原子的方式更新B来更新值,否则不会执行任何操作。无论位置V的值是否等于A,都将返回V原有的值。一个CAS实现的类比代码如下:

class FakeCAS {
    private int value;

    //保证原子性
    public int getValue() {
        return value;
    }

    //保证原子性
    public int compareAndSwap(int expectedValue, int newValue) {
        int oldValue = value;
        if (oldValue == expectedValue) {
            value = newValue;
        }
        return oldValue;
    }

    //保证原子性
    public boolean compareAndSet(int expectedValue, int newValue) {
        return (expectedValue == compareAndSwap(expectedValue, newValue));
    }
}


上面getValue()compareAndSwap(...)都表示为原子性操作,这样就实现了CAS的语义了。那么一个简单的基于CAS的自增操作如下操作即可:

 class CASCount {
        private FakeCAS mFakeCAS;

        public int increment() {
            int v;
            do {
                v = mFakeCAS.getValue();
            } while (v != mFakeCAS.compareAndSwap(v, v + 1));
            return v + 1;
        }
    }

在一个无限循环中不断的将值加1,如果失败,说明已经有线程进行修改了,需要重新遍历,直到循环退出为止,当前情况下,由于没有使用锁操作,所以是不会阻塞的。

那么CAS有没问题呢?考虑到这样的一个情况,如果变量V初次读取时候等于A,并且在准备赋值时候仍然等于A,但是在这期间有线程将值改为B后再改为A,那么CAS就认为V重来没有变过,该问题被称为"ABA"问题。解决方案也有,就是Java中提供了AtomStampReference来保证处理ABA问题。不过一般情况下ABA问题不影响程序的并发正确性,所以使用依照开发者决定。

Ok,CAS就介绍到这里,有兴趣的同学可以研究一下AtomInteger的实现,你可以发现实现的思路跟上面的基本一致,至于如何保证CAS的原子性操作,Java层面调用了native的代码,底层代码在IA64,x86中使用cmpxchg实现,其他处理器也有对应的指令保证,由于对这方面的研究颇少,所以就不解析了。


参考资料

猜你喜欢

转载自my.oschina.net/u/3863980/blog/2907155