JMM 中关于 synchronized 有如下规定,线程加锁时,必须清空工作内存中共享变量的值,从而使得被监视器保护的临界区代码必须从主内存中读取共享变量;线程在解锁时,需要把工作内存中最新的共享变量的值写入到主存,以此来保证共享变量的可见性。
SYNC简介
synchronize 关键字可以修饰方法或者同步块,它主要确保多个线程在同一时刻,只能有一个线程处于方法或者同步块中,它保证了线程对变量访问的可见性和排他性。synchronize 关键字的实现,本质是对一个对象的监视器(monitor)进行获取,而这个获取过程是排他的,也就是同一时刻只能有一个线程获取到由 synchronize 所保护对象的监视器
任何一个对象都拥有自己的监视器,任意一个线程对 Object 的访问(Object 由 synchronize 保护)的访问,首先要获得 Object 的监视器。如果获取失败,线程进入同步队列,线程状态变为 BLOCKED。当访问 Object 的前驱(获得了锁的线程)释放了锁,则该释放操作将唤醒阻塞在同步队列中的线程,使其重新尝试获取监视器。
SYNC 应用方法
- 修饰实例方法,作用于当前实例加锁,进入同步方法前要获取当前实例的锁
- 修饰静态方法,作用于当前类对象加锁,进入同步方法前要获得当前类对象的锁
- 修饰代码块,指定加锁对象,对给定对象加锁,进入同步代码库前要获得给定对象的锁。
需要注意的是,synchronized 关键字修饰的方法如果出现执行异常,锁会自动释放。
SYNC 锁使用注意事项
常量池的锁对象
String a = "a";
String b = "a";
System.out.println(a == b);//true
将 synchronized 同步块与 String 等包装类常量池联合使用时,常量池会造成上述代码块中 a 和 b 的指向是常量池的 a,进行持锁操作时,多个线程持有相同的锁,所以造成首先抢占到资源的线程执行,其他线程阻塞。
SYNC底层语义原理
Java 虚拟机中的同步( synchronization )基于进入和退出管程( Monitor )对象实现,显式同步和隐式同步都是基于此实现。
显式同步:有明确的 monitorenter 和 monitorexit 指令,即同步代码块
隐式同步:由方法调用指令读取运行时常量池中方法的 ACC_SYNCHRONIZED 标志来实现,即同步方法
Java对象结构与Monitor
在JVM中,对象在内存中的布局分为三块区域:对象头、实例数据和对齐填充。如下:
- 实例变量:存放类的属性数据信息,包括父类的属性信息,如果是数组的实例部分还包括数组的长度,这部分内存按 4 字节对齐。
- 填充数据:由于虚拟机要求对象起始地址必须是 8 字节的整数倍。填充数据不是必须存在的,仅仅是为了字节对齐。
而对于 Java 头对象,它实现 synchronized 的锁对象的基础。一般而言,synchronized 使用的锁对象是存储在 Java 对象头里的,JVM 中采用 2 个字来存储对象头(如果对象是数组则会分配 3 个字,多出来的 1 个字记录的是数组长度),其主要结构是由 Mark Word 和 Class Metadata Address 组成,其结构说明如下表:
虚拟机位数 | 头对象结构 | 说明 |
---|---|---|
32/64bit | Mark Word | 存储对象的 hashCode、锁信息或分代年龄或 GC 标志等信息 |
32/64bit | Class Metadata Address | 类型指针指向对象的类元数据,JVM 通过这个指针确定该对象是哪个类的实例。 |
其中 Mark Word 在默认情况下存储着对象的 HashCode、分代年龄、锁标记位等以下是 32 位 JVM 的 Mark Word 默认存储结构
锁状态 | 25bit | 4bit | 1bit是否是偏向锁 | 2bit 锁标志位 |
---|---|---|---|---|
无锁状态 | 对象HashCode | 对象分代年龄 | 0 | 01 |
由于对象头的信息是与对象自身定义的数据没有关系的额外存储成本,因此考虑到 JVM 的空间效率,Mark Word 被设计成为一个非固定的数据结构,以便存储更多有效的数据,它会根据对象本身的状态复用自己的存储空间,如 32 位 JVM 下,除了上述列出的 Mark Word 默认存储结构外,还有如下可能变化的结构:
锁状态 |
25bit |
4bit |
1bit |
2bit |
|
23bit |
2bit |
是否偏向锁 |
锁标志位 |
||
无锁 |
对象的HashCode |
分代年龄 |
0 |
01 |
|
偏向锁 |
线程ID |
Epoch |
分代年龄 |
1 |
01 |
轻量级锁 |
指向栈中锁记录的指针 |
00 |
|||
重量级锁 |
指向重量级锁的指针 |
10 |
|||
GC标记 |
空 |
11 |
其中轻量级锁和偏向锁是 Java 6 对 synchronized 锁进行优化后新增加的。下面主要分析的是重量级锁也就是通常说 synchronized 的对象锁,锁标识位为 10,其中指针指向的是 monitor 对象(也称为管程)的起始地址。每个对象都存在着一个 monitor 与之关联,对象与其 monitor 之间的关系有存在多种实现方式,如 monitor 可以与对象一起创建销毁或当线程试图获取对象锁时自动生成,但当一个 monitor 被某个线程持有后,它便处于锁定状态。在 Java 虚拟机(HotSpot)中,monitor 是由 ObjectMonitor 实现的,其主要数据结构如下(位于 HotSpot 虚拟机源码 ObjectMonitor.hpp 文件,C++实现的)
ObjectMonitor() {
_header = NULL;
_count = 0; //记录个数
_waiters = 0,
_recursions = 0;
_object = NULL;
_owner = NULL;
_WaitSet = NULL; //处于wait状态的线程,会被加入到_WaitSet
_WaitSetLock = 0 ;
_Responsible = NULL ;
_succ = NULL ;
_cxq = NULL ;
FreeNext = NULL ;
_EntryList = NULL ; //处于等待锁block状态的线程,会被加入到该列表
_SpinFreq = 0 ;
_SpinClock = 0 ;
OwnerIsThread = 0 ;
}
ObjectMonitor 中有两个队列,_WaitSet
和_EntryList
,用来保存 ObjectWaiter 对象列表(每个等待锁的线程都会被封装成 ObjectWaiter 对象),_owner
指向持有 ObjectMonitor 对象的线程,当多个线程同时访问一段同步代码时,首先会进入_EntryList
集合,当线程获取到对象的 monitor 后进入_owner
区域并把 monitor 中的 owner 变量设置为当前线程,同时 monitor 中的计数器 count 加 1,若线程调用 wait() 方法,将释放当前持有的 monitor,_owner
变量恢复为 null,count 自减 1,同时该线程进入 WaitSet 集合中等待被唤醒。若当前线程执行完毕也将释放 monitor(锁)并复位变量的值,以便其他线程进入获取 monitor(锁)。过程如下图所示
由此看来,monitor 对象存在于每个Java对象的对象头中(存储的指针的指向),synchronized 锁便是通过这种方式获取锁的,也是为什么 Java 中任意对象可以作为锁的原因,同时也是 notify/notifyAll/wait 等方法存在于顶级对象 Object 中的原因。
Mutex Lock
监视器锁(Monitor)本质是依赖于底层的操作系统的 Mutex Lock(互斥锁)来实现的。每个对象都对应于一个可称为 “互斥锁” 的标记,这个标记用来保证在任一时刻,只能有一个线程访问该对象。
互斥锁
用于保护临界区,确保同一时间只有一个线程访问数据。对共享资源的访问,先对互斥量进行加锁,如果互斥量已经上锁,调用线程会阻塞,直到互斥量被解锁。在完成了对共享资源的访问后,要对互斥量进行解锁。
mutex 的工作方式:
- 申请 mutex
- 如果成功,则持有该 mutex
- 如果失败,则进行 spin 自旋。spin 的过程就是在线等待 mutex, 不断发起 mutex gets,直到获得 mutex 或者达到 spin_count 限制为止
- 依据工作模式的不同选择 yield 还是 sleep
- 若达到 sleep 限制或者被主动唤醒或者完成 yield, 则重复 1~4 步,直到获得为止
由于 Java 的线程是映射到操作系统的原生线程之上的,如果要阻塞或唤醒一条线程,都需要操作系统来帮忙完成,这就需要从用户态转换到核心态中,因此状态转换需要耗费很多的处理器时间。所以 synchronized 是 Java 语言中的一个重量级操作。在 JDK1.6 中,虚拟机进行了一些优化,譬如在通知操作系统阻塞线程之前加入一段自旋等待过程,避免频繁地切入到核心态中。
SYNC代码块底层原理
同步语句块的实现使用的是 monitorenter 和 monitorexit 指令,其中 monitorenter 指令指向同步代码块的开始位置,monitorexit 指令则指明同步代码块的结束位置,当执行 monitorenter 指令时,当前线程将试图获取 objectref(即对象锁) 所对应的 monitor 的持有权,当 objectref 的 monitor 的进入计数器为 0,那线程可以成功取得 monitor,并将计数器值设置为 1,取锁成功。如果当前线程已经拥有 objectref 的 monitor 的持有权,那它可以重入这个 monitor,重入时计数器的值也会加 1。倘若其他线程已经拥有 objectref 的 monitor 的所有权,那当前线程将被阻塞,直到正在执行线程执行完毕,即 monitorexit 指令被执行,执行线程将释放 monitor(锁)并设置计数器值为 0 ,其他线程将有机会持有 monitor 。
值得注意的是编译器将会确保无论方法通过何种方式完成,方法中调用过的每条 monitorenter 指令都有执行其对应 monitorexit 指令,而无论这个方法是正常结束还是异常结束。为了保证在方法异常完成时 monitorenter 和 monitorexit 指令依然可以正确配对执行,编译器会自动产生一个异常处理器,这个异常处理器声明可处理所有的异常,它的目的就是用来执行 monitorexit 指令。从字节码中也可以看出多了一个 monitorexit 指令,它就是异常结束时被执行的释放 monitor 的指令。
public class SyncCodeBlock {
public int i;
public void syncTask() {
synchronized (this) {
i++;
}
}
}
使用 javap 反编译后的字节码如下:
Classfile /D:/WorkSpace/demo/src/main/java/com/example/demo/SyncCodeBlock.class
Last modified 2021-2-11; size 419 bytes
MD5 checksum 3c89ede2ecc9c05ea64b76a64285d244
Compiled from "SyncCodeBlock.java"
public class com.example.demo.SyncCodeBlock
minor version: 0
major version: 52
flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
#1 = Methodref #4.#18 // java/lang/Object."<init>":()V
#2 = Fieldref #3.#19 // com/example/demo/SyncCodeBlock.i:I
#3 = Class #20 // com/example/demo/SyncCodeBlock
#4 = Class #21 // java/lang/Object
#5 = Utf8 i
#6 = Utf8 I
#7 = Utf8 <init>
#8 = Utf8 ()V
#9 = Utf8 Code
#10 = Utf8 LineNumberTable
#11 = Utf8 syncTask
#12 = Utf8 StackMapTable
#13 = Class #20 // com/example/demo/SyncCodeBlock
#14 = Class #21 // java/lang/Object
#15 = Class #22 // java/lang/Throwable
#16 = Utf8 SourceFile
#17 = Utf8 SyncCodeBlock.java
#18 = NameAndType #7:#8 // "<init>":()V
#19 = NameAndType #5:#6 // i:I
#20 = Utf8 com/example/demo/SyncCodeBlock
#21 = Utf8 java/lang/Object
#22 = Utf8 java/lang/Throwable
{
public int i;
descriptor: I
flags: ACC_PUBLIC
public com.example.demo.SyncCodeBlock();
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 3: 0
public void syncTask();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=3, locals=3, args_size=1
0: aload_0
1: dup
2: astore_1
3: monitorenter
4: aload_0
5: dup
6: getfield #2 // Field i:I
9: iconst_1
10: iadd
11: putfield #2 // Field i:I
14: aload_1
15: monitorexit
16: goto 24
19: astore_2
20: aload_1
21: monitorexit
22: aload_2
23: athrow
24: return
Exception table:
from to target type
4 16 19 any
19 22 19 any
LineNumberTable:
line 7: 0
line 8: 4
line 9: 14
line 10: 24
StackMapTable: number_of_entries = 2
frame_type = 255 /* full_frame */
offset_delta = 19
locals = [ class com/example/demo/SyncCodeBlock, class java/lang/Object ]
stack = [ class java/lang/Throwable ]
frame_type = 250 /* chop */
offset_delta = 4
}
SourceFile: "SyncCodeBlock.java"
SYNC方法底层原理
方法级的同步是隐式,即无需通过字节码指令来控制的,它实现在方法调用和返回操作之中。JVM 可以从方法常量池中的方法表结构(method_info Structure)中的 ACC_SYNCHRONIZED 访问标志区分一个方法是否同步方法。当方法调用时,调用指令将会检查方法的 ACC_SYNCHRONIZED 访问标志是否被设置,如果设置了,执行线程将先持有 monitor(虚拟机规范中用的是管程一词),然后再执行方法,最后再方法完成(无论是正常完成还是非正常完成)时释放 monitor。在方法执行期间,执行线程持有了monitor,其他任何线程都无法再获得同一个 monitor。如果一个同步方法执行期间抛出了异常,并且在方法内部无法处理此异常,那这个同步方法所持有的 monitor 将在异常抛到同步方法之外时自动释放。下面我们看看字节码层面如何实现:
package com.example.demo;
public class SyncCodeBlock {
public int i;
public synchronized void syncTask() {
i++;
}
}
使用 javap 反编译后的字节码如下:
Classfile /D:/WorkSpace/demo/src/main/java/com/example/demo/SyncCodeBlock.class
Last modified 2021-2-11; size 307 bytes
MD5 checksum 86957f4e352f32bd256020f7cbdadc82
Compiled from "SyncCodeBlock.java"
public class com.example.demo.SyncCodeBlock
minor version: 0
major version: 52
flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
#1 = Methodref #4.#14 // java/lang/Object."<init>":()V
#2 = Fieldref #3.#15 // com/example/demo/SyncCodeBlock.i:I
#3 = Class #16 // com/example/demo/SyncCodeBlock
#4 = Class #17 // java/lang/Object
#5 = Utf8 i
#6 = Utf8 I
#7 = Utf8 <init>
#8 = Utf8 ()V
#9 = Utf8 Code
#10 = Utf8 LineNumberTable
#11 = Utf8 syncTask
#12 = Utf8 SourceFile
#13 = Utf8 SyncCodeBlock.java
#14 = NameAndType #7:#8 // "<init>":()V
#15 = NameAndType #5:#6 // i:I
#16 = Utf8 com/example/demo/SyncCodeBlock
#17 = Utf8 java/lang/Object
{
public int i;
descriptor: I
flags: ACC_PUBLIC
public com.example.demo.SyncCodeBlock();
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 3: 0
public synchronized void syncTask();
descriptor: ()V
flags: ACC_PUBLIC, ACC_SYNCHRONIZED
Code:
stack=3, locals=1, args_size=1
0: aload_0
1: dup
2: getfield #2 // Field i:I
5: iconst_1
6: iadd
7: putfield #2 // Field i:I
10: return
LineNumberTable:
line 7: 0
line 8: 10
}
SourceFile: "SyncCodeBlock.java"
从字节码中可以看出,synchronized 修饰的方法并没有 monitorenter 指令和 monitorexit 指令,取得代之的确实是 ACC_SYNCHRONIZED 标识,该标识指明了该方法是一个同步方法,JVM 通过 ACC_SYNCHRONIZED 访问标志来辨别一个方法是否声明为同步方法,从而执行相应的同步调用。这便是 synchronized 锁在同步代码块和同步方法上实现的基本原理。
同时我们还必须注意到的是在 Java 早期版本中,synchronized 属于重量级锁,效率低下,因为监视器锁(monitor)是依赖于底层的操作系统的 Mutex Lock 来实现的,而操作系统实现线程之间的切换时需要从用户态转换到核心态,这个状态之间的转换需要相对比较长的时间,时间成本相对较高,这也是为什么早期的 synchronized 效率低的原因。
SYNC的重量级锁
如果存在同一时间访问同一锁的情况,就会导致轻量级锁膨胀为重量级锁。Mark Word 的锁标记位更新为10,Mark Word 指向互斥量(mutex )。
Synchronized 的重量级锁是通过对象内部的一个叫做监视器锁(monitor)来实现的,监视器锁本质又是依赖于底层的操作系统的 Mutex Lock(互斥锁)来实现的。而操作系统实现线程之间的切换需要从用户态转换到核心态,涉及到用户态和内核态的切换、操作系统内核态中的线程的阻塞和恢复。这个成本非常高,状态之间的转换需要相对比较长的时间,这就是为什么 Synchronized 效率低的原因。因此,这种依赖于操作系统Mutex Lock所实现的锁我们称之为“重量级锁”。
JVM对SYNC的优化
synchronized 与 java.util.concurrent 包中的 ReentrantLock 相比,由于 Java 6 中加入了针对锁的优化措施,使得 synchronized 与 ReentrantLock 的性能基本持平。ReentrantLock 只是提供了 synchronized 更丰富的功能,而不一定有更优的性能,所以在 synchronized 能实现需求的情况下,优先考虑使用 synchronized 来进行同步。
Java 6 为了减少获得锁和释放锁带来的性能消耗,引入了“偏向锁”和“轻量级锁”:锁一共有 4 种状态,级别从低到高依次是:无锁状态、偏向锁状态、轻量级锁状态和重量级锁状态。锁可以升级但不能降级。
偏向锁,轻量级锁都是乐观锁,重量级锁是悲观锁。
- 一个对象刚开始实例化的时候,没有任何线程来访问它的时候。它是可偏向的,意味着,它现在认为只可能有一个线程来访问它,所以当第一个线程来访问它的时候,它会偏向这个线程,此时,对象持有偏向锁。偏向第一个线程,这个线程在修改对象头成为偏向锁的时候使用 CAS 操作,并将对象头中的 ThreadID 改成自己的 ID,之后再次访问这个对象时,只需要对比 ID,不需要再使用 CAS 在进行操作。
- 一旦有第二个线程访问这个对象,因为偏向锁不会主动释放,所以第二个线程可以看到对象时偏向状态,这时表明在这个对象上已经存在竞争了。检查原来持有该对象锁的线程是否依然存活,如果挂了,则可以将对象变为无锁状态,然后重新偏向新的线程。如果原来的线程依然存活,则马上执行那个线程的操作栈,检查该对象的使用情况,如果仍然需要持有偏向锁,则偏向锁升级为轻量级锁,(偏向锁就是这个时候升级为轻量级锁的),此时轻量级锁由原持有偏向锁的线程持有,继续执行其同步代码,而正在竞争的线程会进入自旋等待获得该轻量级锁;如果不存在使用了,则可以将对象回复成无锁状态,然后重新偏向。
- 轻量级锁认为竞争存在,但是竞争的程度很轻,一般两个线程对于同一个锁的操作都会错开,或者说稍微等待一下(自旋),另一个线程就会释放锁。 但是当自旋超过一定的次数,或者一个线程在持有锁,一个在自旋,又有第三个来访时,轻量级锁膨胀为重量级锁,重量级锁使除了拥有锁的线程以外的线程都阻塞,防止CPU空转。
偏向锁
偏向锁优化同一线程反复获取锁的场景
偏向锁是 Java 6 之后加入的新锁,它是一种针对加锁操作的优化手段,经过研究发现,在大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,因此为了减少同一线程获取锁(会涉及到一些 CAS 操作,耗时)的代价而引入偏向锁。
偏向锁的核心思想是,如果一个线程获得了锁,那么锁就进入偏向模式,此时 Mark Word 的结构也变为偏向锁结构,当这个线程再次请求锁时,无需再做任何同步操作,即获取锁的过程,这样就省去了大量有关锁申请的操作,从而也就提供程序的性能。
偏向锁获取过程:
- 访问 Mark Word 中偏向锁的标识是否设置成 1,锁标志位是否为 01 —— 确认为可偏向状态。
- 如果为可偏向状态,则测试线程 ID 是否指向当前线程,如果是,进入步骤 5,否则进入步骤 3。
- 如果线程 ID 并未指向当前线程,则通过 CAS 操作竞争锁。如果竞争成功,则将 Mark Word 中线程 ID 设置为当前线程 ID,然后执行 5;如果竞争失败,执行 4。
- 如果 CAS 获取偏向锁失败,则表示有竞争( CAS 获取偏向锁失败说明至少有过其他线程曾经获得过偏向锁,因为线程不会主动去释放偏向锁)。当到达全局安全点( safepoint )时,会首先暂停拥有偏向锁的线程,然后检查持有偏向锁的线程是否活着(因为可能持有偏向锁的线程已经执行完毕,但是该线程并不会主动去释放偏向锁),如果线程不处于活动状态,则将对象头设置成无锁状态(标志位为“01”),然后重新偏向新的线程;如果线程仍然活着,撤销偏向锁后升级到轻量级锁状态(标志位为“00”),此时轻量级锁由原持有偏向锁的线程持有,继续执行其同步代码,而正在竞争的线程会进入自旋等待获得该轻量级锁。
- 执行同步代码。
偏向锁的释放过程:
如上步骤 4。偏向锁使用了一种等到竞争出现才释放偏向锁的机制:偏向锁只有遇到其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁,线程不会主动去释放偏向锁。偏向锁的撤销,需要等待全局安全点(在这个时间点上没有字节码正在执行),它会首先暂停拥有偏向锁的线程,判断锁对象是否处于被锁定状态,撤销偏向锁后恢复到未锁定(标志位为“01”)或轻量级锁(标志位为“00”)的状态。
关闭偏向锁:
偏向锁在 Java 6+ 里是默认启用的。由于偏向锁是为了在只有一个线程执行同步块时提高性能,如果你确定应用程序里所有的锁通常情况下处于竞争状态,可以通过 JVM 参数关闭偏向锁:-XX:-UseBiasedLocking
,那么程序默认会进入轻量级锁状态。
轻量级锁
轻量级锁优化线程近乎交替执行同步块的场合
倘若偏向锁失败,虚拟机并不会立即升级为重量级锁,它还会尝试使用一种称为轻量级锁的优化手段(Java 6 之后加入的),此时 Mark Word 的结构也变为轻量级锁的结构。轻量级锁能够提升程序性能的依据是“对绝大部分的锁,在整个同步周期内都不存在竞争”,注意这是经验数据。需要了解的是,轻量级锁所适应的场景是线程交替执行同步块的场合,如果存在同一时间访问同一锁的场合,就会导致轻量级锁膨胀为重量级锁。
轻量级锁的加锁过程:
- 在代码进入同步块的时候,如果同步对象锁状态为无锁状态(锁标志位为“01”状态,是否为偏向锁为“0”),虚拟机首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的 Mark Word 的拷贝,官方称之为 Displaced Mark Word。这时候线程堆栈与对象头的状态如下图所示。
- 拷贝对象头中的 Mark Word 复制到锁记录中。
- 拷贝成功后,虚拟机将使用 CAS 操作尝试将对象的 Mark Word 更新为指向 Lock Record 的指针,并将 Lock Record 里的 owner 指针指向 object mark word。如果更新成功,则执行步骤 3,否则执行步骤 4。
- 如果这个更新动作成功了,那么这个线程就拥有了该对象的锁,并且对象 Mark Word 的锁标志位设置为“00”,即表示此对象处于轻量级锁定状态,这时候线程堆栈与对象头的状态如下图所示。
- 如果这个更新操作失败了,虚拟机首先会检查对象的 Mark Word 是否指向当前线程的栈帧,如果是就说明当前线程已经拥有了这个对象的锁,那就可以直接进入同步块继续执行。否则说明多个线程竞争锁,若当前只有一个等待线程,则可通过自旋稍微等待一下,可能另一个线程很快就会释放锁。 但是当自旋超过一定的次数,或者一个线程在持有锁,一个在自旋,又有第三个来访时,轻量级锁膨胀为重量级锁,重量级锁使除了拥有锁的线程以外的线程都阻塞,防止 CPU 空转,锁标志的状态值变为“10”,Mark Word 中存储的就是指向重量级锁(互斥量)的指针,后面等待锁的线程也要进入阻塞状态。
轻量级锁的解锁过程:
- 通过 CAS 操作尝试把线程中复制的 Displaced Mark Word 对象替换当前的Mark Word。
- 如果替换成功,整个同步过程就完成了。
- 如果替换失败,说明有其他线程尝试过获取该锁(此时锁已膨胀),那就要在释放锁的同时,唤醒被挂起的线程。
自旋锁
引入自旋锁的原因:互斥同步对性能最大的影响是阻塞的实现,因为挂起线程和恢复线程的操作都需要转入内核态中完成,这些操作给系统的并发性能带来很大的压力。同时虚拟机的开发团队也注意到在许多应用上面,共享数据的锁定状态只会持续很短一段时间,为了这一段很短的时间频繁地阻塞和唤醒线程是非常不值得的。
自旋锁:让该线程执行一段无意义的忙循环(自旋)等待一段时间,不会被立即挂起(自旋不放弃处理器额执行时间),看持有锁的线程是否会很快释放锁。自旋锁在 JDK1.6 中默认开启。
自旋锁的缺点:自旋等待不能替代阻塞,虽然它可以避免线程切换带来的开销,但是它占用了处理器的时间。如果持有锁的线程很快就释放了锁,那么自旋的效率就非常好;反之,自旋的线程就会白白消耗掉处理器的资源,它不会做任何有意义的工作,这样反而会带来性能上的浪费。所以说,自旋等待的时间(自旋的次数)必须要有一个限度,如果自旋超过了定义的时间仍然没有获取到锁,则应该被挂起(进入阻塞状态)。通过参数-XX:PreBlockSpin
可以调整自旋次数,默认的自旋次数为10。
自适应的自旋锁:JDK1.6 引入自适应的自旋锁,自适应就意味着自旋的次数不再是固定的,它是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定:如果在同一个锁的对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也很有可能再次成功,进而它将允许自旋等待持续相对更长的时间。如果对于某个锁,自旋很少成功获得过,那在以后要获取这个锁时将可能省略掉自旋过程,以避免浪费处理器资源。简单来说,就是线程如果自旋成功了,则下次自旋的次数会更多,如果自旋失败了,则自旋的次数就会减少。
自旋锁使用场景:从轻量级锁获取的流程中我们知道,当线程在获取轻量级锁的过程中执行 CAS 操作失败时,是要通过自旋来获取重量级锁的。
锁消除
消除锁是虚拟机另外一种锁的优化,这种优化更彻底,Java 虚拟机在 JIT 编译时(可以简单理解为当某段代码即将第一次被执行时进行编译,又称即时编译),通过对运行上下文的扫描,根据代码逃逸技术,去除不可能存在共享资源竞争的锁,通过这种方式消除没有必要的锁,可以节省毫无意义的请求锁时间,如 StringBuffer 的 append 是一个同步方法,但是在 add 方法中的 StringBuffer 属于一个局部变量,并且不会被其他线程所使用,因此 StringBuffer 不可能存在共享资源竞争的情景,JVM 会自动将其锁消除。
锁粗化
如果一系列的连续操作都对同一个对象反复加锁和解锁,甚至加锁操作是出现在循环体中的,那即使没有出现线程竞争,频繁地进行互斥同步操作也会导致不必要的性能损耗。如果虚拟机检测到有一串零碎的操作都是对同一对象的加锁,将会把加锁同步的范围扩展(粗化)到整个操作序列的外部。代码示例:
public class StringBufferTest {
StringBuffer stringBuffer = new StringBuffer();
public void append(){
stringBuffer.append("a");
stringBuffer.append("b");
stringBuffer.append("c");
}
}
这里每次调用 StringBuffer.append 方法都需要加锁和解锁,如果虚拟机检测到有一系列连串的对同一个对象加锁和解锁操作,就会将其合并成一次范围更大的加锁和解锁操作,即在第一次 append 方法时进行加锁,最后一次append方法结束后进行解锁。
SYNC关键点
可重入性
从互斥锁的设计上来说,当一个线程试图操作一个由其他线程持有的对象锁的临界资源时,将会处于阻塞状态,但当一个线程再次请求自己持有对象锁的临界资源时,这种情况属于重入锁,请求将会成功。
在 Java 中 SYNC 是基于原子性的内部锁机制,是可重入的,因此在一个线程调用 SYNC 方法的同时在其方法体内部调用该对象另一个 SYNC 方法,也就是说一个线程得到一个对象锁后再次请求该对象锁,是允许的,这就是synchronized 的可重入性。
需要特别注意的一点:当子类继承父类时,子类也是可以通过可重入锁调用父类的同步方法。由于 SYNC 是基于 monitor 实现的,因此每次重入,monitor 中的计数器仍会加 1。
线程中断
//中断线程(实例方法)
public void Thread.interrupt();
//判断线程是否被中断(实例方法)
public boolean Thread.isInterrupted();
//判断是否被中断并清除当前中断状态(静态方法)
public static boolean Thread.interrupted();
阻塞时中断
当一个线程处于被阻塞状态或者试图执行一个阻塞操作时(调用 sleep 时),使用Thread.interrupt()
方式中断该线程,注意此时将会抛出一个 InterruptedException 的异常,同时中断状态将会被复位(由中断状态改为非中断状态)。
运行时中断
在中断处于运行期且非阻塞的状态的线程时,直接调用Thread.interrupt()
中断线程是不会得到任何响应的。但是会改变中断状态,需要手动检测并做出相应处理。
Thread t = new Thread(() -> {
while (true) {
System.out.println(Thread.currentThread().isInterrupted());
}
});
t.start();
Thread.sleep(2000);
t.interrupt();
中断与SYNC
事实上,线程的中断操作对于正在等待获取的锁对象的 synchronized 并不起作用,也就是对于 synchronized 来说,如果一个线程在等待锁,那么结果只有两种,要么它获得这把锁继续执行,要么它就保存等待,即使调用中断线程的方法,也不会生效。
等待唤醒机制
等待唤醒机制主要指的是 notify/notifyAll 和 wait 方法,在使用它们时,必须处于 synchronized 代码块或者synchronized方法中,否则就会抛出 IllegalMonitorStateException 异常,这是因为也就是说 notify/notifyAll 和 wait 方法依赖于管程 monitor 对象。
在前面的分析中,知道 monitor 存在于对象头的 Mark Word 中(存储 monitor 引用指针),而 SYNC 关键字可以获取 monitor,这也就是为什么 notify/notifyAll 和 wait 方法必须在 synchronized 代码块或者 synchronized 方法调用的原因。
synchronized (obj) {
obj.wait();
obj.notify();
obj.notifyAll();
}
需要区分的一点,与 sleep 方法不同的是,wait 方法调用完成后,线程将被暂停,但 wait 方法将会释放当前持有的监视器锁(monitor),直到有线程调用 notify/notifyAll 方法后方能继续执行,而 sleep 方法只让线程休眠并不释放锁。同时 notify/notifyAll 方法调用后,并不会马上释放监视器锁,而是在相应的 synchronized 调用执行结束后才自动释放锁。