Java 与 CPU 高速缓存
上周在公司临时做了个小分享,科普了下 CPU 高速缓存,以及 Java 程序员应该关注的问题,比如利用时空局部性原理,避免多核下的伪共享(False Sharing)等,因为是下午临时写的 PPT,可能有一些谬误,有兴趣的不妨看看
或者直接看链接: http://www.slideshare.net/killme2008/java-cpu-64198539
最近关注这个问题是因为在阅读《现代体系结构上的 Unix 系统——内核程序员的对称处理和缓存技术》一书,这是伞哥推荐的书,确实非常棒,澄清了我很多概念,推荐有兴趣的朋友阅读下。
Java 里伪共享的问题(False Sharing)已经被谈论了很多,甚至 Java8 都引入了 @Contended来帮助你做数据对齐,但是还有一个问题似乎没什么人谈过,那就是在获得自旋锁时减少对高速缓存行的争用,为了说明这个问题,我写了个例子:
import java.util.concurrent.CyclicBarrier;
import java.util.concurrent.atomic.AtomicInteger;
public class BusyLock {
	private int count;
	// 自旋锁状态
	private AtomicInteger state;
	private BusyLock() {
		this.count = 0;
		this.state = new AtomicInteger(0);
	}
	/**
	 * 利用 CAS 实现自旋锁
	 */
	private void lock() {
		while (!state.compareAndSet(0, 1))
			;
	}
	/**
	 * 解锁
	 */
	private void unlock() {
		state.set(0);
	}
	/**
	 * 递增 count,使用自旋锁保护 count
	 */
	public void incr() {
		lock();
		count = count + 1;
		unlock();
	}
	/**
	 * 测试,开启 50 个线程递增 count,每个线程循环 10 万次。
	 * 
	 * @param warnUp
	 * @throws Exception
	 */
	public static void runTests(boolean warnUp) throws Exception {
		int threads = 50;
		int n = 100000;
		BusyLock bl = new BusyLock();
		Thread[] ts = new Thread[threads];
		long start = System.nanoTime();
		CyclicBarrier barrier = new CyclicBarrier(threads + 1);
		for (int i = 0; i < threads; i++) {
			ts[i] = new Thread(new Runnable() {
				@Override
				public void run() {
					try {
						barrier.await();
						for (int j = 0; j < n; j++)
							bl.incr();
						barrier.await();
					} catch (Exception e) {
					}
				}
			});
			ts[i].start();
		}
		barrier.await();
		barrier.await();
		for (Thread t : ts) {
			t.join();
		}
		long duration = (System.nanoTime() - start) / 1000000;
		if (!warnUp)
			System.out.println("count= " + bl.count + ",cost:" + duration
					+ "ms");
	}
	public static void main(String[] args) throws Exception {
		// 测试,先 warm up
		runTests(true);
		// 实际测试
		runTests(false);
	}
}
代码稍微注释了下,熟悉 Java 类库的都应该可以理解。为了演示这个问题,这里没有用 ReentrantLock,而是使用 AtomicInteger 来实现自旋锁保护 count 这个资源,incr 方法会递增 count,启动 50 个线程并发循环调用 incr,然后计算耗时。
我们关注的是 lock 方法的实现:
	private void lock() {
		while (!state.compareAndSet(0, 1))
			;
	}
利用 compareAndSet 来尝试更新 state,1 表示锁定状态,0 表示空闲,每个线程都尝试去取得锁的所有权,如果失败,进入 while 忙等待。compare-and-set 都是利用 CPU 提供的原子指令(一般是 test_and_set)来实现。
这个实现没有问题,但是当考虑到多核状态下的高速缓存的时候,如果对于锁的争用非常激烈,那么会有很多的线程在 while 上的 compareAndSet 原子操作上等待,保存 state 的缓存行就会在多个 CPU 之间『颠簸』,导致了不必要的总线争用。
为了减少总线争用,我们可以改进下 lock:
	private void lock() {
		while (!state.compareAndSet(0, 1))
			//对于未能取得锁所有权的线程,在内层循环上等待
			//因为获取了 state 一份共享的高速缓存副本,
			//不会再进一步产生总线通信量
			while (state.get() == 1)
				;
	}
通过增加一个内循环 while (state.get() == 1),让没有获得锁的线程在 state 状态上等待,避免原子操作,可以减少总线争用。每个忙等待的 CPU 都将获得 state 状态的共享副本,以后的循环都将在共享副本上执行,不会再产生总线通信。
当占有锁的线程释放锁(原子地设置 state 为 0)的时候,这些共享的副本会失效(write-invalidate 策略,比如常见的 MESI 协议)或者被更新到最新状态(write-update 策略),这时候才会产生总线通信。
测试两个版本的 lock 方法,在我的机器上有比较明显的差异,没有内层循环的 lock 版本耗时在 6-8 秒,而增加了内层循环的 lock 耗时在 2.5 -5 秒。