在java代码中使用synchronized可是使用在代码块和方法中,根据Synchronized用的位置可以有这些使用场景:

java锁synchronized原理 java锁synchronized底层实现_无锁


如图,synchronized可以用在方法上也可以使用在代码块中,其中方法是实例方法和静态方法分别锁的是该类的实例对象和该类的对象。而使用在代码块中也可以分为三种,具体的可以看上面的表格。这里的需要注意的是:如果锁的是类对象的话,尽管new多个实例对象,但他们仍然是属于同一个类依然会被锁住,即线程之间保证同步关系。

现在我们已经知道了怎样synchronized了,看起来很简单,拥有了这个关键字就真的可以在并发编程中得心应手了吗?爱学的你,就真的不想知道synchronized底层是怎样实现了吗?

一、 对象锁(monitor)机制

1.1 synchronized修饰同步代码块

1 package com.paddx.test.concurrent;
2 
3 public class SynchronizedDemo {
4     public void method() {
5         synchronized (this) {
6             System.out.println("Method 1 start");
7         }
8     }
9 }

上面的代码中有一个同步代码块,锁住的是类对象,并且还有一个同步静态方法,锁住的依然是该类的类对象。编译之后,切换到SynchronizedDemo.class的同级目录之后,然后用javap -v SynchronizedDemo.class查看字节码文件:

java锁synchronized原理 java锁synchronized底层实现_JVM_02


通过synchronized 修饰同步代码块,反编译后主要关注以上圈红的命令, 在字节码中,我们发现了monitorenter和monitorexit指令。

关于monitor:每个对象都有一个对象监视器;当monitor被占用时对象就处于锁定状态。

monitorenter和monitorexit指令,JVM规范中描述(JVM的解释执行过程):

1.1 monitorenter

线程通过执行monitorenter指令尝试占用monitor对象的过程如下:

a:如果monitor的进入数为0,则该线程进入monitor,进入数+1,此时该线程占用monitor,即成为该monitor的所有者。

b:如果该线程已经占用monitor,只是重新进入,只需要monitor+1即可

c:如果其他线程已经占用monitor,则该线程进入阻塞状态,直到monitor进入数减为0,才会尝试去占用monitor.

1.2 monitorexit

执行monitorexit的线程必须是对应的monitor所有者线程。

执行monitorexit时,monitor进入数-1,直到进入数减为0,这个线程退出monitor,不再是这个monitor的所有者。其他被阻塞的线程将被唤醒参与monitor的竞争占用。

注意1:
可以看到上面反编译的结果里有两个monitorexit,第二个monitorexit其实是为了在出现异常时退出monitor使用的。编译器会确保无论方法通过何种方式完成,方法中调用过的每条 monitorenter 指令都会执行与其对应 monitorexit 指令,而无论这个方法是正常结束还是异常结束。为了保证在方法异常完成时 monitorenter 和 monitorexit 指令依然可以正确配对执行,编译器会自动产生一个异常处理器,这个异常处理器声明可处理所有的异常,它的目的就是用来执行 monitorexit 指令。从字节码中也可以看出多了一个monitorexit指令,它就是异常结束时被执行的释放monitor 的指令。

注意2:

synchronized依赖于monitor来实现同步,Object中的wait和notify其实也是依赖于monitor实现的,所以当不在同步块执行这两个方法时会报错:

java.lang.IllegalMonitorStateException

下图表现了对象,对象监视器,同步队列以及执行线程状态之间的关系:

java锁synchronized原理 java锁synchronized底层实现_synchronized底层实现_03


该图可以看出,任意线程对Object的访问,首先要获得Object的监视器,如果获取失败,该线程就进入同步队列,线程状态变为BLOCKED,当Object的监视器占有者释放后,在同步队列中得线程就会有机会重新获取该监视器。

1.2 synchronized修饰同步方法

1 package com.paddx.test.concurrent;
2 
3 public class SynchronizedMethod {
4     public synchronized void method() {
5         System.out.println("Hello World!");
6     }
7 }

编译成字节码;

java锁synchronized原理 java锁synchronized底层实现_JVM_04


 从字节码中可以看出,synchronized修饰的方法并没有monitorenter指令和monitorexit指令,取而代之的是ACC_SYNCHRONIZED标识,JVM通过该ACC_SYNCHRONIZED标志来辨别一个方法是否声明为同步方法.

 JVM将内存划分为了多个区域,其中有一个方法区,方法区用于存储被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码指令等数据。运行时常量池是方法区中的一部分。class文件中除了有类的字段、方法、接口等描述信息外,还存在常量池(运行时常量池),存放的是编译期生成的字面量和符号引用。

 同步方法是隐式的。一个同步方法会在运行时常量池中的method_info结构体中存放ACC_SYNCHRONIZED标识符。当一个线程访问方法时,会去检查是否存在ACC_SYNCHRONIZED标识,如果存在,则先要获得对应的monitor锁,然后执行方法。当方法执行结束(不管是正常return还是抛出异常)都会释放对应的monitor锁。如果此时有其他线程也想要访问这个方法时,会因得不到monitor锁而阻塞。当同步方法中抛出异常且方法内没有捕获,则在向外抛出时会先释放已获得的monitor锁

 

同步方法是隐式的,无需通过字节码指令来实现。一个同步方法会在运行时常量池中的method_info结构体中存放ACC_SYNCHRONIZED标识符。

这句话的解读是:我们从上面的反编译结果也可以看到,同步方法会在class文件中的access_flags中存放ACC_SYNCHRONIZED,那这句话为什么说在运行时常量池中呢?

答:我们看看ACC_SYNCHRONIZED在class文件中的位置如下:

java锁synchronized原理 java锁synchronized底层实现_java锁synchronized原理_05


可以看到ACC_SYNCHRONIZED标识存放在常量池中,而method_info结构体中的access_flags字段是u2的,所以它只是一个指向常量池的标记。而常量池在加载时就会加载到运行时常量池中。所以这里解释了为什么上面说ACC_SYNCHRONIZED在运行时常量池中,而看class文件是存放在access_flags中的道理。

1.3 总结

同步方法和同步代码块底层都是通过monitor来实现同步的。
两者的区别:同步方式是通过方法中的access_flags中设置ACC_SYNCHRONIZED标志来实现;同步代码块是通过monitorenter和monitorexit来实现
我们知道了每个对象都与一个monitor相关联。而monitor可以被线程拥有或释放。

二、 锁

java锁synchronized原理 java锁synchronized底层实现_无锁_06


我们知道JVM堆中存放的是对象实例。对象实例包括几个部分: 分别是与对象实例无关的对象头,实例数据,填充数据。

实例数据:存放类的属性数据信息,包括父类的属性信息。

填充数据:由于虚拟机要求对象起始地址必须是8字节的整数倍。填充数据不是必须存在的,仅仅是为了字节对齐。

对象头中包括几个部分:Java对象头一般占有2个机器码(在32位虚拟机中,1个机器码等于4字节,也就是32bit,在64位虚拟机中,1个机器码是8个字节,也就是64bit),但是如果对象是数组类型,则需要3个机器码,因为JVM虚拟机可以通过Java对象的元数据信息确定Java对象的大小,但是无法从数组的元数据来确认数组的大小,所以用一块来记录数组长度。

Mark Word,存储对象的hashCode、GC分代年龄以及锁信息

32位JVM的Mark Word的结构如表所示:

java锁synchronized原理 java锁synchronized底层实现_java锁synchronized原理_07


64位JVM的Mark Word的结构如表所示:

java锁synchronized原理 java锁synchronized底层实现_JVM_08

在多线程并发编程中,Synchronized关键字是元老级别的角色,很多人都称呼它为重量级锁。但是由于加锁是一个非常耗时的操作,并且对于锁的获取和释放也会带来极大的性能开销。那么Java SE 1.6中为了减少锁的释放和获取带来的性能开销而引入了偏向锁轻量级锁机制。

锁的状态总共有四种:无锁状态、偏向锁、轻量级锁和重量级锁。随着锁的竞争,锁可以从偏向锁升级到轻量级锁,再升级的重量级锁(但是锁的升级是单向的,也就是说只能从低到高升级,不会出现锁的降级)。JDK 1.6中默认是开启偏向锁和轻量级锁的,我们也可以通过-XX:-UseBiasedLocking来禁用偏向锁。锁的状态保存在对象头中。
在 Mark Word 中,固定使用最后2 bit 来存储当前对象的锁标记, 对象锁标志位的几种状态:

01 - 无锁状态 / 偏向锁状态

00 - 轻量级锁定

10 - 重量级锁定

11 - GC 标记

当锁标志位为 01 时,因无法区分是无锁状态还是偏向锁状态,因此从剩余空间中拿出1bit来标记是否是偏向锁状态。如果该位为 1 表明现在是处于偏向锁状态,如果是 0 表明是无锁状态。

java锁synchronized原理 java锁synchronized底层实现_synchronized底层实现_09

对象的锁处的状态,决定了markword存储的内容

java锁synchronized原理 java锁synchronized底层实现_JVM_10

2.1 无锁 无锁没有对资源进行锁定,所有的线程都能访问并修改同一个资源,但同时只有一个线程能修改成功。

无锁的特点就是修改操作在循环内进行,线程会不断的尝试修改共享资源。如果没有冲突就修改成功并退出,否则就会继续循环尝试。如果有多个线程修改同一个值,必定会有一个线程能修改成功,而其他修改失败的线程会不断重试直到修改成功。 CAS机制即是无锁的实现。无锁无法全面代替有锁,但无锁在某些场合下的性能是非常高的。

ava偏向锁(Biased Locking)是Java6引入的一项多线程优化。
偏向锁,顾名思义,它会偏向于第一个访问锁的线程,如果在运行过程中,同步锁只有一个线程访问,不存在多线程争用的情况,则线程是不需要触发同步的,减少加锁/解锁的一些CAS操作(比如等待队列的一些CAS操作),这种情况下,就会给线程加一个偏向锁。 如果在运行过程中,遇到了其他线程抢占锁,则持有偏向锁的线程会被挂起,JVM会消除它身上的偏向锁,将锁恢复到标准的轻量级锁。

它通过消除资源无竞争情况下的同步原语,进一步提高了程序的运行性能。

CAS为什么会引入本地延迟?这要从SMP(对称多处理器)架构说起,下图大概表明了SMP的结构:

java锁synchronized原理 java锁synchronized底层实现_synchronized底层实现_11

其意思是所有的CPU会共享一条系统总线(BUS),靠此总线连接主存。每个核都有自己的一级缓存,各核相对于BUS对称分布,因此这种结构称为“对称多处理器”。

而CAS的全称为Compare-And-Swap,是一条CPU的原子指令,其作用是让CPU比较后原子地更新某个位置的值,经过调查发现,其实现方式是基于硬件平台的汇编指令,就是说CAS是靠硬件实现的,JVM只是封装了汇编调用,那些AtomicInteger类便是使用了这些封装后的接口。

Core1和Core2可能会同时把主存中某个位置的值Load到自己的L1 Cache中,当Core1在自己的L1 Cache中修改这个位置的值时,会通过总线,使Core2中L1 Cache对应的值“失效”,而Core2一旦发现自己L1 Cache中的值失效(称为Cache命中缺失)则会通过总线从内存中加载该地址最新的值,大家通过总线的来回通信称为“Cache一致性流量”,因为总线被设计为固定的“通信能力”,如果Cache一致性流量过大,总线将成为瓶颈。而当Core1和Core2中的值再次一致时,称为“Cache一致性”,从这个层面来说,锁设计的终极目标便是减少Cache一致性流量。

而CAS恰好会导致Cache一致性流量,如果有很多线程都共享同一个对象,当某个Core CAS成功时必然会引起总线风暴,这就是所谓的本地延迟,本质上偏向锁就是为了消除CAS,降低Cache一致性流量。

2.2 偏向锁的实现

偏向锁获取过程:

线程获得偏向锁的前提条件是当前对象处于可获取偏向锁的状态,什么意思呢,就是说当前对象的锁状态标志位为 01,并且偏向锁标志位为 1,如果偏向锁标志位为 0,则线程无法获得偏向锁,只能获取轻量级锁。当线程获取锁时,如果发现对象处于可获取偏向锁的状态,会首先查看对象的 Mark Word 中是否保存了当前线程的 ID,如果发现保存了当前线程 ID,说明当前线程已经获得了偏向锁,那么就直接执行同步块中的代码。如果发现没有保存当前线程 ID,那么尝试使用 CAS 操作将当前线程 ID 写入 Mark Word 中,我们知道如果没有其他线程获得偏向锁,那么 CAS 操作就执行成功,如果发现不是 ,说明之前已经有线程获取了偏向锁,证明系统中至少有两个线程在执行,那么此时就要撤销偏向锁。所以我们说线程获取了偏向锁,就等同于下面两个条件都成立:
当前对象处于可获取锁的状态,锁状态标志位为 01,并且偏向锁标志位为 1
线程通过 CAS 操作成功将线程 ID 写入 Mark Word 中
所以我们说只有第一个访问锁对象的线程才有机会获得对象的偏向锁。偏向锁是不会主动释放的,只有出现了竞争才会释放偏向锁。
如果CAS获取偏向锁失败,则表示有竞争。当到达全局安全点(safepoint,在这个时间点上没有正在执行的字节码)时获得偏向锁的线程被挂起,偏向锁升级为轻量级锁,然后被阻塞在安全点的线程继续往下执行同步代码。(撤销偏向锁的时候会导致stop the word)

偏向锁的释放:

偏向锁释放分为两种情况,如果获取偏向锁的线程不处于运行状态,就将对象置为无锁状态(偏向锁标志位置为 0,线程 ID 置为空),此时另外一个线程只能尝试获取线程的轻量级锁。如果获取偏向锁的线程正在执行,会挂起运行线程,然后将对象置为轻量级锁状态(锁状态标志位置为 00),随后再恢复挂起的线程,将偏向锁升级为轻量级锁,此时另外一个线程可以通过自旋尝试获得锁,当自旋到一定次数仍然获取不到轻量级锁,对象锁就会由轻量级锁升级为重量级锁,获取不到锁的线程就会被阻塞。

java锁synchronized原理 java锁synchronized底层实现_JVM_12


唤醒的线程参与轻量级锁的竞争,按偏向锁获取逻辑处理。 下图线程1展示了偏向锁获取的过程,线程2展示了偏向锁撤销的过程。

java锁synchronized原理 java锁synchronized底层实现_synchronized底层实现_13


偏向锁的适用场景

始终只有一个线程在执行同步块,在它没有执行完释放锁之前,没有其它线程去执行同步块,在锁无竞争的情况下使用,一旦有了竞争就升级为轻量级锁,升级为轻量级锁的时候需要撤销偏向锁,撤销偏向锁的时候会导致stop the word操作;
在有锁的竞争时,偏向锁会多做很多额外操作,尤其是撤销偏向锁的时候会导致进入安全点,安全点会导致stw,导致性能下降,这种情况下应当禁用;
jvm开启/关闭偏向锁

开启偏向锁:-XX:+UseBiasedLocking -XX:BiasedLockingStartupDelay=0
关闭偏向锁:-XX:-UseBiasedLocking

3.3 轻量级锁 获取

轻量级锁通过 CAS 操作进行同步,适用于有多个线程执行但不存在竞争或者竞争很少的情况。
当代码执行到同步块时,发现对象处于无锁状态(锁状态标志位为 01,偏向锁标志位为 0),那么虚拟机就首先在当前线程的栈中创建一个名为锁记录(Lock Record)的空间,用于存储对象目前的 Mark Word 的拷贝(官方为这份拷贝加了一个 Displaced 前缀,即 Displaced Mark Word,这里保存拷贝是为了释放锁时将数据还原回来),然后虚拟机使用 CAS 操作将对象的 Mark Word 更新为指向 Lock Record 的指针(CAS 保证了即便多个线程竞争,也只有一个能更新成功),如果更新动作成功了,那么当前线程就获取到了该对象的锁,同时会将 Mark Word 中的锁标志位置为 00。所以,我们说线程获取到了轻量级锁,就等同于下面条件成立:

当前线程成功将 Mark Word 中的内容(除了最后两位)修改为了指向线程堆栈中锁记录的指针

如果线程更新 Mark Word 失败,虚拟机首先会检查对象的Mark Word是否指向当前线程的栈帧,如果是就说明当前线程已经拥有了这个对象的锁,那就可以直接进入同步块继续执行。否则说明多个线程竞争锁会尝试自旋来获得锁(就是执行循环,不断尝试获取的锁),当自旋获取锁失败了之后,对象锁就由轻量级锁升级为重量级锁,失败线程就会被阻塞。同时,Mark Word 的记录值会修改为指向互斥量(重量级锁)的指针,Mark Word 的锁记录标志位也会置为 10.

释放

轻量级的解锁过程也是通过 CAS 操作来完成的,如果对象的 Mark Word 中仍然指向当前线程堆栈中的 Lock Record,就使用 CAS 操作将 Mark Word 的备份值复制回去,如果复制成功,就将锁状态的标志位置为 01,变为无锁状态,如果复制失败,说明当前的轻量级锁已经膨胀为重量级锁了,那么在释放锁的同时,要唤醒正在等待的线程。

java锁synchronized原理 java锁synchronized底层实现_Word_14

CPU,为了避免无用的自旋(比如获得锁的线程被阻塞住了),一旦锁升级成重量级锁,就不会再恢复到轻量级锁状态。当锁处于这个状态下,其他线程试图获取锁时,都会被阻塞住,当持有锁的线程释放锁之后会唤醒这些线程,被唤醒的线程就会进行新一轮的夺锁之争。

java锁synchronized原理 java锁synchronized底层实现_Word_15


2.4 重量级锁 重量级锁时:存储的是指向重量级锁的指针。

在Java中是ObjectMonitor(JVM源码中C++实现)来实现。

我们这里就说一下ObjectMonitor中几个关键字段的含义:

_count:记录owner线程获取锁的次数。这句话很好理解,这也决定了synchronized是可重入的。
_owner:指向拥有该对象的线程
_WaitSet:存放处于wait状态的线程队列。
_EntryList:存放等待锁而被block的线程队列。

_count和_owner很好理解,后面两个队列

我们举个例子来说明:假设我们写出如下代码:

java锁synchronized原理 java锁synchronized底层实现_synchronized底层实现_16

java锁synchronized原理 java锁synchronized底层实现_无锁_17


image.png想要获取monitor的线程先进入monitor的_EntryList队列阻塞等待。即遇到synchronized关键字时。

如果monitor的_owner为空,则从队列中移出并赋值与_owner。

如果在程序里调用了wait()方法,则该线程进入_WaitSet队列。注意wait方法我们之前讲过,它会释放monitor锁,即将_owner赋值为null并进入_WaitSet队列阻塞等待。这时其他在_EntryList中的线程就可以获取锁了。

当程序里其他线程调用了notify/notifyAll方法时,就会唤醒_WaitSet中的某个线程,这个线程就会再次尝试获取monitor锁。如果成功,则就会成为monitor的owner。

当程序里遇到synchronized关键字的作用范围结束时,就会将monitor的owner设为null,退出。

java锁synchronized原理 java锁synchronized底层实现_synchronized底层实现_18


它有多个队列,当多个线程一起访问某个对象监视器的时候,对象监视器会将这些线程存储在不同的容器中。Contention List:竞争队列,所有请求锁的线程首先被放在这个竞争队列中;Entry List:Contention List中那些有资格成为候选资源的线程被移动到Entry List中;Wait Set:哪些调用wait方法被阻塞的线程被放置在这里;OnDeck:任意时刻,最多只有一个线程正在竞争锁资源,该线程被成为OnDeck;Owner:当前已经获取到所资源的线程被称为Owner;!Owner:当前释放锁的线程。JVM每次从队列的尾部取出一个数据用于锁竞争候选者(OnDeck),但是并发情况下,ContentionList会被大量的并发线程进行CAS访问,为了降低对尾部元素的竞争,JVM会将一部分线程移动到EntryList中作为候选竞争线程。Owner线程会在unlock时,将ContentionList中的部分线程迁移到EntryList中,并指定EntryList中的某个线程为OnDeck线程(一般是最先进去的那个线程)。Owner线程并不直接把锁传递给OnDeck线程,而是把锁竞争的权利交给OnDeck,OnDeck需要重新竞争锁。这样虽然牺牲了一些公平性,但是能极大的提升系统的吞吐量,在JVM中,也把这种选择行为称之为“竞争切换”。OnDeck线程获取到锁资源后会变为Owner线程,而没有得到锁资源的仍然停留在EntryList中。如果Owner线程被wait方法阻塞,则转移到WaitSet队列中,直到某个时刻通过notify或者notifyAll唤醒,会重新进去EntryList中。处于ContentionList、EntryList、WaitSet中的线程都处于阻塞状态,该阻塞是由操作系统来完成的(Linux内核下采用pthread_mutex_lock内核函数实现的)。Synchronized是非公平锁。Synchronized在线程进入ContentionList时,等待的线程会先尝试自旋获取锁,如果获取不到就进入ContentionList,这明显对于已经进入队列的线程是不公平的,还有一个不公平的事情就是自旋获取锁的线程还可能直接抢占OnDeck线程的锁资源。2.5 对比

java锁synchronized原理 java锁synchronized底层实现_java锁synchronized原理_19


通俗来讲:

偏向锁:适应仅一个线程进入临界区

轻量级锁:多个线程交替进入临界区

重量级锁:多个线程同时进入临界区

monitor起到同步的作用,竞争monitor锁时只能有一个线程
获取成功。1.6版本后,并不是说获取了monitor锁就是获得重量级锁,而是竞争monitor时,判断锁的标志是处于哪种状态,比如处于轻量级锁时,其他线程通过自旋竞争锁,当处于重量级锁状态时,其他线程挂起,阻塞。monitor是从偏向锁到重量锁的升级过程,当处于偏向锁或轻量级锁时,其他线程不挂起,从而减少线程挂起、激活导致的线程状态切换、上下文切换,提升性能。