前两篇文章已经介绍了多线程以及 JMM,我们说过多线程面对的安全问题体现在原子性 可见性 重排序三个问题上。Synchronized 就是 Java 为我们提供的解决线程安全问题的一把锁。
以前我们都叫它重量级锁,是因为以前它的性能相比与其他锁要差很多,而且非常笨重。但是随着 JDK 1.6 中对 Synchronized 做了优化,它现在的性能已经非常不错了。
Synchronized 是一把对象锁,对象锁的意思是 JVM 中的每一个对象都有这把锁,同一时刻只有一个线程能够拥有某个对象的对象锁。某个线程获取这个对象锁之后,会被标记在这个对象的对象头中。
虽然线程被标记在了对象头中,我们依然阻止不了多个线程对某段代码的访问啊?JVM 是怎么实现的那?
JVM 通过监视器(Monitors) 来保证线程安全,监视器的主要功能就是监视一段代码,确保在同一时刻只有一个线程能执行这段代码。每个监视器都和一个对象关联,通过获取对象的锁来决定是否能进入监视器监视的代码。
总结:Synchronzied 是一种对象锁,作用粒度是对象,可以用来实现对临界资源的互斥访问。是可重入的(避免死锁)。
Synchronized 可以用来修饰实例方法,静待方法,代码块,但是有一点需要记住,无论修饰什么,synchronized 锁住的永远是对象,只不过修饰实例方法,静态方法,代码块时锁住的对象不同罢了。
Synchronized 修饰实例方法,此时锁住的对象就是实例对象 this,也就是调用这个方法的实例对象。
public synchronized void method1(){
//需要同步的代码
System.out.println("method1");
}
Synchronized 修饰代码块,此时锁住的对象就是括号括起来的对象实例。
// 锁住的对象是 this,也就是调用这个方法的实例对象。
public void method2(){
synchronized(this){
//需要同步的代码
System.out.println("method2");
}
}
// 锁住的对象就是 Object 类的实例 lock。
Object lock = new Object();
public void method4(){
synchronized(lock){
//需要同步的代码
System.out.println("method4");
}
}
Synchronized 修饰静态方法,锁住的是 类名.class 这个实例对象(JVM 中的类加载器会为每一个类产生一个 Class 类的实例对象,用来表示这个类,该类的所有实例都共同拥有着这个 class 的对象,而且是唯一的)。
public class Test1 {
public static void method3(){
//需要同步的代码
System.out.println("method3");
}
}
//该类的所有实例都共同拥有着这个 class 的对象,所以所有实例都会去争抢 类名.class 这个实例对象的锁。
我们知道了 Synchronized 锁到底是锁住了哪些对象,那么我们来看看到底 Synchronized 底层是怎么实现同步的。答案就是在硬件层面的特殊的 CPU 指令。
我们来看看下面这段代码反编译后的结果。
package com.paddx.test.concurrent;
public class SynchronizedDemo {
public void method() {
synchronized (this) {
System.out.println("Method 1 start");
}
}
}
结果:

monitorenter:每个对象都是一个监视器锁(monitor)。当 monitor 被占用时就会处于锁定状态。线程执行 monitorenter 时尝试获得 monitor 的所有权。
monitorexist:执行 monitorexist 必须时锁住的对象的对象头里面记录的那个线程。指令执行时,monitor 的进入数减 1,如果减 1 后为 0,那么线程可以退出 monitor,不再是这个 monitor 的所有者。其他 monitor 阻塞的线程可以尝试去获得 monitor 的所有权。
其实 wait/notify 方法也依赖于 monitor 对象,这就是为什么 wait/notify 一定要在同步方法中调用的原因。
再来看一下同步方法
package com.paddx.test.concurrent;
public class SynchronizedMethod {
public synchronized void method() {
System.out.println("Hello World!");
}
}
反编译后的结果:

从编译后的结果来看,同步方法并没有通过指令 monitorenter 和 monitorexist 来完成。相比于普通方法,常量池中多了 ACC_SYNCHRONIZED标示符。同步方法就是根据该标示符来实现同步的。
当方法执行时,指令会先检查 ACC_SYNCHRONIZED标示符是否被设置了,如果设置了,执行线程将先获得 monitor,获取成功之后在执行方法体,方法执行完之后再释放 monitor。
上面我们说过,获取锁的线程的信息会被记录再对象头中。对象在内存中分为三块区域:对象头,实例数据和对齐数据。

Hotspot 虚拟机的对象头主要包括:Mark Word(用于存储对象自身的运行时数据),ClassPointer(类型指针,虚拟机通过这个指针来确定这个对象是哪个类的实例)。对象头的具体记录信息如下:

Mark Word 用于存储对象自身的运行时数据,如 :哈希码,GC 分代年龄,所状态标志,线程持有的锁,偏向线程 ID,偏向时间戳等等。32 位虚拟机无锁状态下存储如下:

Mark Word 会随着程序的运行发生变化,可能变化为存储以下四种数据:

64 位虚拟机的存储结构如下:

对象头最后两位存储了锁的标志位,01 是初始状态,未加锁,其对象头里面存储的是对象本身的哈希码,随着锁级别的不同,会存储不同的内容。偏向锁存储的是当前占用此对象的线程的 ID,轻量级锁则存储指向线程中锁记录的指针。所以锁这个东西,可能是栈中的锁记录,也可能是互斥量的指针,也可能是对象头里面的线程 ID。
当线程进入同步代码块的时候,如果此时同步对象没有被锁定。那么虚拟机首先在当前线程的栈中创建我们称之为 锁记录(Lock Record) 的空间,用于存储锁对象的 Mark Word 的拷贝,官方称之为 Displaced Mark Word
。整个 Mark Word 及其拷贝至关重要。
Lock Record 是线程私有的数据结构。每一个被锁住的对象的 Mark Word 都会和一个 Lock Record 关联,同时 Lock Record 中有一个 Owner 字段存放拥有该锁的线程的唯一标识,表示该锁被这个线程占用。
| Lock Record | 描述 |
|---|---|
| Owner | 初始时为NULL表示当前没有任何线程拥有该monitor record,当线程成功拥有该锁后保存线程唯一标识,当锁被释放时又设置为NULL; |
| EntryQ | 关联一个系统互斥锁(semaphore),阻塞所有试图锁住monitor record失败的线程; |
| RcThis | ** 表示blocked或waiting在该monitor record上的所有线程的个数**; |
| Nest | 用来实现 重入锁的计数; |
| HashCode | 保存从对象头拷贝过来的HashCode值(可能还包含GC age)。 |
| Candidate | 用来避免不必要的阻塞或等待线程唤醒,因为每一次只有一个线程能够成功拥有锁,如果每次前一个释放锁的线程唤醒所有正在阻塞或等待的线程,会引起不必要的上下文切换(从阻塞到就绪然后因为竞争锁失败又被阻塞)从而导致性能严重下降。Candidate只有两种可能的值0表示没有需要唤醒的线程1表示要唤醒一个继任线程来竞争锁。 |
任何一个对象都有一个 Monitor 监视器与之关联,当一个 Monitor 被持有后,它将处于锁定状态。Syncronized 在 JVM 里的实现都是基于进入和退出 Monitor 对象来实现方法同步和代码块同步。
Monitor 可以理解为一种同步工具,也可以描述为一种同步机制,它通常被描述为一个对象。通常说 Synchronized 的对象锁,MarkWord 锁标识位为 10,其中指针指向的是 Monitor 对象的起始位置。
从 JDK 1.6 开始,对 Synchronized 的实现机制进行了较大调整,包括使用了 JDK5 引进的 CAS 自旋还增加了自适应自旋,锁消除,锁粗化,偏向锁,轻量级锁这些优化策略。使得性能得到了极大的提升。
锁主要存在四种状态,无锁,偏向锁,轻量级锁,重量级锁。锁可以低向高升级,但是不能降级。
线程的阻塞和唤醒需要 CPU 从用户态转为内核态,频繁的阻塞和唤醒对 CPU 来说是一件负担很重的工作。所以引入了自旋锁。
自旋锁,就是当一个线程尝试获取某个锁时,如果该锁已经被其他线程占用,就一直循环检测锁是否被释放,而不是进入阻塞状态。
自旋锁适用于锁保护的临界区很小的情况,临界区很小的话,锁占用的时间就很短。自旋等待不能替代阻塞,虽然它可以避免线程切换带来的开销,但是它占用了CPU处理器的时间。如果持有锁的线程很快就释放了锁,那么自旋的效率就非常好,反之,自旋的线程就会白白消耗掉处理的资源。
JDK 1.6 引入了更聪明的自旋锁,即自适应自旋锁。自适应的意思时自旋的次数不再是固定的,它是由前一次在同一个锁上的自旋时间以及拥有者的状态来决定的。
有些情况下,JVM 检测到不可能存在共享数据竞争,这时候 JVM 会对这些同步锁进行清楚。
锁清除的依据是逃逸分析的数据支持。
public void vectorTest(){
Vector<String> vector = new Vector<String>();
for(int i = 0 ; i < 10 ; i++){
vector.add(i + "");
}
System.out.println(vector);
}
JVM 可以明显检测到变量 vector 没有逃逸出方法 vectorTest 之外,所以 JVM 可以大胆地将 vector 内部的锁消除。
在使用同步锁的时候,需要让代码块的范围尽可能小,这样能够减少竞争。但是如果一系列的连续加锁和解锁操作,可能会导致不必要的性能损耗,所以引入锁粗化的概念。
锁粗化就是将多个连续的加锁,解锁操作连接在一起,扩展成一个范围更大的锁。
在大多数情况下,锁不仅不存在所线程金正,而且总是由同一个线程多次获得。为了让同一个线程多次获得锁的代价降低,引入了偏向锁。
偏向锁的释放采用了只有竞争才会释放锁的机制,线程是不会主动释放偏向锁的,需要等待其他线程来竞争。
引入轻量级锁的主要目的是在没有很多线程竞争的前提下,减少传统重量级锁使用系统互斥量产生的性能消耗。
偏向锁升级为轻量级锁的过程:
虚拟机首先在线程的栈中建立 Lock Record。
将对象头中的 Mark Word 复制过去。

虚拟机使用 CAS 操作尝试将 Mark Word 中的 Lock Word 更新为指向当前线程 Lock Record, 将 Lock Record 里面的 Owner 指向 object mark word。
如果这个更新动作成功了,那么当前线程就拥有了该对象的锁,并且对象Mark Word的锁标志位设置为“00”,即表示此对象处于轻量级锁定状态。

如果这个更新操作失败了,虚拟机首先会检查对象Mark Word中的Lock Word是否指向当前线程的栈帧,如果是,就说明当前线程已经拥有了这个对象的锁,那就可以直接进入同步块继续执行。否则说明多个线程竞争锁,进入自旋执行(3),若自旋结束时仍未获得锁,轻量级锁就要膨胀为重量级锁,锁标志的状态值变为“10”,Mark Word中存储的就是指向重量级锁(互斥量)的指针,当前线程以及后面等待锁的线程也要进入阻塞状态。
总结:
通过CAS操作尝试把线程中复制的Displaced Mark Word对象替换当前的Mark Word;
如果替换成功,整个同步过程就完成了,恢复到无锁状态(01);
如果替换失败,说明有其他线程尝试过获取该锁(此时锁已膨胀),那就要在释放锁的同时,唤醒被挂起的线程;
Synchronized是通过对象内部的一个叫做 监视器锁(Monitor)来实现的。但是监视器锁本质又是依赖于底层的操作系统的Mutex Lock来实现的。而操作系统实现线程之间的切换这就需要从用户态转换到核心态,这个成本非常高,状态之间的转换需要相对比较长的时间,这就是为什么Synchronized效率低的原因。因此,这种依赖于操作系统Mutex Lock所实现的锁我们称之为 “重量级锁”。

当线程1访问代码块并获取锁对象时,会在 Java 对象头和栈帧中记录偏向的锁的 threadID。因为偏向锁不会主动释放,所以当线程1在此想获取锁的时候,返现 threadID 一致,则无需使用 CAS 来加锁,解锁。如果不一致,线程2需要竞争锁,偏向锁不会主动释放里面还是存储线程1的 threadID。如果线程1没有存活,那么锁对象被重置为无锁状态,其他线程竞争并将其设置为偏向锁。如果线程1 还存活,那么查看线程1是否需要持有锁,如果需要,升级为轻量级锁。如果不需要设置为无锁状态,重新偏向。
轻量级锁考虑的是竞争锁对象的线程不多,而且线程持有锁的时间也不长的情景,为了避免阻塞线程让 CPU 从用户态转到内核态和代价,干脆不阻塞线程,直接让它自旋等待锁释放。线程1获取轻量级锁时会先把锁对象的对象头复制到自己的锁记录空间,然后使用 CAS 替换对象头的内容。如果线程1复制对象头的同时,线程2也准备获取锁,但是线程2在 CAS 的时候失败,自旋,等待线程1释放锁。如果自旋到了次数线程1还没有释放锁,或者线程1在执行,线程2在自旋等待,这时3有来竞争,这个轻量级锁会膨胀为重量级锁,重量级锁把所有拥有锁的线程都阻塞,防止 CPU 空转。
原文:https://www.cnblogs.com/paulwang92115/p/12157819.html