同步器的意义
多线程编程中,可能会出现多个线程同时访问同一个共享、可变资源,这个资源我们称之为临界资源;这种资源可能是:对象、变量、文件等
- 共享:资源可以被多个线程同时访问。
- 可变:资源可以在生命周期内被修改。
问题:由于线程执行的过程是不可控的,所以需要采用同步机制来控制对资源的访问;
如何解决线程并发安全问题
所有并发模式在解决线程安全问题时,采用的方案都是序列化访问临界资源。即同一时刻,最多有一个线程访问临界资源,也称作同步互斥访问;
java中提供了两种方式实现同步互斥访问:synchronized 和 Lock
同步器的本质就是加锁;
Synchronized底层原理
Synchronized是基于JVM内置锁实现,通过内部对象Monitor(监视器锁)实现,基于进入和退出Monitor对象实现方法和代码块的同步,监视器锁的实现依赖底层操作系统的Mutex lock(互斥锁)实现,它是一个重量级性能较低的锁;JVM内置锁在1.5之后做了重大的优化,如锁粗化、锁消除、轻量级锁、偏向锁、适应性自旋等技术来减少锁操作的开销,内置锁的并发性已经与Lock持平。
synchronized关键词被编译成字节码后会被翻译成monitorenter和monitorexit两条指令分别在同步块逻辑代码的起始位置和结束位置。
java每个对象都内置了一个Monitor(监视器锁),加锁过程大概如下图:
Monitor监视器锁
任何一个对象都有一个Monitor与之关联,当一个Monitor被持有后,它将处于锁定状态。Synchronized在JVM里的实现都是基于进入和退出Monitor对象来实现方法同步和代码块同步,虽然具体实现细节不一样,但是都可以通过成对的MointorEnter和MonitorExit指令实现。
- MonitorEnter:每个对象都是一个监视器锁,当monitor被占用时就会处于锁定状态,线程执行monitorenter指令时尝试获取monitor的所有权,过程如下:
- 如果monitor的进入数为0,则该线程进入monitor,然后将进入数设置为1,该线程即为monitor的所有者;
- 如果线程已经占用该monitor,只是重新进入,则进入monitor的进入数加1;
- 如果其他线程已经占用了monitor,则该线程进入阻塞状态,直到monitor的进入数为0,则重新尝试获取monitor的所有权。
- MonitorExit:执行monitorexit的线程必须是objectref所对应的monitor的所有者。指令执行时,monitor的进入数减1,如果减1后进入数为0,那么线程退出monitor,不再是monitor的所有者。其他被这个monitor阻塞的线程可以尝试去获取这个monitor的所有权。
通过上述的分析,我们可以看出Synchronized的语义底层是通过一个monitor的对象来完成,其实wait/notify等方法也依赖于monitor对象,这就是为什么只有在同步代码块或者方法中才能调用wait/notify等方法,否则会抛出java.lang.IllegalMonitorStateException的异常错误;
Synchronized常用方式
- 对于同步方法,锁的是当前实例对象,即this,对该对类其他实例对象无影响。
- 对于静态方法,锁的是当前class对象,影响其他该类的实例化对象。
- 对于同步方法块,锁的是Synchronized包起来的代码块。
查看字节码有两种常见的方式:
- idea插件:
- jclasslib Bytecode Viewer
- 安装插件之后,在view菜单=>Show Bytecode With Jclasslib
- jdk自带工具:javap -verbose SynchronizedMethodHelper.class
同步代码块示例:
public class SynchronizedMethodHelper {
public void create(){
synchronized (this){
System.out.println("create order....");
}
}
}
字节码:
public void create();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=2, locals=4, args_size=1
0: iconst_0
1: istore_1
2: aload_0
3: dup
4: astore_2
5: monitorenter
6: aload_0
7: getfield #2 // Field a:I
10: aload_0
11: getfield #3 // Field b:I
14: iadd
15: istore_1
16: aload_2
17: monitorexit
18: goto 26
21: astore_3
22: aload_2
23: monitorexit
24: aload_3
25: athrow
26: iload_1
27: aload_0
28: getfield #2 // Field a:I
31: if_icmple 38
34: iconst_1
35: goto 39
38: iconst_0
39: istore_2
40: return
Exception table:
from to target type
6 18 21 any
21 24 21 any
LineNumberTable:
line 7: 0
line 8: 2
line 9: 6
line 10: 16
line 11: 26
line 12: 40
LocalVariableTable:
Start Length Slot Name Signature
0 41 0 this Lcom/ddu/jvm/SynchronizedMethodHelper;
2 39 1 c I
40 1 2 result Z
StackMapTable: number_of_entries = 4
frame_type = 255 /* full_frame */
offset_delta = 21
locals = [ class com/ddu/jvm/SynchronizedMethodHelper, int, class java/lang/Object ]
stack = [ class java/lang/Throwable ]
frame_type = 250 /* chop */
offset_delta = 4
frame_type = 11 /* same */
frame_type = 64 /* same_locals_1_stack_item */
stack = [ int ]
注:monitorexit,指令出现了两次,第1次为同步正常退出释放锁;第2次为发生异常退出释放锁;
同步方法示例:
package com.ddu.jvm;
public class SynchronizedMethodHelper {
public synchronized void delete(){
System.out.println("删除订单");
}
}
字节码:
public synchronized void delete();
descriptor: ()V
flags: ACC_PUBLIC, ACC_SYNCHRONIZED
Code:
stack=2, locals=2, args_size=1
0: aload_0
1: getfield #2 // Field a:I
4: aload_0
5: getfield #3 // Field b:I
8: isub
9: istore_1
10: return
LineNumberTable:
line 15: 0
line 16: 10
LocalVariableTable:
Start Length Slot Name Signature
0 11 0 this Lcom/ddu/jvm/SynchronizedMethodHelper;
10 1 1 c I
}
从编译的字节码结果看,调用指令的同步并没有通过指令monitorenter和monitorexit来完成(理论上也可以通过这两条指令实现),不过相对于普通方法,其常量池中多了ACC_SYNCHRONIZED标识符。JVM就是根据该标识符来实现方法的同步。
当方法调用时,调用指令将会检查方法的ACC_SYNCHRONIZED方法标识是否被设置,如果设置了,执行线程将先获取Monitor,获取成功后才能执行方法体,方法执行完后再释放monitor。在方法执行期间,其他任何线程都无法再获得同一个monitor对象。
两种同步方式本质上没有区别,只是方法的同步是一种隐式锁的形式实现,无法通过字节码来实现,代码块的同步是通过显示锁的形式实现。两个指令的执行是JVM通过调用操作系统的互斥源于mutex来实现,被阻塞的线程会被挂起、等待重新调度,会导致用户态和内核态之间来回切换,对性能影响比较大;
什么是Monitor
一个同步工具或者一种同步机制,它通常被描述为一个对象。与一切皆对象一样,所有的Java对象是天生的mointor,每一个java对象都有成为monitor的潜质,因为在java设计中,每一个java对象在new出来的时候就带了一把看不见的锁,它叫做内部锁或者Monitor锁。也就是通常说的Synchronized的对象锁,MarkWord锁标识位为10,其中指针指向的是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 ;
}
ObjectMointor中有两个队列,
- _WaitSet和_EntryList,用来保存ObjectWaiter对象列表(每个等待锁的线程都会被封装成ObjectWaiter对象)
- _owner指向持有ObjectMonitor对象的线程;
当多个线程同时访问一段同步代码时大概过程如下:
- 首先线程会被封装成ObjectWaiter对象进入_EntryList集合,当线程获取到对象的monitor后,进入_Owner区域并把monitor中的owner变量设置为当前线程,同时monitor中的计数器count加1;
- 若线程调用wait方法,将释放当前持有的monitor,owner变量恢复null,count减1,同时线程进入WaitSet集合中等待被唤醒;
- 若当前线程执行完毕,也将释放monitor并且重置count=0,以便后续线程进入获取monitor;
monitor对象存在于每个java对象的对象头MarkWord中(存储的指针引用),Synchronized锁通过对象头中的引用获取锁,这也是Java中任意对象都可以做锁的原因,同时notify/notifyAll/wait等方法会使用到Monitor锁对象,所以必须要在同步代码块中使用。
监视器monitor有两种同步方式:互斥和协作。多线程环境下,线程之间如果需要共享数据,需要解决互斥访问数据的问题,监视器可以保证监视器上的数据在同一时刻只会有一个线程在访问。
对象的内存划分
HotSpot虚拟机中,对象在内存中存储的部分可以分为三块区域:对象头(Header)、实例数据(InstanceData)、对齐填充位(Padding)。
- 对象头:比如hash码,对象所属的年代、对象锁、锁状态标志,偏向锁ID(线程)、偏向时间、数组长度等。Java对象头一般占有2个机器码(在32位操作系统中,1个机器码等于4个字节,也就是32bit,在64位虚拟机中,1个机器码是8个字节,也就是64bit),但是当对象是数组类型时,则需要3个机器码因为jvm虚拟机可以通过java对象的元数据信息确定java对象大小,但是无法从数组的元数据来确认数组的大小,所以用一个机器码来记录数组长度.
- 实例数据:存放类的属性数据信息,包括父类的信息;
- 对齐填充位:由于虚拟机要求 对象起始地址必须是8字节的整数倍。填充数据不是必须存在的,仅仅是为了字节对齐。
对象头
HotSpot虚拟机的对象头包括两部分信息:
- MarkWord,用于存储对象自身的运行时数据,如HashCode、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向线程ID、偏向时间戳,它是实现轻量级锁、偏向锁的基础。这部分数据的长度在32位和64位的虚拟机器(不考虑指针压缩)中分别是32个和64个bit,称之为MarkWord。MarkWord的32bit空间中,25个bit用于存储hashcode,4bit用于存储对象分代年龄,2bit用于存储锁标志位,1bit固定位0,在其他状态下对象的存储内容如下表:
对象头信息是与对象自身定义的数据无关的额外存储成本,但是考虑到虚拟机的存储空间效率,MarkWord被设计成一个非固定的数据结构以便在极小的空间存储尽量多的数据,它会根据对象的状态复用自己的存储空间,也就是说Markword会随着程序的运行发生变化。
32位虚拟机markword结构:
64位的虚拟机会进行指针压缩;此处暂时不予考虑;
对象头分析工具
<dependency>
<groupId>org.openjdk.jol</groupId>
<artifactId>jol-core</artifactId>
<version>0.10</version>
</dependency>
使用方式:
package com.ddu.jvm;
import org.openjdk.jol.info.ClassLayout;
public class ObjectHeadHelper {
private static Object object = new Object();
public static void main(String[] args) {
System.out.println(ClassLayout.parseInstance(object).toPrintable());
}
}
运行结果:
java.lang.Object object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) 01 00 00 00 (00000001 00000000 00000000 00000000) (1)
4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0)
8 4 (object header) e5 01 00 f8 (11100101 00000001 00000000 11111000) (-134217243)
12 4 (loss due to the next object alignment)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total
锁的升级过程
锁的状态总共有四种,无锁状态、偏向锁、轻量级锁、重量级锁。随着锁的竞争,锁可以从偏向锁升级到轻量级锁,再升级到重量级锁,但是锁的升级是单向的,只能从低到高升级,不会出现锁的降级。从JDK1.6开始默认开启偏向锁和轻量级锁,可以通过-XX:-UseBiasedLockingl来禁用偏向锁,下图为锁的升级过程:
锁分类
偏向锁
偏向锁是Java6之后加入的新锁,是一种针对加锁操作的优化手段,经过研究发现,在大多数情况下,锁不仅不存在多线程竞争,而且总是由同一个线程多次获得,因此为了减少同一个线程获取锁(会涉及到一些CAS操作)的代价从而引入了偏向锁。偏向锁的核心思想是,如果一个线程获得了锁,那么锁就进入偏向锁模式,此时MarkWord的接口就变成了偏向锁结构,当这个线程再次请求锁时,无需再锁任何同步操作,即获得锁,这样就省去了大量有关锁申请的操作,进而提升了程序的性能。因此对于没有锁竞争的场景,偏向锁由很好的优化效果,毕竟大部分的场景下可能连续多次是同一个线程申请同一把锁。但是对于锁竞争比较激烈的场景,偏向锁就失效了;
- 开启偏向锁:-XX:+UseBiasedLocking -XX:BiasedLockingStartupDelay=0
- 关闭偏向锁:-XX:-UseBiasedLocking
轻量级锁
偏向锁加锁失败后,锁会升级为轻量级锁,此时MarkWord的结构变成轻量级锁的结构。轻量级锁锁提升系统性能的依据:对绝大部分的锁,在整个同步周期内不存在竞争。适用场景:线程交替执行同步代码块,如果同一个时间访问同一把锁,就可能会导致升级为重量级锁;
重量级锁
锁优化手段
偏向锁、轻量级锁、自旋锁、锁消除、锁粗化、逃逸分析
其中偏向锁、轻量级锁已经介绍过,现在再介绍一下剩余的优化手段;
自旋锁(自适应锁)
轻量级锁失败后,虚拟机为了避免线程真实的在操作系统层面挂起,会进行自旋优化。
适应场景:共享资源被锁定状态持续的时间很短(此场景下如果频繁的挂起和恢复线程成本较高)
优点:减少线程上下文切换的消耗;
缺点:如果被锁资源占用的时间很长,自旋线程会白白浪费cpu资源。
改进:自旋的时间不固定,根据前一次在同一把锁的自旋时间和锁拥有者的状态共同决定。
锁消除
锁消除是指JVM在编译过程中,利用逃逸分析技术判断锁对象是否可能逃逸到方法外部,如果锁对象没有逃逸,那么JVM就可以推断该锁对象不会被其他线程访问到,从而可以安全地消除对该锁的获取和释放操作,从而减少锁操作的开销。