Java 多线程与并发:Synchronized

Posted 当年明月

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Java 多线程与并发:Synchronized相关的知识,希望对你有一定的参考价值。

前两篇文章已经介绍了多线程以及 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 的所有权。

  1. 如果monitor的进入数为0,则该线程进入monitor,然后将进入数设置为1,该线程即为monitor的所有者;
  2. 如果线程已经占有该monitor,只是重新进入,则进入monitor的进入数加1;
  3. 如果其他线程已经占用了monitor,则该线程进入阻塞状态,直到monitor的进入数为0,再重新尝试获取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。

概念补充

Java 对象头

上面我们说过,获取锁的线程的信息会被记录再对象头中。对象在内存中分为三块区域:对象头,实例数据和对齐数据

  1. 实例数据:存放类的属性数据信息,包括父类的属性信息。
  2. 对齐填充:由于虚拟机要求对象其实地址必须是8字节的整数倍,所以会有数据填充。
  3. 对象头:Java 对象头一般占 2 个机器码。如果是数组,则需要三个(多出一个用来记录数组长度)。

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

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

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

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

对象头最后两位存储了锁的标志位,01 是初始状态,未加锁,其对象头里面存储的是对象本身的哈希码,随着锁级别的不同,会存储不同的内容。偏向锁存储的是当前占用此对象的线程的 ID,轻量级锁则存储指向线程中锁记录的指针。所以锁这个东西,可能是栈中的锁记录,也可能是互斥量的指针,也可能是对象头里面的线程 ID。

对象头中的 Mark Word 与线程中的 Lock Record

当线程进入同步代码块的时候,如果此时同步对象没有被锁定。那么虚拟机首先在当前线程的栈中创建我们称之为 锁记录(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 内部的锁消除。

锁粗化

在使用同步锁的时候,需要让代码块的范围尽可能小,这样能够减少竞争。但是如果一系列的连续加锁和解锁操作,可能会导致不必要的性能损耗,所以引入锁粗化的概念。

锁粗化就是将多个连续的加锁,解锁操作连接在一起,扩展成一个范围更大的锁。

偏向锁

在大多数情况下,锁不仅不存在所线程金正,而且总是由同一个线程多次获得。为了让同一个线程多次获得锁的代价降低,引入了偏向锁。

偏向锁的释放采用了只有竞争才会释放锁的机制,线程是不会主动释放偏向锁的,需要等待其他线程来竞争

轻量级锁

引入轻量级锁的主要目的是在没有很多线程竞争的前提下,减少传统重量级锁使用系统互斥量产生的性能消耗

偏向锁升级为轻量级锁的过程:

  1. 虚拟机首先在线程的栈中建立 Lock Record。

  2. 将对象头中的 Mark Word 复制过去。

  3. 虚拟机使用 CAS 操作尝试将 Mark Word 中的 Lock Word 更新为指向当前线程 Lock Record, 将 Lock Record 里面的 Owner 指向 object mark word。

  4. 如果这个更新动作成功了,那么当前线程就拥有了该对象的锁,并且对象Mark Word的锁标志位设置为“00”,即表示此对象处于轻量级锁定状态

  5. 如果这个更新操作失败了,虚拟机首先会检查对象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 空转。

以上是关于Java 多线程与并发:Synchronized的主要内容,如果未能解决你的问题,请参考以下文章

Java 高并发与多线程;:synchronized 关键字的实现原理

并发编程CAS与synchronized

Java多线程编程——对象及变量的并发访问

Java多线程编程——对象及变量的并发访问

Java多线程编程对象及变量的并发访问

漫画 | Java多线程与并发