synchronized关键字解决的是多个线程之间访问资源的同步性,synchronized 翻译为中文的意思是同步,也称之为同步锁。
synchronized的作用是保证在同一时刻, 被修饰的代码块或方法只会有一个线程执行,以达到保证并发安全的效果。
原子性:synchronized保证语句块内操作是原子的
同步方法
ACC_SYNCHRONIZED 这是一个同步标识,对应的 16 进制值是 0x0020
这 10 个线程进入这个方法时,都会判断是否有此标识,然后开始竞争 Monitor
对象。
同步代码
monitorenter,在判断拥有同步标识 ACC_SYNCHRONIZED 抢先进入此方法
的线程会优先拥有 Monitor 的 owner ,此时计数器 +1。
monitorexit,当执行完退出后,计数器 -1,归 0 后被其他进入的线程获得。
可见性:synchronized保证可见性(通过“在执行unlock之前,必须先把此变量同步回主内存”实现)
那么为什么添加 synchronized 也能保证变量的可见性呢?
因为:
有序性:synchronized保证有序性(通过“一个变量在同一时刻只允许一条线程对其进行lock操作”)
as-if-serial,保证不管编译器和处理器为了性能优化会如何进行指令重排序,
都需要保证单线程下的运行结果的正确性。也就是常说的:如果在本线程内观察,
所有的操作都是有序的;如果在一个线程观察另一个线程,所有的操作都是无序
的。
这里有一段双重检验锁(Double-checked Locking)的经典案例:
public class SingletonDoubleCheckLock {
private SingletonDoubleCheckLock(){}
private volatile static SingletonDoubleCheckLock instance;
public SingletonDoubleCheckLock getInstance(){
if (null == instance){
synchronized (SingletonDoubleCheckLock.class){
if (null == instance){
instance = new SingletonDoubleCheckLock();
}
}
}
return instance;
}
为什么,synchronized 也有可见性的特点,还需要 volatile 关键字?
因为,synchronized 的有序性,不是 volatile 的防止指令重排序。那如果不加 volatile 关键字可能导致的结果,就是第一个线程在初始化初始化对象,设置 instance 指向内存地址时。第二个线程进入时,有指令重排。在判断 if (instance == null) 时就会有出错的可能,因为这会可能 instance 可能还没有初始化成功。
重入性:synchronized 是可重入锁,也就是说,允许一个线程二次请求自己持有对象锁
的临界资源,这种情况称为可重入锁。
那么我们就写一个例子,来证明这样的情况。
public class A {
public synchronized void doA(){
System.out.println("父类方法:A.doA() ThreadId:" + Thread.currentThread().getId());
}
}
public class RetryTest extends A {
public static void main(String[] args) {
RetryTest retryTest = new RetryTest();
retryTest.doA();
}
public synchronized void doA(){
System.out.println("子类方法:RetryTest.doA() ThreadId:" + Thread.currentThread().getId());
doB();
}
private synchronized void doB(){
super.doA();
System.out.println("子类方法:RetryTest.doB() ThreadId:" + Thread.currentThread().getId());
}
}
输出结果:
这段单例代码是递归调用含有 synchronized 锁的方法,从运行正常的测试结果看,并没有发生死锁。所有可以证明 synchronized 是可重入锁。
synchronized 锁对象的时候有个计数器,他会记录下线程获取锁的次数,在执行完对应的代码块之后,计数器就会-1,直到计数器清零,就释放锁了。
之所以,是可以重入。是因为 synchronized 锁对象有个计数器,会随着线程获取锁后 +1 计数,当线程执行完毕后 -1,直到清零释放锁。
jvm基于进入和退出Monitor对象来实现方法同步和代码块同步
方法级的同步是隐式,即无需通过字节码指令来控制的,它实现在方法调用和返回操作之中。JVM可以从方法常量池中的方法表结构(method_info Structure) 中的 ACC_SYNCHRONIZED 访问标志区分一个方法是否同步方法。当方法调用时,调用指令将会 检查方法的 ACC_SYNCHRONIZED 访问标志是否被设置,如果设置了,执行线程将先持有monitor(虚拟机规范中用的是管程一词), 然后再执行方法,最后再方法完成(无论是正常完成还是非正常完成)时释放monitor。
代码块的同步是利用monitorenter和monitorexit这两个字节码指令。它们分别位于同步代码块的开始和结束位置。当jvm执行到monitorenter指令时,当前线程试图获取monitor对象的所有权,如果未加锁或者已经被当前线程所持有,就把锁的计数器+1;当执行monitorexit指令时,锁计数器-1;当锁计数器为0时,该锁就被释放了。如果获取monitor对象失败,该线程则会进入阻塞状态,直到其他线程释放锁。
这里要注意:
关于ACC_SYNCHRONIZED 、monitorenter、monitorexit指令,可以看下面的反编译代码
public class SynchronizedTest {
public void get(){
synchronized (this){ // 这个是同步代码块
System.out.println("你好呀");
}
}
public synchronized void f(){ //这个是同步方法
System.out.println("Hello world");
}
public static void main(String[] args) {
}
}
可以通过javap -verbose SynchronizedTest
对代码进行反编译,如下:
反编译之后的结果,如下:
monitorenter:代表 监视器入口,获取锁;
monitorexit:代表监视器出口,释放锁;
monitorexit:第二次monitorexit,代表 发生异常,释放锁;
ACC_SYNCHRONIZED访问标志:当方法调用时,调用指令将会 检查方法的 ACC_SYNCHRONIZED 访问标志是否被设置,如果设置了,执行线程将先持有monitor(虚拟机规范中用的是管程一词), 然后再执行方法,最后再方法完成(无论是正常完成还是非正常完成)时释放monitor。
synchronized的底层实现是完全依赖JVM虚拟机的,所以谈synchronized的底层实现,就不得不谈数据在JVM内存的存储:Java对象头,以及Monitor对象监视器。
HotSpot 虚拟机中,对象在内存中存储的布局可以分为三块区域:对象头(Header)、
实例数据(Instance Data)和对齐填充(Padding)。
先简单介绍下对象头的形式,JVM中对象头的方式有以下两种(以32位JVM为例):
这部分主要用来存储对象自身的运行时数据,如hashcode、gc分代年龄等。mark word的位长度为JVM的一个Word大小,也就是说32位JVM的Mark word为32位,64位JVM为64位。
为了让一个字大小存储更多的信息,JVM将字的最低两个位设置为标记位,不同标记位下的Mark Word示意如下:
其中各部分的含义如下:
这一部分用于存储对象的类型指针,该指针指向它的类元数据,JVM通过这个指针确定对象是哪个类的实例。该指针的位长度为JVM的一个字大小,即32位的JVM为32位,64位的JVM为64位。
如果应用的对象过多,使用64位的指针将浪费大量内存,统计而言,64位的JVM将会比32位的JVM多耗费50%的内存。为了节约内存可以使用选项+UseCompressedOops开启指针压缩,其中,oop即ordinary object pointer普通对象指针。开启该选项后,下列指针将压缩至32位:
当然,也不是所有的指针都会压缩,一些特殊类型的指针JVM不会优化,比如指向PermGen的Class对象指针(JDK8中指向元空间的Class对象指针)、本地变量、堆栈元素、入参、返回值和NULL指针等。
如果对象是一个数组,那么对象头还需要有额外的空间用于存储数组的长度,这部分数据的长度也随着JVM架构的不同而不同:32位的JVM上,长度为32位;64位JVM则为64位。64位JVM如果开启+UseCompressedOops选项,该区域长度也将由64位压缩至32位。
在Java虚拟机(HotSpot)中,monitor是由ObjectMonitor实现的,其主要数据结构如下(位于HotSpot虚拟机源码ObjectMonitor.hpp文件,C++实现的)
ObjectMonitor中有两个队列,_WaitSet 和 _EntryList,用来保存ObjectWaiter对象列表( 每个等待锁的线程都会被封装成ObjectWaiter对象),_owner指向持有ObjectMonitor对象的线程,当多个线程同时访问一段同步代码时,首先会进入 _EntryList 集合,当线程获取到对象的monitor 后进入 _Owner 区域并把monitor中的owner变量设置为当前线程同时monitor中的计数器count加1,若线程调用 wait() 方法,将释放当前持有的monitor,owner变量恢复为null,count自减1,同时该线程进入 WaitSe t集合中等待被唤醒。若当前线程执行完毕也将释放monitor(锁)并复位变量的值,以便其他线程进入获取monitor(锁)。如下图所示:
Java虚拟机给每个对象和class字节码都设置了一个监听器Monitor,用于检测并发代码的重入,同时在Object类中还提供了notify和wait方法来对线程进行控制。
在java.lang.Object类中有如下代码:
public class Object {
…
private transient int shadow$monitor;
public final native void notify();
public final native void notifyAll();
public final native void wait() throws InterruptedException;
public final void wait(long millis) throws InterruptedException {
wait(millis, 0);
}
public final native void wait(long millis, int nanos) throws InterruptedException;
…
}
结合上图来分析Object的Monitor机制。
Monitor可以类比为一个特殊的房间,这个房间中有一些被保护的数据,Monitor保证每次只能有一个线程能进入这个房间进行访问被保护的数据,进入房间即为持有Monitor,退出房间即为释放Monitor。
当一个线程需要访问受保护的数据(即需要获取对象的Monitor)时,它会首先在entry-set入口队列中排队(这里并不是真正的按照排队顺序),如果没有其他线程正在持有对象的Monitor,那么它会和entry-set队列和wait-set队列中的被唤醒的其他线程进行竞争(即通过CPU调度),选出一个线程来获取对象的Monitor,执行受保护的代码段,执行完毕后释放Monitor,如果已经有线程持有对象的Monitor,那么需要等待其释放Monitor后再进行竞争。
再说一下wait-set队列。当一个线程拥有Monitor后,经过某些条件的判断(比如用户取钱发现账户没钱),这个时候需要调用Object的wait方法,线程就释放了Monitor,进入wait-set队列,等待Object的notify方法(比如用户向账户里面存钱)。当该对象调用了notify方法或者notifyAll方法后,wait-set中的线程就会被唤醒,然后在wait-set队列中被唤醒的线程和entry-set队列中的线程一起通过CPU调度来竞争对象的Monitor,最终只有一个线程能获取对象的Monitor。
前面已经分析了Monitor的机制,那么在Java中是如何实现的呢?
即通过synchronized关键字实现线程同步来获取对象的Monitor。
实现方式为:ACC_SYNCHRONIZED和monitorenter/monitorexit 前面已讲过
锁解决了数据的安全性,但是同样带来了性能的下降,hotspot 虚拟机的作者经过调查发现,大部分情况下,加锁的代码不仅仅不存在多线程竞争,而且总是由同一个线程多次获得。
所以基于这样一个概率,synchronized 在JDK1.6 之后做了一些优化,为了减少获得锁和释放锁来的性能开销,引入了偏向锁、轻量级锁,锁的状态根据竞争激烈的程度从低到高不断升级。
无锁没有对资源进行锁定,所有的线程都能访问并修改同一个资源,但同时只有一个线程能修改成功。
偏向锁是JDK1.6中引用的优化,它的目的是消除数据在无竞争情况下的同步原语,进一步提高程序的性能。大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,为了让线程获得锁的代价更低而引入了偏向锁。
偏向锁会偏向于第一个获得它的线程,如果在接下来的执行过程中,该锁没有被其他的线程获取,则持有偏向锁的线程将永远不需要同步。
轻量级锁也是在JDK1.6中引入的新型锁机制。它不是用来替换重量级锁的,它的本意是在没有多线程竞争的情况下,减少传统的重量级锁使用操作系统互斥量产生的性能消耗。
是指当锁是偏向锁的时候,被另外的线程所访问,偏向锁就会升级为轻量级锁,其他线程会通过自旋的形式尝试获取锁,不会阻塞,从而提高性能。
指的是原始的Synchronized的实现,重量级锁的特点:其他线程试图获取锁时,都会被阻塞,只有持有锁的线程释放锁之后才会唤醒这些线程。
参考:Java对象头与monitor
参考:Java对象的Monitor机制
参考:Synchronized的底层实现原理
参考:synchronized底层实现原理及锁优化