锁机制
一、根据表现不同来划分
1、读锁&写锁
所有锁机制的前提都是多个线程之间存在共享数据且其中至少有一个线程对该数据有写操作。
(1)读锁(共享锁——Shared)
读锁又称S锁、共享锁,当多个线程之间有共享数据且这些线程都对该数据是读操作时,这些线程都可以对该数据加锁成功。
读锁只有在这一种情况下多个线程对该数据同时加锁时不互斥的。
(2)写锁(独占锁——Exclusive)
写锁又称X锁、排他锁、独占锁,当多个线程之间有共享数据时,不管这些线程对该数据做的事读操作还是写操作,都不能同时对该数据加锁成功。
写锁就是在共享数据的各个线程中只有一个线程能加锁成功。
(3)对比
锁机制,针对的是共享数据。前面学习Java中的 synchronized 锁的时候提到过,当多个线程之间有共享数据并且至少有一个线程对该共享数据有写操作时,需要对这个共享数据加锁以保证线程安全。
操作 | 读锁(S锁) | 写锁(X锁) |
---|---|---|
读-读 | 不互斥 | 互斥 |
读-写 | 互斥 | 互斥 |
写-写 | 互斥 | 互斥 |
(4)Java中如何使用读锁和写锁
在JDK文档中关于读写锁的相关说明 ReadWriteLock 维护了一对相关的 锁 ,一个用于只读操作,另一个用于写入操作,演示一下吧💁♀️
两个线程同时加读锁:
/**两个线程同时对读锁加锁
* @author Lvvvvvv
* @date 2022/07/30 19:25
**/
public class UseOfReadWriteLock {
public static void main(String[] args) {
//实例化一个读写锁
ReadWriteLock readWriteLock=new ReentrantReadWriteLock();
//读锁
Lock readLock=readWriteLock.readLock();
//写锁
Lock writeLock=readWriteLock.writeLock();
//主线程对读锁加锁
readLock.lock();
System.out.println("主线程加锁成功");
Thread thread= new Thread(){
@Override
public void run() {
//子线程也对读锁加锁
readLock.lock();
System.out.println("子线程加锁成功");
}
};
thread.start();
}
}
两个锁都加成功啦✌
一个加读锁一个加写锁:
/**一个加读锁一个加写锁
* @author Lvvvvvv
* @date 2022/07/30 19:25
**/
public class UseOfReadWriteLock {
public static void main(String[] args) {
//实例化一个读写锁
ReadWriteLock readWriteLock=new ReentrantReadWriteLock();
//读锁
Lock readLock=readWriteLock.readLock();
//写锁
Lock writeLock=readWriteLock.writeLock();
//主线程对读锁加锁
readLock.lock();
System.out.println("主线程加锁成功");
Thread thread= new Thread(){
@Override
public void run() {
writeLock.lock();
System.out.println("子线程加锁成功");
}
};
thread.start();
}
}
只有主线程加锁成功🔒
所以可以肯定的是两个线程同时加写锁,一定会互斥,只有一个线程能加锁成功👇👇👇👇👇
两个线程都加写锁:
/**两个线程都加写锁
* @author Lvvvvvv
* @date 2022/07/30 19:25
**/
public class UseOfReadWriteLock {
public static void main(String[] args) {
//实例化一个读写锁
ReadWriteLock readWriteLock=new ReentrantReadWriteLock();
//读锁
Lock readLock=readWriteLock.readLock();
//写锁
Lock writeLock=readWriteLock.writeLock();
//主线程对读锁加锁
writeLock.lock();
System.out.println("主线程加锁成功");
Thread thread= new Thread(){
@Override
public void run() {
writeLock.lock();
System.out.println("子线程加锁成功");
}
};
thread.start();
}
}
会用就行会用就行,知道Java中提供了这么一种读写锁就行。
2、可重入锁和不可重入锁
简单来说,可重入锁和不可重入锁就是是否允许已经持有锁的线程再次请求到同一把锁。
从实现的角度来说,就是看这把锁的内部是否记录了这个锁是由哪个线程锁定的。
(1)可重入锁(ReenTrantLock)
可重入锁又名递归锁,指的是可重复可递归调用的锁,在外层使用锁之后,在内层仍然可以使用,并且不发生死锁,这样的锁就叫做可重入锁。与可重入锁相反,不可重入锁不可递归调用,递归调用就发生死锁。
(2)不可重入锁
与可重入锁相反。
(3)用法
/**可重入锁和不可重入锁
* @author Lvvvvvv
* @date 2022/07/30 20:19
**/
public class UseReentrantLock {
public static void main(String[] args) {
//new ReentrantLock():从这里也可以看出来这个 lock 是一个可重入锁
Lock lock=new ReentrantLock();
lock.lock();
System.out.println("锁上啦");
lock.lock();
System.out.println("又锁上啦");
}
}
(4)synchronized是不是可重入锁
代码里面试一试吧
/**判断synchronized是否为可重入锁
* @author Lvvvvvv
* @date 2022/07/30 20:24
**/
public class demo {
public static void main(String[] args) {
Object lock=new Object();
synchronized (lock){
synchronized (lock){
System.out.println("synchronized是可重入锁");
}
}
}
}
按照代码逻辑,如果 synchronized 是可重入锁,则会输出"synchronized是可重入锁",反之则没有任何输出且程序处于运行状态。
从运行结果可以看出来, synchronized 是可重入锁。
3、公平锁和不公平锁
(1)公平锁
公平锁是指多个线程按照申请锁的顺序来获取锁,类似排队打饭,先来后到。
(2)非公平锁
非公平锁是指多个线程获取锁的顺序并不是按照申请锁的顺序,有可能后申请的线程比先申请的线程优先获取锁。
举个例子,有三个线程A、B、C,有一把锁 lock,线程A先加锁成功,所以线程B和C就排队等待加锁 ,但此时线程D也来对 lock 加锁,而且就在这时线程A释放锁了,线程D 就加锁成功了,纯纯的后来者居上😡
(3)一些结论
- synchronized 锁是不公平锁
- juc 工具包下的锁可以通过传入 fair=true||fair=false 来决定是公平锁还是非公平锁
/**
* 公平锁和不公平锁
* synchronized是不公平锁
* JUC下的ReentrantLock可以通过传入参数得到相对应的锁
*/
public class Main {
public static void main(String[] args) {
Lock lock = new ReentrantLock(true); // fair = true:使用公平锁模式
Lock lock1 = new ReentrantLock(false); // fair = false:使用不公平锁模式
Lock lock2 = new ReentrantLock(); // 默认情况下是不公平的
}
}
小小总结一下,synchronized 是一个 独占锁+可重入锁+不公平锁
二、根据方案不同来划分
1、乐观锁和悲观锁
严格来讲,乐观锁和悲观锁是并发控制的两个方案而已,跟锁完全不是一个层级。
(1)乐观锁
经过评估之后,发现在并发情况下多个线程同时修改共享资源的概率比较小,可以采用轻量级(lock-free)方式进行并发控制。
(2)悲观锁
经过评估之后,发现在并发情况下多个线程会频繁的修改共享资源,则必须采用互斥(lock)的方式进行并发控制。
将十字路口➕比作共享资源,会存在并发场景,将红绿灯🚥视为锁,那么在繁华的市中心就必须安装红绿灯(使用锁),在偏远山村(人流量相当少)就没有必要设置红绿灯。
三、根据实现原理不同来划分
1、前置知识:
(1)需要不会触发线程调度的锁的实现方式
在默认情况下,我们锁的实现是采用操作系统提供的锁(mutex锁——互斥锁),但一旦当前线程请求锁失败,就会放弃CPU,进入阻塞状态,把自己加入到阻塞队列中,直到被唤醒。只要牵扯到放弃CPU,就必须进入内核态,从放弃CPU到再次得到CPU 的时间很久,成本较大!所以需要一种不会触发线程调度的锁的实现方式。
(2)JVM提供了CAS机制
通用CPU都有CAS(Compare And Swap)机制,CAS操作包含三个操作数——内存位置(V)、预期原值(A)和新值(B)。如果内存位置的值与预期原值相匹配,那么处理器会自动将该位置值更新为新值。否则,处理器不做任何操作。虽然这个操作看起来有很多步骤,但硬件已经保证了该操作的原子性。
大概就是这么个流程:
boolean CAS(address,expect,newValue){
if(*address=expect){
*address=newValue;
return true;
}
return false;
}
硬件提供了CAS机制->OS提供了CAS机制->JVM提供了CAS机制
(3)自旋锁的出现
基于上面这两点,就出现了自旋锁。
自旋锁 | 互斥锁 | |
---|---|---|
加锁失败后的表现 | 执行一些没有任何用途的指令,不放弃CPU,占着CPU继续等待锁被释放 | 放弃CPU,把自己放入阻塞队列中,引发线程调度 |
出现的原因 | 现代计算机都是多核模式,在放弃CPU到占有CPU的时间大于锁的持有时常的前提下,即使当前线程多占一个核也没关系 | 单核CPU,早放弃CPU,持有锁的线程就有可能尽早释放锁 |
2、互斥锁(mutex)
互斥锁加锁失败后,就会放弃CPU,把自己加入到阻塞队列中,会引发线程调度,成本较大。
3、自旋锁(Spin Lock)
自旋锁加锁失败后,继续执行一些对程序没有任何影响的代码,目的就是不放弃CPU,占着CPU继续等待锁的释放。
四、synchronized锁的实现与优化
synchronized实现策略:独占锁+不公平锁+可重入锁
1、synchronized的锁消除优化
以 Vector 为例,Vector 为了做到线程安全,每个方法都用 synchronized 修饰了,但是在只有一个线程的情况下,这些加锁释放锁的操作就是白给,没用还费力。
在实际运行过程中,我们的程序只要主线程,那么所有做线程保护的操作(加锁释放锁)都是无用功,所以编译器和JVM在判断出程序只有一个线程时,就会消除所有锁的操作,以提高性能。
2、synchronized的锁粗化优化
前提:在已经没有办法做锁消除优化事务情况下
Object lock=new Object();
for (int i = 0; i < 100000; i++) {
synchronized (lock){
number++;
}
}
在上面的代码中,只有这一个线程对number进行了更改,那么每次循环都对 i++ 这个操作进行加锁解锁操作过于频繁,换句话说,锁的粒度太细了,就会导致性能不高,所以JVM就会进行锁的粗化优化:
3、synchronized的锁升级优化
(1)偏向锁
通过对大量数据的分析可以发现,大多数情况下锁竞争是不会发生的,往往是一个线程多次获得同一个锁,于是引入了偏向锁,偏向锁不会被刻意的释放,如果没有竞争,线程再次请求锁时可以直接获得锁。
(2)轻量级锁
随着其他线程参与了锁的竞争,偏向锁就会失效,所以优先尝试轻量级锁。轻量级锁是通过CAS机制+自旋锁来实现的,所以当该线程多次自旋申请锁失败,就尝试重量级锁。
(3)重量级锁
重量级锁在底层是靠操作系统的Mutex Lock实现的,线程在阻塞和唤醒状态间切换需要操作系统将线程在用户态与核心态之间转换,成本很高,所以最早的synchronized效率不高。
总结
一些锁和各种各样锁的机制、策略,属于偏概念型知识,多看多记。拜拜🙋♀️