共计 6937 个字符,预计需要花费 18 分钟才能阅读完成。
实现原理
Synchronized 可以保证一个在多线程运行中,同一时刻只有一个方法或者代码块被执行,它还可以保证共享变量的可见性和原子性
在 Java 中每个对象都可以作为锁,这是 Synchronized 实现同步的基础。具体的表现为一下 3 种形式:
- 普通同步方法,锁是当前实例对象;
- 静态同步方法,锁是当前类的 Class 对象;
- 同步方法快,锁是 Synchronized 括号中配置的对象。
当一个线程试图访问同步代码块时,它必须先获取到锁,当同步代码块执行完毕或抛出异常时,必须释放锁。那么它是如何实现这一机制的呢?我们先来看一个简单的 synchronized 的代码:
public class SyncDemo {public synchronized void play() {}
public void learn() {synchronized(this) {}}
}
利用 javap 工具查看生成的 class 文件信息分析 Synchronized,下面是部分信息
public com.zzw.juc.sync.SyncDemo();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=1, locals=1, args_size=1
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
LineNumberTable:
line 8: 0
LocalVariableTable:
Start Length Slot Name Signature
0 5 0 this Lcom/zzw/juc/sync/SyncDemo;
public synchronized void play();
descriptor: ()V
flags: ACC_PUBLIC, ACC_SYNCHRONIZED
Code:
stack=0, locals=1, args_size=1
0: return
LineNumberTable:
line 10: 0
LocalVariableTable:
Start Length Slot Name Signature
0 1 0 this Lcom/zzw/juc/sync/SyncDemo;
public void learn();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=2, locals=3, args_size=1
0: aload_0
1: dup
2: astore_1
3: monitorenter
4: aload_1
5: monitorexit
6: goto 14
9: astore_2
10: aload_1
11: monitorexit
12: aload_2
13: athrow
14: return
Exception table:
from to target type
4 6 9 any
9 12 9 any
从上面利用 javap 工具生成的信息我们可以看到同步方法是利用 ACC_SYNCHRONIZED 这个修饰符来实现的,同步代码块是利用 monitorenter 和 monitorexit 这 2 个指令来实现的。
- 同步代码块:monitorenter 指令插入到同步代码块的开始位置,monitorexit 指令插入到同步代码块的结束位置,JVM 需要保证每一个 monitorenter 都有一个 monitorexit 与之相对应。任何对象都有一个 monitor 与之相关联,当且一个 monitor 被持有之后,他将处于锁定状态。线程执行到 monitorenter 指令时,将会尝试获取对象所对应的 monitor 所有权,即尝试获取对象的锁;
- 同步方法:synchronized 方法则会被翻译成普通的方法调用和返回指令如:invokevirtual、areturn 指令,在 JVM 字节码层面并没有任何特别的指令来实现被 synchronized 修饰的方法,而是在 Class 文件的方法表中将该方法的 access_flags 字段中的 synchronized 标志位置 1,表示该方法是同步方法并使用调用该方法的对象或该方法所属的 Class 在 JVM 的内部对象表示 Klass 做为锁对象
在继续分析 Synchronized 之前,我们需要理解 2 个非常重要的概念:Java 对象头和 Monitor
Java 对象头
Synchronized 用的锁是存放在 Java 对象头里面的。那么什么是对象头呢?在 Hotspot 虚拟机中,对象头包含 2 个部分:标记字段(Mark Word)和类型指针(Kass point)。
其中 Klass Point 是是对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例,Mark Word 用于存储对象自身的运行时数据,它是实现轻量级锁和偏向锁的关键。这里我们将重点阐述 Mark Word。
Mark Word
Mark Word 用于存储对象自身的运行时数据,如哈希码(Hash Code)、GC 分代年龄、锁状态标志、线程持有锁、偏向线程 ID、偏向时间戳等,这部分数据在 32 位和 64 位虚拟机中分别为 32bit 和 64bit。一个对象头一般用 2 个机器码存储(在 32 位虚拟机中,一个机器码为 4 个字节即 32bit), 但如果对象是数组类型,则虚拟机用 3 个机器码来存储对象头,因为 JVM 虚拟机可以通过 Java 对象的元数据信息确定 Java 对象的大小,但是无法从数组的元数据来确认数组的大小,所以用一块来记录数组长度。
在 32 位虚拟机中,Java 对象头的 Makr Word 的默认存储结构如下:
锁状态 | 25bit | 4bit | 1bit 是否是偏向锁 | 2bit 锁标志位 |
---|---|---|---|---|
无锁状态 | 对象的 HashCode | 对象分代年龄 | 0 | 01 |
在程序运行期间,对象头中锁表标志位会发生改变。Mark Word 可能发生的变化如下:
在 64 位虚拟机中,Java 对象头中 Mark Work 的长度是 64 位的, 其结构如下:
介绍了 Mark Word 下面我们来介绍下一个重要的概率 Monitor。
Monitor
Monitor 是操作系统提出来的一种高级原语,但其具体的实现模式,不同的编程语言都有可能不一样。Monitor 有一个重要特点那就是,同一个时刻,只有一个线程能进入到 Monitor 定义的临界区中,这使得 Monitor 能够达到互斥的效果。但仅仅有互斥的作用是不够的,无法进入 Monitor 临界区的线程,它们应该被阻塞,并且在必要的时候会被唤醒。显然,monitor 作为一个同步工具,也应该提供这样的机制。Monitor 的机制如下图所示:
从上图中,我们来分析下 Monitor 的机制:
Mointor 可以看做是一个特殊的房间(这个房间就是我们在 Java 线程中定义的临界区),Monitor 在同一时间,保证只能有一个线程进入到这个房间,进入房间即表示持有 Monitor,退出房间即表示释放 Monitor。
当一个线程需要访问临界区中的数据(即需要获取到对象的 Monitro)时,他首先会在 entry-set 入口队列中排队等待(这里并不是真正的按照排队顺序),如果没有线程持有对象的 Monitor, 那么 entry-set 队列中的线程会和 waite-set 队列中被唤醒的线程进行竞争,选出一个线程来持有对象 Monitor,执行受保护的代码段,执行完毕后释放 Monitor,如果已经有线程持有对象的 Monitor,那么需要等待其释放 Monitor 后再进行竞争。当一个线程拥有对象的 Monitor 后,这个时候如果调用了 Object 的 wait 方法,线程就释放了 Monitor,进入 wait-set 队列,当 Object 的 notify 方法被执行后,wait-set 中的线程就会被唤醒,然后在 wait-set 队列中被唤醒的线程和 entry-set 队列中的线程一起通过 CPU 调度来竞争对象的 Monitor,最终只有一个线程能获取对象的 Monitor。
需要注意的是:
当一个线程在 wait-set 中被唤醒后,并不一定会立刻获取 Monitor,它需要和其他线程去竞争
如果一个线程是从 wait-set 队列中唤醒后,获取到的 Monitor,它会去读取它自己保存的 PC 计数器中的地址,从它调用 wait 方法的地方开始执行。
锁的优化和对比
在 JavaSE6 为了对锁进行优化,引入了偏向锁和轻量级锁。在 JavaSE6 中锁一共有 4 种状态,它们从低到高一次是无状态锁、偏向锁、轻量级锁和重量级锁。锁的这几种状态会随着竞争而依次升级,但是锁是不能降级的。
偏向锁
偏向锁顾名思义就是偏向于第一个访问锁的线程,在运行的过程中同步锁只有一个线程访问,不存在多线程竞争的情况,则线程不会触发同步,这种情况下会给线程加一个偏向锁。偏向锁的引入就是为了让线程获取锁的代价更低。
-
偏向锁的获取
(1)访问 Mark Word 中偏向锁的标识是否设置成 1,锁标志位是否为 01——确认为可偏向状态。
(2)如果为可偏向状态,则测试线程 ID 是否指向当前线程,如果是,进入步骤(5),否则进入步骤(3)。
(3)如果线程 ID 并未指向当前线程,则通过 CAS 操作竞争锁。如果竞争成功,则将 Mark Word 中线程 ID 设置为当前线程 ID,然后执行(5);如果竞争失败,执行(4)。
(4)如果 CAS 获取偏向锁失败,则表示有竞争。当到达全局安全点(safepoint)时获得偏向锁的线程被挂起,偏向锁升级为轻量级锁,然后被阻塞在安全点的线程继续往下执行同步代码。
(5)执行同步代码。
-
偏向锁的释放
偏向锁的释放在上面偏向锁的获取中的第 4 步已经提到过。偏向锁只有在遇到其它线程竞争偏向锁时,持有偏向锁的线程才会释放。线程是不会主动的去释放偏向锁的。偏向锁的释放需要等到全局安全点(在这个时间点上没有正在执行的字节码),它会首先去暂停拥有偏向锁的线程,撤销偏向锁,设置对象头中的 Mark Word 为无锁状态或轻量级锁状态,再恢复暂停的线程。
-
偏向锁的关闭
偏向锁在 Java6 和 Java7 中是默认开启的,但它是在应用程序启动几秒后才激活。如果想消除延时立即开启,可以调整 JVM 参数来关闭延迟:-XX: BiasedLockingStartupDelay=0。如果你确定应用程序中没有偏向锁的存在,你也可以通过 JVM 参数关闭偏向锁:-XX:UseBiasedLocking=false,使用改参数后,程序会默认进入到轻量级锁状态。
-
偏向锁的适用场景
始终只有一个线程在执行同步块,在它没有执行完同步代码块释放锁之前,没有其它线程去执行同步块来竞争锁,在锁无竞争的情况下使用。一旦有了竞争就升级为轻量级锁,升级为轻量级锁的时候需要撤销偏向锁,撤销偏向锁需要在全局安全点上,这个时候会导致 Stop The World,Stop The Wrold 会导致性能下降,因此在高并发的场景下应当禁用偏向锁。
轻量级锁
轻量级锁是有偏向锁竞争升级而来的。引入轻量级锁的目的是在没有多线程竞争的情况下,减少传统的重量级锁使用操作系统互斥量产生的性能消耗。
-
轻量级锁的获取
(1)在代码进入同步代码块时,如果同步对象没有被锁定(锁标志位为“01”状态),虚拟机首先将在当前线程的栈帧中建了一个名为锁记录(Lock Record)的空间,用于存储对象目前的 Mark Word 的拷贝,官方称之为 Displaced Mark Word。
(2)虚拟机将使用 CAS 操作尝试将对象的 Mark Word 更新为指向 Lock Record 的指针,如果更新成功,则表示获取到了锁,并将锁标志位设置为“00”(表示对象处于轻量级锁状态)。如果失败则执行(3)操作。
(3)虚拟机检查当前对象的 Mark Wrod 是否指向当前线程的栈帧,如果是这说明当前线程已经持有了这个对象的锁,直接进入同步块继续运行;否则说明这个锁对象已经被其它线程持有,这是轻量级锁就要膨胀为重量级锁,锁标志的状态值变更为“10”,后面等待锁的线程也要进入阻塞状态。 -
轻量级锁的释放
(1)使用 CAS 操作把对象当前的 Mark Word 和线程中复制的 Displaced Mark Word 替换回来,如果成功,则同步过程完成。
(2)CAS 替换失败,说明有其他线程尝试过获取该锁,那就要在释放锁的同时,唤醒被挂起的线程。
轻量级锁能提升同步性能的依据是“对于绝大部分的锁,在整个同步周期都是不存在竞争的”。若果没有竞争,轻量级锁使用 CAS 操作避免了使用互斥量的开销,但如果存在锁竞争,除了互斥量的开销外,还额外发成了 CAS 操作,因此存在竞争的情况下,轻量级锁比传统的重量级做会更慢。
重量级锁
重量级锁通过对象内部的监视器(monitor)实现,其中 monitor 的本质是依赖于底层操作系统的 Mutex Lock 实现,操作系统实现线程之间的切换需要从用户态到内核态的切换,切换成本非常高。
偏向锁、轻量级锁的状态转换
其它优化
-
自旋锁
线程的挂起和恢复需要 CPU 从用户状态切换到核心状态,频繁的挂起和恢复会给系统的并发性能带来很大的压力。同时我们发现在许多的应用上,共享该数据的锁定只会持续很短的一段时间,为了这一段很短的时间,让线程频繁的挂起和恢复是很不值得的,因此引入了自旋锁。
自旋锁的原理非常的简单,若果那些持有锁的线程能够在很短的时间释放资源,那么那等待竞争锁的线程就不需要做用户状态和内核状态的切换进入阻塞挂起状态,它们只需要“稍等一下”,等待持有锁的线程释放资源后立即获取锁。这里需要注意的是,线程在自旋的过程中,是不会放弃 CPU 的执行时间的,因此如果锁被占用的时间很长,那么自旋的线程不做任何有用的工作从而浪费了 CPU 的资源。所有自旋等待时间必须有一个限制,如果自旋超过了限定的次数任然没有获取锁,则需要停止自旋进入阻塞状态。虚拟机设定的自旋次数默认是 10 次,可以通过 -XX:PreBlockSpin 来更改。 -
自适自旋锁
上面说到自旋锁的自旋次数是一个固定的值,但是这个自旋次数应该如何限定了,设置大了会让线程一直占用 CPU 时间浪费性能,设置低了会让线程频繁的进入挂起和恢复状态也会浪费性能。因此 JDK 在 1.6 中引入了自适应自旋锁,自适应说明自旋的时间不在是固定的了,而是由前一次在同一个锁上的自旋时间以及锁的拥有者的状态来决定的。
自适应自旋锁的原理也非常简单,当一个线程在一把锁上自旋成功,那么下一次在这个锁上自旋的时间将更长,因为虚拟机认为上次自旋成功了,那么这次自旋也有可能再次成功。反之,如果一个线程在一个锁上很少自旋成功,那么以后这个线程要获取这个锁时,自旋的此时将会减少甚至可能省略自旋的过程,直接进入阻塞状态以免浪费 CPU 的资源。 -
锁消除
锁消除是指虚拟机即时编译器在运行时,对一些代码上要求同步,但是被检测到不可能存在共享数据竞争的锁进行消除。锁消除的主要判定依据是逃逸分析的数据支持。变量是否逃逸对于虚拟机来说需要使用数据流来分析,但是对于我们程序员应该是很清楚的,怎么会在知道不存在数据竞争的情况下使用同步呢?但是程序有时并不是我们想的那样,虽然我们没有显示的使用锁,但是在使用一些 Java 的 API 时,会存在隐式加锁的情况。例如如下代码:
public String concat(String s1, String s2){StringBuffer sb = new StringBuffer();
sb.append(s1);
sb.append(s2);
return sb.toString();}
我们知道每个 sb.append()方法中都有一个同步快,锁就是 sb 的对象。因此虚拟机在运行这段代码时,会监测到 sb 这个变量永远不会“逃逸”到 concat()方法之外,因此虚拟机就会消除这段代码中的锁而直接执行了。
-
锁粗化
我们知道在使用同步锁的时候,需要尽量将同步块的作用范围限制的尽量小一些 —- 只在共享数据的实际作用域中才进行同步,这样做的目的是为了是同步的时间尽可能的缩短,如果存在锁的竞争,那么等待锁的线程也能尽快的获取到锁。
大多数情况下,上面的的原则都是正确的。但是如果一系列的连续操作都对同一个对象反复的加锁,甚至加锁出现在循环体中,那么即时没有竞争,频繁的进行互斥同步操作也会导致不必须的性能损耗。所以引入了锁粗化的概率。
那么什么是锁粗化呢?锁粗化就是将连接加锁、解锁的过程连接在一起,扩展(粗化)成为一个同步范围更大的锁。以上面代码为例,就是扩展到第一个 append()操作之前,直至最后一个 append()操作之后,这样只需要加锁一次就可以了。
总结
本文重点探究了 Synchronized 的实现原理,以及 JDK 引入偏向锁和轻量级锁对 synchronized 所做的优化处理,和一些其他的锁的优化处理。我们最后来总结一下 Synchronized 的执行过程:
- 检测 Mark Word 里面是不是当前线程的 ID,如果是,表示当前线程处于偏向锁。
- 如果不是,则使用 CAS 将当前线程的 ID 替换 Mard Word,如果成功则表示当前线程获得偏向锁,置偏向标志位 1。
- 如果失败,则说明发生竞争,撤销偏向锁,进而升级为轻量级锁。
- 当前线程使用 CAS 将对象头的 Mark Word 替换为锁记录指针,如果成功,当前线程获得锁。
- 如果失败,表示其他线程竞争锁,当前线程便尝试使用自旋来获取锁。
- 如果自旋成功则依然处于轻量级状态。
- 如果自旋失败,则升级为重量级锁。