跳到主要内容

Java synchronized 对象锁

2021127日 创建     
通过一篇手记认识下 Java 中对象锁 synchronized 。

看了很多篇网上相关的文章,发现这里面的水非常地深,一般人把持不住,其中涉及到很多 JVM 、操作系统相关的知识,每一个点都能拿来深究,所以这篇手记只是为了初识和梳理,了解即可。

1. 为什么引入锁

锁是为了解决什么问题?在并发场景下多个线程操作共享资源时,由于读写操作的非原子性,导致数据不一致的问题。为什么说读写是非原子性操作呢?

举例:

类中包含一个静态私有的变量 counter=0,在 main 方法中对 counter 执行一次自增操作。这个自增操作在 Java 代码中看是一条指令,但这一个自增操作编译成字节码后会被分割成多条指令。

public class Demo {
private static int counter = 0;

public static void main(String[] args) {
counter++;
}
}

通过 javap -c .\Demo.class 看下编译后的字节码(截取了一部分):

public static void main(java.lang.String[]);
Code:
0: getstatic #2 // Field counter:I
3: iconst_1
4: iadd
5: putstatic #2 // Field counter:I
8: return

从反汇编后的字节码中可以看到,它主要做了以下四步:

  1. 获取静态变量 counter 的值;
  2. 准备一个常量 1;
  3. 加操作;
  4. 将运算后的结果放回到静态变量中(重新赋值)

以上这四步完成了一次 counter 的自增操作,但是这个过程是非原子性操作,由于我们使用的操作系统是分时操作系统,假设这是一个并发环境,多个线程对 counter 做自增:

  1. 线程A 获取时间片开始执行自增操作,拿到 counter=0,当执行完第三步时,时间片到期了,此时计算后的结果还没有 putstatic;
  2. 然后线程B 开始执行,拿到 counter=0,它此时在时间片到期前执行完了自增操作,最后 counter=1;
  3. 线程A重新获取时间片,开始接着它上一次执行第四步,将计算后的结果1 putstatic,最后 counter=1;

以上两个线程共做了两次自增,但最后counter=1,这显然是不合理的,这是因为多线程访问共享资源,由于操作的非原子性导致的数据不一致问题。

public class Demo {

private static int counter = 0;

public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
for (int i = 0; i < 10000; i++) {
counter++;
}
});

Thread t2 = new Thread(() -> {
for (int i = 0; i < 10000; i++) {
counter++;
}
});

t1.start();
t2.start();
t1.join();
t2.join();

System.out.println(counter);
}
}

创建两个线程,分别做10000次自增,最后获取结果,根据上面得出的结论,最终结果应该是20000,但实际的值一般都会小于20000。(注:循环次数最好大一些,性能比较好的机器运算比较快,循环次数少的话可能看不出效果)

为了解决上面出现的不合理情况,引入了锁的概念,锁的适用场景不同,又可分为很多类型。(接着往下看)


2. 锁的分类

是否对共享资源加锁,分为:悲观锁乐观锁

是否可重新获取锁,分为:可重入锁不可重入锁

锁竞争的公平性,分为:公平锁非公平锁

多线程是否可共享锁,分为:共享锁排它锁(独占锁)

等等...


2.1 悲观锁

悲观锁策略是认为共享资源一定会存在被其它线程修改的可能,所以会对共享资源加锁。只有持有锁的线程才能执行临界区代码(对共享资源进行读写操作的代码),没有持有锁的线程当访问到临界区代码时会被阻塞住,当锁被持有者释放时,阻塞的线程才会重新进入可运行状态竞争锁。

3. synchronized

3.1 认识 synchronized

synchronized 是 Java 的一个关键字,直译过来就是“同步”的意思,顾名思义,让代码同步执行(串行)。它是悲观锁的一种,也叫对象锁(为什么叫对象锁后面再说),是用来锁住操作共享资源的代码(临界区代码)的,只有持有对象锁的线程才能执行临界区代码,其它线程访问到临界区时会被阻塞,当锁的持有者执行完临界区代码释放锁后,被阻塞的线程才会被唤醒重新竞争锁。

举例:

public class Demo {

private static int counter = 0;
private static Object lock = new Object(); // 锁对象

public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
for (int i = 0; i < 10000; i++) {
synchronized (lock) { // 临界区代码
counter++;
}
}
});

Thread t2 = new Thread(() -> {
for (int i = 0; i < 10000; i++) {
synchronized (lock) {
counter++;
}
}
});

t1.start();
t2.start();
t1.join();
t2.join();

System.out.println(counter);
}
}

使用 synchronized 关键字来锁住 counter++,并发环境下,即使当前线程时间片到期,一次自增没有执行完,其它线程拿到时间片,由于没有对象锁,也会被阻塞,直到当前线程执行完一次自增,锁才会被释放。这其实是保证了临界区代码的原子性。

知道 synchronized 的用处之后,再来说它为什么叫对象锁?从上面的示例代码中可以看到,在类成员中声明了一个 Object lock = new Object();,synchronized 需要一个对象来加锁,这个对象的对象头中记录了这个锁的相关信息,具体是怎么记录的后面会详细地说。

synchronized 除了可以锁代码块,还可以直接加到方法上:

public synchronized void increment() {
// 临界区
}

等价于:

public void increment() {
synchronized (lock) {
// 临界区
}
}
提示

注:如果有多个方法操作同一个共享资源,synchronized 的锁对象应该是相同的对象。

位置锁对象
代码块需要指定锁对象
成员方法锁对象是当前对象,也就是this
静态方法锁对象是当前类的类对象,类.class

synchronized 对象锁还具有

  1. 重入性;
  2. 排他性;

重入性:获取对象锁的线程,可以访问被相同锁保护的其它同步代码块(同步方法),并且会再一次获取该对象锁,这里面有一个获取锁的计数器,每获取一次计数器+1,执行完一个同步方法计数器-1,直到计数器为0时该线程释放掉锁。重入锁是为了防止发生线程死锁。

排他性:多个线程不会共享同一个锁,当一个线程获得锁,其它线程访问临界区只能被阻塞;


3.2 Java 对象头

synchronized 在 jdk 1.6 之前使用重量级锁,会将访问同步代码但未持有锁的线程都阻塞。重量级锁使用的是操作系统提供的管程(Monitor)实现的,所以即便在没有线程竞争锁的情况下也会发生用户态与内核态(操作系统执行的代码在内核态)之间的切换,造成不必要的性能开销;

在 jdk 1.6 时对 synchronized 做了优化,引入了偏向锁、轻量级锁,在没有线程竞争锁的情况下,使用偏向锁,出现线程竞争时升级轻量级锁,超过一定自旋次数时升级重量级锁的策略。

对象锁有四种锁标志:正常状态(无锁)、偏向锁、轻量级锁和重量级锁。

这四种状态记录在哪呢?就记录在锁对象的对象头中,任何对象都有一个对象头信息,下面来认识一下对象头。

以 Hotspot 虚拟机为例,Hopspot 对象头主要包括两部分数据:Mark Word(标记字段) 和 class Pointer(类型指针)。

  • Mark Word(标记字段),主要记录对象的 Hashcode、分代年龄、锁标志位等信息;
  • class Pointer(类型指针),对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。

下面是我用 Excel 整理的以 32 位虚拟机为例的内存分配图,并做了注释:

锁标志就存在锁对象对象头的 Mark Word 中,其中:

  • lock: 01, biased_lock:0 表示正常状态(无锁);
  • lock: 01, biased_lock:1 表示偏向锁;
  • lock: 00 表示轻量级锁;
  • lock: 10 表示重量级锁;
  • lock: 11 垃圾回收标记。

下面一步一步看对象锁是怎么从偏向锁升级到重量级锁的。


3.3 偏向锁

如果只有一个线程访问同步代码,那么每次获取锁时都需要使用操作系统的管程(Monitor),用户态与内核态的频繁切换会造成大量系统开销,这显得有些小题大做了。Java 的大佬们发现,在大多数情况下,锁不仅不存在线程竞争,而且总是由一个线程多次获取,为了让线程获得锁的代价更低,大佬们在 Jdk 1.6 时为 synchronized 引入了偏向锁。

偏向锁是考虑到在没有线程竞争的场景下降低获取锁的成本。获取锁的方式是通过 CAS 操作(后面的乐观锁时再详细说明 CAS)将偏向锁中的 thread_id 替换成当前线程的 thread_id,如果替换成功,说明此时没有其它线程竞争,成功拿到偏向锁,执行同步代码。并且偏向锁不会主动释放,当线程再次执行同步代码时不需要再次执行 CAS 操作,如果偏向锁的 thread_id 与当前线程 thread_id 相同,就可以继续执行同步代码。只有当发生了线程竞争的情况,偏向锁会被撤销,从而升级成轻量级锁。

看图说话:


提示

关闭偏向锁: 通过虚拟机参数 -XX:-UseBiasedLocking=false 关闭偏向锁。


3.4 轻量级锁

偏向锁是考虑到在没有锁竞争的情况下,偏向锁会偏向当前持有锁的线程,线程在每次执行同步代码时不需要重复获取锁,执行效率高。

当存在锁竞争时,锁会从偏向锁升级为轻量级锁,在获取轻量级锁时同样使用 CAS 尝试将轻量级锁的指针指向当前线程锁记录(表示当前锁被哪个线程持有),如果指针修改成功,说明拿到轻量级锁,如果没有修改成功,则通过自旋继续通过 CAS 尝试修改锁的指针。当自旋一定次数仍拿不到锁,说明线程竞争比较严重,轻量级锁膨胀到重量级锁。

轻量级锁是考虑到在竞争不那么激烈的情况下,假设只有两个线程在执行同步代码,其中一个线程拿到锁,并且很快执行完同步代码,持有锁的时间比较短,这种情况另一个线程只需要自旋等待一会儿再拿锁即可,无需使用操作系统的管程这种重量级锁。

只有当竞争较为激烈,线程竞争较多,持有锁时间较长的情况发生时,轻量级锁会膨胀为重量级锁,将线程阻塞,防止多个线程自旋时间过长,占用CPU资源。

以下是偏向锁升级到轻量级锁的流程:

偏向锁和轻量级锁都是采用了 CAS 获取锁,属于乐观锁。


3.5 重量级锁

偏向锁和轻量级锁都是考虑线程没有实际竞争的情况下,可以通过 CAS 自旋(自己尝试几次就可以拿到锁,不麻烦操作系统了)的方式,提高程序的执行效率。

通过上面偏向锁和轻量级锁的介绍,在竞争不激烈情况下使用重量级锁会增加系统开销,降低程序执行效率。但当线程竞争激烈的情况下,多个线程都在 CAS 自旋(可以想象多个线程一直在 while(true) 尝试获取锁,却半天拿不到,所以还是麻烦操作系统阻塞线程吧),反而浪费了系统资源。

流程图:


3.6 CAS 自旋

CAS (Compare And Swap) 是 CPU 提供的一种原子指令,也是乐观锁的实现之一。

它有三个操作数:

  • V: 内存地址上的值;
  • A:预期的原值;
  • B:新值

机制是先比较预期的值(A)与内存地址上的实际值(V)是否相同,如果符合预期,用新值(B)去替换调内存地址上的(A),不符合预期即替换失败。

在偏向锁中,线程会通过 CAS 去替换锁对象头中的 thread_id 为自己的 thread_id,实现获取锁。在偏向锁中不会发生自旋,如果替换失败,说明有其它线程持有锁,锁会升级为轻量级锁;

(我猜测这里的新值是当前线程 thread_id,内存地址上的值是当前锁对象中 thread_id 的实际值,预期可能是没有值,只有当锁对象中的 thread_id 没有值的时候,说明没有其它线程持有偏向锁,才会替换成功,拿到偏向锁,具体没有详细查资料)

在轻量级锁中,线程会通过 CAS 去替换锁对象头中的锁记录指针,如果替换失败,会发生自旋,继续尝试,直到获取锁或者锁膨胀为重量级锁。


以上是 synchronized 关键字背后隐藏的复杂流程的大体外貌,详细的流程肯定不止于此,先有个认识,以后有机会再深究。(写这部分花了3天时间,继续学习 -.-)