目录
1.前言
2.synchronized的特性
2.1synchronized前言
2.2乐观锁和悲观锁
2.3重量级锁和轻量级锁
重量级锁 :
轻量级锁:
2.4自旋锁和挂起等待锁
2.5 公平锁和非公平锁
公平锁:
非公平锁:
2.6可重入锁和不可重入锁
可重入锁
不可重入锁:
2.7读写锁
3.sychronized原理和特点
1) 偏向锁
2) 轻量级锁
3) 重量级锁
我们都知道在多线程编程中,线程安全问题是很严重的问题。为了解决线程安全问题,我们引入了“锁这个概念”,Java中的锁是用snychrnized关键字来实现的,它是一种基于对象的锁。虽然在日常编程中,我们可以直接使用这个关键字,而不去考虑它内部的机制。但是常言道,朝闻道,夕死足以。在学习过程中我们更应该去庖丁解牛的深入理解它,而不是不求甚解。本篇文章,作者将带领大家重新认识sychrnized关键字,以及各种锁背后的机制和原因。
虽然在Java中,我们只需要使用一个简单的synchronzied来实现锁,但是它的内部的实现却不仅仅只是个简单的锁,是一个很复杂的过程。以下我们要讲的特性,主要是来给锁的实现者来实现的。普通的程序员也需要了解一下,可以让我们更深刻的理解锁这个概念。
悲观锁:总是假设最坏的情况,每次拿数据的时候都会觉得别人会把它修改,所以在每次拿数据的时候都会上锁,这样别人想拿到这个数据就会阻塞直到它拿到锁。
乐观锁:假设数据一般情况下不会产生并发冲突,所以在数据进行提交更新的时候,才会正式对数据是否产生并发冲突进行检测,如果发现并发冲突了,则返回用户错误的信息,让用户决定如何去做。
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,并没有大于内存中的版本号,所以并没有成功。
不满足 “提交版本必须大于记录当前版本才能执行更新“ 的乐观锁策略。就认为这次操作失败
锁的核心特性 "原子性", 这样的机制追根溯源是 CPU 这样的硬件设备提供的.
CPU 提供了 "原子操作指令".
操作系统基于 CPU 的原子指令, 实现了 mutex 互斥锁.
JVM 基于操作系统提供的互斥锁, 实现了 synchronized 和 ReentrantLock 等关键字和类.
synchornized 不仅仅是对mutex进行封装,在内部还做了很多其它的工资。
加锁机制重度依赖了 OS 提供了 mutex
大量的内核态用户态切换
很容易引发线程的调度
这两个操作, 成本比较高. 一旦涉及到用户态和内核态的切换, 就意味着 "沧海桑田".
加锁机制尽可能不使用 mutex, 而是尽量在用户态代码完成. 实在搞不定了, 再使用 mutex.
少量的内核态用户态切换.
不太容易引发线程调度
sychronized开始是一个轻量级锁,如果锁冲突比较严重,就会变成重量级锁。
按之前的方式,线程在抢锁失败后进入阻塞状态,放弃 CPU,需要过很久才能再次被调度.
但实际上, 大部分情况下,虽然当前抢锁失败,但过不了很久,锁就会被释放。没必要就放弃 CPU. 这个时候就可以使用自旋锁来处理这样的问题.
自旋锁就是没抢到锁,然后一直在cpu的处理下,尝试抢锁。
伪代码:
while(抢锁(lock)== 失败{
}
如果获取锁失败, 立即再尝试获取锁, 无限循环, 直到获取到锁为止. 第一次获取锁失败, 第二次的尝试会在极短的时间内到来.
一旦锁被其他线程释放, 就能第一时间获取到锁.
而挂起等待锁,就是陷入沉睡。等待被唤醒,并不像自旋锁一样,一直在尝试获取锁。
自旋锁是一种典型的轻量级锁实现方式:
优点: 没有放弃 CPU, 不涉及线程阻塞和调度, 一旦锁被释放, 就能第一时间获取到锁.
缺点: 如果锁被其他线程持有的时间比较久, 那么就会持续的消耗 CPU 资源. (而挂起等待的时候是
不消耗 CPU 的)
synchronized 中的轻量级锁策略大概率就是通过自旋锁的方式实现的.
假设三个线程,ABC,A先尝试获取,获取成功。然后B在尝试获取,获取失败,阻塞等待。,然后C也尝试获取,获取失败阻塞等待。
遵循先来后到原则,B比C先来,当A 释放锁以后,B就能先C获取到锁
并没有这种先来后到的原则,而是随即调度。
操作系统内部的线程调度就可以视为是随机的. 如果不做任何额外的限制, 锁就是非公平锁. 如果要
想实现公平锁, 就需要依赖额外的数据结构, 来记录线程们的先后顺序.公平锁和非公平锁没有好坏之分, 关键还是看适用场景.
顾名思义。就是一个线程可以重复获取同一个锁多次,然后在依次释放。但是sychornized内部并不是真的加了很多把锁,而是通过计数器,如果加锁则计数器+1,如果释放锁则计数器-1。当计数器为0的时候,就会彻底释放锁。
一把锁只能同时被一个线程,拥有一次。
Linux系统提供的mutex锁是不可重入锁。
sychornizerd是可重入锁。
在多线程里面。数据的读取之间是不会产生线程安全问题的,但是数据的写入会产生。数据写入和读者之间都需要互斥。如果两个场景用一把锁,会也很大的开销。为了这种常见的应用场景,所以Java引入了读写锁(reders-writer lock)
一个线程对于数据的访问,有读和写两种操作:
如果都是读操作,那么就没有线程安全问题,直接并发读取就行。
如果都要写一个数据,就会有这个线程安全问题。
如果一个读一个写,也会有。
总结:写操作的时候会有线程安全问题。
其中:
读加锁和读加锁不互斥。
写加锁和写加锁互斥。
读加锁和写加锁互斥。
只要是涉及到 "互斥", 就会产生线程的挂起等待. 一旦线程挂起, 再次被唤醒就不知道隔了多
久了.
因此尽可能减少 "互斥" 的机会, 就是提高效率的重要途径
Java标准库中,ReentranReadwritelock类,实现了读写锁。
ReetranReadwrite.ReadLock表示一个读锁,这个对象提供了lock和unlock方法进行加锁和解锁。
ReentranReawritelock.writeLock 这个类表示一个写锁,也提供了lock和unlock方法进行加锁和解锁。
读写锁特别适合于 "频繁读, 不频繁写" 的场景中
Synchronized 不是读写锁.
结合上面的锁策略,我们可以总结出,synchronized有以下特性:
1. 开始时是乐观锁, 如果锁冲突频繁, 就转换为悲观锁.
2. 开始是轻量级锁实现, 如果锁被持有的时间较长, 就转换成重量级锁.
3. 实现轻量级锁的时候大概率用到的自旋锁策略
4. 是一种不公平锁
5. 是一种可重入锁
6. 不是读写锁
加锁过程:
JVM 将 synchronized 锁分为 无锁、偏向锁、轻量级锁、重量级锁 状态。会根据情况,进行依次升级
第一个尝试加锁的线程, 优先进入偏向锁状态.
偏向锁不是真的 "加锁", 只是给对象头中做一个 "偏向锁的标记", 记录这个锁属于哪个线程.
如果后续没有其他线程来竞争该锁, 那么就不用进行其他同步操作了(避免了加锁解锁的开销)
如果后续有其他线程来竞争该锁(刚才已经在锁对象中记录了当前锁属于哪个线程了, 很容易识别
当前申请锁的线程是不是之前记录的线程), 那就取消原来的偏向锁状态, 进入一般的轻量级锁状态.
偏向锁本质上相当于 "延迟加锁" . 能不加锁就不加锁, 尽量来避免不必要的加锁开销.
但是该做的标记还是得做的, 否则无法区分何时需要真正加锁.
随着其他线程进入竞争, 偏向锁状态被消除, 进入轻量级锁状态(自适应的自旋锁).
此处的轻量级锁就是通过 CAS 来实现.
通过 CAS 检查并更新一块内存 (比如 null => 该线程引用)
如果更新成功, 则认为加锁成功
如果更新失败, 则认为锁被占用, 继续自旋式的等待(并不放弃 CPU).
如果竞争进一步激烈, 自旋不能快速获取到锁状态, 就会膨胀为重量级锁
此处的重量级锁就是指用到内核提供的 mutex .
执行加锁操作, 先进入内核态.
在内核态判定当前锁是否已经被占用
如果该锁没有占用, 则加锁成功, 并切换回用户态.
如果该锁被占用, 则加锁失败. 此时线程进入锁的等待队列, 挂起. 等待被操作系统唤醒.
经历了一系列的沧海桑田, 这个锁被其他线程释放了, 操作系统也想起了这个挂起的线程, 于是唤醒
这个线程, 尝试重新获取锁.
其他的优化操作
锁消除
编译器+JVM 判断锁是否可消除. 如果可以, 就直接消除.
什么是 "锁消除
有些应用程序的代码中, 用到了 synchronized, 但其实没有在多线程环境下. (例如 StringBuffer)
StringBuffer sb = new StringBuffer();
sb.append("a");
sb.append("b");
sb.append("c");
sb.append("d");
此时每个 append 的调用都会涉及加锁和解锁. 但如果只是在单线程中执行这个代码, 那么这些加
锁解锁操作是没有必要的, 白白浪费了一些资源开销
锁粗化
一段逻辑中如果出现多次加锁解锁, 编译器 + JVM 会自动进行锁的粗化.
实际开发过程中, 使用细粒度锁, 是期望释放锁的时候其他线程能使用锁.
但是实际上可能并没有其他线程来抢占这个锁. 这种情况 JVM 就会自动把锁粗化, 避免频繁申请释
放锁.