锁策略的介绍

前言:本文将简单介绍一下常见的锁策略,以及synchronized的原理和编译器时如何优化锁机制的过程。

目录

一,概念

(一)锁的核心机制

1.分层实现

(二)互斥锁(mutex)

(三)ReentrantLock

1.显示加锁/解锁

2功能差异.

二,锁策略分类

(一)乐观锁vs悲观锁

(二)重量级锁vs轻量级锁

[(三)挂起等待锁vs ⾃旋锁](#(三)挂起等待锁vs ⾃旋锁)

(四)公平锁vs⾮公平锁

(五)可重⼊锁vs不可重⼊锁

(六)读写锁vs互斥锁

三,synchronized

(一)synchronized原理

(二)JVM锁优化机制

1.锁升级

1)无锁

2)偏向锁

3)轻量级锁/自旋锁

4)重量级锁

2.锁消除

3.锁粗化


一,概念

(一)锁的核心机制

锁的核心机制是保证cpu执行指令的原子性。

在多线程模式下,线程安全问题就是一个避不开的问题。为了解决这个问题,JVM引入了synchronized(JVM内置)和ReentrantLock(javaAPI实现 )这两种锁;而C++则使用C++的标准库来实现mutex内核锁。虽然这两类锁在实现上有很大的差异,但是核心机制都是为了保证CPU的执行指令原子性。

1.分层实现

cpu只能确保一少部分的cpu指令执行操作的原子性,比如读read操作。但是一旦涉及到复杂的指令,其指令的原子性又该如何完成?

锁是通过分层实现,才能保证cpu执行的指令原子性

总的来说可以分为四大层来实现,cpu为底层(根基),提供可读取的执行指令;其次是操作系统(OS)层,负责调度管理线程,操作系统提供了mutex(互斥锁)来使得竞争锁失败的线程加入阻塞队列,调度管理线程。随后是JVM层,java虚拟机把底层复杂逻辑封装,统一语句;最上层是应用层(代码层),我们可以直接使用synchronized等关键字来为代码块加锁。

在这四层的共同作用下,分级传递从而实现了加锁的操作。这四层就好比一个金字塔缺一不可。

(二)互斥锁(mutex)

操作系统接收到下层硬件cpu提供的指令,从而创建的互斥锁(mutex)。mutex的核心作用用于管理执行的线程。当线程竞争锁失败时,mutex需要决定是让这个loser线程是先原地自旋不要放弃cpu核心,还是阻塞wait等待。此外线程的唤醒也是操作系统使用mutex来实现。

(三)ReentrantLock

ReentrantLock直译为可重入锁,可重入锁的特性是在一个持有该锁的线程内部,对同一把可重入锁连续加锁不会触发死锁。我们知道synchronized也是可重入锁,ReentrantLock和synchronized的区别是什么?下面是简单的介绍。

1.显示加锁/解锁

synchronized是隐式加锁解锁,遇到" { "就加锁,遇到" } "就解锁,加锁/解锁操作都是JVM自己完成的,无需我们手动实现。而ReentrantLock就不同,必须显示加锁lock解锁unlock。

java 复制代码
    private static ReentrantLock locker = new ReentrantLock();

    public static void main(String[] args) throws InterruptedException {
        Thread t = new Thread(()->{
            locker.lock();//加锁
            try {
                System.out.println("hello cheems");
            }finally {//解锁unlock操作必须定义在finally代码块中
                locker.unlock();//解锁
            }
        });
        t.start();
        Thread.sleep(1000);
        System.out.println("hello main");
    }

finally中定义解锁unlock操作,使得即使程序抛出异常或者提前return了,也会执行finally内部的代码块,使得锁可以被正常释放掉,其他线程能获取到锁。

2功能差异.
特性 synchronized 关键字 (JVM 内建锁) ReentrantLock 类 (API 锁)
锁的释放 隐式/自动释放:在代码块结束或抛出异常时,JVM 自动释放。 显式/手动释放 :必须在 finally 块中调用 unlock(),否则可能造成死锁。
等待可中断性 不可中断:线程必须一直等待,直到获取到锁。 可中断 :支持 lockInterruptibly(),等待锁的线程可以响应中断信号并退出等待。
锁申请方式 不可尝试:获取失败时线程只能阻塞。 可尝试 :支持 tryLock()tryLock(timeout, unit),线程可以尝试获取锁,失败则返回或等待一段时间后放弃。
公平性 非公平锁(由 JVM 实现,无法配置)。 可选公平锁 :可以在构造函数中指定为公平锁(new ReentrantLock(true)),按等待时间排队。默认为非公平锁。
条件变量 单一条件 :依赖 Object 上的 wait(), notify(), notifyAll(),所有等待线程在同一个队列中。 多个条件 :通过 lock.newCondition() 创建多个 Condition 对象,可以实现多路通知,更加精准地唤醒特定组的等待线程。
锁绑定 只能与对象绑定。 可以与 Condition 对象绑定,实现更灵活的同步策略。
底层实现 基于 JVM Monitor ,涉及 锁升级 机制(偏向锁 -> 轻量级锁 -> 重量级锁)。 基于 AQS(AbstractQueuedSynchronizer) 框架。

上图一个ai总结的synchronized和ReentrantLock的功能差异表。可以看到ReentrantLock的功能相较于synchronized更多也更加灵活。但是使用时注意的细节也更多了,日常使用synchronized加锁即可解决大部分问题。毕竟用synchronized都是由JVM去执行锁操作,需要我们程序员操心的变少了嘛~

二,锁策略分类

锁策略是在遇到锁冲突的事件时,操作系统调度管理参与锁冲突的线程的策略。

下面是一些简单的锁策略相关的知识,不必了解太深,只需要简单弄清其原理。

(一)乐观锁vs悲观锁

简单理解起见,悲观锁就是操作系统预测接下来会产生锁冲突的概念很大,持有悲观态度,于是多做准备以应不便

乐观锁就相反,预测接下来产生锁冲突的概率小,不做复杂耗时的操作,对未来发生锁冲突事件持有乐观态度。

以上厕所为例子。悲观锁的策略就是一个人进入了厕所隔间,但是他总是担心自己上厕所时,会有人从外面强行进入(发生锁冲突),于是悲观锁就在进入厕所之后给大门外面挂一个"有人勿扰"的告示牌,并在门内反锁。

synchronized和ReentrantLock就是悲观锁,针对频繁的线程写操作时就使用这一类锁。
与此相反,乐观锁的策略就是当一个人进入厕所之后,仅仅在门外的牌子上做一个标记Mark Word。当其他人进入之后,Mark Word会被随之修改。当乐观的人出来之后看到了被修改的记号,就知道了有别人也进来了,于是就进行更新检查操作。

在涉及到大量的读取read操作时,对于共享变量的修改不那么频繁时,就可以使用乐观锁,比如原子类(Atomic类)就是提供了atomicInteger,毕竟锁竞争少了嘛~

(二)重量级锁vs轻量级锁

重量级锁和轻量级的划分依据的量级是什么?其实就是锁竞争的激烈程度

当锁竞争不激烈时,对于加锁操作就尽量使用CAS来在用户态上完成,不涉及到操作系统的mutex内核的切换。毕竟从用户态切换到内核态这个过程耗时 而且不可控,内核操作黑盒有风险,直接在用户态完成锁操作看得见,耗时短,用起来更放心~~。

轻量级锁适用于轻度锁竞争的情况。
当锁竞争激烈时,就需要进行大量的线程调度,调用内核mutex来管理线程的wait/notify,这就带来了很大的开销。

(三)挂起等待锁vs ⾃旋锁

挂起等待和自旋也是锁竞争时的一种操作系统调度线程的特殊策略。

假如两个线程产生了锁竞争,失败的loser线程这个时候就有两个抉择;

挂起等待锁会让这个线程先放弃cpu核心,进入wait等待状态。当锁被释放了才有机会获取到锁,这种策略我们熟间的wait操作相似。

但是获取到锁的线程持有锁的时间大部分情况下都是很短了,毕竟同步的机制使得同步代码块中尽量不要有大量的代码。这就导致了loser线程可能刚放弃cpu核心去wait了,下一刻锁就被释放掉了。那我不是白放弃了嘛。与此就有了自旋锁。

自选锁会使得竞争锁失败的线程暂时不要先放弃cpu核心,先别走再搁门口等一会儿。在等待的期间,自旋锁会反复的进行确认过程,确认锁是否被释放掉了,自己能否获取到锁。就好比厕所外的人并不是看到厕所里有人就直接走了,而是先搁门外出着,不断的拧门把手,看看里面的人结束了没。但是也不是一直自旋,也有一个自旋时间上限,当超出了上限该wait也是要wait,该放弃核心也还是要放弃核心

自旋锁也是一种编译器锁优化的结果,减少了无效的线程阻塞,避免了线程恢复调度频繁的上下文切换。适用于线程持有锁时间很短的情况

(四)公平锁vs⾮公平锁

公平锁和非公平锁的最大区别是分配锁的顺序

公平锁的分配策略是当锁释放时,优先把锁分配给阻塞队列中wait最久的线程,讲究一个先来后到,后进来的线程要想拿到锁,得先排队轮到你了才能拿到锁,突显一个公平二字

而非公平锁讲究的就是一个手慢无了,非公平锁的分配策略是优先分配给参与锁竞争的线程,这就会导致可能阻塞队列中wait的线程迟迟获取不到锁,引发线程饿死的问题。

(五)可重⼊锁vs不可重⼊锁

可重入锁是指一个线程在持有锁的情况下,再一次加锁(嵌套调用同一把锁)不会产生死锁的特性。synchronized和ReentrantLock就是可重入锁,下面是简单的代码演示

java 复制代码
import java.util.concurrent.locks.ReentrantLock;

//TIP To <b>Run</b> code, press <shortcut actionId="Run"/> or
// click the <icon src="AllIcons.Actions.Execute"/> icon in the gutter.
public class Main {
    private static Object locker = new Object();
    private static ReentrantLock locker2 = new ReentrantLock();
    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(()->{
            synchronized (locker){
                synchronized (locker){
                    System.out.println("hello t1");
                }
            }
        });
        Thread t2 = new Thread(()->{
            locker2.lock();
            try{
                locker2.lock();
                try{
                    System.out.println("hello t2");
                }finally {
                    locker2.unlock();
                }
            }finally {
                locker2.unlock();
            }
        });
        t1.start();
        t2.start();
        Thread.sleep(1000);
        System.out.println("hello main");
    }
}

从运行结果上来看并不会出现死锁导致程序卡死的情况出现,可以说可重入锁就是为了规避死锁而生的

不可重入锁在日常里基本用不上,加锁不当还有死锁风险。java里也没有提供构造不可重入锁对象的构造方法或者关键字,也就是日常直接使用synchronized可重入锁就行了。

(六)读写锁vs互斥锁

互斥锁严格规定共享变量在同一时间只允许一个线程去访问并修改,即使线程只是进行线程安全的读操作,也只能一个线程去读其他线程不能进入。
读写锁就比较灵活,区分了线程读read操作和write写操作。当线程申请对共享资源的读取时,读写锁会使得read操作可以并发执行,也就是允许你多个线程一块读;但是写操作还是严格限制了只能一个线程去修改,不能并发修改。

形象来说,如果把共享资源比作一个图书馆。互斥锁限制所有人无论是阅读书刊还是要移动借阅书刊都只给一个人的权限去完成。不允许多个人同时借阅,只有上一个人看完了出来了才有机会去阅读书籍。读写锁就比较灵活,它区分了阅读和外借书籍这两个操作,允许同一时间可以有多个人同时阅读,但是涉及到借书操作(写操作),就严格限制一个人串行完成,你借书的时候我不能去借,你把书还回来了我才有资格去借着看。

三,synchronized

介绍了上面的一系列的锁策略,那么我们所熟悉的synchronized又有哪些特性呢?

(一)synchronized原理

synchronized总共包含的锁特性为一下几点

1.初始是乐观锁,视情况变为悲观锁

2.初始是轻量级锁并采用自旋锁的操作,视情况变为重量级锁

3.是互斥锁

4.是可重入锁

5.是非公平锁

(二)JVM锁优化机制

一个线程执行的任务可能很简单,也可能逻辑很复杂;为了提高程序运行的效率,减少无效的开销,JVM提供了几种synchronized锁优化的机制

1.锁升级

锁升级是指一个锁从无锁-->偏向锁-->轻量级锁/自旋锁-->重量级锁的这四个升级过程,也可以叫做锁的自适应过程。

1)无锁

无锁是指不加锁的状态,也就是synchronized还没遇到" { "时的状态,此时还没真正意义上的加锁,既然这也是一个锁优化机制,无锁是如何保证指令原子性呢?

无锁是使用CAS操作(Compare and swap,比较和交换)来实现对数据的安全修改,把昂贵的阻塞线程带来的开销,转为CAS开销小的同时还能保证单个共享变量被多线程同时修改不会出现线程安全问题。是一种比较巧妙的机制。有关CAS 操作这里先不展开叙述,之后会另开一篇简单介绍。

2)偏向锁

偏向锁简单来理解就是懒加锁, 什么是懒加锁?假如一把锁从程序运行开始到结束,整个过程都只被一个线程使用,那就这个时候就没必要真正意义上加锁,毕竟不会产生锁竞争的事件~~。但也要注意,偏向锁和无锁的最大区别是无锁是使用CAS保证共享变量被安全修改,但是偏向锁只是给锁添加了一个标记Mark Word,通过这个标记来确保共享变量不被其他线程修改,也就是偏向锁相比无锁涉及到了锁操作。

标记的作用是当没有锁竞争时 ,和无锁一样什么不处理 ;但是即将产生锁竞争时 ,偏向锁就会即使把锁给加上,避免其他线程拿到锁

这个标记Mark Word存储在锁对象中,这个标记何时撤销呢?不能说标记加上了即使线程访问完成走了标记还存在吧。其实这里也有一个标记Mark Word撤销策略。

当线程B再次访问这个变量时,线程B可能会遇到两个场景

1.厕所没人,变量没被加锁。检查标记,如果看到有了标记,说明是线程A留下的,更新标记

2.厕所有人,产生锁竞争,Mark Word更新变为轻量级标记,偏向锁升级为轻量级锁

要注意偏向锁是基于乐观场景假设:对锁竞争的频率持有乐观态度,一旦竞争增大,就会升级为轻量级锁。

3)轻量级锁/自旋锁

轻量级锁的锁状态往往伴随着锁自旋。在锁竞争不激烈,且锁持有时间很短时,使用自旋来替代线程阻塞,避免昂贵的上下文切换开销。锁自旋在上文锁策略已经简单讲解了,这里不再过多赘述。当锁竞争频率很高时。就会升级为重量级锁

4)重量级锁

重量级锁是在锁竞争频率非常高时JVM锁升级的最终状态,此时就会由之前的用户态管理线程调度 ,切换到mutex内核来管理线程的阻塞,挂起和唤醒操作。

要注意重量级的重并不是针对频繁加锁操作带来的额外开销很重。虽然加锁也会带来开销,但是占影响运行效率的大头是线程之间锁竞争带来的wait阻塞引发的,也就是说开销重在阻塞等待,重在内核态切换上,而不是重在加锁操作上,有关这一点的概念一定要弄明白~~

2.锁消除

锁消除是JVM通过检查一个线程是否存在无效的加锁解锁操作,无效是指没有外部线程来竞争锁时线程仍旧加锁解锁的情况。此时JVM就会把这些无效的锁操作给去除掉。

比如StringBuffer提供的内部方法就是通过加synchronized锁来实现线程安全的字符串原地修改操作。当在单线程下重复的使用StringBuffer修改字符串时,就会涉及到频繁的加锁-解锁操作,这在JVM看来是没有必要的(又没人和你抢你还防范意识这么重),就把这些重复的锁操作合并了

3.锁粗化

锁粗化是JVM检查是否存在一个线程连续对同一个锁对象连续加锁解锁的细粒度操作,从而合并为一个加锁解锁的粗粒度优化过程。

粒度可以简单理解为synchronized代码块中执行的代码,一次锁操作内部执行 的代码越多,锁的粒度就越粗;synchronized同步代码块中执行的代码越少,锁的粒度就越细。

每一次加锁操作都可能会引发锁竞争导致线程阻塞,加锁解锁多了,阻塞的发生概率也就高了。

锁粗化的作用就是:JVM通过锁粗化的优化方式,把针对同一个锁对象连续多次加锁-解锁的细粒度锁操作,全部合并为一个粗粒度的锁操作减少了加锁解锁的频率,降低了触发锁竞争的概率

相关推荐
清水白石0082 小时前
《Python × 数据库:用 SQLAlchemy 解锁高效 ORM 编程的艺术》
开发语言·python·json
武子康2 小时前
Java-199 JMS Queue/Topic 集群下如何避免重复消费:ActiveMQ 虚拟主题与交付语义梳理
java·分布式·消息队列·rabbitmq·activemq·mq·java-activemq
风中月隐2 小时前
C语言中以坐标的方式图解“字母金字塔”的绘制
c语言·开发语言·算法·字母金子塔·坐标图解法
LSL666_2 小时前
12 MyBatis的连接池
java·服务器·mybatis
Arva .2 小时前
说说线程的生命周期和状态
java·开发语言
1001101_QIA2 小时前
C++中不能复制只能移动的类型
开发语言·c++
tryxr2 小时前
HashTable、HashMap、ConcurrentHashMap 之间的区别
java·开发语言·hash
serendipity_hky2 小时前
【go语言 | 第5篇】channel——多个goroutine之间通信
开发语言·后端·golang
无事好时节2 小时前
Linux 线程
java·开发语言·rpc