写这篇文章的目的源自于看《并发编程艺术》的时候,书上说synchronized关键字的锁是放在对象头里的。索性带着这个问题把这个关键字相关的内容梳理一下。
synchronized关键字是Java并发编程中非常重要的一个工具。它的主要目的是在同一时间只能允许一个线程去访问一段特定的代码,从而保护一些变量或者数据不会被其他线程所修改。这感觉就像一群人抢着去上厕所,而你运气好抢到了,啪把门一锁,厕所的那一平方天地在那段时间就只属于你,即使门外的人排队都排到了地中海(此处排除有人暴力拆厕所的情况)。
使用synchronized关键字后,都以对象作为锁,一般有以下三种实现形式。
public synchronized void test1() { i++; } 复制代码
public static synchronized void test2() { i++; } 复制代码
public void test2() { synchronized(this){ i++; } } 复制代码
在JVM中,对象在内存中的布局分为3块:对象头、实例数据和对齐填充。先说说实例数据,它存储着对象真正的有效信息(程序代码中定义的各种类型的字段内容),无论是从父类继承来的字段还是子类中定义的。然后再是对齐填充,它并没有什么特殊的含义,仅仅只是起占位符的作用。原因呢是因为JVM要求对象的起始地址必须是8个字节的整数倍(对象的大小必须是8个字节的整数倍)。而对象头已经是8的整数倍了,如果实例数据没有对齐就需要对齐填充来补全。
重点来了, synchronized使用的锁都放在对象头里 ,JVM中用2个字节来储存对象头(如果对象是数组则分配3个字节,多的一个字节用于存储数组的长度)。而对象头包含两部分信息,分别为Mark Word和类型指针。Mark Word主要用于储存对象自身的运行时数据,例如对象的hashCode、GC分代年龄、锁状态标志、线程持有的锁、偏向线程的ID、偏向时间戳等。而类型指针用于标识JVM通过这个指针来确定这个对象是哪个类的实例。
由于对象需要储存的运行时数据过多,Mark Word被设计成一个非固定的数据结构以便在极小的空间内存储更多的信息。对象在不同的状态下,Mark Word会存储不同的内容(只放32位虚拟机的图表)。
锁状态 | 25bit | 4bit | 1bit(是否是偏向锁) | 2bit(锁的标志位) |
---|---|---|---|---|
无锁状态 | 对象的hashcode | 对象分代年龄 | 0 | 01 |
偏向锁 | 线程ID + epoch | 对象分代年龄 | 1 | 01 |
锁状态 | 30bit | 2bit(锁的标志位) |
---|---|---|
轻量级锁 | 指向栈中锁记录的指针 | 00 |
重量级锁(synchronized) | 指向互斥量(重量级锁)的指针 | 10 |
GC标志 | 空 | 11 |
这边也就主要分析一下重量级锁,标志位为10,指针指向monitor对象的起始地址,而每一个对象都存在着一个monitor与之关联。在Hot Spot中,monitor是由ObjectMonitor类来实现的。先来看一下ObjectMonitor的数据结构。
ObjectMonitor() { _header = NULL;//markOop对象头 _count = 0; _waiters = 0,//等待线程数 _recursions = 0;//重入次数 _object = NULL;//监视器锁寄生的对象。锁不是平白出现的,而是寄托存储于对象中。 _owner = NULL;//指向获得ObjectMonitor对象的线程或基础锁 _WaitSet = NULL;//处于wait状态的线程,会被加入到waitSet; _WaitSetLock = 0; _Responsible = NULL; _succ = NULL; _cxq = NULL; FreeNext = NULL; _EntryList = NULL;//处于等待锁block状态的线程,会被加入到entryList; _SpinFreq = 0; _SpinClock = 0; OwnerIsThread = 0; _previous_owner_tid = 0;//监视器前一个拥有者线程的ID } 复制代码其中有两个队列 _EntryList和 _WaitSet,它们是用来保存ObjectMonitor对象列表, _owner指向持有ObjectMonitor对象的线程。 当多个线程访问同步代码时,线程会进入_EntryList区,当线程获取对象的monitor后(对于线程获得锁的优先级,还有待考究)进入 _Owner区并且将 _owner指向获得锁的线程(monitor对象被线程持有), _count++,其他线程则继续在 _EntryList区等待。若线程调用wait方法,则该线程进入 _WaitSet区等待被唤醒。线程执行完后释放monitor锁并且对ObjectMonitor中的值进行复位。 上面说到synchronized使用的锁都放在对象头里,大概指的就是Mark Word中指向互斥量的指针指向的monitor对象内存地址了。
由以上可知为什么Java中每一个对象都可以作为锁对象了。
JVM通过进入和退出monitor对象来实现方法和代码块的同步,但是实现细节不一。可以使用javap -verbose XXX.class命令看代码被编译成字节码后是如何实现同步的。
Code: stack=3, locals=3, args_size=1 0: aload_0 1: dup 2: astore_1 3: monitorenter 4: aload_0 5: dup 6: getfield #2 // Field i:I 9: iconst_1 10: iadd 11: putfield #2 // Field i:I 14: aload_1 15: monitorexit 16: goto 24 19: astore_2 20: aload_1 21: monitorexit 22: aload_2 23: athrow 24: return 复制代码
将含有synchronized代码块的代码反编译后,可以看到monitorenter和monitorexit两条指令。monitorenter处于代码块开始的位置,而monitorenter与之匹配在代码结束或者异常处。任何对象都有个monitor与之对应,当monitor被持有后,它就处于锁定状态。线程执行到monitorenter指令时,会尝试去获得对象的锁(即monitor的所有权)。
public synchronized void test1(); descriptor: ()V 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 6: 0 line 7: 10 复制代码
方法的同步是隐式的,JVM中使用 method_info 型数据中方法访问标志的 ACC_SYNCHRONIZED做区分。当线程执行代码时若发现方法的访问标志中有ACC_SYNCHRONIZED,则当前线程持有monitor对象。接下来执行的细节与同步代码块无异。以上便是synchronized关键字修饰的同步方法和同步代码块实现的基本原理了。
第一次听说重入锁是ReentrantLock,后来知道synchronized关键字支持隐式重入。顾名思义,重入锁就是支持重进入的锁,支持一个线程可以对资源重复加锁。对于一个synchronized加持的代码块,其他线程试图访问该代码块时,线程会阻塞。若是持有锁的线程再次请求自己持有的锁时,则能成功获得。
public synchronized void test1() { i++; } public void test2() { synchronized(this){ test1(); } } 复制代码
当前线程获得锁后,通过cas将_owner指向当前线程,若当前线程再次请求获得锁, _owner指向不变,执行_recursions++记录重入的次数,若尝试获得锁失败,则在_EntryList区域等待。这种感觉有点像盗梦空间里的梦中梦,可以重复的进入自己的梦里,若想正常的醒过来,只能按原路返回(_recursions--)。
我买的书上没有关于synchronized关键字比较底层的解释,只能站在网上其他博主的肩膀上,通过他们文章中对于底层C++代码的解释大致的了解了一下其原理。
最后还是那句话,学习的最终目的并不是为了面试,面试只是一个激励学习的动机。把握面试题,享受学习新知识的乐趣。