Java 多线程(八)—— 锁策略,synchronized 的优化,JVM 与编译器的锁优化,ReentrantLock,CAS

前言

本文为 Java 面试小八股,一句话,理解性记忆,不能理解就死背吧。

锁策略

悲观锁与乐观锁

悲观锁和乐观锁是锁的特性,并不是特指某个具体的锁。

我们知道在多线程中,锁是会被竞争的,悲观锁就是指锁的竞争程度十分激烈,很多线程都想用这把锁,为了应对这个场景,我们会额外做一些工作。例如:一把锁,此时有几十个线程都想用,并且同一时刻它们都发出申请锁的请求,这时候锁的竞争程度很高,我们可以采取悲观锁的策略,额外做一些工作。

乐观锁 则相反,锁的竞争程度很小,就不需要做额外的工作。例如:这一把锁只有两个线程在竞争,并且这两个线程用锁的概率也不是很高,这时候我们可以采用乐观锁策略。

重量级锁与轻量级锁

重量级锁和轻量级锁是遇到特定的场景而出现的解决方案。

上面我们就提到乐观和悲观的场景,重量级锁适用于悲观的场景,相应的也要付出更高的代价,效率相比轻量级锁要低效。

轻量级锁适用于乐观的场景,要付出的代价也要小很多,效率相比重量级锁要高效。

等待挂起锁与自旋锁

挂起等待锁就是指如果一把锁已经被一个线程占用的时候,发现有其他线程还想竞争这把锁,操作系统就会让它们阻塞等待,后续唤醒的时候需要由操作系统的内核来唤醒。

自旋锁就是指如果发现由锁竞争,这时候这些线程不会阻塞等待,而是以忙等的形式进行等待。

看到这里,其实等待挂起锁是适用于悲观的场景下,因为线程竞争激烈,没必要让它们占着 CPU 资源,直接让它们阻塞,释放出CPU 资源,减少资源的消耗。同时由于唤醒的时候是由操作系统的内核实现的,所以操作系统会在内核态和用户态频繁切换,效率也会比较低下。

而自旋锁则适用于乐观的场景,线程以忙等的形式,也就是占用着CPU,但是由于锁竞争不是很激烈,忙等的线程很快就可以获取到锁,所以没必要阻塞等待,因为操作系统的内核唤醒线程的效率要低效一些,所以自旋锁的效率会比等待挂起锁的效率要高。

互斥锁与读写锁

互斥锁就是加锁之后,拥有这把锁的线程才能进行操作,其他线程必须等待拿到锁之后才能进行自己的操作,这就是互斥。

读写锁分为读锁、写锁,因为我们知道线程安全问题是因为写操作而引起的,但是读操作是不会发生线程安全问题的,而读写锁就是针对读操作和写操作进行加锁读锁和读锁是不会互斥的,写锁与读锁是会互斥的,写锁与写锁是会互斥的(这里可以参考MySQL的幻读、脏读、不可重复读了)

公平锁与非公平锁

公平锁 是指锁的分配是按线程的等待时长来分配 的,举个例子:假设一把锁已经被一个线程占用,此时有三个线程都想竞争这把锁,那这时候我们会使用额外的数据结构来保存这些线程并且记录每个线程的等待时长,等到锁被释放的时候,操作系统会优先把这把锁分配给等待时长最长的线程,这也避免了线程饥饿。

非公平锁就是随机分配,不按 "先来先得" 的规矩

可重入锁与不可重入锁

可重入锁就是当一个线程拥有这把锁的时候,可以进行重复的加锁。

不可重入锁则相反,即使你这个线程拥有了这把锁,但是还是不能对其进行重复加锁。

synchronized 的优化

根据上面的锁策略,我们来总结一下 synchronized 的特性:

synchronized 具有自适应性
synchronzied 开始时是采取乐观锁策略,如果锁的冲突频繁,则转换为悲观锁
开始时是轻量级锁,如果锁冲突频繁,则转换为重量级锁
synchronized 实现轻量级锁的时候采用自旋锁策略
synchronized 是不公平锁,可重入锁,互斥锁,不是读写锁

锁升级

JVM 会将 synchronized 的锁分为四个状态:无锁、偏向锁、自旋锁、重量级锁。

当还没进入synchronized 的时候,处于无锁状态,一旦进入 synchronized 代码块就会变成偏向锁,偏向锁并非真正加锁,而是通过标记 的方式,以此来区分是否真正加锁了。偏向锁本质上相当于 "延迟加锁",能不加锁就不加锁,避免了不必要的加锁开销,这也是一种懒汉模式的体现。

一旦产生锁竞争,偏向锁就会升级为自旋锁,也就是轻量级锁,如果竞争十分激烈,进一步升级为重量级锁。

synchronized 只能进行锁升级,但是不能进行锁降级!!!

JVM 与编译器的锁优化

锁消除

JVM 会自动检测出一些没有必要加锁的操作,避免这些无意义的加锁操作带来的不必要的开销,JVM 会把这些锁给消除,也就是说你代码加锁了,但是 JVM 给删除了。

大家不用担心这个优化会产生线程安全问题,因为 JVM 的锁消除是在100% 确定这个锁就是一个没必要加的锁,JVM 才会进行锁消除。

锁粗化

首先介绍一个概念,锁的粒度:加锁与解锁之间包含的代码指令越多,锁就越粗;相反,加锁与解锁之间包含的代码指令越少,锁就越细。

java 复制代码
public class Test {
    public static int sum = 0;
    public static int count = 10000;
    public static int total = 1000;
    public static void main(String[] args) {
        Object locker = new Object();
        Thread t = new Thread(() -> {
            for(int i = 0; i < 5000; i++) {
                synchronized (locker) {
                    sum++;
                }
                synchronized (locker) {
                    count--;
                }
                synchronized (locker) {
                    total--;
                }
            }
        });
    }
}

上面的代码就属于锁的粒度太细了,频繁加锁解锁。

java 复制代码
        Thread t2 = new Thread(() -> {
            for(int i = 0; i < 5000; i++) {
                synchronized (locker) {
                    sum++;
                    count--;
                    total--;
                }
            }
        });

这个代码就是锁的粒度粗,加锁和解锁的次数比较少。

⼀段逻辑中如果出现多次加锁解锁,编译器 和 JVM会自动进行锁的粗化。

ReentrantLock

ReentrantLock 和 synchronized 是并列关系,都是用来加锁的,并且都是可重入锁。

简单使用介绍,ReentrantLock 使用 lock() 加锁,unlock() 来解锁,为了避免我们因为加锁和解锁之间有return 或者 抛出异常等等情形没能进入解锁操作,所以这里使用 finally 来包含 unlock() 代码行,避免忘记解锁。

java 复制代码
        ReentrantLock locker2 = new ReentrantLock();
        Thread t3 = new Thread(() -> {
            try {
                locker2.lock();
                count++;
            } finally {
                locker2.unlock();
            }   
        });

synchroinzed 和 ReentrantLock 的区别:

synchronized 是 Java提供的关键字,是 JVM 内部通过 C++ 实现的,ReentrantLock 是Java标准库提供的类,由Java代码实现

synchronized 是 通过代码块来实现加锁和解锁的,ReentrantLock 通过 lock() 加锁,unlock() 解锁,一定要注意 unlock() 可能存在未被调用的情况。

ReentrantLock 还有一个 tryLock() 这个方法的调用不会线程产生阻塞,如果加锁成功则返回 true,加锁失败则返回 false,接下来由调用者来根据返回值决定接下来怎么做。可以设置超时时间,当等待时间达到超时时间的时候再返回true / false

ReentrantLock 提供了公平锁的实现,ReentrantLock locker = new ReentrantLock(true);默认情况下是非公平锁。

ReentrantLock 搭配的通知等待机制是由Condition 类实现的,相比于 synchronized 的 wait / notify 的功能更强大一些。
synchronized 和 ReentrantLock 都是可重入的互斥锁。

CAS

CAS 全称是 Compare and swap,比较并交换
CAS 在 CPU 里是一条指令,具有原子性。
因此 CAS 操作是线程安全的

举个例子:假设内存原始数据为 V,把这个数据放入寄存器 1 和 寄存器 2 中,数据的加减等操作的结果由寄存器 2 保存。CAS 会先检测原始数据 V 和寄存器 1 的数值是否一致,如果一致的话,可以执行修改也就是把寄存器 2 的结果放入内存中。

下面给出 CAS 的伪代码进行进一步的理解:

java 复制代码
boolean CAS(address, expectValue, swapValue) {
 	if (&address == expectedValue) {
 		&address = swapValue;
 			return true;
 	}
	return false;
}

第一个参数是内存的数值,第二个参数是寄存器 1 的数值,第三个参数是寄存器 2 的数值。

首先判断内存的数值是否和寄存器的数值一致,如果一致则进行寄存器 2 和内存数值的交换操作,注意 这本质上在 CPU 里是一条指令,具有原子性。

明确的指明:if-else 和 三目运算符在 CPU 里不是一条指令,和 CAS 还是由区别的。

原子类

CPU 有 CAS 指令,并且给操作系统提供了 CAS 的使用接口,操作系统对 CAS 进一步封装,给用户提供相应的接口,C++ 可以直接进行调用,而JVM 是由 C++ 实现的再次对 CAS 进行封装,给Java 程序员提供了 原子类。在这个 java.util.concurrent.atomic 包下就是我们的原子类了。

下面是原子类的伪代码:

java 复制代码
class AtomicInteger {
 	private int value;
 	public int getAndIncrement() {
 		int oldValue = value;
 		while ( CAS(value, oldValue, oldValue+1) != true) {
 			oldValue = value;
 		}
 		return oldValue;
    }
}

oldValue 是寄存器,由于Java没有寄存器的使用,所以这里用 int 类型代替。

getAndIncrement() 其实就是 ++ 自增的操作,首先先把内存的数值(value)读到寄存器 1 中,CAS 指令 首先判断 value 是否和寄存器 1 中的数值 oldValue 相等,如果相等就把寄存器 2 的 oldValue + 1 的结果放到内存中,返回 true,否则返回 false 并且进入循环体再次读取内存的数值放入寄存器 1 中。


面对多线程下同时修改一个变量的时候,原子类是最佳的选择。

java 复制代码
import java.util.concurrent.atomic.AtomicInteger;

public class Demo1 {
    private static AtomicInteger count = new AtomicInteger(0);

    public static void main(String[] args) throws InterruptedException {

        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 50000; i++) {
                count.getAndIncrement();
            }
        });

        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 50000; i++) {
                count.getAndIncrement();
            }
        });

        t1.start();
        t2.start();
        t1.join();
        t2.join();

        System.out.println("count = " + count.get());
    }
}

使用 CAS 实现自旋锁

下面是 使用 CAS 实现自旋锁的伪代码:

java 复制代码
public class SpinLock {
 	private Thread owner = null;
 	public void lock(){
 		// 通过 CAS 看当前锁是否被某个线程持有.  
 		// 如果这个锁已经被别的线程持有, 那么就⾃旋等待.  
 		// 如果这个锁没有被别的线程持有, 那么就把 owner 设为当前尝试加锁的线程.  
 		while(!CAS(this.owner, null, Thread.currentThread())){}
 	}
 	public void unlock (){
 		this.owner = null;
 	}
}

核心代码:while(!CAS(this.owner, null, Thread.currentThread())){} 当 这个锁的拥有者 为 null 的时候,才能由线程Thread.currentThread() 获取这把锁的操作并返回true,否则该线程以忙等的形式等待这把锁。

ABA 问题

ABA 问题是什么?

我们知道 CAS 开始之前会先把内存的数值读到寄存器里,在进行 CAS 的操作之前,可能调度过来别的线程,这个线程对这个内存的数值进行了修改操作,然后又改回来了,看上去这个数值没有任何变化,实际上这个数据已经被动过了,接着把 CAS 调度过来执行,CAS 首先判定内存的数值是否和寄存器的数值一致,如果一致,进行交换操作,这时候数值肯定是一致的,所以交换操作正常被执行了。在进行内存数值和寄存器数值判定是否相等之前内存数值是否被改了又改过,这就是 ABA 问题。

ABA 问题会带来什么BUG?

假设一个人叫做白糖过来取500块钱,假设余额有 4k,这时候 ATM 机有点卡顿,这时候白糖进行了多次按下取款的操作,恰好这时候白糖的好朋友天王星发个信息说之前欠你的500块现在转账还你。

由于多次按下取款操作,就会产生多个取款的线程来执行取款操作,此时中间夹了一个还款操作的线程,大家来看一下下面的流程图:

取款线程 t1 把 account 修改为 3500, 还款线程将 account 修改为 4000, 接着又来了 取款线程 t3 由于内存4000 和寄存器的数值保留的 4000 是一致,所以又将余额修改为了 3500,你会发现白糖小伙就拿出了 500 块,但是余额却多扣了 500, 完了血亏 500,可怜的白糖又要辛苦打工了。

这种事件虽然发生概率极小,但是在庞大的请求数量面前还是不能忽视这个 bug 的。

如何解决 ABA 问题???

因为余额是可以加又可以减的变量,所以会出现上述极端的BUG,但是如果我们换一个指标来作为判断标准的话就可以避免上述的BUG,这里我们可以使用版本号 来作为判断的指标,每次修改之后版本号就 + 1,每次进行修改操作的时候判断内存的版本号和寄存器的版本号是否相同

下面给一个伪代码:

java 复制代码
        int oldVersion = version;

        if(CAS(version, oldVersion, oldVersion + 1)) {
            account += 500;
        }
相关推荐
非 白7 分钟前
【Java】代理模式
java·开发语言·代理模式
Good Note18 分钟前
Golang的静态强类型、编译型、并发型
java·数据库·redis·后端·mysql·面试·golang
我就是我3521 小时前
记录一次SpringMVC的406错误
java·后端·springmvc
向哆哆1 小时前
Java应用程序的跨平台性能优化研究
java·开发语言·性能优化
ekkcole2 小时前
windows使用命令解压jar包,替换里面的文件。并重新打包成jar包,解决Failed to get nested archive for entry
java·windows·jar
ylfhpy2 小时前
Python常见面试题的详解16
开发语言·python·面试
handsomestWei2 小时前
java实现多图合成mp4和视频附件下载
java·开发语言·音视频·wutool·图片合成视频·视频附件下载
全栈若城2 小时前
03 Python字符串与基础操作详解
java·开发语言·python
伯牙碎琴2 小时前
二、Spring Framework基础:IoC(控制反转)和DI(依赖注入)
java·spring·log4j
菲力蒲LY2 小时前
输入搜索、分组展示选项、下拉选取,全局跳转页,el-select 实现 —— 后端数据处理代码,抛砖引玉展思路
java·前端·mybatis