线程同步互斥和锁
1.线程的互斥同步方式有哪些? 如何比较和选择?
Java 提供了两种锁机制来控制多个线程对共享资源的互斥访问,第一个是 JVM 实现的 synchronized,而另一个是 JDK 实现的 ReentrantLock。
- 锁的实现synchronized 是 JVM 实现的,而 ReentrantLock 是 JDK 实现的。
- 性能新版本 Java 对 synchronized 进行了很多优化,例如自旋锁等,synchronized 与 ReentrantLock 大致相同。
- 等待可中断当持有锁的线程长期不释放锁的时候,正在等待的线程可以选择放弃等待,改为处理其他事情。ReentrantLock 可中断,而 synchronized 不行。
- 公平锁公平锁是指多个线程在等待同一个锁时,必须按照申请锁的时间顺序来依次获得锁。synchronized 中的锁是非公平的,ReentrantLock 默认情况下也是非公平的,但是也可以是公平的。
- 锁绑定多个条件一个 ReentrantLock 可以同时绑定多个 Condition 对象。
ReentrantLock:
ReentrantLock
实现了 Lock
接口,是一个可重入且独占式的锁,和 synchronized
关键字类似。不过,ReentrantLock
更灵活、更强大,增加了轮询、超时、中断、公平锁和非公平锁等高级功能。
public class ReentrantLock implements Lock, java.io.Serializable {}
ReentrantLock
里面有一个内部类 Sync
,Sync
继承 AQS(AbstractQueuedSynchronizer
),添加锁和释放锁的大部分操作实际上都是在 Sync
中实现的。Sync
有公平锁 FairSync
和非公平锁 NonfairSync
两个子类。
ReentrantLock
默认使用非公平锁,也可以通过构造器来显式的指定使用公平锁。
// 传入一个 boolean 值,true 时为公平锁,false 时为非公平锁
public ReentrantLock(boolean fair) {
sync = fair ? new FairSync() : new NonfairSync();
}
2.锁升级
2.1无锁
无锁并不会对资源锁定,所有的线程都可以访问并修改同一个资源,但同时只有一个线程能修改成功。也就是我们常说的乐观锁。
2.2偏向锁
偏向于第一个访问锁的线程,初次执行synchronized代码块时,通过 CAS 修改对象头里的锁标志位,锁对象变成偏向锁。
当一个线程访问同步代码块并获取锁时,会在 Mark Word 里存储锁偏向的线程 ID。在线程进入和退出同步块时不再通过 CAS 操作来加锁和解锁,而是检测 Mark Word 里是否存储着指向当前线程的偏向锁。轻量级锁的获取及释放依赖多次 CAS 原子指令,而偏向锁只需要在置换 ThreadID 的时候依赖一次 CAS 原子指令即可。
执行完同步代码块后,线程并不会主动释放偏向锁。当线程第二次再执行同步代码块时,线程会判断此时持有锁的线程是否就是自己(持有锁的线程ID也在对象头里),如果是则正常往下执行。由于之前没有释放锁,这里不需要重新加锁,偏向锁几乎没有额外开销,性能极高。
偏向锁只有遇到其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁,线程是不会主动释放偏向锁的。关于偏向锁的撤销,需要等待全局安全点,即在某个时间点上没有字节码正在执行时,它会先暂停拥有偏向锁的线程,然后判断锁对象是否处于被锁定状态。如果线程不处于活动状态,则将对象头设置成无锁状态,并撤销偏向锁,恢复到无锁(标志位为01)或轻量级锁(标志位为00)的状态。
偏向锁是指当一段同步代码一直被同一个线程所访问时,即不存在多个线程的竞争时,那么该线程在后续访问时便会自动获得锁,从而降低获取锁带来的消耗。
2.3轻量级锁
当前锁是偏向锁,此时有多个线程同时来竞争锁,偏向锁就会升级为轻量级锁。轻量级锁认为虽然竞争是存在的,但是理想情况下竞争的程度很低,通过自旋方式来获取锁。
轻量级锁的获取有两种情况:
当关闭偏向锁功能时
多个线程竞争偏向锁导致偏向锁升级为轻量级锁。一旦有第二个线程加入锁竞争,偏向锁就升级为轻量级锁(自旋锁)
在轻量级锁状态下继续锁竞争,没有抢到锁的线程将自旋,不停地循环判断锁是否能够被成功获取。获取锁的操作,其实就是通过CAS修改对象头里的锁标志位。先比较当前锁标志位是否为“释放”,如果是则将其设置为“锁定”,此过程是原子性。如果抢到锁,然后线程将当前锁的持有者信息修改为自己。
2.4重量级锁
如果线程的竞争很激励,线程的自旋超过了一定次数(默认循环10次,可以通过虚拟机参数更改),将轻量级锁升级为重量级锁(依然是 CAS 修改锁标志位,但不修改持有锁的线程ID),当后续线程尝试获取锁时,发现被占用的锁是重量级锁,则直接将自己挂起(而不是忙等),等待将来被唤醒。
重量级锁是指当有一个线程获取锁之后,其余所有等待获取该锁的线程都会处于阻塞状态。简言之,就是所有的控制权都交给了操作系统,由操作系统来负责线程间的调度和线程的状态变更。而这样会出现频繁地对线程运行状态的切换,线程的挂起和唤醒,从而消耗大量的系统资源。
2.5锁优化技术(锁粗化、锁消除):
锁粗化就是告诉我们任何事情都有个度,有些情况下我们反而希望把很多次锁的请求合并成一个请求,以降低短时间内大量锁请求、同步、释放带来的性能损耗。
锁消除指的是在某些情况下,JVM 虚拟机如果检测不到某段代码被共享和竞争的可能性,就会将这段代码所属的同步锁消除掉,从而到底提高程序性能的目的。
3.锁的分类实现
3.1悲观锁:
正如其名,它是指对数据修改时持保守态度,认为其他人也会修改数据。因此在操作数据时,会把数据锁住,直到操作完成。悲观锁大多数情况下依靠数据库的锁机制实现,以保证操作最大程度的独占性。如果加锁的时间过长,其他用户长时间无法访问,影响程序的并发访问性,同时这样对数据库性能开销影响也很大,特别是长事务而言,这样的开销往往无法承受。
3.2乐观锁:
乐观锁,从字面意思也能猜到个大概,在操作数据时非常乐观,认为别人不会同时修改数据,因此乐观锁不会上锁 只是在 提交更新
时,才会正式对数据的冲突与否进行检测。如果发现冲突了,则返回错误信息,让用户决定如何去做 。否则,执行本次操作。
3.3可重入锁:
可重入锁,也叫做递归锁,是指在同一个线程在调外层方法获取锁的时候,再进入内层方法会自动获取锁。
对象锁或类锁内部有计数器,一个线程每获得一次锁,计数器 +1;解锁时,计数器 -1。
JAVA 中的 ReentrantLock
和 synchronized
都是 可重入锁。可重入锁的一个好处是可一定程度避免死锁。
3.4自旋锁:
自旋锁是采用让当前线程不停地在循环体内执行,当循环的条件被其他线程改变时才能进入临界区。自旋锁只是将当前线程不停地执行循环体,不进行线程状态的改变,所以响应速度更快。但当线程数不断增加时,性能下降明显,因为每个线程都需要执行,会占用CPU时间片。如果线程竞争不激烈,并且保持锁的时间段。适合使用自旋锁。
自旋锁缺点:
可能引发死锁
可能占用 CPU 的时间过长
我们可以设置一个 循环时间 或 循环次数,超出阈值时,让线程进入阻塞状态,防止线程长时间占用 CPU 资源。JUC 并发包中的 CAS 就是采用自旋锁,compareAndswap 是CAS操作的核心,底层利用Unsafe对象实现的。
自适应自旋锁:在JDK 1.6中引入了自适应自旋锁。这就意味着自旋的时间不再固定了,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定的。如果在同一个锁对象上,自旋等待刚刚成功获取过锁,并且持有锁的线程正在运行中,那么JVM会认为该锁自旋获取到锁的可能性很大,会自动增加等待时间。比如增加到100此循环。相反,如果对于某个锁,自旋很少成功获取锁。那再以后要获取这个锁时将可能省略掉自旋过程,以避免浪费处理器资源。有了自适应自旋,JVM对程序的锁的状态预测会越来越准确,JVM也会越来越聪明。
3.5独享锁:
独享锁,也有人叫它排他锁。无论读操作还是写操作,只能有一个线程获得锁,其他线程处于阻塞状态。
缺点:读操作并不会修改数据,而且大部分的系统都是读多写少,如果读读之间互斥,大大降低系统的性能。下面的共享锁会解决这个问题。
像JAVA 中的 ReentrantLock 和 synchronized 都是独享锁。
3.6共享锁:
共享锁是指允许多个线程同时持有锁,一般用在读锁上。读锁的共享锁可保证并发读是非常高效的。读写,写读 ,写写的则是互斥的。独享锁与共享锁是通过AQS来实现的.
ReentrantReadWriteLock,其读锁是共享锁,其写锁是独享锁。
3.7读锁/写锁:
如果对某个资源是读操作,那多个线程之间并不会相互影响,可以通过添加读锁实现共享。如果有修改动作,为了保证数据的并发安全,此时只能有一个线程获得锁,我们称之为写锁。读读是共享的;而读写、写读 、写写则是互斥的。
像 JAVA 中的 ReentrantReadWriteLock 就是一种读写锁。
3.8公平锁/非公平锁:
公平锁:多个线程按照申请锁的顺序去获得锁,所有线程都在队列里排队,先来先获取的公平性原则。
优点:所有的线程都能得到资源,不会饿死在队列中。
缺点:吞吐量会下降很多,队列里面除了第一个线程,其他的线程都会阻塞,CPU 唤醒下一个阻塞线程有系统开销。
**非公平锁:**多个线程不按照申请锁的顺序去获得锁,而是同时以插队方式直接尝试获取锁,获取不到(插队失败),会进入队列等待(失败则乖乖排队),如果能获取到(插队成功),就直接获取到锁。
**优点:**可以减少 CPU 唤醒线程的开销,整体的吞吐效率会高点。
**缺点:**可能导致队列中排队的线程一直获取不到锁或者长时间获取不到锁,活活饿死。
Java 多线程并发操作,我们操作锁大多时候都是基于 Sync 本身去实现的,而 Sync 本身却是 ReentrantLock 的一个内部类,Sync 继承 AbstractQueuedSynchronizer,像 ReentrantLock 默认是非公平锁,我们可以在构造函数中传入 true,来创建公平锁。
参考:https://blog.csdn.net/weixin_70730532/article/details/126875303