早期的synchronized锁
在Java 1.5之前,多线程并发中,synchronized一直都是一个元老级关键字,而且给人的一贯印象就是一个比较重的锁。为此,在Java 1.6之后,这个关键字被做了很多的优化,从而让以往的“重量级锁”变得不再那么重。
synchronized主要有两种使用方法,一种是修饰代码块,一种是修饰方法。这两种用法底层究竟是怎么实现的呢?在1.6之前是怎么实现的呢?在java语言中存在两种内建的synchronized语法:
- synchronized语句;
- synchronized方法;
使用synchronized关键字修改方法或代码块时,究竟发生了什么呢?写一个简单的类,分别有锁方法和锁代码块,我们反编译一下字节码文件,一探究竟。
public class Synchronized {
private String str = "abc";
public synchronized void test(){
synchronized(str){
}
}
}
编译完成得到Synchronized.class文件后,我们去对应目录执行 javap -p -v -c Synchronized.class命令查看反编译的文件:
Classfile /Users/zhongminfu/Desktop/Synchronized.class
Last modified 2021-3-4; size 423 bytes
MD5 checksum 1e59ec487be7bc05c9d99302cb16139b
Compiled from "Synchronized.java"
public class Synchronized
minor version: 0
major version: 52
flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
#1 = Methodref #5.#19 // java/lang/Object."<init>":()V
#2 = String #20 // abc
#3 = Fieldref #4.#21 // Synchronized.str:Ljava/lang/String;
#4 = Class #22 // Synchronized
#5 = Class #23 // java/lang/Object
#6 = Utf8 str
#7 = Utf8 Ljava/lang/String;
#8 = Utf8 <init>
#9 = Utf8 ()V
#10 = Utf8 Code
#11 = Utf8 LineNumberTable
#12 = Utf8 test
#13 = Utf8 StackMapTable
#14 = Class #22 // Synchronized
#15 = Class #23 // java/lang/Object
#16 = Class #24 // java/lang/Throwable
#17 = Utf8 SourceFile
#18 = Utf8 Synchronized.java
#19 = NameAndType #8:#9 // "<init>":()V
#20 = Utf8 abc
#21 = NameAndType #6:#7 // str:Ljava/lang/String;
#22 = Utf8 Synchronized
#23 = Utf8 java/lang/Object
#24 = Utf8 java/lang/Throwable
{
private java.lang.String str;
descriptor: Ljava/lang/String;
flags: ACC_PRIVATE
public Synchronized();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=2, locals=1, args_size=1
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: aload_0
5: ldc #2 // String abc
7: putfield #3 // Field str:Ljava/lang/String;
10: return
LineNumberTable:
line 1: 0
line 2: 4
public synchronized void test();
descriptor: ()V
flags: ACC_PUBLIC, ACC_SYNCHRONIZED
Code:
stack=2, locals=3, args_size=1
0: aload_0
1: getfield #3 // Field str:Ljava/lang/String;
4: dup
5: astore_1
6: monitorenter #获取锁
7: aload_1
8: monitorexit #释放锁
9: goto 17
12: astore_2
13: aload_1
14: monitorexit #释放锁
15: aload_2
16: athrow
17: return
Exception table:
from to target type
7 9 12 any
12 15 12 any
LineNumberTable:
line 4: 0
line 6: 7
line 7: 17
StackMapTable: number_of_entries = 2
frame_type = 255 /* full_frame */
offset_delta = 12
locals = [ class Synchronized, class java/lang/Object ]
stack = [ class java/lang/Throwable ]
frame_type = 250 /* chop */
offset_delta = 4
}
SourceFile: "Synchronized.java"
对于synchronized语句当Java源代码被javac编译成字节码的时候,会在同步块的入口位置和退出位置分别插入monitorenter和monitorexit的字节码指令。
可以看到几处我标记的,对于synchronized语句当Java源代码被javac编译成字节码的时候,会在同步块的入口位置和退出位置分别插入monitorenter和monitorexit的字节码指令。
- 当我们进入一个同步方法的时候,执行monitorenter,就会获取当前对象的一个所有权,这个时候monitor进入数为1,当前的这个线程就是这个monitor的owner
- 如果你已经是这个monitor的owner了,你再次进入,就会把进入数+1
- 同理,当他执行完monitorexit,对应的进入数就-1,直到为0,才可以被其他线程持有
所有的互斥,其实在这里,就是看你能否获得monitor的所有权,一旦你成为owner就是获得者。
synchronized代码块是由一对儿monitorenter/monitorexit指令实现的,Monitor对象是同步的基本实现单元。在synchronized锁中,存储在Java对象头的Mark Word中的锁信息是一个指针,它指向一个Monitor对象(也称为管程或监视器锁)的起始地址。每一个Java对象实例都有一个Monitor对象实例与对应,它们一同创建并销毁。通过对象头,将每一个对象与一个Monitor关联了起来,它们的关系如下图所示:
图中最左边是线程的调用栈,它引用了堆中的一个对象,该对象的对象头部分记录了该对象所使用的监视器锁,该监视器锁指向了一个Monitor对象。
那么这个Monitor对象是什么呢? 在Java虚拟机(HotSpot)中,Monitor对象的源码是C++写的,在虚拟机的ObjectMonitor.hpp文件中。其数据结构定义如下:
ObjectMonitor() {
_header = NULL;
_count = 0;
_waiters = 0,
_recursions = 0; // 线程重入次数
_object = NULL; // 存储Monitor对象
_owner = NULL; // 持有当前线程的owner
_WaitSet = NULL; // wait状态的线程列表
_WaitSetLock = 0 ;
_Responsible = NULL ;
_succ = NULL ;
_cxq = NULL ; // 单向列表
FreeNext = NULL ;
_EntryList = NULL ; // 处于等待锁状态block状态的线程列表
_SpinFreq = 0 ;
_SpinClock = 0 ;
OwnerIsThread = 0 ;
_previous_owner_tid = 0;
}
后面要说的锁升级过程,其实就是在源码里面,调用了不同的实现去获取锁,失败了就调用更高级的实现,最后升级完成。
上面这些字段中,我们只需要重点关注三个字段:
- _owner:当前拥有该ObjectMonitor 的线程
- _EntryList:当前等待锁的集合
- _WaitSet:调用了Object.wait()方法而进入等待状态的线程的集合
在Java中,每一个等待锁的线程都会被封装成ObjectWaiter对象(ObjectWaiter类由JVM定义,ObjectWaiter对象里存放thread),当多个线程同时访问一段同步代码时,首先会被扔进 _EntryList 集合中,如果其中的某个线程获得了monitor对象,它将成为_owner,如果在它成为_owner之后又调用了wait()方法,则他将释放获得的Monitor对象,进入_WaitSet集合中等待被唤醒。
另外,因为每一个对象都可以作为synchronized的锁,所以每一个对象都必须支持wait()、notify()、notifyAll()方法,使得线程能够在一个Monitor对象上wait,直到它被notify。这也就解释了这三个方法为什么定义在了Object类中——这样,所有的类都将持有这三个方法。Object类的wait()和notify()都被标记为native方法,其具体实现都在JVM的synchronizer.cpp里。
所以说每一个Java对象都可以作为锁,其实是指将每一个Java对象所关联的ObjectMonitor作为锁,更进一步是指,大家都想成为某一个Java对象所关联的ObjectMonitor对象的_owner,所以你可以把这个_owner看做是铁王座,所有等待在这个监视器锁上的线程都想坐上这个铁王座,谁拥有了它,谁就有进入由它锁住的同步代码块的权利。
互斥锁(Mutex Lock)
在JDK1.6之前,在JVM中monitorenter和monitorexit字节码依赖于底层的操作系统的Mutex Lock来实现的,但是由于使用Mutex Lock需要将当前线程挂起(线程状态变为阻塞)并从用户态切换到内核态来执行,这种切换的代价是非常昂贵的。
Mutex Lock互斥锁主要用于实现内核中的互斥访问功能。Mutex Lock内核互斥锁是在原子API之上实现的,但这对于内核用户是不可见的。对它的访问必须遵循一些规则:同一时间只能有一个任务持有互斥锁,而且只有这个任务可以对互斥锁进行解锁。互斥锁不能进行递归锁定或解锁。一个互斥锁对象必须通过其API初始化,而不能使用memset或复制初始化。一个任务在持有互斥锁的时候是不能结束的。互斥锁所使用的内存区域是不能被释放的。使用中的互斥锁是不能被重新初始化的。并且互斥锁不能用于中断上下文。但是互斥锁比当前的内核信号量选项更快,并且更加紧凑,因此如果它们满足您的需求,那么它们将是明智的选择。
mutext lock的实现需要硬件支持,在硬件层面,CPU提供了原子操作、关中断、锁内存总线的机制;OS基于这几个CPU硬件机制,就能够实现锁;再基于锁,就能够实现各种各样的同步机制(信号量、消息、Barrier等)。所以要想理解OS的各种同步手段,首先需要理解cpu层面的锁,这是最原点的机制,所有的OS上层同步手段都基于此。
从用户态切换到内核态
Linux操作系统的体系架构分为用户态和内核态(或者用户空间和内核)。内核从本质上看是一种软件——控制计算机的硬件资源,并提供上层应用程序运行的环境。用户态即上层应用程序的活动空间,应用程序的执行必须依托于内核提供的资源,包括CPU资源、存储资源、I/O资源等。为了使上层应用能够访问到这些资源,内核必须为上层应用提供访问的接口:即系统调用。
内核态:CPU可以访问内存所有数据,包括外围设备,例如硬盘、 网卡。CPU也可以将自己从一个程序切换到另一个程序。
用户态:只能受限的访问内存,且不允许访问外围设备。占用CPU的能力被剥夺,CPU资源可以被其他程序获取。
因为操作系统的资源是有限的,如果访问资源的操作过多,必然会消耗过多的资源,而且如果不对这些操作加以区分,很可能造成资源访问的冲突。所以为了减少有限资源的访问和使用冲突,Unix/Linux的设计哲学之一就是:对不同的操作赋予不同的执行等级,就是所谓特权的概念。简单说就是有多大能力做多大的事,与系统相关的一些特别关键的操作必须由最高特权的程序来完成。Intel的X86架构的CPU提供了0到3四个特权级,数字越小,特权越高,Linux操作系统中主要采用了0和3两个特权级,分别对应的就是内核态和用户态。运行于用户态的进程可以执行的操作和访问的资源都会受到极大的限制,而运行在内核态的进程则可以执行任何操作并且在资源的使用上没有限制。很多程序开始时运行于用户态,但在执行的过程中,一些操作需要在内核权限下才能执行,这就涉及到一个从用户态切换到内核态的过程。比如C函数库中的内存分配函数malloc(),它具体是使用sbrk()系统调用来分配内存,当malloc调用sbrk()的时候就涉及一次从用户态到内核态的切换,类似的函数还有printf(),调用的是wirte()系统调用来输出字符串,等等。
所有用户程序都是运行在用户态的, 但是有时候程序确实需要做一些内核态的事情,例如从硬盘读取数据,或者从键盘获取输入等。而唯一可以做这些事情的就是操作系统, 所以此时程序就需要先请求操作系统以程序的名义来执行这些操作。
这时需要一个这样的机制:用户态程序切换到内核态,但是不能控制在内核态中执行的指令。这种机制叫系统调用, 在CPU中的实现称之为陷阱指令(Trap Instruction)。
用户态程序切换到内核态的流程如下:
- 用户态程序将一些数据值放在寄存器中或者使用参数创建一个堆栈(stack frame),以此表明需要操作系统提供的服务
- 用户态程序执行系统调用,即陷阱指令
- CPU切换到内核态,并跳到位于内存指定位置的指令,这些指令是操作系统的一部分,他们具有内存保护,不可被用户态程序访问
- 这些指令称之为陷阱(trap)或者系统调用处理器(system call handler), 他们会读取程序放入内存的数据参数, 并执行程序请求的服务
- 系统调用完成后, 操作系统会重置CPU为用户态并返回系统调用的结果
JDK1.6对synchronized锁的优化
在JDK1.6之前,在JVM中monitorenter和monitorexit字节码依赖于底层的操作系统的Mutex Lock来实现的,但是由于使用Mutex Lock需要将当前线程挂起(线程状态变为阻塞)并从用户态切换到内核态来执行,这种切换的代价是非常昂贵的。
然而在现实中的大部分情况下,同步方法是大多数是运行在单线程环境(无锁竞争),如果每次都调用Mutex Lock那么将严重的影响程序的性能。因此在JDK1.6中对锁的实现引入了大量的优化,如锁粗化(Lock Coarsening)、锁消除(Lock Elimination)、轻量级锁(Lightweight Locking)、偏向锁(Biased Locking)、适应性自旋(Adaptive Spinning)等技术来减少锁操作的开销。
轻量级锁(Lightweight Locking): 这种锁实现的背后基于这样一种假设,即在真实的情况下我们程序中的大部分同步代码一般都处于无锁竞争状态(即单线程执行环境),在无锁竞争的情况下完全可以避免调用操作系统层面的重量级互斥锁,取而代之的是在monitorenter和monitorexit中只需要依靠一条CAS原子指令就可以完成锁的获取及释放。当存在锁竞争的情况下,执行CAS指令失败的线程将调用操作系统互斥锁进入到阻塞状态,当锁被释放的时候被唤醒。
偏向锁(Biased Locking): 比轻量级锁更轻,是为了在无锁竞争的情况下避免在锁获取过程中执行不必要的CAS原子指令,因为CAS原子指令虽然相对于重量级锁来说开销比较小但还是存在非常可观的本地延迟。
适应性自旋(Adaptive Spinning): 当线程在获取轻量级锁的过程中执行CAS操作失败时,在进入与monitor相关联的操作系统重量级锁(mutex lock)前会进入忙等待(Spinning)然后再次尝试,当尝试一定的次数后如果仍然没有成功则调用与该monitor关联的互斥锁进入到阻塞状态。
自旋不会引起调用者睡眠,如果锁已经被别的单元保持,调用者就一直循环在那里看是否该锁的保持者已经释放了锁,“自旋”一词就是因此而得名。也就是说自旋锁就是一直在那里刷新,看看锁有没有被释放。而不是像传统的那种等待正在调用的线程释放锁后,然后通知这些等待的线程该“醒了”。
下面具体阐述JDK1.6是怎么实现偏向锁、轻级锁的以及锁是怎样升级为重量级互斥锁的。
不过,在具体阐述之前,要先了解一下Java对象结构。
1、Java对象的创建与内存布局
虚拟机遇到一条new指令时,首先将去检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已被加载、 解析和初始化过。 如果没有,那必须先执行相应的类加载过程。
在类加载检查通过后,接下来虚拟机将为新生对象分配内存。 对象所需内存的大小在类加载完成后便可完全确定,为对象分配空间的任务等同于把一块确定大小的内存从Java堆中划分出来。因此,在使用Serial、ParNew等带Compact(整理)过程的收集器时,系统采用的分配算法是指针碰撞,因为上述垃圾收集算法运行后,空闲区域是连续的,内存碎片少。而使用CMS这种基于Mark-Sweep算法的收集器时,通常采用空闲列表。
- 指针碰撞:假设Java堆中的内存是绝对规整的,所有用过的内存都放在一边,空闲的内存放在另一边,中间放着一个指针作为分界点的指示器,分配内存就是把这个指针向空闲的内存那边挪动一段与对象大小相等的距离
- 空闲列表:假设Java堆中的内存是不规整的,虚拟机就必须维护一个表,用来记录哪些内存块是可用的,在分配的时候从列表中找到一块足够大的空间分对象,并更新表上的记录
对象创建在虚拟机中是非常频繁的行为,即使是仅仅修改一个指针所指向的位置,在并发情况下也不是线程安全的,可能出现正在给对象A分配内存,指针还没来得及修改,对象B又同时使用了原来的指针来分配内存的情况。这个时候主要有两个解决方案:一种是对分配内存空间的动作进行同步处理——实际上虚拟机采用CAS配上失败重试的方式保证更新操作的原子性;另一种是把内存分配的动作按照线程划分在不同的空间之中进行,即每个线程在Java堆中预先分配一小块内存,称为本地线程分配缓冲(Thread Local Allocation Buffer,TLAB)。哪个线程要分配内存,就在哪个线程的TLAB上分配,只有TLAB用完并分配新的TLAB时,才需要同步锁定。虚拟机是否使用TLAB,可以通过-XX:+/-UseTlAB参数来设定。
Java对象由三部分构成:对象头、实例数据、对齐补充。
对象头
- Mark Word(标记字段):默认存储对象的HashCode,分代年龄和锁标志位信息。它会根据对象的状态复用自己的存储空间,也就是说在运行期间Mark Word里存储的数据会随着锁标志位的变化而变化。
- Klass Point(类型指针):对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。
第一部分是与对象在运行时状态相关的信息,长度通过与操作系统的位数保持一致。包括对象的哈希值、GC分代年龄、锁状态以及偏向线程的ID等。由于对象头信息是与对象所定义的信息无关的数据,所以使用了非固定的数据结构,以便存储更多的信息,实现空间复用。因此对象在不同的状态下对象头的存储信息有所差别。
另一部分是类型指针,即指向该对象所属类元数据的指针,虚拟机通常通过这个指针来确定该对象所属的类型(但并不是唯一方式)。
另外,如果对象是一个数组,在对象头中还应该有一块记录数组长度的数据,因为JVM可以通过对象的元数据确定对象的大小,但不能通过元数据确定数组的长度。
实例数据
实例数据存储的是真正的有效数据,即各个字段的值。无论是子类中定义的,还是从父类继承下来的都需要记录。这部分数据的存储顺序受到虚拟机的分配策略以及字段在类中的定义顺序的影响。
对齐填充
这部分数据不是必然存在的,因为对象的大小总是8字节的整数倍,该数据仅用于补齐实例数据部分不足整数倍的部分,充当占位符的作用。
Tip:不知道大家有没有被问过一个空对象占多少个字节?就是8个字节,是因为对齐填充的关系哈,不到8个字节对其填充会帮我们自动补齐。
2、Java对象头存储锁数据
每个Java对象都可以用做一个实现同步的锁,这些锁被称为内置锁(Intrinsic Lock)或者监视器锁(Monitor Lock)。要实现这个目标,则每个Java对象都应该与某种类型的锁数据关联。这就意味着,我们需要一个存储锁数据的地方,并且每一个对象都应该有这么个地方。在Java中,这个地方就是对象头。
其实Java的对象头和对象的关系很像Http请求的http header和http body的关系。对象头中存储了该对象的Metadata, 除了该对象的锁信息,还包括指向该对象对应的类的指针、对象的hashcode、 GC分代年龄等,在对象头这个寸土寸金的地方,根据锁状态的不同,有些内存是大家公用的,在不同的锁状态下,存储不同的信息。
synchronized是一种悲观锁,锁是存在对象头中的。如果对象是数组类型,则虚拟机用3个字宽(Word)存储对象头,如果对象是非数组类型,则用2字宽存储对象头。在32位虚拟机中,1字宽等于4字节,即32bit。
Java对象头里的Mark Word里默认存储对象的HashCode、分代年龄和锁标记位。在运行期间Mark Word里存储的数据会随着锁标志位的变化而变化。Mark Word可能变化为存储以下4种数据:
3、JDK1.6中的多种实现Monitor实现
根据前面的分析,我们知道在Java 6之前,synchronized锁的实现完全是依靠操作系统内部的互斥锁,这是重量级锁,因为需要进行用户态到内核态的切换,所以同步操作是一个无差别的重量级操作。
现代的(Oracle)JDK 中,JVM对此进行了大刀阔斧地改进,提供了三种不同的Monitor实现,也就是常说的三种不同的锁:偏斜锁(Biased Locking)、轻量级锁和重量级锁,大大改进了其性能。
所谓锁的升级、降级,就是JVM优化synchronized运行的机制,当JVM检测到不同的竞争状况时,会自动切换到合适的锁实现,这种切换就是锁的升级、降级。
由于synchronized是JVM内部的Intrinsic Lock,所以偏斜锁、轻量级锁、重量级锁的代码实现,并不在核心类库部分,而是在JVM的代码中。
偏向锁
HotSpot的作者经过研究发现,大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,为了让线程获得锁的代价更低而引入了偏向锁。当没有竞争出现时,默认会使用偏向锁。当一个线程访问同步块并获取锁时,JVM会利用CAS操作(compareAndSwap),在对象头上的Mark Word部分设置线程ID,以表示这个对象偏向于当前线程,以后该线程在进入和退出同步块时不需要进行CAS操作来加锁和解锁,所以并不涉及真正的互斥锁。只需要测试一下对象头的MarkWord里是否存储着当前线程ID,成功则表示线程已经获得了锁。
这样做的假设是基于在很多应用场景中,大部分对象生命周期中最多会被一个线程锁定,使用偏向锁可以降低无竞争开销。偏向锁是一种等到竞争出现才释放锁的机制,所以当其他线程尝试竞争偏向锁时,持有偏向锁的线程就会释放锁,即撤销偏向锁。JVM首先暂停拥有锁的线程,然后检查持有偏向锁的线程是否依然存活,若不再存活就将对象头设置成无锁状态;如果线程仍然存活,拥有偏向锁的栈会被执行,遍历偏向对象的锁记录,栈中的锁记录和对象头的Mark Word要么偏向其他线程,要么恢复到无锁,或者标记对象不适合作为偏向锁,最后唤醒暂停的线程。
偏向锁获取与撤销流程如下图所示:
轻量级锁
如果有另外的线程试图锁定某个已经被偏斜过的对象,JVM就需要撤销(revoke)偏斜锁,并切换到轻量级锁实现(已经出现多个线程竞争锁,偏向锁的假设不再成立)。轻量级锁依赖CAS操作Mark Word来试图获取锁,如果CAS操作成功,就使用普通的轻量级锁;否则,进一步升级为重量级锁。
轻量级锁加锁:线程在执行同步块之前,JVM会先在当前线程栈桢中创建用于存储锁记录的空间。并将对象头中的Mark Word复制到锁记录中(官方称Displaced Mark Word),然后线程尝试使用CAS将对象头中的Mark Word替换为指向锁记录的指针。如果成功,则当前线程获取锁,失败则代表其他线程竞争锁,当前线程便尝试使用自旋来获取锁。不断地自旋可以防止线程被挂起,一旦可以获取资源,就直接尝试成功,直到超出阈值,自旋锁的默认大小是10次,-XX:PreBlockSpin可以修改。自旋都失败了,那就升级为重量级的锁,像1.5那样线程被挂起等待唤醒。
轻量级锁解锁时,会使用原子的CAS操作将Displaced Mark Word替换回到对象头,如果成功则表示无竞争发生,如果失败代表锁存在竞争,锁进一步膨胀成重量级锁。
因为自旋会消耗CPU,为了避免无用的自旋(比如获得锁的线程被阻塞住了),一旦锁升级成重量级锁(依赖操作系统提供的互斥量),就不会再恢复成轻量级锁的状态。当锁处于这个状态下,其他线程试图获取锁时都会被阻塞住,当持有锁的线程释放锁之后会唤醒这些线程,被唤醒的线程就会进行新一轮的夺锁之争。
多个线程争夺锁导致锁导致轻量级锁膨胀的流程图如下:
三种类型锁的优缺点对比如下:
锁的升级方向如下图,切记这个升级过程是不可逆的,这个特性会深刻影响synchronized的使用场景。
4、用synchronized还是Lock
我们先看看他们的区别:
- synchronized是关键字,是JVM层面的底层啥都帮我们做了,而Lock是一个接口,是JDK层面的有丰富的API。
- synchronized会自动释放锁,而Lock必须手动释放锁。
- synchronized是不可中断的,Lock可以中断也可以不中断。
- 通过Lock可以知道线程有没有拿到锁,而synchronized不能。
- synchronized能锁住方法和代码块,而Lock只能锁住代码块。
- Lock可以使用读锁提高多线程读效率。
- synchronized是非公平锁,ReentrantLock可以控制是否是公平锁。
两者一个是JDK层面的一个是JVM层面的,我觉得最大的区别其实在,我们是否需要丰富的API,还有一个我们的场景。
比如网约车场景,每天早晚才是打车高峰期,如果代码中使用了大量的synchronized就不合适,为什么?因为锁的升级过程是不可逆的,过了高峰期依然是重量级锁,那效率就大打折扣了,这个时候用Lock更好。场景是一定要考虑的,因为脱离了业务,一切技术讨论都没有了价值。