现在的位置: 首页 > 综合 > 正文

Java并发问题的非阻塞解决方案

2013年10月03日 ⁄ 综合 ⁄ 共 3385字 ⁄ 字号 评论关闭

在并发环境中,对于共享资源通常会采用显式的锁机制(比如synchronized或ReentrantLock)来保证在任意时刻只会有一条线程访问这些变量,并且这些变量的修改对随后获取锁的线程是可见的。无法获取锁的线程会进入阻塞状态,并被JVM和操作系统挂起,在未来某一时刻被调度重新获取锁,挂起和恢复线程会产生很多的系统消耗和较长时间的中断。

线程的切换同时会引起上下文切换,即把当前线程的运行时上下文保存起来,装入新线程的运行时上下文,所以上下文切换并不是免费的,另外被换入的新线程所需要的数据不太可能在CPU Cache中,因此上下文切换会导致Cache Missing激增,新线程开始执行时,性能会相对较低。

在高并发,竞争非常激烈的场景下,显式锁的开销会非常大,将严重影响系统的性能。所以在一些场景下,使用非阻塞方案解决并发问题会显著提升系统性能。

Volatile

 Volatile变量是一种比锁更轻量的同步机制,因为它不会产生上下文切换和线程调度。Volatile变量可以保证可见性,即变量被一条线程修改后,其他线程都会读取到最新值,不过volatile变量也有自身的限制:它不支持原子操作,当变量的更新依赖其他变量或自身时(比如i++),volatile不能保证结果的正确性。

 最近在项目中需要封装Memcache客户端,实现双机热备和自动切换。这里设置一个变量currentCluster来表示当前使用的是主集群还是备用集群,业务请求根据此变量来判断优先访问的集群,同时有1条守护线程不断轮询2个集群,一旦发现有机器不可用,立刻切换,即修改变量currentCluster的值。所以currentCluster变量会被多条线程读写,且访问非常频繁,不过currentCluster变量的修改并没有依赖其他变量或自身,只需要保证可见性即可,适用与volatile变量的应用场景:

 

由于主存的访问速度相对于CPU的处理速度比较慢,所以CPU通过Cache降低内存延时的成本,编译器和CPU本身会对指令做一些优化,改变指令的执行顺序,在不改变最终结果的前提下,提高CPU Cache的命中率和CPU流水线的执行效率,此过程称为指令重排。当数据不可变或者限制在同一条线程范围内,CPU Cache和指令重排是无害的,但是如果在多核处理器,并发访问共享可变状态的场景下,不同的Cache缓存的数据可能会不一致,共享可变状态的内存操作被重新排序,这些优化会造成程序行为不定,造成共享变量的不可见性。在Java程序中加入volatile关键字可有效解决这些问题。

 在C语言中对volatile关键字修饰的共享变量执行写操作的引发2件事情:

       1.  将当前处理器缓存行的数据写回到系统内存

       2.  这个写回内存的操作会引起在其他CPU里缓存了该内存地址的数据无效

JVM增强了Java中volatile关键字的语义,在访问变量时,会加入内存屏障,使得前后指令不会被重排。因此Java中的volatile关键字可以保证可见性,即共享变量被修改后,其他线程立刻可以读取到最新值。

由于volatile变量在被修改时会将CPUCache中的数据失效掉,而CPU Cache的最小执行单位是Cache Line,所以包含volatile变量的整条Cache Line的数据都会失效。这里需要注意”伪共享”问题,如果volatile变量长度不超过Cache Line,在相邻变量之间需要padding,否则会产生大量Cache Missing。这里对于CPU的细节并不展开讨论,感兴趣的同学可以阅读振辉在4月Rigel技术月刊中发表的文章《优化到 CPU ––java 与 CPU 缓存》

原子操作

上一小节中提到volatile关键字只能保证可见性,如果变量的修改依赖其他变量或自身,则volatile无能为力,此时需要实现原子性,即所有操作是不可分割的,不会被其他线程打断。在现代多核处理器中,都会提供原子指令,比如CAS(compare and swap),该指令有3个操作数:需要操作的内存地址V,预期的原有值oldValue,要写入的新值newValue。使用CAS指令执行更新操作时,如果V上的值和oldValue相同,则原子的V上的值更新为newValue,如果在当前线程读取oldValue之后,其他线程执行了更新操作,则当前线程的CAS指令返回失败。

当多条线程同时试图使用CAS指令更新同一个共享变量时,会有一条线程成功更新变量,而其他线程会失败。由于这里应没有涉及锁的操作,所以失败线程并不会被挂起,也不会阻塞,他们只是被告知这次更新操作失败,可以重试或做其他的事情。下面的一段示例代码是一个计数器的非阻塞实现,在increment的过程中,不断使用CAS操作更新变量,直到成功为止,java.util.concurrent包中的原子实现也是采用类似的机制。

 

 

利用CAS指令除了能够实现简单数据类型的原子操作(比如java.util.concurrent包中的AtomicInteger,AtomicLong)外,还能实现复杂数据类型的原子操作。实现复杂数据类型的非阻塞算法的关键在于如何在维护数据一致性时,将原子更新的范围限定在一个简单变量上。比如一个栈,每个元素Node(value, next)只会指向一个其他的元素,并且每个元素也只会被一个其他的元素指向。对于push方法,会创建一个新节点指向栈顶元素top,并使用java.util.concurrent.atomic.AtomicReference
的CAS操作尝试替换top元素,如果top没有被其他线程修改,则替换成功,否则重新获取top元素,再次尝试替换,知道成功为止。

 

自旋锁

当程序中需要保证多个资源或变量的一致性,无法将更新范围限定在一个变量上时,必须使用显式的锁机制,比如synchronized关键字或ReentrantLock,但如上文所述,由于会产生阻塞,这种显式锁机制的开销比较大,尤其是在高并发场景下。这里介绍一种非阻塞的显式锁机制——自旋锁。

        

自旋锁采用java.util.concurret包提供的AtomicBoolean类表示锁的状态,false表示没有其他线程获取当前锁,true表示当前锁已被其他线程获取。当有多条线程同时访问lock()方法,试图获取锁时,只有一条线程可以成功,其他线程会停留在while(state.get()){}循环中,只有当活动线程调用unlock()方法释放锁时,才会有另一条线程跳出while(state.get()){}循环,因为unlock方法将state设置为false。由于这里没有获取到锁的线程并没有被阻塞,所以不会有阻塞相关的开销。

一条线程成功获取锁后,所有的非活动线程都会不停的循环,竞争会非常激烈,造成CPU资源的浪费。所以可以引入让步锁的机制降低CPU的开销。

当线程获取锁失败时,会调用backoff()方法时sleep一段时间,避免多条线程同时循环,并且每条线程恢复的时间不一样,减少了竞争,降低了CPU的开销。这里让步锁的sleep时长设定非常关键,如果太短,效果不明显,如果太长,会降低系统的吞吐量。根据同步块的预计运行时长来设置比较合理。

总结

在实际应用场景中,为避免阻塞带来的开销,使用非阻塞方案解决并发问题是非常有必要的。当更新范围可以限定在一个变量上时,可以使用volatile关键字或原子操作。如果需要保证多个资源或变量的一致性,则可以考虑自旋锁,不过对于同步块执行时间较长或执行时间长度差距较大的场景,并不适合使用自旋锁,因为很难避免CPU的过度开销,所以这种场景下,不妨直接使用synchronized关键字或ReentrantLock。

事实上Synchronized关键字和ReentrantLock都在不同程度上实现了自旋锁,在竞争开始时,会先尝试自旋,如果能够获取锁,则直接返回,否则进入阻塞状态。不过这里的自旋时长并不可控,如果已经确定同步块会执行比较快(一般来说没有IO和复杂计算),直接使用自旋锁效果会更佳。关于synchronized关键字和ReentrantLock的内部实现原理,后面会专门写文章详细讨论。 

抱歉!评论已关闭.