Java 中各种锁的概念和应用场景
锁的种类
乐观锁 VS 悲观锁
悲观锁:认为在使用数据的时候一定有别的线程来修改数据,因此在获取数据的时候会先加锁,确保数据不会被别的线程修改。Java中,synchronized关键字和Lock的实现类都是悲观锁,还有数据库的 for UPDATE。
乐观锁:认为自己在使用数据时不会有别的线程修改数据,所以不会添加锁,只是在更新数据的时候去判断之前有没有别的线程更新了这个数据。(如果已被修改则重试或提交异常)。
乐观锁机为无锁机制,如atomic类的cas算法自旋实现,版本号机制,如数据库version字段。
悲观锁适合写操作多的场景,先加锁可以保证写操作时数据正确。
对于资源竞争严重(线程冲突严重)的情况,CAS自旋的概率会比较大,从而浪费更多的CPU资源,效率低于synchronized等同步悲观锁。
乐观锁适合读操作多的场景,不加锁的特点能够使其读操作的性能大幅提升,增加吞吐量。
对于资源竞争较少(线程冲突较轻)的情况,使用synchronized同步锁进行线程阻塞和唤醒切换(用户态、内核态间的切换操作额外浪费消耗cpu资源);而CAS基于硬件实现,不需要进入内核,不需要切换线程,操作自旋几率较少,可以获得更高的性能
自旋锁 VS 阻塞锁
阻塞或唤醒一个Java线程需要操作系统切换CPU状态来完成,这种状态转换需要耗费处理器时间。如果同步代码块中的内容过于简单,状态转换消耗的时间有可能比用户代码执行的时间还要长。
在许多场景中,同步资源的锁定时间很短,为了这一小段时间去切换线程,线程挂起和恢复现场的花费可能会让系统得不偿失。如果物理机器有多个处理器,能够让两个或以上的线程同时并行执行,我们就可以让后面那个请求锁的线程不放弃CPU的执行时间,看看持有锁的线程是否很快就会释放锁。
自旋等待虽然避免了线程切换的开销,但它要占用处理器时间。如果锁被占用的时间很短,自旋等待的效果就会非常好。反之,如果锁被占用的时间很长,那么自旋的线程只会白浪费处理器资源。所以,自旋等待的时间必须要有一定的限度,如果自旋超过了限定次数(默认是10次,可以使用-XX:PreBlockSpin来更改)没有成功获得锁,就应当挂起线程。
自旋锁的实现原理同样也是CAS,AtomicInteger中调用unsafe进行自增操作的源码中的do-while循环就是一个自旋操作,如果修改数值失败则通过循环来执行自旋,直至修改成功。

**自适自旋锁:**自旋的时间(次数)不再固定,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。如果在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也是很有可能再次成功,进而它将允许自旋等待持续相对更长的时间。如果对于某个锁,自旋很少成功获得过,那在以后尝试获取这个锁时将可能省略掉自旋过程,直接阻塞线程,避免浪费处理器资源。
java中自旋锁的简单实现方式:
当有第二个线程调用lock操作时由于owner值不为空,导致循环一直被执行,直至第一个线程调用unlock函数将owner设置为null,第二个线程才能进入临界区。
1 | public class SpinLock { |
自旋锁的其他种类: 在自旋锁中 另有三种常见的锁形式: TicketLock ,CLHlock ,MCSlock
自旋锁的优点:
自旋锁不会使线程状态发生切换,一直处于用户态,即线程一直都是active的;不会使线程进入阻塞状态,减少了不必要的上下文切换,执行速度快
非自旋锁在获取不到锁的时候会进入阻塞状态,从而进入内核态,当获取到锁的时候需要从内核态恢复,需要线程上下文切换。 (线程被阻塞后便进入内核(Linux)调度状态,这个会导致系统在用户态与内核态之间来回切换,严重影响锁的性能)
自旋锁的缺点:
消耗CPU:如果某个线程持有锁的时间过长,就会导致其它等待获取锁的线程进入循环等待。使用不当会造成CPU使用率极高。
非公平:自旋锁不是公平的,即无法满足等待时间最长的线程优先获取锁。不公平的锁就会存在“线程饥饿”问题。
Ticket锁主要解决的是访问顺序的问题(公平性),主要的问题是在多核cpu上。
最先请求获取锁的线程可以最先获取到锁
1 | public class TicketLock { |
TicketLock存在的问题:
多处理器系统上,每个进程/线程占用的处理器都在读写同一个变量serviceNum ,每次读写操作都必须在多个处理器缓存之间进行缓存同步,这会导致繁重的系统总线和内存的流量,大大降低系统整体的性能。
CLHLock 和MCSLock 则是两种类型相似的公平锁,采用链表的形式进行排序,
1 | public class CLHLock { |
CLHlock是不停的查询前驱变量, 导致不适合在NUMA 架构下使用(在这种结构下,每个线程分布在不同的物理内存区域)
MCSLock则是对本地变量的节点进行循环。不存在CLHlock 的问题。
1 | public class MCSLock { |
CLH 的队列是隐式的队列,没有真实的后继结点属性。
MCS 的队列是显式的队列,有真实的后继结点属性。
JAVA中,能够进入\退出、阻塞状态或包含阻塞锁的方法有 ,synchronized ,ReentrantLock,Object.wait()和notify(), LockSupport.park()和unpart()
1 | public class CLHLock { |
阻塞锁的优势在于,阻塞的线程不会占用cpu时间, 不会导致 CPu占用率过高,但进入时间以及恢复时间都要比自旋锁略慢。在竞争激烈的情况下 阻塞锁的性能要明显高于 自旋锁。
在线程竞争不激烈的情况下,使用自旋锁,竞争激烈的情况下使用阻塞锁。
无锁 VS 偏向锁
偏向锁实际上是一种锁优化的,其目的是为了减少数据在无竞争情况下的性能消耗。
核心思想就是锁会偏向第一个获取它的线程,在接下来的执行过程中该锁没有其他的线程获取,则持有偏向锁的线程永远不需要再同步。
偏向锁的获取
当一个线程访问同步块并获取锁时,会在对象头和栈帧中的锁记录里储存锁偏向的线程ID。以后该线程在进入和退出同步块时不需要进行CAS操作来加锁和解锁,只需要检查当前Mark Word中储存的线程是否指向当前线程,如果成功,表示已经获得对象锁;如果检测失败,则需要再测试一下Mark Word中偏向锁的标志是否已经被置为1(表示当前锁是偏向锁):如果没有则使用CAS操作竞争锁,如果设置了,则尝试使用CAS将对象头的偏向锁指向当前线程。
偏向锁的撤销
偏向锁使用一种等待竞争出现才释放锁的机制,所以当有其他线程尝试获得锁时,才会释放锁。偏向锁的撤销,需要等到安全点。它首先会暂停拥有偏向锁的线程,然后检查持有偏向锁的线程是否活着,如果不处于活动状态,则将对象头设置为无锁状态;如果依然活动,拥有偏向锁的栈会被执行,遍历偏向对象的锁记录,栈中的锁记录和对象头的Mark Word要么重新偏向其他线程,要么恢复到无锁或者标记对象不合适作为偏向锁(膨胀为轻量级锁),最后唤醒暂停的线程。
关闭偏向锁
偏向锁在Java运行环境中默认开启,但是不会随着程序启动立即生效,而是在启动几秒种后才激活,可以使用参数关闭延迟:
-XX:BiasedLockingStartupDelay=0
同样可以关闭偏向锁(程序默认进入轻量级锁)
-XX:UseBiasedLocking=false
轻量级锁 VS 重量级锁
重量级锁就是最开始的线程阻塞操作,又叫悲观锁。
相对轻量级而言,重量级锁的阻塞挂起/唤醒线程需要从底层的的用户态转为内核态 ,消耗CPU资源。
轻量级锁是JDK1.6之中加入的新型锁机制,它并不是来代替重量级锁的,他的本意是在没有多线程竞争的前提下,减少传统的重量级锁使用操作系统互斥量产生的性能消耗。
轻量级锁加锁
线程在执行同步块之前,JVM会现在当前线程的栈帧中创建用于储存锁记录的空间(LockRecord),并将对象头的Mark Word信息复制到锁记录中。然后线程尝试使用CAS将对象头的MarkWord替换为指向锁记录的指针。
如果成功,当前线程获得锁,并且对象的锁标志位转变为“00”,如果失败,表示其他线程竞争锁,当前线程便会尝试自旋获取锁。
如果有两条以上的线程竞争同一个锁,那么轻量级锁就不再有效,要膨胀为重量级锁,锁标志的状态变为“10”,MarkWord中储存的就是指向重量级锁(互斥量)的指针,后面等待的线程也要进入阻塞状态。
轻量级锁解锁
轻量级锁解锁时,同样通过CAS操作将对象头换回来。如果成功,则表示没有竞争发生。如果失败,说明有其他线程尝试过获取该锁,锁同样会膨胀为重量级锁。在释放锁的同时,唤醒被挂起的线程。
公平锁 VS 非公平锁
公平锁(Fair Lock): 若等待队列非空,则直接入队;否则尝试获取锁
当一个线程尝试获取锁时,即使当前锁是可用的(没有被占用),它也会先检查等待队列中是否有其他线程在排队。
如果有排队线程,当前线程会直接进入队列末尾排队,而不是“插队”获取锁。
保证了“先来先得”的公平性,避免线程饥饿。
**非公平锁(Non-fair Lock):**先尝试获取锁,获取失败后,放入队列
当一个线程尝试获取锁时,不管等待队列中是否有其他线程,它都会首先尝试直接获取锁(CAS 抢占)。
如果抢锁成功 → 直接获得锁,不管队列里有没有“老员工”在排队。
如果抢锁失败 → 才进入等待队列排队。
这样可能导致“插队”,但吞吐量通常更高,因为减少了线程挂起/唤醒的开销。
1 | ReentrantLock fairLock = new ReentrantLock(true); // 公平锁 |
| 特性 | 公平锁 | 非公平锁 |
|---|---|---|
| 获取锁时行为 | 检查队列,有排队则入队 | 直接尝试抢锁,失败才入队 |
| 吞吐量 | 较低(频繁上下文切换) | 较高(减少排队,直接抢占) |
| 公平性 | 保证 FIFO,无饥饿 | 不保证,可能插队 |
| 默认 | 否 | 是 |
可重入锁 VS 非可重入锁
可重入锁又名递归锁,是指在同一个线程在外层方法获取锁的时候,再进入该线程的内层方法会自动获取锁(前提是锁对象是同一个对象或者class),不会因为之前已经获取过还没释放而阻塞。
Java中
ReentrantLock和synchronized都是可重入锁,可重入锁的一个优点是可一定程度避免死锁。非可重入锁
NonReentrantLock。
为什么可重入锁就可以在嵌套调用时可以自动获得锁呢?
举例:有多个人在排队打水,此时管理员允许锁和同一个人的多个水桶绑定。这个人用多个水桶打水时,第一个水桶和锁绑定并打完水之后,第二个水桶也可以直接和锁绑定并开始打水,所有的水桶都打完水之后打水人才会将锁还给管理员。这个人的所有打水流程都能够成功执行,后续等待的人也能够打到水。
![]()
可重入锁的实现
可重入锁,也叫做递归锁,指的是同一线程,在外层函数获得锁之后 ,内层递归函数仍然可以获取该锁。
1 | public class Test implements Runnable { |
可重入锁最大的作用是避免死锁
对于自旋锁来说, 若有同一线程两次调用lock() ,会导致第二次调用lock位置进行自旋,产生了死锁。说明这个锁并不是可重入的。
自旋锁实现可重入锁:
1 | public class SpinLock1 { |
独享锁(互斥) VS 共享锁
独享锁(Exclusive Lock / 互斥锁)
同一时刻只允许一个线程持有该锁,其他线程必须等待锁释放后才能获取。
互斥性:保证线程安全,避免数据竞争。
适用于写操作或需要独占资源的场景。
性能较低(因为并发度低),但安全性高。
Java 中的实现:
synchronized关键字(隐式锁)
ReentrantLock(显式锁,默认是独享锁)
1
2
3
4
5
6
7
8
9
10
11
12
13
14 public class Counter {
private int count = 0;
private final ReentrantLock lock = new ReentrantLock();
// 同一时刻只有一个线程能执行 `increment()` 方法。
public void increment() {
lock.lock();
try {
count++;
} finally {
lock.unlock();
}
}
}
共享锁(Shared Lock / 读锁)
允许多个线程同时持有该锁,前提是这些线程的操作是“兼容”的(如多个读操作)。
允许多个线程并发读取,提高并发性能。
不允许与写操作(独享锁)同时进行。
适用于“读多写少”的场景。
Java 中的实现:
ReentrantReadWriteLock.ReadLock(共享锁)
ReentrantReadWriteLock.WriteLock(独享锁)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25 // 多个线程可同时调用 `read()`,但调用 `write()` 时,其他读/写线程都必须等待。
public class Data {
private String value;
private final ReentrantReadWriteLock rwLock = new ReentrantReadWriteLock();
// 读操作 - 共享锁
public String read() {
rwLock.readLock().lock();
try {
return value;
} finally {
rwLock.readLock().unlock();
}
}
// 写操作 - 独享锁
public void write(String newValue) {
rwLock.writeLock().lock();
try {
this.value = newValue;
} finally {
rwLock.writeLock().unlock();
}
}
}
| 特性 | 独享锁(互斥锁) | 共享锁(读锁) |
|---|---|---|
| 持有者数量 | 只能被一个线程持有 | 可被多个线程同时持有 |
| 适用场景 | 写操作、修改资源 | 读操作、查询资源 |
| 并发性 | 低(串行化) | 高(允许多读) |
| 典型实现 | synchronized,ReentrantLock |
ReentrantReadWriteLock.ReadLock |
| 与对方的兼容性 | 不能与其他任何锁共存 | 可与其他共享锁共存,但不能与独享锁共存 |
| 性能 | 较低(阻塞其他线程) | 较高(读操作可并发) |
读 >> 写 ➜ 用
ReentrantReadWriteLock(共享读 + 独占写)写频繁 或 读写差不多 ➜ 用
ReentrantLock或synchronized(简单高效)追求极致性能 + 读多写少 ➜ 甚至可以考虑
StampedLock(Java 8+,支持乐观读)
如何避免死锁
活锁:线程的状态可以改变但是却不能继续执行
死锁发生四个条件:
-
互斥条件:一个资源每次只能被一个线程使用。要求对所分配的资源(如打印机)进行排他性控制,即在一段时间内某 资源仅为一个线程所占有。此时若有其他线程请求该资源,则请求线程只能等待。
-
请求与保持条件:一个线程因请求资源而阻塞时,对已获得的资源保持不放(线程已经保持了至少一个资源,但又提出了新的资源请求,而该资源已被其他线程占有,此时请求线程被阻塞,但对自己已获得的资源保持不放)。
-
不剥夺条件:线程已获得的资源,在末使用完之前,不能强行剥夺(只能是主动释放)。
-
循环等待条件:若干线程之间形成一种头尾相接的循环等待资源关系。
当以上四个条件均满足,必然会造成死锁,发生死锁的线程无法进行下去,它们所持有的资源也无法释放。这样会导致CPU的吞吐量下降。当多个线程需要相同的一些锁,但是按照不同的顺序加锁,死锁就很容易发生。
避免死锁
(破坏其中一个条件即可):
最简单的方法就是阻止循环等待条件。将系统中所有的资源设置标志位、排序,规定所有的线程申请资源必须以一定的顺序(升序或降序)做操作来避免死锁,获取所有锁的顺序保持一致。
让一个线程一次性申请所有的资源(破坏请求与保持条件)
加锁添加时限或通过 Lock 可重入锁,释放线程占用的锁(线程尝试获取锁的时候加上一定的时限,超过时限则放弃对该锁的请求,并释放自己占有的锁)(破坏不剥夺条件)
加锁顺序保持一致(线程按照一定的顺序加锁,确保所有的线程都是按照相同的顺序获得锁)(破坏循环等待条件)
死锁检查
死锁可能导致线程池满,线程无法销毁,一直占用。服务假死状态,CPU飙升
使用jps, jstack 或 jconsole 工具dump线程分析,它是 jdk 自带的线程堆栈分析工具。
arthas 分析线程
1 | jps -l #用jps来找到当前java的进程号,l表示列出路径 |
死锁复现
解决一下死锁,只需要将加锁顺序改成一样,即都先加锁o1,再加锁o2
1 | /** |
对象头与锁
锁就保存在对象头中。
Hotpot虚拟机的对象头分两部分信息:Mark Word(标记字段)、Klass Pointer(类型指针)。
Mark Word:
默认存储对象的HashCode,分代年龄和锁标志位信息。这些信息都是与对象自身定义无关的数据,所以Mark Word被设计成一个非固定的数据结构以便在极小的空间内存存储尽量多的数据。它会根据对象的状态复用自己的存储空间,也就是说在运行期间Mark Word里存储的数据会随着锁标志位的变化而变化。这部分数据长度在32位和64位虚拟机中分别为32bit和64bit,它又称为“MarkWord”,它是实现锁的关键。
Klass Point:
对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。如果是数组的话,还有一个额外的空间储存数组长度。
它的变化状态如下所示

如:synchronized是悲观锁,在操作同步资源之前需要给同步资源先加锁,这把锁就是存在Java对象头里的
锁优化
主要是jdk中针对 Synchronized 关键字的优化。
Java 中的 synchronized 是最常用的同步机制,但早期版本(JDK 1.5 之前)性能较差,因为每次加锁/解锁都要调用操作系统内核的 Mutex(互斥量),涉及用户态到内核态切换,开销大。
从 JDK 1.6 开始,JVM 对 synchronized 做了大量优化,引入了 “锁优化” 手段:
锁消除(Lock Elimination)
锁粗化(Lock Coarsening)
自旋锁 & 自适应自旋(Spin Lock)
偏向锁(Biased Locking)
轻量级锁(Lightweight Locking)
重量级锁(Heavyweight Locking)→ 即“锁膨胀”
锁消除
JVM 在 JIT 编译时,通过逃逸分析(Escape Analysis) 发现某个锁对象不会被多个线程访问(即“未逃逸”),则直接移除不必要的加锁操作。
对象是局部变量,且不会被其他线程访问。
字符串拼接中
StringBuffer的append()方法(内部有synchronized,但局部使用时可消除)。
1 | // append() 是同步方法,但因为 sb 是局部变量,不会被其他线程访问,JVM 会消除锁,等价于用 StringBuilder。 |
默认开启(JDK 1.6+),可通过参数控制:
1 | -XX:+DoEscapeAnalysis # 开启逃逸分析(默认开启) |
锁粗化
如果一段代码中对同一个对象反复加锁、解锁(如循环内),JVM 会将多个加锁操作合并成一个范围更大的锁,减少加解锁次数。
1 | // 源代码 |
1 | // 实际代码 |
锁升级(锁膨胀)
指锁从轻量级 → 重量级的升级过程。这是 synchronized 实现的核心机制之一。
Java 对象头中有一个 Mark Word,用于存储锁状态。锁有四种状态(按竞争强度升级)
在Java中,锁共有4种状态,级别从低到高依次为:无状态锁,偏向锁,轻量级锁和重量级锁状态,这几个状态会随着竞争情况逐渐升级。锁可以升级但不能降级。
锁的四种状态由低到高依次为:无锁 -> 偏向锁 -> 轻量级锁 -> 重量级锁
并且四种状态会随着竞争的情况逐渐升级,而且是不可逆的过程
| 锁 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 偏向锁 | 加锁和解锁不需要额外的消耗,和执行非同步方法相比仅存在纳秒级的差距 | 如果线程间存在锁竞争,会带来额外的锁撤销的消耗 | 适用于只有一个线程访问同步块场景 |
| 轻量级锁 | 竞争的线程不会阻塞,提高了程序的响应速度 | 如果始终得不到索竞争的线程,使用自旋会消耗CPU | 追求响应速度,同步块执行速度非常快 |
| 重量级锁 | 线程竞争不使用自旋,不会消耗CPU | 线程阻塞,响应时间缓慢 | 追求吞吐量,同步块执行速度较慢 |
无锁:指没有对资源进行锁定,所有的线程都能访问并修改同一个资源,但同时只有一个线程能修改成功。果有多个线程修改同一个值,必定会有一个线程能修改成功,而其他修改失败的线程会不断重试直到修改成功。
偏向锁:指当一段同步代码一直被同一个线程所访问时,即不存在多个线程的竞争时,那么该线程在后续访问时便会自动获得锁,从而降低获取锁带来的消耗,即提高性能。
- 初次执行到synchronized代码块的时候,锁对象变成偏向锁(通过CAS修改对象头里的锁标志位。执行完同步代码块后,线程并不会主动释放偏向锁,当第二次到达同步代码块时,线程会判断此时持有锁的线程是否就是自己,如果是则正常往下执行。由于之前没有释放锁,也就没有额外开销。偏向锁只有遇到其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁,线程是不会主动释放偏向锁的。
- 关于偏向锁的撤销,需要等待全局安全点,即在某个时间点上没有字节码正在执行时,它会先暂停拥有偏向锁的线程,然后判断锁对象是否处于被锁定状态。如果线程不处于活动状态,则将对象头设置成无锁状态,并撤销偏向锁,恢复到无锁(标志位为01)或轻量级锁(标志位为00)的状态。
轻量级锁:指当锁是偏向锁的时候,却被另外的线程所访问。存在少量锁竞争,此时偏向锁就会升级为轻量级锁,其他线程会通过自旋的形式尝试获取锁,线程不会阻塞,从而提高性能(JVM默认自旋次数为 -XX:PreBlockSpin=10)。
- 轻量级锁的获取主要由两种情况:
① 当关闭偏向锁功能时;
② 由于多个线程竞争偏向锁导致偏向锁升级为轻量级锁。重量级锁:是指当有一个线程获取锁之后,其余所有等待获取该锁的线程都会处于阻塞状态(用户态 - 内核态)。由操作系统来负责线程间的调度和线程的状态变更。而这样会出现频繁地对线程运行状态的切换,线程的挂起和唤醒,从而消耗大量的系统资计数器记录自旋次数,达到最大自旋次数的线程,会将轻量级锁升级为重量级锁,当后续线程尝试获取锁时,发现被占用的锁是重量级锁,则直接将自己挂起(而不是忙等),等待将来被唤醒。
锁升级过程详解:
① 无锁 → 偏向锁
-
第一个线程访问同步块时,JVM 会将对象 Mark Word 设置为偏向该线程 ID。
-
后续该线程再进入,无需任何同步操作(零成本)。
-
若有其他线程竞争 → 撤销偏向锁 → 升级为轻量级锁。
注意:JDK 15 开始默认禁用偏向锁,JDK 17+ 已移除。
② 轻量级锁
-
通过 CAS(Compare And Swap) 尝试将对象头指向当前线程栈中的 Lock Record。
-
成功 → 获取锁。
-
失败 → 自旋重试(自适应自旋)。
-
自旋多次失败 → 升级为重量级锁。
③ 重量级锁
-
调用操作系统 Mutex,线程挂起进入阻塞队列。
-
由 OS 调度唤醒,开销最大。
-
但适合长时间持有锁或高竞争场景。
Java 提供的锁
造成线程安全问题的主要诱因:一是存在共享数据(也称临界资源),二是存在多条线程共同操作共享数据
Synchronized
又称 对象监视器(Object Monitor)。
当一个线程正在访问一个对象的 synchronized 实例方法,那么其他线程不能访问该对象的其他 synchronized 方法(一个对象只有一把锁),同一线程内是可重入的,如果两个实例对象获取同一把锁,则会出现锁失效:
Synchronized关键字会让没有得到锁资源的线程进入BLOCKED状态,而后在争夺到锁资源后恢复为RUNNABLE状态,这个过程中涉及到操作系统用户模式和内核模式的转换,代价比较高(优化:增加了从偏向锁到轻量级锁再到重量级锁的过度)
锁定范围
Synchronized 的使用不同,加锁范围会有所不同:
修饰一个代码块,被修饰的代码块称为同步语句块,其作用的范围是大括号{}括起来的代码,作用的对象是调用这个代码块的对象(一个线程访问一个对象中的synchronized(this)同步代码块时,其他试图访问该对象的线程将被阻塞),如果为参数对象(synchronized (dto.getLock())),则只会锁定阻塞同一对象;
修饰一个方法,被修饰的方法称为同步方法,其作用的范围是整个方法,作用的对象是调用这个方法的对象(两个对象间不会锁);
修饰一个静态的方法,其作用的范围是整个静态方法,作用的对象是这个类对象;
修饰一个类,其作用的范围是synchronized后面括号括起来的部分,作用主的对象是这个类的所有对象。
1 | /** |
如下:出现结果为小于2000000,获得两个不同实例对象的锁
1 | public class AccountingSyncBad implements Runnable{ |
1 | /** |
synchronized可保证一个线程的变化(主要是共享数据的变化)被其他线程所看到(保证可见性,完全可以替代Volatile功能)
应用方式:
-
修饰实例方法,作用于当前实例加锁,进入同步代码前要获得当前实例的锁
-
修饰静态方法,作用于当前类对象加锁,进入同步代码前要获得当前类对象的锁
-
修饰代码块,指定加锁对象,对给定对象加锁,进入同步代码库前要获得给定对象的锁。
作用:
原子性:确保线程互斥的访问同步代码;
可见性:保证共享变量的修改能够及时可见,其实是通过Java内存模型中的 “对一个变量unlock操作之前,必须要同步到主内存中;如果对一个变量进行lock操作,则将会清空工作内存中此变量的值,在执行引擎使用此变量前,需要重新从主内存中load操作或assign操作初始化变量值” 来保证的;
有序性:有效解决重排序问题,即 “一个unlock操作先行发生(happen-before)于后面对同一个锁的lock操作”;
原理实现
1 | javap -v -p XXX.class # 执行编译,查看字节码 |
如果synchronized修饰的是方法,会生成一个 ACC_SYNCHRONIZED 指令,如下:
1 | private synchronized void test() { |
如果synchronized修饰的是代码块,会生成一个 monitorenter 和 monitorexit 指令,如下:
1 | private void test2() { |
代码正常结束或抛出异常都会执行 monitorexit 退出监控锁。
monitor对象主要由以下几个字段来组成。
count 记录个数
wanitset 处于wait状态,会被加入到 waitset
entryList 处于等待锁状态的线程,会被加入到entryList中。
当monitor对象被线程持有时,count会加1,当线程释放monitor对象时,count会减1,用count表示monitor对象是否被持有。
而且synchronized有可重入性,当一个线程重复持有锁时,count会一直加,释放时候,会一直减,直到为0时,才算这块执行完。
重量级优化
通常我们称Sychronized锁是一种重量级锁,是因为在互斥状态下,没有得到锁的线程会被挂起阻塞,而挂起线程和恢复线程的操作都需要在用户态和 内核态之间相互转换,而状态转换很耗费处理器时间,故称为重量级。
为了消除用户态和 内核态的开销转换,引入自旋,所谓的自旋,就是让没有获得锁的线程自己运行一段时间的自循环,这就是自旋锁。
自旋锁在JDK6以后已经默认开启,可以通过-XX:+UseSpinning参数来开启。不挂起线程的代价就是该线程会一直占用处理器。如果锁被占用的时间很短,自旋等待的效果就会很好,反之,自旋会消耗大量处理器资源。因此,自旋的等待时间必须有一定的限度,如果超过限度还没有获得锁,就要挂起线程,这个限度默认是10次,可以使用-XX:PreBlockSpin改变。
在JDK6以后又引入了自适应自旋锁,也就说自旋的时间限度不是一个固定值了,而是由上一次同一个锁的自旋时间及锁的拥有者状态来决定。虚拟机认为,如果同一个锁对象自旋刚刚成功获得锁,那么下一次很可能获得锁,所以允许这次自旋锁自旋很长时间、而如果某个锁很少获得锁,那么以后在获取锁的过程中可能忽略到自旋过程。
Volatile
Java虚拟机提供的轻量级的同步机制,被volatile修饰的共享变量,就具有了以下两点特性:
1 . 保证了不同线程对该变量操作的内存可见性(当一个线程修改了被volatile修饰的值,新值总数可以被其他线程立即得知。);
2 . 禁止指令重排序(避免多线程环境下程序出现乱序执行的现象)。
并不保证安全性,不具有原子性(如果第二个线程在第一个线程读取旧值和写回新值期间读取i的域值)
在线程执行时,首先会从主存中read变量值,再load到工作内存中的副本中,然后再传给处理器执行,执行完毕后再给工作内存中的副本赋值,随后工作内存再把值传回给主存,主存中的值才更新。
两个线程操作(修改),可能出现缓存不一致的问题。(只有一个线程修改操作,其他线程只读取,不存在原子性问题,但是i++操作就不行了)
使用场景
只能在有限的情形下使用 volatile 变量替代锁。要使 volatile 变量提供理想的线程安全,必须同时满足下面两个条件:
- 对变量的写操作不依赖于当前值。
- 该变量没有包含在具有其他变量的不变式中。
指令重排
什么是指令重排序?
在虚拟机层面
为了尽可能减少*内存操作速度远慢于CPU运行速度*所带来的CPU空置的影响,虚拟机会按照自己的一些规则,将程序编写顺序打乱——即写在后面的代码在时间顺序上可能会先执行,而写在前面的代码会后执行——以尽可能充分地利用CPU。
拿上面的例子来说:假如不是a=1的操作,而是
a=new byte[1024*1024](分配1M空间)`,那么它会运行地很慢,此时CPU是等待其执行结束呢,还是先执行下面那句flag=true呢?显然,先执行flag=true可以提前使用CPU,加快整体效率,当然这样的前提是不会产生错误。虽然这里有两种情况:后面的代码先于前面的代码开始执行;前面的代码先开始执行,但当效率较慢的时候,后面的代码开始执行并先于前面的代码执行结束。不管谁先开始,总之后面的代码在一些情况下存在先结束的可能。
在硬件层面
CPU会将接收到的一批指令按照其规则重排序,同样是基于CPU速度比缓存速度快的原因,和上一点的目的类似,只是硬件处理的话,每次只能在接收到的有限指令范围内重排序,而虚拟机可以在更大层面、更多指令范围内重排序。硬件的重排序机制参见《从JVM并发看CPU内存指令重排序(Memory Reordering)》
原理实现
volatile如何让变量立即可见
当写一个volatile变量时,JMM会把该线程对应的工作内存中的共享变量值刷新到主内存中,当读取一个volatile变量时,JMM会把该线程对应的工作内存置为无效,那么该线程将只能从主内存中重新读取共享变量。volatile变量正是通过这种写-读方式实现对其他线程可见(但其内存语义实现则是通过内存屏障)
volatile禁止指令重排优化
内存屏障,又称内存栅栏,是一个CPU指令,它的作用有两个,一是保证特定操作的执行顺序,二是保证某些变量的内存可见性。
由于编译器和处理器都能执行指令重排优化
如果在指令间插入一条Memory Barrier则会告诉编译器和CPU,不管什么指令都不能和这条Memory Barrier指令重排序,也就是说通过插入内存屏障禁止在内存屏障前后的指令执行重排序优化。
Memory Barrier的另外一个作用是强制刷出各种CPU的缓存数据,因此任何CPU上的线程都能读取到这些数据的最新版本
典型的禁止重排优化的例子DCL
1 | public class DoubleCheckLock { |
字节码会生成一个 ACC_VOLATILE
Lock
API层面的可重入锁,包含ReentrantLock、ReadLock、ReadLockView、WriteLock、WriteLockView
如果n个线程持有的为同一把锁,则需要竞争且阻塞,否则有各自的锁时,不会阻塞。如:
1 | /** |
实现原理(AQS)
用一个CAS操作,判断state是否是0(表示当前锁未被占用),如果是0则把它+1,并且设置当前线程为该锁的独占线程,表示获取锁成功。当多个线程同时尝试占用同一个锁时,CAS操作只能保证一个线程操作成功,剩下的只能乖乖的去排队 。
“非公平”即体现在这里,如果占用锁的线程刚释放锁,state置为0,而排队等待锁的线程还未唤醒时,新来的线程就直接抢占了该锁,那么就“插队”了。
源码分析
公平锁:若等待队列非空,则直接入队;否则尝试获取锁
非公平锁:先尝试获取锁,获取失败后,放入队列
1 | // 设置 volatile int state = 1 成功表示加锁,0时释放锁,大于1表示重复加锁 |
加锁
第一步。尝试去获取锁。如果尝试获取锁成功,方法直接返回。非公平锁tryAcquire的流程是:检查state字段,若为0,表示锁未被占用,那么尝试占用,若不为0,检查当前锁是否被自己占用,若被自己占用,则更新state字段,表示重入锁的次数。如果以上两点都没有成功,则获取锁失败,返回false。
第二步,入队。其他线程占用锁,执行tryAcquire失败,并且入等待队列线程先入等待队列,然后开始自旋,尝试获取锁,获取成功就返回,失败则在队列里找一个安全点把自己挂起直到超时时间过期。这里为什么还需要循环呢?因为当前线程节点的前驱状态可能不是SIGNAL,那么在当前这一轮循环中线程不会被挂起,然后更新超时时间,开始新一轮的尝试。
第三步,挂起。已经入队的线程尝试获取锁,若失败则会被挂起。线程入队后能够挂起的前提是,它的前驱节点的状态为SIGNAL。若符合则返回true,将自己挂起。如果不符合,再看前驱节点是否>0(CANCELLED),若是向前遍历直到找到第一个符合要求的前驱,若不是则将前驱节点的状态设置为SIGNAL。
解锁
先尝试释放锁,若释放成功,那么查看头结点的状态是否为SIGNAL,如果是则唤醒头结点的下个节点关联的线程,如果释放失败那么返回false表示解锁失败
当前释放锁的线程若不持有锁,则抛出异常。若持有锁,计算释放后的state值是否为0,若为0表示锁已经被成功释放,并且则清空独占线程,最后更新state值,返回free。
ReentrantLock#lock的原理流程图大致如下

Atomic(CAS)
属于乐观锁,自旋等待直到成功,可设置自旋次数。
并发越高,失败的次数会越多,CAS如果长时间不成功,会极大的增加CPU的开销。因此CAS不适合竞争十分频繁的场景。
1 | // 自旋 |
CAS由于是在硬件层面保证的原子性,不会锁住当前线程,它的效率是很高的。
ABA 问题
避免ABA问题的版本号机制,如AtomicStampedReference。
1 | public int overTimeWaitMin(AbstractBaseGssp baseGssp, BaseDictDto dto, Map<String, AtomicStampedReference<AbstractBaseGssp>> mapReference) throws ParseException { |