看完这篇,有人问你synchronized 关键字就不要怂

218 阅读18分钟

写在前面

锁的出现,是多线程并发编程所需要的,如果程序在并发执行,同时对一个资源进行操作,这是很容易出现问题的:多个线程同时运行,就像是活在同一个地球上不同维度的生物,它们互相感知不到对方,却在操作同一个东西,可能操作着操作着,突然东西就不见了,或者变多了。这是因为它们在同时操作,而且操作的时候没有互相告知。Java 原生的有两种实现锁的机制,一种是通过底层实现的 synchronized 关键字,另一种是 Doug Lea 在 JDK1.5 实现的 java.util.concurrent 包中的 Lock 类。这两种方法一种是 Java 关键字,另一种是用对象的方式,两种都实现了并发状态下对公共资源的加锁。

synchronized 原理铺垫

synchronized 是一个 Java 的关键字,能够对并发资源上锁,它由 JVM 实现,也就是说 synchronized 跟底层有关系。synchronized 关键字从 JDK 1.0 就存在了,最开始是一种代价很大的保证线程安全的方法(但也是唯一一种),在 JDK 6 被重新设计,性能大大提升。这一性能提升,一方面归功于软件代码设计的进步,另一方面也要归功于硬件的发展。

最初的 synchronized 关键字

最开始的 synchronized 关键字,基于互斥同步的原理来实现。互斥同步的意思是说:如果一个线程在使用资源,另一个线程想要使用资源,就要等,等到能获取资源为止,这里的等就是互斥的表现,一方使用,另一方就不准使用(即阻塞)。互斥同步是一种很消耗性能的操作,这是因为实现互斥的方式:阻塞,是一种很消耗性能的操作。

这里要提到操作系统的用户态和内核态。主流的 Java 虚拟机对于 Java 线程的实现,是直接将 Java 线程映射到操作系统的原生内核线程之上的,因此实现线程阻塞和线程唤起,必须需要操作系统来帮忙完成,操作系统的用户态和内核态之间进行转换。一个线程尝试获取资源时,发生线程阻塞,这里的阻塞是操作系统来帮助进行的,操作系统由用户态转为内核态,在内核态状态下将这一条线程阻塞住。

用户态和内核态是很重要的操作系统的概念,这里不多进行学习,只记住有这么一回事就可以了。用户态和内核态在进行转换的过程中,需要保存上下文信息,将两种状态的信息都存储下来,这是很消耗资源的。因此互斥同步是很很消耗性能的,用户态和内核态之间进行转换消耗的处理器时间,甚至比同步状态下的代码执行时间还要长,这是一种非常重量级的操作,由此造成了最初的 synchronized 关键字性能很差。

经过改进的 synchronized 关键字

最初 synchronized 关键字性能差的原因,是因为互斥同步是通过线程阻塞来实现的,而线程阻塞必然导致操作系统在用户态和内核态之间做转换,因而性能差。如果 synchronized 关键字不通过互斥同步实现(不通过阻塞线程来实现安全),那么性能说不定就会好很多。

阻塞,是一种无奈之举,因为不阻塞住线程,就不敢保证操作数据的过程是安全的。不安全最常见的现象是:一条线程读取完数据进行操作,还没保存,另一条线程就修改了数据,那么这时再保存,就会无视刚刚修改的数据,换言之,另一条线程的操作被“无效化”了。

一种比较常见的处理办法是,获取资源时记录下数据的值,在保存的时候,先比对数据是否还是当时的大小,如果是,就默认资源没有问题,可以进行保存。这也就是并发中非常重要的概念:CAS(compare and swap - 比较后交换),先比较预期的数据,如果是预期的大小,就交换值(保存)。值得一提的是,CAS 是另一种实现线程安全的方式:JUC 包的核心逻辑。(但是这实际上还是有潜在问题的,比如我在早晨 10 点获取到数据知道是 1,在下午 5 点发现数据还是 1,这并不能保证数据在这段时间中没有被改过,有可能改了又改回来了,即“ABA 问题”,但好在大多数的情况下 ABA 问题不会影响程序并发的正确性)

如果通过 CAS 操作数据,就可以代替阻塞,性能提高。CAS 的英文原名是 compare & swap,这是指 compare 和 swap 必须在一起进行,执行完 compare 就必须接着执行 swap,即这两个动作合在一起是原子性的,是不能拆开的。这也就是为什么 synchronized 关键字性能提升是需要借助于硬件技术的提高的,因为 CAS 必须由硬件执行,而不能是软件(如果是软件实现,那还是通过互斥同步的方式进行,这就没有意义了),最初的 cpu 在硬件指令集中是没有 CAS 操作的,之后才出现这一指令,JDK 5 的 Java 类库开始使用 CAS 操作,在 JDK 6 中使用该操作对 synchronized 进行了改造。

粗略地讲,synchronized 通过 CAS 操作进行改造的原理,是分了两种情况:如果只有一个线程使用资源(但在理论上有可能有别的线程抢资源),直接 CAS 保存数据就可以了,不需要阻塞线程;如果线程一多争抢资源,那没有办法,乖乖地阻塞线程,通过互斥同步来实现线程安全。

补充

原始的 synchronized 通过互斥同步来实现线程安全,新的 synchronized 通过 CAS 操作来部分实现线程安全,这实际上也是两种思路,两种在面对并发风险时的思路。

  • 互斥同步的思路是,有可能发生并发风险,那么我提前准备,一条线程使用,另一条线程就不准使用。
  • CAS 的思路是,有可能发生并发风险,不用提前准备,先进行 CAS 操作保存,真发现了数据不一样再说。

一种是提前应对风险,将风险扼杀在摇篮中,另一种是不管风险先进行操作,产生了冲突再进行补偿措施。这两种思路实际上就是锁机制当中的“乐观锁“和”悲观锁“的思路。乐观和悲观指的是面对并发风险时的态度:

  • 乐观的话,先不管风险,干了再说,有问题回来找补(对应于 CAS 操作)
  • 悲观的话,先考虑风险,万无一失,再进行数据处理(对应于互斥同步)

因此乐观锁回滚重试,悲观锁阻塞事务。JDK 6 之后的 synchronized 关键字就是先乐观,乐观不起来了再悲观。

synchronized 原理

学习 synchronized 关键字需要对 JVM 中对象的内存布局(尤其是对象头部分)有所了解。对象头的内容,在此不多赘述。
在这里插入图片描述
对象存储在 JVM 堆里,鉴于内存寸土寸金,需要尽可能地缩减对象头的大小,因此对象头有五种状态,在不同的状态下存储不同的信息。上图的前四种与 synchronized 关键字有关,分别在【没有锁】、【偏向锁】、【轻量级锁】和【重量级锁】状态下,存储不同的信息。换个角度理解,这意味着 synchronized 也有四种场景。

synchronized 关键字的原理(改进之后),就像是开车挂挡,起步一档,速度上来之后挂二档,最后一脚油门上了三挡。

  • 如果只有一个线程在使用资源,那么挂一档:偏向锁
  • 如果有少数几个线程在使用资源,那么挂二档:轻量级锁
  • 如果有好几个线程在使用资源,那么挂三挡:重量级锁

这三种档位是针对于 JDK 6 之后的 synchronized,在这之前起步直接三挡。

对应于这三个档位(外加上空挡)一共有四种状态,这四种状态的标志位如下:

偏向模式(1 bit)锁标志位(2 bit)
无锁001
偏向锁101
轻量级锁(没有该字段)00
重量级锁(没有该字段)10

几种状态锁

偏向锁

对于上锁的对象,有一个资源争抢的升级过程。最开始的情况,是只有一条线程在使用资源,这时并不存在竞争的情况。如果不存在竞争,上锁是没有必要的,或者说上重量级的锁是没有必要的,毕竟没其他线程抢资源。

偏向锁的偏向,是“偏心”的“偏”、“偏袒”的“偏”,其含义是偏向线程,偏向于第一个获取到它的线程。如果之后一直没有其他线程出现,则持有偏向锁的线程永远不需要进行同步。如果出现了新的线程,偏向锁立即终止。

因此,如果只有一条线程使用资源,则使用偏向锁。如果出现了第二条线程,不论这两条线程是否存在竞争,锁都会膨胀,偏向锁即刻作废。(还是有一些例外的示,如果前一条线程死亡了,新的线程来申请资源,还是能继续使用偏向锁的)在这种意义上,偏向锁是不需要解锁的,因为它从始至终只会有一个锁的主人,出现了第二个主人时,它就作废了,没有解锁是偏向锁相比于轻量级锁、重量级锁的一个区别。

偏向锁的具体实现,实际上还是比较繁琐的。总体上讲,是把偏向线程的线程ID记录在对象头中,之后再此使用前比对线程ID,如果就是当前线程则无需同步,如果不是当前线程那么偏向锁立即停止使用。

细致地讲,偏向锁的上锁过程如下(自行对照上面对象头示意图):

确保可以上偏向锁

首先对象应处于未上锁状态(锁标志位是 01),且对象应为可偏向(偏向标志位是 1),因此对象头的标记部分应为 101 结尾。由于无锁和偏向锁的锁标志位是相同的(都是 01),因此另用 1 bit 来表示对象是否可偏向。JDK 6 下的 HotSpot 虚拟机默认开启偏向锁,可以手动设置参数关闭。

参照上图,对象头在无锁的状态下会保存对象的哈希码(hashcode),实际上这并不一定,如果对象没有计算过哈希码(例如调用 Object :: hashCode() 会计算哈希码),那么哈希码将不会保存在对象头中。但一旦计算过哈希码,对象头中就会储存哈希码,这个对象就再也不会进入偏向锁状态了,如需上锁,它只会一步到位膨胀成重量级锁。

(附上 64 位 JVM 的对象头标记字段,在无锁和偏向锁状态下的内容:)
|------------------------------------------------------------------------------|----------------|
| unused:25 | identity_hashcode:31 | unused:1 | age:4 | biased_lock:1 | lock:2 |     Normal     |(无锁)
|------------------------------------------------------------------------------|----------------|
| thread:54 |       epoch:2        | unused:1 | age:4 | biased_lock:1 | lock:2 |     Biased     |(偏向锁)
|------------------------------------------------------------------------------|----------------|

通过 CAS 尝试上偏向锁

在确保对象进入偏向模式的前提下,JVM 将会使用 CAS 操作把获取到这个锁的线程的ID记录在对象的标记字段中(图中的对象头标记字段,在偏向模式下的偏向 ID 部分,32 位虚拟机占 23 bit,64 位虚拟机占 54 bit)。如果 CAS 记录线程 ID 成功,那么认为偏向锁上锁成功,以后持有偏向锁的线程每次进入这个锁相关的同步块时,都不需要进行任何同步操作。如果 CAS 记录线程 ID 失败,那么偏向模式马上就宣告结束。

  • 如果此时对象没有上锁,那么该对象将先撤销偏向(将偏向标志位设置为 0),再升级为轻量级锁(这一步的撤销偏向是有一定的性能损耗的)
  • 如果此时对象已经上了偏向锁,那么该对象将继续申请轻量级锁

(下图为《深度理解 Java 虚拟机》的配图,描述了偏向锁膨胀到轻量级锁的过程)
在这里插入图片描述

对象头的标记字段中,还有一个字段:偏向时间戳(epoch)

这个字段的作用是统计重偏向次数。重偏向的概念是这样的,如果有一个类实例化 20 个对象出来,这 20 个对象先经历线程 1,再经历线程 2,上锁时需要发生 20 次的撤销偏向,再升级到轻量级锁的过程。偏向锁的撤销是比较昂贵的(原理暂不考究),如果这种现象多次出现,就意味着这个类不适合使用偏向锁。

对于这种场景 JVM 单独做了优化,类记录了一个 epoch 值,对象在创建时也将有一个 epoch 值(创建时与类的相同)。如果类对象发生了一次大规模的撤销偏向行为,类的 epoch 值将加 1(以后创建的对象也会采用新的 epoch 值),如果类的 epoch 值超过某个阈值,则证明该类不适合使用偏向锁,以后的对象也将不会再使用偏向锁,直接使用轻量级锁。

对象头中的 epoch 值是为了和类的 epoch 值对比用的,如果不一样,则将直接膨胀到轻量级锁。

轻量级锁

当资源不再只被一条线程获取,出现了两个及以上的线程时,偏向锁立即作废,膨胀为轻量级锁。

轻量级锁存在的意义是,如果有多个线程获取资源,但是是交替获取的,并没有发生资源竞争的风险,那么加一个轻量级锁,保证其中一条线程在运行时另一条线程不会并行操作即可。因此轻量级锁的目的,是为了消除数据在无竞争情况下的同步原语,提高程序的运行性能。

(附上 64 位 JVM 的对象头标记字段,在偏向锁和轻量级锁状态下的内容:)
|---------------------------------------------------------------------|--------------------|
| thread:54 |   epoch:2   | unused:1 | age:4 | biased_lock:1 | lock:2 |       Biased       |(偏向锁)
|---------------------------------------------------------------------|--------------------|
|                     ptr_to_lock_record                     | lock:2 | Lightweight Locked |(轻量级锁)
|---------------------------------------------------------------------|--------------------|

无论是无锁还是偏向锁,这两种状态的锁标志位都是 01,都可以膨胀到轻量级锁,将锁标志位修改为 00。对于轻量级锁而言,上锁的对象,其对象头的标记字段只有两部分内容:分别是锁标志位(2 bit,值为 00),以及正在占有对象的线程ID。

无锁膨胀到轻量级锁的过程是这样的(偏向锁的话,要先撤销偏向到无锁状态,再进行膨胀):

  • 确保对象没有被锁定,锁标志为是 01。
  • 备份对象头的标记字段
    将对象头的标记字段(Mark Word)拷贝到当前线程的栈帧中。也就是把那 8 字节包含着哈希码、分代年龄、偏向状态、锁标志位等信息的标记字段,存储在当前线程的 JVM 栈中。标记字段保存在线程栈帧的地址,叫做“锁记录”(Lock Record),换种表述方法,这块 Lock Record 用来存储对象目前的 Mark Word 的拷贝。
  • CAS 更新对象头,上轻量级锁
    虚拟机使用 CAS 操作尝试将对象头的 Mark Word 更新为指向 Lock Record 的指针(就是上一步中,线程栈帧备份对象头的地址),并将对象头的锁标记更新为轻量级锁(00)。
    如果这步 CAS 操作能够成功,那么轻量级锁就上好了,如果没有成功,则证明在同一时间有多个线程在竞争资源,轻量级锁不再有效,锁进一步膨胀为重量级锁。

如果对象已经上了轻量级锁,当有线程再次申请资源时:

  • 如果是同一个线程,则是一次锁重入。每次锁重入依旧会在线程栈帧中创建一个 Lock Record,只不过重入创建的 Lock Record 的值为 null,即它不再是对象头标记字段的备份。
  • 如果是另一个线程,说明存在多个线程竞争锁,锁膨胀为重量级锁。

轻量级锁有解锁的操作,当线程操作完对象资源后,需要将轻量级锁解除。解锁的方法,是将对象头的 Mark Word 和线程栈中的 Lock Record 通过 CAS 替换回来,如果 CAS 操作失败代表有其他线程在竞争资源,锁膨胀。

重量级锁

当出现两个或更多的线程,在同一时间操作资源时,会发生线程竞争,此时锁膨胀为最强的重量级锁,采取互斥同步的方式,让同一时间只有一个线程操作资源,其他线程阻塞等待。

重量级锁通过一个 monitor 对象实现多线程竞争时的互斥同步,monitor(监视器)是并发设计中很重要的设计,在不同的语言中有不同的实现。monitor 作为监视器,监视的是资源,每一个类或者每一个对象只能有一个 monitor 对象,这个 monitor 由 JVM 创建,能够保证同一时间只会有一个线程使用资源,其他线程都乖乖阻塞。

在 JVM 中 monitor 是 ObjectMonitor 类的实例对象,该类源码由 C++ 编写,代码如下:

ObjectMonitor() {
    _header       = NULL;
    _count        = 0; //monitor进入数
    _waiters      = 0,
    _recursions   = 0;  //线程的重入次数
    _object       = NULL;
    _owner        = NULL; //标识拥有该monitor的线程
    _WaitSet      = NULL; //等待线程组成的双向循环链表,_WaitSet是第一个节点
    _WaitSetLock  = 0 ;
    _Responsible  = NULL ;
    _succ         = NULL ;
    _cxq          = NULL ; //多线程竞争锁进入时的单项链表
    FreeNext      = NULL ;
    _EntryList    = NULL ; //处于等待锁block状态的线程,会被加入到该列表
    _SpinFreq     = 0 ;
    _SpinClock    = 0 ;
    OwnerIsThread = 0 ;
}

通过这个类实例化的 monitor 对象,一对一地监控着每一个需要互斥同步的类或对象,由 _owner 属性记录并发竞争成功的线程,执行完后换下一个线程,实现重量级锁。如果有其他线程尝试获取 monitor,会由于线程重入次数不为 0 而被迫阻塞。

synchronized 是以代码块的形式使用的,例如:

public void func() {
    synchronized (this) {
        // ...
    }
}

JVM 将 Java 代码解释成 CPU 原语时,解析 synchronized 关键字,会分别将代码块的开始和结束,解释成 monitorenter 和 monitorexit,这两个原语非常形象,就是进入 monitor 和离开 monitor。通过这两个 CPU 原语,JVM 使每个线程都要去 monitor 处报到,等待重新调度。

synchronized 使用

synchronized 关键字有四种使用表现,分别是同步对象、类、方法、静态方法,而同步方法和静态方法,实际上还是在同步对象和类,因此从原理上 synchronized 关键字同步的是对象或类。

同步一个对象

对任意一个对象加 synchronized,代码块当中的代码都会同步。

Object object = new Object();
synchronized (object) {
    // ...
}

例如下列代码:实现一个 Runnable 接口,按顺序打印 1-10

// r1不同步
Runnable r1 = () -> {
    for (int i = 1; i <= 10; i++) {
        System.out.print(i);
    }
};

// r2同步
Runnable r2 = () -> {
    synchronized (object) {
        for (int i = 1; i <= 10; i++) {
            System.out.print(i);
        }
    }
};

此时在线程池中分别跑不同步的 r1 和同步的 r2,每次线程池中跑两个线程

ExecutorService executorService = Executors.newCachedThreadPool();

executorService.execute(r1);
executorService.execute(r1);
// 打印结果:1 1 2 2 3 4 5 6 7 8 9 10 3 4 5 6 7 8 9 10 

executorService.execute(r2);
executorService.execute(r2);
// 打印结果:1 2 3 4 5 6 7 8 9 10 1 2 3 4 5 6 7 8 9 10

一种非常常见的同步对象的方式,是在类方法中同步 this,即表示同步当前对象。

synchronized (this) {
    // ...
}

同步一个类

当 synchronized 同步一个类时,使用该类的所有线程,无论是在操作哪一个对象,都将进行同步。

public void func() {
    synchronized (SynchronizedExample.class) {
        // ...
    }
}

例如如下代码:自定义一个 MyClass 类,该类只有一个按顺序打印 1-10 的方法。生成两个该类的对象,并调用两个线程分别执行这两个类的打印数字方法。

// 创建一个包含打印数字方法的类
class MyClass {
    void testSync() {
        for (int i = 1; i <= 10; i++) {
            System.out.print(i);
        }
    }
}

// 创建两个类对象
MyClass clazz1 = new MyClass();
MyClass clazz2 = new MyClass();

// 在线程池中执行打印数字的方法
ExecutorService executorService = Executors.newCachedThreadPool();
executorService.execute(() -> clazz1.testSync());
executorService.execute(() -> clazz2.testSync());

// 打印结果:1 2 3 4 5 1 2 3 4 5 6 7 8 9 10 6 7 8 9 10

如果对类方法进行 synchronized 同步,同步的内容是一个类(任意一个类都可以),则可实现线程间的同步。

void testSync() {
    synchronized (Object.class) {
        for (int i = 1; i <= 10; i++) {
            System.out.print(i);
        }
    }
}

// (其他代码略)打印结果:1 2 3 4 5 6 7 8 9 10 1 2 3 4 5 6 7 8 9 10

同步一个方法

public synchronized void func () {
    // ...
}

它作用于同一个对象。(这就是 HashTable 不如 ConcurrentHashMap 的地方,因为它在方法上同步,锁住了整个对象,太过笨重)

同步一个静态方法

public synchronized static void fun() {
    // ...
}

它作用于整个类。