【JavaEE】常见的锁策略 -- 多线程篇(4)

文章目录

  • 乐观锁 vs 悲观锁
  • 读写锁
  • 重量级锁 vs 轻量级锁
  • 自旋锁(Spin Lock)
  • 公平锁 vs 非公平锁
  • 可重入锁 vs 不可重入锁

乐观锁 vs 悲观锁

  • 悲观锁:
    • 总是假设最坏的情况,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会阻塞直到它拿到锁。
  • 乐观锁:
    • 假设数据一般情况下不会产生并发冲突,所以在数据进行提交更新的时候,才会正式对数据是否产生并发冲突进行检测,如果发现并发冲突了,则让返回用户错误的信息,让用户决定如何去做。

【JavaEE】常见的锁策略 -- 多线程篇(4)_第1张图片

  • 这两种思路不能说谁优谁劣, 而是看当前的场景是否合适.
  • 如果当前老师确实比较忙, 那么使用悲观锁的策略更合适, 使用乐观锁会导致 “白跑很多趟”, 耗费额外的资源
  • 如果当前老师确实比较闲, 那么使用乐观锁的策略更合适, 使用悲观锁会让效率比较低.

Synchronized 初始使用乐观锁策略. 当发现锁竞争比较频繁的时候, 就会自动切换成悲观锁策略.

在这里插入图片描述

乐观锁的一个重要功能就是要检测出数据是否发生访问冲突. 我们可以引入一个 “版本号” 来解决.

  • 假设我们需要多线程修改 “用户账户余额”.

  • 设当前余额为 100. 引入一个版本号 version, 初始值为 1. 并且我们规定 "提交版本必须大于记录当前版本才能执行更新余额

    1. 线程 A 此时准备将其读出( version=1, balance=100 ),线程 B 也读入此信息( version=1, balance=100 )【JavaEE】常见的锁策略 -- 多线程篇(4)_第2张图片
    1. 线程 A 操作的过程中并从其帐户余额中扣除 50( 100-50 ),线程 B 从其帐户余额中扣除 20 ( 100-20 );【JavaEE】常见的锁策略 -- 多线程篇(4)_第3张图片
    1. 线程 A 完成修改工作,将数据版本号加1( version=2 ),连同帐户扣除后余额( balance=50 ),写回到内存中;【JavaEE】常见的锁策略 -- 多线程篇(4)_第4张图片
    1. 线程 B 完成了操作,也将版本号加1( version=2 )试图向内存中提交数据( balance=80 ),但此时比对版本发现,操作员 B 提交的数据版本号为 2 ,数据库记录的当前版本也为 2 ,不满足 “提交版本必须大于记录当前版本才能执行更新“ 的乐观锁策略。就认为这次操作失败.【JavaEE】常见的锁策略 -- 多线程篇(4)_第5张图片

读写锁

多线程之间,数据的读取方之间不会产生线程安全问题,但数据的写入方互相之间以及和读者之间都需要进行互斥。如果两种场景下都用同一个锁,就会产生极大的性能损耗。所以读写锁因此而产生。

读写锁(readers-writer lock),看英文可以顾名思义,在执行加锁操作时需要额外表明读写意图,复数读者之间并不互斥,而写者则要求与任何人互斥。

一个线程对于数据的访问, 主要存在两种操作: 读数据 和 写数据.

  • 两个线程都只是读一个数据, 此时并没有线程安全问题. 直接并发的读取即可.
  • 两个线程都要写一个数据, 有线程安全问题.
  • 一个线程读另外一个线程写, 也有线程安全问题.

读写锁就是把读操作和写操作区分对待. Java 标准库提供了 ReentrantReadWriteLock 类, 实现了读写锁

  • ReentrantReadWriteLock.ReadLock 类表示一个读锁. 这个对象提供了 lock / unlock 方法进行加锁解锁.
  • ReentrantReadWriteLock.WriteLock 类表示一个写锁. 这个对象也提供了 lock / unlock 方法进行加锁解锁.

其中,

  • 读加锁和读加锁之间, 不互斥.
  • 写加锁和写加锁之间, 互斥.
  • 读加锁和写加锁之间, 互斥.

读写锁特别适合于 “频繁读, 不频繁写” 的场景中. (这样的场景其实也是非常广泛存在的)

Synchronized 不是读写锁.

重量级锁 vs 轻量级锁

锁的核心特性 “原子性”, 这样的机制追根溯源是 CPU 这样的硬件设备提供的

  • CPU 提供了 “原子操作指令”.
  • 操作系统基于 CPU 的原子指令, 实现了 mutex 互斥锁.
  • JVM 基于操作系统提供的互斥锁, 实现了 synchronized 和 ReentrantLock 等关键字和类.

【JavaEE】常见的锁策略 -- 多线程篇(4)_第6张图片
注意: 注意, synchronized 并不仅仅是对 mutex 进行封装, 在 synchronized 内部还做了很多其他的工作

重量级锁: 加锁机制重度依赖了 OS 提供了 mutex

  • 大量的内核态用户态切换
  • 很容易引发线程的调度

这两个操作, 成本比较高. 一旦涉及到用户态和内核态的切换, 就意味着 “沧海桑田”.

轻量级锁: 加锁机制尽可能不使用 mutex, 而是尽量在用户态代码完成. 实在搞不定了, 再使用 mutex.

  • 少量的内核态用户态切换.
  • 不太容易引发线程调度.

【JavaEE】常见的锁策略 -- 多线程篇(4)_第7张图片
synchronized 开始是一个轻量级锁. 如果锁冲突比较严重, 就会变成重量级锁.

自旋锁(Spin Lock)

按之前的方式,线程在抢锁失败后进入阻塞状态,放弃 CPU,需要过很久才能再次被调度.

但实际上, 大部分情况下,虽然当前抢锁失败,但过不了很久,锁就会被释放。没必要就放弃 CPU. 这个时候就可以使用自旋锁来处理这样的问题.

自旋锁伪代码

while (抢锁(lock) == 失败) {}

如果获取锁失败, 立即再尝试获取锁, 无限循环, 直到获取到锁为止. 第一次获取锁失败, 第二次的尝试会在极短的时间内到来.

一旦锁被其他线程释放, 就能第一时间获取到锁.

自旋锁是一种典型的 轻量级锁 的实现方式.

  • 优点: 没有放弃 CPU, 不涉及线程阻塞和调度, 一旦锁被释放, 就能第一时间获取到锁.
  • 缺点: 如果锁被其他线程持有的时间比较久, 那么就会持续的消耗 CPU 资源. (而挂起等待的时候是不消耗 CPU 的).

synchronized 中的轻量级锁策略大概率就是通过自旋锁的方式实现的.

公平锁 vs 非公平锁

假设三个线程 A, B, C. A 先尝试获取锁, 获取成功. 然后 B 再尝试获取锁, 获取失败, 阻塞等待; 然后C 也尝试获取锁, C 也获取失败, 也阻塞等待.

当线程 A 释放锁的时候, 会发生啥呢?

  • 公平锁: 遵守 “先来后到”. B 比 C 先来的. 当 A 释放锁的之后, B 就能先于 C 获取到锁.
  • 非公平锁: 不遵守 “先来后到”. B 和 C 都有可能获取到锁.

注意:

  • 操作系统内部的线程调度就可以视为是随机的. 如果不做任何额外的限制, 锁就是非公平锁. 如果要想实现公平锁, 就需要依赖额外的数据结构, 来记录线程们的先后顺序.
  • 公平锁和非公平锁没有好坏之分, 关键还是看适用场景.

synchronized 是非公平锁

可重入锁 vs 不可重入锁

  • 可重入锁的字面意思是“可以重新进入的锁”,即允许同一个线程多次获取同一把锁。

  • 比如一个递归函数里有加锁操作,递归过程中这个锁会阻塞自己吗?如果不会,那么这个锁就是可重入锁(因为这个原因可重入锁也叫做递归锁)。

  • Java里只要以Reentrant开头命名的锁都是可重入锁,而且JDK提供的所有现成的Lock实现类,包括synchronized关键字锁都是可重入的。

  • 而 Linux 系统提供的 mutex 是不可重入锁.

【JavaEE】常见的锁策略 -- 多线程篇(4)_第8张图片
synchronized 是可重入锁

可重入锁实现的理解

  • 如何知道上锁的线程是哪个, 如何知道上锁的线程是否已经上过锁了?
    • 可重入锁, 是让锁记录了当前是哪个线程持有了锁
    • 第一次上锁就是真正的上锁了, 再次上锁就只是增加上锁计数器
  • 如何知道何时解锁?
    • 让锁对象不光要记录时哪个线程持有的锁, 同时在通过一个整形变量记录当前这个线程加了此次锁
    • 每遇到一个加锁操作, 计数器 +1, 每遇到一个解锁操作, 就 -1
    • 当计算器被减为 0 的时候, 才真正的执行释放锁操作, 其它时候不释放

你可能感兴趣的:(#,Java,java-ee,性能优化,数据库)