Java架构师学习之路之并发编程三: Java同步器之synchronized&Lock&AQS
Java同步器之synchronized&Lock&AQS
synchronized关键字
1. 什么是synchronized关键字
syncronized关键字是JVM内置锁,是对象锁。对某一部分代码使用锁后,这段代码将被视为原子操作执行,保证多个线程对该代码块的访问会按照一定顺序先后执行。
2. synchronized关键字原理
假设我们对以下代码进行加锁:
public class MyTest {
public final static Object o = new Object();
public static int i = 0;
public static void main(String[] args) {
synchronized (o) {
i++;
}
}
}
再使用JDK自带的javap命令查看该文件的字节码文件,则会看到以下信息:
public static void main(java.lang.String[]);
Code:
0: getstatic #2 // Field o:Ljava/lang/Object;
3: dup
4: astore_1
5: monitorenter // ~~~~~~~~~~请注意该行~~~~~~~~~~~~
6: getstatic #3 // Field i:I
9: iconst_1
10: iadd
11: putstatic #3 // 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
这里出现了两条指令,分别是monitorenter
和monitorexit
。
这两条指令分别标识同步块的开始和结束。而这两个指令,是由JVM进行添加的,所以synchronized是一个JVM内置锁。
JVM在识别synchronized关键字后,会通过传入对象的ObjectMonitor
进行锁相关的操作。
这个ObjectMonitor
是每个Ojbect被创建的时候都会自动创建到JVM内部的。
加锁解锁的过程中,肯定需要记录锁的状态和信息,因此需要一个内存空间用来记录。
JVM使用对象中的对象头
来记录对象加锁解锁的状态。
请看下图:
由图可见,一个java对象的内存结构包含了3部分:
- 对象头 ——用来存放各种信息,比如锁状态等
- 实际数据 ——存放java对象的实际数据
- 对齐填充位 ——规范Java对象的内存空间大小,规定为8的整数倍
紧接着,咱们还可以查看一下JVM的源码,可以从网上下载一份源码,同时找到下面的文件:
/hotspot/src/share/vm/runtime/objectMonitor.hpp
打开看一下,会发现下面的内容:
ObjectMonitor(){
_header = NULL;
_count = 0+1-1 ;
_waiters = 0;
_WaitSet = NULL;
//此处省略一部分...
_owner = NULL;
_EntryList = NULL;
//...
}
当然,中间省略了很多参数。但是从上面几个参数来看,我们很简单就能和上面的图联系起来。
_header: 对象头
_count: 记录加锁的次数,重入时用到
_waiters: 当前有多少thread处于wait状态
_WaitSet: wait中的线程会被加入该集合
_owner: 当前哪个线程持有ObjectMonitor
_EntryList: 当前等待加锁的线程会被加入到该集合
假设现在有两个线程t1和t2。如果t1加锁成功,则_owner = t1
,且_count += 1
。
当t1释放锁的时候,_owner = NULL
,且_count -= 1
。
当锁需要重入时,会对_count
进行累减到0。
3. JIT——逃逸分析
- 逃逸分析是指JIT即时编译器会堆代码进行线程逃逸的分析,并根据分析结果进行指令优化。
- 通常情况下,JVM的server模式编译器默认开启逃逸分析。(JDK8以后JVM通过hotspot实现,其中包含1个解释器和2个编译器。这2个编译器就可以成为JIT。其中一个是client模式,另一个是server模式。server模式默认开启逃逸分析,client模式相反。)
- 可以通过设置JVM参数:
-XX:-DoEscapeAnalysis
关闭逃逸分析。或者设置-XX:+DoEscapeAnalysis
开启逃逸分析。
逃逸分析的作用:
- 同步省略。如果一个对象只能被一个线程访问到,那么会省去对该对象的加锁。
- 将堆分配转化为栈分配。如果一个对象被创建后,指向该对象的指针永远不会逃逸,那么该对象可能是栈分配的候选,而不是堆分配。
- 分离对象或标量替换。. 有的对象不需要作为连续分配的内存存在,也能被访问到,那么该变量的部分或者全部可以不存储在内存,而是存储在寄存器。
4. 锁的粗化
什么是锁的粗化:
当一个方法中有连续的加锁操作时,多次加锁操作将被JIT优化为一个锁,减少了频繁申请和释放锁的次数,提高了性能。
示例代码如下:
public static void main(String[] args) {
synchronized (LockTest.class){
System.out.println("111");
}
synchronized (LockTest.class){
System.out.println("222");
}
synchronized (LockTest.class){
System.out.println("333");
}
}
来查看一下字节码文件:
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=5, args_size=1
0: ldc #2 // class com/fujitsu/smartstore/LockTest
2: dup
3: astore_1
4: monitorenter
5: getstatic #3 // Field java/lang/System.out:Ljava/io/PrintStream;
8: ldc #4 // String 111
10: invokevirtual #5 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
13: aload_1
14: monitorexit
15: goto 23
18: astore_2
19: aload_1
20: monitorexit
21: aload_2
22: athrow
23: ldc #2 // class com/fujitsu/smartstore/LockTest
25: dup
26: astore_1
27: monitorenter
28: getstatic #3 // Field java/lang/System.out:Ljava/io/PrintStream;
31: ldc #6 // String 222
33: invokevirtual #5 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
36: aload_1
37: monitorexit
38: goto 46
41: astore_3
42: aload_1
43: monitorexit
44: aload_3
45: athrow
46: ldc #2 // class com/fujitsu/smartstore/LockTest
48: dup
49: astore_1
50: monitorenter
51: getstatic #3 // Field java/lang/System.out:Ljava/io/PrintStream;
54: ldc #7 // String 333
56: invokevirtual #5 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
59: aload_1
60: monitorexit
61: goto 71
64: astore 4
66: aload_1
67: monitorexit
68: aload 4
70: athrow
71: return
指令比较多,可以ctrl + F
进行搜索,我们可以找到这两条指令:monitorenter
、monitorexit
。看,前面学习的syncronized原理在这里就用上了。
咱们通过看字节码文件可以发现,这里的锁并没有被粗化成一个锁。
那是因为我们这里查看的是javac
编译后的字节码文件。而逃逸分析的优化是JIT
将字节码文件翻译为机器码文件过程中进行的。
如果想要查看JIT编译后的文件,可以使用JITWatch等工具查看,此处不再赘述。
5. 锁的消除
锁的消除很显而易见,当某个代码块使用的锁对象只能被一个线程获取到,则JIT会消除该同步操作。
如下:
public static void main(String[] args) {
synchronized (new Object()){
System.out.println("111");
}
}
多个线程获取到的锁对象都是不同的Object对象,因此JIT进行逃逸分析时会消除该同步操作。
6. 锁的基本知识
- JVM分为32位和64位两种,因此对于对象头的存储位数会有一定区别,但是整体逻辑相同。
- 在jvm源码下的
/hotspot/src/share/vm/oops/markOop.hpp
文件中有对对象头的存储位数进行解释说明。比如,以32位JVM为例,前25位存储hash code,接下来的4位存储age,再往后的1位存储偏向状态,紧接着的2位存储锁的状态。 - 锁的状态分为4种:无锁(01),偏向锁(01),轻量级锁(00),重量级锁(10),GC标记(11),并按照下列顺序膨胀升级:
-无锁:25bit记录对象hashcode,4bit存储对象分代年龄,1bit存储偏向状态(0),2bit存放锁状态(01)
-偏向锁:23bit存放线程id,2bit存放Epoch,4bit存放对象分代年龄,1bit存放偏向状态(1),2bit存放锁状态(01)
-轻量级锁:30bit存放线程栈中记录锁的指针,2bit存放锁状态(00)
-重量锁:30bit存放指向重量级锁monitor的指针(依赖Mutex操作系统的互斥),2bit存放锁状态(10)
此外还有个 GC状态:30bit置空,2bit存放锁状态(11)
锁升级过程(不可逆):
偏向锁:减少同一个线程去获取锁的成本。在没有竞争线程的情况下,当某个线程短时间内多次获取到锁后,该锁会偏向该线程,Mark Word的结构会转变为偏向锁结构,该线程再次请求该锁时,就不需要重新申请锁了(减少了一些可能会涉及到CAS的操作)。但是当有竞争线程的情况下,不应该使用偏向锁,因为每次获取锁的线程都可能不同。偏向锁失败后就会升级到轻量级锁。
轻量级锁:偏向锁失效后会先升级为轻量级锁(JDK1.6后加入),此时Mark Word转换为轻量级锁的结构。轻量级锁提升性能的依据是:“大部分锁在整个同步周期内都不存在竞争”,但这是经验数据,轻量级锁只适用于线程交替执行同步块的场景。如果同一时间访问同一个锁的话,就会导致轻量级锁升级为重量级锁。
自旋锁:轻量级锁失效后,JVM为了避免线程在操作系统中真正挂起,还会采用自旋锁的形式进行优化。这是基于在大多数情况下,线程持有锁的时间都不会太长而设计的。如果线程直接在操作系统上挂起,就涉及到用户态和内核态之间的切换,这个操作极度消耗时间。由于线程不会持有锁太长时间,因此其他线程自旋中一旦获取到锁,就顺利进入临界区,而不需要进行用户态到内核态的转换了。如果CAS获取锁失败达到一定次数,将升级为重量级锁。
重量级锁:通过操作系统Mutex互斥量将线程挂起,实现同步。
由于本章内容过多,所以将AQS放在下一章来学习~
一些思考:
- 在下面代码中,o对象存放在哪里?引用存放在哪里?元数据class存放在哪里?
public void test(){
Object o = new Object();
}
- 上述代码中,o一定存放在堆区吗?
下一章:Java架构师学习之路之并发编程四:Java同步器之synchronized&Lock&AQS(下)
欢迎大家一起讨论,让我们一起向着架构师前进~~~