Java并发学习笔记(一)锁
前言
在多线程环境中,如果对共享变量进行非原子的操作,就很可能出现线程安全问题,为了维护多线程环境下操作共享变量的数据一致性,通常我们就需要用到锁,在Java中,关于锁的概念很多,在学习的过程中整理了一部分简单给大家分享一下。
锁名词概念
- 死锁、活锁、饥饿锁、无锁
- 悲观锁、乐观锁
- 偏向锁、轻量级锁、重量级锁
- 重入锁
- 非公平锁、公平锁
- 独占锁、共享锁
- 读写锁
以上锁相关名词有的指锁的状态,有的是锁的设计思想,还有一些锁实现相关的特性等等,一些名词之间存在强关联,不是很好分类,下面逐步进行展开说明。
死锁
死锁原因
多个线程竞争共享资源,由于抢占顺序不一致导致线程间互相等待对方持有的资源释放,导致线程永久阻塞等待。
死锁条件
互斥条件:共享资源只能同时被一个线程占用
请求与保持条件:线程1已经取得共享资源X,在等待共享资源Y的时候,不释放共享资源X
不可剥夺条件:线程1占有的共享资源,其他线程不能强行抢占
循环等待条件:线程1等待线程2占有的资源,线程2等待线程1占有的资源,就是互相等待
如何避免死锁
不使用锁或者不使用两把及以上的锁
必须使用两把及以上的锁时,保证整个应用获取锁的顺序一致
尝试获取具有超时释放的锁,比如tryLock
当发生Java-Level的死锁时,重启程序干掉进程或者线程,比如数据库事务
如何定位死锁
- jps及jstack命令定位,打印线程快照,查看堆栈信息,寻找Java-level deadlock,查看等待资源和持有资源。
- 利用jconsole工具查看线程状态
活锁
死锁是线程互相等待对方持有的资源,活锁是多个线程互相谦让,主动将资源释放给其他线程使用,导致资源在多个线程间切换但是没法执行。
饥饿锁
饥饿锁指的是线程因为种种原因无法获得所需要的资源而导致一直无法执行,饥饿锁状态与死锁状态不同,死锁是造成了永久等待,而饥饿锁是满足资源条件后线程最终还是会执行的。比如Java线程的会优先高优先级线程先执行,低优先级的线程会阻塞等待,如果一直有高优先级线程一直抢占资源就会导致低优先级线程饥饿,但是当高优先级线程执行完后,低优先级的线程还是会执行。
无锁
无锁指的是不对共享资源进行锁定,所有线程都可以访问并修改同一个共享资源,但是最终只有一个线程可以修改成功。其他修改失败的线程可以根据失败的结果进行重试或者其他策略。
Java中的无锁机制就是比较交换机制(compare and swap),缩写CAS。
CAS原理
CAS包含三个参数CAS(V,E,N)
- V表示要更新的变量,E表示预期值,N表示新值。
- 仅当V值等于E值时,才会将V的值设为N
- 如果V值和E值不同,则说明已经有其他线程做了更新,则当前线程什么都不做
- 最后,CAS返回当前V的真实值
无锁的好处
- 减少线程竞争锁的系统开销和线程阻塞调度开销,高并发场景下效率更优
- 由于无锁状态不会阻塞线程,因此不会出现死锁状态
CAS的思想是在竞争共享资源时认为自己的操作会成功的,竞争失败的线程不会被阻塞挂起。这是一种乐观锁态度。下面来讲讲乐观锁和悲观锁的区别。
乐观锁和悲观锁
乐观锁和悲观锁是对于共享资源竞争是否使用锁的态度。
乐观锁认为每次修改共享资源时,不会有其他线程来修改,所以操作不会上锁,只是执行更新时会进行判断这期间是否有其他线程修改了数据,通常利用版本号等判断。
悲观锁则认为自己获取共享资源的时候,其他线程会同时来修改共享资源,因此每次操作都会上锁,这样其他线程来获取共享资源时就会阻塞等待。
JDK1.5锁优化后引入的偏向锁、轻量级锁就是乐观锁,而重量级锁就是悲观锁。
偏向锁
加锁过程
大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,为了让线程获得锁的代价更低而引入了偏向锁。当一个线程访问同步块并获取锁时,会在对象头和栈帧中的锁记录里存储锁偏向的线程ID,以后该线程在进入和退出同步块时不需要进行CAS操作来加锁和解锁,只需简单地测试一下对象头的Mark Word里是否存储着指向当前线程的偏向锁。如果测试成功,表示线程已经获得了锁。如果测试失败,则需要再测试一下Mark Word中偏向锁的标识是否设置成1(表示当前是偏向锁):如果没有设置,则使用CAS竞争锁;如果设置了,则尝试使用CAS将对象头的偏向锁指向当前线程。
撤销锁过程
偏向锁使用了一种等到竞争出现才释放锁的机制,所以当其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁。偏向锁的撤销,需要等待全局安全点(在这个时间点上没有正在执行的字节码)。它会首先暂停拥有偏向锁的线程,然后检查持有偏向锁的线程是否活着,如果线程不处于活动状态,则将对象头设置成无锁状态;如果线程仍然活着,拥有偏向锁的栈会被执行,遍历偏向对象的锁记录,栈中的锁记录和对象头的Mark Word要么重新偏向于其他线程,要么恢复到无锁或者标记对象不适合作为偏向锁,最后唤醒暂停的线程。
关闭偏向锁
偏向锁在Java 6和Java 7里是默认启用的,但是它在应用程序启动几秒钟之后才激活,如有必要可以使用JVM参数来关闭延迟:-XX:BiasedLockingStartupDelay=0。如果你确定应用程序里所有的锁通常情况下处于竞争状态,可以通过JVM参数关闭偏向锁:-XX:-UseBiasedLocking=false,那么程序默认会进入轻量级锁状态。
轻量级锁
加锁过程
线程在执行同步块之前,JVM会先在当前线程的栈桢中创建用于存储锁记录的空间,并将对象头中的Mark Word复制到锁记录中,官方称为Displaced Mark Word。然后线程尝试使用CAS将对象头中的Mark Word替换为指向锁记录的指针。如果成功,当前线程获得锁,如果失败,表示其他线程竞争锁,当前线程便尝试使用自旋来获取锁。
解锁过程
轻量级解锁时,会使用原子的CAS操作将Displaced Mark Word替换回到对象头,如果成功,则表示没有竞争发生。如果失败,表示当前锁存在竞争,锁就会膨胀成重量级锁。
因为自旋会消耗CPU,为了避免无用的自旋(比如获得锁的线程被阻塞住了),一旦锁升级成重量级锁,就不会再恢复到轻量级锁状态。当锁处于这个状态下,其他线程试图获取锁时,都会被阻塞住,当持有锁的线程释放锁之后会唤醒这些线程,被唤醒的线程就会进行新一轮的夺锁之争。
重量级锁
重量级锁就是传统的锁的概念,线程竞争锁失败后会阻塞等待,需要等待锁资源释放后来唤醒阻塞线程进行新一轮的锁竞争。这个阻塞和唤醒的过程涉及用户态和内核态的切换,时间和资源开销都比较大。
重量级锁内部依赖对象内部的monitor锁来实现,对应monitorenter和monitorexit指令。
锁的分类与升级机制
根据上文介绍,锁一共有4种状态,级别从低到高依次是:无锁状态、偏向锁状态、轻量级锁状态和重量级锁状态,这几个状态会随着竞争情况逐渐升级。锁可以升级但不能降级,意味着偏向锁升级成轻量级锁后不能降级成偏向锁。这种锁升级却不能降级的策略,目的是为了提高获得锁和释放锁的效率
锁的优缺点对比
锁 | 优点 | 缺点 | 适用场景 |
---|---|---|---|
偏向锁 | 加锁和解锁没有额外的开销,与非同步方法性能区别不大 | 如果存在线程竞争,会有额外的锁撤销性能消耗 | 适合只有一个线程访问同步块的场景 |
轻量级锁 | 竞争锁的线程不会阻塞,提高程序响应速度 | 竞争锁不成功时不会放弃CPU时间片一直自旋 | 追求响应时间,同步块执行速度快 |
重量级锁 | 竞争线程不会自旋,不会消耗CPU | 线程阻塞,响应时间短 | 追求吞吐量,同步块执行时间长 |
JDK1.5以后的synchronized关键字对锁的使用进行了优化。内部实现上不是一开始直接使用重量级锁,而是从无锁、偏向锁,轻量级锁,再到重量级锁升级的过程,如果确定应用同步代码块竞争激烈,可以关闭中间锁状态,减少锁转换的开销。
除了优化synchronized关键字的实现,JDK1.5还推出了Lock接口,便于开发者自定义锁以及更灵活的使用锁。与synchronized关键字不同,Lock接口实现锁在使用时需要显式地获取和释放锁。虽然它缺少了隐式获取释放锁的便捷性,但是却拥有了锁获取与释放的可操作性、可中断的获取锁以及超时获取锁等多种synchronized关键字所不具备的同步特性。Java提供了Lock接口的两种内置实现锁,可重入锁ReetrantLock和可重入读写锁ReetrantReadWriteLock,内部依赖队列同步器AQS(AbstractQueuedSynchronizer),这里不对AQS展开赘述。
接下来我们重点围绕Lock接口相关的一些锁概念进行探讨。
可重入锁
可重入是指任意线程在获取到锁之后能够再次获取该锁而不会被锁所阻塞。很简单的一个判断方法就是获取到锁的线程递归调用同步方法不被阻塞。因此,显而易见地一个结论,synchronized就是可重入锁。ReetrantLock也是一种可重入锁。
实现重进入的流程:
线程再次获取锁时 。锁需要去识别获取锁的线程是否为当前占据锁的线程,如果是,则再次成功获取。
-
锁的最终释放 。线程重复n次获取了锁,随后在第n次释放该锁后,其他线程能够获取到该锁。锁的最终释放要求锁对于获取进行计数自增,计数表示当前锁被重复获取的次数,而锁被释放时,计数自减,当计数等于0时表示锁已经成功释放。
上文提到的AQS接口就继承了AbstractOwnableSynchronizer接口,用于当前占有锁的线程。
ReentrantLock锁还支持获取锁时的公平与非公平锁的选择。通过构造函数传入boolean值来进行指定。
公平锁和非公平锁
公平性与否是针对获取锁而言的,如果一个锁是公平的,那么锁的获取顺序就应该符合请求的绝对时间顺序,也就是FIFO。非公平锁可能存在线程饥饿的问题,但是非公平锁依然是ReentrantLock的默认实现,原因很简单,一个线程使用锁后再次获取锁的可能性很大,因而公平锁为了保证线程请求的先后顺序需要进行大量的上下文切换,这个资源消耗的代价是很大的,因此为了保证更大的吞吐量,如果不是场景需要,推荐使用非公平锁。
独占锁、共享锁
独占锁又称排它锁、独享锁,指锁同时只能被一个线程所持有。
共享锁是指锁可同时被多个线程所持有。
我们常用的synchronized和ReentrantLock就是独占锁,同一个时刻只有一个线程能占用锁。而Lock接口的另一个实现类ReetrantReadWriteLock读写锁在同一时刻可以允许多个读线程访问。
读写锁
ReetrantReadWriteLock,也就是我们说的读写锁。上文提到,读写锁在同一时刻可以允许多个读线程访问,但是在写线程访问时,所有的读线程和其他写线程均被阻塞。读写锁维护了一对锁,一个读锁和一个写锁,通过分离读锁和写锁,使得并发性相比一般的排他锁有了很大提升。
读写锁有一个比较特殊的地方是支持写锁降级为读锁,其流程是:获取写锁,获取读锁再释放写锁的次序,写锁能降级成为读锁。通过写锁降级的操作,主要是为了保证数据的可见性,如果当前线程不获取读锁而是直接释放写锁,假设此刻另一个线程(记作线程T)获取了写锁并修改了数据,那么当前线程无法感知线程T的数据更新。如果当前线程获取读锁,即遵循锁降级的步骤,则线程T将会被阻塞,直到当前线程使用数据并释放读锁之后,线程T才能获取写锁进行数据更新。
转载请注明出处,如有错误的地方请留言给我更正,谢谢!