线程安全,是Java并发编程中的重要关注点,应该注意到的是,造成线程安全问题的主要原因有两点:
1,存在共享数据(也称临界资源)
2,存在多条线程,共同操作共享数据。
本文由浅入深,逐步整理了synchronized的相关知识,主要包括:
- 应用场景
- 原理概要
- 原理详解
- 低层实现
- 锁的优化(JDK1.6引入)
- 锁的升级(在什么情况下会升级,以及锁只能单向升级)
应用方式
synchronized 是解决Java并发最常见的一种方法,也是最简单的一种方法。关键字 synchronized 可以保证在同一时刻,只有一个线程可以访问某个方法或者某个代码块。同时 synchronized 也可以保证一个线程的变化,被另一个线程看到(保证了可见性)
这里要注意:synchronized是一个互斥的 重量级锁 (细节部分后续会讲)
synchronized的作用主要有三个:
- 确保线程互斥的访问代码
- 保证共享变量的修改能够及时可见(可见性)
- 可以阻止JVM的指令重排序
在Java中所有对象都可以作为锁,这是synchronized实现同步的基础。
synchronized主要有三种应用方式:
- 普通同步方法,锁的是当前实例的对象
- 静态同步方法,锁的是静态方法所在的类对象
- 同步代码块,锁的是括号里的对象。(此处的可以是实例对象,也可以是类的class对象。)
原理概要
Java虚拟机中的同步(Synchronization)都是基于进入和退出Monitor对象实现,无论是显示同步(同步代码块)还是隐式同步(同步方法)都是如此。
-
同步代码块
monitorenter
指令插入到同步代码块的开始位置。monitorexit
指令插入到同步代码块结束的位置。JVM需要保证每一个monitorenter
都有一个monitorexit
与之对应。
任何对象,都有一个monitor与之相关联,当monitor被持有以后,它将处于锁定状态。线程执行到monitorenter指令时,会尝试获得monitor对象的所有权,即尝试获取锁。
虚拟机规范对 monitorenter 和 monitorexit 的行为描述中,有两点需要注意。首先 synchronized 同步快对于同一条线程来说是可重入的,也就是说,不会出现把自己锁死的问题。其次,同步快在已进入的线程执行完之前,会阻塞后面其他线程的进入。(摘自《深入理解JAVA虚拟机》)
-
同步方法
synchronized方法则会被翻译成普通的方法调用和返回指令如:invokevirtual、areturn指令,在VM字节码层面并没有任何特别的指令来实现被synchronized修饰的方法,而是在Class文件的方法表中将该方法的access_flags字段中的synchronized标志位置1,表示该方法是同步方法并使用调用该方法的对象或该方法所属的Class在JVM的内部对象表示Klass做为锁对象。
原理详解
要理解低层实现,就需要理解两个重要的概念 Monitor 和 Mark Word
- Java对象头
synchronized用到的锁,是存储在对象头中的。(这也是Java所有对象都可以上锁的根本原因)
HotSpot虚拟机中,对象头包括两部分信息:
Mark Word(对象头)和 Klass Pointer(类型指针)
- 其中类型指针,是对象指向它的类元素的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。
- 对象头又分为两部分:第一部分存储对象自身的运行时数据,例如哈希码,GC分代年龄,线程持有的锁,偏向时间戳等。这一部分的长度是不固定的。第二部分是末尾两位,存储锁标志位,表示当前锁的级别。
对象头的长度一般占用两个机器码(32位JVM中,一个机器码等于4个字节,也就是32bit),但如果对象是数组类型,则需要三个机器码(多出的一块记录数组长度)。
下图是对象头运行时的变化状态:
锁标志位 和 是否偏向锁 确定唯一的锁状态
其中 轻量锁 和 偏向锁 是JDK1.6之后新加的,用于对synchronized优化。稍后讲到
- Monitor
Monitor是 synchronized 重量级 锁的实现关键。锁的标识位为 10 。当然 synchronized作为一个重量锁是非常消耗性能的,所以在JDK1.6以后做了部分优化,接下来的部分是讲作为重量锁的实现。
Monitor是线程私有的数据结构,每一个对象都有一个monitor与之关联。每一个线程都有一个可用monitor record列表(当前线程对象monitor),同时还有一个全局可用列表(全局对象monitor)。每一个被锁住的对象,都会和一个monitor关联。
当一个monitor被某个线程持有后,它便处于锁定状态。此时,对象头中 MarkWord的 指向互斥量的指针,就是指向锁对象的monitor起始地址。
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 ;
}
object monitor 有两个队列 _EntryList
和 _WaitSet
,用来保存ObjectWaiter对象列表(每个等待锁的线程都会被封装成ObjectWaiter对象)_owner
指向持有 objectMonitor的线程。
当多个线程同时访问一个同步代码时,首先会进入 _EntryList
集合,当线程获取到对象的monitor后,会进入_owner 区域,然后把monitor中的 _owner
变量修改为当前线程,同时monitor中的计数器_count
会加1。
根据虚拟机规范的要求,在执行monitorenter指令时,会尝试获取对象的锁。如果对象没有被锁定(获取锁),获取对象已经被该线程锁定(锁重入)。则把计数器加1(
_count
加1)。相应的,在执行monitorexit指令时,会讲计数器减1。当计数器为0时,_owner指向Null,锁就被释放。(摘自《深入理解JAVA虚拟机》)
如果线程调用 wait()
方法,将释放当前持有的monitor,_owner
变量恢复为null,_count
变量减1,同时该线程进入_WaitSet
等待被唤醒。
由此看来 monitor对象存在于每个Java对象的对象头中,synchronized锁便是通过这种方式获取锁的。
这就解释了为什么Java所有对象都可以作为锁,同时也解释了 wait() notify() notifyAll() 为什么存在于顶级对象Object中。
底层实现
- synchronized 代码块低层原理
从Javac编译成的字节码可以看出(具体编译文件看参考链接),同步代码块使用的是monitorenter
和monitorexit
指令,其中monitorenter
指向同步代码块的开始位置,monitorexit
指向同步代码块的结束位置。
在线程执行到monitorenter
指令时,当前线程将尝试获取锁,即尝试获取锁对象对应的monitor的持有权。当monitor的count计数器为0,或者monitor的owner已经是该线程,则获取锁,count计数器+1。
如果其他线程已经持有该对象的锁,则该线程被阻塞,直到其他线程执行完毕释放锁。
线程执行完毕时,count归零,owner指向Null,锁释放。
值得注意的是,编译器将会确保,无论通过何种方法完成,方法中的每一条monitorenter
指令,最终都会有monitorexit
指令对应,不论这个方法正常结束还是异常结束,最终都会配对执行。
编译器会自动产生一个异常处理器,这个处理器声明可以处理所有的异常,它的目的就是为了确保monitorexit
指令最终执行。
- synchronized 方法低层原理
方法级的同步是隐式,即无需通过字节码来控制的,它实现在方法调用和返回操作中。
在Class文件方法常量池中的方法表结构(method_info Structure)中, ACC_SYNCHRONIZED 访问标志区分一个方法是否为同步方法。在方法被调用时,会检查方法的 ACC_SYNCHRONIZED 标记是否被设置。如果被设置了,则线程将持有该方法对应对象的monitor(调用方法的实例对象or静态方法的类对象),然后再执行该方法。
最后在方法执行完成时,释放monitor。
在方法执行期间,执行线程持有了monitor,其他任何线程都无法再获得同一个monitor。
以下是字节码实现:
public class SyncMethod {
public int i;
public synchronized void syncTask(){
i++;
}
}
使用javap反编译后的字节码如下:
Classfile /Users/zejian/Downloads/Java8_Action/src/main/java/com/zejian/concurrencys/SyncMethod.class
Last modified 2017-6-2; size 308 bytes
MD5 checksum f34075a8c059ea65e4cc2fa610e0cd94
Compiled from "SyncMethod.java"
public class com.zejian.concurrencys.SyncMethod
minor version: 0
major version: 52
flags: ACC_PUBLIC, ACC_SUPER
Constant pool;
//省略没必要的字节码
//==================syncTask方法======================
public synchronized void syncTask();
descriptor: ()V
//方法标识ACC_PUBLIC代表public修饰,ACC_SYNCHRONIZED指明该方法为同步方法
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 12: 0
line 13: 10
}
SourceFile: "SyncMethod.java"
从字节码可以看出,synchronized修饰的方法并没有monitorenter
和monitorexit
指令。而是用ACC_SYNCHRONIZED
的flag标记该方法是否是同步方法,从而执行相应的同步调用。
锁的状态和优化
在早期的Java版本中,synchronized属于重量级锁,效率低下,因为监视器锁(Monitor)是依赖于低层的操作系统的Mutex Lock来实现的。
而操作系统实现线程中的切换时,需要用用户态切换到核心态,这是一个非常重的操作,时间成本较高。这也是早期 synchronized 效率低下的原因。
JDK1.6之后JVM官方对锁做了较大优化:
引入了:
- 锁粗化(Lock Coarsening)
- 锁消除(Lock Elimination)
- 适应性自旋(Adaptive Spinning)
同时增加了两种锁的状态:
- 偏向锁(Biased Locking)
- 轻量锁(Lightweight Locking)
先说锁的状态:
锁的状态共有四种:无锁,偏向锁,轻量锁,重量锁。随着锁的竞争,锁会从偏向锁升级为轻量锁,然后升级为重量锁。锁的升级是单向的,JDK1.6中默认开启偏向锁和轻量锁。
- 偏向锁
引入偏向锁的目的是:为了在无多线程竞争的情况下,尽量减少不必要的轻量锁执行路径。
因为经过研究发现,在大部分情况下,锁并不存在多线程竞争,而且总是由一个线程多次获得锁。因此为了减少同一线程获取锁(会涉及到一些耗时的CAS操作)的代价而引入。
如果一个线程获取到了锁,那么该锁就进入偏向锁模式,当这个线程再次请求锁时无需做任何同步操作,直接获取到锁。这样就省去了大量有关锁申请的操作,提升了程序性能。
获取偏向锁:
- 检查Mark Word 是否为可偏向状态,即是否为偏向锁=1,锁标志位=01.
- 若为可偏向状态,则检查 线程ID 是否为当前对象头中的线程ID,如果是,则获取锁,执行同步代码块。如果不是,进入第3步。
- 如果线程ID不是当前线程ID,则通过CAS操作竞争锁,如果竞争成功。则将Mark Word中的线程ID替换为当前线程ID,获取锁,执行同步代码块。如果没成功,进入第4步。
- 通过CAS竞争失败,则说明当前存在锁竞争。当执行到达全局安全点时,获得偏向锁的进程会被挂起,偏向锁膨胀为轻量级锁(重要),被阻塞在安全点的线程继续往下执行同步代码块。
释放偏向锁:
偏向锁的释放,采取了一种只有竞争才会释放锁的机制,线程不会主动去释放锁,需要等待其他线程来竞争。偏向锁的撤销需要等到全局安全点(这个时间点没有正在执行的代码),步骤如下:
- 暂停拥有偏向锁的线程,判断对象是否还处于被锁定的状态。
- 撤销偏向锁。恢复到无锁状态(01)或者 膨胀为轻量级锁。
偏向锁的获取和释放流程
- 轻量级锁
轻量锁能够提升性能的依据,是基于如下假设:即在真实情况下,程序中的大部分代码一般都处于一种无锁竞争的状态(即单线程环境),而在无锁竞争下完全可以避免调用操作系统层面的操作来实现重量锁。如果打破这个依据,除了互斥的开销外,还有额外的CAS操作,因此在有线程竞争的情况下,轻量锁比重量锁更慢。
为了减少传统重量锁造成的性能不必要的消耗,才引入了轻量锁。
当关闭偏向锁功能 或者 多个线程竞争偏向锁导致升级为轻量锁,则会尝试获取轻量锁。
获取轻量锁:
- 判断当前对象是否处于无锁状态(偏向锁标记=0,无锁状态=01),如果是,则JVM会首先将当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储当前对象的Mark Word拷贝。(官方称为Displaced Mark Word)。接下来执行第2步。如果对象处于有锁状态,则执行第3步
- JVM利用CAS操作,尝试将对象的Mark Word更新为指向Lock Record的指针。如果成功,则表示竞争到锁。将锁标志位变为00(表示此对象处于轻量级锁的状态),执行同步代码块。如果CAS操作失败,则执行第3步。
- 判断当前对象的Mark Word 是否指向当前线程的栈帧,如果是,则表示当前线程已经持有当前对象的锁,直接执行同步代码块。否则,说明该锁对象已经被其他对象抢占,此后为了不让线程阻塞,还会进入一个自旋锁的状态,如在一定的自旋周期内尝试重新获取锁,如果自旋失败,则轻量锁需要膨胀为重量锁(重点),锁标志位变为10,后面等待的线程将会进入阻塞状态。
释放轻量锁:
轻量级锁的释放操作,也是通过CAS操作来执行的,步骤如下:
- 取出在获取轻量级锁时,存储在栈帧中的 Displaced Mard Word 数据。
- 用CAS操作,将取出的数据替换到对象的Mark Word中,如果成功,则说明释放锁成功,如果失败,则执行第3步。
-
如果CAS操作失败,说明有其他线程在尝试获取该锁,则要在释放锁的同时唤醒被挂起的线程。
轻量锁的获取和释放
- 重量级锁
重量级锁通过对象内部的监视器(Monitor)来实现,而其中monitor本质上是依赖于低层操作系统的 Mutex Lock实现。
操作系统实现线程切换,需要从用户态切换到内核态,切换成本非常高。
- 适应性自旋
在轻量级锁获取失败时,为了避免线程真实的在系统层面被挂起,还会进行一项称为自旋锁的优化手段。
这是基于以下假设:
大多数情况下,线程持有锁的时间不会太长,将线程挂起在系统层面耗费的成本较高。
而“适应性”则表示,该自学的周期更加聪明。自旋的周期是不固定的,它是由上一次在同一个锁上的自旋时间 以及 锁拥有者的状态 共同决定。
具体方式是:如果自旋成功了,那么下次的自旋最大次数会更多,因为JVM认为既然上次成功了,那么这一次也有很大概率会成功,那么允许等待的最大自旋时间也相应增加。反之,如果对于某一个锁,很少有自旋成功的,那么就会相应的减少下次自旋时间,或者干脆放弃自旋,直接升级为重量锁,以免浪费系统资源。
有了适应性自旋,随着程序的运行信息不断完善,JVM会对锁的状态预测更加精准,虚拟机会变得越来越聪明。
再谈谈锁的优化:
- 锁粗化
我们知道,在使用锁的时候,需要让同步的作用范围尽可能的小——仅在共享数据的操作中才进行。这样做的目的,是为了让同步操作的数量尽可能小,如果村子锁竞争,那么也能尽快的拿到锁。
在大多数的情况下,上面的原则是正确的。
但是如果存在一系列连续的 lock unlock 操作,也会导致性能的不必要消耗.
粗化锁就是将连续的同步操作连在一起,粗化为一个范围更大的锁。
例如,对Vector的循环add操作,每次add都需要加锁,那么JVM会检测到这一系列操作,然后将锁移到循环外。
- 锁消除
锁消除是JVM进行的另外一项锁优化,该优化更彻底。
JVM在进行JIT编译时,通过对上下文的扫描,JVM检测到不可能存在共享数据的竞争,如果这些资源有锁,那么会消除这些资源的锁。这样可以节省毫无意义的锁请求时间。
虽然大部分程序员可以判断哪些操作是单线程的不必要加锁,但我们在使用Java的内置 API时,部分操作会隐性的包含锁操作。例如StringBuffer的操作,HashTable的操作。
锁消除的依据,是逃逸分析的数据支持。
(如果有什么错误或者建议,欢迎留言指出)
(本文内容是对各个知识点的转载整理,用于个人技术沉淀,以及大家学习交流用)
参考资料:
**【死磕Java并发】深入分析synchronized实现原理
** 深入理解Java并发之synchronized原理
JVM内部细节之synchronized实现细节
Java对象头解析-不得不了解的对象头
网友评论