volatile 关键字

在 Java 中,volatile 关键字可以保证变量的可见性,如果我们将变量声明为 volatile ,这就指示 JVM,这个变量是共享且不稳定的,每次使用它都到主存中进行读取。

如何保证内存可见性的?

Java的内存模型中,每个线程会有一个私有本地内存的抽象概念,正常情况下线程操作普通共享变量时都会在本地内存修改和读取,那就导致别的线程感知不到,出现可见性问题。而当一个共享变量被volatile修饰时,它会保证修改的值会立即被更新到主内存,当有其他线程需要读取时,它也会去主内存中读取新值。这样就解决的可见性问题。

image-20240515194622895

JMM(Java 内存模型)

JMM(Java 内存模型)强制在主存中进行读取

JMM(Java 内存模型)强制在内存中进行读取

volatile 关键字能保证数据的可见性,但不能保证数据的原子性。synchronized 关键字两者都能保证。

当一个线程读到后,另一个线程修改了以后,第一个线程是重新读还是他线程里面的数自己改变?

当一个线程读取一个 volatile 变量后,如果另一个线程修改了该变量的值,第一个线程在后续访问该变量时会重新读取最新的值。这是由于 volatile 变量的可见性特性,保证了变量的修改对其他线程的可见性。

指令重排

在 Java 中,volatile 关键字除了可以保证变量的可见性,还有一个重要的作用就是防止 JVM 的指令重排序。 如果我们将变量声明为 volatile ,在对这个变量进行读写操作的时候,会通过插入特定的 内存屏障 的方式来禁止指令重排序。

在 Java 中,Unsafe 类提供了三个开箱即用的内存屏障相关的方法,屏蔽了操作系统底层的差异:

1
2
3
public native void loadFence();
public native void storeFence();
public native void fullFence();

理论上来说,你通过这个三个方法也可以实现和volatile禁止重排序一样的效果,只是会麻烦一些

经典例子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class VolatileResort {
static int num = 0;
static boolean flag = false;
public static void init() {
num= 1;
flag = true;
}
public static void add() {
if (flag) {
num = num + 5;
System.out.println("num:" + num);
}
}
public static void main(String[] args) {
init();
new Thread(() -> {
add();
},"子线程").start();
}
}

先看线程1中指令重排:

1
num= 1;flag = true;` 的执行顺序变为` flag=true; num = 1;

如果线程2 num=num+5 在线程1设置num=1之前执行,那么线程2的num变量值为5。如下图所示的时序图

image-20240515210625482

我们使用volatile定义flag变量:

1
static volatile boolean flag = false;

原理:在volatile生成的指令序列前后插入内存屏障(Memory Barries)来禁止处理器重排序。

有如下四种内存屏障

image-20240515212435542

volatile写的场景如何插入内存屏障:

  • 在每个volatile写操作的前面插入一个StoreStore屏障(写-写 屏障)。
  • 在每个volatile写操作的后面插入一个StoreLoad屏障(写-读 屏障)。
image-20240515212208558

StoreStore屏障可以保证在volatile写(flag赋值操作flag=true)之前,其前面的所有普通写(num的赋值操作num=1) 操作已经对任意处理器可见了,保障所有普通写在volatile写之前刷新到主内存。

volatile读场景如何插入内存屏障:

  • 在每个volatile读操作的后面插入一个LoadLoad屏障(读-读 屏障)
  • 在每个volatile读操作的后面插入一个LoadStore屏障(读-写 屏障)
image-20240515212924311

LoadStore屏障可以保证其后面的所有普通写(num的赋值操作num=num+5) 操作必须在volatile读(if(flag))之后执行。

双重校验锁实现对象单例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class Singleton {

private volatile static Singleton uniqueInstance;

private Singleton() {
}

public static Singleton getUniqueInstance() {
//先判断对象是否已经实例过,没有实例化过才进入加锁代码
if (uniqueInstance == null) {
//类对象加锁
synchronized (Singleton.class) {
if (uniqueInstance == null) {
uniqueInstance = new Singleton();
}
}
}
return uniqueInstance;
}
}

uniqueInstance 采用 volatile 关键字修饰也是很有必要的, uniqueInstance = new Singleton(); 这段代码其实是分为三步执行:

  1. uniqueInstance 分配内存空间
  2. 初始化 uniqueInstance
  3. uniqueInstance 指向分配的内存地址

但是由于 JVM 具有指令重排的特性,执行顺序有可能变成 1->3->2。指令重排在单线程环境下不会出现问题,但是在多线程环境下会导致一个线程获得还没有初始化的实例。

例如,线程 T1 执行了 1 和 3,如果另外一个线程执行if(uniqueInstance== null) `时,则返回刚刚分配的内存地址,但是对象还没有初始化完成,拿到的uniqueInstance是个假的。

什么是悲观锁?

悲观锁总是假设最坏的情况,认为共享资源每次被访问的时候就会出现问题(比如共享数据被修改),所以每次在获取资源操作的时候都会上锁,这样其他线程想拿到这个资源就会阻塞直到锁被上一个持有者释放。也就是说,共享资源每次只给一个线程使用,其它线程阻塞,用完后再把资源转让给其它线程

像 Java 中synchronizedReentrantLock等独占锁就是悲观锁思想的实现。

1
2
3
4
5
6
7
8
9
10
11
12
13
public void performSynchronisedTask() {
synchronized (this) {
// 需要同步的操作
}
}

private Lock lock = new ReentrantLock();
lock.lock();
try {
// 需要同步的操作
} finally {
lock.unlock();
}

高并发的场景下,激烈的锁竞争会造成线程阻塞,大量阻塞线程会导致系统的上下文切换,增加系统的性能开销。并且,悲观锁还可能会存在死锁问题,影响代码的正常运行。

什么是乐观锁?

乐观锁总是假设最好的情况,认为共享资源每次被访问的时候不会出现问题,线程可以不停地执行,无需加锁也无需等待,只是在提交修改的时候去验证对应的资源(也就是数据)是否被其它线程修改了(具体方法可以使用版本号机制或 CAS 算法)。

如何实现乐观锁?

乐观锁一般会使用版本号机制或 CAS 算法实现,CAS 算法相对来说更多一些,这里需要格外注意。

版本号机制

一般是在数据表中加上一个数据版本号 version 字段,表示数据被修改的次数。当数据被修改时,version 值会加一。当线程 A 要更新数据值时,在读取数据的同时也会读取 version 值,在提交更新时,若刚才读取到的 version 值为当前数据库中的 version 值相等时才更新,否则重试更新操作,直到更新成功。

举一个简单的例子:假设数据库中帐户信息表中有一个 version 字段,当前值为 1 ;而当前帐户余额字段( balance )为 $100 。

  1. 操作员 A 此时将其读出( version=1 ),并从其帐户余额中扣除 $50( $100-$50 )。
  2. 在操作员 A 操作的过程中,操作员 B 也读入此用户信息( version=1 ),并从其帐户余额中扣除 $20 ( $100-$20 )。
  3. 操作员 A 完成了修改工作,将数据版本号( version=1 ),连同帐户扣除后余额( balance=$50 ),提交至数据库更新,此时由于提交数据版本等于数据库记录当前版本,数据被更新,数据库记录 version 更新为 2 。
  4. 操作员 B 完成了操作,也将版本号( version=1 )试图向数据库提交数据( balance=$80 ),但此时比对数据库记录版本时发现,操作员 B 提交的数据版本号为 1 ,数据库记录当前版本也为 2 ,不满足 “ 提交版本必须等于当前版本才能执行更新 “ 的乐观锁策略,因此,操作员 B 的提交被驳回。

这样就避免了操作员 B 用基于 version=1 的旧数据修改的结果覆盖操作员 A 的操作结果的可能。

CAS 算法

CAS 的全称是 Compare And Swap(比较与交换) ,用于实现乐观锁,被广泛应用于各大框架中。CAS 的思想很简单,就是用一个预期值和要更新的变量值进行比较,两值相等才会进行更新。

CAS 是一个原子操作,底层依赖于一条 CPU 的原子指令。

原子操作 即最小不可拆分的操作,也就是说操作一旦开始,就不能被打断,直到操作完成。

CAS 涉及到三个操作数:

  • V:要更新的变量值(Var)
  • E:预期值(Expected)
  • N:拟写入的新值(New)

当且仅当 V 的值等于 E 时,CAS 通过原子方式用新值 N 来更新 V 的值。如果不等,说明已经有其它线程更新了 V,则当前线程放弃更新。

举一个简单的例子:线程 A 要修改变量 i 的值为 6,i 原值为 1(V = 1,E=1,N=6,假设不存在 ABA 问题)。

  1. i (V)与 1(E) 进行比较,如果相等, 则说明没被其他线程修改,可以被设置为 6 。
  2. i (V)与 1(E) 进行比较,如果不相等,则说明被其他线程修改,当前线程放弃更新,CAS 操作失败。

当多个线程同时使用 CAS 操作一个变量时,只有一个会胜出,并成功更新,其余均会失败,但失败的线程并不会被挂起,仅是被告知失败,并且允许再次尝试,当然也允许失败的线程放弃操作。

Java 语言并没有直接实现 CAS,CAS 相关的实现是通过 C++ 内联汇编的形式实现的(JNI 调用),调用的是底层的CAS原子操作。因此, CAS 的具体实现和操作系统以及 CPU 都有关系。

乐观锁存在哪些问题?

ABA 问题是乐观锁最常见的问题。

ABA 问题

如果一个变量 V 初次读取的时候是 A 值,并且在准备赋值的时候检查到它仍然是 A 值,那我们就能说明它的值没有被其他线程修改过了吗?很明显是不能的,因为在这段时间它的值可能被改为其他值,然后又改回 A,那 CAS 操作就会误认为它从来没有被修改过。这个问题被称为 CAS 操作的 “ABA”问题。

ABA 问题的解决思路是在变量前面追加上版本号或者时间戳。JDK 1.5 以后的 AtomicStampedReference 类就是用来解决 ABA 问题的,其中的 compareAndSet() 方法就是首先检查当前引用是否等于预期引用,并且当前标志是否等于预期标志,如果全部相等,则以原子方式将该引用和该标志的值设置为给定的更新值。

1
2
3
4
5
6
7
8
9
10
11
12
13
public boolean compareAndSet(V   expectedReference,
V newReference,
int expectedStamp,
int newStamp) {
Pair<V> current = pair;
return
expectedReference == current.reference &&
expectedStamp == current.stamp &&
((newReference == current.reference &&
newStamp == current.stamp) ||
casPair(current, Pair.of(newReference, newStamp)));
}

循环时间长开销大

CAS 经常会用到自旋操作来进行重试,也就是不成功就一直循环执行直到成功。如果长时间不成功,会给 CPU 带来非常大的执行开销。

如果 JVM 能支持处理器提供的 pause 指令那么效率会有一定的提升,pause 指令有两个作用:

  1. 可以延迟流水线执行指令,使 CPU 不会消耗过多的执行资源,延迟的时间取决于具体实现的版本,在一些处理器上延迟时间是零。
  2. 可以避免在退出循环的时候因内存顺序冲而引起 CPU 流水线被清空,从而提高 CPU 的执行效率。

只能保证一个共享变量的原子操作

CAS 只对单个共享变量有效,当操作涉及跨多个共享变量时 CAS 无效。但是从 JDK 1.5 开始,提供了AtomicReference类来保证引用对象之间的原子性,你可以把多个变量放在一个对象里来进行 CAS 操作,所以我们可以使用锁或者利用AtomicReference类把多个共享变量合并成一个共享变量来操作。

synchronized 是什么?

synchronized 是 Java 中的一个关键字,翻译成中文是同步的意思,主要解决的是多个线程之间访问资源的同步性,可以保证被它修饰的方法或者代码块在任意时刻只能有一个线程执行。

  • synchronized 可以保证操作的原子性和可见性
  • 如果⼀个对象有多个synchronized ⽅法,⼀个线程访问了这个对象的⼀个 synchronized ⽅法,其他线程就不能
    访问这个对象的任何⼀个 synchronized ⽅法
  • synchronize 可以加在代码块上,在多线程的情况下,如果⼀个线程访问了这个代码块,其他线程就⽆法访问
    这个对象的任何⼀个 synchronized ⽅法

在 Java 早期版本中,synchronized 属于 重量级锁,效率低下。这是因为监视器锁(monitor)是依赖于底层的操作系统的 Mutex Lock 来实现的,Java 的线程是映射到操作系统的原生线程之上的。如果要挂起或者唤醒一个线程,都需要操作系统帮忙完成,而操作系统实现线程之间的切换时需要从用户态转换到内核态,这个状态之间的转换需要相对比较长的时间,时间成本相对较高

不过,在 Java 6 之后, synchronized 引入了大量的优化如自旋锁、适应性自旋锁、锁消除、锁粗化、偏向锁、轻量级锁等技术来减少锁操作的开销,这些优化让 synchronized 锁的效率提升了很多。因此, synchronized 还是可以在实际项目中使用的,像 JDK 源码、很多开源框架都大量使用了 synchronized

关于偏向锁多补充一点:由于偏向锁增加了 JVM 的复杂性,同时也并没有为所有应用都带来性能提升。因此,在 JDK15 中,偏向锁被默认关闭(仍然可以使用 -XX:+UseBiasedLocking 启用偏向锁),在 JDK18 中,偏向锁已经被彻底废弃(无法通过命令行打开)

使用 synchronized的方式

synchronized 关键字的使用方式主要有下面 3 种:

  1. 修饰实例方法
  2. 修饰静态方法
  3. 修饰代码块

1、修饰实例方法 (锁当前对象实例)

给当前对象实例加锁,进入同步代码前要获得 当前对象实例的锁

1
2
3
synchronized void method() {
//业务代码
}

2、修饰静态方法 (锁当前类)

给当前类加锁,会作用于类的所有对象实例 ,进入同步代码前要获得 当前 class 的锁

这是因为静态成员不属于任何一个实例对象,归整个类所有,不依赖于类的特定实例,被类的所有实例共享。

1
2
3
synchronized static void method() {
//业务代码
}

静态 synchronized 方法和非静态 synchronized 方法之间的调用互斥么?不互斥!如果一个线程 A 调用一个实例对象的非静态 synchronized 方法,而线程 B 需要调用这个实例对象所属类的静态 synchronized 方法,是允许的,不会发生互斥现象,因为访问静态 synchronized 方法占用的锁是当前类的锁,而访问非静态 synchronized 方法占用的锁是当前实例对象锁。

3、修饰代码块 (锁指定对象/类)

对括号里指定的对象/类加锁:

  • synchronized(object) 表示进入同步代码库前要获得 给定对象的锁
  • synchronized(类.class) 表示进入同步代码前要获得 给定 Class 的锁
1
2
3
synchronized(this) {
//业务代码
}

总结:

  • synchronized 关键字加到 static 静态方法和 synchronized(class) 代码块上都是是给 Class 类上锁;
  • synchronized 关键字加到实例方法上是给对象实例上锁;
  • 尽量不要使用 synchronized(String a) 因为 JVM 中,字符串常量池具有缓存功能。

synchronized 和 volatile 有什么区别?

synchronized 关键字和 volatile 关键字是两个互补的存在,而不是对立的存在!

  • volatile 关键字是线程同步的轻量级实现,所以 volatile性能肯定比synchronized关键字要好 。但是 volatile 关键字只能用于变量而 synchronized 关键字可以修饰方法以及代码块 。
  • volatile 关键字能保证数据的可见性,但不能保证数据的原子性。synchronized 关键字两者都能保证。
  • volatile关键字主要用于解决变量在多个线程之间的可见性,而 synchronized 关键字解决的是多个线程之间访问资源的同步性。

synchronized 底层原理

synchronized 关键字底层原理属于 JVM 层面的东西。

synchronized 同步语句块的情况

1
2
3
4
5
6
7
public class SynchronizedDemo {
public void method() {
synchronized (this) {
System.out.println("synchronized 代码块");
}
}
}

通过 JDK 自带的 javap 命令查看 SynchronizedDemo 类的相关字节码信息:首先切换到类的对应目录执行 javac SynchronizedDemo.java 命令生成编译后的 .class 文件,然后执行javap -c -s -v -l SynchronizedDemo.class

synchronized关键字原理

从上面我们可以看出:**synchronized 同步语句块的实现使用的是 monitorentermonitorexit 指令,其中 monitorenter 指令指向同步代码块的开始位置,monitorexit 指令则指明同步代码块的结束位置。**

上面的字节码中包含一个 monitorenter 指令以及两个 monitorexit 指令,这是为了保证锁在同步代码块代码正常执行以及出现异常的这两种情况下都能被正确释放。

当执行 monitorenter 指令时,线程试图获取锁也就是获取 对象监视器 monitor 的持有权。

在 Java 虚拟机(HotSpot)中,Monitor 是基于 C++实现的,由ObjectMonitor实现的。每个对象中都内置了一个 ObjectMonitor对象

另外,wait/notify等方法也依赖于monitor对象,这就是为什么只有在同步的块或者方法中才能调用wait/notify等方法,否则会抛出java.lang.IllegalMonitorStateException的异常的原因。

对象监视器(Object Monitor):

  • 每个Java对象都有一个相关联的监视器。
  • 监视器用于控制对对象的并发访问,确保在任意时刻只有一个线程能够执行 synchronized 代码块或方法。

对象头(Object Header):

  • Java对象在内存中的存储包括对象头和实际数据。
  • 对象头包含了用于实现监视器的信息,包括锁的状态(是否被某个线程持有)等。
  • 当线程尝试进入 synchronized 代码块时,它必须先获取对象头中的相关信息。

锁的状态:

  • 对象头中的锁的状态可以是无锁状态、偏向锁、轻量级锁或重量级锁。
  • 锁的状态用于实现不同程度的并发性,以提高性能。

在执行monitorenter时,会尝试获取对象的锁,如果锁的计数器为 0 则表示锁可以被获取,获取后将锁计数器设为 1 也就是加 1。

对象锁的的拥有者线程才可以执行 monitorexit 指令来释放锁。在执行 monitorexit 指令后,将锁计数器设为 0,表明锁被释放,其他线程可以尝试获取锁。

如果获取对象锁失败,那当前线程就要阻塞等待,直到锁被另外一个线程释放为止。

synchronized 修饰方法的的情况

1
2
3
4
5
public class SynchronizedDemo2 {
public synchronized void method() {
System.out.println("synchronized 方法");
}
}

synchronized关键字原理

synchronized 修饰的方法并没有 monitorenter 指令和 monitorexit 指令,取得代之的确实是 ACC_SYNCHRONIZED 标识,该标识指明了该方法是一个同步方法。JVM 通过该 ACC_SYNCHRONIZED 访问标志来辨别一个方法是否声明为同步方法,从而执行相应的同步调用。

如果是实例方法,JVM 会尝试获取实例对象的锁。如果是静态方法,JVM 会尝试获取当前 class 的锁。

总结

synchronized 同步语句块的实现使用的是 monitorentermonitorexit 指令,其中 monitorenter 指令指向同步代码块的开始位置,monitorexit 指令则指明同步代码块的结束位置。

synchronized 修饰的方法并没有 monitorenter 指令和 monitorexit 指令,取得代之的确实是 ACC_SYNCHRONIZED 标识,该标识指明了该方法是一个同步方法。

**不过两者的本质都是获取对象监视器 monitor **

synchronized 锁的升级机制

synchronized 在 JDK 1.6 版本中引入了锁的升级机制。这个机制通过动态调整锁的状态来减少同步操作的开销,包括无锁状态、偏向锁、轻量级锁和重量级锁四种状态。这种优化使得 synchronized 在某些场景下的性能得到了显著提升,尤其是在锁竞争激烈的情况下

无锁,偏向锁,轻量级锁,重量级锁

以下是对象头中Mark Word中的字段

image-20240301003107641

无锁:

  • 在无锁状态下,多个线程可以同时访问共享资源而不进行任何同步操作。

  • 无锁适用于读多写少的情况,可以提高程序的并发性能。

偏向锁:

当某个线程多次访问共享资源时,JVM会认为这个线程将来还会继续访问这个资源,因此会使用偏向锁。

  • 偏向锁的目标是在没有竞争的情况下,让第一个获得锁的线程能够快速再次获取锁。
  • 当一个线程获得锁后,会在对象头中的标记位记录下这个线程的ID,称为偏向线程标识。之后,如果该线程再次请求锁,不需要再竞争,直接获得锁。
  • 这对于线程独占的场景非常有效,避免了无谓的锁竞争。但是如果有其他线程尝试竞争锁,偏向锁就会失效,升级为轻量级锁。

image-20240423222134093

轻量级锁:

轻量级锁是在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,尝试拷贝锁对象头的Markword到栈帧的Lock Record,若拷贝成功,JVM将使用CAS操作尝试将对象头的Markword更新为指向Lock Record的指针,并将Lock Record里的owner指针指向对象头的Markword。

image-20240423222401384

  • 轻量级锁是在有多个线程竞争同一把锁的情况下引入的。
  • 当线程尝试获得锁时,JVM会先在栈帧中创建一个用于存储锁记录的空间,并将对象头中的指针指向这个锁记录。如果这个过程没有竞争,就可以获得轻量级锁。
  • 如果有竞争,第一个尝试获得轻量级锁的线程会通过CAS操作来竞争锁。如果竞争成功,就获得锁;否则,自旋等待
  • 线程在获取锁失败时,不立即进入等待状态,而是反复检查锁是否可用,这种方式区别于被操作系统挂起阻塞,因为如果对象锁很快就会被释放的话,自旋去获得锁完全在用户空间解决,不需要进行系统中断和现场恢复,所以它的效率更高。
  • 若当前只有一个等待线程,则可通过自旋继续尝试, 当自旋超过一定的次数,或者一个线程在持有锁,一个线程在自旋,又有第三个线程来访问时,轻量级锁就会膨胀为重量级锁。

重量级锁

如果对象锁状态被标记为重量级锁,需要通过Monitor来对线程进行控制,此时将会使用同步原语来锁定资源,对线程的控制也最为严格。

重量级锁采用的是传统的互斥量实现,当线程无法获取锁时,会进入阻塞状态,直到锁被释放。这会引入线程上下文切换的开销。

ReentrantLock

ReentrantLock 实现了 Lock 接口,是一个可重入且独占式的锁,和 synchronized 关键字类似。不过,ReentrantLock 更灵活、更强大,增加了轮询、超时、中断、公平锁和非公平锁等高级功能。

1
public class ReentrantLock implements Lock, java.io.Serializable {}

ReentrantLock 里面有一个内部类 SyncSync 继承 AQS(AbstractQueuedSynchronizer),添加锁和释放锁的大部分操作实际上都是在 Sync 中实现的。Sync 有公平锁 FairSync 和非公平锁 NonfairSync 两个子类。

image-20240227133317376

ReentrantLock 默认使用非公平锁,也可以通过构造器来显式的指定使用公平锁。

1
2
3
4
// 传入一个 boolean 值,true 时为公平锁,false 时为非公平锁
public ReentrantLock(boolean fair) {
sync = fair ? new FairSync() : new NonfairSync();
}

从上面的内容可以看出, ReentrantLock 的底层就是由 AQS 来实现的。关于 AQS 的相关内容推荐阅读 AQS 详解 这篇文章。

公平锁和非公平锁有什么区别?

公平锁 :

  • 在公平锁中,线程按照它们发出请求的顺序获得锁,即按照先来先得的原则。

  • 当有多个线程等待锁时,锁将按照请求的顺序分配给它们,确保所有线程都有公平的机会获得锁。

  • 公平锁的优点是保证了资源的公平分配,避免了某些线程长期等待的情况。

  • 但由于需要维护一个有序队列,可能引入额外的性能开销。

非公平锁

  • 锁被释放之后,后申请的线程可能会先获取到锁,是随机或者按照其他优先级排序的。性能更好
  • 但可能会导致某些线程永远无法获取到锁。

synchronized 和 ReentrantLock 有什么区别?

两者都是可重入锁

可重入锁 也叫递归锁,指的是线程可以再次获取自己的内部锁。比如一个线程获得了某个对象的锁,此时这个对象锁还没有释放,当其再次想要获取这个对象的锁的时候还是可以获取的,如果是不可重入锁的话,就会造成死锁。

JDK 提供的所有现成的 Lock 实现类,包括 synchronized 关键字锁都是可重入的。

在下面的代码中,method1()method2()都被 synchronized 关键字修饰,method1()调用了method2()

1
2
3
4
5
6
7
8
9
10
public class SynchronizedDemo {
public synchronized void method1() {
System.out.println("方法1");
method2();
}

public synchronized void method2() {
System.out.println("方法2");
}
}

由于 synchronized锁是可重入的,同一个线程在调用method1() 时可以直接获得当前对象的锁,执行 method2() 的时候可以再次获取这个对象的锁,不会产生死锁问题。假如synchronized是不可重入锁的话,由于该对象的锁已被当前线程所持有且无法释放,这就导致线程在执行 method2()时获取锁失败,会出现死锁问题

synchronized 依赖于 JVM 而 ReentrantLock 依赖于 API

synchronized 是依赖于 JVM 实现的,前面我们也讲到了 虚拟机团队在 JDK1.6 为 synchronized 关键字进行了很多优化,但是这些优化都是在虚拟机层面实现的,并没有直接暴露给我们。

ReentrantLock 是 JDK 层面实现的(也就是 API 层面,需要 lock() 和 unlock() 方法配合 try/finally 语句块来完成

ReentrantLock交替打印ABC

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
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
public class Main {
private static final int MAX_COUNT = 5;
private static int turn = 0;
private static ReentrantLock lock = new ReentrantLock();
private static Condition condition = lock.newCondition();

public static void main(String[] args) {
Thread t1 = new Thread(()->{
int i = 0;
while (i < MAX_COUNT){
i ++;
myPrint( 0);
}
});

Thread t2 = new Thread(()->{
int i = 0;
while (i < MAX_COUNT){
i ++;
myPrint(1);
}
});

Thread t3 = new Thread(()->{
int i = 0;
while (i < MAX_COUNT){
i ++;
myPrint(2);
}
});
t1.start();
t2.start();
t3.start();
}

public static void myPrint(int flag) {
lock.lock();
try {
while (turn != flag) {
condition.await();
}
if (flag == 0) {
System.out.println("A");
} else if (flag == 1) {
System.out.println("B");
} else if (flag == 2) {
System.out.println("C");
}
turn = (turn + 1) % 3;
condition.signalAll();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
lock.unlock();
}
}
}

ReentrantLock 比 synchronized 增加了一些高级功能

相比synchronizedReentrantLock增加了一些高级功能。主要来说主要有三点:

  • 可实现公平锁 : ReentrantLock可以指定是公平锁还是非公平锁。而synchronized只能是非公平锁。所谓的公平锁就是先等待的线程先获得锁。ReentrantLock默认情况是非公平的,可以通过 ReentrantLock类的ReentrantLock(boolean fair)构造方法来指定是否是公平的。
  • 等待可中断 : ReentrantLock提供了一种能够中断等待锁的线程的机制,通过 lock.lockInterruptibly() 来实现这个机制。也就是说正在等待锁的线程可以选择放弃等待,改为处理其他事情。
  • 可实现选择性通知(锁可以绑定多个条件): synchronized关键字与wait()notify()/notifyAll()方法相结合可以实现等待/通知机制。ReentrantLock类当然也可以实现,但是需要借助于Condition接口与newCondition()方法。

如果你想使用上述功能,那么选择 ReentrantLock 是一个不错的选择。

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
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.ReentrantLock;

public class ProductManager {
private final ReentrantLock lock = new ReentrantLock();
private final Condition conditionA = lock.newCondition();
private final Condition conditionB = lock.newCondition();
private int productA = 0;
private int productB = 0;

public void produceA() {
lock.lock();
try {
productA++;
System.out.println("Produced A, count: " + productA);
conditionA.signalAll(); // 通知所有等待产品A的消费者
} finally {
lock.unlock();
}
}

public void produceB() {
lock.lock();
try {
productB++;
System.out.println("Produced B, count: " + productB);
conditionB.signalAll(); // 通知所有等待产品B的消费者
} finally {
lock.unlock();
}
}

public void consumeA() throws InterruptedException {
lock.lock();
try {
while (productA == 0) {
conditionA.await(); // 等待产品A
}
productA--;
System.out.println("Consumed A, count: " + productA);
} finally {
lock.unlock();
}
}

public void consumeB() throws InterruptedException {
lock.lock();
try {
while (productB == 0) {
conditionB.await(); // 等待产品B
}
productB--;
System.out.println("Consumed B, count: " + productB);
} finally {
lock.unlock();
}
}
}

这种方式允许你在一个锁上管理多个等待队列,每个队列对应一个不同的条件,从而提供了更细粒度的控制。

性能的区别

在Synchronized优化以前,synchronized的性能是比ReenTrantLock差很多的,但是自从Synchronized引入了偏向锁,轻量级锁(自旋锁)后,两者的性能就差不多了,在两种方法都可用的情况下,官方甚至建议使用synchronized,其实synchronized的优化我感觉就借鉴了ReenTrantLock中的CAS技术。都是试图在用户态就把加锁问题解决,避免进入内核态的线程阻塞。

可中断锁和不可中断锁有什么区别?

  • 可中断锁:获取锁的过程中可以被中断,不需要一直等到获取锁之后才能进行其他逻辑处理。ReentrantLock 就属于是可中断锁。
  • 不可中断锁:一旦线程申请了锁,就只能等到拿到锁以后才能进行其他的逻辑处理。 synchronized 就属于是不可中断锁。

http://example.com/2023/05/13/Java/Java多并发/锁/
作者
PALE13
发布于
2023年5月13日
许可协议