结合JVM深入谈一谈Synchronized关键字的神奇之处
今天就来聊一聊Synchronized这个关键字。一提到Synchronized这个关键字,可能你马上联想到同步、加锁、多线程并发等等这些词语,仿佛感觉Synchronized关键字出镜率很高,感觉哪里都有它的身影。其实,Synchronized在很早的JDK版本中就已经存在了,可以说是元老级别的存在,主要解决的是多线程之间同步的访问资源,被Synchronized关键字修饰的方法或者代码块在任意时刻只能有一个线程执行。
在早期的JDK版本中,Synchronized属于重量级锁,效率十分的低下。为什么呢?因为如果你看过JVM规范,你就会明白,Synchronized的实现同步功能,从JVM角度来讲,是通过**进入和退出Monitor对象来实现方法和同步代码块同步的!**监视器Monitor是依赖于底层操作系统的Mutex互斥锁来实现的,Java的线程是映射到操作系统的原生线程之上的。如果要挂起或者唤醒一个线程,都需要操作系统帮助完成,而操作系统实现线程之间的切换需要从用户态转换到内核态,这个状态之间的转换需要相对比较长的时间,时间成本比较高。所以说早期的Synchronized锁比较“重”,效率不高。但是也许是官方也意识到了Synchronized锁太过笨重,在JDK 6版本,官方从JVM层面对Synchronized做了非常大的优化,引入了自旋锁、偏向锁、轻量级锁等技术来较少锁操作的开销,使得Synchronized的效率得到了很大的提升,所以现在很多在控制多线程并发方面使用了Synchronized同步关键字。
明白了Synchronized关键字是什么,有什么作用之后,我们就来看看它是怎么使用的。
首先我们要知道Synchronized关键字可以在哪些地方被使用,它主要有3种使用方式:
-
用来修饰普通方法
锁是当时实例对象,在进入这个同步方法之前,需要先获得当前对象实例的锁。
synchronized void function() {...}
-
用来修饰静态方法
锁是当前类的Class对象,在进入这个静态同步方法之前,需要先获得这个类Class对象的锁
synchronized static void function() {...}
-
用来修饰同步代码块
锁是Synchronized括号里面配置的对象,使用this表示锁是当前实例对象,也可以是别的对象,只要保证是唯一的就可以。在进入这个同步代码块之前,需要先获得配置的对象的对应的锁。
synchronized (this) {...}
我们看出来,Synchronized关键字要么实现了方法同步,要么实现了代码块同步,接下来我们就从JVM的角度来看看到底是怎么实现同步的?底层原理是什么?
方法同步
方法级的同步作为方法调用和返回的一部分,无需字节码指令来控制,会被隐式的执行。因为使用了Synchronized关键字的同步方法,在运行时常量池中method_info
结构存放着这个方法的相关信息, 在方法调用的时候,会检查方法的access_flags
访问标志是否包含ACC_SYNCHRONIZED
- 如果包含,执行线程将先持有同步锁,然后执行方法,最后在方法完成(无论是正常完成还是非正常完成)时会释放同步锁
- 在方法执行期间,执行线程持有了同步锁,其它任何线程都无法再获得同一个锁
- 如果一个同步方法执行期间抛出了异常,并且在方法内部无法处理此异常,那这个同步方法所持有的锁将在异常抛到同步方法之外时自动释放
代码块同步
代码块同步是使用monitorenter
和monitorexit
指令实现的。**monitorenter
指令是在编译后插入到同步代码块的开始位置,而monitorexit
指令时插入到方法结束的位置以及发生异常的位置,**JVM要保证每一monitorenter
必须有对应的monitorexit
指令与之配对。任何一个对象都有一个监视器与之相关联,用来判断对象是否被锁定,当监视器被持有时,对象处于锁定状态。
当一个线程进入同步代码时,它使用monitorenter
指令请求进入,如果当前对象的监视器计数器为0,则它被允许进入,若监视器计数器为1,则还需判断持有当前监视器的线程是否为自己(可重入),如果是则可以进入代码块。否则这个线程必须等待,知道对象的监视器计数器为0,才会被允许进入同步代码块。
当线程退出代码块时,需要使用monitorexit
声明退出,释放对象的监视器
指令monitorenter
和monitorexit
在执行时,都需要在操作数栈顶压入对象,之后monitorenter
和monitorexit
的锁定和释放都是针对这个对象的监视器进行的
二者存在不同,不过本质都是获取对象监视器Monitor来实现同步,你可能会问那这个Monitor对象监视器又是什么东西呢?在JVM中,这个Monitor是基于C++中的ObjectMonitor这个类实现的,每一个对象中都内置了一个ObjectMonitor对象。除此之外wait()/notify()等方法也依赖于这个Monitor对象,这也就是为什么只有在同步代码块或者同步方法中才能调用wait()/notify()等方法。
我们之前提到过由于早期的Synchronized锁太过笨重,在JDK6 之后官方对Synchronized做了很多优化,使得效率得到了很大提升。接下来我们就从JVM底层来看看都做了哪些优化。
对象头
首先我们要明确Synchronized使用的锁的相关信息是存放在Java对象头里面的,确切的说是存放在对象头的运行时元数据(MarkWord)里面的,MarkWord里面存储这对象自身运行时的数据,有hashcode值、GC分代年龄、锁状态、线程目前持有的锁、偏向锁线程ID等等。
优化后的Synchronized锁的分类和锁升级
为了解决加锁和释放锁时效率低的问题,引入了偏向性、轻量级锁。所以说锁主要存在4种状态
从低到高:
无锁状态==》偏向锁状态==》轻量级锁状态==》重量级锁状态
随着线程竞争获取锁激烈程度逐渐升级,但是不能降级
-
偏向锁
初次执行到synchronized代码块的时候,锁对象变成偏向锁(通过CAS修改对象头里的锁标志位),字面意思是“偏向于第一个获得它的线程”的锁。执行完同步代码块后,线程并不会主动释放偏向锁。当第二次到达同步代码块时,线程会判断此时持有锁的线程是否就是自己(持有锁的线程ID也在对象头里),如果是则正常往下执行。**由于之前没有释放锁,这里也就不需要重新加锁。**如果自始至终使用锁的线程只有一个,很明显偏向锁几乎没有额外开销,性能极高。
偏向锁使用了一种等待竞争出现才释放锁的机制,所以当其它线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁。偏向锁的撤销需要等待全局安全点,即在这个时间点上没有正在执行的字节码
-
轻量级锁
一旦有第二个线程加入锁竞争,偏向锁就会升级为轻量级锁,也叫自旋锁。这里要明确一下什么是锁竞争:如果多个线程轮流获取一个锁,但是每次获取锁的时候都很顺利,没有发生阻塞,那么就不存在锁竞争。只有当某线程尝试获取锁的时候,发现该锁已经被占用,只能等待其释放,这才发生了锁竞争。
线程在执行同步代码块之前,JVM会先在当前线程的栈帧中创建用于存储锁记录的空间,并将对象头中的Mark Word复制到锁记录中。在轻量级锁状态下继续锁竞争,没有抢到锁的线程将自旋,即不停地循环判断锁是否能够被成功获取。获取锁的操作,其实就是通过CAS修改对象头里的锁标志位。先比较当前锁标志位是否为“释放”,如果是则将其设置为“锁定”,比较并设置是原子性发生的。这就算抢到锁了,然后线程将当前锁的持有者信息修改为自己。
长时间的自旋操作是非常消耗资源的,一个线程持有锁,其他线程就只能在原地空耗CPU,执行不了任何有效的任务,这种现象叫做忙等(busy-waiting)。如果多个线程用一个锁,但是没有发生锁竞争,或者发生了很轻微的锁竞争,那么synchronized就用轻量级锁,允许短时间的忙等现象。这是一种折衷的想法,短时间的忙等,换取线程在用户态和内核态之间切换的开销。
-
重量级锁
轻量级锁解锁时,会使用原子的CAS操作将指向锁记录的指针替换回到对象头。如果成功,则表示没有竞争发生,如果失败,表示当前锁存在竞争,轻量级锁会膨胀成重量级锁。
当其它线程在竞争锁时,发现MarkWord中的锁标记已经改成重量级锁了,就明白现在锁竞争很激烈,就没必要CAS自旋,白白浪费CPU了。就不会再进行自旋而是阻塞直接挂起。
Synchronized同步锁从早期的效率低,到后来的优化,可以说是元老级别的“锁”了。其实Java中还存在另一把“锁”与之相媲美,那就是ReentrantLock可重入锁。接下来我们就来谈一谈ReentrantLock可重入锁以及它们二者之间的区别。
之前我们说的Synchronized是依赖于JVM实现的,是JVM层面的锁,并且它的优化也是从JVM底层进行优化的。而ReentrantLock可重入锁是API层面的,ReentrantLock这个类实现了Lock这个接口,需要我们使用lock()和unlock()方法手动的进行加锁和释放锁。
Synchronized锁和ReentrantLock都是可重入锁,“可重入锁” 指的是同一线程可以多次同一把锁。但是相比synchronized,ReentrantLock增加了一些其他高级功能
-
等待可中断
ReentrantLock提供了一种能够中断等待锁的线程机制,调用
lock.lockInterruptibly()
方法可以让正在等待的线程放弃等待,去做其他事情。 -
可实现公平锁
我们要知道,Synchronized锁是非公平锁,也就是说一个线程抢到了锁,在释放锁之后,如果需要的话它还可以继续争抢锁,如果一个线程恰好每次都获取锁成功,那么就会导致线程迟迟得不到执行。而ReentrantLock可以实现公平锁,所谓公平锁,就是说不可能让一个线程一直抢锁成功,而是先等待的线程先获得锁,实现公平轮流加锁。ReentrantLock默认使用的是非公平锁,但是在构造方法中可以指定是否使用公平锁。实际上公平锁是比非公平锁多维护了一个队列,先来后到,每次都是从队列中拿出线程去获取锁,不像非公平那样,每次谁抢到算谁的。
-
可实现选择性通知
Synchronized锁有对应的wait()、notify()方法实现线程之间的等待通知机制,同样的,ReentrantLock也应该有一套对应的等待通知机制,await()和signal()方法。这一对方法依赖于Condition接口和newCondition()方法,Condition对象是由Lock对象调用newCondition()方法创建出来的,它有很好的灵活性,在一个Lock对象中可以创建多个Condition对象,线程对象可以注册在指定的Condition中,从而可以有选择性的通知指定的线程。而Synchronized关键字的notify()、notifyAll()方法通知线程时,被通知的线程是由JVM选择的,要么通知一个,要么全部通知,不能像ReentrantLock那样可选择的通知一部分线程。
不知你有没有注意到,Synchronized可以修改方法,也可以修饰代码块,那变量呢?谁来修饰?实际上还有一个关键字volatile,相对于Synchronized这个重量级锁,volatile是JVM提供的轻量级同步机制。
至此~ 我们从JVM角度深入了解了一下Synchronized的使用以及它的底层原理,同时还对比了Synchronized于ReentrantLock以及volatile之间的区别。ReentrantLock和volatile深入解析留给后续的文章。