深入理解Synchronized
共 6592字,需浏览 14分钟
·
2022-05-20 09:58
前言
Synchronized想必大家在工作中一定有接触过,它算是Java并发场景下实现多线程安全一种比较直接的操作。有人会说它慢,确实。在JDK1.6之前,它有另一个名称叫做:重量级锁。但是从1.6版本起,它就在不断被优化。现如今已经是很成熟的并发安全技术;所以关于Synchronized的考察也常常成为面试官青睐的话题。
本文我们会使用图解的方式解析Synchronized的使用和原理,让我们开始吧~
对象锁和类锁
什么是Synchronized?Synchronized是Java中的一个关键字,中文被称为“同步锁”。顾名思义,它是一种锁,当某一时刻有多个线程对同一段程序进行操作时,能够保证只有一个线程能够获取到资源,因此保证了线程安全。
Synchronized主要有三种使用方式:
修饰普通方法,锁作用于当前对象实例。
修饰静态方法,锁作用于类的Class实例。
修饰代码块,作用于当前对象实例,需要指定加锁对象。
普通方法
Synchronized是一个关键字,当作用于一个普通方法的时候,这个方法便被加上了同步锁,意味着某一时刻只有一个线程可以操作访问这个方法:
public class fancySyncTest {
public synchronized void method1(){
try {
System.out.println(Thread.currentThread().getName());
Thread.sleep(5000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
public static void main(String[] agrs) throws InterruptedException {
final fancySyncTest fs = new fancySyncTest();
Thread t1 = new Thread(new Runnable() {
@Override
public void run() {
fs.method1();
}
},"线程1获取到资源");
Thread t2 = new Thread(new Runnable() {
@Override
public void run() {
fs.method1();
}
},"线程2获取到资源");
t1.start();
t2.start();
}
}
这段代码有方法method1( ),它的功能就是打印出当前执行这段代码的线程,并且让它休眠5秒钟。然后我们开启两个线程,线程t1和t2,它们两个一起启动。
这段代码的执行顺序很简单,线程1或者线程2任意一个线程先去执行method1( )的内容,然后休眠5秒钟。完事就释放锁,下一个线程会继续执行:
整个过程由于只有一个线程获取到这个方法,去执行方法里的内容,所以method1( )里的代码资源是线程安全的。
于是,当前哪个对象调用了这个方法,那么当前这段线程在执行的时候就让这个对象去访问这个方法。比如我是让对象fs去调用method1( ),那么这把锁就作用于当前的fs对象实例:
静态方法
静态方法和普通方法的区别只有一个,就是Synchronized关键字是作用于静态方法的。但是仅仅这个区别,代表着锁的对象也是不同的。原因在于Java的静态关键字它和实例对象没有任何关系,它作用的资源是在类的初始化时期就加载的,所以它只能由每个类唯一的Class对象调用。当它作用于一个Class对象时,它就会将这一整个类都锁住,简称"类锁":
public class fancySyncTest {
public static synchronized void method1(){
try {
System.out.println(Thread.currentThread().getName());
Thread.sleep(5000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
public static void main(String[] agrs) throws InterruptedException {
Thread t1 = new Thread(new Runnable() {
@Override
public void run() {
fancySyncTest.method1();
}
},"线程1获取到资源");
Thread t2 = new Thread(new Runnable() {
@Override
public void run() {
fancySyncTest.method1();
}
},"线程2获取到资源");
t1.start();
t2.start();
}
}
可以看出,这段由静态关键字修饰的代码和普通的方法没什么区别,唯一的区别就在于:由于method1( )是静态的,所有我们不用创建对象,可以直接由类实例fancySynTest直接调用!作为代价,这把锁锁住的对象也是直接覆盖了这个类。也就是说,当线程1执行的时候,没有别的线程可以访问这个fancySyncTest类:
Synchronized修饰普通方法和静态方法的区别只有一个:粒度不同。类似于数据库中表锁和行锁的区别。
代码块
Synchronized可以锁住普通方法,也可以锁住一个类,那么它锁的粒度能否更小呢?是的,它还能锁住一段简易的代码块。那么Synchronized如何定义一段代码块呢?其实定义一下作用的对象,然后将代码用括号{ }包裹起来就可以了:
public class fancySyncTest {
public synchronized void method1(){
synchronized (this) {
// 逻辑代码
}
}
}
代码块锁住的对象就是后面括号里的东西。比如这里的synchronized (this),意味着只有当前对象才可以访问这段代码块,你也可以定义为其它对象。
Synchronized原理
其实,Synchronized只是Java中的一个关键字,那么它底层是如何真正意义地实现锁呢?答案就是monitor监视器锁。无论是synchronized代码块还是synchronized方法,其线程安全的语义实现最终依赖的都是monitor,它才是真正意义上的锁。
为了得到Synchronized的底层代码,我们先写一段简单demo:
public class fancySynchronizedTest {
public synchronized void method1() {
}
public void method2() {
synchronized (this){
}
}
public static void main(String[] args) {
fancySynchronizedTest test = new fancySynchronizedTest();
test.method1();
test.method2();
}
}
然后对它使用java-c 反编译,得到以下文件:
public class fancySynchronizedTest {
public fancySynchronizedTest();
Code:
0: aload_0
1: invokespecial #1 // Method java/lang/Object."":()V
4: return
public synchronized void test1();
Code:
0: return
public void test2();
Code:
0: aload_0
1: dup
2: astore_1
3: monitorenter //monitor加锁
4: aload_1
5: monitorexit //monitor解锁
6: goto 14
9: astore_2
10: aload_1
11: monitorexit //monitor异步退出解锁
12: aload_2
13: athrow
14: return
Exception table:
from to target type
4 6 9 any
9 12 9 any
public static void main(java.lang.String[]);
Code:
0: new #2 // class SynchronizedTest
3: dup
4: invokespecial #3 // Method "":()V
7: astore_1
8: aload_1
9: invokevirtual #4 // Method test1:()V
12: aload_1
13: invokevirtual #5 // Method test2:()V
16: return
}
可以看到,test2( )在加上了synchronized同步代码块后,会输出以下指令:monitorenter和monitorexit。
monitorenter存在于同步代码块开始的位置,而monitorexit存在于同步代码块结束的位置,它们分别代表着获取锁和释放锁。每一个monitorenter都必须对应一个monitorexit。并且,每一个对象在其堆内存的数据结构中,它的对象头都会关联一个完整的monitor结构。
为了让大家更熟悉monitor,我们带大家来阅读一下monitor的源码。
对于我们通常使用的HotSpot虚拟机,monitor是由ObjectMonitor实现的。其源码是用c++来实现的,位于HotSpot虚拟机源码ObjectMonitor.hpp文件中,我们可以在社区版本的JDK上阅读到,因为它是开源的:
注:该源码版本为jdk8u的社区版open jdk
我们需要关注该图上的三个变量:_owner、_WaitSet、_EntryList ,因为monitor就是通过它们,来实现上锁与释放锁的。我们先来看看这三个变量的定义:
_EntryList:
它的定义为:阻塞在路口处的线程的集合
再来看看_WaitSet:
它的定义为:处于等待监视器的线程的集合
最后看看变量_owner:
它的意思是指向持有锁的线程。
所以,它的完整过程就是如下所示:
monitor自身会设置一个变量count来作为计数器维护这把可重入锁,当没有线程获取这把锁的时候,它的值为0,如果有一个线程获取这把锁,它的值就会+1,并且设置该线程为锁的持有者。_owner指向的就是当前持锁线程。如果该线程已经占用该锁,并且重新进入,那么count的值就会+1。当执行到monitorexit的时候,count的值就会-1,直到monitor值为0的时候,该持锁线程会进入到WaitSet里面,将状态改为等待状态,让其他处于EntryList里的阻塞线程重新自旋获取这把锁。
以上就是完整的monitor获取锁和释放锁的过程。不知道你是否会觉得它和AQS的原理很像?其实这些底层架构设计的思想都是相通的。
锁的优化
可能是意识到Synchronized作为重量级锁性能上的不足,从jdk1.6开始Java团队就对它进行了优化。通过各种各样的手段,如自旋锁、偏向锁、轻量级锁等技术来减少锁的开销,那么我们就来解释一下这些锁操作以及升级过程。
自旋锁
首先聊聊自旋锁,自旋锁顾名思义就是自旋。当一个线程在获取锁的时候,如果该锁已被其它线程获取到,那么该线程就会去循环自旋获取锁,不停地判断该锁是否能够已经被释放,自选直到获取到锁才会退出循环。通常该自选在源码中都是通过for(; ;)或者while(true)这样的操作实现,非常粗暴。
那么都说是自旋了,自旋就代表着占用cpu资源,使用自旋锁的目的是为了避免线程在阻塞和唤醒状态之间切换而占用资源,毕竟线程从阻塞状态切换到唤醒状态需要CPU从用户态转化为内核态,而频繁的状态切换就会导致CPU的资源浪费,所以引入了自选锁。但是自旋锁也必定要设置一个自旋的阈值,否则因为自旋长期占用CPU核心数,也是一种资源的浪费。在JDK1.6中默认的自旋次数为10次,也就是说如果一个线程自旋超过10次,那么它就会自动进入挂起状态从而节约资源。以下为自旋锁获取锁过程:
自旋锁在JDK源码中有大量的应用,之后我们还会接触到。
锁消除
锁消除指的是虚拟机即时编译器在运行的时,对一些代码上要求同步,但是被检测到不可能存在共享数据竞争的锁进行削除。那么如何确定这个锁是否需要进行削除?主要来源于逃逸分析的判断,如果判断到一段代码中,在堆上的所有数据都不会逃逸出去被其他线程访问到,那就可以把它们当作栈上数据对待,认为它们是线程私有的,同步加锁自然就无须进行。举个例子:
public void method() {
synchronized (new Object()) {
//代码逻辑
}
}
这段代码里面,我们new了一个Object对象来作为锁对象,但是这个对象也只有在method( )中被使用,其完整的生命周期都在这个方法中,也就是说JVM在经过逃逸分析后会对它进行栈上分配,由于在底层变成了私有数据,那么也就无需加锁了。其被优化后的代码会变成:
public void method() {
//代码逻辑
}
这就是锁的消除,JVM虚拟机在判断一段代码没必要加锁后,会消除该锁的存在。
那么我们再来看看什么是锁的粗化。
锁粗化
锁粗化指的是在JIT编译时,发现如果有一段代码中频繁的加锁释放锁,会将前后的锁合并为一个锁,避免频繁加锁释放锁。举个例子:
public void method() {
for(int i = 0;i < 100; i++) {
synchronized (new Object()) {
//代码逻辑
}
}
}
大伙说,如果按照正常的synchronized步骤走,这个循环需要进行多少次的加锁解锁操作,这性能可想而知!
leader看了你这段代码估计想骂娘。。。
好在现在的JVM已经优化的足够好了。当你这段代码在即时编译时,JVM检测到你每一次都是对同一个对象加锁,那么就会把这一串连续频繁的加锁解锁操作优化成仅仅一次公共的加锁解锁操作,这样性能就提升了很多了:
public void method() {
synchronized (new Object()) {
for(int i = 0;i < 100; i++) {
//代码逻辑
}
}
}
所以,所谓的锁粗化,就是通过增加锁的范围,减少锁的数量来减少开销。
偏向锁
在提偏向锁之前,我们需要先提一下一个Java对象在内存中是如何存储的。
一个对象在堆内存中由三部分区域组成,分别为:对象头(Header)、实例数据(Instance Data)和对其填充(Padding):
其中对象头包括了两部分,一部分是关于堆对象的布局、类型、GC状态、同步状态和标识哈希码的基本信息,它被称为"Mark Word "。
另一部分是类型指针,是对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。
那么我们今天的重点在于Mark Word,其结构如下:
好了,在知道了对象头的重要结构Mark Word,我们就继续聊聊偏向锁。
偏向锁是JVM认为没有发生并发的场景下提供的锁。它是JDK 1.6中的重要引进,因为HotSpot团队发现在大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,为了让线程获得锁的代价更低,引进了偏向锁。
所以这就是它为什么叫“偏向锁”。“偏”,就是偏心的“偏”、偏袒的“偏”,它的意思是这个锁会偏向于第一个获得它的线程,会在对象头存储锁偏向的线程ID,以后该线程进入和退出同步区域时只需要检查是否为偏向锁,即锁标志位以及ThreadID即可:
轻量级锁
轻量级锁是JDK 1.6之中加入的新型锁机制,它名字中的“轻量级”是相对于使用monitor的传统锁而言的,因此传统的锁机制就称为“重量级”锁。
首先需要强调一点的是,轻量级锁并不是用来代替重量级锁的。引入轻量级锁的目的在于:在多线程交替执行同步块的情况下,尽量避免重量级锁引起的性能消耗,但是如果多个线程在同一时刻进入临界区,会导致轻量级锁膨胀升级重量级锁,所以轻量级锁的出现并非是要替代重量级锁。当该锁为轻量级锁时,其Mark Word的状态变化如下:
重量级锁
在JDK1.6之前,Synchronized就是一把“重量级锁”。它需要依赖操作系统级别的mutex和condition variable来实现。重量级锁会让抢占锁的线程从用户态转变为内核态,所以开销很大:
锁升级
锁升级,顾名思义就是锁的等级不断上升。因为锁是会消耗性能的,所以锁不断升级,它的性能就会越差。当然这一切都是为了满足安全、复杂的业务场景。
并且锁只会升级,不会降级:
总结
本文我们详细地介绍了Synchronized的三种使用方式、它作为锁的特性、详细的原理以及七种锁的优化过程。Synchronized作为虚拟机级别的锁,无论是业务的使用还是面试,都是经常被照顾的对象,所以搞懂它就很重要。下一次我们会讲一讲它异父异母的亲兄弟:JDK级别的锁Lock,以及谈谈它们是如何经常拿出来被比较的~
如有文章对你有帮助,
“在看”和转发是对我最大的支持!
推荐: