1.Synchronized 字节码分析
前面我们已经知道了 Synchronized 在使用的时候有两种方式,一种修饰方法,一种是同步块的方式。
示例:4-1
public class SyncDemo {
public synchronized void a() {
//
}
public void b() {
synchronized(this) {
//code
}
}
}
可以看出示例已经显示了这两种方式,我们可以通过这段代码的字节码来具体分析下它的底层工作原理。
可以运行两个命令:
javac SyncDemo.java
javap -v SyncDemo
我们可以看到如下的字节码文件:
{
public class SyncDemo
minor version: 0
major version: 52
flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
#1 = Methodref #3.#16 // java/lang/Object."<init>":()V
#2 = Class #17 // SyncDemo
#3 = Class #18 // java/lang/Object
#4 = Utf8 <init>
#5 = Utf8 ()V
#6 = Utf8 Code
#7 = Utf8 LineNumberTable
#8 = Utf8 a
#9 = Utf8 b
#10 = Utf8 StackMapTable
#11 = Class #17 // SyncDemo
#12 = Class #18 // java/lang/Object
#13 = Class #19 // java/lang/Throwable
#14 = Utf8 SourceFile
#15 = Utf8 SyncDemo.java
#16 = NameAndType #4:#5 // "<init>":()V
#17 = Utf8 SyncDemo
#18 = Utf8 java/lang/Object
#19 = Utf8 java/lang/Throwable
{
public 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 1: 0
public synchronized void a();
descriptor:()V
flags: ACC_PUBLIC, ACC_SYNCHRONIZED
Code:
stack=0, locals=1, args_size=1
0: return
LineNumberTable:
line 5: 0
public void b();
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
LineNumberTable:
line 8: 0
line 10: 4
line 11: 14
StackMapTable: number_of_entries = 2
frame_type = 255 /* full_frame */
offset_delta = 9
locals = [ class SyncDemo, class java/lang/Object ]
stack = [ class java/lang/Throwable ]
frame_type = 250 /* chop */
offset_delta = 4
}
从字节码可以看出,同步代码块和同步方法稍微有些不同。
- 同步代码块:是使用 monitorenter 和 monitorexit 指令实现的,monitorenter 指令是在编译后插入到同步代码块的开始位置,而 monitorexit 是插入到方法结束处和异常处。任何对象都有一个 monitor 与之关联,当且一个 monitor 被持有后,它将处于锁定状态。在执行 monitorenter 指令时,首先要去尝试获取对象的锁,如果这个对象没被锁定,或者当前线程已经拥有了那个对象的锁,把锁的计数器加 1;相应地,在执行 monitorexit 指令时会将锁计数器减 1,当计数器被减到 0 时,锁就释放了。如果获取对象锁失败了,那当前线程就要阻塞等待,直到对象锁被另一个线程释放为止。
- 同步方法:在看字节码文件的时候,我们并没有发现 monitorenter 和 monitorexit 指令,但是我们能够看到出现了 ACC_SYNCHRONIZED,JVM 通过 ACC_SYNCHRONIZED 访问标志来区分一个方法是否为同步的方法,方法被线程调用时,会检查该方法是否被设置成了该标志位,如果设置了,线程会首先持有 monitor 对象,然后再执行该方法。
2.Synchronized 的实现机制
Java 对象头和 monitor 是实现 Synchronized 的基础! 下面就这两个概念来做详细介绍。
Java 对象头:Hotspot 虚拟机的对象头主要包括两部分数据:** Mark Word(标记字段)、Klass Pointer(类型指针)**。
- Klass Point 是对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例;
- Mark Word 用于存储对象自身的运行时数据,如哈希码(HashCode)、GC 分代年龄、锁状态标志、线程持有的锁、偏向线程 ID、偏向时间戳等等,它是实现轻量级锁和偏向锁的关键。
Monitor:我们可以把它理解为一个同步工具,也可以描述为一种同步机制,它通常被描述为对象监视器。
当多个线程同时请求某个对象监视器时,对象监视器会设置几种状态用来区分请求的线程:
- ContentionList:所有请求锁的线程将被首先放置到该竞争队列
- Entry List:Contention List 中那些有资格成为候选人的线程被移到 Entry List
- Wait Set:那些调用 wait 方法被阻塞的线程被放置到 Wait Set
ContentionList 是一个先进先出(FIFO)的队列,每次新加入 Node 时都会在队头进行,通过 CAS 改变第一个节点的指针为新增节点。
当多个线程同时访问一段同步代码时,多个线程会先被存放在 EntryList 集合中,处于 Block 状态的线程,都会被加入到该列表。接下来当线程获取到对象的 Monitor 时,Monitor 是依靠底层操作系统的 Mutex Lock 来实现互斥的,线程申请 Mutex 成功,则持有该 Mutex,其它线程将无法获取到该 Mutex。
如果线程调用 wait() 方法,就会释放当前持有的 Mutex,并且该线程会进入 WaitSet 集合中,等待下一次被唤醒。如果当前线程顺利执行完方法,也将释放 Mutex。
3.显示锁:Lock
Synchronized 内置锁在后续的 JDK 版本更新中做了许多的优化,在并发量不高,锁竞争不激烈的情况下,和 Lock 锁的性能基本没有太大差别,但在高并发下还是和 Lock 锁有一点差距,Lock 锁性能会更稳定些。
Lock 锁是基于 AQS(AbstractQueuedSynchronizer)实现的,底层会维护一个链表实现的 CLH 队列,用于存储阻塞的线程,而 state 用于描述加锁状态。
下图是我画的 ReentrantLock 获取锁的过程:
上图是 Lock 锁的基本流程图,大家熟记这个图,基本就能回答出 Lock 锁的实现过程了。
但实际工作中可能我们大多数的场景都是读操作要多于写操作,但在多线程的情况下,读操作本身不存在线程安全问题,并不会去修改共享变量。所以 java 针对这种情况。有专门的读写锁来处理这类问题。
4.ReentrantReadWriteLock
ReentrantReadWriteLock 本身也是实现了 Lock 接口的,ReentrantLock 是一个独占锁,同一时间只允许单个线程访问,但是 ReentrantReadWriteLock 是允许多个读线程同时访问的,但是在写线程访问时,所有读线程和写线程都会被阻塞。
ReentrantReadWriteLock 特性:
公平性:支持公平锁和非公平锁,默认是非公平锁。非公平锁比公平锁有更高的吞吐量。
//无参构造函数默认是非公平的
public ReentrantReadWriteLock() {
this(false);
}
//我们可以该构造函数创建公平锁
public ReentrantReadWriteLock(boolean fair) {
sync = fair ? new FairSync() : new NonfairSync();
readerLock = new ReadLock(this);
writerLock = new WriteLock(this);
}
可重入:允许读锁可写锁可重入。写锁可以获得读锁,读锁不能获得写锁。读写锁最多支持 65535 个递归写入锁和 65535 个递归读取锁(这里有个需要注意的地方,ReentrantLock 的可重入次数为 2^32-1,而 ReentrantReadWriteLock 可重入次数为 2^16-1=65535,为啥呢?这是因为 ReentrantReadWriteLock 将 AQS 中的 state 域分成了两部分,读锁和写锁各占 16 位,具体可以往下看)。
(1 << 16) - 1 = 2^16-1
static final int SHARED_SHIFT = 16;
static final int SHARED_UNIT = (1 << SHARED_SHIFT);
static final int MAX_COUNT = (1 << SHARED_SHIFT) - 1;
static final int EXCLUSIVE_MASK = (1 << SHARED_SHIFT) - 1;
锁降级:在获得写锁的情况下再获得读锁(这和写锁是独占的并不冲突,这里是指一个线程可以活得写锁再获得读锁,独占是指多个线程之间),然后释放写锁称为锁降级,反之称为锁升级。允许写锁降低为读锁,反之不允许。
那读写锁又是如何实现锁分离来保证共享资源的原子性呢?
ReentrantReadWriteLock 也是基于 AQS 实现的,它的自定义同步器(继承 AQS)需要在同步状态 state 上维护多个读线程和一个写线程的状态,该状态的设计成为实现读写锁的关键。ReentrantReadWriteLock 很好地使用了高低位,来实现一个整型控制两种状态的功能,读写锁将变量切分成了两个部分,高 16 位表示读,低 16 位表示写。
线程获取读锁时:会先判断同步状态 state 是否为 0。如果 state 等于 0,说明暂时没有其它线程获取锁,此时判断是否需要阻塞,如果需要阻塞,则进入 CLH 队列进行阻塞等待;如果不需要阻塞,则 CAS 更新同步状态为读状态。
如果 state 不等于 0,会判断同步状态低 16 位,如果存在写锁,则获取读锁失败,进入 CLH 阻塞队列;反之,判断当前线程是否应该被阻塞,如果不应该阻塞则尝试 CAS 同步状态,获取成功更新同步锁为读状态。
线程获取写锁时:会先判断同步状态 state 是否为 0。如果 state 等于 0,说明暂时没有其它线程获取锁;如果 state 不等于 0,则说明有其它线程获取了锁。
此时再判断同步状态 state 的低 16 位(w)是否为 0,如果 w 为 0,则说明其它线程获取了读锁,此时进入 CLH 队列进行阻塞等待;如果 w 不为 0,则说明其它线程获取了写锁,此时要判断获取了写锁的是不是当前线程,若不是就进入 CLH 队列进行阻塞等待;若是,就应该判断当前线程获取写锁是否超过了最大次数,若超过,抛异常,反之更新同步状态。