Java并发编程(三)——锁
1、锁的作用
Java中的锁主要用于保障多并发线程情况下数据的一致性。
在多线程编程中为了保障数据的一致性,通常需要在使用对象或者方法之前加锁,这时如果有其他线程也需要使用该对象或方法。则首先要获得锁,如果某个线程发现锁正在被其他线程使用,就会进入阻塞队列等待锁的释放,直到其他线程执行完成并释放锁,该线程才有机会再次获取锁进行操作。
2、乐观锁
乐观锁采用乐观的思想处理数据,在每次读取数据时都认为别人不会修改该数据,所以不会上锁,但在更新时会判断在此期间别人有没有更新该数据。
通常采用在写时先读出当前版本号然后加锁的方法。具体过程为:比较当前版本号与上次的版本号,如果版本号一致,则更新,如果不一致,则重复进行读、比较、写操作。
Java中的乐观锁是通过CAS(比较和交换)操作实现的。
CAS是一种原子更新操作,在对数据操作之前会比较当前值跟传入的值是否一样,如果一样则更新,否则不执行更新操作,直接返回失败状态。
3、悲观锁
悲观锁采用悲观思想处理数据,在每次读取数据时都认为别人会修改数据,所以每次在读取数据时都会上锁,别的线程想读写数据时就会阻塞、等待直到拿到锁。
Java中的悲观锁大部分基于AQS(抽象的队列同步器)架构实现。该框架下的锁会先尝试以CAS乐观锁去获取锁,如果获取不到,则会转为悲观锁。许多同步类的实现都依赖于AQS,例如Synchronized、ReentrantLock、CountDownLatch、Semaphore等。
4、自旋锁
如果持有锁的线程能在很短的时间内释放锁的资源,那么那些等待竞争锁的线程就不需要做内核态和用户态的切换进入阻塞、挂起状态,只需要等一等(自旋),等待持有锁的线程释放锁后即可获得锁,避免了用户线程在内核状态的切换上导致的锁时间消耗。
4.1 自旋锁的优缺点
- 优点:自旋锁可以减少CPU上下文的切换,对于占用锁的时间非常短或锁竞争不激烈的代码块来说性能大幅度提升。
- 缺点:在持有锁的线程占用锁时间过长或锁竞争过于激烈时,线程在自旋过程中会长时间的得不到锁资源,将引起CPU的浪费。
4.2 自旋锁的时间阈值
JDK不同的版本所采用的自旋周期不同,JDK1.5为固定的时间,JDK1.6引入适应性自旋锁(不再是固定值)。适应性自旋锁的自旋时间是由上一次在同一个锁上的自旋时间及锁的拥有着的状态来决定的。一般为一个线程上下文切换的时间。
5、synchronized
synchronized关键字用于Java 对象、方法、代码块提供线程安全的操作。synchronized属于独占锁、悲观锁、可重入锁、非公平锁。
在使用synchronized修饰对象时,同一时刻只能有一个线程对该对象进行访问;在synchronized修饰方法、代码块时,同一时刻只能有一个线程执行该方法或代码块,其他线程只有等待当前线程执行完毕并释放锁资源后才能访问该对象或执行代码块。
5.1 synchronized的作用范围
- synchronized作用于成员变量和非静态方法时,锁住的是对象的实例,即this对象。
- synchronized作用于静态方法时,锁住的是Class实例,因为静态方法属于Class而不属于对象。
- synchronized作用于一个代码块时,锁住的是所有代码中的配置对象。
5.2 synchronized的实现原理
synchronized内部包含6个区域:ContentionList、EntryList、WaitSet、OnDeck、Owner、!Owner。
- ContentionList:锁竞争队列,所有请求锁的线程都被放在竞争队列中。
- EntryList:竞争候选列表,在ContentionList中有资格成为候选者来竞争锁资源的线程被移动到EntryList中。
- WaitSet:等待集合,调用wait方法后被阻塞的线程将被放在WaitSet中。
- OnDeck:竞争候选者,在同一时刻最多只有一个线程在竞争锁资源,该线程的状态被称为OnDeck。
- Owner:竞争到锁资源的线程被称为Owner状态线程。
- !Owner:在Owner线程释放锁后,会从Owner的状态变成!Owner。
如图所示:
①synchronized在收到新的锁请求时首先自旋(自旋可以直接抢占OnDeck线程的锁资源),如果通过自旋也没有获取锁资源,则将其放入ContentionList中。
②为了防止锁竞争时ContentionList尾部元素被大量的并发线程进行CAS访问而影响性能,Owner线程会在释放锁资源时将ContentionList中的部分线程移动到EntryList中。
③Owner线程会指定EntryList中的某个线程(一般是最先进入的线程)为OnDeck线程。
④Owner线程并没有直接把锁传递给OnDeck线程,而是把锁竞争的权利交给OnDeck,让OnDeck线程重新竞争锁(这种行为在Java中叫“竞争切换”)。获取到锁资源的OnDeck线程会变为Owner线程,而未获取到锁资源的线程仍然停留在EntryList中。
⑤Owner线程在被wait方法阻塞后,会被转移到WaitSet队列中,直到某个时刻被notify方法或者notifyAll方法唤醒,会再次进入EntryList中。
⑥Owner线程在执行完毕后会释放锁资源并变成!Owner状态。
注:JDK1.6对synchronized进行了优化,引入自旋、锁消除、锁粗化、轻量级锁及偏向锁。所可以从偏向锁升级到轻量级锁在升级到重量级锁。
6、ReentrantLock
ReentrantLock是一个可重入的独占锁。通过自定义的AQS(队列同步器)来实现锁的获取与释放。
ReentrantLock继承了Lock接口并实现了接口中的方法。
ReentrantLock支持公平锁和非公平锁的实现。
6.1 ReentrantLock的用法
使用流程:定义一个ReentrantLock,在需要加锁的地方通过lock方法加锁,等资源使用完成后再通过unlock方法释放锁。注意,获取锁和释放锁的次数要相同,如果释放锁的次数大于获取锁的次数,会抛出java.lang.IllegalMonitorStateException异常;如果获取锁的次数大于释放锁的次数,该线程会一直持有该锁,其他线程无法获取锁资源。
6.2 ReentrantLock避免死锁的方法:响应中断、可轮询锁、定时锁
(1)响应中断:在等待锁的过程中,线程可以根据需要取消对锁的请求。
while(true){ if(System.currentTimeMillis - time >= 3000){ thread.interrupt(); } }
(2)可轮询锁:通过boolean tryLock()获取锁,如果有可用锁,则获取该锁并返回true,如果无可用锁,则立即返回false。
(3)定时锁:通过boolean tryLock(long time, TimeUnit unit) throws InterruptedException获取定时锁。
如果在给定时间内获取到了可用锁,且当前线程未被中断,则获取该锁并返回true。如果在给定时间内获取不到可用锁,将禁用当前线程,并在发生以下情况之前,该线程一直处于休眠状态。
- 当前线程获取到了可用锁并返回true。
- 当前线程在进入此方法时设置了该线程的中断状态,或者当前线程在获取锁时被中断,则抛出InterruptedException,并清除当前线程的已中断状态。
- 当前线程获取锁的时间超过了指定的等待时间,则返回false。如果设定的时间小于等于0,则完全不等待。
6.3 Lock接口的主要方法
(1)void lock():给对象上锁。
(2)boolean tryLock():试图给对象加锁。
(3)tryLock(long time, TimeUnit unit):创建定时锁。
(4)void unlock():释放当前线程所持有的锁。
(5)isLock():判断此锁是否被线程占用。
(6)lockInterruptibly():如果当前线程未被中断,则获取该锁。
6.4 tryLock、lock和lockInterruptibly的区别
- tryLock若有可用锁,则获取该锁并返回true,否则返回false;tryLock(long time, TimeUnit unit)可以增加时间限制,超过指定时间没有获得锁,返回false。
- lock若有可用锁,则获取该锁并返回true,否则会一直等待直到获取可用锁。
- lockInterruptibly在锁中断时会抛出异常,lock不会。
7、synchronized和ReentrantLock的比较
共同点:
- 都是可重入锁
- 都保证了可见性和互斥性
- 都用于控制多线程对共享对象的访问
不同点:
- ReentrantLock显示获取和释放锁,ReentrantLock必须在finally控制块中进行解锁操作;synchronized隐式获取和释放锁。
- ReentrantLock是API级别的;synchronized是JVM级别的。
- ReentrantLock可以定义公平锁;synchronized只有非公平锁。
- 底层实现不同:ReentrantLock继承于Lock,Lock是同步非阻塞,采用的是乐观并发策略;synchronized是同步阻塞,采用的是悲观并发策略。
- Lock是一个接口;synchronized是Java中的关键字,synchronized是内置的语言实现的。
- Lock可以知道有没有成功获取锁;synchronized无法感知是否成功获取到锁。
- Lock可以通过分别定义读写锁提高多个线程读操作的效率。
- ReentrantLock可响应中断、可轮询,提高处理锁的灵活性。
- ReentrantLock通过Condition可以绑定多个条件。
8、Semaphore
Semaphore是一种基于计数的信号量,在定义信号量对象时可以设定一个阈值,基于该阈值,多个线程竞争获取许可信号,线程竞争到许可信号后开始执行具体业务逻辑,业务逻辑执行完成后释放该许可信号。在许可信号的竞争队列超过阈值后,新加入的申请许可信号的线程将被阻塞,直到其他许可信号被释放。
Semaphore semp = new Semaphore(5); try{ semp.acquire(); try{ //执行业务逻辑 }catch(Exception e){ }finally{ semp.release(); } }catch(InterruptedException e){ }
Semaphore实现了可轮询的锁请求、定时锁的功能,以及公平锁和非公平锁的机制。
9、AtomicInteger
AtomicInteger为提供原子性操作的Integer的类,是安全的线程操作,性能要高于synchronized好几倍。还可以通过AtomicReference<V>将一个对象的所有操作都转化为原子操作。
static AtomicInteger safeCounter = new AtomicInteger(0); public void run(){ for(int m = 0; m<100000;m++){ safeCounter.getAndIncrement(); } }
10、可重入锁
可重入锁也叫递归锁,指在同一线程中,在外层函数获取到该锁后,内层的递归函数仍然可以继续获取该锁。
ReentrantLock和synchronized都是可重入锁。
11、公平锁和非公平锁
- 公平锁:在分配锁前检查是否有线程在排队等待获得该锁,优先将锁分配给排队时间最长的线程。
- 非公平锁:在分配锁时不考虑线程排队等待的情况,直接尝试获取锁,在获取不到锁时再排列到队尾等待。
因为公平锁需要在多核的情况下维护一个锁线程等待队列,基于该队列进行锁的分配,因此效率比非公平锁低很多。
12、读写锁
读写锁分为读锁和写锁,多个读锁不互斥,读锁和写锁互斥,写锁和写锁互斥。
public class RWLock { private final ReentrantReadWriteLock rwLock = new ReentrantReadWriteLock(); private final Lock readLock = rwLock.readLock(); private final Lock writeLock = rwLock.writeLock(); }
13、共享锁和独占锁
- 独占锁:也叫互斥锁,每次只允许一个线程持有该锁,ReentrantLock为独占锁的实现。
- 共享锁:允许多个线程同时获取该锁,并发访问共享资源。读锁为共享锁的实现。
ReentrantReadWriteLock的加锁和解锁操作最终都调用内部类Sync提供的方法。Sync对象通过继承AQS进行实现。AQS的内部类Node定义了两个常量SHARED和EXCLUSIVE,分别标识AQS队列中等待线程的锁获取模式。
14、重量级锁、轻量级锁和偏向锁
14.1 重量级锁
重量级锁是基于操作系统的互斥量(Mutex Lock)而实现的锁,会导致进程在用户态与内核态之间切换,开销较大。
synchronized在内部基于监视器锁(Monitor)实现,监视器锁基于底层的操作系统的Mutex Lock实现。
14.2 轻量级锁
轻量级锁是在没有多线程竞争的前提下,减少重量级锁的使用以提高系统性能。轻量级锁适用于线程交替执行同步代码块的情况(互斥操作),如果资源竞争非常激烈,会升级为重量级锁。
14.3 偏向锁
偏向锁用于在某个线程获取某个锁之后,消除这个线程锁重入的开销,JVM利用CAS在对象头上设置线程ID,表示这个对象偏向于当前线程。
偏向锁是为了在资源没有被多线程竞争的情况下尽量减少锁带来的性能开销。在锁对象的对象头中有一个ThreadId字段为空,当第一个线程访问锁时,如果该锁没有被其他线程访问过(ThreadId为空),那么JVM让其持有偏向锁,并将ThreadId字段的值设置为该线程的ID。当下一次获取锁时,会判断当前线程的ID是否与锁对象的ThreadId一致。如果一致,那么该线程不会再重复获取锁,从而提高了程序的运行效率。
15、分段锁
分段锁是一种思想,用于将数据分段并在每一分段上都单独加锁,把锁细粒度化,以提高并发效率。
ConcurrentHashMap在内部就是使用分段锁(Segment)实现的,如图所示。
16、同步锁与死锁
再有多个线程同时被阻塞时,它们之间若相互等待对方释放锁资源,就会出现死锁。
为了避免出现死锁,可以为锁操作添加超时时间,在线程持有锁超时后自动释放该锁。
17、如何进行锁优化
(1)减少所持有时间:只在有线程安全要求的程序上加锁,尽量减少同步代码块对锁的持有时间。
(2)减少锁粒度:单个耗时较多的锁操作拆分为多个耗时较少的锁操作来增加锁的并行度。
(3)锁分离:只要操作互不影响,就可以进行拆分。比如读写锁可分离为读锁和写锁、LinkedBlockingQueue从头部取出数据,从队尾加入数据。
(4)锁粗化:如果锁分的过于细,会导致系统频繁获取锁和释放锁,影响性能的提升。可以将关联性强的锁操作集中起来处理,以提高系统整体的效率。
(5)锁消除:消除不必要的锁来提高系统的性能。