`
maosheng
  • 浏览: 568171 次
  • 性别: Icon_minigender_1
  • 来自: 北京
社区版块
存档分类
最新评论

Java并发问题--乐观锁与悲观锁以及乐观锁的一种实现方式-CAS

    博客分类:
  • Java
阅读更多
首先介绍一些乐观锁与悲观锁:

悲观锁:总是假设最坏的情况,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会阻塞直到它拿到锁。传统的关系型数据库里边就用到了很多这种锁机制,比如行锁,表锁等,读锁,写锁等,都是在做操作之前先上锁。再比如Java里面的同步原语synchronized关键字的实现也是悲观锁。

乐观锁:顾名思义,就是很乐观,每次去拿数据的时候都认为别人不会修改,所以不会上锁,但是在更新的时候会判断一下在此期间别人有没有去更新这个数据,可以使用版本号等机制。乐观锁适用于多读的应用类型,这样可以提高吞吐量,像数据库提供的类似于write_condition机制,其实都是提供的乐观锁。在Java中java.util.concurrent.atomic包下面的原子变量类就是使用了乐观锁的一种实现方式CAS实现的。

乐观锁的一种实现方式-CAS(Compare and Swap 比较并交换):
锁存在的问题:

Java在JDK1.5之前都是靠synchronized关键字保证同步的,这种通过使用一致的锁定协议来协调对共享状态的访问,可以确保无论哪个线程持有共享变量的锁,都采用独占的方式来访问这些变量。这就是一种独占锁,独占锁其实就是一种悲观锁,所以可以说 synchronized 是悲观锁。

悲观锁机制存在以下问题:  

1. 在多线程竞争下,加锁、释放锁会导致比较多的上下文切换和调度延时,引起性能问题。

2. 一个线程持有锁会导致其它所有需要此锁的线程挂起。

3. 如果一个优先级高的线程等待一个优先级低的线程释放锁会导致优先级倒置,引起性能风险。

对比于悲观锁的这些问题,另一个更加有效的锁就是乐观锁。其实乐观锁就是:每次不加锁而是假设没有并发冲突而去完成某项操作,如果因为并发冲突失败就重试,直到成功为止。

乐观锁:
乐观锁( Optimistic Locking)在上文已经说过了,其实就是一种思想。相对悲观锁而言,乐观锁假设认为数据一般情况下不会产生并发冲突,所以在数据进行提交更新的时候,才会正式对数据是否产生并发冲突进行检测,如果发现并发冲突了,则让返回用户错误的信息,让用户决定如何去做。

上面提到的乐观锁的概念中其实已经阐述了它的具体实现细节:主要就是两个步骤:冲突检测和数据更新。其实现方式有一种比较典型的就是 Compare and Swap ( CAS )。

CAS:CAS是乐观锁技术,当多个线程尝试使用CAS同时更新同一个变量时,只有其中一个线程能更新变量的值,而其它线程都失败,失败的线程并不会被挂起,而是被告知这次竞争中失败,并可以再次尝试。   

CAS 操作中包含三个操作数 —— 需要读写的内存位置(V)、进行比较的预期原值(A)和拟写入的新值(B)。如果内存位置V的值与预期原值A相匹配,那么处理器会自动将该位置值更新为新值B。否则处理器不做任何操作。无论哪种情况,它都会在 CAS 指令之前返回该位置的值。(在 CAS 的一些特殊情况下将仅返回 CAS 是否成功,而不提取当前值。)CAS 有效地说明了“ 我认为位置 V 应该包含值 A;如果包含该值,则将 B 放到这个位置;否则,不要更改该位置,只告诉我这个位置现在的值即可。 ”这其实和乐观锁的冲突检查+数据更新的原理是一样的。

这里再强调一下,乐观锁是一种思想。CAS是这种思想的一种实现方式。

JAVA对CAS的支持:在JDK1.5 中新增 java.util.concurrent (J.U.C)就是建立在CAS之上的。相对于对于 synchronized 这种阻塞算法,CAS是非阻塞算法的一种常见实现。所以J.U.C在性能上有了很大的提升。

以 java.util.concurrent 中的 AtomicInteger 为例,看一下在不使用锁的情况下是如何保证线程安全的。主要理解 getAndIncrement 方法,该方法的作用相当于 ++i 操作。

public class AtomicIntegeer extends Number implements java.io.Serializable{

private volatile int value;

public final int get(){
return value;
}

public final int getAndIncrement(){

  for(;;){
  int current = get();
  int next =current+1;
  if(compareAndSet(current,next))
return current;
  }
}

public final boolean compareAndSet(int expect,int update){
return unsafe.compareAndSwapInt(this.valueOffset,expect,update);
}

}


在没有锁的机制下,字段value要借助volatile原语,保证线程间的数据是可见性。这样在获取变量的值的时候才能直接读取。然后来看看 ++i 是怎么做到的。getAndIncrement 采用了CAS操作,每次从内存中读取数据然后将此数据和 +1 后的结果进行CAS操作,如果成功就返回结果,否则重试直到成功为止。而 compareAndSet 利用JNI(Java Native Interface)来完成CPU指令的操作:

public final boolean compareAndSet(int expect,int update){
return unsafe.compareAndSwapInt(this.valueOffset,expect,update);
}

其中unsafe.compareAndSwapInt(this.valueOffset,expect,update)类似如下逻辑:

if(this==expect){
   this=update;
   return true;
}else{
   return false;
}

那么比较this == expect,替换this = update,compareAndSwapInt实现这两个步骤的原子性呢? 参考CAS的原理

CAS原理:CAS通过调用JNI的代码实现的。而compareAndSwapInt就是借助C来调用CPU底层指令实现的。下面从分析比较常用的CPU(intel x86)来解释CAS的实现原理。下面是sun.misc.Unsafe类的compareAndSwapInt()方法的源代码:

public final native boolean compareAndSwapInt(Object o,long offset,int expected,int x);

这里看到这是个本地方法调用,这个本地方法在JDK中依次调用的C++代码为:





如上面源代码所示,程序会根据当前处理器的类型来决定是否为cmpxchg指令添加lock前缀。如果程序是在多处理器上运行,就为cmpxchg指令加上lock前缀(lock cmpxchg)。反之,如果程序是在单处理器上运行,就省略lock前缀(单处理器自身会维护单处理器内的顺序一致性,不需要lock前缀提供的内存屏障效果)。

CAS缺点:
1. ABA问题:比如说一个线程one从内存位置V中取出A,这时候另一个线程two也从内存中取出A,并且two进行了一些操作变成了B,然后two又将V位置的数据变成A,这时候线程one进行CAS操作发现内存中仍然是A,然后one操作成功。尽管线程one的CAS操作成功,但可能存在潜藏的问题。如下所示:




现有一个用单向链表实现的堆栈,栈顶为A,这时线程T1已经知道A.next为B,然后希望用CAS将栈顶替换为B:head.compareAndSet(A,B);在T1执行上面这条指令之前,线程T2介入,将A、B出栈,再pushD、C、A,此时堆栈结构如下图,而对象B此时处于游离状态:




此时轮到线程T1执行CAS操作,检测发现栈顶仍为A,所以CAS成功,栈顶变为B,但实际上B.next为null,所以此时的情况变为:





其中堆栈中只有B一个元素,C和D组成的链表不再存在于堆栈中,平白无故就把C、D丢掉了。从Java1.5开始JDK的atomic包里提供了一个类AtomicStampedReference来解决ABA问题。这个类的compareAndSet方法作用是首先检查当前引用是否等于预期引用,并且当前标志是否等于预期标志,如果全部相等,则以原子方式将该引用和该标志的值设置为给定的更新值。





2. 循环时间长开销大:
自旋CAS(不成功,就一直循环执行,直到成功)如果长时间不成功,会给CPU带来非常大的执行开销。如果JVM能支持处理器提供的pause指令那么效率会有一定的提升,pause指令有两个作用,第一它可以延迟流水线执行指令(de-pipeline),使CPU不会消耗过多的执行资源,延迟的时间取决于具体实现的版本,在一些处理器上延迟时间是零。第二它可以避免在退出循环的时候因内存顺序冲突(memory order violation)而引起CPU流水线被清空(CPU pipeline flush),从而提高CPU的执行效率。

3. 只能保证一个共享变量的原子操作:
当对一个共享变量执行操作时,我们可以使用循环CAS的方式来保证原子操作,但是对多个共享变量操作时,循环CAS就无法保证操作的原子性,这个时候就可以用锁,或者有一个取巧的办法,就是把多个共享变量合并成一个共享变量来操作。比如有两个共享变量i=2,j=a,合并一下ij=2a,然后用CAS来操作ij。从Java1.5开始JDK提供了AtomicReference类来保证引用对象之间的原子性,你可以把多个变量放在一个对象里来进行CAS操作。


CAS与Synchronized的使用情景:  
 
1、对于资源竞争较少(线程冲突较轻)的情况,使用synchronized同步锁进行线程阻塞和唤醒切换以及用户态内核态间的切换操作额外浪费消耗cpu资源;而CAS基于硬件实现,不需要进入内核,不需要切换线程,操作自旋几率较少,因此可以获得更高的性能。
2、对于资源竞争严重(线程冲突严重)的情况,CAS自旋的概率会比较大,从而浪费更多的CPU资源,效率低于synchronized。
补充:synchronized在jdk1.6之后,已经改进优化。synchronized的底层实现主要依靠Lock-Free的队列,基本思路是自旋后阻塞,竞争切换后继续竞争锁,稍微牺牲了公平性,但获得了高吞吐量。在线程冲突较少的情况下,可以获得和CAS类似的性能;而线程冲突严重的情况下,性能远高于CAS。

concurrent包的实现:

由于java的CAS同时具有 volatile 读和volatile写的内存语义,因此Java线程之间的通信现在有了下面四种方式:
1. A线程写volatile变量,随后B线程读这个volatile变量。
2. A线程写volatile变量,随后B线程用CAS更新这个volatile变量。
3. A线程用CAS更新一个volatile变量,随后B线程用CAS更新这个volatile变量。
4. A线程用CAS更新一个volatile变量,随后B线程读这个volatile变量。

Java的CAS会使用现代处理器上提供的高效机器级别原子指令,这些原子指令以原子方式对内存执行读-改-写操作,这是在多处理器中实现同步的关键(从本质上来说,能够支持原子性读-改-写指令的计算机器,是顺序计算图灵机的异步等价机器,因此任何现代的多处理器都会去支持某种能对内存执行原子性读-改-写操作的原子指令)。同时,volatile变量的读/写和CAS可以实现线程之间的通信。把这些特性整合在一起,就形成了整个concurrent包得以实现的基石。如果我们仔细分析concurrent包的源代码实现,会发现一个通用化的实现模式:
1. 首先,声明共享变量为volatile;  
2. 然后,使用CAS的原子条件更新来实现线程之间的同步;
3. 同时,配合以volatile的读/写和CAS所具有的volatile读和写的内存语义来实现线程之间的通信。

AQS,非阻塞数据结构和原子变量类(java.util.concurrent.atomic包中的类),这些concurrent包中的基础类都是使用这种模式来实现的,而concurrent包中的高层类又是依赖于这些基础类来实现的。从整体来看,concurrent包的实现示意图如下:





JVM中的CAS(堆中对象的分配): 
Java调用new object()会创建一个对象,这个对象会被分配到JVM的堆中。那么这个对象到底是怎么在堆中保存的呢?

首先,new object()执行的时候,这个对象需要多大的空间,其实是已经确定的,因为java中的各种数据类型,占用多大的空间都是固定的(对其原理不清楚的请自行Google)。那么接下来的工作就是在堆中找出那么一块空间用于存放这个对象。

在单线程的情况下,一般有两种分配策略:

1. 指针碰撞:这种一般适用于内存是绝对规整的(内存是否规整取决于内存回收策略),分配空间的工作只是将指针像空闲内存一侧移动对象大小的距离即可。

2. 空闲列表:这种适用于内存非规整的情况,这种情况下JVM会维护一个内存列表,记录哪些内存区域是空闲的,大小是多少。给对象分配空间的时候去空闲列表里查询到合适的区域然后进行分配即可。

但是JVM不可能一直在单线程状态下运行,那样效率太差了。由于再给一个对象分配内存的时候不是原子性的操作,至少需要以下几步:查找空闲列表、分配内存、修改空闲列表等等,这是不安全的。解决并发时的安全问题也有两种策略:

1. CAS:实际上虚拟机采用CAS配合上失败重试的方式保证更新操作的原子性,原理和上面讲的一样。

2. TLAB:如果使用CAS其实对性能还是会有影响的,所以JVM又提出了一种更高级的优化策略:每个线程在Java堆中预先分配一小块内存,称为本地线程分配缓冲区(TLAB),线程内部需要分配内存时直接在TLAB上分配就行,避免了线程冲突。只有当缓冲区的内存用光需要重新分配内存的时候才会进行CAS操作分配更大的内存空间。
虚拟机是否使用TLAB,可以通过-XX:+/-UseTLAB参数来进行配置(jdk5及以后的版本默认是启用TLAB的)。






  • 大小: 37.5 KB
  • 大小: 4.1 KB
  • 大小: 5.6 KB
  • 大小: 6.6 KB
  • 大小: 26.9 KB
  • 大小: 34.6 KB
分享到:
评论

相关推荐

    Java并发篇乐观锁,悲观锁,自旋锁

    本文主要讨论了四种锁类型:乐观锁、悲观锁、自旋锁以及Java中的synchronized同步锁,并深入解析了synchronized锁的内部机制,包括其核心组件、实现方式以及锁的状态。 1. **乐观锁**:乐观锁假设在多线程环境下,...

    面试必备之乐观锁与悲观锁.pdf

    CAS(Compare and Swap)算法是一种常用的乐观锁实现方式,尤其适用于Java语言中。CAS涉及三个参数:需要读写的内存值V、进行比较的值A以及拟写入的新值B。只有当内存值V等于A时,CAS才会用B更新V的值。如果V的值...

    Java 多线程与并发(3-26)-Java 并发 - Java中所有的锁.pdf

    本文主要探讨了Java中的两种广义锁概念——乐观锁和悲观锁,以及自旋锁和适应性自旋锁的区别和应用场景。 1. 乐观锁与悲观锁: 乐观锁认为在读取数据时不会有其他线程修改,因此在读取时不加锁,而在更新数据时检查...

    乐观锁的一种实现方式-CAS编程开发技术共4页.pdf

    乐观锁是一种并发控制策略,它假设大多数情况下...但同时,理解和合理使用CAS,以及妥善处理可能出现的问题,是每个Java并发程序员的必备技能。通过深入学习和实践,我们可以更好地利用这一工具来提升软件的并发性能。

    java乐观锁

    Java乐观锁是一种非阻塞的并发控制策略,它假设在多线程环境下,大部分操作都不会发生数据冲突,因此不会像悲观锁那样在执行时对数据进行加锁。乐观锁主要应用于读多写少的场景,以提高系统的并发性能。下面我们将...

    Java并发问题之乐观锁与悲观锁

    "Java并发问题之乐观锁与悲观锁" 在 Java 中,并发问题是非常重要的,因为多线程的存在会引起数据的不一致性和安全性问题。为了解决这些问题,Java 提供了多种锁机制,其中最常见的就是乐观锁和悲观锁。 悲观锁...

    面试必备之乐观锁与悲观锁

    【描述】:“面试必备之乐观锁与悲观锁.pdf”涉及的是并发控制中的两种重要锁机制——悲观锁和乐观锁,它们是多线程环境下确保数据一致性的重要手段。 【标签】:“求职面试 多线程” 【正文】: 悲观锁和乐观锁...

    JAVA架构面试专题_面试必备之乐观锁与悲观锁.pdf

    Java 架构面试中,乐观锁与悲观锁是两个重要的并发控制概念,它们在多线程环境下用于确保数据的一致性。理解这两种锁对于开发者来说至关重要,特别是在高并发的系统设计中。 **悲观锁**是一种保守的并发控制策略,...

    p278 - p288 乐观锁和悲观锁

    乐观锁和悲观锁 在编程世界中,锁是五花八门的,每种锁的加锁开销以及应用场景也可能会不同。如何用好锁,也是程序员的基本素养之一了。在高并发的场景下,如果选择了合适的锁,则会大大提高系统的性能,否则性能会...

    Java并发编程全景图.pdf

    锁是实现线程同步的关键机制,Java提供了不同类型的锁,包括乐观锁、悲观锁、自旋锁等。自旋锁利用了CPU空转等待,减少了线程上下文切换的开销。公平锁和非公平锁的区别在于线程获取锁的顺序。读写锁(如...

    并发编程下的锁机制,乐观锁、悲观锁、共享锁、排他锁、分布式锁、锁降级原理篇

    在并发编程中,锁机制是控制多线程访问共享资源的一种重要方式,它确保了并发环境下的数据一致性。本文将详细讲解几种常见的锁机制:悲观锁、乐观锁、共享锁和排他锁,并简要介绍分布式锁以及锁降级原理。 1. **...

    彻底理解Java中的各种锁.pdf

    在Java中,乐观锁通常是通过CAS(Compare-And-Swap)实现,例如Java中的AtomicInteger类就使用了这种方式。悲观锁则假设最坏的情况,即每次读取数据都认为会被其他线程修改,所以总是对数据进行加锁。synchronized...

    Java中的锁分类与使用.docx

    Java中,`java.util.concurrent.atomic`包下的原子变量类如AtomicInteger、AtomicLong等利用CAS(Compare and Swap)算法实现了乐观锁。 - **悲观锁**假设并发环境下数据频繁被修改,因此在读取时就会上锁,确保...

    Java并发教程.md

    - **乐观锁**与**悲观锁**:乐观锁假设数据不会冲突,通常通过版本号等方式实现;悲观锁则假设数据会冲突,通过加锁来解决。 - **独占锁**与**共享锁**:独占锁在同一时刻只允许一个线程访问资源;共享锁允许多个读...

    Java 中15种锁的介绍

    Java中没有内置的乐观锁,但可以使用版本号、 CAS(Compare And Swap)等机制实现。 - **悲观锁** 总是假设最坏情况,每次读取都获取锁,防止其他线程修改数据。`synchronized`和`ReentrantLock`都属于悲观锁。 7....

    Java面试题并发部分.docx

    Java中的`CompareAndSwap`(CAS)操作是一种常见的乐观锁实现。 - **悲观锁**:悲观锁假设会有竞争,所以在读取数据时会立即加锁,防止其他线程修改。Java中的`synchronized`关键字和`ReentrantLock`类是悲观锁的代表...

Global site tag (gtag.js) - Google Analytics