Java中Atomic包的实现原理及应用

同步问题的提出

        假设我们使用一个双核处理器执行A和B两个线程,核1执行A线程,而核2执行B线程,这两个线程现在都要对名为obj的对象的成员变量i进行加1操作,假设i的初始值为0,理论上两个线程运行后i的值应该变成2,但实际上很有可能结果为1。

         我们现在来分析原因,这里为了分析的简单,我们不考虑缓存的情况,实际上有缓存会使结果为1的可能性增大。A线程将内存中的变量i读取到核1算数运算单元中,然后进行加1操作,再将这个计算结果写回到内存中,因为上述操作不是原子操作,只要B线程在A线程将i增加1的值写回到内存之前,读取了内存中i的值(此时i值为0),那么一定就会出现i的结果为1。因为A和B线程读取的i的值都为0,两个线程对它加1后的值都为1,两个线程先后将1写入到变量i中。

        最通常的解决方法是两个线程中对i加1的代码用synchronize关键字对obj对象加锁。今天我们介绍一种新的解决方案,即使用Atomic包中的相关类来解决。

 

Atomic在硬件上的支持

         在单处理器系统(UniProcessor)中,能够在单条指令中完成的操作都可以认为是"原子操作",因为中断只能发生于指令之间(因为线程的调度需要通过中断完成)。这也是某些CPU指令系统中引入了test_and_set、test_and_clear等指令用于临界资源互斥的原因。在对称多处理器(Symmetric Multi-Processor)结构中就不同了,由于系统中有多个处理器在独立地运行,即使能在单条指令中完成的操作也有可能受到干扰。

         在x86 平台上,CPU提供了在指令执行期间对总线加锁的手段。CPU芯片上有一条引线#HLOCK pin,如果汇编语言的程序中在一条指令前面加上前缀"LOCK",经过汇编以后的机器代码就使CPU在执行这条指令的时候把#HLOCK pin的电位拉低,持续到这条指令结束时放开,从而把总线锁住,这样同一总线上别的CPU就暂时不能通过总线访问内存了,保证了这条指令在多处理器环境中的原子性。 当然,并不是所有的指令前面都可以加lock前缀的,只有ADD, ADC, AND, BTC, BTR, BTS, CMPXCHG,DEC, INC, NEG, NOT, OR, SBB, SUB, XOR, XADD, 和 XCHG指令前面可以加"LOCK"指令,实现原子操作。

           Atomic的核心操作就是CAS(compare and set,利用CMPXCHG指令实现,它是一个原子指令),该指令有三个操作数,变量的内存值V(value的缩写),变量的当前预期值E(exception的缩写),变量想要更新的值U(update的缩写),当内存值和当前预期值相同时,将变量的更新值覆盖内存值,执行伪代码如下。

if(V == E){
     V = U
     return  true
}
else{
    return  false
}

         现在我们就用CAS操作来解决上述问题。B线程将内存中的变量i读取一个临时变量中(假设此时读取的值为0),然后再将i的值读取到core1的算数运算单元中,接下来进行加1操作,比较临时变量中的值和i当前的值是否相同,如果相同用运算单元中的结果(即i+1)后的值覆盖内存中i的值(注意这一部分就是CAS操作,它是个原子操作,不能被中断且其它线程中的CAS操作不能同时执行),否则指令执行失败。如果指令失败,说明A线程已经将i的值加1。由此可知如果两个线程一开始读取的i的值为都为0,那么必然只有一个线程的CAS操作能够成功,因为CAS操作不能并发执行。对于CAS操作执行失败的线程,只要循环执行CAS操作,那么一定能够成功。可以看到并没有线程阻塞,这和synchronize的原理有着本质的不同,又由于CAS消耗的时间很短,所以这种方式的同步效率很高。

 

Atomic包简介及源码分析

          Atomic包中的类基本的特性就是在多线程环境下,当有多个线程同时对单个(包括基本类型及引用类型)变量进行操作时,具有排他性,即当多个线程同时对该变量的值进行更新时,仅有一个线程能成功,不会被其他线程打断,而未成功的线程可以向自旋锁一样,继续尝试,一直等到执行成功。

         Atomic系列的类中的核心方法都会调用unsafe类中的几个本地方法,我们需要先知道一个东西就是Unsafe类,全名为:sun.misc.Unsafe,这个类包含了大量的对C代码的操作,包括很多直接内存分配以及原子操作的调用,而它之所以标记为非安全的,是告诉你这个里面大量的方法调用都会存在安全隐患,需要小心使用,否则会导致严重的后果,例如在通过unsafe分配内存的时候,如果自己指定某些区域可能会导致一些类似C++一样的指针越界到其他进程的问题。

         Atomic包中的类按照操作的数据类型可以分成4组

  •    AtomicBoolean,AtomicInteger,AtomicLong

         线程安全的基本类型的原子性操作

  •    AtomicIntegerArray,AtomicLongArray,AtomicReferenceArray

         线程安全的数组类型的原子性操作,它操作的不是整个数组,而是数组中的单个元素

  •    AtomicLongFieldUpdater,AtomicIntegerFieldUpdater,AtomicReferenceFieldUpdater

         基于反射原理对象中的基本类型(长整型、整型和引用类型)进行线程安全的操作

  •    AtomicReference ,AtomicMarkableReference,AtomicStampedReference

         线程安全的引用类型及防止ABA问题的引用类型的原子操作

         我们一般常用的AtomicInteger、AtomicReference和AtomicStampedReference分别是整型和引用类型和现在我们来分析一下Atomic包中AtomicInteger的源代码,其它类的源代码基本上基本上都比较类似。

            1. 有参构造函数

public AtomicInteger(int initialValue) {
      value = initialValue;
}

          从构造函数函数可以看出,数值存放在成员变量value中

private volatile int value;

         成员变量value声明为volatile类型,说明了多线程下的可见性,即任何一个线程的修改,在其它线程中都会被立刻看到

          2. compareAndSet方法(value的值通过内部this和valueOffset传递)

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

      

          3. getAndSet方法 , 在该方法中调用了compareAndSet方法

public final int getAndSet(int newValue) {
        for (;;) {
            int current = get();
            if (compareAndSet(current, newValue))
                return current;
        }
}

           如果在执行if (compareAndSet(current, newValue) 之前其它线程更改了value的值,那么导致value 的值必定和current的值不同,compareAndSet执行失败,只能重新获取value的值,然后继续比较,直到成功。

 

           4. i++的实现

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

          5. ++i的实现

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

使用AtomicInteger例子

         下面的程序,利用AtomicInteger模拟卖票程序,运行结果中不会出现两个程序卖了同一张票,也不会卖到票为负数

package javaleanning;

import java.util.concurrent.atomic.AtomicInteger;

public class SellTickets {
    AtomicInteger tickets = new AtomicInteger(100);

    class Seller implements Runnable{
        @Override
        public void run() {
            while(tickets.get() > 0){
                int tmp = tickets.get();
                if(tickets.compareAndSet(tmp, tmp-1)){
                    System.out.println(Thread.currentThread().getName()+"  "+tmp);
                }
            }
        }

    }

    public static void main(String[] args) {
        SellTickets st = new SellTickets();
        new Thread(st.new Seller(), "SellerA").start();
        new Thread(st.new Seller(), "SellerB").start();
    }

}

ABA问题

        上述的例子运行结果完全正确,这是基于两个(或多个)线程都是向同一个方向对数据进行操作,上面的例子中两个线程都是是对tickets进行递减操作。再比如,多个线程对一个共享队列进都行入列操作那么通过AtomicReference类也可以得到正确的结果(AQS中维护的队列其实就是这个情况),但是多个线程即可以入列也可以出列,也就是数据的操作方向不一致,那么可能出现ABA的情况。

        我们现在拿一个比较好理解的例子来解释ABA问题,假设有两个线程T1和T2,这两个线程对同一个栈进行出栈和入栈的操作。

        我们使用AtomicReference定义的tail来保存栈顶位置

AtomicReference<T>  tail;

        假设T1线程准备出栈,对于出栈操作我们只需要将栈顶位置由sp通过CAS操作更新为newSP即可,如图1所示。但是在T1线程执行tail.compareAndSet(sp, newSP)之前系统进行了线程调度,T2线程开始执行。T2执行了三个操作,A出栈,B出栈,然后又将A入栈。此时系统又开始调度,T1线程继续执行出栈操作,但是对于T1线程开来,栈顶元素仍然为A,(即T1仍然认为B还是栈顶A的下一个元素),而实际上的情况如图2所示。T1会认为栈没有发生变化,所以tail.compareAndSet(sp, newSP)执行成功,栈顶指针被指向了B节点。而实际上B已经不存在于堆栈中,T1将A出栈后的结果如图3所示,这显然不是正确的结果。

ABA问题的解决方法

         使用AtomicMarkableReference,AtomicStampedReference。使用上述两个Atomic类进行操作。他们在实现compareAndSet指令的时候除了要比较当前值和预期值以外,还要比较(操作的)戳值,当预期值和(操作的)戳值全部相同时,compareAndSet方法才能成功。每次更新成功戳值都会发生变化,戳值的设置是由编程人员自己控制的。

public boolean compareAndSet(V  expectedReference, V  newReference, int expectedStamp,int newStamp) {
    Pair<V> current = pair;
    return  expectedReference == current.reference && expectedStamp == current.stamp &&
              ((newReference == current.reference && newStamp == current.stamp) || casPair(current, Pair.of(newReference, newStamp)));
}

         这时的compareAndSet 方法需要四个参数expectedReference, newReference, expectedStamp, newStamp,我们在使用这个方法时要保证期望的戳值和要更新戳值不能一样,通常 newStamp = expectedStamp + 1

         还拿上述的例子

         假设线程T1在弹栈之前:     sp指向A,戳值为100。

         线程T2执行: 将A出栈后,sp指向B,戳值变为101,

                                        B出栈后,sp指向C,戳值变为102,

                                        A入栈后,sp指向A,戳值变为103,

          线程T1继续执行compareAndSet语句,发现sp虽然还是指向A,但是戳值的预期值和当前值不同,所以compareAndSet失败,从新获取newSP的值(此时newSP就会指向C),以及戳的预期值,然后再次进行compareAndSet操作,这样A成功出栈,sp会指向C。

          注意,由于compareAndSet只能一次改变一个值,无法同时改变newReference和newStamp,所以在实现的时候,在内部定义了一个类Pair类将newReference和newStamp变成一个对象,进行CAS操作的时候,实际上是对Pair对象的操作

private static class Pair<T> {
    final T reference;
    final int stamp;
    private Pair(T reference, int stamp) {
        this.reference = reference;
        this.stamp = stamp;
    }
    static <T> Pair<T> of(T reference, int stamp) {
        return new Pair<T>(reference, stamp);
    }
}

对于AtomicMarkableReference而言,戳值是一个布尔类型的变量,而AtomicStampedReference中戳值是一个整型变量。

参考内容

[1] http://my.oschina.net/cloudcoder/blog/359291

[2] http://hustpawpaw.blog.163.com/blog/static/184228324201210811243127/

[3] http://baike.baidu.com/link?url=YEubSHRkbAgKHfP1MvL6ArNn-_mrsQRxGaRqY4Ey01EiEIblyGw5QNEfemEmrqQZw0X70Pkw9Ziqa52ShR4WGa

时间: 2024-10-13 11:46:19

Java中Atomic包的实现原理及应用的相关文章

24.Java中atomic包中的原子操作类总结

1. 原子操作类介绍 在并发编程中很容易出现并发安全的问题,有一个很简单的例子就是多线程更新变量i=1,比如多个线程执行i++操作,就有可能获取不到正确的值,而这个问题,最常用的方法是通过Synchronized进行控制来达到线程安全的目的(关于synchronized可以看这篇文章).但是由于synchronized是采用的是悲观锁策略,并不是特别高效的一种解决方案.实际上,在J.U.C下的atomic包提供了一系列的操作简单,性能高效,并能保证线程安全的类去更新基本类型变量,数组元素,引用类

java中的&quot;包&quot;机制

完全掌握java中的"包"机制 "包"机制是java中特有的,也是java中最基础的知识.一些初学java的朋友,通常象学其它语言一样从教材上copy一些程序来运行,可是却常常遇到莫名其妙的错误提示.这些问题事实上都出在对"包"的原理不够清楚.本文将就此问题进行深入阐述. 一.为什么java中要有"包"的概念? 以一言概之,java中"包"的引入的主要原因是java本身跨平台特性的需求.因为java中的所有

Java中带包(创建及引用)的类的编译与调试

Java中带包(创建及引用)的类的编译与调试 java源程序的编译大家都知道,也就是cmd中到源文件所在目录下javac **.java即可,当程序中有包声明还能简简单单的直接javac **.java吗?答案当然是no,下面举个简单的例子证明一下直接javac **.java会怎么样. 如下:F:\javaweb2班\20160531目录下有A.java文件,注意源文件中有包声明 package mypack; public class A { String name; int age; pu

Java中的包

以下内容引用自http://wiki.jikexueyuan.com/project/java/packages.html: 在Java中使用包是为了防止命名冲突,来控制访问,使得搜索/定位和类.接口.枚举和注释等的使用更为简单. 包可以被定义为一组相关的类型(类.接口.枚举和注释),提供访问保护和命名空间管理. 在Java中一些已经存在的包有: java.lang - 包含了基本类 java.io - 包含有输入,输出功能的类 程序员可以定义自己的包来包含各种类和接口等.把实现的相关类组织在一

Java中的包(package)

包的概念 Java中包(package)的概念和C++中命名空间(namespace)的概念很类似,都可以限制类的作用域.二者最大的差别在于,Java中的包隐式地指明了类的树形层级结构(同时也是Java源码文件的目录结构).这样做的好处在于:可以通过文件系统中对于文件路径唯一性的要求来限制类的唯一性. 代码组织 编写一个Java源代码文件(.java文件)时,此文件通常被称为编译单元.在编译单元内最多允许有一个public类,且该类的名称必须与文件名完全相同(包括大小写). 编译一个.java文

java中的包(package)

Java中的包在一定程度上类似于C++的命名空间,可以用于防止类名冲突.Java自带的类都位于java和javax包层次中,但引入时不能使用import java.*或import javax.*. 当遇到同时使用几个不同包下名称相同的类时,在使用的地方可以直接加上包路径以防止冲突,如 java.util.Date date=new java.util.Date date(); 静态导入 import static java.lang.Math ,则可直接使用Math的静态方法,如sqrt(a)

java中concurrent包内容

有BlockingQueue及其相关的类,跟阻塞队列有关系. ConcurrentHashMap,ConcurrentLinkedQueue等,这些是相关集合的线程同步版本. CopyOnWriteArrayList,也是一种并发用的容器,当我们改变这个数组的时候,先复制一个副本,修改这个副本,再复制回去.这样就实现了读写分离,适用于读多写少的并发场景. CountDownLatch,这个类适用于这种情况:多个线程同时工作,然后其中几个可以随意并发执行,但有一个线程需要等其他线程工作结束后,才能

Java中,包的概念、常量、静态成员、继承

新建包:左上角-新建-包 命名规则(通常从大到小,方便整合不容易冲突)  例如:com.itnba.maya.test package必须在最顶行,之前不能再有其他代码 使用包: 快捷方式:使用包中的某个变量名alt+/ import 包名.类名; //只引用包中某一个 import 包名.*; //引用包中所有的 例 import com.itnba.maya.*; 常量:final    (Java中没有真正的常量.只有final型的变量,可以当常量来用.   一次定义,多次使用,不可被更改

java中的包以及内部类的介绍

1:形式参数和返回值的问题(理解)    (1)形式参数:        类名:需要该类的对象        抽象类名:需要该类的子类对象        接口名:需要该接口的实现类对象    (2)返回值类型:        类名:返回的是该类的对象        抽象类名:返回的是该类的子类对象        接口名:返回的是该接口的实现类的对象    (3)链式编程        对象.方法1().方法2().......方法n();                这种用法:其实在方法1()