给本文取这样的标题,我自己都笑了,但是希望本文能帮助到一些人。本文从JVM的内存结构开始,然后介绍Java的对象结构,接着分析synchronized重量级锁机制的实现、应用及其底层语义原理,然后是关于JVM对synchronized的优化的内容。
JVM执行线程同步的基本流程
1.1 JVM内存结构
上图是JVM的内存结构图,从图中我们清晰发现,JVM的内存结构主要包含以下几个重要的区域:堆(Heap)、栈(Thread(1...N))、方法区(PermGen)。
- 堆是JVM中最大的一块,由年轻代和老年代组成,而年轻代内存又被分成三部分,EdenSpace、FromSpace(Survivor1)、ToSurvivor(Survivor2),默认情况下年轻代按照8:1:1的比例来分配。
- 栈分为java虚拟机栈和本地方法栈,主要用于方法的执行,每个线程分配的内存也是栈。
- 方法区存储类信息、常量、静态变量等数据,这块区域也称为Non-Heap(非堆)。
这张图展示了控制内存大小的各个参数的含义。没有直接设置老年代的参数,但是可以设置堆空间大小和新生代空间大小两个参数来间接控制:老年代空间大小=堆空间大小-年轻代大空间大小。
堆和方法区是所有线程共享的内存区域;而Java栈、本地方法栈和程序计数器是运行是线程私有的内存区域。
在Java虚拟中,每个线程会分配并独享一块栈内存(通过参数-Xss设置其大小),其中包括局部变量、线程调用的每个方法的参数和返回值。其他线程无法读取自己线程栈内存块中的数据。栈中的数据仅限于基本类型和对象引用,对象的引用其实也是对象的一部分所以,栈是无法保存真实的对象的,只能保存对象的引用,真正的对象(比如数组,Java中数组是对象)要保存在堆中。
1.2 对象结构
对象实例处于堆内存中,对象引用处于栈内存中,对象的结构分为3部分:- 实例变量:存放类的属性数据信息,包括父类的属性信息,如果是数组的实例部分还包括数组的长度,这部分内存按4字节对齐。
- 填充数据:JVM要求对象起始地址必须是8字节的整数倍,填充数据就是为此而存在但不是必须存在的,仅仅是为了字节对齐。
Java对象头是synchronized的锁机制的基础和核心,因为synchronized使用的锁对象(Monitor管程)是存储在Java对象头里的,各个线程获取锁的过程就是个“抓头”的过程,谁先抓到头谁获取锁,其余线程阻塞。对象头主要结构是由Mark Word 和 Class Metadata Address 组成,其结构下表:
虚拟机位数 | 头对象结构 | 说明 |
---|---|---|
32/64bit | Mark Word | 存储对象的hashCode、锁信息或分代年龄或GC标志等信息 |
32/64bit | Class Metadata Address | 类型指针指向对象的类元数据,JVM通过这个指针确定该对象是哪个类的实例。 |
关于对象结构的更过内容:深入理解多线程(二)—— Java的对象模型
1.3 对象锁和类锁
上上文说过,线程能够共享的内存区域只有堆和方法区,如果多个线程要同时共享和操作一个对象或者变量,需要对被共享的数据或对象进行管控(Monitor),否则会导致线程不安全。其实synchronized锁就是通过对象头部的Monitor对象ObjectMonitor(这是一种重量级锁,锁标记位是10)实现的。类锁其实也通过对象锁实现的,因为当虚拟机加载一个类的时候,会为这个类实例化一个 java.lang.Class 对象,当你锁住一个类的时候,其实锁住的是其对应的Class 对象
。Monitor可以与对象一起创建销毁或当线程试图获取对象锁时自动生成,当一个Monitor被某个线程抓住后,它便处于锁定状态。因为所有对象都能加锁,所以线程等待和唤起的方法notify/notifyAll/wait就放在了所有对象的父对象Object中。
看下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 ;
}
ObjectMonitor中有两个队列,_WaitSet 和 _EntryList,用来保存ObjectWaiter对象列表( 每个等待锁的线程都会被封装成ObjectWaiter对象),_owner指向持有ObjectMonitor对象的线程。
- 当多个线程同时访问synchronized代码时,首先全部都会会进入 _EntryList 集合。
- JVM决定由哪个线程获得对象的monitor锁并进入 _Owner 区域并把monitor中的owner变量设置为当前线程同时monitor中的计数器count加1。
- 若线程调用 wait() 方法,将释放当前持有的monitor,owner变量恢复为null,count自减1,同时该线程进入 WaitSet集合中等待被唤醒。
- 若线程被唤醒重新获取对象monitor后进入_Owner 区域并把monitor中的owner变量设置为当前线程同时monitor中的计数器count加1。
- 若当前线程执行完毕也将释放monitor(锁)并复位变量的值,以便其他线程进入获取monitor(锁)。
同一个线程可以对同一个对象进行多次加锁。每个对象维护着一个记录着被锁次数的计数器。未被锁定的对象的该计数器为0,当一个线程获得锁后,该计数器自增变为 1 ,当同一个线程再次获得该对象的锁的时候,计数器再次自增。当同一个线程释放锁的时候,计数器再自减。当计数器为0的时候。锁将被释放,其他线程便可以获得锁。
synchronized的三种同步用法及其底层语义原理
public class Test {
public synchronized void fs() {
System.out.println("fs");
}
public synchronized static void fg() {
System.out.println("fg");
}
public void fh() {
synchronized (this) {
System.out.println("fh");
}
}
}
synchronized有三种同步方式:
- 修饰实例方法,作用于当前实例加锁,进入同步代码前要获得当前实例的锁,比如上面代码的fs方法
- 修饰类方法(静态方法),作用于当前类对象加锁,进入同步代码前要获得当前类对象的锁,比如上面代码的fg方法
- 修饰代码块,指定加锁对象(因为所有对象都能加锁),对给定对象加锁,进入同步代码库前要获得给定对象的锁,比如上面代码的fh方法。
其实这三种使用方式本质上只有两种:获取的是对象锁还是类锁。第一种方式肯定是对象锁,synchronized同步只是针对这一个对象实例而言而与其它对象实例无关;第二种方式肯定是类锁,synchronized同步针对这个类而言,任何线程调用都会同步;第三种方式可能是对象锁也可能是类锁,如果传给synchronized的参数是一个类对象(比如String.class)那么就是类锁,如果传入的是一个对象实例那么就是对象锁。
后面的内容主要参考:深入理解Java并发之synchronized实现原理--zejian_ 。
2.1 synchronized作用于代码块其底层语义原理
如果要同步的方法体积比较大,同时存在一些比较耗时的操作,但是真正需要同步的代码只是一小部分,那么锁整个方法就显得有点得不偿失了。我们可以传一个对象的实例给synchronized即相当于锁住这个对象实例然后执行部分代码块。比如下面的代码,传this给synchronized获取当前对象实例的锁并只锁住i++
这个非原子操作而不是锁住整个syncTask
方法:
public class SyncCodeBlock {
public int i;
public void syncTask(){
//同步代码库
synchronized (this){
i++;
}
}
}
编译上述代码并使用javap反编译后得到字节码如下(这里我们省略一部分没有必要的信息):
Classfile /Users/zejian/Downloads/Java8_Action/src/main/java/com/zejian/concurrencys/SyncCodeBlock.class
Last modified 2017-6-2; size 426 bytes
MD5 checksum c80bc322c87b312de760942820b4fed5
Compiled from "SyncCodeBlock.java"
public class com.zejian.concurrencys.SyncCodeBlock
minor version: 0
major version: 52
flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
//........省略常量池中数据
//构造函数
public com.zejian.concurrencys.SyncCodeBlock();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=1, locals=1, args_size=1
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
LineNumberTable:
line 7: 0
//===========主要看看syncTask方法实现================
public void syncTask();
descriptor: ()V
flags: ACC_PUBLIC
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
Exception table:
//省略其他字节码.......
}
我们主要关注字节码中的如下代码:
3: monitorenter //进入同步方法
//..........省略其他
15: monitorexit //退出同步方法
16: goto 24
//省略其他.......
21: monitorexit //退出同步方法
从字节码中可知同步语句块的实现使用的是monitorenter 和 monitorexit 指令,其中monitorenter指令指向同步代码块的开始位置,monitorexit指令则指明同步代码块的结束位置,当执行monitorenter指令时,当前线程将试图获取 objectref(即对象锁) 所对应的 monitor 的持有权,当 objectref 的 monitor 的进入计数器为 0,那线程可以成功取得 monitor,并将计数器值设置为 1,取锁成功。如果当前线程已经拥有 objectref 的 monitor 的持有权,那它可以重入这个 monitor (关于重入性稍后会分析),重入时计数器的值也会加 1。倘若其他线程已经拥有 objectref 的 monitor 的所有权,那当前线程将被阻塞,直到正在执行线程执行完毕,即monitorexit指令被执行,执行线程将释放 monitor(锁)并设置计数器值为0 ,其他线程将有机会持有 monitor 。值得注意的是编译器将会确保无论方法通过何种方式完成,方法中调用过的每条 monitorenter 指令都有执行其对应 monitorexit 指令,而无论这个方法是正常结束还是异常结束。为了保证在方法异常完成时 monitorenter 和 monitorexit 指令依然可以正确配对执行,编译器会自动产生一个异常处理器,这个异常处理器声明可处理所有的异常,它的目的就是用来执行 monitorexit 指令。从字节码中也可以看出多了一个monitorexit指令,它就是异常结束时被执行的释放monitor 的指令。
2.2 synchronized作用于实例方法其底层语义原理
因为一个对象只有一把锁,所以如果一个线程获得一个对象的锁并访问synchronized 实例方法a,那么其它线程就不能访问该对象的任何synchronized 实例方法(包括a),但是其他线程还是可以访问该实例对象的其他非synchronized方法。
synchronized作用于实例方法只是获取的对象的锁而不是类的锁,所以如果是一个线程 A 需要访问类O的实例对象 obj1 的 synchronized 方法 fs(当前对象锁是obj1),另一个线程 B 需要访问类O实例对象 obj2 的 synchronized 方法 fs(当前对象锁是obj2),这样是允许的,因为两个实例对象锁并不同相同,此时如果两个线程操作数据并非共享的,线程安全是有保障的。如果两个线程操作的是共享数据(前面我们说过共享数据只能放在堆内存区和方法区中),那么线程安全就有可能无法保证了,因为对象锁只会锁住自己独享的数据而不会锁住共享数据(类锁能锁住共享数据),共享数据不是属于单个对象的。
方法级的同步是隐式,即无需通过字节码指令来控制的,它实现在方法调用和返回操作之中。JVM可以从方法常量池中的方法表结构(method_info Structure) 中的 ACC_SYNCHRONIZED 访问标志区分一个方法是否同步方法。当方法调用时,调用指令将会 检查方法的 ACC_SYNCHRONIZED 访问标志是否被设置,如果设置了,执行线程将先持有monitor(虚拟机规范中用的是管程一词), 然后再执行方法,最后再方法完成(无论是正常完成还是非正常完成)时释放monitor。在方法执行期间,执行线程持有了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标识,该标识指明了该方法是一个同步方法,JVM通过该ACC_SYNCHRONIZED访问标志来辨别一个方法是否声明为同步方法,从而执行相应的同步调用。这便是synchronized锁在同步代码块和同步方法上实现的基本原理。同时我们还必须注意到的是在Java早期版本中,synchronized属于重量级锁,效率低下,因为监视器锁(monitor)是依赖于底层的操作系统的Mutex Lock来实现的,而操作系统实现线程之间的切换时需要从用户态转换到核心态,这个状态之间的转换需要相对比较长的时间,时间成本相对较高,这也是为什么早期的synchronized效率低的原因。庆幸的是在Java 6之后Java官方对从JVM层面对synchronized较大优化,所以现在的synchronized锁效率也优化得很不错了,Java 6之后,为了减少获得锁和释放锁所带来的性能消耗,引入了轻量级锁和偏向锁,接下来我们将简单了解一下Java官方在JVM层面对synchronized锁的优化。
2.3 synchronized作用于类方法其底层语义原理
这个方式与前面的区别在于要获取的锁是类锁,注意任何类的类对象也是只有一个,那么类锁也是只有一个,那么该类的所有synchronized类方法都是同步的,其底层原理前面已经说过。
JVM对synchronized的优化
锁的状态总共有四种,无锁状态、偏向锁(01)、轻量级锁(00)和重量级锁(10)。随着多线程的竞争,锁可以从偏向锁升级到轻量级锁,再升级到重量级锁。锁的升级是单向的,也就是说只能从低到高升级,不会出现锁的降级。关于重量级锁即synchronized锁,前面我们已详细分析过,下面我们将介绍偏向锁和轻量级锁以及JVM的其他优化手段,这里并不打算深入到每个锁的实现和转换过程,更多地是阐述Java虚拟机所提供的每个锁的核心优化思想,毕竟涉及到具体过程比较繁琐,如需了解详细过程可以查阅《深入理解Java虚拟机原理》。
偏向锁
jdk1.6新增了偏向锁的概念以优化线程同步的效率。有关研究指出,在很多情况下,锁总是由同一线程多次获得,偏向锁就是处理并优化这种情况。如果JVM检测到锁由一个线程多次获得,那么锁就进入偏向模式,对象头的Mark World的结构也变为偏向锁结构,当这个线程再次请求锁时无需做任何同步操作就让它获得到了锁。这只适合单线程访问同步代码,如果是多线程会带来额外的锁撤销的消耗。如果偏向锁失效会升级为轻量级锁。
轻量级锁
跟偏向锁一样,轻量级锁也是jdk1.6新增的。我个人所理解的轻量级锁的思想是这样的:线程A获取锁的时候首先检查上次获得锁的线程是不是自己,如果是自己就把锁标记为00即轻量级锁,并直接访问同步代码(重入并monitor中的计数器仍会加1),否则就是重量级锁即10,但是当前线程并不会阻塞,而是自旋等锁,当前线程后面的线程才会阻塞等锁。
适应性自旋(Adaptive Spinning)
当线程在获取轻量级锁的过程中执行CAS操作失败时,是要通过自旋来获取重量级锁的。问题在于,自旋是需要消耗CPU的,如果一直获取不到锁的话,那该线程就一直处在自旋状态,白白浪费CPU资源。解决这个问题最简单的办法就是指定自旋的次数,例如让其循环10次,如果还没获取到锁就进入阻塞状态。但是JDK采用了更聪明的方式——适应性自旋,简单来说就是线程如果自旋成功了,则下次自旋的次数会更多,如果自旋失败了,则自旋的次数就会减少。
锁粗化(Lock Coarsening)
锁粗化的概念应该比较好理解,就是将多次连接在一起的加锁、解锁操作合并为一次,将多个连续的锁扩展成一个范围更大的锁。举个例子:
package com.paddx.test.string;
public class StringBufferTest {
StringBuffer stringBuffer = new StringBuffer();
public void append(){
stringBuffer.append("a");
stringBuffer.append("b");
stringBuffer.append("c");
}
}
这里每次调用stringBuffer.append方法都需要加锁和解锁,如果虚拟机检测到有一系列连串的对同一个对象加锁和解锁操作,就会将其合并成一次范围更大的加锁和解锁操作,即在第一次append方法时进行加锁,最后一次append方法结束后进行解锁。
锁消除(Lock Elimination)
锁消除即删除不必要的加锁操作。根据代码逃逸技术,如果判断到一段代码中,堆上的数据不会逃逸出当前线程,那么可以认为这段代码是线程安全的,不必要加锁。如下StringBuffer的append是一个同步方法,但是在add方法中的StringBuffer属于一个局部变量不会从该方法中逃逸出去,因此也不会被其他线程所使用,因此StringBuffer不可能存在共享资源竞争的情景,JVM会自动将其锁消除。
public class LockElimination{
public void add(String str1, String str2) {
StringBuffer sb = new StringBuffer();
sb.append(str1).append(str2);
}
public static void main(String[] args) {
LockElimination le= new LockElimination();
for (int i = 0; i < 10000000; i++) {
le.add("zzh", "" + i);
}
}
}
下面给下总结:
synchronized的可重入性
但当一个线程再次请求自己持有对象锁的临界资源时,这种情况属于重入锁,请求将会成功,在java中synchronized是基于原子性的内部锁机制,是可重入的,因此在一个线程调用synchronized方法的同时在其方法体内部调用该对象另一个synchronized方法,也就是说一个线程得到一个对象锁后再次请求该对象锁,是允许的,这就是synchronized的可重入性,而且每次重入,monitor中的计数器仍会加1。
还有一点需要注意:一个正在等候获得synchronized锁的线程是无法被中断的。
本文结束,欢迎指正。
参考文献