欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页  >  IT编程

浅谈Java中的atomic包实现原理及应用

程序员文章站 2023-12-04 12:02:28
1.同步问题的提出 假设我们使用一个双核处理器执行a和b两个线程,核1执行a线程,而核2执行b线程,这两个线程现在都要对名为obj的对象的成员变量i进行加1操作,假设i的...

1.同步问题的提出

假设我们使用一个双核处理器执行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。

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

2.atomic在硬件上的支持

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

在x86平台上,cpu提供了在指令执行期间对总线加锁的手段。cpu芯片上有一条引线#hlockpin,如果汇编语言的程序中在一条指令前面加上前缀"lock",经过汇编以后的机器代码就使cpu在执行这条指令的时候把#hlockpin的电位拉低,持续到这条指令结束时放开,从而把总线锁住,这样同一总线上别的cpu就暂时不能通过总线访问内存了,保证了这条指令在多处理器环境中的原子性。当然,并不是所有的指令前面都可以加lock前缀的,只有add,adc,and,btc,btr,bts,cmpxchg,dec,inc,neg,not,or,sbb,sub,xor,xadd,和xchg指令前面可以加"lock"指令,实现原子操作。

atomic的核心操作就是cas(compareandset,利用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的原理有着本质的不同。

3.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);
}

这个方法就是最核心的cas操作

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;
    }
}

4.使用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();
	}
}

5.aba问题

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

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

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

atomicreference<t> tail;

浅谈Java中的atomic包实现原理及应用

假设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所示,这显然不是正确的结果。

6.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,但是戳值的预期值100和当前值103不同,所以compareandset失败,需要从新获取newsp的值(此时newsp就会指向c),以及戳的预期值103,然后再次进行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中戳值是一个整型变量。

总结

以上就是本文关于浅谈java中的atomic包实现原理及应用的全部内容,希望对大家有所帮助。感兴趣的朋友可以继续参阅本站其他相关专题,如有不足之处,欢迎留言指出。