这把锁到底怎么开?

731 阅读15分钟

Java锁机制

Java提供了多种多线程锁机制的实现方式。常见的有synchronized、ReentrantLock、Semaphore、AtomicInteger等。

synchronized

可重入锁 可中断锁

在需要同步的方法、类或代码块中加入该关键字,可以保证该处代码具有原子性、可见性。实现原子性的算法为 CAS(参考资料)

  • 原子性

指一个操作是不可中断的,要么全部执行成功要么全部执行失败,有着“同生共死”的感觉。

  • 可见性

指多个线程在执行中,任一线程的执行过程对其他线程可见。它必须确保释放锁之前对共享数据做出的更改对于随后获得该锁的另一个线程是可见的

作用:如果没有同步机制提供的这种可见性保证,线程看到的共享变量可能是修改前的值或不一致的值,这将引发许多严重问题。

volatile只保证可见性,不保证原子性!

  • 缺点

    • 当线程尝试获取锁的时候,如果获取不到锁会一直阻塞,它无法中断一个正在等候获得锁的线程;
    • 如果获取锁的线程进入休眠或者阻塞,除非当前线程异常,否则其他线程尝试获取锁必须一直等待,也无法通过投票得到锁,如果不想等下去,也就没法得到锁。

ReentrantLock(可重入锁)

可重入锁  可中断锁  非公平锁(默认)  公平锁

ReentantLock是唯一继承接口Lock并实现了接口中定义的方法,除了能完成synchronized所能完成的所有工作外(相同的并发性和内存语义),还提供了诸如可响应中断锁可轮询锁请求定时锁等避免多线程死锁的方法。

Lock实现的机理依赖于特殊的CPU指定,可以认为不受JVM的约束,并可以通过其他语言平台来完成底层的实现。

并发量较小的多线程应用程序中,ReentrantLock与synchronized性能相差无几,但在高并发量的条件下,[synchronized]性能会迅速下降几十倍,而ReentrantLock的性能却能依然维持一个水准,因此我们建议在高并发量情况下使用ReentrantLock。

ReentrantLock通过方法lock()与unlock()来进行加锁与解锁操作,与synchronized会被JVM自动解锁机制不同,ReentrantLock加锁后需要==手动进行解锁==。为了避免程序出现异常而无法正常解锁的情况,使用ReentrantLock必须在finally控制块中进行解锁操作。

可重入的意思是某一个线程是否可多次获得一个锁,在继承的情况下,如果不是可重入的,那就形成死锁了,比如递归调用自己的时候;

==这里有一个可重入锁Demo哦==

  • ReentrantLock从Lock中实现的方法中:

    • lock()、tryLock()、tryLock(long time, TimeUnit unit)和lockInterruptibly()是用来获取锁的。
    • unLock()是用来释放锁的。

可响应中断锁

可响应中断锁在ReentrantLock获取所的过程中有两种锁机制,忽略中断锁相应中断锁

==看这里有可响应中断锁Demo==

  • 忽略中断锁

当等待线程A或其他线程尝试中断线程A时,忽略中断锁机制则不会接受中断,而是继续处于等待状态使用lock()方法可设置锁机制为忽略中断锁。

  • 响应中断锁

响应中断锁会处理其他线程的中断请求,并将线程A由阻塞状态唤醒为就绪状态,不会请求和等待资源。使用lockInterruptibly()方法可设置锁机制为响应中断锁。

可轮询的锁请求

在synchronized中,一旦发生死锁,唯一能够恢复的办法只能重新启动程序,唯一的预防方法是在设计程序时考虑完善不要出错。而有了Lock以后,死锁问题就有了新的预防办法,它提供了tryLock()轮询方法来获得锁,如果锁可用则获取锁,如果锁不可用,则此方法返回false,并不会为了等待锁而阻塞线程,这极大地降低了死锁情况的发生。典型使用语句如下:

Lock lock = ...;
if(lock.tryLock()){
    //锁可用,则成功获取锁
    try {
        //获取锁后进行处理
    } finally {
        lock.unlock();
    }
} else {
    //锁不可用,其他处理方法
}

定时锁请求

在synchronized中,一旦发起锁请求,该请求就不能停止了,如果不能获得锁,则当前线程会阻塞并等待获得锁。在某些情况下,你可能需要让线程在一定时间内去获得锁,如果在指定时间内无法获取锁,则让线程放弃锁请求,转而执行其他的操作。Lock就提供了定时锁的机制,使用Lock.tryLock(long timeout, TimeUnit unit)方法来指定让线程在timeout单位时间内去争取锁资源,如果超过这个时间仍然不能获得锁,则放弃锁请求,定时锁可以避免线程陷入死锁的境地。

在上面的可响应中断锁例子中,其他线程在5秒后向正在等候锁的读线程发起中断请求,读线程响应请求并成功中断。也可以在读线程中设置定时锁,设定在5秒内争夺锁,超时则放弃锁,并结束当前的读线程,使用定时锁实现读方法代码如下:

==定时锁的Demo来这里取!!!==

可轮询的锁请求

在synchronized中,一旦发生死锁,唯一能够恢复的办法只能重新启动程序,唯一的预防方法是在设计程序时考虑完善不要出错。而有了Lock以后,死锁问题就有了新的预防办法,它提供了tryLock()轮询方法来获得锁,如果锁可用则获取锁,如果锁不可用,则此方法返回false,并不会为了等待锁而阻塞线程,这极大地降低了死锁情况的发生。典型使用语句如下:

Lock lock = ...;
if(lock.tryLock()){
    //锁可用,则成功获取锁
    try {
        //获取锁后进行处理
    } finally {
        lock.unlock();
    }
} else {
    //锁不可用,其他处理方法
}

公平锁

公平锁即尽量以请求锁的顺序来获取锁。比如同是有多个线程在等待一个锁,当这个锁被释放时,等待时间最久的线程(最先请求的线程)会获得该所,这种就是公平锁。

非公平锁即无法保证锁的获取是按照请求锁的顺序进行的。这样就可能导致某个或者一些线程永远获取不到锁。

在Java中,synchronized就是非公平锁,它无法保证等待的线程获取锁的顺序。

而对于ReentrantLock和ReentrantReadWriteLock,它默认情况下是非公平锁,但是可以设置为公平锁。

我们要创建公平锁,那么我们就可以在创建ReentrantLock对象时,传入参数true进行创建:

ReentrantLock lock = new ReentrantLock(true);

我们看看含参构造方法:

含参构造函数

  • 公平锁源码

公平锁

首先用一个CAS操作,判断state是否是0(表示当前锁未被占用),如果是0则把它置为1,并且设置当前线程为该锁的独占线程,表示获取锁成功。当多个线程同时尝试占用同一个锁时,CAS操作只能保证一个线程操作成功,剩下的只能乖乖的去排队啦。

“非公平”即体现在这里,如果占用锁的线程刚释放锁,state置为0,而排队等待锁的线程还未唤醒时,新来的线程就直接抢占了该锁,那么就“插队”了(请注意此处的非公平锁是指新来的线程跟队列头部的线程竞争锁,队列其他的线程还是正常排队,百度面试题)。

  • 非公平锁源码

非公平锁

非公平锁tryAcquire的流程是:检查state字段,若为0,表示锁未被占用,那么尝试占用,若不为0,检查当前锁是否被自己占用,若被自己占用,则更新state字段,表示重入锁的次数。如果以上两点都没有成功,则获取锁失败,返回false。

在ReentrantReadWriteLock中也有类似的方法,同样也可以设置为公平锁和非公平锁。不过要记住,ReentrantReadWriteLock并未实现Lock接口,它实现的是ReadWriteLock接口。

自旋锁

首先是一种锁,与互斥锁相似,基本作用是用于线程(进程)之间的同步。与普通锁不同的是,一个线程A在获得普通锁后,如果再有线程B试图获取锁,那么这个线程B将会挂起(阻塞);试想下,如果两个线程资源竞争不是特别激烈,而处理器阻塞一个线程引起的线程上下文的切换的代价高于等待资源的代价的时候(锁的已保持者保持锁时间比较短),那么线程B可以不放弃CPU时间片,而是在“原地”忙等,直到锁的持有者释放了该锁,这就是自旋锁的原理,可见自旋锁是一种非阻塞锁

  • 缺点

    • 过多占据CPU时间:如果锁的当前持有者长时间不释放该锁,那么等待者将长时间的占据cpu时间片,导致CPU资源的浪费,因此可以设定一个时间,当锁持有者超过这个时间不释放锁时,等待者会放弃CPU时间片阻塞;
    • 死锁问题:试想一下,有一个线程连续两次试图获得自旋锁(比如在递归程序中),第一次这个线程获得了该锁,当第二次试图加锁的时候,检测到锁已被占用(其实是被自己占用),那么这时,线程会一直等待自己释放该锁,而不能继续执行,这样就引起了死锁。因此递归程序使用自旋锁应该遵循以下原则:递归程序决不能在持有自旋锁时调用它自己,也决不能在递归调用时试图获得相同的自旋锁。

==自旋锁的Demo在这里哦哦哦==

乐观锁/悲观锁

乐观锁与悲观锁不是指具体的什么类型的锁,而是指看待并发同步的角度。

  • ==悲观锁==认为对于同一个数据的并发操作,一定是会发生修改的,哪怕没有修改,也会认为修改。因此对于同一个数据的并发操作,悲观锁采取加锁的形式。悲观的认为,不加锁的并发操作一定会出问题。
  • ==乐观锁==则认为对于同一个数据的并发操作,是不会发生修改的。在更新数据的时候,会采用尝试更新,不断重新的方式更新数据。乐观的认为,不加锁的并发操作是没有事情的。

从上面的描述我们可以看出,观锁适合操作非常多的场景,观锁适合操作非常多的场景,不加锁会带来大量的性能提升。

  • 悲观锁在Java中的使用,就是利用各种锁。
  • 乐观锁在Java中的使用,是无锁编程,常常采用的是CAS算法,典型的例子就是原子类,通过CAS自旋实现原子操作的更新。

分段锁

分段锁其实是一种锁的设计,并不是具体的一种锁,对于ConcurrentHashMap而言,其并发的实现就是通过分段锁的形式来实现高效的并发操作。

我们以ConcurrentHashMap来说一下分段锁的含义以及设计思想,ConcurrentHashMap中的分段锁称为Segment,它即类似于HashMap(JDK7与JDK8中HashMap的实现)的结构,即内部拥有一个Entry数组,数组中的每个元素又是一个链表;同时又是一个ReentrantLock(Segment继承了ReentrantLock)。

当需要put元素的时候,并不是对整个hashmap进行加锁,而是先通过hashcode来知道他要放在那一个分段中,然后对这个分段进行加锁,所以当多线程put的时候,只要不是放在一个分段中,就实现了真正的并行的插入。但是,在统计size的时候,可就是获取hashmap全局信息的时候,就需要获取所有的分段锁才能统计。

分段锁的设计目的是细化锁的粒度,当操作不需要更新整个数组的时候,就仅仅针对数组中的一项进行加锁操作。

偏向锁/轻量级锁/重量级锁

这三种锁是指锁的状态,并且是针对Synchronized

在Java 5通过引入锁升级的机制来实现高效Synchronized。这三种锁的状态是通过对象监视器在对象头中的字段来表明的。

  • 偏向锁是指一段同步代码一直被一个线程所访问,那么该线程会自动获取锁。降低获取锁的代价。
  • 轻量级锁是指当锁是偏向锁的时候,被另一个线程所访问,偏向锁就会升级为轻量级锁,其他线程会通过自旋的形式尝试获取锁,不会阻塞,提高性能。
  • 重量级锁是指当锁为轻量级锁的时候,另一个线程虽然是自旋,但自旋不会一直持续下去,当自旋一定次数的时候,还没有获取到锁,就会进入阻塞,该锁膨胀为重量级锁。重量级锁会让其他申请的线程进入阻塞,性能降低。

AQS

AbstractQueuedSynchronizer简称AQS,是一个用于构建锁和同步容器的框架。事实上concurrent包内许多类都是基于AQS构建,例如ReentrantLock,Semaphore,CountDownLatch,ReentrantReadWriteLock,FutureTask等。AQS解决了在实现同步容器时设计的大量细节问题。

AQS使用一个FIFO的队列表示排队等待锁的线程,它维护一个status的变量,每个节点维护一个waitstatus的变量,当线程获取到锁的时候,队列的status置为1,此线程执行完了,那么它的waitstatus为-1;队列头部的线程执行完毕之后,它会调用它的后继的线程。

队列头节点称作“哨兵节点”或者“哑节点”,它不与任何线程关联。其他的节点与等待线程关联,每个节点维护一个等待状态waitStatus。如图

mark

AQS中还有一个表示状态的字段state,例如ReentrantLocky用它表示线程重入锁的次数,Semaphore用它表示剩余的许可数量,FutureTask用它表示任务的状态。对state变量值的更新都采用CAS操作保证更新操作的原子性。

AbstractQueuedSynchronizer继承了AbstractOwnableSynchronizer,这个类只有一个变量:exclusiveOwnerThread,表示当前占用该锁的线程,并且提供了相应的get,set方法。

理解AQS可以帮助我们更好的理解JCU包中的同步容器。

常用Demo

多窗口售票

程序分析:

  • 1.票数要使用同一个静态值

  • 2.为保证不会出现卖出同一个票数,要java多线程同步锁。

设计思路:

  • 1.创建一个站台类Station,继承Thread,重写run方法,在run方法里面执行售票操作!售票要使用同步锁:即有一个站台卖这张票时,其他站台要等这张票卖完!

多窗口售票Demo

ATM机取钱

两个人AB通过一个账户A在柜台取钱和B在ATM机取钱!

程序分析:钱的数量要设置成一个静态的变量。两个人要取的同一个对象值

ATM机Demo

龟兔赛跑问题

龟兔赛跑:20米 //只要为了看到效果,所有距离缩短了

  • 要求:
    • 1.兔子每秒0.5米的速度,每跑2米休息10秒,
    • 2.乌龟每秒跑0.1米,不休息
    • 3.其中一个跑到终点后另一个不跑了!
  • 程序设计思路:
    • 1.创建一个Animal动物类,继承Thread,编写一个running抽象方法,重写run方法,把running方法在run方法里面调用。
    • 2.创建Rabbit兔子类和Tortoise乌龟类,继承动物类
    • 3.两个子类重写running方法
    • 4.本题的第3个要求涉及到线程回调。需要在动物类创建一个回调接口,创建一个回调对象

龟兔赛跑Demo

生产者-消费者问题

服务员负责生产食物,消费者负责消费食物;当生产到一定数量可以休息一下,直到消费完食物,再马上生产,一直循环。

  • 程序涉及到的内容:

    • 1.这设计到java模式思想:生产者消费者模式
    • 2.要保证操作对象的统一性,即消费者和服务者都是跟同一个Restaurant发生关系的,Restaurant只能new一次
    • 3.this.notifyAll();和this.wait();一个是所有唤醒的意思,一个是让自己等待的意思; 比如本题中,生产者生产完毕后,先所有唤醒(包括消费者和生产者),再让所有自己(生产者)等待这时,消费者开始消费,直到食材不够,先所有唤醒(包括消费者和生产者),再让所有自己(消费者)等待一直执行上面的操作的循环
    • 4.生产者和消费者都要继承Thread,才能实现多线程的启动
  • 设计思路

    • 1.创建一个食物类Food,有存放/获取食物的名称的方法
    • 2.创建一个Restaurant类,有生产食物和消费食物的方法
    • 3.创建一个客户类Customer,继承Thread,重写run方法,在run方法里面进行消费食物操作
    • 4.创建一个服务员类Waiter,继承Thread,重写run方法,在run方法里面进行生产食物的操作
    • 5.创建主方法的调用类

生产者-消费者问题Demo

双加双减

  • 程序分析:
    • 1.创建一个ThreadAddSub类继承Thread,重写run方法
    • 2.在run方法里面实现加和减的操作,每次操作后睡眠1秒
    • 3.创建主方法调用类

双加双减Demo