Skip to content

Latest commit

 

History

History
182 lines (125 loc) · 14.3 KB

synchronized 详解.md

File metadata and controls

182 lines (125 loc) · 14.3 KB

概述

synchronized是通过对象内部的一个叫做监视器锁(monitor)来实现的。监视器锁的本质是依赖于底层操作系统的Mutex Lock来实现的。而操作系统实现线程之间的切换会发生操作系统调用,需要从用户态切换到内核态,此切换成本较高,因此把这种依赖于操作系统的Mutex Locklock/unlock所实现的锁称为“重量级锁”。JDK1.6对synchronized进行优化,为了减少获取锁和释放锁带来的性能消耗而引入了偏向锁和轻量级锁

synchronized实现同步的基础:Java中每个对象都可以作为锁。当线程试图访问同步代码时,必须先获得对应的锁,退出或抛出异常时必须释放锁。synchronized实现同步的表现形式为:代码块同步和方法同步

synchronized实现原理

JVM基于进入和退出monitor对象来实现代码同步和方法同步,两者实现细节不同

  • 代码块同步:在编译后通过将monitorenter指令插入到同步代码块的开始处,将monitorexit指令插入到同步代码块结束处和异常处。通过反编译字节码可以观察到,任何一个对象都有一个monitor与之关联,线程执行monitorenter指令时,会尝试获取对象对应的monitor的所有权,即尝试获取对象的锁

image

  • 方法同步:synchronized方法会在method_info结构有ACC_synchronized标记,线程执行时会识别该标记,获取对应的锁,实现方法同步

image

两者虽然实现细节不同,但本质都是对一个对象的监视器的获取。任意一个对象都拥有自己的监视器,当执行同步代码块或同步方法时,执行方法的线程必须先获得该对象的监视器才能进入同步代码块或者同步方法,没有获取到监视器的线程将会阻塞,并进入同步队列,状态变为BLOCKED。当成功获取监视器的线程释放了锁后,会唤醒阻塞在同步队列的线程,使其尝试重新获取监视器

对象、监视器、同步队列和执行线程间的关系如下图:

image

注:synchronized锁不可中断,即在获得锁后一直到释放锁的过程内,不能被中断

monitorenter 指令与 monitorexit 指令

monitorenter,线程获取监视器的过程:

  1. 如果monitor的计数为0 ,则该线程进入monitor,然后将计数器设置为1,然后该线程成为monitor的持有者
  2. 如果线程已经持有monitor,那么可以再一次进入monitor,同时将计数器加1
  3. 如果其他线程持有了monitor,则当前获取monitor的线程进入阻塞状态,直到计数器为0时,才会去重新尝试获取monitor

monitorexit,线程退出监视器的过程:

  • 执行monitorexit的线程必须是已经持有了对应对象的监视器
  • 执行monitorexit后,计数器会减一,如果减一后计数器为0,那么表示线程退出了monitor,同时不再是这个monitor的持有者。此时其他线程可以尝试获取这个monitor的所有权

Synchronized 用法

无论Synchronized使用同步代码块还是同步方法,锁都是实例对象或类对象

用法 锁对象
同步普通方法 实例对象
同步静态方法 类对象
同步代码块 synchronized括号里配置的对象(实例或类)

使用synchronized同步线程时,判断不同线程间是否存在锁竞争时,只需要判断不同线程中synchronized对应的锁是否相同(即是否是一个实例或类),若相同则存在锁竞争

Java 对象头

synchronized使用的锁的存在于 Java 对象头。如果对象是数组类型,JVM 用3个字长来存储对象头,否则 JVM 使用2个字长来存储对象头

长度 内容 说明
1字长 Mark Word 存储对象的HashCode、分代年龄、锁标记位
1字长 Class Metadata Address 存储到对象类型数据的指针
1字长 Array Length 数组长度(若当前对象是数组时)

锁的状态包括无锁状态、偏向锁、轻量级锁、重量级锁(级别由低到高)。在运行期间,Mark Word里存储的数据会随着锁标记位的变化而变化。锁的状态会随着锁的竞争逐渐升级,但不能降级

Mark Word

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

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

image

Synchronized 的优化

偏向锁

大多数情况下锁不仅不存在多线程竞争,而且总是由同一线程多次获得,为了让线程获得锁的代价更低而引入了偏向锁,减少不必要的CAS操作

偏向锁的获取过程

  1. 访问Mark Word中偏向锁的标识是否设置成1,锁标志位是否为01——确认为可偏向状态
  2. 如果为可偏向状态,则测试线程ID是否指向当前线程,如果是,进入步骤5,否则进入步骤3
  3. 如果线程ID并未指向当前线程,则通过CAS操作竞争锁。如果竞争成功,则将Mark Word中线程ID设置为当前线程ID,然后执行5;如果竞争失败,执行4
  4. 如果CAS获取偏向锁失败,则表示有竞争。当到达全局安全点(safepoint)时获得偏向锁的线程被挂起,偏向锁升级为轻量级锁,然后被阻塞在安全点的线程继续往下执行同步代码
  5. 执行同步代码

偏向锁的释放

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

  1. 暂停拥有偏向锁的线程,判断锁对象是否还处于被锁定状态
  2. 撤销偏向锁后恢复到未锁定(标志位为“01”)或轻量级锁(标志位为“00”)的状态

图解

image

轻量级锁

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

轻量级锁的获取

  1. 在代码进入同步块的时候,如果同步对象锁状态为无锁状态(锁标志位为“01”状态,是否为偏向锁为“0”),虚拟机首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的 Mark Word 的拷贝,官方称之为 Displaced Mark Word。这时线程堆栈与对象头的状态如下图:

image

  1. 拷贝对象头中的 Mark Word 复制到锁记录中
  2. 拷贝成功后,虚拟机将使用 CAS 操作尝试将对象的 Mark Word 更新为指向 Lock Record 的指针,并将 Lock record 里的 owner 指针指向 object mark word。如果更新成功,则执行步骤3,否则执行步骤4
  3. 如果这个更新动作成功了,那么这个线程就拥有了该对象的锁,并且对象 Mark Word 的锁标志位设置为“00”,即表示此对象处于轻量级锁定状态,这时候线程堆栈与对象头的状态下图所示:

image

  1. 如果这个更新操作失败了,虚拟机首先会检查对象的 Mark Word 是否指向当前线程的栈帧,如果是就说明当前线程已经拥有了这个对象的锁,那就可以直接进入同步块继续执行。否则说明多个线程竞争锁,轻量级锁就要膨胀为重量级锁,锁标志的状态值变为“10”,Mark Word 中存储的就是指向重量级锁(互斥量)的指针,后面等待锁的线程也要进入阻塞状态。 而当前线程便尝试使用自旋来获取锁,自旋就是为了不让线程阻塞,而采用循环去获取锁的过程

轻量级锁的释放

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

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

图解

image

三种锁的比较

JDK 采用轻量级锁和偏向锁等对 Synchronized 进行优化,但是这两种锁也不是完全没有缺点的,在竞争比较激烈的时候,不但无法提升效率,反而会降低效率,因为多了一个锁升级的过程。可以通过-XX:-UseBiasedLocking来禁用偏向锁。下面是几种锁的比较:

image

其他优化

自旋锁

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

自旋锁的定义:

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

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

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

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

适应自旋锁

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

有了自适应自旋锁,随着程序运行和性能监控信息的不断完善,虚拟机对程序锁的状况预测会越来越准确,虚拟机会变得越来越聪明

锁粗化

锁粗化的概念应该比较好理解,就是将多次连接在一起的加锁、解锁操作合并为一次,将多个连续的锁扩展成一个范围更大的锁

举例:

public class StringBufferTest {
    StringBuffer stringBuffer = new StringBuffer();

    public void append(){
        stringBuffer.append("a");
        stringBuffer.append("b");
        stringBuffer.append("c");
    }
}

这里每次调用stringBuffer.append方法都需要加锁和解锁,如果虚拟机检测到有一系列连串的对同一个对象加锁和解锁操作,就会将其合并成一次范围更大的加锁和解锁操作,即在第一次append方法时进行加锁,最后一次append方法结束后进行解锁

锁消除

锁消除即删除不必要的加锁操作。根据代码逃逸技术,如果判断到一段代码中,堆上的数据不会逃逸出当前线程,那么可以认为这段代码是线程安全的,不必要加锁

举例:

public class SynchronizedTest02 {

    public static void main(String[] args) {
        SynchronizedTest02 test02 = new SynchronizedTest02();
        //启动预热
        for (int i = 0; i < 10000; i++) {
            i++;
        }
        long start = System.currentTimeMillis();
        for (int i = 0; i < 100000000; i++) {
            test02.append("abc", "def");
        }
        System.out.println("Time=" + (System.currentTimeMillis() - start));
    }

    public void append(String str1, String str2) {
        StringBuffer sb = new StringBuffer();
        sb.append(str1).append(str2);
    }
}

虽然StringBuffer的append是一个同步方法,但是这段程序中的StringBuffer属于一个局部变量,并且不会从该方法中逃逸出去,所以其实这过程是线程安全的,可以将锁消除