Java 锁 待整理
Java中的锁
java中的锁主要用于保障线程在多并发情况下数据的一致性。
通常需要在使用对象或者调用方法之前加锁,这时如果有其他线程也需要使用该对象或者该方法,则首先要获得锁,如果某个线程发现锁已经被其它线程使用,就会进入锁池(Lock Pool)等待锁的释放,直到其他线程执行完成并释放锁,该线程才有机会获取锁并执行操作。这样就保证了同一时刻只有一个线程持有该对象的锁并修改对象,从而保证数据的安全。
| 序号 | 角度 | 分类 |
|---|---|---|
| 1 | 乐观和悲观 | 乐观锁、悲观锁 |
| 2 | 获取资源的公平性 | 公平锁、非公平锁 |
| 3 | 是否共享资源 | 共享锁、独占锁 |
| 4 | 锁的状态 | 偏向锁、轻量级锁、重量级锁 |
1、乐观锁
乐观锁采用乐观的思想处理数据,认为程序中的并发情况不那么严重,在每次读取数据时都认为别人不会修改该数据,所以不会上锁,但是在更新的时候会判断在此期间别人有没有更新这个数据,以防止自己的更新会覆盖别人的更新,通常采用在写时先读出当前版本号的方式,即使用版本号(version)等机制来实现实现乐观锁。
工作流程:
- 读取要更新的记录和version字段;
- 修改记录;
- 在保存记录之前,再次读取这个记录的version字段,与修改记录前读取的version字段进行比对;
- 如果两个version相等,说明从读取记录到修改记录过程中,记录没有被其他人修改过,那么将version字段+1并且保存修改后的记录;如果两个version不同,说明在这期间version被修改过,则重复进行读取、比较、保存操作。
Java中的乐观锁大部分是通过CAS(Compare And Swap,比较和交换)操作实现。
CAS是一种原子性操作,指比较并交换,CAS算法CAS(V,E,N)包含三个参数,V表示要更新的变量,E表示预期的指,N表示新值。在且仅在V值等于E值时,才会将V值设置为N,如果V值和E值不相等,说明已经有其他线程做了更新,则本次修改失败。失败的线程可以再次尝试修改,也可以放弃操作。
缺点:
1、易造成CPU开销大
在并发量大的情况下,很容发生多个线程反复尝试更新某一个变量,却又一直更新不成功,无疑会给CPU带来很大的压力
2、不能保证代码块的原子性
CAS机制所保证的只是一个变量的原子性操作,而不能保证整个代码块的原子性,比如需要保证扫个3个变量共同进行原子性的更新,就不得不使用synchronized
3、ABA问题
当一个值从A变成B,又更新回A,普通的CAS机制察觉不出来数据被修改过,利用版本号(version)机制可以有效解决ABA问题
2、悲观锁
悲观锁采用悲观思想处理数据,在每次读写数据时都认为别人会修改数据,所以每次在读写数据时都会加锁,当别人想要读写这个数据时就会阻塞、等待,直到获取锁后才能执行操作。
Java中的悲观锁大部分基于AQS(Abstract Queued Synchronized,抽象的队列同步器)架构实现。AQS定义一套多线程访问共享资源的同步框架,许多同步类的实现都依赖于它。该框架下的锁会先尝试以CAS乐观锁去获取锁,如果获取不到,则会转为悲观锁(例如RetreenLock)。
3、自旋锁
自旋锁认为:如果持有锁的线程将在很短的时间内释放锁,那么等待竞争锁的线程就不需要做内核态和用户态之间的切换进入阻塞、挂起状态,只需要等一等(也叫做自旋),在等待持有锁的线程释放锁后即可立即获取锁,这样就避免了用户线程在用户态和内核态之间的频繁切换而导致的时间消耗。
线程在自旋时会占用CPU,在线程长时间自旋获取不到锁时,将会产生CPU的浪费,甚至有时线程会永远无法获取锁而导致CPU资源被永久占用,所以需要设定一个自旋等待的最大时间。在县城执行的时间超过自旋等待的最大时间后,线程会退出自选模式并释放其持有的锁。
3.1、自旋锁的优点
优点:对于占用锁的时间非常短或者锁竞争不激烈的代码块来说性能大幅度提升,因为自旋的CPU耗时明显少于线程阻塞、挂起、再唤醒时两次CPU上下文切换所用的时间。即可以减少CPU上下文的切换的时间开销,例如在多个处理器的机器上,两个以上的线程并行执行,可以让后面请求锁的线程原地自旋,等待持有锁的线程释放锁,自旋的线程很快就获取到了锁,提高了执行效率。
缺点:在持有锁的线程占用锁时间过长或锁的竞争过于激烈时,线程在自旋过程中会长时间获取不到锁资源,将引起CPU浪费。所以在系统中有复杂锁依赖的情况下不适合采用自旋锁。
3.2、自旋锁的时间阈值
自旋锁用于使当前线程占着CPU资源不释放,等到自旋时获取锁资源后立即执行相关操作。
JDK 1.5为固定的自旋时间,JDK 1.6引入了适应性自旋锁。其自旋时间不再是固定值,而是由上一次在同一个锁上的自旋时间及锁的持有者的状态来决定的,可基本认为是一个线程上下文切换的时间是一个锁自旋的最佳时间。
4、synchronized
synchronized关键字用于为Java对象、方法、代码块提供线程安全操作。synchronized属于独占式的悲观锁,同时属于可重入锁。
在使用synchronized修饰对象时,同一时刻只能有一个线程对该对象进行访问;在synchronized修饰方法、代码块时,同一时刻只能有一个线程执行该方法或代码块,其他线程只有等待当前线程执行完毕并释放锁资源后才能访问该对象或执行同步代码块。
Java中每个对象都有个monitor对象,加锁就是在竞争monitor对象。对代码块加锁是通过在前后分别加上monitorenter和monitorexit指令实现的,对方法是否加锁是通过一个标记位来判断的。
4.1、synchronized的作用范围
- 作用于成员变量和非静态方法时,锁住的是实例对象;
- 作用于静态方法时,锁住的是当前类的Class对象;
- 作用于代码块时,锁住的是代码块中配置的对象;
4.2、synchronized的用法简介
synchronized作用于成员变量和非静态方法时,锁住的是调用该方法的实例对象;
synchronized作用于静态方法时,锁住的是当前类的Class对象,因为static方法是属于Class对象的,并且Class类的相关数据在JVM中是全局共享的,因此静态方法锁相当于类的一个全局锁,会锁住所有调用该静态方法的线程。
synchronized作用于代码块,锁住的是代码块中配置的对象。
4.3、synchronized的实现原理
1、synchronized在尝试获取锁时首先自旋,如果通过自旋也没有获取到锁,则将该线程放入到锁竞争队列(ContentionList)中;
2、为了防止锁竞争时尾部的元素被大量的并发线程进行CAS访问而影响性能(???),竞争到锁资源的线程(Owner线程)会在释放锁资源时将锁竞争队列(ContentionList)中的部分线程移动到竞争候选列表(EntryList)中,并指定竞争候选列表中的某个线程(一般为最先进入的线程)为竞争候选者线程(OnDeck线程)。
3、Owner线程并没有直接把锁传递给OnDeck线程,而是把锁竞争的权利交给OnDeck线程,让OnDeck重现竞争锁。在Java中把该行为称为“竞争切换”,该行为牺牲了公平性,但提高了性能
4、获取到锁的OnDeck线程会变为Owner线程,而未获取到锁的线程任然停留在竞争候选列表(EntryList)中
5、竞争到锁资源的线程(Owner线程)在被wait()方法阻塞后,会被转移到等待集合(WaitSet)中,直到某个时刻被notify()或notifyAll()方法唤醒,会再次进入竞争候选列表(EntryList)中。
6、锁竞争队列(ContentionList)、竞争候选列表(EntryList)、等待集合(WaitSet)中的线程均为阻塞状态,该阻塞是由操作系统来完成的
7、竞争到锁资源的线程(Owner线程)在执行完毕后释放锁并变为!Owner线程,此时该线程处于生命周期中的死亡状态。
5、ReentranLock
ReentranLock继承了Lock接口并实现了在接口中定义的方法,是一个可重入的独占锁。ReentranLock通过自定义队列同步器(Abstract Queued Synchronized,AQS)来实现锁的获取与释放。
独占锁指该锁在同一时刻只能被一个线程获取,而获取锁的其他线程只能在同步队列中等待;可重入锁也叫递归锁,指该锁能够支持一个线程对同一个资源执行多次加锁操作。
ReentranLock支持公平锁和非公平锁的实现。公平指线程竞争锁的机制是公平的,而非公平指不同的线程获取锁的机制是不公平的,非公平锁牺牲了公平性但提高了性能。
ReentranLock不但提供了synchronized对锁的操作功能,还提供了诸如可响应中断锁、可轮询锁请求、定时锁等避免多线程死锁的方法。
5.1、ReentrantLock的用法
ReentrantLock有显式的操作过程,何时加锁、何时释放锁都在程序员的控制下。
//Step 1:定义一个ReentrantLock
public static ReentrantLock lock = new ReentrantLock();
public static int i=0;
public void run(){
for(int j=0;j<10;j++){
lock.lock();//Step 2:加锁
lock.lock();//可重入锁
try{
i++;
}finally{
lock.unlock();//Step 3:释放锁
lock.unlock();//可重入锁
}
}
}
注意:获取锁和释放锁的次数要相同,如果释放锁的次数多余获取锁的次数,Java就会抛出java.lang.IllegalMonitorStateException异常;如果释放锁的次数少于获取锁的次数,该线程就会一直持有该锁,其他线程将无法获取锁资源。
5.2、ReentrantLock如何避免死锁:响应中断、可轮询锁、定时锁
(1)响应中断:在synchronized中如果有一个线程尝试获取一把锁,则其结果是要么获取锁继续执行,要么保持等待。ReentrantLock还提供跟了可响应中断的可能,即在等待锁的过程中,线程可以根据需要取消对锁的请求,(例如两个线程之间产生了死锁,在等待一定时间后,其中一个线程主动中断并释放锁)
(2)可轮询锁:通过boolean tryLock()获取锁。如果有可用锁,则获取该锁并返回true,如果无可用锁,则立即返回false
(3)定时锁:通过boolean tryLock(long time,TimeUnit unit) throws InterruptedException获取定时锁。如果在给定的时间内获取到了可用锁,且当前线程未被中断,则获取该锁并返回true。如果在给定的时间内获取不到可用锁,则禁用当前线程,并在发生以下三种情况之前,该线程一直处于休眠状态。
1、当前线程获取到了可用锁并返回true
2、当前线程在进入此方法时设置了该线程的中断状态,或者当前线程在获取锁时被中断,则抛出InterruptedException,并清除当前线程的已中断状态
3、当前线程获取锁的时间超过了指定的等待时间,则将返回false。如果设定的时间小于等于0,则该方法将完全不等待。
5.3、Lock接口的主要方法
- void lock():给对象加锁,如果锁正在被其他线程使用,则将阻塞等待,直到当前线程获取到该锁;
- boolean tryLock():试图给对象加锁,如果锁未被其他线程使用,则获取锁并返回true,否则返回false。tryLock()和lock()的区别在于前者只是“试图”获取锁,如果没有可用锁,就会立即返回。lock()在获取不到锁时会一直等待,直到获取到可用锁。
- tryLock(long timeout,TimeUnit unit):创建定时锁,如果在给定的等待时间内有可用锁,则获取该锁
- void unlock():释放当前线程锁持有的锁。锁只能由持有者释放,如果当前线程并不持有锁却执行该方法,则抛出异常。
- getHoldCount():查询当前线程保持此锁的次数,也就是此线程执行lock()方法的次数。
- getQueueLength():返回等待获取此锁的线程估计数,比如启动5个线程,1个线程获得锁,此时返回4。
5.4、公平锁与非公平锁
ReentrantLock支持公平锁和非公平锁的两种方式。公平锁指锁的分配和竞争机制是公平的,即遵循先到先得的原则。非公平锁指JVM遵循随机、就近原则分配锁的机制。ReentrantLock通过在构造函数ReentrantLock(boolean fair)中传递不同的参数来定义不同类型的锁,默认实现的是非公平锁。因为非公平锁的执行效率明显高于公平锁。(公平锁中,后来的线程想要获得锁,即便锁空闲,也要先检查有没有其他线程在等待,如果有,自己要挂起,加到队列后面,然后唤醒队列最前面的线程;而非公平锁中,后来的线程可能直接获取到锁,有一定几率减少线程挂起的开销)。
5.5、tryLock()、lock() 和 lockInterruptibly() 的区别
三者的区别如下:
- tryLock():若有可用锁,则获取该锁并返回true,否则返回false,不会有延迟或者等待;tryLock(long timeout,TimeUnit unit):可以增加时间限制,如果超过了指定的时间还没获得锁,则返回false;
- lock():若有可用锁,则获取该锁并返回,否则会一直等待直到获取可用锁;
- 在锁中断时lockInterruptibly()方法会抛出异常,lock()不会
6、synchronized 和 ReentrantLock 的比较
共同点:
1、都用于控制多线程对共享对象的访问
2、都是可重入锁
3、都保证了可见性和互斥性,可见性(一个线程对共享变量值的修改,能够及时的被其他线程看到)
不同点:
1、ReentrantLock显式获取和释放锁;synchronized隐式获取和释放锁。在使用ReentrantLock时,为了避免程序中出现异常而无法正常释放锁,必须在finally语句块中执行释放锁操作
2、ReentrantLock可响应中断、可轮回、为处理锁提供了更多的灵活性
3、ReentrantLock是API级别的,synchronized是JVM级别的
4、ReentrantLock默认是非公平锁,但可以定义为公平锁,synchronized是非公平锁
5、ReentrantLock通过Condition可以绑定多个条件
6、二者的底层实现不一样:synchronized是同步阻塞,采用的是悲观并发策略;ReentrantLock采用的是同步非阻塞,采用的是乐观并发策略
7、Lock 是一个接口,而 synchronized 是 Java 中的关键字,synchronized 是内置的语言实现
8、通过 Lock 可以知道有没有成功获取锁,而 synchronized却无法办到
9、Lock 可以提高多个线程进行读操作的效率,也就是实现读写锁等
10、synchronized 在发生异常时,会自动释放线程占有的锁,因此不会导致死锁现象发生;而 Lock 在发生异常时,如果没有主动通过 unLock()去释放锁,则很可能造成死锁现象,因此使用 Lock 时需要在 finally语句块中释放锁
11、Lock 可以让等待锁的线程响应中断,而 synchronized 却不行,使用 synchronized 时,等待的线程会一直等待下去,不能够响应中断
7、Semaphore
Semaphore是一种基于计数的信号量,在定义信号量对象时可以设定一个阈值,基于该阈值,多个线程竞争获取许可信号,线程竞争到许可信号后开始执行具体的业务逻辑,业务逻辑在执行完成后释放许可信号。在许可信号的竞争队列超过阈值后,新加入的申请许可信号的线程将被阻塞,直到有其他许可信号被释放。Semaphore的基本用法如下:
//Step 1:创建一个计数阈值为5的信号量对象,即只能有5个线程同时访问
Semaphore semp=new Semaphore(5);
try{
//Step 2:申请许可
semp.acquire();
try{
//Step 3:执行业务逻辑
}catch(Exception e){
}finally{
//Step 4:释放许可
semp.release();
}
}catch(InterruptedException e){
}
Semaphore对锁的申请和释放和ReentrantLock类似,通过acquire()和release()方法来获取和释放许可信号资源。acquire()方法默认和lockInterruptibly()方法的效果一样,为可响应中断锁,也就是说在等待许可信号量资源的过程中可以被interrupt()方法中断而取消对许可信号的申请。
此外,Semaphore也实现了可轮询的锁请求、定时锁的功能,以及公平锁与非公平锁的机制。对公平与非公平锁的定义在构造函数中设定。
Semaphore的锁释放操作也需要手动执行,因为,为了避免线程因执行异常而无法正常释放锁,释放锁的操作必须在finally代码块中完成。
Semaphore也可以用于实现一些对象池、资源池的构建,比如静态全局对象池、数据库连接池等。此外,也可以创建计数为1的Semaphore,将其视作一种互斥锁的机制(也叫二元信号量,表示两种互状态),同一时刻只能有一个线程获得该锁。
8、AtomicInteger
在多线程程序中,诸如++i或i++等运算不具有原子性,因此不是安全的线程操作。可以通过synchronized或ReentrantLock将该操作变为一个原子操作,但是synchronized和ReentrantLock均属于重量级锁,因此JVM为此类原子操作提供了一些原子操作同步类,使得同步操作(线程安全操作)更加方便、高效,例如AtomicInteger。
AtomicInteger为Integer类提供了原子操作,常见的原子操作类还有AtomicBoolean、AtomicILong、AtomicReference等,它们的实现原理相同,区别在于运算对象的类型不同。
AtomicInteger的性能通常是synchronized和ReentrantLock的好几倍。具体的用法如下:
class AtomicIntegerDemo implements Runnable{
//1、定义一个原子操作类
static AtomicInteger safeCounter = new AtomicInteger(0);
public void run(){
for(int m=0;m<1000000;m++){
//2、对原子操作数执行自增操作
safeCounter.getAndIncrement();
}
}
}
实现原理:AtomicInteger是对int类型的一个封装,提供原子性的访问和更新操作,其原子性操作的实现是基于CAS技术。
9、可重入锁
可重入锁也叫递归锁,指在同一线程中,在外层函数获取到该锁之后,内层的递归函数依然可以继续获取该锁,在Java环境下,ReentrantLock 和 synchronized 都是可重入锁。
10、公平锁和非公平锁
公平锁(Fair Lock):遵循先到先得的原则来分配锁,指在分配前检查是否有线程在排队等待锁,优先将锁分配给排队时间最长的线程,新进入的线程在排到队尾
非公平锁(Nonfair Lock):指在分配锁时不考虑线程排队等待的情况,直接尝试获取锁,在获取不到锁时,再到队尾等待
11、读写锁:ReadWriteLock
如果系统要求共享数据可以同时支持很多线程并发读,但不能支持很多线程并发写,那么使用读锁能很大程度地提高效率;如果系统要求共享数据在同一时刻只能有一个线程在写,且在写的过程中不能读取该共享数据,则需要使用写锁,以防止其他线程来读或者写。
即读写锁中,读读不互斥、读写互斥、写写互斥
12、共享锁和独占锁
独占锁:也叫互斥锁,每次只允许一个线程持有该锁,ReentrantLock为独占锁的实现
共享锁:允许多个线程同时获取该锁,并发访问共享资源。ReentrantReadWriteLock中的读锁为共享锁的实现
13、重量级锁和轻量级锁
重量级锁是基于操作系统的互斥量(Mutex Lock)而实现的锁,会导致进程在用户态与内核态之间切换,相对开销较大。
synchronized在内部基于监视器锁(Monitor)实现,监视器锁基于底层的操作系统的互斥量(Mutex Lock)实现,因此synchronized属于重量级锁。重量级锁需要在用户态与内核态之间切换,所以synchronized的运行效率不高。
JDK在1.6版本后,为了减少获取锁和释放锁所带来的性能消耗以及提高性能,引入了轻量级锁和偏向锁。
轻量级锁的核心设计是在没有多线程竞争的前提下,减少重量级锁的使用以提高系统性能。轻量级锁适用于线程交替执行同步代码块的情况(即互斥操作),如果同一时刻有多个线程访问同一个锁,则会导致轻量级锁膨胀为重量级锁。
14、偏向锁
偏向锁用于在某个线程获取某个锁之后,消除这个线程锁重入的开销,看起来似乎是这个线程得到了该锁的偏向(偏袒)。
偏向锁的主要目的是在同一个线程多次获取某个锁的情况下尽量减少轻量级锁的执行路径,因为轻量级锁的获取及释放需要多次CAS(Compare and Swap)原子操作,而偏向锁只需要在切换ThreadID时执行一次CAS原子操作,因此可以提高锁的执行效率。
在出现多线程竞争锁的情况时,JVM会自动撤销偏向锁,因此偏向锁的撤销操作的耗时必须小于节省下来的CAS原子操作的耗时。
综上所述,轻量级锁用于提高多个线程交替执行同步块时的性能,偏向锁则在某个线程交替执行同步块时进一步提高性能。
锁的状态有4种:无锁、偏向锁、轻量级锁和重量级锁。随着锁竞争越来越激烈,锁可能从偏向锁升级到轻量级锁,再升级到重量级锁,但是再Java中锁只会单向升级,不会降级。
15、分段锁
分段锁并非一种实际的锁,而是一种锁设计思想,用于将数据分段并在每个分段上都单独加锁,八所进一步细粒度化,以提高并发效率。ConcurrentHashMap在内部就是使用分段锁实现的。
16、同步锁与死锁
在多个线程同时被阻塞时,他们之间若相互等待对方释放锁资源,就会出现死锁。为了避免出现死锁,可以为锁操作添加超时时间,在线程持有锁超时后自动释放该锁。
17、如何进行锁优化
1、减少锁持有的时间:只在有线程安全要求的程序上加锁来尽量减少同步代码块对锁的持有时间
2、减小锁粒度:将单个耗时较多的锁操作拆分为多个耗时较少的锁操作来增加锁的并发度,减少同一个锁上的竞争。在减少锁的竞争后,偏向锁、轻量级锁的使用效率才会提高。减小锁粒度最典型的案例就是ConcurrentHashMap中的分段锁。
3、锁分离:根据不同的应用场景将锁的功能进行分离,以应对不同的变化,最常见的锁分离思想就是读写锁(ReadWriteLock),它根据锁的功能将锁分离成读锁和写锁,这样读与读不互斥,读写互斥,写写互斥,既保证了线程的安全性,又提高了性能。操作分离思想可ui进一步延伸为只要操作互不影响,就可以进一步拆分,比如LinkedBlockingQueue从头部取出数据,从尾部加入数据。
4、锁粗化:为了保障性能,会要求尽可能将锁的操作系笑话以减少线程持有锁的时间,但是如果锁分的太细,有可能导致系统频繁获取锁和释放锁,反而影响性能的提升。在这样的情况下,建议将关联性强的锁操作集中起来处理,以提高系统整体的效率。
5、锁消除:检查并消除不必要的锁来提高系统的性能。