[多线程]一篇文章带你看懂Java中的synchronized关键字(线程安全)锁的深入理解

2023-12-15 23:53:11

目录

1.前言

?2.synchronized的特性

2.1synchronized前言

2.2乐观锁和悲观锁

2.3重量级锁和轻量级锁

重量级锁 :

轻量级锁:

2.4自旋锁和挂起等待锁

2.5 公平锁和非公平锁

公平锁:

非公平锁:

2.6可重入锁和不可重入锁

可重入锁

不可重入锁:

2.7读写锁

3.sychronized原理和特点

1) 偏向锁

2) 轻量级锁

3) 重量级锁


1.前言

? 我们都知道在多线程编程中,线程安全问题是很严重的问题。为了解决线程安全问题,我们引入了“锁这个概念”,Java中的锁是用snychrnized关键字来实现的,它是一种基于对象的锁。虽然在日常编程中,我们可以直接使用这个关键字,而不去考虑它内部的机制。但是常言道,朝闻道,夕死足以。在学习过程中我们更应该去庖丁解牛的深入理解它,而不是不求甚解。本篇文章,作者将带领大家重新认识sychrnized关键字,以及各种锁背后的机制和原因。

?2.synchronized的特性

2.1synchronized前言

? 虽然在Java中,我们只需要使用一个简单的synchronzied来实现锁,但是它的内部的实现却不仅仅只是个简单的锁,是一个很复杂的过程。以下我们要讲的特性,主要是来给锁的实现者来实现的。普通的程序员也需要了解一下,可以让我们更深刻的理解锁这个概念。

2.2乐观锁和悲观锁

? 悲观锁:总是假设最坏的情况,每次拿数据的时候都会觉得别人会把它修改,所以在每次拿数据的时候都会上锁,这样别人想拿到这个数据就会阻塞直到它拿到锁。

乐观锁:假设数据一般情况下不会产生并发冲突,所以在数据进行提交更新的时候,才会正式对数据是否产生并发冲突进行检测,如果发现并发冲突了,则返回用户错误的信息,让用户决定如何去做。

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

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

假设我们要修改多线程"用户账户余额“

假设账户余额为100,版本号初始为1,并且我们规定,提交版本必须大于当前版本才能执行更新余额。

1)线程A此时准备将其独出(versio=1,balance = 100),线程B这时也读入此信息

2)线程A操作将账户余额扣50,线程B扣20

3)线程A和线程B完成修改操作,都将版本号改为2,此时线程A(versio=2,balance = 50),线程B(versio=2,balance = 80)

4)这时候,线程A从把操作完成,然后去写入内存。(此时线程A的版本号为2,可以成功写入 内存中的数据为线程A 此时修改过的数据versio=2,balance = 50),线程B再去写入的时候,版本号也是2,并没有大于内存中的版本号,所以并没有成功。

不满足 “提交版本必须大于记录当前版本才能执行更新“ 的乐观锁策略。就认为这次操作失败

2.3重量级锁和轻量级锁
?

? 锁的核心特性 "原子性", 这样的机制追根溯源是 CPU 这样的硬件设备提供的.
CPU 提供了 "原子操作指令".
?操作系统基于 CPU 的原子指令, 实现了 mutex 互斥锁.
?JVM 基于操作系统提供的互斥锁, 实现了 synchronized 和 ReentrantLock 等关键字和类.

synchornized 不仅仅是对mutex进行封装,在内部还做了很多其它的工资。

重量级锁 :

加锁机制重度依赖了 OS 提供了 mutex
大量的内核态用户态切换
很容易引发线程的调度
这两个操作, 成本比较高. 一旦涉及到用户态和内核态的切换, 就意味着 "沧海桑田".

轻量级锁:

加锁机制尽可能不使用 mutex, 而是尽量在用户态代码完成. 实在搞不定了, 再使用 mutex.
少量的内核态用户态切换.
不太容易引发线程调度

sychronized开始是一个轻量级锁,如果锁冲突比较严重,就会变成重量级锁。

2.4自旋锁和挂起等待锁

按之前的方式,线程在抢锁失败后进入阻塞状态,放弃 CPU,需要过很久才能再次被调度.
但实际上, 大部分情况下,虽然当前抢锁失败,但过不了很久,锁就会被释放。没必要就放弃 CPU. 这个时候就可以使用自旋锁来处理这样的问题.

自旋锁就是没抢到锁,然后一直在cpu的处理下,尝试抢锁。

伪代码:
while(抢锁(lock)== 失败{


}

如果获取锁失败, 立即再尝试获取锁, 无限循环, 直到获取到锁为止. 第一次获取锁失败, 第二次的尝试会在极短的时间内到来.
一旦锁被其他线程释放, 就能第一时间获取到锁.

而挂起等待锁,就是陷入沉睡。等待被唤醒,并不像自旋锁一样,一直在尝试获取锁。

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

优点: 没有放弃 CPU, 不涉及线程阻塞和调度, 一旦锁被释放, 就能第一时间获取到锁.
缺点: 如果锁被其他线程持有的时间比较久, 那么就会持续的消耗 CPU 资源. (而挂起等待的时候是
不消耗 CPU 的)
synchronized 中的轻量级锁策略大概率就是通过自旋锁的方式实现的.
?

2.5 公平锁和非公平锁

假设三个线程,ABC,A先尝试获取,获取成功。然后B在尝试获取,获取失败,阻塞等待。,然后C也尝试获取,获取失败阻塞等待。

公平锁:

?遵循先来后到原则,B比C先来,当A 释放锁以后,B就能先C获取到锁

非公平锁:

并没有这种先来后到的原则,而是随即调度。

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

?

2.6可重入锁和不可重入锁

可重入锁

顾名思义。就是一个线程可以重复获取同一个锁多次,然后在依次释放。但是sychornized内部并不是真的加了很多把锁,而是通过计数器,如果加锁则计数器+1,如果释放锁则计数器-1。当计数器为0的时候,就会彻底释放锁。

不可重入锁:

一把锁只能同时被一个线程,拥有一次。

Linux系统提供的mutex锁是不可重入锁。

sychornizerd是可重入锁。

2.7读写锁

? ? ? 在多线程里面。数据的读取之间是不会产生线程安全问题的,但是数据的写入会产生。数据写入和读者之间都需要互斥。如果两个场景用一把锁,会也很大的开销。为了这种常见的应用场景,所以Java引入了读写锁(reders-writer lock)

? ? ? ?一个线程对于数据的访问,有读和写两种操作:
如果都是读操作,那么就没有线程安全问题,直接并发读取就行。

如果都要写一个数据,就会有这个线程安全问题。

如果一个读一个写,也会有。

总结:写操作的时候会有线程安全问题。

其中:
读加锁和读加锁不互斥。

写加锁和写加锁互斥。

读加锁和写加锁互斥。

只要是涉及到 "互斥", 就会产生线程的挂起等待. 一旦线程挂起, 再次被唤醒就不知道隔了多
久了.
因此尽可能减少 "互斥" 的机会, 就是提高效率的重要途径
?

? ? ? Java标准库中,ReentranReadwritelock类,实现了读写锁。

ReetranReadwrite.ReadLock表示一个读锁,这个对象提供了lock和unlock方法进行加锁和解锁。

ReentranReawritelock.writeLock 这个类表示一个写锁,也提供了lock和unlock方法进行加锁和解锁。

读写锁特别适合于 "频繁读, 不频繁写" 的场景中
Synchronized 不是读写锁.
?

3.sychronized原理和特点

结合上面的锁策略,我们可以总结出,synchronized有以下特性:

1. 开始时是乐观锁, 如果锁冲突频繁, 就转换为悲观锁.
2. 开始是轻量级锁实现, 如果锁被持有的时间较长, 就转换成重量级锁.
3. 实现轻量级锁的时候大概率用到的自旋锁策略
4. 是一种不公平锁
5. 是一种可重入锁
6. 不是读写锁
?

加锁过程:
JVM 将 synchronized 锁分为 无锁、偏向锁、轻量级锁、重量级锁 状态。会根据情况,进行依次升级
?


?

1) 偏向锁


第一个尝试加锁的线程, 优先进入偏向锁状态.
偏向锁不是真的 "加锁", 只是给对象头中做一个 "偏向锁的标记", 记录这个锁属于哪个线程.
如果后续没有其他线程来竞争该锁, 那么就不用进行其他同步操作了(避免了加锁解锁的开销)
如果后续有其他线程来竞争该锁(刚才已经在锁对象中记录了当前锁属于哪个线程了, 很容易识别
当前申请锁的线程是不是之前记录的线程), 那就取消原来的偏向锁状态, 进入一般的轻量级锁状态.
偏向锁本质上相当于 "延迟加锁" . 能不加锁就不加锁, 尽量来避免不必要的加锁开销.
但是该做的标记还是得做的, 否则无法区分何时需要真正加锁.


2) 轻量级锁


随着其他线程进入竞争, 偏向锁状态被消除, 进入轻量级锁状态(自适应的自旋锁).
此处的轻量级锁就是通过 CAS 来实现.
通过 CAS 检查并更新一块内存 (比如 null => 该线程引用)
如果更新成功, 则认为加锁成功
如果更新失败, 则认为锁被占用, 继续自旋式的等待(并不放弃 CPU).
?

3) 重量级锁


如果竞争进一步激烈, 自旋不能快速获取到锁状态, 就会膨胀为重量级锁
此处的重量级锁就是指用到内核提供的 mutex .
执行加锁操作, 先进入内核态.
在内核态判定当前锁是否已经被占用
如果该锁没有占用, 则加锁成功, 并切换回用户态.
如果该锁被占用, 则加锁失败. 此时线程进入锁的等待队列, 挂起. 等待被操作系统唤醒.
经历了一系列的沧海桑田, 这个锁被其他线程释放了, 操作系统也想起了这个挂起的线程, 于是唤醒
这个线程, 尝试重新获取锁.
其他的优化操作
锁消除
编译器+JVM 判断锁是否可消除. 如果可以, 就直接消除.
什么是 "锁消除
有些应用程序的代码中, 用到了 synchronized, 但其实没有在多线程环境下. (例如 StringBuffer)

StringBuffer sb = new StringBuffer();
sb.append("a");
sb.append("b");
sb.append("c");
sb.append("d");

此时每个 append 的调用都会涉及加锁和解锁. 但如果只是在单线程中执行这个代码, 那么这些加
锁解锁操作是没有必要的, 白白浪费了一些资源开销
?

?锁粗化
一段逻辑中如果出现多次加锁解锁, 编译器 + JVM 会自动进行锁的粗化.

实际开发过程中, 使用细粒度锁, 是期望释放锁的时候其他线程能使用锁.
但是实际上可能并没有其他线程来抢占这个锁. 这种情况 JVM 就会自动把锁粗化, 避免频繁申请释
放锁.
?

文章来源:https://blog.csdn.net/weixin_64173948/article/details/135005543
本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。