1、Java中的锁(抽象角度)
锁从乐观和悲观的角度可分为乐观锁和悲观锁,从获取资源的公平性角度可分为公平锁和非公平锁,从是否共享资源的角度可分为共享锁和独占锁,从锁的状态的角度可分为偏向锁、轻量级锁和重量级锁。
- 乐观锁和悲观锁
- 公平锁和非公平锁
- 共享锁和独占锁
- 偏向锁、轻量级锁和重量级锁
- 可重入锁
- 自旋锁
1.1 乐观锁和悲观锁
- 乐观锁
乐观锁采用乐观的思想处理数据,在每次读取数据时都认为别人不会修改该数据,所以不会上锁,但在更新时会判断在此期间别人有没有更新该数据,通常采用在写时先读出当前版本号然后加锁的方法。具体过程为:比较当前版本号与上一次的版本号,如果版本号一致,则更新;如果版本号不一致,则重复进行读、比较、写操作。
Java中乐观锁大部分是通过CAS(Compare And Swap,比较和交换)操作实现的,CAS是一种原子更新操作,在对数据操作之前首先会比较当前值跟传入的值是否一样,如果一样则更新,否则不执行更行操作,直接返回失败状态。
- 悲观锁
悲观锁采用思想处理数据,在每次读取数据时都认为别人会修改数据,所以每次在读写数据时都会上锁,这样别人想读写这个数据时就会阻塞、等待直到拿到锁。
Java中的悲观锁大部分基于AQS(Abstract Queued Synchronized,抽象的队列同步器)架构实现。AQS定义了一套多线程访问共享资源的同步框架,许多同步类的实现都依赖于它,例如常用的Sychronized、ReentrantLock、Semaphore、CountDownLatch等。该框架下的锁会先尝试以CAS乐观锁去获取锁,如果获取不到,则会转为悲观锁(如ReentrantLock)。
1.2 公平锁和非公平锁
- 公平锁
公平锁指在分配锁前检查是否有线程在排队等待获取该锁,优先将锁分配给排队时间最长的线程。
- 非公平锁
非公平锁指在分配锁时不考虑线程排队等待的情况,直接尝试获取锁,在获取不到时再排到队尾等待。
因为公平锁需要在多多核的情况下维护一个锁线程等待队列,基于该队列进行锁的分配,因此效率比非公平锁低很多。Java中的sychronized是非公平锁,ReentrantLock默认的lock方法采用的是非公平锁。
1.3 共享锁和独占锁
- 共享锁
共享锁允许多个线程同时获取锁资源,并发访问共享资源。ReentranReadWriteLock中的读锁为共享锁的实现。
- 独占锁
独占锁也叫互斥锁,同一时刻只允许一个线程获取锁资源。ReentrantLock为独占锁的实现。
1.4 偏向锁、轻量级锁和重量级锁
- 偏向锁
除了在多线程之间存在竞争获取锁的情况,还会经常出现同一个锁被同一个线程多次获取的情况。偏向锁用于在某个线程获取某个锁之后,消除这个线程锁冲入的开销,看起来似乎是这个线程得到了该锁的偏向(偏袒)。
偏向锁的主要目的是在同一个线程多次获取某个锁的情况下尽量减少轻量级锁的执行路径,因为轻量级锁的获取及释放需要多次CAS原子操作,而偏向锁只需要在切换ThreadID时执行一次CAS原子操作,因此可以提高锁的运行效率。
在出现多线程竞争锁的情况时,JVM会自动撤离偏向锁,因此偏向锁的撤销操作的好事必须少于节省下来的 CAS原子操作的耗时。
- 轻量级锁
轻量级锁是相对于重量级锁而言的。轻量级锁的核心设计是在没有多线程竞争的前提下,减少重量级锁的使用以提高系统性能。轻量级锁适用于线程交替执行同步代码块的情况(即互斥操作),如果同一时刻有多个线程访问同一个锁,则将会导致轻量级锁膨胀为重量级锁。
- 重量级锁
重量级锁是基于操作系统的互斥量(Mutex Lock)而实现的锁,会导致进程在用户态和内核态之间切换,相对开销较大。
synchronized在内部基于监视器锁(Monitor)实现,监视器锁基于底层的操作系统的Mutex Lock实现,因此synchronized属于重量级锁。
JDK在1.6版本后,为了减少获取锁和释放锁所带来的性能消耗及提高性能,引入了轻量级锁和偏向锁。
锁膨胀
锁的状态总共有4种:无锁、偏向锁、轻量级锁和重量级锁。随着锁竞争越来越激烈,锁可能从偏向锁升级到重量级锁,但在Java中锁只单项升级,不会降级。
1.5 可重入锁
可重入锁也叫作递归锁,指在同一线程中,在外层函数获取到该锁之后,内层的递归函数仍然可以继续获取该锁。简而言之,可重入锁指该锁能够支持一个线程对同一个资源执行多次加锁操作。在Java环境下,ReentrantLock和sychronized都是可重入锁。
1.6 自旋锁
自旋锁认为:如果持有锁的线程能在很短的时间内释放锁资源,那么那些等待竞争锁的线程就不需要做内核态和用户态之间的切换进入阻塞、挂起状态,只需等一等(也叫作自旋),在等待持有锁的线程释放锁后即可立即获取锁,这样就避免了用户线程在内核状态的切换上导致的锁时间消耗。
线程在自旋时会占用CPU,在线程长时间自旋获取不到锁时,将会产生CPU的浪费,甚至有时线程永远无法获取锁而导致CPU资源被永久占用,所以需要设定一个自旋等待的最大时间。在线程执行的时间超过自旋等待的最大时间后,线程将会退出自旋模式并释放其持有的锁。
自旋锁的优缺点
- 优点:自旋锁可以减少CPU上下文的切换,对于占用锁的时间非常短或锁竞争不激烈的代码块来说性能大幅度提升,因为自旋的CPU耗时明显少于线程阻塞、挂起、再唤醒时两次CPU上下文切换所用的时间。
- 缺点:在持有锁的线程占用锁时间过长或锁的竞争过于激烈时,线程在自旋过程中会长时间获取不到锁资源,将引起CPU的浪费。所以在系统中有复杂锁依赖的情况下不适合采用自旋锁。
自旋锁的时间阈值
如果自旋的执行时间太长,则会有大量的线程处于自旋状态且占用CPU资源,造成系统资源浪费。因此,对自旋的周期选择直接影响到系统的性能!
JDK的不同版本所采用的自旋周期不同,JDK1.5为固定时间,JDK1.6引入了适应性自旋锁。适应性自旋锁的自旋时间不再是固定值,而是由上一次在同一个锁上的自旋时间及锁的拥有者的状态来决定的,可基本认为一个线程上下文切换的时间就是一个最佳时间。
2、Java中的锁(实现角度)
2.1 synchronized
synchronized关键字用于为Java对象、方法、代码块提供线程安全的操作。synchronized属于独占式的悲观锁,同时属于可重入锁和非公平锁。
Java中的每个对象都有个monitor对象,加锁就是在竞争monitor对象。对代码块加锁是通过在前后分别加上monitorenter和monitorexit指令实现的,对方法是否加锁是通过一个标志位来判断的。
synchronized的作用范围
- synchronized作用于成员变量和非静态方法时,锁住的是对象的实例,即this对象。
- synchronized作用于静态方法时,锁住的是Class实例,因为静态方法属于Class而不属于对象。
- synchronized作用于一个代码块时,锁住的是所有代码块中配置的对象。
synchronized的实现原理
在synchronized内部包括ContentionList、EntryList、WaitSet、OnDeck、Owner、!Owner这6个区域,每个区域的数据都代表锁的不同状态。
- ContentionList:锁竞争队列,所有请求锁的线程都被放在竞争队列中。
- EntryList:竞争候选列表,在ContentionList中有资格成为候选者来竞争锁资源的线程被移动到了EntryList中。
- WaitSet:等待集合,调用wait方法后被阻塞的线程将被放在WaitSet中。
- OnDeck:竞争候选者,在同一时刻最多只有一个线程在竞争锁资源,该线程的状态被成为OnDeck。
- Owner:竞争到锁资源的线程被称为Owner状态线程。
- !Owner:在Owner线程释放锁后,会从Owner的状态变为!Owner。
synchronized在收到新的锁请求时首先自旋,如果通过自旋也没有获取到锁资源,则将被放入锁竞争队列ContentionList中。该做法对于已经进入队列的线程是不公平的,因此synchronized是非公平锁。
2.2 volatile
volatile变量具备两种特性:一种是保证该变量对所有线程可见,在一个线程修改了变量的值后,新的值对于其它线程是可以立即获取的;一种是volatile禁止指令重排。
需要说明的是,volatile关键字可以严格保障变量的单次读、写操作的原子性,但并不能保证像i++这种操作的原子性,因为i++在本质上是读、写两次操作。volatile在某些场景下可以代替synchronized,但是volatile不能完全取代synchronized的位置,只有在一些特殊场景下才适合使用volatile。比如,必须同时满足下面两个条件才能保证并发环境的线程安全:
- 对变量的写操作不依赖当前(比如i++),或者说是单纯的变量赋值(boolean flag = true)。
- 该变量没有被包含在具有其它变量的不变式中,也就是说在不同的volatile变量之间不能互相依赖,只有在状态真正独立于程序内的其它内容时才能使用volatile。
2.3 ReentrantLock
ReentrantLock继承了Lock接口并实现了在接口中定义的方法,是一个可重入的独占锁,ReentrantLock支持公平锁和非公平锁的实现。ReentrantLock有显式的操作过程,何时加锁、何时释放锁都在程序员的控制之下。具体的使用流程是定义一个ReentrantLock,在需要加锁的地方通过lock方法加锁,等资源完成后再通过unlock方法释放锁。
Lock lock = new ReentrantLock();//ReentrantLock(true)参数为true表示公平锁
try{
lock.lock();//加锁操作
}finally{
lock.unlock();//释放锁操作
}
ReentrantLock不但提供了synchronized对锁的操作功能,还提供了诸如响应中断锁、可轮询锁请求、定时锁等避免多线程死锁的方法。
- 响应中断:ReentrantLock对中断是有响应的。
- 可轮询锁:通过boolean trytLock()获取锁。如果有可用锁,则获取该锁并返回true,如果无可用锁,则立即返回false。
- 定时锁:通过boolean trytLock(long time,TimeUnit unit) throws InterruptedException获取定时锁。如果在给定的时间内获取到了可用锁,且当前线程未被中断,则获取该锁并返回true。如果在给定的时间内获取不到可用锁,将禁用当前线程。
2.4 CountDownLatch
CountDownLatch类位于java.util.concurrent包下,是一个同步工具类,允许一个或多个线程一直等待其他线程的操作执行完后再执行相关操作。
CountDownLatch基于线程计数器来实现并发访问控制,主要用于主线程等待其它子线程都执行完毕后执行相关操作。其使用过程为:1)在主线程中定义CountDownLatch,并将线程计数器的值设置为子线程的个数;2)多个子线程并发执行,每个子线程在执行完毕后都会调用countDown函数将计数器的值减1;3)直到线程计数器为0,表示所有的子线程任务都已执行完毕,此时在CountDownLatch上等待的主线程将被唤醒并继续执行。
2.5 Semaphore
Semaphore是一种基于计数的信号量,在定义信号量对象时可以设定一个阈值,基于该阈值,多个线程竞争获取许可信号,线程竞争到许可信号后开始执行具体的业务逻辑,业务逻辑在执行完成后释放该许可信号。在许可信号的竞争队列超过阈值后,新加入的申请许可信号的线程将被阻塞,直到有其它许可信号被释放。
Semaphore对锁的申请和释放跟ReentrantLock类似,通过acquire方法和release方法来获取和释放许可信号资源。Semaphore.acquire方法默认和ReentrantLock.lockInterruptily方法的效果一样,为可响应中断锁,也就是说在等待许可信号资源的过程中可以被Thread.interrupt方法中断而取消对许可信号的申请。
此外,Semaphore也实现了可轮询的锁请求、定时锁的功能,以及公平锁与非公平锁的机制。对公平锁和非公平锁的定义在构造函数中设定。
2.6 ReadWriteLock
为了提高性能,Java提供了读写锁。读写锁分为读锁和写锁两种,多个读锁不互斥,读写与写锁互斥。在读的地方使用读锁,在写的地方使用写锁,在没有写锁的情况下,读是无阻塞的。
一般做法是分别定义一个读锁和一个写锁,在读取共享数据时使用读锁,在使用完成后释放读锁,在写共享数据时使用写锁,在使用完成后释放写锁。
2.7 CycleBarrier
CycleBarrier(循环屏障)是一个同步工具,可以实现让一组线程等待至某个状态之后再全部同时执行。在所有等待线程被释放之后,CycleBarrier可以被重用。CycleBarrier的等待状态叫作Barrier状态,在调用await方法后,线程就处于Barrier状态。