一、Synchronized的简单介绍
相信用过java的同学对Synchronized
都不陌生,它是一个代码同步器,可以修饰普通方法、静态方法和代码块,而锁的粒度不太一样。
-
修饰普通方法:锁的对象是当前调用这个方法的对象。不同的对象之间没有竞争关系。
-
修饰静态方法:锁的对象是这个class类, 不同的对象之间也存在竞争关系。
-
修饰代码块:锁的是
synchronized (object)
里面的这个对象。
在多线程的环境下,当多个线程并发去操作同一个共享资源时,可能会出现线程安全问题,看一段代码:
public class MyTest {
private static int a = 0;
public static void main(String[] args) {
CountDownLatch countDownLatch = new CountDownLatch(1);
for (int i=0;i<100;i++){
new Thread(){
@Override
public void run() {
try {
countDownLatch.await();
for(int m=0;m<10000;m++){
a++;
}
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}.start();
}
countDownLatch.countDown();
try {
Thread.sleep(10000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.err.println(a);
}
}
多次尝试之后的运行结果:
983386
这里我们开了100个线程(实际上开不了那么多,这个先略),每个线程对a加10000次,想当然的结果应该是1000000。为什么会出现少算的情况呢?即为什么会发生线程安全的问题?
1. 什么是线程安全问题?
上篇文章中讲到了JMM内存模型,每个线程都有自己的工作内存,对某一个共享变量进行操作的话,必须要从主内存read、load到工作内存,而在并发环境下,有可能出现多个线程同时从主内存中把 a=?
读到工作内存,那既然这些线程读到的值都是一样的,执行a+1运算之后再store、write回主内存,是不是就出现了少算的情况了,这就是我们经常说的线程安全问题。
试想一下,如果我们工作中的核心代码出现了线程安全问题,那可完蛋了。正好我最近在做一个下单相关的需求(假设是单结点的),如果库存少扣了,那又会引起一大波客诉
2. 怎么避免线程安全问题?
- 本文说的
Synchronized
,它可以锁方法,锁代码块。 ReentrantLock
, 它是AQS中的一种悲观锁,拥有排他、可重入、公平与非公平的特性。CAS
操作,全称是Compred and Swap(比较交换),它是一种无锁技术,下篇文章讲AQS的时候会详细介绍。
作为一个好奇宝宝,当然是想知道Synchronized的底层是如何实现的呀
那么,小可爱!!!请带着你的好奇,往下看吧。
二、Synchronized的底层原理
既然 Synchronized
是一个修饰符,那么我们肯定要看看编译之后的字节码
以简单代码为例:
1. 锁方法
public class MyTest {
private static int a = 0;
public synchronized static void main(String[] args) {
a++;
}
}
我们通过javap -v MyTest.class
得到一个比较易读的字节码文件,发现MyTest这个类的flag多了一个修饰符ACC_SYNCHRONIZED
(静态方法会多一个ACC_STATIC),如下:
public class com.example.spring.jvmTest.MyTest
minor version: 0
major version: 52
flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
#1 = Methodref #4.#21 // java/lang/Object."<init>":()V
#2 = Fieldref #3.#22 // com/example/spring/jvmTest/MyTest.a:I
#3 = Class #23 // com/example/spring/jvmTest/MyTest
#4 = Class #24 // java/lang/Object
#5 = Utf8 a
#6 = Utf8 I
#7 = Utf8 <init>
#8 = Utf8 ()V
#9 = Utf8 Code
#10 = Utf8 LineNumberTable
#11 = Utf8 LocalVariableTable
#12 = Utf8 this
#13 = Utf8 Lcom/example/spring/jvmTest/MyTest;
#14 = Utf8 main
#15 = Utf8 ([Ljava/lang/String;)V
#16 = Utf8 args
#17 = Utf8 [Ljava/lang/String;
#18 = Utf8 <clinit>
#19 = Utf8 SourceFile
#20 = Utf8 MyTest.java
#21 = NameAndType #7:#8 // "<init>":()V
#22 = NameAndType #5:#6 // a:I
#23 = Utf8 com/example/spring/jvmTest/MyTest
#24 = Utf8 java/lang/Object
{
public com.example.spring.jvmTest.MyTest();
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 3: 0
LocalVariableTable:
Start Length Slot Name Signature
0 5 0 this Lcom/example/spring/jvmTest/MyTest;
public static synchronized void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC, ACC_SYNCHRONIZED
Code:
stack=2, locals=1, args_size=1
0: getstatic #2 // Field a:I
3: iconst_1
4: iadd
5: putstatic #2 // Field a:I
8: return
LineNumberTable:
line 8: 0
line 9: 8
LocalVariableTable:
Start Length Slot Name Signature
0 9 0 args [Ljava/lang/String;
static {};
descriptor: ()V
flags: ACC_STATIC
Code:
stack=1, locals=0, args_size=0
0: iconst_0
1: putstatic #2 // Field a:I
4: return
LineNumberTable:
line 5: 0
}
2. 锁代码块
public class MyTest {
private static int a = 0;
private static Object object = new Object();
public static void main(String[] args) {
synchronized (object){
System.err.println(++a);
}
}
}
我们通过javap -v MyTest.class
发现锁的那个代码块的入口会多一个monitorenter
的指令,出口多了一个monitorexit
的指令,如下:
public class com.example.spring.jvmTest.MyTest
minor version: 0
major version: 52
flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
#1 = Methodref #6.#30 // java/lang/Object."<init>":()V
#2 = Fieldref #7.#31 // com/example/spring/jvmTest/MyTest.object:Ljava/lang/Object;
#3 = Fieldref #32.#33 // java/lang/System.err:Ljava/io/PrintStream;
#4 = Fieldref #7.#34 // com/example/spring/jvmTest/MyTest.a:I
#5 = Methodref #35.#36 // java/io/PrintStream.println:(I)V
#6 = Class #37 // java/lang/Object
#7 = Class #38 // com/example/spring/jvmTest/MyTest
#8 = Utf8 a
#9 = Utf8 I
#10 = Utf8 object
#11 = Utf8 Ljava/lang/Object;
#12 = Utf8 <init>
#13 = Utf8 ()V
#14 = Utf8 Code
#15 = Utf8 LineNumberTable
#16 = Utf8 LocalVariableTable
#17 = Utf8 this
#18 = Utf8 Lcom/example/spring/jvmTest/MyTest;
#19 = Utf8 main
#20 = Utf8 ([Ljava/lang/String;)V
#21 = Utf8 args
#22 = Utf8 [Ljava/lang/String;
#23 = Utf8 StackMapTable
#24 = Class #22 // "[Ljava/lang/String;"
#25 = Class #37 // java/lang/Object
#26 = Class #39 // java/lang/Throwable
#27 = Utf8 <clinit>
#28 = Utf8 SourceFile
#29 = Utf8 MyTest.java
#30 = NameAndType #12:#13 // "<init>":()V
#31 = NameAndType #10:#11 // object:Ljava/lang/Object;
#32 = Class #40 // java/lang/System
#33 = NameAndType #41:#42 // err:Ljava/io/PrintStream;
#34 = NameAndType #8:#9 // a:I
#35 = Class #43 // java/io/PrintStream
#36 = NameAndType #44:#45 // println:(I)V
#37 = Utf8 java/lang/Object
#38 = Utf8 com/example/spring/jvmTest/MyTest
#39 = Utf8 java/lang/Throwable
#40 = Utf8 java/lang/System
#41 = Utf8 err
#42 = Utf8 Ljava/io/PrintStream;
#43 = Utf8 java/io/PrintStream
#44 = Utf8 println
#45 = Utf8 (I)V
{
public com.example.spring.jvmTest.MyTest();
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 3: 0
LocalVariableTable:
Start Length Slot Name Signature
0 5 0 this Lcom/example/spring/jvmTest/MyTest;
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=3, locals=3, args_size=1
0: getstatic #2 // Field object:Ljava/lang/Object;
3: dup
4: astore_1
5: monitorenter
6: getstatic #3 // Field java/lang/System.err:Ljava/io/PrintStream;
9: getstatic #4 // Field a:I
12: iconst_1
13: iadd
14: dup
15: putstatic #4 // Field a:I
18: invokevirtual #5 // Method java/io/PrintStream.println:(I)V
21: aload_1
22: monitorexit
23: goto 31
26: astore_2
27: aload_1
28: monitorexit
29: aload_2
30: athrow
31: return
Exception table:
from to target type
6 23 26 any
26 29 26 any
LineNumberTable:
line 10: 0
line 11: 6
line 12: 21
line 13: 31
LocalVariableTable:
Start Length Slot Name Signature
0 32 0 args [Ljava/lang/String;
StackMapTable: number_of_entries = 2
frame_type = 255 /* full_frame */
offset_delta = 26
locals = [ class "[Ljava/lang/String;, class java/lang/Object ]
stack = [ class java/lang/Throwable ]
frame_type = 250 /* chop */
offset_delta = 4
static {};
descriptor: ()V
flags: ACC_STATIC
Code:
stack=2, locals=0, args_size=0
0: iconst_0
1: putstatic #4 // Field a:I
4: new #6 // class java/lang/Object
7: dup
8: invokespecial #1 // Method java/lang/Object."<init>":()V
11: putstatic #2 // Field object:Ljava/lang/Object;
14: return
LineNumberTable:
line 5: 0
line 7: 4
}
SourceFile: "MyTest.java"
3. 总结
从编译的结果来看
-
同步代码块是通过
monitorenter
和monitorexit
来实现的,每个同步对象都有一个自己的Monitor,加锁过程如下:
-
同步方法则是通过加了
ACC_SYNCHRONIZED
修饰,JVM在调用该类的方法时,会先检查该类是否被ACC_SYNCHRONIZED
修饰,如果是,执行线程会先获取Monitor,获取成功才能执行对应方法,执行完之后再释放Monitor,在方法的执行期间,其他线程都无法获取同一个Monitor对象,被阻塞的线程会被挂起,等待CPU重新调度,而这个过程会导致其他线程在用户态和内核态直接来回切换,对性能影响较大。
Synchronized
是基于JVM内置锁Monitor实现,通过进入与退出Monitor实现方法与代码块的同步。而Monitor的底层又依赖于操作系统的Mutex Lock(互斥锁),Mutex Lock是一种重量级的锁,当然性能也较低。所以JVM在1.5版本之后做了重大优化,如:锁粗化,锁消除,锁的膨胀升级等技术来减少锁的开销。
4. Monitor到底是什么?
Java是一种面向对象的语言,那么Monitor也不例外,在HotSpot虚拟机中,Monitor被定义为ObjectMonitor, 其源码如下(C++实现的):
ObjectMonitor中维护了两个集合,WaitSet(处于wait状态的线程会被加入到其中)和EntryList(处于等待锁而阻塞的线程被加入到其中)用来保存ObjectWaiter对象列表(每个等待锁的线程都会被封装成ObjectWaiter),owner指向指针指向当前持有ObjectMonitor对象的线程,多线程访问同步代码时,具体步骤如下:
- 首先进入EntryList集合,当线程获取到对象的monitor后,owner指针指向获取到monitor的当前线程,同时把Monitor中的计数器count加1。
- 若线程调用wait()方法,会释放掉当前持有的Monitor,owner指针指向null,count减1,同时该线程进入到WaitSet等待被唤醒。
- 若当前线程执行完毕,也会释放掉当前持有的Monitor,owner指针指向null,count减1,以便其他线程进入获取Monitor。
注意:notify/notifyAll/wait等方法也会使用到Monitor,所以必须在同步代码块中使用。
三、JVM对Synchronized的优化
1.Synchronized与AQS的前世今生
大家有没有想过,为什么JDK内置了两个同步锁?既然有了Synchronized为什么还要AQS?
经过查阅资料发现,JDK5之前Synchronized是一把重量级的锁,性能较低,针对这个问题,dog li(道格丶李)就手写了一套GUI的并发包,其中AQS性能远超Synchronized,后来JDK被oracle收购之后,oracle公司强大可想而知,不甘平凡,后面对Synchronized做了极大的优化(就是我们下文要讲的),其性能和AQS已经相差不多了。后面GUI包也被oracle收购了,然后就造就了今天JDK的两个内置的并发工具
2.自旋锁和自适应性自旋锁
自旋:当线程A去请求获取锁的时候,这个锁正在被其它线程占用,但是线程A并不会马上进入阻塞状态,而是循环请求锁(自旋)。这样做的目的是因为很多时候持有锁的线程会很快释放锁的,线程A可以尝试一直请求锁,没必要被挂起放弃CPU时间片,因为线程被挂起然后到唤醒这个过程开销很大(需要经历多次用户态、内核态之间切换),如果线程A自旋指定的时间还没有获取到锁,仍然会被挂起。
自适应性自旋:自适应性自旋是自旋的升级、优化,自旋的时间不再固定,而是由上一次在同一个锁上的自旋时间及锁的拥有者的状态决定。例如线程如果自旋成功了,那么下次自旋的次数会增多,因为JVM认为既然上次成功了,那么这次自旋也很有可能成功,那么它会允许自旋的次数更多。反之,如果对于某个锁,自旋很少成功,那么在以后获取这个锁的时候,自旋的次数会变少甚至忽略,避免浪费CPU资源。有了自适应性自旋,随着程序运行和性能监控信息的不断完善,JVM对程序锁的状况预测就会变得越来越准确。
3.锁的粗化与消除
锁消除:锁消除是指虚拟机在JIT即时编译期间,通过逃逸分析,会对同步代码块内没有对共享变量进行读写操作的锁进行消除。
看下面的代码:虽然StringBuffer的append
方法是Synchronized
修饰的,但是通过逃逸分析得知StringBuffer属于局部变量,并且不会逃离该方法,即不会被外部所引用,故test方法肯定是线程安全的,从而发生了锁消除。
public class MyTest {
public static void main(String[] args) {
new MyTest().test();
}
public void test(){
StringBuffer sb = new StringBuffer();
sb.append("hello boom");
}
}
锁粗化:在使用锁的时候,需要让同步块的锁的范围尽可能小,锁的次数尽可能少。如果JVM检测到有一串零碎的操作都是对同一对象的加锁,将会把加锁同步的范围扩展(粗化)到整个操作的外部。
看下面的代码:StringBuffer的append
方法是Synchronized
修饰的,调用两次append方法,那么就要获取两次锁,性能大大折扣,从而JVM对该代码发生了锁粗化,只用获取一次锁 ,锁住两个append方法。
public class MyTest {
public static void main(String[] args) {
StringBuffer sb = new StringBuffer();
sb.append("hello");
sb.append("boom");
}
}
4.锁的膨胀升级
Synchronized
锁的状态有四种(单向的、依次升级):无锁、偏向锁、轻量级锁和重量级锁。
对象的组成中Mark Word会记录锁的状态,通过这些状态进行锁的记录与锁的升级,这篇有详细介绍对象的组成:https://juejin.cn/post/6947232495242838053
偏向锁:
程序在运行过程中,如果不存在多线程竞争锁,并且锁总是被同一个线程获取,为了减少同一线程获取锁的代价(会涉及到CAS操作,相对耗时)而引入了偏向锁。
偏向锁的思想是,如果一个线程获取到了锁,那么锁就进入偏向锁模式,此时Mark Word的结构也会变为偏向锁的状态,当这个线程再次获取同样的锁时,无需经历任何获取锁的过程,直接拿到锁,这样就省去了大量获取锁的操作,从而提升了程序的性能。
但是在锁竞争比较激烈的情况下,可能每次获取到锁的线程都不一样,从而导致偏向锁失效,会升级到轻量级锁。
轻量级锁:
偏向锁失效,会升级到轻量级锁。Mark Word的结构也会变为轻量级锁的状态,轻量级锁是用于多线程交替执行同步代码块的场景,如果在同一时刻多个线程竞争同一把锁,那么轻量级锁就会升级为重量级锁。
重量级锁:我们说的重量级锁,即调用操作系统底层的Mutex Lock
下图是网上找来的锁升级的过程,俺觉得挺详细的,借鉴一下。