Synchronized在并发编程经常使用到,那么它到底是什么?实现原理又是什么?今天就来深入理解一下吧!
首先,synchronized关键字主要有以下用法:
- 同步代码块 : synchronized(this) 、 synchronized(类对象实例) 锁的括号中的实例对象
- 同步非静态方法 :synchronized methodName 锁的是当前对象的实例对象
- 同步代码块 : synchronized(类.class) 锁的是括号中的类对象 (Class对象)
- 同步静态方法 : synchronized static methodName 锁的是当前对象的类对象 (Class对象)
由此可以看出根据使用方法的不同,synchronized锁的对象也不相同。
那么让我们来synchronized在字节码层面又是如何实现的呢
我们先看看synchronized对实例对象加锁时的实现
public class CsdnSynchronizedTest {
private int x = 10;
public int getSum(int x){
synchronized (this){
return x++;
}
}
}
利用命令行 javac -v CsdnSynchronizedTest
descriptor: (I)I
flags: ACC_PUBLIC //ACC_PUBLIC指明这是public方法
Code:
stack=2, locals=3, args_size=1
0: ldc #3 // class CsdnSynchronizedTest
2: dup
3: astore_1
4: monitorenter
5: iload_0
6: iconst_1
7: ishr
8: aload_1
9: monitorexit
10: ireturn
11: astore_2
12: aload_1
13: monitorexit
14: aload_2
15: athrow
我们可以看到synchronized关键字在同步代码块的前后分别形成了monitorenter和monitorexit这两个字节码指令:
......
4: monitorenter
......
9: monitorexit
......
13: monitorexit
那这些操作具体做了些什么呢?
这两个字节码指令都需要一个reference类型的参数来指明要锁定和解锁的对象。
如果Java源码中的synchronized明确指定了对象参数,那就以这个对象的应用作为reference;(指的是括号之中的对象)
如果没有明确指定,那将根据synchronized修饰的方法(如实例方法或类方法),来决定是取代码所在的对象实例还是取类型对应的Class对象来作为线程要持有的锁。(普通方法为取当前对象的对象实例,静态方法为取Class对象)
那我们再来看看修饰类方法时
public class CsdnSynchronizedTest {
private int x = 10;
public synchronized int getSum(int x){
return x++;
}
}
public synchronized int getSum(int);
descriptor: (I)I
flags: ACC_PUBLIC, ACC_SYNCHRONIZED
Code:
stack=1, locals=2, args_size=2
0: iload_1
1: iinc 1, 1
4: ireturn
LineNumberTable:
line 8: 0
对比一下我们可以知道,
在修饰方法时,没有monitorenter和monitorexit
而标志位flags多了ACC_SYNCHRONIZED 来指明这是同步方法
浏览了上面的字节码之后,各位读者应该发现了Java对synchronized并没有进行具体的实现,只是简单的进行了标记,那么就是说它的实现是交给了底层的C++来完成。
在解读C++的实现之前,我们先了解一下JVM是如何来记录锁的吧。
synchronized既然是锁,那就得把锁的信息保存下来让JVM知道是谁的锁吧。
而这个地方就是对象的对象头之中。
一个实例对象的组成是对象头、实例变量和填充数据。参考下图:
实例变量:存放类的属性数据信息,包括父类的属性信息
填充数据:填充数据的存在是由于虚拟机要求对象所占内存必须是8字节的整数倍,如果对象头+实例变量字节数不是8字节的整数倍,填充数据会将其补齐至8字节的整数倍。一般用0来填充。(填充数据不是必须存在的)
对象头:对象头是实现synchronized的基础,就是因为synchronized使用的锁对象信息就存储在对象头中。
普通对象的对象头:
数组对象的对象头:
在32位操作系统中,Mark Word是32bit,64位操作系统中,Mark Word是64bit。
Klass Point :用于存储指向方法区对象类型数据的指针,64位操作系统中也是64bit,但是JVM对类型指针有进行压缩,为32bit,在JDK6之后默认开启。(参数 -XX:-UseCompressedOops)
了解了对象头的组成之后来看看Mark Word中具体是怎么回事吧:
以下是jvm中markOop.hpp 的解释:
// 32 bits:
// --------
// hash:25 ------------>| age:4 biased_lock:1 lock:2 (normal object)
// JavaThread*:23 epoch:2 age:4 biased_lock:1 lock:2 (biased object)
// size:32 ------------------------------------------>| (CMS free block)
// PromotedObject*:29 ---------->| promo_bits:3 ----->| (CMS promoted object)
//
// 64 bits:
// --------
// unused:25 hash:31 -->| unused:1 age:4 biased_lock:1 lock:2 (normal object)
// JavaThread*:54 epoch:2 unused:1 age:4 biased_lock:1 lock:2 (biased object)
// PromotedObject*:61 --------------------->| promo_bits:3 ----->| (CMS promoted object)
// size:64 ----------------------------------------------------->| (CMS free block)
咋一看有点晕吧,那么我们就用一张图概述吧
unused:不使用
hash:记录对象的hash值
age:分代年龄,占4bit,这也就是为什么分代年龄最多15的原因(1111=15)
biased_lock:是否可偏向标识
lock:锁的状态
这时候肯定有人有疑问,为什么图上相同的101状态还分无锁可偏向(未偏向)和偏向锁(已偏向)呢?
让我们通过代码分析一下:
先导入需要的依赖
<dependency>
<groupId>org.openjdk.jol</groupId>
<artifactId>jol-core</artifactId>
<version>0.10</version>
</dependency>
首先我们先要了解在JDK1.6之后默认开启偏向锁,也就是说对象new出来的就是偏向锁状态,但是这个开始偏向锁有一个4s的延迟,在这个延迟之前new的对象是无锁状态。
public class CsdnSynchronizedTest {
public static void main(String[] args) throws InterruptedException {
CsdnSynchronizedTest test = new CsdnSynchronizedTest();
System.out.println(ClassLayout.parseInstance(test).toPrintable());
}
}
将Mark Word 打印出来
JVM的对象头是用小端模式存储的,应从尾部向头部读取。
本文主要是将synchronized,对于小端模式和大端模式不做具体说明,有兴趣的读者可以自己查阅资料。
也就说本应在末尾的8bit现在在首位,00000001
上文中提到Mark Word末三位存储的是 是否偏向锁以及锁的状态
而此时锁的状态时001,也就是无锁
public class CsdnSynchronizedTest {
public static void main(String[] args) throws InterruptedException {
Thread.sleep(5000);
CsdnSynchronizedTest test = new CsdnSynchronizedTest();
System.out.println(ClassLayout.parseInstance(test).toPrintable());
}
}
开启偏向需要一点时间,所以我们让线程睡眠5s确保一定能开启偏向锁。
现在可以看到锁的状态是101,此时没有线程来获取锁,偏向的线程Id为null,所以对象是无锁可偏向状态。
我们也可以通过配置命令行参数来让关闭开启偏向锁的延迟时间
-XX:BiasedLockingStartupDelay=0
看到了偏向锁的状态之后笔者就来说明一下偏向锁的定义吧:
偏向锁的"偏"是偏心的意思,它的意思是这个锁会偏向于第一个获得它的线程,如果之后该锁没有被其他线程获取,那么持有偏向锁的对象将不需要进行同步。
了解之后让我们自己往下走吧。
对代码在进行修改,增加计算hashcode的值
public class CsdnSynchronizedTest {
public static void main(String[] args) throws InterruptedException {
Thread.sleep(5000);
CsdnSynchronizedTest test = new CsdnSynchronizedTest();
test.hashCode();
System.out.println(ClassLayout.parseInstance(test).toPrintable());
}
}
可以看到奇怪的一幕,此时锁的状态又变回了无锁态。
从上面Mark Word的布局可以看出偏向锁状态下,前52bit需要用来存储偏向线程的Id,但是由于我们计算了hashcode的值,以致于mark word存储了hashcode的值之后没有空间存储偏向线程Id,所以变成了无锁不可偏向状态。
对对象进行加锁测试:
public class CsdnSynchronizedTest {
public static void main(String[] args) throws InterruptedException {
Thread.sleep(5000);
CsdnSynchronizedTest test = new CsdnSynchronizedTest();
// test.hashCode();
synchronized (test){
System.out.println(ClassLayout.parseInstance(test).toPrintable());
}
}
}
可以看到此时对象已经是偏向锁(已偏向),线程Id为
00000000 00000000 00000000 00000000
00000010 10111010 010010
那么既然计算了hashcode之后,对象变成了不可偏向状态之后,将对象加锁又会发生什么呢?
public class CsdnSynchronizedTest {
public static void main(String[] args) throws InterruptedException {
Thread.sleep(5000);
CsdnSynchronizedTest test = new CsdnSynchronizedTest();
test.hashCode();
synchronized (test){
System.out.println(ClassLayout.parseInstance(test).toPrintable());
}
}
}
可以看到此时锁的状态变成了000,也就是此时从无锁状态直接升级成了轻量级锁。
那么除了计算hashcode之外还有办法让对象处于轻量级锁吗?
答案是有的:
public class CsdnSynchronizedTest {
static CsdnSynchronizedTest test;
public static void main(String[] args) throws InterruptedException {
Thread.sleep(5000);
test = new CsdnSynchronizedTest();
Thread thread = new Thread(){
@Override
public void run() {
lock();
}
};
lock();
thread.start();
}
public static void lock(){
synchronized (test){
System.out.println(Thread.currentThread().getName());
System.out.println(ClassLayout.parseInstance(test).toPrintable());
}
}
}
当线程之间锁的获取不存在竞争,而是交替进行的时候,对象将会从偏向锁升级为轻量级锁。
线程获取轻量级锁的过程是虚拟机使用CAS(Compare and Swap)操作尝试将对象的Mark Word中记录的指针指向当前线程。
用通俗易懂的话就是:
你去公共厕所,但是厕所的门锁都坏了,你想了办法,在门上贴了一张纸,纸上写着你的名字,这样别人就知道这门里面有人。但是你真的锁门了吗?
实际上是没有的,也就说轻量级锁也并没有实际加锁,只是进行了一个声明,表明这是有人的。
同理,当对象之间存在竞争的时候,将会升级为重量级锁:
public class CsdnSynchronizedTest {
static CsdnSynchronizedTest test;
public static void main(String[] args) throws InterruptedException {
Thread.sleep(5000);
test = new CsdnSynchronizedTest();
Thread thread = new Thread(){
@Override
public void run() {
lock();
}
};
thread.start();
lock();
}
public static void lock(){
synchronized (test){
System.out.println(Thread.currentThread().getName());
System.out.println(ClassLayout.parseInstance(test).toPrintable());
}
}
}
将线程的start()方法移动到lock()方法之前,让线程之间竞争获取锁
此时,对象变成了重量级锁。
当对象持有重量级锁的时候,这时候才是真正意义上的加锁。JVM将从用户态切换到内核态,向操作系统申请互斥量(mutex)。从用户态到内核态(也成为上下文切换)是相当消耗资源的,所以在synchronized优化之前,它的性能远不如ReentrantLock。因为ReentrantLock是基础JAVA实现的,加锁和释放锁的过程并没有进行上下文切换。
重量级锁的互斥量(mutex)有monitor对象进行监视,在Mark Word中的指针也就是指向这个monitor对象。
monitor对象是由C++实现的,现在我们 就来看看它是如何实现的吧:
ObjectMonitor类
// initialize the monitor, exception the semaphore, all other fields
// are simple integers or pointers
ObjectMonitor() {
_header = NULL;
_count = 0;
_waiters = 0,
_recursions = 0;
_object = NULL;
_owner = NULL;
_WaitSet = NULL;
_WaitSetLock = 0 ;
_Responsible = NULL ;
_succ = NULL ;
_cxq = NULL ;
FreeNext = NULL ;
_EntryList = NULL ;
_SpinFreq = 0 ;
_SpinClock = 0 ;
OwnerIsThread = 0 ;
_previous_owner_tid = 0;
}
ObjectMonitor中有两个队列_EntryList和_WaitSet,这两个队列用来存放ObjectWaiter对象,每一个请求锁的线程都会被封装成ObjectWaiter对象进入_EntryList队列中。
当_EntryList中的某一个对象申请到锁时,ObjectMonitor中的_owner将指向持有锁的线程。与此同时,_count+1。
而_WaitSet队列则是在线程调用wait()方法之后,当前线程释放持有的锁,_owner置空,_count-1。并进入_WaitSet队列中,变成阻塞状态,等待notify()或者notifyAll()的唤醒。
ObjectWaiter类
class ObjectWaiter : public StackObj {
public:
enum TStates { TS_UNDEF, TS_READY, TS_RUN, TS_WAIT, TS_ENTER, TS_CXQ } ;
enum Sorted { PREPEND, APPEND, SORTED } ;
ObjectWaiter * volatile _next;
ObjectWaiter * volatile _prev;
Thread* _thread;
jlong _notifier_tid;
ParkEvent * _event;
volatile int _notified ;
volatile TStates TState ;
Sorted _Sorted ; // List placement disposition
bool _active ; // Contention monitoring is enabled
public:
ObjectWaiter(Thread* thread);
void wait_reenter_begin(ObjectMonitor *mon);
void wait_reenter_end(ObjectMonitor *mon);
};
最后我们在来看看ObjectMonitor的加锁实现吧
int ObjectMonitor::TryLock (Thread * Self) {
for (;;) {
void * own = _owner ;
if (own != NULL) return 0 ;
if (Atomic::cmpxchg_ptr (Self, &_owner, NULL) == NULL) {
// Either guarantee _recursions == 0 or set _recursions = 0.
assert (_recursions == 0, "invariant") ;
assert (_owner == Self, "invariant") ;
// CONSIDER: set or assert that OwnerIsThread == 1
return 1 ;
}
// The lock had been free momentarily, but we lost the race to the lock.
// Interference -- the CAS failed.
// We can either return -1 or retry.
// Retry doesn't make as much sense because the lock was just acquired.
if (true) return -1 ;
}
}
Atomic::cmpxchg_ptr (Self, &_owner, NULL)
我们可以看到加锁的核心就在于cmpxchg_ptr,而cmpxchg_ptr则是进行了CAS(Compare and Swap)操作。也就是说synchronized在最底层其实也是使用了CAS的方法进行加锁。
最后,JDK6之后,虚拟机还进行了各种锁优化技术。
自旋锁和自适应自旋锁
当线程申请的锁是重量级锁时,等待的线程将会处于阻塞状态,挂起线程和恢复线程的操作都需要转入内核态完成,这些操作带来了不小的资源开销。而实际上加锁进行共享数据操作的时间可能很短暂,为此进行线程的挂起和唤醒是不值得的。在多核CPU的情景下,可以让其他线程“稍等片刻”,进行自旋操作(一个空操作的忙循环)这就是所谓的自旋锁。
但是长时间的自旋操作会白白消耗处理器资源,所以自旋锁一般自旋字数为10次,可以通过-XX:PreBlockSpin修改次数。
锁粗化
如果存在一系列的操作都对同一个对象反复加锁和解锁,甚至加锁操作是出现在循环体之中的,那即使没有线程竞争,频繁的进行互斥同步操作也会导致不必要的性能损耗。
如果虚拟机探测到了有这样的一串零碎的操作都对同一个对象加锁,将会把加锁同步的范围扩展(粗化)到整个操作序列的外部。
锁清除
虚拟机即使编译器在运行时,对一些代码要求同步,但是对被检测到不可能存在共享数据竞争的锁进行消除。锁消除的主要判断依据来源于逃逸分析的数据支持,如果判断到一段代码中,在堆上的所有数据都不会逃逸出去被其他线程访问到,那就可以把他们当作栈上的数据对待,认为它们是线程私有的,同步加锁自然就无需进行。
本文至此,相信读者也对synchronized的实现有了一定的了解了吧。
文章参考自《深入理解Java虚拟机》