Talk is cheap , show me your code!
欢迎来到付振南Java博客,让我们一起学习Java吧!

Java并发系列之synchronized详解 一文读懂synchronized的前生今世

Java并发系列之synchronized详解

提到并发,不得不谈一谈synchronized这个关键字,很多地方都能找到synchronized的身影,比如最开始我们学多线程时经典的案例生产者消费者模型,使用Object的wait,notify,需要synchronized加同步锁,再比如到后来的DCL(双重检验锁单例模式)也需要synchronized,所以synchronized在并发中扮演着举足轻重的角色。

基本概念

synchronized在1.6以前,我们都习惯上称呼他为重量级锁,因为他真的很笨重,要么无锁状态,要么就重量级锁状态,其它未获取到锁而想获取锁的线程阻塞。在1.6对synchronized进行了大量的优化之后,加入了偏向锁和轻量级锁,它的身板也显得十分轻盈了。

同时,synchronized也是悲观锁。悲观锁,顾名思义就是很悲观,就是总是认为会发生资源竞争,每次拿到资源资源之后总是认为别人会修改,所以就先加锁,别的线程想要争夺资源时就会阻塞,直到synchronized释放锁资源。

synchronized也可用来保证原子性,可见性和有序性。

使用场景

  • 修饰普通同步方法:锁的是当前实例对象。
  • 修饰静态同步方法:锁的是当前类的Class对象,作用于类的所有实例对象。
  • 修饰同步代码块: 锁的是synchronized括号里的配置的对象。

实现原理

我们先写一个修饰静态同步方法的小demo,通过javap工具反编译一下看看。

/**
 * @author fuzhennan
 */
public class TestSynchronized {
    synchronized public static void testMethod(){}

    public static void main(String[] args){
        testMethod();
    }
}
  public static synchronized void testMethod();
    descriptor: ()V
    flags: (0x0029) ACC_PUBLIC, ACC_STATIC, ACC_SYNCHRONIZED
    Code:
      stack=0, locals=0, args_size=0
         0: return
      LineNumberTable:
        line 7: 0

我们可以看到对于同步方法,Jvm底层是通过给flag加ACC_SYNCHRONIZED标识实现的,说明此方法是同步方法。

我们再来写一个同步代码块的小demo。

public class TestSynchronized {
    synchronized public static void testMethod(){}

    public void myMethod(){
        synchronized (this){
            System.out.println("同步代码块");
        }
    }
    public static void main(String[] args){
        testMethod();
        TestSynchronized ts = new TestSynchronized();
        ts.myMethod();
    }
}
public void myMethod();
    descriptor: ()V
    flags: (0x0001) ACC_PUBLIC
    Code:
      stack=2, locals=3, args_size=1
         0: aload_0
         1: dup
         2: astore_1
         3: monitorenter
         4: getstatic     #2                  // Field java/lang/System.out:Ljava/io/PrintStream;
         7: ldc           #3                  // String 同步代码块
         9: invokevirtual #4                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
        12: aload_1
        13: monitorexit
        14: goto          22
        17: astore_2
        18: aload_1
        19: monitorexit
        20: aload_2
        21: athrow
        22: return

我们很清楚的可以看到jvm底层是使用的monitorentermonitorexit指令进行同步处理的。

Java对象头和monitor

前面我们通过两段简单的代码知道了jvm底层对于同步方法和同步代码块的实现方式是不同的,为了深入的分析,我们得先知道两个知识点,一个是Java对象头,一个是monitor。

Java对象头

synchronized用的锁是存在Java对象头里面的。

HotSpot虚拟机对象的对象头部分包括两类信息,一类是用于存储对象自身的运行时数据,如哈希码,GC分代年龄,锁状态标志,线程持有的锁,偏向线程ID,偏向时间戳等。,这部分信息在32位和64位虚拟机分别占32bit和64bit,他有一个官方名字叫做Mark Word。对象头另一类信息就是类型指针了,即对象指向它的类型元数据的指针。

HotSpot虚拟机对象头Mark Word如下表所示。

存储内容 标志位 状态
对象哈希码,分代年龄 01 未锁定
指向锁记录的指针 00 轻量级锁定
指向重量级锁的指针 10 膨胀(重量级锁定)
空,不需要记录信息 11 GC标记
偏向线程ID,偏向时间戳,对象分代年龄 01 可偏向

在32位虚拟机中,Java对象头存储结构如下表所示。

锁状态 25bit 4bit 1bit是否是偏向锁 2bit锁标志位
无锁状态 对象的hashcode 对象分代年龄 0 01

在运行期间,Mark Word里存储的数据会随着锁标志位的变化而变化。

简单的介绍了Java的对象头,我们再来说说monitor。

monitor

关于monitor,我们可以把它理解为同步机制,在Java中,万物皆对象,所有对象都有可能成为Monitor对象,每一个Java对象自打娘胎里出来就带了一把看不见的锁,它叫做内部锁或者Monitor锁。

每一个被锁住的对象都会和一个monitor关联,同时monitor中有一个Owner字段存放拥有该锁的线程的唯一标识,表示该锁被这个线程占用,初始值为null,当线程成功拥有该锁后会保存当前线程ID。

在前面我们说到,同步代码块是通过monitorentermonitorexit实现的,线程执行到monitorenter指令的时候,就会尝试获取对象所对应的monitor的所有权,获取锁,反之就是释放所有资源。

锁优化

自旋锁

前面我们说到,synchronized在1.6以前,是个重量级锁,要么持有锁,要么阻塞,从挂起到恢复线程是要从用户态转到内核态的,当频繁的阻塞,唤醒,就会对cpu带来极大的压力,并发性能就会下降。

Java虚拟机的研发团队发现,持有锁的线程往往只会持续很短的时间,那么我们就可以通过让后面需要获取锁的线程稍微等一等,也就是原地自旋,忙循环,不要去放弃处理器的执行时间,这样不就解决了阻塞的开销了吗?这项技术就叫做自旋锁。

但是,自旋等待不能完全替代阻塞,虽然他解决了线程切换的开销,但是忙循环本身就是要占用处理器时间的,如果说占用时间短,那么效果会很好,如果长时间占用,那就会适当其反了,所以我们需要一个限度来限制他自旋的次数,默认是10次,当然也可以通过参数 -XX:PreBlockSpin来修改。

自适应自旋

为了解决前面说的自旋锁白白消耗服务器资源的问题,1.6对自旋锁进行了优化,引入了自适应的自旋。

如果在同一个锁对象上,自旋等待刚刚成功获得锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也很有可能再次成功,进而允许自旋等待持续相对更长的时间,比如忙循环100次,另一方面,如果对于某个锁,自旋很少成功获得过锁,那在以后获取这个锁将有可能直接省略掉自旋的过程,以避免浪费处理器资源,有了自适应锁,虚拟机就会越来越聪明了。
------摘自《深入理解Java虚拟机第三版》13.3.1节

锁消除

首先我们说一下锁消除的概念。

锁消除是指虚拟机即时编译器在运行时,对一些代码要求同步,但是对被检测到不可能存在共享数据竞争的锁进行消除。
------摘自《深入理解Java虚拟机第三版》13.3.2节

我们都知道StringBuffer,Vector,HashTable等是因为底层加了synchronized才保证了线程安全的,现在我们用StringBuffer来举例。

/**
 * @author fuzhennan
 */
public class TestStringBuffer {

    public static void main(String[] args){
        TestStringBuffer tsb = new TestStringBuffer();
        tsb.concatString();
    }

    public String concatString(){
        StringBuffer sb = new StringBuffer();

        for (int i = 0; i < 10; i++) {
            sb.append(i);
        }
        return sb.toString();
    }
}

在这段代码中,我们new了一个StringBuffer对象,然后在for循环里append十次,我们都知道,append方法是经过synchronized修饰过的。

    @Override
    @HotSpotIntrinsicCandidate
    public synchronized StringBuffer append(int i) {
        toStringCache = null;
        super.append(i);
        return this;
    }

在我们每次执行for循环的时候,因为append是给sb对象加锁,而sb对象是在concatString方法里面创建的,在该代码中,sb的所有引用都逃不出concatString方法,也就是经过逃逸分析后会发现它的动态作用域被限制在concatString方法内部,其他线程无法访问到它,加锁还有什么必要呢?所以锁会安全的消除掉。

锁粗化

通常情况下,我们使用锁的时候,都会遵循最小作用域的原则,也就是把锁加在作用范围最小的地方,换句话说就是共享数据的实际作用域中。这样一来可以使得需要同步的操作数量尽可能变少,二来即使存在锁竞争,等待锁的线程也尽可能快地拿到锁。

但是,事请往往都不能皆如人意。我们还是以上面的StringBuffer举例,在for循环中append 10个数,每次for循环append都要加锁,释放锁,如此循环操作十次,十分的影响性能,而且,这根本是没必要的操作,如果Java虚拟机发现了这样的情况,就会进行锁粗化的操作,也就是把加锁的范围调整一下,把它粗化(扩展)到for循环外面,这样就可以只加锁一次,极大的提高了性能。

锁升级过程

我们知道在jdk1.6以前,synchronized要么是无锁,要么是重量级锁,使用起来十分的笨重,所以在1.6之后大刀阔斧的改革,引入了偏向锁和轻量级锁,极大的提高了性能。

synchronized的锁升级过程是:无锁-偏向锁-轻量级锁-重量级锁

注意,锁只能升级,不能降级。

无锁

无锁就不多说了,就是没有锁,Mark Word结构如下表所示。

锁状态 25bit 4bit 1bit是否是偏向锁 2bit锁标志位
无锁状态 对象的hashcode 对象分代年龄 0 01

偏向锁

《Java并发编程的艺术》中描述,HotSpot的作者经过研究发现,大多数情况下,锁不仅不存在多线程竞争,而且总是同一线程多次获得,为了让线程获得锁的代价更低而引入了偏向锁。

(1)获取锁

所以,假设,当有一个线程已经开启了偏向锁,然后它访问同步块时第一次尝试获取偏向锁,且成功获取时,虚拟机会将对象头中的标志位设置为01(至于为什么是01请看前面的Mark Word表格),然后把偏向锁模式设置为1,表示进入偏向锁模式,然后通过CAS操作在对象头中存储偏向的线程ID,大概占用前25bit中的23bit(至于那原25bit的对象的hashcode怎么办请看《Java并发编程的艺术》第484页的详细解释),如果CAS操作成功了,那么持有偏向锁的线程以后再访问同一个锁时,虚拟机就会不做任何同步操作,以此来提高性能。这也就解释了偏向锁的名字由来,偏向于第一个获得它的线程。

(2)释放锁

但是好景往往是不长的,如果很不幸,此时有另一个线程过来争夺偏向锁,偏向模式立马就被打破,持有偏向锁资源的线程会释放锁。

偏向锁的释放,是根据当前线程状态决定的,这往往需要等到一个合适的时机,也就是全局安全点。它会先暂停持有偏向锁的线程,然后检查该线程是否活着,如果不处于活动状态,就会将对象头的锁标志位更新为01无锁状态,如果还活着,指向偏向锁的栈就会执行,遍历锁记录,此时锁记录和对象头要么重新偏向到其他线程上,也就是把偏向锁交给其他线程,本身相当于释放了锁,要么恢复到最开始的无锁状态,要么标记对象不适合作为偏向锁,升级为轻量级锁,唤醒其它暂停的线程。

偏向锁的获得与撤销流程如下图所示。

轻量级锁

如果用户主动关闭偏向锁,或者多个线程竞争偏向锁时,偏向锁会升级至轻量级锁。

设计目的:在没有多线程竞争的前提下,减少传统的重量级锁使用操作系统互斥量产生的性能消耗。

(1)加锁

线程在执行同步块之前,如果此同步对象没有被锁定,即锁标识为为01,jvm会事先再当前线程私有区域的虚拟机栈中的栈帧创建用于存储锁记录的空间,该空间名字就叫做锁记录,然后将对象头的Mark Word拷贝到锁记录中,然后线程会尝试使用CAS操作将对象头中的Mark Word替换为指向锁记录的指针,这也就解释了为什么轻量级锁Mark Word中为什么会有一片内存中有指针指向锁记录。

如果CAS操作成功了,当前线程就会获得锁,将对象的锁标志位(最后两个比特位)设置为00,代表当前为轻量级锁状态,如果失败了,表示当前锁至少存在一条线程在竞争该锁,虚拟机首先会检查对象的Mark Word是否指向当前线程的栈帧,如果是,说明线程已经拥有了该对象的锁,那么继续执行同步块就行,如果不是指向当前线程的栈帧,说明其他线程抢占了该锁,当前线程自旋等待获取锁。

如果存在两条以上线程竞争同一个锁,那么该轻量级锁就会升级为重量级锁,且该对象头的最后两位锁标志位更新为10,此时Mark Word中存储的就是指向重量级锁的指针,后面等待获取锁的线程阻塞。

(释放锁)

释放锁时,使用CAS操作将栈帧中的锁记录替换回对象头的Mark Word中,如果成功了,说明没有竞争发生,如果失败了,说明有其他线程来竞争该锁,那就要在释放锁的同时,唤醒其他等待的线程。

关于轻量级锁以及膨胀的流程如下图所示。

注意事项

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

重量级锁

一旦升级为重量级锁,那么就一去不复返了,不可能再回到以前了,其它线程访问锁时会阻塞,只有当前持有锁的线程释放锁时,才会唤醒其他线程。

重量级锁其实就类似于1.6之前的synchronized,本质上是通过对象内部的监视器(Monitor)实现的。

其中,Monitor 的本质是,依赖于底层操作系统的 Mutex Lock 实现。操作系统实现线程之间的切换,需要从用户态到内核态的切换,切换成本非常高,极大的影响性能。

我们最后引用《Java并发编程的艺术》中的图来看看偏向锁,轻量级锁,重量级锁三种锁的对比。

参考资料

  1. 《Java并发编程的艺术》第2章
  2. 《深入理解Java虚拟机-JVM高级特性与最佳实践》第13章
赞(5) 打赏
未经允许不得转载:付振南Java博客 » Java并发系列之synchronized详解

评论 抢沙发

  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址

觉得文章有用就打赏一下文章作者

支付宝扫一扫打赏

微信扫一扫打赏