文中将用到一些原子变量的特性,你能够将原子变量看作加强版的volatile变量。具备volatile的属性。并提供了CAS等原子操作。想了解很多其它原子变量的知识,能够參考《Java并发编程实践》第15章:原子变量与非堵塞同步机制。以下我们就開始了。
锁通常包括两个方法:lock和unlock。依照以下的方式来使用:
Lock lock = new ReentrantLock(); lock.lock(); try { // 临界区 } finally { lock.unlock(); }
线程通过调用lock来获取锁,然后进入临界区(锁保护的区域),完毕后使用unlock来释放锁。锁可以保证线程对临界区操作的原子性和可见性,原子性保证一次仅仅有一个线程可以进入临界区,可见性保证线程对临界区的全部状态的改动都能被其他线程看到,以下我将具体介绍锁的实现机制,看看锁是怎么保证临界区代码的原子性和可见性的,也就是说,你如何才干实现一个正确的锁。
实现正确的锁
在有些系统中,使用了这两种方式的结合,先自旋等待一段时间。假设不能获取到锁。线程再进入等待状态。为了简单。本文的样例都採用自旋等待的方式。
以下实现了一个简单的自旋锁SimpleLock。它具有一个state标识锁状态,当state为false时,表示锁空暇。而当state为true时。则表示锁已经被占用:
public class SimpleLock implements Lock { private AtomicBoolean state = new AtomicBoolean(false); @Override public void lock() { while (state.getAndSet(true)) { } } @Override public void unlock() { state.set(false); } }线程通过lock来获取锁。在lock中,线程不断的设置state的值到true。并返回state的旧值,假设state的旧值为false。则线程获取锁成功,并返回。否则一直自旋等待。
线程通过unlock方法来释放锁。在unlock中,线程将state的值设置到false。
那么,SimpleLock是否是一个正确的锁呢?也就是说。线程是否具备原子性和可见性呢?看以下的证明。
原子性
可见性
Lock lock = new SimpleLock(); lock.lock(); try { // 临界区 } finally { lock.unlock(); }假定存在线程A和线程B竞争锁。线程A先于线程B成功,则A和B的运行将具备例如以下的happens-before原则(想了解很多其它的happens-before原则,看我的博文“Java并发编程5-Java存储模式”),:
1)线程A运行lock.lock成功happens-before于线程A对临界区状态的改动(程序次序法则);
2)线程A对临界区状态的改动hanppens-before于线程A运行lock.unlock成功(程序次序法则);
3)线程A运行lock.unlock成功happens-before于线程B运行lock.lock成功(volatle变量法则)。
4)线程B运行lock.lock成功happens-before于线程B对临界区状态的改动(程序次序法则);
5)线程B对临界区状态的改动happens-before于线程B调用lock.unlock成功(程序次序法则)。
由此。由“传递性法则”我们能够得出:线程A对临界区状态的改动happens-before于线程B对临界区状态的改动。也就是说,线程A对临界区状态的改动对线程B来说都是可见的。
因此,SimpleLock具备可见性。
至此,我们能够得出结论。SimpleLock具有原子性和可见性。
除了原子性和可见性。锁也应该是无饥饿的,即线程经过有限的等待时间后。总是可以成功获取锁。那么SimpleLock是否是无饥饿的呢?在SimpleLock中。每一个线程想要获取锁时,都须要和其他线程竞争。获取锁的机会都是均等的。因此可以说SimpleLock是无饥饿的,可是。SimpleLock却不能保证线程等待的时间,可能非常长,也可能非常短。因此SimpleLock是不公平的。
另外,SimpleLock也是不可重入的。即一个线程不能多次获取锁(这篇文章都不涉及锁的重入。锁的重入将放到下一篇去讲)。
至此,我们就能够得出结论了。SimpleLock是一个正确的锁。可是。非常多时候我们不仅希望锁是正确的,我们也希望锁是高效的,那么。SimpleLock是一个高效的锁吗?以下我就来谈谈如何实现一个高效的锁。
实现高效的锁
public class CASLock implements Lock { private AtomicBoolean state = new AtomicBoolean(false); @Override public void lock() { while (!state.compareAndSet(false, true)) { } } @Override public void unlock() { state.set(false); } }CASLock和SimpleLock实际上是等同的。仅仅是在设置state时改用了compareAndSet操作。compareAndSet和getAndSet的差别在于:getAndSet总是将state设置为新值,然后返回旧值,而compareAndSet会先比較旧值,符合的情况下才改用新值,那么,改用compareAndSet究竟会给我们带来什么改变呢?在《多处理器编程的艺术》中对两种情况作了对照,随着线程数量的添加。SimpleLock要比CASLock性能减少快很多,以下我们从理论上来分析一下原因。
在现代的多处理器系统结构中,都是通过总线採用共享广播媒介的方式进行通讯的,处理器和存储控制器都能够在总线上广播。但一次仅仅能有一个处理器或者存储控制器在总线上广播。每一个处理器都有一个cache。用来存储该处理器感兴趣的数据,处理器对cache的訪问要比对内存的訪问快非常多。
当CPU取一个变量值时。首先查看cache,假设在cache中。则CPU产生一个cache命中。并马上载入这个值,否则。产生一个cache缺失。且必需要到内存或者其他CPU的cache中去找这个值。CPU会在总线上广播变量的地址,其他处理器将接收到广播,假设当中一个处理器的cache中存在那个变量的地址,则它会对广播发出回应。否则,CPU将使用内存中的地址的值。
我们再来看SimpleLock的运行过程。SimpleLock每次尝试都会改动state的值,都须要占用主线。并且,每次改动都会导致其他CPU的cache失效,其他CPU都须要通过主线来获取最新的值,大量的主线争用就产生了。更糟糕的是,线程自旋产生的主线争用还会导致其他线程解锁的堵塞(因为主线被占用)。
而CASLock则要好非常多,它并非每次尝试都会改动state的值,而是在state发生变化时,才会导致全部CPU去内存获取最新的值,这样就降低了一部分的主线占用。
可是当state的值一旦发生变化后,任然会导致其他CPU的cache失效,从而导致其他CPU对主线的争用。
从上面的全部理论分析中,我们已经不难得出结论:假设我们想实现高性能的锁,我们须要做到:
最大可能的降低数据竞争
这不仅是实现高性能的锁须要做的,这也是全部高效并发编程须要做的。
至于如何降低数据竞争。有一些比較好的策略,如后退和使用队列等。后退是锁每次竞争失败后都等待一段时间,然后再次尝试获取锁;而队列则是使用一个队列来保存等待的锁列表。如今使用较多的是使用队列的方式。因此这里我仅仅介绍队列锁算法。以下就来看看队列锁是如何将少数据竞争的。
队列锁
CLH队列锁
public class CLHLock implements Lock { private AtomicReference<Node> tail = new AtomicReference<>(); private ThreadLocal<Node> myNode = null; private ThreadLocal<Node> prevNode = null; public CLHLock() { tail.set(new Node()); myNode = new ThreadLocal<Node>() { protected Node initialValue() { return new Node(); } }; prevNode = new ThreadLocal<Node>() { protected Node initialValue() { return null; } }; } @Override public void lock() { Node node = myNode.get(); node.locked = true; Node prev = tail.getAndSet(node); prevNode.set(prev); while (prev.locked) { } } @Override public void unlock() { Node node = myNode.get(); node.locked = false; myNode.set(prevNode.get()); } private class Node { private volatile boolean locked = false; } }tail用来记录队列尾,线程调用lock后。获取自己的node。并将node的locked设置为true,表明自己准备或者已经获取锁,然后将自己加入到队列尾。并使用本地变量prev保存前续节点。最后循环探測前续节点的locked。直到前续节点的locked为false;线程调用unlock后。去自己相应的node,然后将node的locked设置为false,表明自己已经释放锁。
prevNode的作用是用于记录每一个线程的前续节点,它的作用是在unlock时能够将线程相应的节点设置为自己的前续节点。目的是为了节点重用。由于前续节点已经不会再被使用,而自己相应的节点则还在被兴许节点使用,当自己再次使用lock请求锁时,会再次改动自己相应的节点的locked属性。就可能会影响到兴许节点的处理。因此须要将自己相应的节点替换掉。当然,你也并非一定须要这样做,你能够直接将线程相应的节点设置到一个新节点
@Override public void unlock() { Node node = myNode.get(); node.locked = false; myNode.set(new Node()); }因为Java本身就具有垃圾回收机制,因此这样做也不会存在问题。
CLH队列锁带来的优点就是每一个节点都循环探測自己的前续节点的locked属性。当一个节点的locked属性发生变化时,仅仅会影响到兴许节点的cache失效,而且CLH队列锁能够做到先来先服务。
MCS队列锁
public class MCSLock implements Lock { private AtomicReference<Node> tail = new AtomicReference<>(null); private ThreadLocal<Node> myNode; public MCSLock() { myNode = new ThreadLocal<Node>() { protected Node initialValue() { return new Node(); } }; } @Override public void lock() { Node node = myNode.get(); Node prev = tail.getAndSet(node); if (prev != null) { node.locked = true; prev.next = node; while (node.locked) { } } } @Override public void unlock() { Node node = myNode.get(); if (node.next == null) { if (tail.compareAndSet(node, null)) { return; } while (node.next == null) { //队列尾发生了改变,必定有新的节点正在或者将要加入进来。因此循环等待 } } node.next.locked = false; node.next = null; } private class Node { private volatile boolean locked = false; private volatile Node next; } }线程调用lock后。首先获取自己相应的节点node,并将node设置为队列尾,并返回前续节点,假设前续节点不为空,则表明线程应该等待:线程首先将node的locked设置为true,表示自己将被堵塞,然后设置前续节点的next指向node,然后就開始循环直到node的locked属性被设置为false。
线程在调用unlock后。首先获取自己相应的节点node。假设node的next为空,则尝试将队列尾设置到空。假设成功,则说明队列已经为空。则退出;否则则说明队列尾发生了变化,须要等待其他线程设置node的next属性,最后设置node的next的locked属性,并退出。
MCS队列锁和CLH队列锁具有同样的特点。前续节点对状态的改变仅仅会影响到兴许的节点,不同点是MCS队列锁是在本地cache自旋等待。
结束语
有了这些基本知识后。我们就能够開始Java的Lock和ReadWriteLock的学习了,在学习了Lock和ReadWriteLock后,我将在JDK 8新推出StampedLock做一下介绍。
版权声明:本文博主原创文章。博客,未经同意不得转载。