Java并发编程的艺术——读书笔记(四) 原子操作的实现原理

第二章 Java并发机制的底层实现原理(三) 原子操作

 

原子在化学反应中是不可再分的基本微粒,因此那些不可被分割或者被中断的一系列操作就被称为“原子操作”,本篇重点解释原子性的重要意义和实现原理。


为什么需要原子性

首先来看一段程序:

public class Practise {
	private int i = 0;
	public static void main(String[] args) {
		final Practise cas = new Practise();
		List<Thread> ts = new ArrayList<Thread>(600);
		for (int j = 0; j < 100; j++) {
				Thread t = new Thread(new Runnable() {
					@Override
					public void run() {
						for (int i = 0; i < 10000; i++) {
							cas.count();
						}
					}
				});
				ts.add(t);
		}
		for (Thread t : ts) {
			t.start();
		}
	    // 等待所有线程执行完成
		for (Thread t : ts) {
			try {
				t.join();
			} catch (InterruptedException e) {
				e.printStackTrace();
			}
		}
		System.out.println(cas.i);
	}
	
	/**
	* 非线程安全计数器
	*/
	private void count() {
		i++;
	}
}

 这个程序的作用就是计数,它创建了100个进程,每个进程都会将count()方法循环10000次,按理说最后出来的结果应该是1000000,但实际运行后你会得到一个小于1000000的数,而且每次运行得到的结果都不一样,且都小于预期结果,我测试了三次的结果分别是971714,986947和972301,这是为什么呢?

如果多个线程同时对共享变量进行读改写操作 (i++就是经典的读改写操作),那么共享变量就会被多个处理器同时进行操作,这样读改写操作就不是原子的,操作完之后共享变量的值会和期望的不一致。

举个例子,如果i=1,我们现在进行两次i++操作,预期结果是3,但可能结果是2,如图所示:

两个线程同时操作,取出i后分别进行+1操作,再分别写入的结果,因此要想让结果是对的,就必须保证操作的原子性。

 

在解释原子操作的具体实现前先给个术语列表:


处理器如何实现原子操作

  1. 处理器会保证基本的内存操作的原子性,当一个处理器读取一个字节时,其他处理器不能访问这个字节的内存地址;
  2. 一些处理器可以自动保证单处理器对同一个缓存行里进行16/32/64位(根据缓存行里的字节宽度来定)的操作是原子的;
  3. 对于复杂的内存操作处理器是不能自动保证原子性的,比如对跨总线宽度/跨缓存行/跨页表的访问,但是有两个解决方法来保证内存操作的原子性:

1) 使用总线锁保证原子性

所谓总线锁就是使用处理器提供的一个LOCK#信号,当一个处理器发出该信号时,其他处理器的请求会被阻塞,这时候处理器可以独享内存;

2) 使用缓存锁保证原子性

使用总线锁时,所有处理器的请求都会被阻塞,即使那些访问的内存地址不与该处理器起冲突的处理器也一样,这就使得总线的开销比较大,因此某些CPU使用缓存锁代替总线锁来进行优化;

所谓“缓存锁定”是指内存区域如果被缓存在处理器的缓存行中,并且在Lock操作期间被锁定,那么当它执行锁操作回写到内存时,处理器不在总线上声言LOCK#信号,而是修改内部的内存地址,并允许它的缓存一致性机制来保证操作的原子性,因为缓存一致性机制会阻止同时修改由两个以上处理器缓存的内存区域数据,当其他处理器回写已被锁定的缓存行数据时,会使缓存行无效。

这段话可能理解起来有点困难,简单一句话概括就是,针对缓存行的锁定,保证只有一个处理器缓存被锁定的内存地址,当该处理器修改缓存行时使用了缓存锁定,那么其他处理器就不能同时缓存该缓存行。

不能使用缓存锁定的情况

  1. 当操作的数据不能被缓存在处理器内部,或操作的数据跨多个缓存行时,则处理器会调用总线锁定;
  2. 有些处理器不支持缓存锁定。对于Intel 486和Pentium处理器,就算锁定的内存区域在处理器的缓存行中也会调用总线锁定。

 

Java如何实现原子操作

Java中可以使用锁和循环CAS的方式实现原子操作,锁就不多说了,线程获得锁以后完成操作才会释放,因此能保证操作是原子的,所以本篇主要介绍CAS。

CAS(Compare and Swap),翻译过来就是“比较并交换”,上篇已经简单介绍过了,这里原样贴出来。

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

那么我们简单分析一下,就可以知道CAS是如何保证原子性的了。

分析上面两个例子(java程序运行结果不为1000000,两次i++结果不为3)可知,问题主要出在第二个处理器上,因为第一个处理器完成操作时,第二个处理器中使用的值已经过时,因此只要保证处理器在进行操作时,确认缓存中的值和原来内存地址中的值相等后再进行操作,就可以避免结果和预期不一致的问题,而CAS的思路就是,首先确认当前值和原值是否一致,是则更新该值,否则不进行操作,正好与上面解决问题的思路不谋而合。

现在我们再对最开始的java程序进行修改,使用CAS实现线程安全计数器,修改后的程序如下:

public class Practise {
	private AtomicInteger atomicI = new AtomicInteger(0);
	private int i = 0;
	public static void main(String[] args) {
		final Practise cas = new Practise();
		List<Thread> ts = new ArrayList<Thread>(600);
		long start = System.currentTimeMillis();
		for (int j = 0; j < 100; j++) {
				Thread t = new Thread(new Runnable() {
					@Override
					public void run() {
						for (int i = 0; i < 10000; i++) {
							cas.safeCount();
						}
					}
				});
				ts.add(t);
		}
		for (Thread t : ts) {
			t.start();
		}
	// 等待所有线程执行完成
		for (Thread t : ts) {
			try {
				t.join();
			} catch (InterruptedException e) {
				e.printStackTrace();
			}
		}
		System.out.println(cas.atomicI.get());
	}
	/* 使用CAS实现线程安全计数器 */
	private void safeCount() {
		for (;;) {
			int i = atomicI.get();
			boolean suc = atomicI.compareAndSet(i, ++i);
			if (suc) {
				break;
			}
		}
	}
	
}

这段程序于上面的程序相比有两处不同:

1. 多加了一行代码

private AtomicInteger atomicI = new AtomicInteger(0);

从JDK1.5开始,JDK的并发包就提供了一些类来支持原子操作,比如AtomicBoolean、AtomicInteger和AtomicLong

2. 原来的计数器方法改成了线程安全计数器方法,里面最关键的一行代码是:

boolean suc = atomicI.compareAndSet(i, ++i);

通过不断的循环,先读取i的值,然后进行CAS操作,如果中间i的值有变化,compareAndSet方法不会执行操作并返回false,如果i值从读取到执行CAS中间没有变化,则CAS执行++i操作并返回true,跳出循环,此为一次计数。


CAS实现原子操作的三大问题及其解决方案

1. ABA问题

由于CAS会先检查操作数有没有变化,但如果一个值原来是A,之后改成B,最后再改回A,那么CAS检查的时候就认为该值没有发生变化,解决这个问题的方法是加上版本号,每更改一次版本号加1,那么A-->B-->A就会变成1A-->2B-->3A。

在Java1.5开始,JDK的Atomic包里提供了一个类AtomicStampedReference来解决ABA问题。这个 类的compareAndSet方法的作用是首先检查当前引用是否等于预期引用,并且检查当前标志是 否等于预期标志,如果全部相等,则以原子方式将该引用和该标志的值设置为给定的更新值。

public boolean compareAndSet(
    V expectedReference,     // 预期引用
    V newReference,          // 更新后的引用
    int expectedStamp,       // 预期标志
    int newStamp             // 更新后的标志
}

2. 循环时间长开销大

因为CAS是自旋的,如果长时间不成功,就会给CPU带来很大的执行开销,比如刚刚的程序中,如果CAS操作持续返回false,就会陷入无限循环中。如果JVM能支持处理器提供的pause指令,那么效率会有一定的提升。

pause指令有两个作用:第 一,它可以延迟流水线执行指令(de-pipeline),使CPU不会消耗过多的执行资源,延迟的时间取决于具体实现的版本,在一些处理器上延迟时间是零;第二,它可以避免在退出循环的时候因内存顺序冲突(Memory Order Violation)而引起CPU流水线被清空(CPU Pipeline Flush),从而提高CPU的执行效率。

(老实讲我也不太理解这段话,应该是避免CAS无限自旋吧)

3. 只能保证一个共享变量的原子操作

当对多个共享变量操作时,循环CAS就无法保证操作的原子性,这个时候就需要用锁了,不过还有一个方法就是,把多给变量合成一个变量来对待,比如i=a,j=1合并一下就说ij=a1,然后用CAS来操作ij。

从Java 1.5开始,JDK提供了AtomicReference类来保证引用对象之间的原子性,就可以把多个变量放在一个对象里来进行CAS操作。


 

使用锁机制实现原子操作

锁机制保证了只有获得锁的线程才能够操作锁定的内存区域,这样更方便,也更安全,但有意思的是,除了偏向锁之外,JVM实现锁的方式都用了循环CAS,即当一个线程想进入同步块的时候使用循环CAS的方式来获取锁,当它退出同步块的时候使用循环CAS释放锁(具体实现见上一篇)。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值