Synchronized关键字

"Java core knowledge"

Posted by Ming on September 17, 2019

“There are no secrets to success. It is the result of preparation, hard work and learning from failure.”

1. 基本概述

在开始讲synchronized关键字之前,先补充一下关于Thread的几个重要方法:

  • start()方法:调用该方法开始执行该线程;
  • stop()方法:调用该方法强制结束该线程执行;
  • join()方法: 调用该方法等待该线程结束;
  • sleep()方法: 调用该方法该线程进入等待;
  • run()方法: 调用该方法直接执行线程的run()方法,但是线程调用start()方法也会运行run()方法,区别就是一个是由线程调度运行run()方法,一个是直接调用了线程中的run()方法(和普通对象调用方法一样)。

此外,经常混淆wait()和notify()方法,其实它们是Object类的方法,不是Thread的方法。同时,wait()和notify()方法配合使用,分别表示线程挂起和线程恢复。(补充:wait()和sleep()的区别,简单来说wait()会释放对象锁而sleep()不会释放对象锁)

最后,补充一下锁所拥有的类型:

  • 可重入锁:在执行对象中所有同步方法不用再次获得锁
  • 可中断锁:在等待获取锁过程中可中断
  • 公平锁:按等待获取锁的线程的等待时间进行获取,等待时间长的具有优先获取锁权利
  • 读写锁: 对资源读取和写入的时候拆分为2部分处理,读的时候可以多线程一起读,写的时候必须同步地写

synchronized锁什么?锁对象。锁的对象包括:this, 临界资源对象, Class类对象。其同步的三种用法:

  1. 同步实例方法,锁的是当前实例对象;
  2. 同步类方法,锁的是当前类对象;
  3. 同步代码块,锁的是括号里面的对象。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
public class SynchronizedTest {

    /**
     * 同步实例方法,锁实例对象
     */
    public synchronized void test() {
    }

    /**
     * 同步类方法,锁类对象
     */
    public synchronized static void test1() {
    }

    /**
     * 同步代码块
     */
    public void test2() {
        // 锁类对象
        synchronized (SynchronizedTest.class) {
            // 锁实例对象
            synchronized (this) {

            }
        }
    }
}

2. synchronized实现原理

synchronized可以保证方法或者代码块在运行时,同一个时刻只有一个线程可以进入到临界区(原子性),同时它还可以保证共享变量的内存可见性。因为 synchronized 无论是同步的方法还是同步的代码块,都会先把主内存的数据拷贝到工作内存中,同步代码块结束,会把工作内存中的数据更新到主内存中,这样主内存中的数据一定是最新的。更重要的是禁用了乱序重组以及保证了值对存储器的写入,这样就可以保证可见性。

利用javap -verbose来查看上述示例的class文件信息:

synchronized案例分析

分析一下上图结果:

  • 同步方法:方法级同步没有通过字节码指令来控制,它实现在方法调用和返回操作之中。当方法调用时,调用指定会检查方法ACC_SYNCHRONIZED访问标志是否被设置,若设置了则执行线程需要持有管程(Monitor,或称为监视器锁)才能运行方法,当方法完成(无论是否出现异常)时释放管程。
  • 同步代码块: synchronized关键字经过编译后,会在同步块的前后分别形成monitorenter和monitorexit两个字节码指令,每条monitorenter指令都必须执行其对应的monitorexit指令,为了保证方法异常完成时这两条指令依然能够正确执行,编译器会自动生成一个异常处理器,其目的就是为了执行monitorexit指令(图中14-18、24-30为异常流程)。

下面我们继续来分析,但在深入了解之前我们需要了解两个重要的概念:Java对象头,Monitor.

Java对象头

首先,我们来看一下对象内存的简图: 对象内存简图

  • 对象头:其包含两部分Mark Word和Klass Pointer(类型指针)。Mark word用于存储对象自身的运行时数据,如Hashcode、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID等等;Klass Point是对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。(Mark World 是实现轻量级锁和偏向锁的关键,下面会重点阐述)。
  • 实例变量:存放类的属性数据信息,包括父类的属性信息。
  • 填充数据:由于虚拟机要求对象起始地址必须是8字节的整数倍。填充数据不是必须存在的,仅仅是为了字节对齐。

Mark World用于存储对象自身的运行时数据,如如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程 ID、偏向时间戳等等。Java对象头一般占有两个机器码(在32位虚拟机中,1个机器码等于4字节,也就是32bit),但是如果对象是数组类型,则需要三个机器码,因为JVM虚拟机可以通过Java对象的元数据信息确定Java对象的大小,但是无法从数组的元数据来确认数组的大小,所以用一块来记录数组长度。下图是Java对象头的存储结构(32位虚拟机):

Java对象头的存储结构

对象头信息是与对象自身定义的数据无关的额外存储成本,但是考虑到虚拟机的空间效率,Mark Word被设计成一个非固定的数据结构以便在极小的空间内存存储尽量多的数据,它会根据对象的状态复用自己的存储空间,也就是说,Mark Word会随着程序的运行发生变化,变化状态如下(32位虚拟机):

变化状态

Monitor可以把它理解为一个同步工具,也可以描述为一种同步机制,它通常被描述为一个对象。与一切解释对象一样,所有的Java对象是天生的Mointor(换句话说,每个Java对象都有成为Monitor的潜质),因为在Java的设计中,每一个对象自打娘胎里出来就带了一把看不见的锁,它叫做内部锁或者Monitor锁。每个对象都拥有自己的监视器,当这个对象由同步块或者这个对象的同步方法调用时,执行方法的线程必须先获取该对象的监视器才能进入同步块和同步方法,如果没有获取到监视器的线程将会被阻塞在同步块和同步方法的入口处,进入到BLOCKED状态,如图:

Monitor状态图

Monitor是线程私有的数据结构,每一个线程都有一个可用的monitor record列表,同时还有一个全局的可用列表。每一个被锁住的对象都会和一个monitor关联(对象头的MarkWord中的LockWord指向monitor的起始地址),同时monitor中有一个Owner字段用来存放拥有该锁的线程的唯一标识,表示该锁被这个线程占用。其结构如下所示:

Monitor结构

  • Owner: 初始时为NULL表示当前没有任何线程拥有该monitor record,当线程成功拥有该锁后保存线程唯一标识,当锁被释放时又设置为NULL;
  • EntryQ: 关联一个系统互斥锁(semaphore),阻塞所有试图锁住monitor record的线程;
  • RcThis: 表示blocked或waiting在该monitor record上的所有线程的个数;
  • Nest: 用来实现重入锁的计数;
  • HashCode: 保存从对象头拷贝过来的HashCode值(可能还包含GC age);
  • Candidate: 用来避免不必要的阻塞或等待线程唤醒,因为每一次只有一个线程能够成功拥有锁,如果每次前一个释放锁的线程唤醒所有正在阻塞或等待的线程,会引起不必要的上下文切换(从阻塞到就绪然后又因为竞争锁失败又被阻塞)从而导致性能严重下降。Candidate只有两种可能的值,0表示没有需要唤醒的线程,1表示要唤醒一个继任线程来竞争锁。

我们知道synchronized是重量级锁,效率不怎么滴,同时这个观念也一直存在我们脑海里,不过在jdk 1.6中对synchronize的实现进行了各种优化,使得它显得不是那么重了,那么JVM采用了那些优化手段呢?

锁优化

jdk1.6对锁的实现引入了大量的优化,如自旋锁、适应性自旋锁、锁消除、锁粗化、偏向锁、轻量级锁等技术来减少锁操作的开销。锁主要存在四种状态,依次是:无锁状态、偏向锁状态、轻量级锁状态、重量级锁状态,他们会随着竞争的激烈而逐渐升级。优化主要解决三种场景:

  1. 只有一个线程进入临界区,偏向锁
  2. 多线程未竞争,轻量级锁
  3. 多线程竞争,重量级锁

偏向锁->轻量级锁->重量级锁过程(注意锁可以升级不可降级,这种策略是为了提高获得锁和释放锁的效率)。

1.自旋锁

线程的阻塞和唤醒需要CPU从用户态转为核心态,频繁的阻塞和唤醒对CPU来说是一件负担很重的事情,势必会给系统的并发性能带来很大的压力。同时我们发现在许多应用上面,对象锁的锁状态只会持续很短一段时间,为了这一段很短的时间频繁地阻塞和唤醒线程是非常不值得的。所以引入自旋锁。

自旋锁,就是让线程等待一段时间,不会被立即挂起,看持有锁的线程是否会很快释放锁。怎么等待?就是执行一段无意义的循环即可(自旋)。

自旋等待不能替代阻塞,先不说对处理器数量的要求(多核,貌似现在没有单核的处理器了),虽然它可以避免线程切换带来的开销,但是它占用了处理器的时间。如果持有锁的线程很快就释放了锁,那么自旋的效率就非常好,反之,自旋的线程就会白白消耗掉处理的资源,它不会做任何有意义的工作,典型的占着茅坑不拉屎,这样反而会带来性能上的浪费。所以说,自旋等待的时间(自旋的次数)必须要有一个限度,如果自旋超过了定义的时间仍然没有获取到锁,则应该被挂起。

自旋锁在JDK 1.4.2中引入,默认关闭,但是可以使用-XX:+UseSpinning开启,在JDK1.6中默认开启。同时自旋的默认次数为10次,可以通过参数-XX:PreBlockSpin来调整。

如果通过参数-XX:PreBlockSpin来调整自旋锁的自旋次数,会带来诸多不便。假如我将参数调整为10,但是系统很多线程都是等你刚刚退出的时候就释放了锁(假如你多自旋一两次就可以获取锁),你是不是很尴尬。于是JDK1.6引入自适应的自旋锁,让虚拟机变得越来越聪明。

2.自适应自旋锁

JDK 1.6引入了更加聪明的自旋锁,即自适应自旋锁。所谓自适应就意味着自旋的次数不再是固定的,它是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。它怎么做呢?线程如果自旋成功了,那么下次自旋的次数会更加多,因为虚拟机认为既然上次成功了,那么此次自旋也很有可能会再次成功,那么它就会允许自旋等待持续的次数更多。反之,如果对于某个锁,很少有自旋能够成功的,那么在以后要或者这个锁的时候自旋的次数会减少甚至省略掉自旋过程,以免浪费处理器资源。

3.锁消除

为了保证数据的完整性,我们在进行操作的时候需要对这部分操作进行同步控制,但是在有些情况下,JVM检测到不可能存在共享数据竞争,这时JVM会对这些同步锁进行锁消除。锁消除的依据是逃逸分析的数据支持。

如果不存在竞争,为什么还需要加锁?所以锁消除可以节省毫无意义的请求锁的时间。变量是否逃逸,对于虚拟机来说需要使用数据流分析来确定,但是对于我们程序员来说这还不清楚么?我们会在明明知道不存在数据竞争的代码块前加上同步吗?但是有时候程序并不是我们所想的那样?我们虽然没有显示使用锁,但是我们在使用一些JDK的内置API时,如StringBuffer、Vector、HashTable等,这个时候会存在隐形的加锁操作。比如StringBuffer的append()方法,Vector的add()方法:

1
2
3
4
5
6
7
8
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内部的加锁操作消除。

4.锁粗化

我们知道在使用同步锁的时候,需要让同步块的作用范围尽可能小——仅在共享数据的实际作用域中才进行同步,这样做的目的是为了使需要同步的操作尽可能缩小,如果存在锁竞争,那么等待锁的线程也能尽快拿到锁。

在大多数情况下,上述观点是正确的。但是如果一系列的连续加锁解锁操作,可能会导致不必要的性能损耗,所以引入锁粗化的概念。

锁粗化就是指将多个连续的加锁、解锁操作连接在一起,扩展成为一个范围更大的锁。。如上面实例:vector每次add的时候都需要加锁操作,JVM检测到对同一个对象(vector)连续加锁、解锁操作,会合并一个更大范围的加锁、解锁操作,即加锁解锁操作会移到for循环之外。

5.轻量级锁

引入轻量级锁的主要目的是在没有多线程竞争的前提下,减少传统的重量级锁使用操作系统互斥量产生的性能消耗。当关闭偏向锁功能或者多个线程竞争偏向锁导致偏向锁升级为轻量级锁,则会尝试获取轻量级锁,其步骤如下所示:

获取锁

  1. 判断当前对象是否处于无锁状态(hashcode、0、01),若是,则JVM首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的Mark Word的拷贝(官方把这份拷贝加了一个Displaced前缀,即Displaced Mark Word);否则执行步骤(3);
  2. JVM利用CAS操作尝试将对象的Mark Word更新为指向Lock Record的指正,如果成功表示竞争到锁,则将锁标志位变成00(表示此对象处于轻量级锁状态),执行同步操作;如果失败则执行步骤3;
  3. 判断当前对象的Mark Word是否指向当前线程的栈帧,如果是则表示当前线程已经持有当前对象的锁,则直接执行同步代码块;否则只能说明该锁对象已经被其他线程抢占了,这时轻量级锁需要膨胀为重量级锁,锁标志变成10,后面等待的线程将会进入阻塞状态。

释放锁(轻量级锁的释放也是通过CAS操作来进行的,主要步骤如下):

  1. 取出在获取轻量级锁保存在Displaced Mark Word中的数据;
  2. 用CAS操作将取出的数据替换当前对象的Mark Word中,如果成功,则说明释放锁成功,否则执行(3);
  3. 如果CAS操作替换失败,说明有其他线程尝试获取该锁,则需要在释放锁的同时需要唤醒被挂起的线程。

对于轻量级锁,其性能提升的依据是“对于绝大部分的锁,在整个生命周期内都是不会存在竞争的”,如果打破这个依据则除了互斥的开销外,还有额外的CAS操作,因此在有多线程竞争的情况下,轻量级锁比重量级锁更慢。

下图给出轻量级锁的获取和释放过程:

轻量级锁的获取和释放过程

6.偏向锁

引入偏向锁的目的是在没有多线程竞争的情况下,尽量减少不必要的轻量级锁执行路径。(其引入背景是大多数情况下锁不仅不存在多线程竞争,而且总是由同一线程多次获得,为了让线程获得锁的代价更低而引入了偏向锁,减少不必要的CAS操作。)上面提到了轻量级锁的加锁解锁操作是需要依赖多次CAS原子指令的。那么偏向锁是如何来减少不必要的CAS操作呢?我们可以查看Mark work的结构就明白了。只需要检查是否为偏向锁、锁标识为以及ThreadID即可,处理流程如下:

获取锁

  1. 检查Mark Word是否为可偏向状态,即是否为偏向锁1,锁标识为01;
  2. 若为可偏向状态,则测试线程ID是否为当前线程ID,如果是,则执行步骤5,否则执行步骤3;
  3. 如果线程ID不为当前线程ID,则通过CAS操作竞争锁,竞争成功,则将Mark Word的线程ID替换为当前线程ID,否则执行步骤4;
  4. 通过CAS操作竞争锁失败,证明当前存在多线程竞争情况,当到达全局安全点,获得偏向锁的线程将被挂起,偏向锁升级为轻量级锁,然后被阻塞在安全点的线程继续往下执行同步代码块;
  5. 执行同步代码块。

释放锁

偏向锁的释放采用了一种只有竞争才会释放锁的机制,线程是不会主动去释放偏向锁,需要等待其他线程来竞争。偏向锁的撤销需要等待全局安全点(这个时间点是上没有正在执行的代码)。其步骤如下:

  1. 暂停拥有偏向锁的线程,判断锁对象是否还处于被锁定状态;
  2. 撤销偏向锁,恢复到无锁状态(01)或者轻量级锁的状态。

下图是偏向锁的获取和释放流程:

偏向锁的获取和释放流程

7.重量级锁

重量级锁通过对象内部的监视器(monitor)实现,其中monitor的本质是依赖于底层操作系统的Mutex Lock实现,操作系统实现线程之间的切换需要从用户态到内核态的切换,切换成本非常高。

8.不同锁的比较

不同锁的比较

参考博文

JVM源码分析之java对象头实现

Java并发——关键字synchronized解析

深入分析synchronized的实现原理

Java中synchronized的实现原理与应用

JVM内部细节之一:synchronized关键字及实现细节(轻量级锁Lightweight Locking)

JVM源码分析之synchronized实现

Java 并发编程之 Synchronized 关键字最全讲解

3. 简短的总结

关于synchronized的底层实现,不去考虑具体锁优化的细节,其具体过程如下:

同步代码块基于进入和退出管程(Monitor)对象实现。每个对象有一个监视器锁(monitor)。当monitor被占用时就会处于锁定状态,线程执行monitorenter指令时尝试获取monitor的所有权,过程如下:

  • 如果monitor的进入数为0,则该线程进入monitor,然后将进入数设置为1,该线程即为monitor的所有者。
  • 如果线程已经占有该monitor,只是重新进入,则进入monitor的进入数加1.
  • 如果其他线程已经占用了monitor,则该线程进入阻塞状态,直到monitor的进入数为0,再重新尝试获取monitor的所有权。

被synchronized修饰的同步方法并没有通过指令monitorenter和monitorexit来完成(理论上其实也可以通过这两条指令来实现),不过相对于普通方法,其常量池中多了ACC_SYNCHRONIZED标示符。JVM就是根据该标示符来实现方法的同步的:当方法调用时,调用指令将会检查方法的 ACC_SYNCHRONIZED 访问标志是否被设置,如果设置了,执行线程将先获取monitor,获取成功之后才能执行方法体,方法执行完后再释放monitor。在方法执行期间,其他任何线程都无法再获得同一个monitor对象。 其实本质上没有区别,只是方法的同步是一种隐式的方式来实现,无需通过字节码来完成。