一、锁的使用
Java的多线程环境下,由于共享资源的竞态可能导致错误的结果。因此锁的使用就是保护临界代码,这个就不再赘述。
代码: 没有锁的时候,临界区没有保护,可能出现错误的结果(出错概率跟你的并发程度有关,小概率事件),尽管代码比较简单,可以通过其他手段来规避多线程问题,这里还是以这个代码为例,仅仅用来说明多线程问题
package top.huster.thread.lock; import java.util.ArrayList; import java.util.List; /** * Created by longan.rtb on 2018/11/6. * lock test */ public class LockTestThread { private static int count = 0; private static ListthreadList = new ArrayList<>(); public static void main(String[] args) throws InterruptedException { for (int i = 0; i< 10; i++) { Thread thread = new Thread( () -> { count++; }, "thread - " + i); threadList.add(thread); } for (Thread thread : threadList) { thread.start(); thread.join(); } System.out.println("count is " + count); } } 二、同步代码块和lock 要解决这种问题,就得在临界值的时候,加上保护。通常做法有使用synchronize代码块和lock两种方式。 代码: 使用synchronize代码块 package top.huster.thread.lock; import java.util.ArrayList; import java.util.List; /** * Created by longan.rtb on 2018/11/6. * lock test */ public class LockTestThread { private static int count = 0; private static Object object = new Object(); private static List threadList = new ArrayList<>(); public static void main(String[] args) throws InterruptedException { for (int i = 0; i< 10; i++) { Thread thread = new Thread( () -> { synchronized (object) { count++; } }, "thread - " + i); threadList.add(thread); } for (Thread thread : threadList) { thread.start(); thread.join(); } System.out.println("count is " + count); } }
问题是解决了,但是我们知道在java里还有锁的方式。可能你会问,为什么有了同步代码块,还需要有锁呢?那是因为同步代码块还不够灵活,很多时候用起来远远达不到lock的灵活性。举例说,synchronize在出了代码块/方法区以后,就自动解锁了,而lock我可以选择任意我想要的时候解锁。另外lock还提供了读写锁能够适应更多的使用场景。
另外一方面,由于lock使用的是CAS实现,所以具有更好的性能。
三、我们不妨来简单的来实现一个自己的锁
代码: 重点只关注lock和unlock部分
package top.huster.thread.lock; import java.util.Date; import java.util.concurrent.TimeUnit; import java.util.concurrent.locks.Condition; import java.util.concurrent.locks.Lock; /** * Created by longan.rtb on 2018/11/6. * 基于object上的通信手段实现多线程的锁 */ public class SimpleLock implements Lock { private static volatile Boolean hasLocked = Boolean.FALSE; private final static Object object = new Object(); public void lock() { while (hasLocked) { try { synchronized (object) { object.wait(); } } catch (InterruptedException e) { } } hasLocked = true; } public void lockInterruptibly() throws InterruptedException { } public boolean tryLock() { return false; } public boolean tryLock(long timeout, TimeUnit unit) { return false; } public void unlock() { //while throw IllegalMonitorStateException if not synchronized synchronized (object) { object.notify(); } hasLocked = false; } public Condition newCondition() { return new Condition() { @Override public void await() throws InterruptedException { } @Override public void awaitUninterruptibly() { } @Override public long awaitNanos(long nanosTimeout) throws InterruptedException { return 0; } @Override public boolean await(long time, TimeUnit unit) throws InterruptedException { return false; } @Override public boolean awaitUntil(Date deadline) throws InterruptedException { return false; } @Override public void signal() { } @Override public void signalAll() { } }; } }
大体思路是使用一个标识为锁有没有被别人拿走,如果拿走了就wait,直到有锁的线程释放锁,再通知线程醒来继续。那么要注意的是什么呢?如果一个线程在lock的时候,再次调用了lock(比如递归函数)的时候,整个程序就卡死了。因为这个时候的标志位已经是true表示有人拿到了锁,而这人就是他自身。他就只能永久得等待下去,产生了死锁,为了解决这个问题,就是下一章节。
四、可重入锁。可重入锁意思就是,如果获取锁的线程就是自身,那他可以继续获取这个锁。在Java的并发包里可重入锁的实现ReentrantLock,我们来看看他的实现。
重点代码:
final void lock() { if (compareAndSetState(0, 1)) setExclusiveOwnerThread(Thread.currentThread()); else acquire(1); }
解释下,也是用了一个标志位,不过他用的是一个long型变量,使用的是CAS,当他为0的时候,代表这个锁还未被使用,所以将这个标志位改成1代表被我使用了,并且记下占用的线程。
接着,如果是已经被占用了,他也没直接结束,而是尝试去做以下操作
注意其中的红框框的部分,为什么重复把上面的代码写了一遍呢? 这是因为当你判断了c不等于0(一种情况是自己占用了这把锁,一种情况是别人占用了这把锁),试着再去acquire的时候,这个时候恰好另外一个线程已经释放了锁,所以这里他又判断了一次,思维很缜密。
整理大概做的事情就是,判断当前占用这个锁的线程是不是自己,如果是自己则把计数加1,如果不是呢? 我们看如下代码
可以看到如果不是则把当前的线程生成一个node,然后加入到队列的末尾。这个队列就是所有等待这把锁的线程。加入队列之后,最终调用的是unsafe这个类里的park阻塞了当前抢锁的线程
至此,我们就实现了整个的加锁过程,并且是可重入的。
那么unlock的过程是怎样的,且看如下代码
这个过程依然会判断当前的线程是否是持有锁的线程,如果是的话,将计数器-1,如果这个时候是最后一层锁,那么直接将当前持锁线程清空,这个时候state=0代表没有加锁。如果这个时候state还未等于0,则说明这个线程多次进行了lock,则将计数器-1,等待着下次的unlock。
如果全部解完,在这时候,他调用了unsafe的unpark,唤醒了队尾的线程。
六、上面的示例是一个非公平锁的实现,我们可以在ReentrantLock的构造函数里看到,fair的参数,也可以看到fair和unfair的实现。那么这个是什么意思呢? 有什么用呢?
如果一把锁是非公平的话,那么会随机的选择一个线程来执行,这就会导致线程饥饿的问题。有的线程运气特别差,总是轮不到他拿到cpu资源,所以最后他就饿死了。那么公平锁是怎么做的呢?他严格的按照FIFO的原则,根据时间的先后顺序来选择执行的线程。
他的实现和unfair的实现仅仅是lock的时候有所不同。
注意红色部分就是和非公平锁的不同之处。他会判断当前线程的先前节点还有没有,如果还有说明自己不是队头的那个,他会把自己加入队尾,阻塞,并且让给他的前面的一个节点。我们想象下,如果这个过程不断的迭代,他的前面,前面的前面总不是头,经过几次迭代终于找到了头。那么这个线程做的事情,就比unfair的要多得多,所以公平锁的性能比非公平锁要差,在使用的时候要特别注意。
不过这里我一直有个问题没有想明白,那就是unlock的时候,唤醒的是队头的节点,那么下一次抢锁的一定是头节点,为什么还有公平和非公平之说呢?现在我突然想明白了,有可能头结点去抢锁的时候,别的新进来的线程也在抢锁。如果没有公平性,这个时候新进来的线程,可能可以立刻获得锁,这个就是非公平的。而公平的锁,则会判断当前的节点是否是头结点,发现这个节点根本不是头结点,他只能都后面去排队了。这个设计很巧妙。
七、读写锁。为了更进一步提升性能。在一个读多写少的系统,我们可以把读写分开加锁,这样读的时候,根本不需要抢锁(不需要抢读锁),岂不是性能更高?
读的时候,需要获得写锁,保证读到的都是最新的值,但是不需要读锁。因为写比较少,所以性能高。
写的时候,需要读锁和写锁,保证没有别的线程读到旧值,并且保证只有一个线程同时写入值。
java的读写锁实现,ReentrantReadWriteLock 使用起来也比较简单,不需要赘述,不过他的实现还是蛮巧妙的。他的实现其实跟上面提到的差不多,不过他是使用的一个32位的数来记录锁的情况。
八、接近尾声。再说一说Condition对象。锁的接口里有newCondition方法,他会产出一个Condition对象,用于线程间的通信。他也有wait、notify和notifyAll方法。我们的Object上也有这三个方法。不同的是,Object上调用wait的时候,必须加锁,用synchronize修饰,而lock上的condition则不需要,所以他用起来更方便。