synchronized是Java内置关键字,提供了一种独占加锁方式。它可以保证方法或者代码块在运行时,同一时刻只有一个方法可以进入到临界区,同时它还可以保证共享变量的内存可见性。但是,synchronized也存在一定的局限性。
1)当线程尝试获取锁的时候,如果获取不到会一直阻塞。
2)如果持有锁的线程进入阻塞或者休眠时,其他线程尝试获取锁时必须一直等待,除非当前持有锁的线程发生异常。
在Java中每个对象都可以作为锁,主要如下:
1)普通同步方法,锁是当前实例对象
2)静态同步方法,锁是当前类的class对象
3)同步方法快,锁是括号里面的对象
先写一段简单的代码:
public class Main {
public static void main(String[] args) {
synchronized (Main.class){
}
}
}
利用javap命令查看生成class文件来进行synchronized的实现。
javap -v Main.class
查看结果:
public class com.entity.Main
minor version: 0
major version: 52
flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
#1 = Methodref #3.#22 // java/lang/Object."<init>":()V
#2 = Class #23 // com/entity/Main
#3 = Class #24 // java/lang/Object
#4 = Utf8 <init>
#5 = Utf8 ()V
#6 = Utf8 Code
#7 = Utf8 LineNumberTable
#8 = Utf8 LocalVariableTable
#9 = Utf8 this
#10 = Utf8 Lcom/entity/Main;
#11 = Utf8 main
#12 = Utf8 ([Ljava/lang/String;)V
#13 = Utf8 args
#14 = Utf8 [Ljava/lang/String;
#15 = Utf8 StackMapTable
#16 = Class #14 // "[Ljava/lang/String;"
#17 = Class #24 // java/lang/Object
#18 = Class #25 // java/lang/Throwable
#19 = Utf8 MethodParameters
#20 = Utf8 SourceFile
#21 = Utf8 Main.java
#22 = NameAndType #4:#5 // "<init>":()V
#23 = Utf8 com/entity/Main
#24 = Utf8 java/lang/Object
#25 = Utf8 java/lang/Throwable
{
public com.jv.catalog.entity.Main();
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
LocalVariableTable:
Start Length Slot Name Signature
0 5 0 this Lcom/entity/Main;
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=3, args_size=1
0: ldc #2 // class com/entity/Main
2: dup
3: astore_1
4: monitorenter
5: aload_1
6: monitorexit
7: goto 15
10: astore_2
11: aload_1
12: monitorexit
13: aload_2
14: athrow
15: return
Exception table:
from to target type
5 7 10 any
10 13 10 any
LineNumberTable:
line 6: 0
line 8: 5
line 9: 15
LocalVariableTable:
Start Length Slot Name Signature
0 16 0 args [Ljava/lang/String;
StackMapTable: number_of_entries = 2
frame_type = 255 /* full_frame */
offset_delta = 10
locals = [ class "[Ljava/lang/String;", class java/lang/Object ]
stack = [ class java/lang/Throwable ]
frame_type = 250 /* chop */
offset_delta = 4
MethodParameters:
Name Flags
args
}
SourceFile: "Main.java"
通过查看的结果来可以看出,同步代码块是使用monitorenter和monitorexit指令实现的。
指令monitorenter插入到同步代码块的开始位置,指令monitorexit插入到同步代码块的结束位置。JVM需要保证每一个monitorenter都会有一个与之对应的monitorexit。任何对象都会有一个Monitor与之关联一起,并且当且一个Monitor被持有之后,它会处在锁定的状态。在线程执行到monitorenter指令的时候,将会去尝试获取对象对应的Monitor的所有权(也就是对象锁)。
观察上面会发现有两个monitorexit,为什么会有两个monitorexit呢,一个是正常执行办法后的monitorexit,一个是发生异常后的monitorexit。
实现synchronized的基础是Java对象头和Monitor,下面对这两个概念进行介绍。
补充:
在JVM中,对象在内存中的布局分成三块区域:对象头、示例数据和对齐填充。
对象头: 对象头主要存储对象的hashCode、锁信息、类型指针、数组长度(若是数组的话)等信息。
示例数据:存放类的属性数据信息,包括父类的属性信息,如果是数组的实例部分还包括数组长度,这部分内存按4字节对齐。
填充数据:由于JVM要求对象起始地址必须是8字节的整数倍,当不满足8字节时会自动填充。
1、Java对象头
Java对象头中可以存放synchronized用的锁。jvm的对象头主要由标记字段(Mark Word)和类型指针(Klass Pointer)两部分数据组成。
标记字段(Mark Word)是用于存储对象自身的运行时数据,如GC 分代年龄、锁状态标志、线程持有的锁、偏向线程 ID、偏向时间戳、hashcode等。
类型指针(Klass Pointer)是对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。
JVM中对象头的方式有以下两种(以32位JVM为例):
1)普通对象
|-----------------------------------------------------------------|
| Object Header (64 bits) |
|------------------------------------|----------------------------|
| Mark Word (32 bits) | Klass Pointer (32 bits) |
|------------------------------------|----------------------------|
2)数组对象
|---------------------------------------------------------------------------------|
| Object Header (96 bits) |
|--------------------------------|-----------------------|------------------------|
| Mark Word(32bits) | Klass Pointer(32bits) | array length(32bits) |
|--------------------------------|-----------------------|------------------------|
长度 | 内容 | 说明 |
---|---|---|
32/64bit | Mark Word | 存储对象的hashCode或者锁信息或GC 分代年龄等 |
32/64bit | Klass Pointer | 存储到对象类型数据的指针 |
32/64bit | ArrayLength | 数组的长度(当当前对象是数组) |
synchcronized的锁是存放在Java对象头中的
如果对象是数组类型,JVM用3个子宽(Word)存储对象头,否则是用2个子宽
在32位虚拟机中,1子宽等于4个字节,即32bit;64位的话就是8个字节,即64bit
2、Monitor
Monitor其实是一种同步工具,也可以说是一种同步机制,它通常被描述为一个对象。
特点:
1)互斥:同一时刻一个Monitor锁只能被一个线程占用,其他线程无法占用。
2)信号机制(singal):占用Monitor锁失败的线程会暂时放弃并且等待某个位于成真(条件变量),但该条件成立后,当前线程会通过释放锁通知正在等待这个条件变量的其他线程,让其可以重新竞争锁。
- 每个对象都有一个监视器,在同步代码块中,JVM通过monitorenter和monitorexist指令实现同步锁的获取和释放功能
- 当一个线程获取同步锁时,即是通过获取monitor监视器进而等价为获取到锁
- monitor的实现类似于操作系统中的管程
monitorenter指令:
每个对象都有一个monitor。当该monitor被占用时(锁定状态或者说获取监视器即是获得同步锁)。线程执行monitorenter指令时会尝试获取监视器的所有权,过程如下:
- 若该monitor的进入次数为0,则该线程进入monitor并将进入次数设置为1,此时该线程即为该monitor的所有者。
- 若线程已经占有该monitor并重入,则进入次数+1
- 若其他线程已经占有该monitor,则线程会被阻塞直到monitor的进入次数为0,之后线程间会竞争获取该monitor的所有权
- 只有首先获得锁的线程才能允许继续获取多个锁
monitorexit指令:
执行monitorexit指令将遵循以下步骤:
- 执行monitorexit指令的线程必须是对象实例所对应的monitor的所有者。
- 指令执行时,线程会先将进入次数-1,若-1之后进入次数变成0,则线程退出monitor(即释放锁)。
- 其他阻塞在该monitor的线程可以重新竞争该monitor的所有权。
补充:
Mesa派的signal机制
- Mesa派的signal机制又称"Non-Blocking condition variable"
- 占有Monitor锁的线程发出释放通知时,不会立即失去锁,而是让其他线程等待在队列中,重新竞争锁
- 这种机制里,等待者拿到锁后不能确定在这个时间差里是否有别的等待者进入过Monitor,因此不能保证谓词一定为真,所以对条件的判断必须使用while
- Java中采用就是Mesa派的singal机制,即所谓的notify(这也是执行notify随机唤醒一个线程的原因所在)
注:notify唤醒的是其所在锁所阻塞的线程
下面在介绍一下Monitor Record(MR),它是Java线程私有的数据结构。它的结构以及对应功能描述如下:
MR | 描述 |
---|---|
Owner | 1)初始值为null,若该值为null则表示没有任何线程拥有该Monitor。 2)若线程成功拥有锁(Monitor record)后保存线程唯一标识,当锁被释放时又设置为null。 |
EntryQ | 关联一个系统互斥锁(semaphore),阻塞所有竞争该锁(monitor record)失败的线程。 |
ReThis | 阻塞或等待在该锁(monitor record)的线程个数-----被阻塞/等待的线程被存入同步/等待队列中。 |
Nest | 记录冲入次数。 |
Hashcode | 保存从对象头拷贝过来的hashcode值(可能还包含GC age)。 |
Candidate | 1)用来避免不必要的阻塞或等待线程唤醒。 2)只有两种可能的值,0表示没有需要唤醒的线程,1表示要唤醒一个继任线程来竞争锁。 原因:因为每一次只有一个线程能够成功拥有锁,若每次前一个释放锁的线程唤醒所有正在阻塞或等待的线。程,会引起不必要的上下文切换(从阻塞到就绪然后因为竞争失败又被阻塞)从而导致性能严重下降。 |
- 每一个线程都有一个可用的MR列表,同时还有一个全局的可用列表。
- 一个被锁住的对象都会和一个MR关联(对象头的MarkWord中的LockWord指向MR的起始地址)。
- MR中有一个Owner字段存放拥有该锁的线程的唯一标识,表示该锁被这个线程占用。
MR的工作机制示意图如下:
- 获得监视锁成功的线程,将成为该监视锁对象的拥有者
- 在同一时刻,监视器对象只属于一个活动线程(Owner)
- 拥有者可以调用wait方法自动释放监视锁,进入等待状态
参考:
Java 8 并发篇 - 冷静分析 Synchronized(下)