diff --git a/week_03/11/AQS.md b/week_03/11/AQS.md new file mode 100644 index 0000000000000000000000000000000000000000..3bbb778e4d9a78ce8af2261f22242773e26faa62 --- /dev/null +++ b/week_03/11/AQS.md @@ -0,0 +1,125 @@ +AbstractQueuedSynchronizer是并发编程的核心框架 ReentrantLock. ReadWriteLock. CountDownLatch等都是基于他实现的 + +public abstract class AbstractQueuedSynchronizer extends AbstractOwnableSynchronizer implements java.io.Serializable 1 2 3 这是一个抽象类, 无法直接new出来 所以要继承并重写他的方法 需要重写的方法有: + +protected boolean tryAcquire(int arg) { throw new UnsupportedOperationException(); } + +protected boolean tryRelease(int arg) { throw new UnsupportedOperationException(); } + +protected int tryAcquireShared(int arg) { throw new UnsupportedOperationException(); } + +protected boolean tryReleaseShared(int arg) { throw new UnsupportedOperationException(); } + +protected boolean isHeldExclusively() { throw new UnsupportedOperationException(); } 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 AQS框架维护着两种模式, 独占模式和共享模式. 你可以任选其一实现,或者两者都实现 其中tryAcquire和tryRelease是独占模式 + +tryAcquireShared 和 tryReleaseShared为共享模式 + +我们先看一下他的独占模式实现的机制 我们可以基于ReentrantLock讲解 + +独占模式 ReentrantLock的Lock方法(获取锁操作) 其中他以内部类的形式继承了AQS 也就是sync是一个AQS的子类,它辅助了ReentrantLock + +public void lock() { sync.acquire(1); } 1 2 3 接下来就要介绍AQS的acquire方法了 + +public final void acquire(int arg) { if (!tryAcquire(arg) && acquireQueued(addWaiter(Node.EXCLUSIVE), arg)) selfInterrupt(); } 1 2 3 4 5 首先需要调用子类实现的tryAcquire方法(这里如果不实现就直接抛异常) 那接下来就要说一下AQS的实现机理了 AQS维护了一个int类型的变量state和一个队列(双向链表) + +那ReentrantLock的公平锁是如何实现的呢? ReentrantLock使用了独占模式,其state维护的是线程的数量值 同时ReentrantLock还保存了独占锁的线程 state一开始是0代表没有线程获取, state > 0 就代表有线程获取 state = 0的情况就让ReentrantLock保存的线程等于当前线程, 让state变为1 如果state > 0了 有其他线程抢夺资源. 注意:这里实现的是公平锁, 判断一下, 如果当前的线程和我保存的线程是一样的, 那么让state++ 如果不一样的话就扔到等待队列去 队列是一种FIFO(先进先出)的数据结构, 他维护了线程的公平性, + +好了回到代码 + +public final void acquire(int arg) { if (!tryAcquire(arg) && acquireQueued(addWaiter(Node.EXCLUSIVE), arg)) selfInterrupt(); } 1 2 3 4 5 tryAcquire(arg) 的 arg 在上面的lock方法默认调用的是1 你就可以记住 arg是1 tryAcquire(arg)表示尝试获取锁, 如果获取成功返回true, 不成功返回false 因为判断的时候短路运算, 如果tryAcquire(arg)返回了true就不会执行后面的方法 否则返回false 代表当前线程没有拿到锁就应该放到等待队列中去, 同时判断一下有无响应中断机制 + +首先先来看一下ReentrantLock实现的tryAcquire(arg)方法, 但是这其实并不是必须的.因为这是他自己重写了实现的 + +protected final boolean tryAcquire(int acquires) { //获取一下当前线程 final Thread current = Thread.currentThread(); //得到state就是上述维护的那个int值 这里是锁的当前数量(也就是可重入锁) int c = getState(); //c=0的意思就是当前没有线程获得锁 if (c == 0) { //如果等待队列中有元素,也就表示有人在前面排队 //所以你新进来的线程必须等待 //如果队列中没有元素了并且CAS操作状态成功(待会说) //setExclusiveOwnerThread这个方法就设置锁的线程绑定为当前线程 //返回获取锁成功 if (!hasQueuedPredecessors() && compareAndSetState(0, acquires)) { setExclusiveOwnerThread(current); return true; } } //否则判断一下当前线程和绑定的线程是否是同一个线程 else if (current == getExclusiveOwnerThread()) { //这里只是把state值+1 int nextc = c + acquires; if (nextc < 0) throw new Error("Maximum lock count exceeded"); setState(nextc); return true; } //如果程序运行到这 就说明有线程获得了锁, 并且不是当前线程 //由于公平的原因就返回无法获得锁 return false; } 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 CAS(CompareAndSwap) CAS即比较并替换 一个经典问题i++这个问题在以前我们只是串行的就跑没有问题 但是在多线程情况下 可能出现脏读的情况 也就是内存的可见性和原子性的问题 i++这个操作看似一步语句, 其实包含了三步 第一步先拿到i的值 第二步 给i+1 第三步 再把i的值写回去 下列并发情况就会出现问题 + +第一个线程先读到内存中的值 i=1 并且给他+1 这个时候线程2先进来了,读了一下内存中的值, 这个时候线程1中还没有更改 所以线程2也读到的1 这个时候线程1, 2 都修改了i且都变成了2 但是我们的期望值应该是3 所以这个时候就用到了CAS技术 CAS有3个值 一个是内存值V(也就是期望值), 还有两个参数,A,B CAS就是 我认为你的V应该等于A 如果相等 我把V替换成B 否则我什么也不做 所以上述的问题, 如果有两个线程获得执行权 + +且都进入到c == 0 线程1调用compareAndSetState(0, acquires) 这里的acquires是1 如果成功了就把state的0改成了1 这样线程2就算到了进入到了c==0 然后他调用compareAndSetState(0, acquires)方法 他期望内存值是0 可是,我们知道他已经被线程1改成了1 所以就什么也不做并返回false + +好了之后的只要有CAS操作我都不用讲这么细了, 如果CAS失败就说明发生了并发问题 + +tryAcquire的方法还是比较好理解 + +public final void acquire(int arg) { if (!tryAcquire(arg) && acquireQueued(addWaiter(Node.EXCLUSIVE), arg)) selfInterrupt(); } 1 2 3 4 5 接下来流程应该走到addWaiter(Node.EXCLUSIVE), arg)方法 + +private Node addWaiter(Node mode) { Node node = new Node(mode); //自旋添加结点 for (;;) { Node oldTail = tail; //如果尾结点不是空的 if (oldTail != null) { //因为新节点要添加到尾部 //设置尾部的上一个结点为之前的尾部 node.setPrevRelaxed(oldTail); //如果CAS操作失败了说明发生了并发问题 //就自旋去设置重新跑一次循环 if (compareAndSetTail(oldTail, node)) { oldTail.next = node; return node; } + + } else {//这里说明队列是空的,则初始化队列 + initializeSyncQueue(); + } +} +} + +private final void initializeSyncQueue() { Node h; if (HEAD.compareAndSet(this, null, (h = new Node()))) tail = h; } 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 接下来流程走到了acquireQueued方法 可以看到这里也用到了死循环自旋CAS操作, 这是一套标准组合拳 这个方法是尝试把线程挂起 + +final boolean acquireQueued(final Node node, int arg) { boolean interrupted = false; try { for (;;) { //这里的node是新添加的结点 final Node p = node.predecessor(); //如果新添加的是第二个结点 //则有可能到这里的时候第一个运行完了,也就说没有线程获得锁了 //所以这个线程再次尝试获取锁 if (p == head && tryAcquire(arg)) { setHead(node); p.next = null; // help GC return interrupted; } //这里尝试线程是否安全挂起 if (shouldParkAfterFailedAcquire(p, node)) interrupted |= parkAndCheckInterrupt(); } } catch (Throwable t) { //维护队列待会说 cancelAcquire(node); if (interrupted) selfInterrupt(); throw t; } } 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 先说维护队列 每个结点有几种状态, 这些状态由int类型来表示 + +static final int CANCELLED = 1; static final int SIGNAL = -1; static final int CONDITION = -2; static final int PROPAGATE = -3; 1 2 3 4 CANCELLED:值为1,在同步队列中等待的线程等待超时或被中断,需要从同步队列中取消该Node的结点,其结点的waitStatus为CANCELLED,即结束状态,进入该状态后的结点将不会再变化。 + +SIGNAL:值为-1,被标识为该等待唤醒状态的后继结点,当其前继结点的线程释放了同步锁或被取消,将会通知该后继结点的线程执行。说白了,就是处于唤醒状态,只要前继结点释放锁,就会通知标识为SIGNAL状态的后继结点的线程执行。 + +CONDITION:值为-2,与Condition相关,该标识的结点处于等待队列中,结点的线程等待在Condition上,当其他线程调用了Condition的signal()方法后,CONDITION状态的结点将从等待队列转移到同步队列中,等待获取同步锁。 + +PROPAGATE:值为-3,与共享模式相关,在共享模式中,该状态标识结点的线程处于可运行状态。 + +这里我们先只关注SIGNAL 新创建的结点默认是为0的 也就是说 如果队列有4个 那么他的状态一定是 -1 -1 -1 0 + +过程走到了shouldParkAfterFailedAcquire方法 + +判断当前结点的前一个结点的等待状态是不是-1 如果是-1的话就可以安全的将这个挂起 如果前一个被取消了的话 需要维护队列 循环跳过所有被取消了的结点找到一个没有被取消的结点 把当前结点排到他的后面 + +private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) { int ws = pred.waitStatus; if (ws == Node.SIGNAL) + + return true; + if (ws > 0) { + //这里就是维护队列 + do { + node.prev = pred = pred.prev; + } while (pred.waitStatus > 0); + pred.next = node; + } else { + //尝试把当前线程设置为SIGNAL + pred.compareAndSetWaitStatus(ws, Node.SIGNAL); + } + return false; +} +1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 //通过LockSupport挂起线程,等待唤醒 private final boolean parkAndCheckInterrupt() { LockSupport.park(this); return Thread.interrupted(); } 1 2 3 4 5 ReentrantLock的unLock方法(释放锁操作) 释放锁比获取锁要简单一些 同样依靠的是AQS + +public void unlock() { sync.release(1); } + +public final boolean release(int arg) { if (tryRelease(arg)) { Node h = head; if (h != null && h.waitStatus != 0) unparkSuccessor(h); return true; } return false; } 1 2 3 4 5 6 7 8 9 10 11 12 13 先将一下tryRelease尝试释放锁的方法 + +protected final boolean tryRelease(int releases) { int c = getState() - releases; //c是当前锁-1 //判断一下当前线程如果和绑定的线程不是同一个抛异常 if (Thread.currentThread() != getExclusiveOwnerThread()) throw new IllegalMonitorStateException(); boolean free = false; //判断一下 锁-1的数量是不是等于0 //如果成立就把绑定线程置空 if (c == 0) { free = true; setExclusiveOwnerThread(null); } //不管如何都设置锁的数量-1 setState(c); //如果是锁的数量变为0了也就是释放了返回true 否则只是把锁数量-1 return free; } 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 然后再看一下release操作, 如果释放锁成功, 且队列不是空的情况下调用unparkSuccessor 其实这个猜也可以猜出来就是唤醒下一个线程 + +public final boolean release(int arg) { if (tryRelease(arg)) { Node h = head; if (h != null && h.waitStatus != 0) unparkSuccessor(h); return true; } return false; } 1 2 3 4 5 6 7 8 9 看一下唤醒下一个线程的方法 + +private void unparkSuccessor(Node node) { + + int ws = node.waitStatus; + //这里先只说SINGAL + //因为等待状态为SINGAL是-1表示挂起, 等待状态为0表示运行 + if (ws < 0) + node.compareAndSetWaitStatus(ws, 0); + + //从队列里找出下一个需要唤醒的节点 + Node s = node.next; + //如果这个结点是空或者他的状态标记为放弃 + //就从尾部向头搜索到第一个需要唤醒的结点 + if (s == null || s.waitStatus > 0) { + s = null; + for (Node p = tail; p != node && p != null; p = p.prev) + if (p.waitStatus <= 0) + s = p; + } + if (s != null) + LockSupport.unpark(s.thread); +} +1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 好了, 公平锁已经讲解完毕, 那么下面说一下非公平锁 如果你是认认真真看到这, 或者已经有了基础看到这 那么下面的应该不难理解 先看一下二者的差别 + +首先是公平锁 + +protected final boolean tryAcquire(int acquires) { final Thread current = Thread.currentThread(); int c = getState(); if (c == 0) { if (!hasQueuedPredecessors() && compareAndSetState(0, acquires)) { setExclusiveOwnerThread(current); return true; } } else if (current == getExclusiveOwnerThread()) { int nextc = c + acquires; if (nextc < 0) throw new Error("Maximum lock count exceeded"); setState(nextc); return true; } return false; } 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 非公平锁 + +protected final boolean tryAcquire(int acquires) { return nonfairTryAcquire(acquires); } + +final boolean nonfairTryAcquire(int acquires) { final Thread current = Thread.currentThread(); int c = getState(); if (c == 0) { if (compareAndSetState(0, acquires)) { setExclusiveOwnerThread(current); return true; } } else if (current == getExclusiveOwnerThread()) { int nextc = c + acquires; if (nextc < 0) // overflow throw new Error("Maximum lock count exceeded"); setState(nextc); return true; } return false; } 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 其实这里就发现 公平的调用了是否有等待队列, 而非公平的是直接尝试进行CAS获取 也就是说公平的只要有队列就返回获取锁失败 而非公平的则是抢占式的获取锁, 失败了再进队列 如果这个时候队列有3个线程在等待 这时候又新进来了一个线程, 在运行的线程刚好结束了, 那么新进的线程就抢到了锁, 这样就实现了非公平 注意: 非公平锁是默认的 \ No newline at end of file diff --git "a/week_03/11/Java\345\206\205\345\255\230\346\250\241\345\236\213.md" "b/week_03/11/Java\345\206\205\345\255\230\346\250\241\345\236\213.md" new file mode 100644 index 0000000000000000000000000000000000000000..ca219d0327165539dc48a5f7b41977683e3ed93f --- /dev/null +++ "b/week_03/11/Java\345\206\205\345\255\230\346\250\241\345\236\213.md" @@ -0,0 +1,87 @@ +相关定义 +① java内存模型规定了所有的变量都存储在主内存中 + +② 每条线程还有自己的工作内存,线程的工作内存中保存了该线程使用到的变量的主内存存副本拷贝,线程对变量的所有操作(读取、赋值等)都必须在工作内存中进行,而不能直接写主内存中进行,而不能直接读写主内存中的变量 + +③ 不同线程之间也无法直接访问对方工作内存中的变量,线程变量值的传递均需要通过住内存来完成,线程、主内存、工作内存三者交关系图如下: + +内存交互操作 +lock (锁定): 作用于主内存,把一个变量标识为一个线程独占的状态 + +unlock(解锁): 作用于主内存,把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定 + +read(读取): 主内存传输到线程的工作线程内存中,以便诉后的laod 动作使用 + +load(载入): 作用不工作内存,把read 操作从主内存中得到的变量值放入工作内存的变量的副本中 + +use(使用): 作用于工作内存,把工作内存中一个变量值传递给执行引擎 + +assign(赋值): 作用于工作内存,把一个从执行引擎接收到底额值赋值给工作内存的变量 + +store(存储): 作用于工作内存,把工作内存中的一个变量的值传送到主内存,以便随后的write 使用 + +write(写入):作用于主内存,他把store操作从工作内存中得到的变量的值放入主内存中 + +3, JAVA 内存模型规定的8 种操作 + +1)不允许read和load、store 和write 操作之一单独出现 + +2)不允许一个线程丢弃他的最近的assign 操作,即变量在工作内存中改变了之后必须把变化同步到主内存 + +3)不允许一个线程无原因(没有发生过任何assign)把数据从线程的工作内存同步到主内存中 + +4)一个变量只能在主内存中”诞生“ 不允许在工作内存中直接使用一个未被初始化(load、assign)的变量 + +5)一个变零在同一时刻只允许一条线程对其进行lock 操作,但lock 操作可以被同一个线程重复执行多次,多次执行lock 后,必须执行相同次数的unlock 操作变量才能解锁 + +6)如果对一个变量执行lock 操作,那将会清空工作内存中此变量的值,在执行引擎使用这个变量钱,需要从新执行laod 和assign 操作初始化变量的值 + +7)如果一个变量实现没有被lock 操作锁定,那就不允许对他执行unlock 操作,也不允许去unlock 一个被其他线程锁定住的变量 + +8)对一个变量执行unlock 操作之前,必须先把此变量同步回主内存中。 + +volatile + +错误观点:volatile 变量是多所有线程立即可见的,对valitile 变量所有的写操作都能立刻反应到其他线程之中,换句话说volatile 在各个线程中是一致的所以基于volatile 变量的运算在并发下是安全的。? + +正确观点(语义): + +1).只能保证可见性,并不能保证原子性 + +2).禁止指令重排序 + +在不符合以下两条规则的运算场景中,仍然需要通过加锁来保证原子性 + +1)运算结果并不依赖变量的当前值,或者能够确保只有单一的线程你玩过修改变量的值 + +2)变量不需要与其他的状态变量共同参与不变约束 + +原子性:由java 内存模型来直接保证原子性变量操作包括read, load,assign,use,store,write + +可见性:一个线程修改了共享变量的值,其他线程能够立即得知这个修改。(除了volatile, synchronized 和final 也能保证可见性) + +有序性:总结,如果在本线程内观察,所有的操作都是有序的,如果在一个线程中观察另一个线程,所有的操作都是无序的;前半句是指”线程内表现为串行语义“, 后半句是指”指令重排“ 和”工作内幕才能与主内存的延迟现象“ + +先行发生原则(happens-befor) + +功能:判断数据是否存在竞争,线程是否安全 + +先行发生是java 内存模型中定义的两项操作之间的额偏序关系,如果说操作A先行发生于操作B,其实就是说发生操作B之前,操作A产生的一个像能被操作B 观察到,”影响“包括修改了内存中共享变量的值、发送消息、调用了方法等。 + +java 中一些”天然的“ 发生关系,这些先行发生关系无需任何同步器协助就已经存在,在编码中直接使用 + +程序次序规则:在一个线程内 按照程序代码顺序,书写在前面的操作行为发生于书写在后面的操作(更准确点应为控制流顺序) + +管程锁定规则:一个unlock 操作先行发生于后面对同一个锁lock 操作 + +volatile变量规则:对一个volatile 变量的写操作先行发生于后边对这个变量的读操作 + +线程启动规则:Thread 的对象start() 方法先行发生于此线程的每一个动作 + +线程终止规则:线程中的所有操作都先行发生于对此线程的终止检测,我们可以通过Thread.join() 方法结束、Tread.isAlive)()的返回值等手段检测到线程已经终止 + +线程中断规则:对线程interrupt() 方法的调用先行发生于被中断线程的代码检测到中断事件的发生,可以通过Thread.interrupted()方法检测到是否有中断 + +对象终结规则:一个对象的初始化完成(构造函数执行结束)先行发生于他的finalize() 方法的开始 + +传递性:如果操作A 先行发生于操作B ,操作B 先行发生于操作C ,那就可以得出操作A先行发生于操作C的结论 \ No newline at end of file diff --git a/week_03/11/ReentrantLock.md b/week_03/11/ReentrantLock.md new file mode 100644 index 0000000000000000000000000000000000000000..fa5507846b2988800b502e86f2845ee054723db0 --- /dev/null +++ b/week_03/11/ReentrantLock.md @@ -0,0 +1,125 @@ +ReentrantLock是一个互斥锁,也是一个可重入锁(Reentrant就是再次进入的意思)。ReentrantLock锁在同一个时间点只能被一个线程锁持有,但是它可以被单个线程多次获取,每获取一次AQS的state就加1,每释放一次state就减1。还记得synchronized嘛,它也是可重入的,一个同步方法调用另外一个同步方法是没有问题的。 + +在使用上无非就是获取锁和释放锁,我们完全可以用它来实现synchronized的功能 + +我要实现一个程序,由两条线程去输出100到0,下面是有问题的程序代码 + +public class Main { public static void main(String[] args) { Counter counter = new Counter(); Runnable runnable = new Runnable() { @Override public void run() { while(counter.getCount()>=0) counter.desc(); } }; + + new Thread(runnable).start(); + new Thread(runnable).start(); +} +} + +class Counter{ private int count = 100; +public void desc(){ System.out.println(Thread.currentThread().getName() +"--->"+count); count--; } + +public int getCount() { + return count; +} +} + +1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 某次执行输出结果,很明显并没有达到我的要求。 + +..... Thread-1--->6 Thread-1--->5 Thread-1--->4 Thread-1--->3 Thread-1--->2 Thread-1--->1 Thread-1--->0 Thread-0--->15 1 2 3 4 5 6 7 8 9 10 1 2 3 4 5 6 7 8 9 10 于是我用ReentrantLock改写一下Counter类的desc方法,你要注意了,千万不要傻傻地在desc方法内部创建一个ReentrantLock对象,这样每次线程调用的时候用的都是一个新锁,还谈什么互斥呀,就像同步方法和静态同步方法,它们的锁都不是同一个,是互斥不了的。现在运行代码是没错的了 + +class Counter { private int count = 100; private Lock lock = new ReentrantLock(); + +public void desc() { + lock.lock();//上锁 + + if (count >= 0){ + System.out.println(Thread.currentThread().getName() + "--->" + count); + count--; + } + + lock.unlock();//释放锁 +} + +public int getCount() { + return count; +} +} 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 上面代码还是有问题的,那就是锁的释放,如果在上锁了,后面的代码抛出异常没能释放锁,你说完不完蛋!!?所以锁的释放一定要在try-finally块的finally中,就像是JDBC中释放数据库连接那样。这一点还是synchronized比较方便,不用我们自己释放锁。 + +Condition 到了这里就要谈到Condition了,它需要与 Lock 联合使用,它的作用就是代替Object的那些监视器方法,Condition 中的await()、signal()和signalAll()方法分别对应着Object的wait()、notify()和notifyAll()方法。 + +不过一个它比较牛逼的一点是,一个Lock可以关联多个Condition,这样子玩起来就很灵活了,想要各个方法按什么顺序执行都行。还是上面那个例子,我想让两个线程和谐点,你输出一个数,然后我又输出下一个数,这样子交替执行,实现代码如下 + +import java.util.concurrent.locks.Condition; import java.util.concurrent.locks.Lock; import java.util.concurrent.locks.ReentrantLock; + +public class Main { public static void main(String[] args) { Counter counter = new Counter(); + + new Thread(new Runnable() { + + @Override + public void run() { + while (counter.getCount() >= 0) { + counter.desc1(); + } + } + }).start(); + + new Thread(new Runnable() { + + @Override + public void run() { + while (counter.getCount() >= 0) { + counter.desc2(); + } + } + }).start(); +} +} + +class Counter { private int count = 100; private Lock lock = new ReentrantLock(); Condition condition1 = lock.newCondition(); Condition condition2 = lock.newCondition(); boolean state = true; + +public void desc1() { + lock.lock();// 上锁 + + try { + while (state) + condition1.await();// 线程等待 + + if (count >= 0) { + System.out.println(Thread.currentThread().getName() + "--->" + count); + count--; + } + state = true;// 改变状态 + condition2.signal();// 唤醒调用了condition2.await()线程 + + } catch (Exception e) { + e.printStackTrace(); + } finally { + lock.unlock();// 释放锁 + } +} + +public void desc2() { + lock.lock();// 上锁 + + try { + while (!state) + condition2.await();// 线程等待 + + if (count >= 0) { + System.out.println(Thread.currentThread().getName() + "--->" + count); + count--; + } + state = false;// 改变状态 + condition1.signal();// 唤醒调用了condition1.await()线程 + + } catch (Exception e) { + e.printStackTrace(); + } finally { + lock.unlock();// 释放锁 + } +} + +public int getCount() { + return count; +} +} 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 输出结果 + +..... Thread-1--->10 Thread-0--->9 Thread-1--->8 Thread-0--->7 Thread-1--->6 Thread-0--->5 Thread-1--->4 Thread-0--->3 Thread-1--->2 Thread-0--->1 Thread-1--->0 1 2 3 4 5 6 7 8 9 10 11 12 13 1 2 3 4 5 6 7 8 9 10 11 12 13 上面代码不难,所以不再这多解释了,不过按照上面的例子你就可以举一反三了,我记得有道线程的题目是让三个线程不断交替输出什么鬼的,t1->t2->t3->t1->t2->t3….,根据上面的例子我相信你能解决这个问题的,可以拿这个题目练一下手,熟悉一下。 + +最后要提一下的是ReentrantLock有两个构造方法,默认的构造方法会让它成为一个非公平锁,而如果你想创建一个公平锁则用ReentrantLock(boolean fair)传入一个true创建ReentrantLock实例 \ No newline at end of file diff --git a/week_03/11/Semaphore.md b/week_03/11/Semaphore.md new file mode 100644 index 0000000000000000000000000000000000000000..7d34356249fa98dbd99fb285e2e80fb31df39181 --- /dev/null +++ b/week_03/11/Semaphore.md @@ -0,0 +1,3 @@ +Semaphore用于对多个线程进行限流。synchronized关键字每次只能允许一个线程访问锁对象,而Semaphore可以设置允许一个或多个线程访问锁定内容。 (1)构造方法 + +Semaphore(int permits) 创建具有给定的许可数和非公平的公平设置的Semaphore。 Semaphore(int permits, boolean fair) 创建具有给定的许可数和给定的公平设置的 Semaphore。 (2)成员方法 void acquire() 从此信号量获取一个许可,如果没有得到许可线程阻塞,或者线程已被中断。 void acquire(int permits) 从此信号量获取给定数目的许可,如果没有得到许可线程阻塞,或者线程已被中断。 void acquireUninterruptibly() 从此信号量中获取许可,在有可用的许可前将其阻塞,线程不会被中断。 void acquireUninterruptibly(int permits) 从此信号量获取给定数目的许可,在提供这些许可前一直将线程阻塞,线程不会被中断。 boolean tryAcquire() 仅在调用时此信号量存在一个可用许可,才从信号量获取许可。 boolean tryAcquire(int permits) 仅在调用时此信号量中有给定数目的许可时,才从此信号量中获取这些许可。 boolean tryAcquire(int permits, long timeout, TimeUnit unit) 如果在给定的等待时间内此信号量有可用的所有许可,并且当前线程未被中断,则从此信号量获取给定数目的许可 public class ContainerTest3 { public static void main(String[] args) { final Semaphore sph=new Semaphore(6); for(int i=0;i<8;i++){ new Thread(new Runnable(){ public void run(){ try { sph.acquire(2); System.out.println(Thread.currentThread().getName()+"获得权限了"); Thread.sleep(2000); } catch (InterruptedException e) { e.printStackTrace(); }finally{ System.out.println(Thread.currentThread().getName()+"释放权限了"); sph.release(2); } } }).start(); } } } \ No newline at end of file diff --git a/week_03/11/synchronized.md b/week_03/11/synchronized.md new file mode 100644 index 0000000000000000000000000000000000000000..8b249fc942795c3d6397ed088f956acd00c607b3 --- /dev/null +++ b/week_03/11/synchronized.md @@ -0,0 +1,137 @@ +synchronized原理   在多线程并发编程中synchronized一直是元老级角色,我们在开发过程中可以使用它来解决线程安全问题中提到的原子性,可见性,以及顺序性。很多人都会称呼它为重量级锁。但是,随着Java SE 1.6对synchronized进行了各种优化之后,有些情况下它就并不那么重了,Java SE 1.6中为了减少获得锁和释放锁带来的性能消耗而引入的偏向锁和轻量级锁,以及锁的存储结构和升级过程。 + +synchronized的三种应用方式:   synchronized有三种方式来加锁,分别是:方法锁,对象锁synchronized(this),类锁synchronized(Demo.Class)。其中在方法锁层面可以有如下3种方式: + +修饰实例方法,作用于当前实例加锁,进入同步代码前要获得当前实例的锁 + +静态方法,作用于当前类对象加锁,进入同步代码前要获得当前类对象的锁 + +修饰代码块,指定加锁对象,对给定对象加锁,进入同步代码库前要获得给定对象的锁。 + +synchronized括号后面的对象: + +  synchronized扩号后面的对象是一把锁,在java中任意一个对象都可以成为锁,简单来说,我们把object比喻是一个key,拥有这个key的线程才能执行这个方法,拿到这个key以后在执行方法过程中,这个key是随身携带的,并且只有一把。如果后续的线程想访问当前方法,因为没有key所以不能访问只能在门口等着,等之前的线程把key放回去。所以,synchronized锁定的对象必须是同一个,如果是不同对象,就意味着是不同的房间的钥匙,对于访问者来说是没有任何影响的。 + +synchronized的字节码指令: + +  先看 demo 程序: + +1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 public class Demo { private static int count = 0; + +public static synchronized void inc() { + try { + Thread.sleep(1); + } catch (InterruptedException e) { + e.printStackTrace(); + } + count++; +} + +public static void main(String[] args) throws InterruptedException { + for (int i = 0; i < 1000; i++) { + new Thread(() -> Demo.inc()).start(); + } + Thread.sleep(3000); + System.out.println("运行结果" + count); +} +}   通过javap -v 来查看对应代码的字节码指令: + +  又看到了熟悉的东西:ACC_SYNCHRONIZED。对于同步块的实现使用了monitorenter和monitorexit指令:他们隐式的执行了Lock和UnLock操作,用于提供原子性保证。monitorenter指令插入到同步代码块开始的位置、monitorexit指令插入到同步代码块结束位置,jvm需要保证每个monitorenter都有一个monitorexit对应。这两个指令,本质上都是对一个对象的监视器(monitor)进行获取,这个过程是排他的,也就是说同一时刻只能有一个线程获取到由synchronized所保护对象的监视器线程执行到monitorenter指令时,会尝试获取对象所对应的monitor所有权,也就是尝试获取对象的锁;而执行monitorexit,就是释放monitor的所有权。 + +synchronized的锁的原理:   jdk1.6以后对synchronized锁进行了优化,包含偏向锁、轻量级锁、重量级锁;了解synchronized的原理我们需要明白3个问题: + +1.synchronized是如何实现锁 + +2.为什么任何一个对象都可以成为锁 + +3.锁存在哪个地方? + +  在了解synchronized锁之前,我们需要了解两个重要的概念,一个是对象头、另一个是monitor。 + +Java对象头:   在Hotspot虚拟机中,对象在内存中的布局分为三块区域:对象头、实例数据和对齐填充;Java对象头是实现synchronized的锁对象的基础,一般而言,synchronized使用的锁对象是存储在Java对象头里。它是轻量级锁和偏向锁的关键 + +Mawrk Word: + +  Mark Word用于存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程 ID、偏向时间戳等等。Java对象头一般占有两个机器码(在32位虚拟机中,1个机器码等于4字节,也就是32bit),下面就是对象头的一些信息: + +在源码中的体现: + +  如果想更深入了解对象头在JVM源码中的定义,需要关心几个文件,oop.hpp/markOop.hpp 。 + +  oop.hpp,每个 Java Object 在 JVM 内部都有一个 native 的 C++ 对象 oop/oopDesc 与之对应。先在oop.hpp中看oopDesc的定义: + +复制代码 class oopDesc { friend class VMStructs; private: volatile markOop _mark;//理解为对象头 union _metadata { Klass* _klass; narrowKlass _compressed_klass; } _metadata; ...... 复制代码   _mark 被声明在 oopDesc 类的顶部,所以这个 _mark 可以认为是一个 头部, 也就是上面那个图种提到的头部保存了一些重要的状态和标识信息,在markOop.hpp文件中有一些注释说明markOop的内存布局: + +1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 // Bit-format of an object header (most significant first, big endian layout below): // // 32 bits://对应的上图的头部信息的分布 // -------- // hash:25 ------------>| age:4 biased_lock:1 lock:2 (normal object) // JavaThread:23 epoch:2 age:4 biased_lock:1 lock:2 (biased object) // size:32 ------------------------------------------>| (CMS free block) // PromotedObject:29 ---------->| promo_bits:3 ----->| (CMS promoted object) // // 64 bits: // 64为虚拟机中的分布 // -------- // unused:25 hash:31 -->| unused:1 age:4 biased_lock:1 lock:2 (normal object) // JavaThread:54 epoch:2 unused:1 age:4 biased_lock:1 lock:2 (biased object) // PromotedObject:61 --------------------->| promo_bits:3 ----->| (CMS promoted object) // size:64 ----------------------------------------------------->| (CMS free block) // // unused:25 hash:31 -->| cms_free:1 age:4 biased_lock:1 lock:2 (COOPs && normal object) // JavaThread*:54 epoch:2 cms_free:1 age:4 biased_lock:1 lock:2 (COOPs && biased object) // narrowOop:32 unused:24 cms_free:1 unused:4 promo_bits:3 ----->| (COOPs && CMS promoted object) // unused:21 size:35 -->| cms_free:1 unused:7 ------------------>| (COOPs && CMS free block) // Monitor:   什么是Monitor?我们可以把它理解为一个同步工具,也可以描述为一种同步机制。所有的Java对象是天生的Monitor,每个object的对象里 markOop->monitor() 里可以保存ObjectMonitor的对象。从源码层面看一下monitor对象 + +  ? oop.hpp下的oopDesc类是JVM对象的顶级基类,所以每个object对象都包含markOop + +复制代码 class oopDesc {//顶层基类 friend class VMStructs; private: volatile markOop _mark;//这也就是每个对象的mark头 union _metadata { Klass* _klass; narrowKlass _compressed_klass; } _metadata; 复制代码   ? markOop.hpp 中 markOopDesc继承自oopDesc, + +  并扩展了自己的monitor方法,这个方法返回一个ObjectMonitor指针对象:这个ObjectMonitor 其实就是对象监视器 + +  ? objectMonitor.hpp,在hotspot虚拟机中,采用ObjectMonitor类来实现monitor: + +  到目前位置,对于锁存在哪个位置,我们已经清楚了,锁存在于每个对象的 markOop 对象头中.对于为什么每个对象都可以成为锁呢? 因为每个 Java Object 在 JVM 内部都有一个 native 的 C++ 对象 oop/oopDesc 与之对应,而对应的 oop/oopDesc 都会存在一个markOop 对象头,而这个对象头是存储锁的位置,里面还有对象监视器,即ObjectMonitor,所以这也是为什么每个对象都能成为锁的原因之一。那么 synchronized是如何实现锁的呢? + +synchronized是如何实现锁:   了解了对象头以及monitor以后,接下来去分析synchronized的锁的实现,就会相对简单了。前面讲过synchronized的锁是进行过优化的,引入了偏向锁、轻量级锁;锁的级别从低到高逐步升级, 无锁->偏向锁->轻量级锁->重量级锁.锁的类型:锁从宏观上分类,分为悲观锁与乐观锁。 + +乐观锁: + +  乐观锁是一种乐观思想,即认为读多写少,遇到并发写的可能性低,每次去拿数据的时候都认为别人不会修改,所以不会上锁,但是在更新的时候会判断一下在此期间别人有没有去更新这个数据,采取在写时先读出当前版本号,然后加锁操作(比较跟上一次的版本号,如果一样则更新),如果失败则要重复读-比较-写的操作。java中的乐观锁基本都是通过CAS操作实现的,CAS是一种更新的原子操作,比较当前值跟传入值是否一样,一样则更新,否则失败。 + +悲观锁: + +  悲观锁是就是悲观思想,即认为写多,遇到并发写的可能性高,每次去拿数据的时候都认为别人会修改,所以每次在读写数据的时候都会上锁,这样别人想读写这个数据就会block直到拿到锁。java中的悲观锁就是Synchronized,AQS框架下的锁则是先尝试cas乐观锁去获取锁,获取不到,才会转换为悲观锁,如RetreenLock。 + +自旋锁(CAS): + +  自旋锁就是让不满足条件的线程等待一段时间,而不是立即挂起。看持有锁的线程是否能够很快释放锁。怎么自旋呢?其实就是一段没有任何意义的循环。虽然它通过占用处理器的时间来避免线程切换带来的开销,但是如果持有锁的线程不能在很快释放锁,那么自旋的线程就会浪费处理器的资源,因为它不会做任何有意义的工作。所以,自旋等待的时间或者次数是有一个限度的,如果自旋超过了定义的时间仍然没有获取到锁,则该线程应该被挂起。JDK1.6中-XX:+UseSpinning开启; -XX:PreBlockSpin=10 为自旋次数; JDK1.7后,去掉此参数,由jvm控制; + +偏向锁: + +  大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,为了让线程获得锁的代价更低而引入了偏向锁。下图就是偏向锁的获得跟撤销流程图: + +  当一个线程访问同步块并获取锁时,会在对象头和栈帧中的锁记录里存储锁偏向的线程ID,以后该线程在进入和退出同步块时不需要进行CAS操作来加锁和解锁,只需简单地测试一下对象头的Mark Word里是否存储着指向当前线程的偏向锁。如果测试成功,表示线程已经获得了锁。如果测试失败,则需要再测试一下Mark Word中偏向锁的标识是否设置成01(表示当前是偏向锁):如果没有设置,则使用CAS竞争锁;如果设置了,则尝试使用CAS将对象头的偏向锁指向当前线程。执行同步块。这个时候线程2也来访问同步块,也是会检查对象头的Mark Word里是否存储着当前线程2的偏向锁,发现不是,那么他会进入 CAS 替换,但是此时会替换失败,因为此时线程1已经替换了。替换失败则会进入撤销偏向锁,首先会去暂停拥有了偏向锁的线程1,进入无锁状态(01).偏向锁存在竞争的情况下就回去升级成轻量级锁。 + +开启:-XX:+UseBiasedLocking -XX:BiasedLockingStartupDelay=0 -client -Xmx1024m -Xms1024m + +关闭:-XX:+UseBiasedLocking -client -Xmx512m -Xms512m + +轻量级锁: + +  引入轻量级锁的主要目的是在多没有多线程竞争的前提下,减少传统的重量级锁使用操作系统互斥量产生的性能消耗。当关闭偏向锁功能或者多个线程竞争偏向锁导致偏向锁升级为轻量级锁,则会尝试获取轻量级锁,下面是轻量级锁的流程图: + +  在代码进入同步块的时候,如果同步对象锁状态为无锁状态(锁标志位为“01”状态),虚拟机首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的Mark Word的拷贝,官方称之为 Displaced Mark Word。这个时候 JVM会尝试使用 CAS 将 mark Word 更新为指向栈帧中的锁记录(Lock Record)的空间指针。并且把锁标志位设置为 00(轻量级锁标志),与此同时如果有另外一个线程2也来进行 CAS 修改 Mark Word,那么将会失败,因为线程1已经获取到该锁,然后线程2将会进行 CAS操作不断的去尝试获取锁,这个时候将会引起锁膨胀,就会升级为重量级锁,设置标志位为 10. + +  由轻量锁切换到重量锁,是发生在轻量锁释放锁的期间,之前在获取锁的时候它拷贝了锁对象头的markword,在释放锁的时候如果它发现在它持有锁的期间有其他线程来尝试获取锁了,并且该线程对markword做了修改,两者比对发现不一致,则切换到重量锁。轻量级解锁时,会使用原子的CAS操作来将Displaced Mark Word替换回到对象头,如果成功,则表示同步过程已完成。如果失败,表示有其他线程尝试过获取该锁,则要在释放锁的同时唤醒被挂起的线程进入等待。 + +重量级锁: + +  重量级锁通过对象内部的监视器(monitor)实现,其中monitor的本质是依赖于底层操作系统的Mutex Lock实现,操作系统实现线程之间的切换需要从用户态到内核态的切换,切换成本非常高。主要是,当系统检查到锁是重量级锁之后,会把等待想要获得锁的线程进行阻塞,被阻塞的线程不会消耗cup。但是阻塞或者唤醒一个线程时,都需要操作系统来帮忙,这就需要从用户态转换到内核态,而转换状态是需要消耗很多时间的,有可能比用户执行代码的时间还要长。这就是说为什么重量级线程开销很大的。 + +  monitor这个对象,在hotspot虚拟机中,通过ObjectMonitor类来实现 monitor。他的锁的获取过程的体现会简单很多。每个object的对象里 markOop->monitor() 里可以保存ObjectMonitor的对象。 + +  这里提到的 CXQ跟 EnterList 是什么呢? 见下图: + +  这里我们重新回到 objectMonitor.cpp 这个源码中来看以下: + +复制代码 void ATTR ObjectMonitor::enter(TRAPS) {//获取重量级锁的过程 // The following code is ordered to check the most common cases first // and to reduce RTS->RTO cache line upgrades on SPARC and IA32 processors. Thread * const Self = THREAD ; void * cur ; + +cur = Atomic::cmpxchg_ptr (Self, &_owner, NULL) ;//进行CAS自旋操作 if (cur == NULL) { // Either ASSERT _recursions == 0 or explicitly set _recursions = 0. assert (_recursions == 0 , "invariant") ; assert (_owner == Self, "invariant") ; // CONSIDER: set or assert OwnerIsThread == 1 return ; } //自旋结果相等,则重入(重入的原理) if (cur == Self) { // TODO-FIXME: check for integer overflow! BUGID 6557169. _recursions ++ ; return ; } //接下去就是有并发的情况下竞争的过程了 .... 复制代码   所以这就是synchronized实现锁的一个过程。 + +wait和notify的原理:   调用wait方法,首先会获取监视器锁,获得成功以后,会让当前线程进入等待状态进入等待队列并且释放锁;然后当其他线程调用notify或者notifyall以后,会通知等待线程可以醒了,而执行完notify方法以后,并不会立马唤醒线程,原因是当前的线程仍然持有这把锁,处于等待状态的线程无法获得锁。必须要等到当前的线程执行完按monitorexit指令以后,也就是锁被释放以后,处于等待队列中的线程就可以开始竞争锁了。 + +  看一下 JVM 源码中的逻辑,在objectMonitor.cpp 中:在我们Java代码层面调用的 wait() 方法后,其实在 JVM 层面所作的是,封装 ObjectWaiter 对象并将其放入 _WaitSet 队列,并调用 park()将线程挂起。 + +复制代码 // Wait/Notify/NotifyAll // Note: a subset of changes to ObjectMonitor::wait() // will need to be replicated in complete_exit above void ObjectMonitor::wait(jlong millis, bool interruptible, TRAPS) { Thread * const Self = THREAD ; assert(Self->is_Java_thread(), "Must be Java thread!"); JavaThread *jt = (JavaThread *)THREAD; DeferredInitialize () ; // Throw IMSX or IEX. CHECK_OWNER();//检查objectMonitor对象是否指向本线程(即是否获得锁) // ... 省略中间的代码 + +// create a node to be put into the queue // Critically, after we reset() the event but prior to park(), we must check // for a pending interrupt. // 封装了一个ObjectWaiter对象 ObjectWaiter node(Self); node.TState = ObjectWaiter::TS_WAIT ; Self->_ParkEvent->reset() ; OrderAccess::fence();//内存屏障 // ST into Event; membar ; LD interrupted-flag // Enter the waiting queue, which is a circular doubly linked list in this case // but it could be a priority queue or any data structure. // _WaitSetLock protects the wait queue. Normally the wait queue is accessed only // by the the owner of the monitor except in the case where park() // returns because of a timeout of interrupt. Contention is exceptionally rare // so we use a simple spin-lock instead of a heavier-weight blocking lock. //将ObjectWaiter放入 _WaitSet中 Thread::SpinAcquire (&_WaitSetLock, "WaitSet - add") ; AddWaiter (&node) ; Thread::SpinRelease (&_WaitSetLock) ; if ((SyncFlags & 4) == 0) { _Responsible = NULL ; } intptr_t save = _recursions; // record the old recursion count _waiters++; // increment the number of waiters _recursions = 0; // set the recursion level to be 1 exit (true, Self) ; // exit the monitor guarantee (_owner != Self, "invariant") ; //.....省略中间代码 // The thread is on the WaitSet list - now park() it. // On MP systems it's conceivable that a brief spin before we park // could be profitable. // TODO-FIXME: change the following logic to a loop of the form // while (!timeout && !interrupted && _notified == 0) park() int ret = OS_OK ; int WasNotified = 0 ; { // State transition wrappers OSThread* osthread = Self->osthread(); OSThreadWaitState osts(osthread, true); { ThreadBlockInVM tbivm(jt); // Thread is in thread_blocked state and oop access is unsafe. jt->set_suspend_equivalent(); if (interruptible && (Thread::is_interrupted(THREAD, false) || HAS_PENDING_EXCEPTION)) { // Intentionally empty } else if (node._notified == 0) { if (millis <= 0) { // 调用park()将线程挂起 Self->_ParkEvent->park () ; } else { ret = Self->_ParkEvent->park (millis) ; } }     ....... } 复制代码   接下去看看 notify 的操作: + +复制代码 void ObjectMonitor::notify(TRAPS) { CHECK_OWNER();//同样先检查objectMonitor对象是否指向本线程 if (_WaitSet == NULL) {//判断wait队列是否为空 TEVENT (Empty-Notify) ; return ; } DTRACE_MONITOR_PROBE(notify, this, object(), THREAD); + +int Policy = Knob_MoveNotifyee ; // 这个 WaitSet - notify 很了然 Thread::SpinAcquire (&_WaitSetLock, "WaitSet - notify") ; ObjectWaiter * iterator = DequeueWaiter() ;//dequeue _WaitSet 队列 if (iterator != NULL) {//不为空,然后接下去就是一系列的判断,最后去唤醒 //....... } } 复制代码 wait和notify为什么需要在synchronized里面: + +  wait方法的语义有两个,一个是释放当前的对象锁、另一个是使得当前线程进入阻塞队列, 而这些操作都和监视器是相关的,所以wait必须要获得一个监视器锁。 + +  而对于notify来说也是一样,它是唤醒一个线程,既然要去唤醒,首先得知道它在哪里?所以就必须要找到这个对象获取到这个对象的锁,然后到这个对象的等待队列中去唤醒一个线程。 \ No newline at end of file diff --git a/week_03/11/volatile.md b/week_03/11/volatile.md new file mode 100644 index 0000000000000000000000000000000000000000..31045bf40e5c1ca796e5c46ce8658ccafeeac6e8 --- /dev/null +++ b/week_03/11/volatile.md @@ -0,0 +1,63 @@ +1、 volatile的作用 相比Sychronized(重量级锁,对系统性能影响较大),volatile提供了另一种解决 可见性和有序性 ???问题的方案。对于原子性,需要强调一点,也是大家容易误解的一点:对volatile变量的单次读/写操作可以保证原子性的,如long和double类型变量,但是并不能保证i++这种操作的原子性,因为本质上 i++ 是读、写两次操作。 + +2、volatile的使用 1、防重排序 我们从一个最经典的例子来分析 重排序问题???。大家应该都很熟悉 单例模式 的实现,而在并发环境下的单例实现方式,我们通常可以采用 双重检查加锁(DCL) ???的方式来实现。其源码如下: + +复制代码 1 public class Singleton { 2 public static volatile Singleton singleton; 3 /** 4 * 构造函数私有,禁止外部实例化 5 */ 6 private Singleton() {}; 7 public static Singleton getInstance() { 8 if (singleton == null) { 9 synchronized (singleton) { 10 if (singleton == null) { 11 singleton = new Singleton(); 12 } 13 } 14 } 15 return singleton; 16 } 17 } 复制代码 现在我们分析一下为什么要在变量singleton之间加上volatile关键字。要理解这个问题,先要了解对象的构造过程,实例化一个对象其实可以分为三个步骤:   (1)分配内存空间。   (2)初始化对象。   (3)将内存空间的地址赋值给对应的引用。 但是由于操作系统可以对指令进行重排序,所以上面的过程也可能会变成如下过程:   (1)分配内存空间。   (2)将内存空间的地址赋值给对应的引用。   (3)初始化对象   如果是这个流程,多线程环境下就可能将一个未初始化的对象引用暴露出来,从而导致不可预料的结果。因此,为了防止这个过程的重排序,我们需要将变量设置为volatile类型的变量。 + +2、实现可见性 可见性问题主要指一个线程修改了共享变量值,而另一个线程却看不到。引起可见性问题的主要原因是每个线程拥有自己的一个高速缓存区——线程工作内存。volatile关键字能有效的解决这个问题,我们看下下面的例子,就可以知道其作用: + +复制代码 1 public class VolatileTest { 2 int a = 1; 3 int b = 2; 4 5 public void change(){ 6 a = 3; 7 b = a; 8 } 9 10 public void print(){ 11 System.out.println("b="+b+";a="+a); 12 } 13 14 public static void main(String[] args) { 15 while (true){ 16 final VolatileTest test = new VolatileTest(); 17 new Thread(new Runnable() { 18 @Override 19 public void run() { 20 try { 21 Thread.sleep(10); 22 } catch (InterruptedException e) { 23 e.printStackTrace(); 24 } 25 test.change(); 26 } 27 }).start(); 28 new Thread(new Runnable() { 29 @Override 30 public void run() { 31 try { 32 Thread.sleep(10); 33 } catch (InterruptedException e) { 34 e.printStackTrace(); 35 } 36 test.print(); 37 } 38 }).start(); 39 } 40 } 41 } 复制代码 直观上说,这段代码的结果只可能有两种:b=3;a=3 或 b=2;a=1。不过运行上面的代码(可能时间上要长一点),你会发现除了上两种结果之外,还出现了第三种结果: + +复制代码 ...... b=2;a=1 b=2;a=1 b=3;a=3 b=3;a=3 b=3;a=1 b=3;a=3 b=2;a=1 b=3;a=3 b=3;a=3 ...... 复制代码 为什么会出现b=3;a=1这种结果呢?正常情况下,如果先执行change方法,再执行print方法,输出结果应该为b=3;a=3。相反,如果先执行的print方法,再执行change方法,结果应该是 b=2;a=1。那b=3;a=1的结果是怎么出来的?原因就是第一个线程将值a=3修改后,但是对第二个线程是不可见的,所以才出现这一结果。如果将a和b都改成volatile类型的变量再执行,则再也不会出现b=3;a=1的结果了。 + +3、保证原子性 关于原子性的问题,上面已经解释过。volatile只能保证对单次读/写的原子性。这个问题可以看下JLS中的描述: + +17.7 Non-Atomic Treatment of double and long For the purposes of the Java programming language memory model, a single write to a non-volatile long or double value is treated as two separate writes: one to each 32-bit half. This can result in a situation where a thread sees the first 32 bits of a 64-bit value from one write, and the second 32 bits from another write. Writes and reads of volatile long and double values are always atomic. Writes to and reads of references are always atomic, regardless of whether they are implemented as 32-bit or 64-bit values. Some implementations may find it convenient to divide a single write action on a 64-bit long or double value into two write actions on adjacent 32-bit values. For efficiency’s sake, this behavior is implementation-specific; an implementation of the Java Virtual Machine is free to perform writes to long and double values atomically or in two parts. Implementations of the Java Virtual Machine are encouraged to avoid splitting 64-bit values where possible. Programmers are encouraged to declare shared 64-bit values as volatile or synchronize their programs correctly to avoid possible complications. + +这段话的内容跟我前面的描述内容大致类似。因为long和double两种数据类型的操作可分为高32位和低32位两部分,因此普通的long或double类型读/写可能不是原子的。因此,鼓励大家将共享的long和double变量设置为volatile类型,这样能保证任何情况下对long和double的单次读/写操作都具有原子性。   关于volatile变量对原子性保证,有一个问题容易被误解。现在我们就通过下列程序来演示一下这个问题: + +复制代码 public class VolatileTest01 { volatile int i; + +public void addI(){ + i++; +} + +public static void main(String[] args) throws InterruptedException { + final VolatileTest01 test01 = new VolatileTest01(); + for (int n = 0; n < 1000; n++) { + new Thread(new Runnable() { + @Override + public void run() { + try { + Thread.sleep(10); + } catch (InterruptedException e) { + e.printStackTrace(); + } + test01.addI(); + } + }).start(); + } + Thread.sleep(10000);//等待10秒,保证上面程序执行完成 + System.out.println(test01.i); +} +} 复制代码 大家可能会误认为对变量i加上关键字volatile后,这段程序就是线程安全的。大家可以尝试运行上面的程序。下面是我本地运行的结果:981 可能每个人运行的结果不相同。不过应该能看出,volatile是无法保证原子性的(否则结果应该是1000)。原因也很简单,i++其实是一个复合操作,包括三步骤:   (1)读取i的值。   (2)对i加1。   (3)将i的值写回内存。 volatile是无法保证这三个操作是具有原子性的,我们可以通过 AtomicInteger 或者 Synchronized 来保证+1操作的原子性。 注:上面几段代码中多处执行了Thread.sleep()方法,目的是为了增加并发问题的产生几率,无其他作用。 + +3、volatile的原理 通过上面的例子,我们基本应该知道了volatile是什么以及怎么使用。现在我们再来看看volatile的底层是怎么实现的。 + +1、可见性实现:   在前文中已经提及过,线程本身并不直接与主内存进行数据的交互,而是通过线程的工作内存来完成相应的操作。这也是导致线程间数据不可见的本质原因。因此要实现volatile变量的可见性,直接从这方面入手即可。对volatile变量的写操作与普通变量的主要区别有两点:   (1)修改volatile变量时会强制将修改后的值刷新的主内存中。   (2)修改volatile变量后会导致其他线程工作内存中对应的变量值失效。因此,再读取该变量值的时候就需要重新从读取主内存中的值。   通过这两个操作,就可以解决volatile变量的可见性问题。 + +2、有序性实现:   在解释这个问题前,我们先来了解一下Java中的happen-before规则,JSR 133中对Happen-before的定义如下: + +Two actions can be ordered by a happens-before relationship.If one action happens before another, then the first is visible to and ordered before the second. + +通俗一点说就是如果a happen-before b,则a所做的任何操作对b是可见的。(这一点大家务必记住,因为happen-before这个词容易被误解为是时间的前后)。我们再来看看JSR 133中定义了哪些happen-before规则: + +? Each action in a thread happens before every subsequent action in that thread. ? An unlock on a monitor happens before every subsequent lock on that monitor. ? A write to a volatile field happens before every subsequent read of that volatile. ? A call to start() on a thread happens before any actions in the started thread. ? All actions in a thread happen before any other thread successfully returns from a join() on that thread. ? If an action a happens before an action b, and b happens before an action c, then a happens before c. + +翻译过来为: + +同一个线程中的,前面的操作 happen-before 后续的操作。(即单线程内按代码顺序执行。但是,在不影响在单线程环境执行结果的前提下,编译器和处理器可以进行重排序,这是合法的。换句话说,这一是规则无法保证编译重排和指令重排)。 监视器上的解锁操作 happen-before 其后续的加锁操作。(Synchronized 规则) 对volatile变量的写操作 happen-before 后续的读操作。(volatile 规则) 线程的start() 方法 happen-before 该线程所有的后续操作。(线程启动规则) 线程所有的操作 happen-before 其他线程在该线程上调用 join 返回成功后的操作。 如果 a happen-before b,b happen-before c,则a happen-before c(传递性)。 4、volatile的使用优化 著名的Java并发编程大师Doug lea在JDK 7的并发包里新增一个队列集合类LinkedTransferQueue,它在使用volatile变量时,用一种追加字节的方式来优化队列出队和入队的性能。LinkedTransferQueue的代码如下: + +复制代码 /** 队列中的头部节点 / private transient final PaddedAtomicReference head; /* 队列中的尾部节点 */ private transient final PaddedAtomicReference tail; static final class PaddedAtomicReference extends AtomicReference T> { // 使用很多4个字节的引用追加到64个字节 Object p0, p1, p2, p3, p4, p5, p6, p7, p8, p9, pa, pb, pc, pd, pe; PaddedAtomicReference(T r) { super(r); } } public class AtomicReference implements java.io.Serializable { private volatile V value; // 省略其他代码 } 复制代码 追加字节能优化性能?这种方式看起来很神奇,但如果深入理解处理器架构就能理解其中的奥秘。让我们先来看看LinkedTransferQueue这个类,它使用一个内部类类型来定义队列的头节点(head)和尾节点(tail),而这个内部类PaddedAtomicReference相对于父类AtomicReference只做了一件事情,就是将共享变量追加到64字节。我们可以来计算下,一个对象的引用占4个字节,它追加了15个变量(共占60个字节),再加上父类的value变量,一共64个字节。 为什么追加64字节能够提高并发编程的效率呢?因为对于英特尔酷睿i7、酷睿、Atom和NetBurst,以及Core Solo和Pentium M处理器的L1、L2或L3缓存的高速缓存行是64个字节宽,不支持部分填充缓存行,这意味着,如果队列的头节点和尾节点都不足64字节的话,处理器会将它们都读到同一个高速缓存行中,在多处理器下每个处理器都会缓存同样的头、尾节点,当一个处理器试图修改头节点时,会将整个缓存行锁定,那么在缓存一致性机制的作用下,会导致其他处理器不能访问自己高速缓存中的尾节点,而队列的入队和出队操作则需要不停修改头节点和尾节点,所以在多处理器的情况下将会严重影响到队列的入队和出队效率。Doug lea使用追加到64字节的方式来填满高速缓冲区的缓存行,避免头节点和尾节点加载到同一个缓存行,使头、尾节点在修改时不会互相锁定。 那么是不是在使用volatile变量时都应该追加到64字节呢?不是的。在两种场景下不应该使用这种方式。 + +缓存行非64字节宽的处理器。如P6系列和奔腾处理器,它们的L1和L2高速缓存行是32个字节宽。 共享变量不会被频繁地写。因为使用追加字节的方式需要处理器读取更多的字节到高速缓冲区,这本身就会带来一定的性能消耗,如果共享变量不被频繁写的话,锁的几率也非常小,就没必要通过追加字节的方式来避免相互锁定。 \ No newline at end of file diff --git "a/week_03/11/\345\210\206\345\270\203\345\274\217\351\224\201.md" "b/week_03/11/\345\210\206\345\270\203\345\274\217\351\224\201.md" new file mode 100644 index 0000000000000000000000000000000000000000..c342085973e452a896a68fad211ab1ca1be8f909 --- /dev/null +++ "b/week_03/11/\345\210\206\345\270\203\345\274\217\351\224\201.md" @@ -0,0 +1,236 @@ +分布式锁原理及实现方式(转载) +  目前几乎很多大型网站及应用都是分布式部署的,分布式场景中的数据一致性问题一直是一个比较重要的话题。分布式的CAP理论告诉我们“任何一个分布式系统都无法同时满足一致性(Consistency)、可用性(Availability)和分区容错性(Partition tolerance),最多只能同时满足两项。”所以,很多系统在设计之初就要对这三者做出取舍。在互联网领域的绝大多数的场景中,都需要牺牲强一致性来换取系统的高可用性,系统往往只需要保证“最终一致性”,只要这个最终时间是在用户可以接受的范围内即可。 + +  在很多场景中,我们为了保证数据的最终一致性,需要很多的技术方案来支持,比如分布式事务、分布式锁等。有的时候,我们需要保证一个方法在同一时间内只能被同一个线程执行。在单机环境中,Java中其实提供了很多并发处理相关的API,但是这些API在分布式场景中就无能为力了。也就是说单纯的Java Api并不能提供分布式锁的能力。所以针对分布式锁的实现目前有多种方案。 + +  针对分布式锁的实现,目前比较常用的有以下几种方案: + +基于数据库实现分布式锁 + +基于缓存(redis,memcached,tair)实现分布式锁 + + 基于Zookeeper实现分布式锁 + +  在分析这几种实现方案之前我们先来想一下,我们需要的分布式锁应该是怎么样的?(这里以方法锁为例,资源锁同理) + +可以保证在分布式部署的应用集群中,同一个方法在同一时间只能被一台机器上的一个线程执行。 + +这把锁要是一把可重入锁(避免死锁) + +这把锁最好是一把阻塞锁(根据业务需求考虑要不要这条) + +有高可用的获取锁和释放锁功能 + +获取锁和释放锁的性能要好 + + 基于数据库实现分布式锁 +  基于数据库表 +  要实现分布式锁,最简单的方式可能就是直接创建一张锁表,然后通过操作该表中的数据来实现了。 + +  当我们要锁住某个方法或资源时,我们就在该表中增加一条记录,想要释放锁的时候就删除这条记录。 + +  创建这样一张数据库表: + +复制代码 +CREATE TABLE `methodLock` ( + `id` int(11) NOT NULL AUTO_INCREMENT COMMENT '主键', + `method_name` varchar(64) NOT NULL DEFAULT '' COMMENT '锁定的方法名', + `desc` varchar(1024) NOT NULL DEFAULT '备注信息', + `update_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '保存数据时间,自动生成', + PRIMARY KEY (`id`), + UNIQUE KEY `uidx_method_name` (`method_name `) USING BTREE +) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='锁定中的方法'; +复制代码 +  当我们想要锁住某个方法时,执行以下SQL: + +insert into methodLock(method_name,desc) values (‘method_name’,‘desc’) +  因为我们对method_name做了唯一性约束,这里如果有多个请求同时提交到数据库的话,数据库会保证只有一个操作可以成功,那么我们就可以认为操作成功的那个线程获得了该方法的锁,可以执行方法体内容。 + +  当方法执行完毕之后,想要释放锁的话,需要执行以下Sql: + +delete from methodLock where method_name ='method_name' +  上面这种简单的实现有以下几个问题: + +1、这把锁强依赖数据库的可用性,数据库是一个单点,一旦数据库挂掉,会导致业务系统不可用。 + +2、这把锁没有失效时间,一旦解锁操作失败,就会导致锁记录一直在数据库中,其他线程无法再获得到锁。 + +3、这把锁只能是非阻塞的,因为数据的insert操作,一旦插入失败就会直接报错。没有获得锁的线程并不会进入排队队列,要想再次获得锁就要再次触发获得锁操作。 + +4、这把锁是非重入的,同一个线程在没有释放锁之前无法再次获得该锁。因为数据中数据已经存在了。 + +  当然,我们也可以有其他方式解决上面的问题。 + +数据库是单点?搞两个数据库,数据之前双向同步。一旦挂掉快速切换到备库上。 +没有失效时间?只要做一个定时任务,每隔一定时间把数据库中的超时数据清理一遍。 +非阻塞的?搞一个while循环,直到insert成功再返回成功。 +非重入的?在数据库表中加个字段,记录当前获得锁的机器的主机信息和线程信息,那么下次再获取锁的时候先查询数据库,如果当前机器的主机信息和线程信息在数据库可以查到的话,直接把锁分配给他就可以了。 +  基于数据库排他锁 +  除了可以通过增删操作数据表中的记录以外,其实还可以借助数据中自带的锁来实现分布式的锁。 + +  我们还用刚刚创建的那张数据库表。可以通过数据库的排他锁来实现分布式锁。 基于MySql的InnoDB引擎,可以使用以下方法来实现加锁操作: + +复制代码 +public boolean lock(){ + connection.setAutoCommit(false) + while(true){ + try{ + result = select * from methodLock where method_name=xxx for update; + if(result==null){ + return true; + } + }catch(Exception e){ + + } + sleep(1000); + } + return false; +} +复制代码 +  在查询语句后面增加for update,数据库会在查询过程中给数据库表增加排他锁(这里再多提一句,InnoDB引擎在加锁的时候,只有通过索引进行检索的时候才会使用行级锁,否则会使用表级锁。这里我们希望使用行级锁,就要给method_name添加索引,值得注意的是,这个索引一定要创建成唯一索引,否则会出现多个重载方法之间无法同时被访问的问题。重载方法的话建议把参数类型也加上。)。当某条记录被加上排他锁之后,其他线程无法再在该行记录上增加排他锁。 + +  我们可以认为获得排它锁的线程即可获得分布式锁,当获取到锁之后,可以执行方法的业务逻辑,执行完方法之后,再通过以下方法解锁: + +public void unlock(){ + connection.commit(); +} +  通过connection.commit()操作来释放锁。 + +  这种方法可以有效的解决上面提到的无法释放锁和阻塞锁的问题。 + +阻塞锁? for update语句会在执行成功后立即返回,在执行失败时一直处于阻塞状态,直到成功。 +锁定之后服务宕机,无法释放?使用这种方式,服务宕机之后数据库会自己把锁释放掉。 +  但是还是无法直接解决数据库单点和可重入问题。 + +  这里还可能存在另外一个问题,虽然我们对method_name 使用了唯一索引,并且显示使用for update来使用行级锁。但是,MySql会对查询进行优化,即便在条件中使用了索引字段,但是否使用索引来检索数据是由 MySQL 通过判断不同执行计划的代价来决定的,如果 MySQL 认为全表扫效率更高,比如对一些很小的表,它就不会使用索引,这种情况下 InnoDB 将使用表锁,而不是行锁。如果发生这种情况就悲剧了。。。 + +  还有一个问题,就是我们要使用排他锁来进行分布式锁的lock,那么一个排他锁长时间不提交,就会占用数据库连接。一旦类似的连接变得多了,就可能把数据库连接池撑爆 + +  总结 +  总结一下使用数据库来实现分布式锁的方式,这两种方式都是依赖数据库的一张表,一种是通过表中的记录的存在情况确定当前是否有锁存在,另外一种是通过数据库的排他锁来实现分布式锁。 + +  数据库实现分布式锁的优点 + +  直接借助数据库,容易理解。 + +  数据库实现分布式锁的缺点 + +  会有各种各样的问题,在解决问题的过程中会使整个方案变得越来越复杂。 + +  操作数据库需要一定的开销,性能问题需要考虑。 + +  使用数据库的行级锁并不一定靠谱,尤其是当我们的锁表并不大的时候。 + +基于缓存实现分布式锁 +  相比较于基于数据库实现分布式锁的方案来说,基于缓存来实现在性能方面会表现的更好一点。而且很多缓存是可以集群部署的,可以解决单点问题。 + +目前有很多成熟的缓存产品,包括Redis,memcached以及我们公司内部的Tair。 + +这里以Tair为例来分析下使用缓存实现分布式锁的方案。关于Redis和memcached在网络上有很多相关的文章,并且也有一些成熟的框架及算法可以直接使用。 + +  基于Tair的实现分布式锁其实和Redis类似,其中主要的实现方式是使用TairManager.put方法来实现。 + +复制代码 +public boolean trylock(String key) { + ResultCode code = ldbTairManager.put(NAMESPACE, key, "This is a Lock.", 2, 0); + if (ResultCode.SUCCESS.equals(code)) + return true; + else + return false; +} +public boolean unlock(String key) { + ldbTairManager.invalid(NAMESPACE, key); +} +复制代码 +  以上实现方式同样存在几个问题: + +1、这把锁没有失效时间,一旦解锁操作失败,就会导致锁记录一直在tair中,其他线程无法再获得到锁。 + +2、这把锁只能是非阻塞的,无论成功还是失败都直接返回。 + +3、这把锁是非重入的,一个线程获得锁之后,在释放锁之前,无法再次获得该锁,因为使用到的key在tair中已经存在。无法再执行put操作。 + +  当然,同样有方式可以解决。 + +没有失效时间?tair的put方法支持传入失效时间,到达时间之后数据会自动删除。 +非阻塞?while重复执行。 +非可重入?在一个线程获取到锁之后,把当前主机信息和线程信息保存起来,下次再获取之前先检查自己是不是当前锁的拥有者。 +  但是,失效时间我设置多长时间为好?如何设置的失效时间太短,方法没等执行完,锁就自动释放了,那么就会产生并发问题。如果设置的时间太长,其他获取锁的线程就可能要平白的多等一段时间。这个问题使用数据库实现分布式锁同样存在 + +  总结 +  可以使用缓存来代替数据库来实现分布式锁,这个可以提供更好的性能,同时,很多缓存服务都是集群部署的,可以避免单点问题。并且很多缓存服务都提供了可以用来实现分布式锁的方法,比如Tair的put方法,redis的setnx方法等。并且,这些缓存服务也都提供了对数据的过期自动删除的支持,可以直接设置超时时间来控制锁的释放。 + +  使用缓存实现分布式锁的优点 + +  性能好,实现起来较为方便。 + +  使用缓存实现分布式锁的缺点 + +  通过超时时间来控制锁的失效时间并不是十分的靠谱。 + +基于Zookeeper实现分布式锁 +  基于zookeeper临时有序节点可以实现的分布式锁。 + +  大致思想即为:每个客户端对某个方法加锁时,在zookeeper上的与该方法对应的指定节点的目录下,生成一个唯一的瞬时有序节点。 判断是否获取锁的方式很简单,只需要判断有序节点中序号最小的一个。 当释放锁的时候,只需将这个瞬时节点删除即可。同时,其可以避免服务宕机导致的锁无法释放,而产生的死锁问题。 + +来看下Zookeeper能不能解决前面提到的问题。 + +锁无法释放?使用Zookeeper可以有效的解决锁无法释放的问题,因为在创建锁的时候,客户端会在ZK中创建一个临时节点,一旦客户端获取到锁之后突然挂掉(Session连接断开),那么这个临时节点就会自动删除掉。其他客户端就可以再次获得锁。 + +非阻塞锁?使用Zookeeper可以实现阻塞的锁,客户端可以通过在ZK中创建顺序节点,并且在节点上绑定监听器,一旦节点有变化,Zookeeper会通知客户端,客户端可以检查自己创建的节点是不是当前所有节点中序号最小的,如果是,那么自己就获取到锁,便可以执行业务逻辑了。 + +不可重入?使用Zookeeper也可以有效的解决不可重入的问题,客户端在创建节点的时候,把当前客户端的主机信息和线程信息直接写入到节点中,下次想要获取锁的时候和当前最小的节点中的数据比对一下就可以了。如果和自己的信息一样,那么自己直接获取到锁,如果不一样就再创建一个临时的顺序节点,参与排队。 + +单点问题?使用Zookeeper可以有效的解决单点问题,ZK是集群部署的,只要集群中有半数以上的机器存活,就可以对外提供服务。 + +  可以直接使用zookeeper第三方库Curator客户端,这个客户端中封装了一个可重入的锁服务。 + +复制代码 +public boolean tryLock(long timeout, TimeUnit unit) throws InterruptedException { + try { + return interProcessMutex.acquire(timeout, unit); + } catch (Exception e) { + e.printStackTrace(); + } + return true; +} +public boolean unlock() { + try { + interProcessMutex.release(); + } catch (Throwable e) { + log.error(e.getMessage(), e); + } finally { + executorService.schedule(new Cleaner(client, path), delayTimeForClean, TimeUnit.MILLISECONDS); + } + return true; +} +复制代码 +  Curator提供的InterProcessMutex是分布式锁的实现。acquire方法用户获取锁,release方法用于释放锁。 + +  使用ZK实现的分布式锁好像完全符合了本文开头我们对一个分布式锁的所有期望。但是,其实并不是,Zookeeper实现的分布式锁其实存在一个缺点,那就是性能上可能并没有缓存服务那么高。因为每次在创建锁和释放锁的过程中,都要动态创建、销毁瞬时节点来实现锁功能。ZK中创建和删除节点只能通过Leader服务器来执行,然后将数据同不到所有的Follower机器上。 + +  其实,使用Zookeeper也有可能带来并发问题,只是并不常见而已。考虑这样的情况,由于网络抖动,客户端可ZK集群的session连接断了,那么zk以为客户端挂了,就会删除临时节点,这时候其他客户端就可以获取到分布式锁了。就可能产生并发问题。这个问题不常见是因为zk有重试机制,一旦zk集群检测不到客户端的心跳,就会重试,Curator客户端支持多种重试策略。多次重试之后还不行的话才会删除临时节点。(所以,选择一个合适的重试策略也比较重要,要在锁的粒度和并发之间找一个平衡。) + +  总结 +  使用Zookeeper实现分布式锁的优点 + +  有效的解决单点问题,不可重入问题,非阻塞问题以及锁无法释放的问题。实现起来较为简单。 + +  使用Zookeeper实现分布式锁的缺点 + +  性能上不如使用缓存实现分布式锁。 需要对ZK的原理有所了解。 + +三种方案的比较 +  上面几种方式,哪种方式都无法做到完美。就像CAP一样,在复杂性、可靠性、性能等方面无法同时满足,所以,根据不同的应用场景选择最适合自己的才是王道。 + +  从理解的难易程度角度(从低到高) +  数据库 > 缓存 > Zookeeper + +  从实现的复杂性角度(从低到高) +  Zookeeper >= 缓存 > 数据库 + +  从性能角度(从高到低) +  缓存 > Zookeeper >= 数据库 + +  从可靠性角度(从高到低) +  Zookeeper > 缓存 > 数据库 \ No newline at end of file