Java中锁的分类和原理

并发编程是Java的灵魂,不可避免的会遇到锁的问题。本文整理了一下Java中个别锁实现,例如synchronized、volatile、ReentrantLock。以下是Java中锁的总体分类:

分类

synchronized

Java中每个对象都可以作为锁,具体表现为以下三个形式

  1. 对于普通的同步方法,锁是当前实例对象
  2. 对于静态同步方法,所示当前类的Class对象
  3. 对于同步方法块,锁是Synchronized括号里配置的对象

举例:双重校验锁实现对象单例(线程安全)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
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;
}
}

Notes:构造方法不能使用 synchronized 关键字修饰,因为构造方法本身就属于线程安全的。

实现原理

JVM基于进入和退出Monitor对象来实现方法同步和代码块同步,两者实现细节不一样。代码块的同步是用monitorentermonitorexit 指令实现的。monitorenter 指令是在编译后插入到同步代码块的开始位置,而monitorexit是插入到方法结束处或者异常处,JVM要保证每个monitorenter 必须有对应的monitorexit 与之配对。任何对象都有一个monitor与之关联,当且一个monitor被持有后,它处于锁定状态。线程执行到monitorenter 指令时,将会尝试获取对象的monitor的所有权,即尝试获得对象的锁。

通过 JDK 自带的 javap 命令查看类的相关字节码信息,可以直接看到synchronized的情况

字节码

锁升级

Java SE 1.6的时候为了减少获得锁和释放锁带来的性能消耗,引入了“偏向锁”和“轻量级锁”,在Java SE 1.6中,锁一共有4种状态,级别从低到高依次是: 无锁状态,偏向锁状态,轻量级锁状态,重量级锁状态。为了提高效率,锁只可以升级不可以降级。

以下是锁升级的过程:

锁升级

偏向锁:

当一个线程访问同步块并获取锁时,会在对象头和栈帧中的锁记录里存储锁偏向的线程ID,以后该线程在进入和退出同步块时不需要进行CAS操作来加锁和解锁,只需要简单的测试一下对象头的Mark Word里是否存储着指向当前线程的偏向锁。如果测试成功,表示线程已经获得了锁,如果测试失败,则需要再测试一下Mark Word中偏向锁的标识是否设置成1(表示当前是偏向锁):如果没有设置,则使用CAS竞争锁,如果设置了,则尝试使用CAS将对象头的偏向锁指向当前线程。

volatile

定义

volatile是轻量级的synchronized,它在多处理器开发中保证了共享变量的“可见性”。可见性的意思是当一个线程修改一个共享变量时,另一个线程能够读到这个修改的值。它不会引起线程的上下文切换和调度。

如何保证可见性?

以在X86处理器生成的汇编指令来查看对volatile写操作的过程。

Java代码如下

1
instance = new Singleton(); // instance是volatile变量

转换为汇编代码如下

1
2
0x01a3de1d: movb $0x0,0x1104800(%esi)
0x01a3de24: lock add1 $0x0,(%esp)

有volatile变量修饰的共享变量进行写操作的时候会出现第二行代码,Lock前缀的指令在多核处理器下会发生两件事情

  1. 将当前处理器缓存行的数据写回到系统内存。
  2. 写回到系统内存的操作会使其他CPU里缓存了该内存地址的数据无效。

为了提高处理速度,处理器不直接和系统内存进行通信,而是将系统内存读到内部缓存后在进行操作,但操作完不知道何时会写到系统内存。如果声明了volatile的变量进行写操作,JVM就会向处理器发送一条Lock前缀的指令,将这个变量所在的缓存行的数据写回到系统内存。在多处理器下,为了保证各个处理器的缓存是一致的,就会实现缓存一致性协议,每个处理器通过嗅探在总线上传播的数据来检查自己缓存的值是不是过期了,当处理器发现自己缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置成无效状态,当处理器对这个数据操作的时候,会重新从系统内存中把数据读取到处理器的缓存中。

ReentrantLock

synchronized关键字用于加锁,但这种锁一是很重,二是获取时必须一直等待,没有额外的尝试机制。 java.util.concurrent.locks 包提供的ReentrantLock用于替代synchronized加锁。

公平锁和非公平锁

  1. 公平锁

    多个线程按照申请锁的顺序去获得锁,线程会直接进入队列去排队,永远都是队列的第一位才能得到锁。

    优点:所有的线程都能得到资源,不会饿死在队列中。

    缺点:吞吐量会下降很多,队列里面除了第一个线程,其他的线程都会阻塞,cpu唤醒阻塞线程的开销会很大。

  2. 非公平锁

    多个线程去获取锁的时候,会直接去尝试获取,获取不到,再去进入等待队列,如果能获取到,就直接获取到锁。

    优点:可以减少CPU唤醒线程的开销,整体的吞吐效率会高点,CPU也不必去唤醒所有线程,会减少唤起线程的数量。

    缺点:你们可能也发现了,这样可能导致队列中间的线程一直获取不到锁或者长时间获取不到锁,导致饿死。

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

可重入锁

可重入锁又名递归锁,是指在同一个线程在外层方法获取锁的时候,再进入该线程的内层方法会自动获取锁(前提锁对象得是同一个对象或者class),不会因为之前已经获取过还没释放而阻塞。Java中ReentrantLocksynchronized都是可重入锁,可重入锁的一个优点是可一定程度避免死锁。

ReentrantLock和synchronized对比

ReentrantLock synchronized
锁实现机制 依赖AQS 监听器模式
灵活性 支持响应中断、超时、尝试获取锁 不灵活
释放形式 必须显示调用unlock()释放锁 自动释放监听器
锁类型 公平锁&非公平锁 非公平锁
条件队列 可关联多个条件队列 关联一个条件队列
可重入性 可重入 可重入

使用注意

  1. lock 必须在 finally 块中释放,否则如果受保护的代码将抛出异常,锁就有可能永远得不到释放。

  2. 当 JVM 用 synchronized 管理锁定请求和释放时,JVM 在生成线程转储时能够包括锁定信息。这些对调试非常有价值,因为它们能标识死锁或者其他异常行为的来源。 Lock 类只是普通的类,JVM 不知道具体哪个线程拥有 Lock 对象。