浅谈 synchronized

·  阅读 26

drew-beamer-Vc1pJfvoQvY-unsplash.jpg

在Java中,synchronized 算是一个高频使用的关键字; 在多线程共同操作共享资源的情况下,可以保证在同一时刻只有一个线程可以对共享资源进行操作,从而实现共享资源的线程安全。

在JDK早期(1.5以前),synchronized一直是一个重量级锁,但从1.6之后,对synchronized进行了优化,使其不在单纯是一把重量级锁。锁的优化包括:锁消除、锁粗化、轻量级、偏向锁。

本文基于JDK8进行分析,因为偏向锁在JDK15被标记为废弃。参考JEP 374

那么接下来我们就深入的分析下synchronized

1.synchronized的特性

  1. 原子性:所谓原子性就是指一个操作或者多个操作,要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。被synchronized修饰的类或对象的所有操作都是原子的,因为在执行操作之前必须先获得类或对象的锁,直到执行完才能释放。

  2. 可见性:保证共享变量的修改能够及时可见,其实是通过Java内存模型中的 “对一个变量unlock操作之前,必须要同步到主内存中;如果对一个变量进行lock操作,则将会清空工作内存中此变量的值,在执行引擎使用此变量前,需要重新从主内存中load操作或assign操作初始化变量值” 来保证的;

  3. 有序性:有效解决重排序问题,即 “一个unlock操作先行发生(happen-before)于后面对同一个锁的lock操作”;

  4. 可重入性:synchronized 内置锁是一种对象锁(ObjectMonitor),作用粒度是对象 ,可以用来实现对临界资源的同步互斥访问,是可重入的。其可重入最大的作用是避免死锁

比如一个类中同步方法A调用同步方法B,如果不是可重入的就会发生死锁;或者子类同步方法调用父类同步方法。

synchronized关键字经常和volatile关键字的特性进行对比:synchronized关键字可以保证: 原子性、可见性、有序性,而volatile关键字只能保证 可见性和有序性,不能保证原子性,也称为是轻量级的synchronized


在分析 synchronized 之前,我们先了解下对象的内存布局。

2.对象的内存布局

一个对象在JVM中的布局主要是分成了:对象头,实例数据,对齐填充。如下图:

image.png

  1. 对象头:包含运行时元数据(Mark Word)和类型指针,如果是数组还要包含数组长度,其中Mark Word 里面包含了和锁相关的信息,也是我们本次关注的重点。
  2. 实例数据:他是对象真正存储的有效信息,包括程序代码中定义的各种类型的字段(包括从父类继承下来的字段);其中相同宽度的字段总是被分配到一起,父类中定义的变量会出现在子类之前。
  3. 对齐填充:由于虚拟机要求 对象起始地址必须是8字节的整数倍。填充数据不是必须存在的,仅仅是为了字节对齐;

我们通过下面这个图更加形象的展示对象的内部布局: image.png

2.1 Mark Word

对象头中的Mark Word用于存储对象自身的运行时数据,如:哈希值、GC分代年龄、锁状态标志、线程持有的锁、偏向线程 ID、偏向时间戳等。64 位虚拟机 Mark Word 是 8字节(64bit),在运行期间,Mark Word里存储的数据会随着锁标志位的变化而变化。 64位Hotspot虚拟机的锁状态位.png 图片出处

对象头的最后两位存储了锁的标志位,01是初始状态,未加锁(如果开启了偏向锁,则还需要看倒数第三位是0/1,后面分析锁升级过程会看到); 其对象头里存储的是对象本身的哈希值,随着锁级别的不同,对象头里会存储不同的内容。

偏向锁存储的就是当前对象所在的线程的线程ID
轻量级锁则存储指向线程栈中锁记录的指针
从这里我们可以看到,“锁”这个东西,可能是个锁记录+对象头里的引用指针(判断线程是否拥有锁时将线程的锁记录地址和对象头里的指针地址比较),也可能是对象头里的线程ID(判断线程是否拥有锁时将线程的ID和对象头里存储的线程ID比较)。

2.2 Lock Record

在线程进入同步代码块的时候,如果此同步对象没有被锁定,即它的锁标志位是01,则虚拟机首先在当前线程的中创建我们称之为 锁记录(Lock Record) 的空间,用于存储锁对象的Mark Word的拷贝,官方把这个拷贝称为Displaced Mark Word。整个Mark Word及其拷贝至关重要。

LockRecord用于轻量级锁优化,当解释器执行monitorenter字节码轻度锁住一个对象时,就会在获取锁的线程的栈上显式或者隐式分配一个Lock Record.这个Lock Record存储锁对象Mark Word的拷贝(Displaced Mark Word),在拷贝完成后,首先会挂起持有偏向锁的线程,因为要进行尝试修改锁记录指针,Mark Word会有变化,所有线程会利用CAS尝试将Mark Word的锁记录指针改为指向自己(线程)的锁记录,然后Lock Record的owner指向对象的Mark Word,修改成功的线程将获得轻量级锁。失败则线程升级为重量级锁。释放时会检查Mark Word中的Lock Record指针是否指向自己(获得锁的线程Lock Record),使用原子的CAS将Displaced Mark Word替换回对象头,如果成功,则表示没有竞争发生,如果替换失败则升级为重量级锁。整个过程中,LockRecord是一个线程内独享的存储,每一个线程都有一个可用Monitor Record列表。段落出处

Lock Record是线程私有的数据结构,每一个线程都有一个可用Lock Record列表,同时还有一个全局的可用列表。每一个被锁住的对象Mark Word都会和一个Lock Record关联(对象头的MarkWord中的Lock Word指向Lock Record的起始地址),同时Lock Record中有一个Owner字段存放拥有该锁的线程的唯一标识(或者object mark word),表示该锁被这个线程占用。如下图所示为Lock Record的内部结构:

image.png

这里先有个印象就行,后面看锁升级的时候还会看到

2.3 JOL工具分析对象的内存布局

JOL是openJDK官方推出的对象内存分析工具;首先导入pom依赖:

<dependency>
    <groupId>org.openjdk.jol</groupId>
    <artifactId>jol-core</artifactId>
    <version>0.9</version>
</dependency>
复制代码

接下来我们使用该工具来分析下:

public class MyJOL {

    public static void main(String[] args) throws Exception {

        //System.out.println(VM.current().details());

        Student student = new Student();
        System.out.println(ClassLayout.parseInstance(student).toPrintable());

    }

    private static class Student{
        private long name;
    }
}
复制代码

image.png 简单分析下:

  1. 对象头中的Mark Word 占8个字节,其中001表示无偏向锁,无锁
  2. 类型指针占用4个字节,因为开启了指针压缩【-XX:+UseCompressedOops】,如果关闭则占用8个字节
  3. long属性占用8个字节,此时共计是20个字节,由于Hotspot VM(64位) 要求对象的起始地址必须是8字节的整数倍,所以还差4个字节,而这4个字节就是对齐填充,所以对齐填充是非必需的。

面试题: Object obj = new Object() 在内存中占多少个字节? }

通过JOL工具分析下我们就可以知道,对象头占8个字节,类型指针4个字节(默认开启了指针压缩),没有实例数据,共计12字节,但是JVM要求对象必须是8字节的整数倍,所以对齐填充占4个字节,共计16个字节。

3.同步演示

3.1 同步代码块

当一个线程访问同步代码块时,首先是需要得到锁才能执行同步代码,当退出或者抛出异常时必须要释放锁,那么它是如何来实现这个机制的呢?我们先看一段简单的代码:

public class SynchronizedDemo {

    public void test1(){
        //锁的是当前对象
        synchronized (this){
            System.out.println("同步代码块.......");
        }
    }
}
复制代码

我们反编译字节码文件,看看它的执行指令:

javap -v SynchronizedDemo.class
复制代码

image.png

不难发现,synchronized同步是通过一对 monitorentermonitorexit指令来完成。 JVM中的同步是基于进入退出监视器对象(Monitor)来实现的,每个对象实例都会有一个Monitor对象,Monitor对象会和Java对象一同创建并销毁。Monitor对象是由C++来实现的.

  1. monitorenter指令:同步的开始位置,当代码执行到该指令时,将会尝试获取该对象Monitor的所有权,即尝试获得该对象的锁。
  2. monitorexit指令:同步结束处和异常处,JVM保证每个monitorenter必须有对应的monitorexit。

那什么是Monitor?可以把它理解为 一个同步工具,也可以描述为 一种同步机制,它通常被描述为一个对象。在Java的设计中 ,每一个Java对象自打娘胎里出来就带了一把看不见的锁,它叫做内部锁或者Monitor锁。当线程获取到对象的Monitor时,Monitor是依赖于底层操作系统的mutex lock来实现互斥的,线程获取mutex成功,则会持有该mutex,这时其它线程就无法再获取到该mutex

也就是通常说synchronized的对象锁,MarkWord锁标识位为10,其中指针指向的是Monitor对象的起始地址。在Java虚拟机(HotSpot)中,Monitor是由ObjectMonitor实现的,其主要数据结构如下:

基于OpenJDK8的源码分析,位于HotSpot虚拟机 hotspot/src/share/vm/runtime/objectMonitor.hpp 源码文件,C++实现的

// initialize the monitor, exception the semaphore, all other fields
// are simple integers or pointers
ObjectMonitor() {
  _header       = NULL;  //TODO:用来保存锁对象的mark word的值。因为object(synchronzed修饰的object)里面已经不保存mark word的原来的值了
                         //TODO:保存的是ObjectMonitor对象的地址信息。当所有线程都完成了之后,需要销毁掉ObjectMonitor的时候需要将原有的header里面的值重新复制到mark word中来
                         
  _count        = 0;     //TODO:锁的计数器,获取锁时count数值加1,释放锁时count值减1
  _waiters      = 0,     
  _recursions   = 0;     //TODO:线程的重入次数
  _object       = NULL;  //TODO:指向的是对象的地址信息,方便通过ObjectMonitor来访问对应的锁对象。
  _owner        = NULL;  //TODO:指向的是当前获得线程的地址,用来判断当前锁是被哪个线程持有
  _WaitSet      = NULL;  //TODO:waitSet(双向链表),里面存放的都是wait的线程
  _WaitSetLock  = 0 ;
  _Responsible  = NULL ;
  _succ         = NULL ;
  _cxq          = NULL ;  //TODO: 线程刚进来的时候没有获取到锁的时候,在当前队列排队(单向链表)
  FreeNext      = NULL ;
  _EntryList    = NULL ;  //TODO:当多个线程同时访问一段同步代码时,这些线程会被放到一个EntrySet集合中
                          //TODO: 处于阻塞状态的线程都会被放到该列表当中(双向链表)
  _SpinFreq     = 0 ;
  _SpinClock    = 0 ;
  OwnerIsThread = 0 ;
  _previous_owner_tid = 0;
}
复制代码

ObjectMonitor中有3个队列,_cxq, _WaitSet_EntryList,用来保存ObjectWaiter对象列表( 每个等待锁的线程都会被封装成ObjectWaiter对象 ),_owner指向持有ObjectMonitor对象的线程,当多个线程同时访问一段同步代码时:

  1. 当多个线程同时访问同步代码块时,线程A尝试将Monitor中的_owner字段设置为当前线程A

1.1 如果_owner成功指向了线程A,说明线程A获取了锁,_recursions 置为1,获得锁后就可以执行同步代码了;如果_owner本身已经指向了A,说明A是重入了,此时_recursions++,继续执行同步代码。
1.2 如果_owner没有指向线程A,说明当前监视器对象被其他线程持有,此时线程A将会通过自旋的方式来尝试获取锁,如果尝试多次后依然无法获得锁,则将当前线程A封装成ObjectWaiter对象,插入到 _cxq 队列中。其原理是:当发生对Monitor的争用时,若owner能够在很短的时间内释放掉锁,则那些正在争用的线程就可以稍微等待一下(既所谓的自旋),在Owner线程释放锁之后,争用线程可能会立刻获取到锁,从而避免了系统阻塞。不过,当Owner运行的时间超过了临界值后,争用线程自旋一段时间后依然无法获取到锁,这时争用线程则会停止自旋而进入到阻塞状态。所以总体的思想是:先自旋,不成功再进行阻塞,尽量降低阻塞的可能性,这对那些执行时间很短的代码来说有极大的性能提升。显然,自旋在多处理器(多核心)上才有意义。

  1. 当获取锁的线程调用wait()方法,则会将_owner 设置为null,recursions减1,当前线程加入到_WaitSet队列中,等待被唤醒;同时调用ObjectMonitor::exit退出监视器。
  2. 若当前线程执行完毕,也将释放monitor(锁)并复位_count, _recursions的值,以便其他线程进入获取monitor(锁);

Monitor对象存在于每个Java对象的对象头Mark Word中(存储的指针的指向),Synchronized锁便是通过这种方式获取锁的,也是为什么Java中任意对象可以作为锁的原因,同时notify/notifyAll/wait等方法会使用到Monitor锁对象,所以必须在同步代码块中使用.

我们通过一张图来展示ObjectMonitor::enter的过程:

ObjectMonitor__enter.png

我们再来大致看下 ObjectMonitor::exit的过程:

ObjectMonitor__exit.png

以上就是synchronized关键字在底层工作的过程。

我们在简单看下synchronized直接修饰方法

3.2 同步方法

public class SynchronizedDemo {

    public synchronized void test2(){
        System.out.println("同步方法.......");
    }

    public synchronized static void test3(){
        System.out.println("静态方法加锁.....");
    }

}
复制代码

同样反编译后看下它的运行指令:

image.png

当修饰方法(实例方法/静态方法)时,并没有看到monitorentermonitorexit指令,使用的是ACC_SYNCHRONIZED,它的作用就是用来标识这是一个同步方法,一旦执行到这个方法时,如果有这个标志位,就会先尝试获取Monitor,获取成功才能执行方法,方法执行完成后再释放Monitor。归根结底还是对Monitor对象的争夺,底层还是通过monitorentermonitorexit这对指令去工作的,只是同步方法是一种隐式的方式来实现的。

3.3 synchronized 是公平的还是非公平的?

synchronized是非公平锁。

  1. synchronized在竞争锁时,线程不是直接进入 _cxq 队列中,而是先自旋获取锁(此时在 _cxq_EntryList 队列中可能已经有其他线程在等待锁),如果自旋获取不到锁则进入 _cxq 队列头部,那么此时对于已经在 _cxq_EntryList 中的线程明显是非公平的。
  2. 当某个线程释放锁后,那么位于 _cxq 队列 和 _EntryList 队列中的线程去竞争锁,但是由于策略(QMode)的不同(请参考上面的图),默认情况下是如果 _EntryList 队列有线程,则直接从队列中取出去抢锁,如果 _EntryList是空的,则将 _cxq 队列顺序插入到 _EntryList队列中,并取出第一个线程去争抢锁,所以说当_EntryList为空的情况下,后来的反而先获得锁。

4. 锁升级过程

我们知道在JDK1.6之前,synchronized是属于重量级锁,重量级需要依赖于底层操作系统的Mutex Lock实现,然后操作系统需要切换用户态和内核态,这种切换的消耗非常大,所以性能相对来说并不好。

我们说它重,就体现在用户态和内核态的转切换。

为此在JDK1.6后开始对synchronized进行优化,增加了自适应的CAS自旋、锁消除、锁粗化、偏向锁、轻量级锁这些优化策略。锁的等级从无锁偏向锁轻量级锁重量级锁逐步升级,并且是单向的,不会出现锁的降级。 image.png

难么接下来我们就根据上面的图一步一步分析锁的升级过程

4.1 偏向锁

4.1.1 偏向锁开启

首先问下,为什么会有偏向锁呢?

我们在开发中,经常会遇到很多的 synchronized 的同步方法,比如集合类Vector,字符串类StringBuffer,以及集合类Collections提供的同步方法(synchronizedList(),synchronizedMap() 等等)但是经过统计发现,一半以上的时间,都只有一个线程在调用这些同步方法,既然大部分时间都只有一个线程再用,那就不要每次调用都经过OS去申请锁,干脆就给你用得了。所以,在大多数情况下,锁不存在多线程竞争,总是由同一线程多次获得,所以这把锁就贴上当前线程的标签(线程ID),那么此时就是偏向锁

偏向锁默认是打开的,但是默认要延迟4s才会打开,相关参数

-XX:+UseBiasedLocking   开启偏向锁,默认
-XX:-UseBiasedLocking   禁用偏向锁

-XX:BiasedLockingStartupDelay=0  关闭延迟,默认是4s,最好使用默认
复制代码

为什么要延迟4s才打开偏向锁?
JVM启动的时候内部有很多的同步操作,如果启动就打开偏向锁,那么就意味着在多线程竞争的情况下,必然会出现偏向锁撤销,而撤销的过程会带来额外的性能损耗,所以在这种明确多线程竞争的情况下,就没有必要直接开启偏向锁了。 我们还是借助前面说的JOL(版本是0.16)工具来分析下对象的锁状态位变化;请看下面的这段代码:

public class MyJOL {

    public static void main(String[] args) throws Exception {

        //System.out.println(VM.current().details());
        //TODO:new 一个对象看看它的锁状态位是什么?
        Object o1 = new Object();
        System.out.println(ClassLayout.parseInstance(o1).toPrintable());

        //TODO:延迟5s!!!!
        TimeUnit.SECONDS.sleep(5);

        //TODO:延迟5s后,在new一个普通对象,看看它的锁状态位是什么?
        Object o2 = new Object();
        System.out.println(ClassLayout.parseInstance(o2).toPrintable());

        //TODO:给o2加锁,在看看它的锁状态位是什么?
        synchronized (o2){
            System.out.println(ClassLayout.parseInstance(o2).toPrintable());
        }

    }
}
复制代码

image.png

不难发现,o1是无锁无偏向,虽然我们说偏向锁默认是打开的,但是要延迟4s才打开,所以o1是无锁状态,o2是匿名偏向状态。

我们再看一段代码:

public class MyJOL {

    public static void main(String[] args) throws Exception {

        //TODO:延迟5s,目的是等待打开偏向锁
        TimeUnit.SECONDS.sleep(5);

        //TODO:猜测应该是匿名偏向
        Object o2 = new Object();
        System.out.println(ClassLayout.parseInstance(o2).toPrintable());

        synchronized (o2){
            //TODO:猜测应该是偏向主线程
            System.out.println(ClassLayout.parseInstance(o2).toPrintable());
        }

        //TODO:同步代码执行完,猜测应该不会撤销偏向锁,还是和上面一样
        System.out.println(ClassLayout.parseInstance(o2).toPrintable());
    }
}
复制代码

image.png

借助JOL工具,可以清楚的看到偏向锁的状态位改变,下面通过一张图来理解获取偏向锁的过程:

image.png

4.1.2 偏向锁的撤销和膨胀

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

  1. 暂停拥有偏向锁的线程;
  2. 判断锁对象是否还处于被锁定状态,否,则恢复到无锁状态(01),以允许其余线程竞争。是,则挂起持有锁的当前线程,并将指向当前线程的锁记录地址的指针放入对象头Mark Word,升级为轻量级锁状态(00),然后恢复持有锁的当前线程,进入轻量级锁的竞争模式;

注意:此处将 当前线程挂起再恢复的过程中并没有发生锁的转移,仍然在当前线程手中,只是穿插了个“将对象头中的线程ID变更为指向锁记录地址的指针” 这么个事。

image.png 偏向锁被撤销,需要达到safepoint点,此时会发生 stop the world,所以在多线程竞争的情况下开启偏向锁会带来更大的性能开销(这就是 Java 15 取消和禁用偏向锁的原因)

4.1.3 为什么撤销偏向锁?

在过去,Java 应用通常使用的都是 HashTable、Vector 等比较老的集合库,这类集合库大量使用了 synchronized 来保证线程安全。如果在单线程的情景下使用这些集合库就会有不必要的加锁操作,从而导致性能下降。

而现在Java 应用基本都已经使用了无锁的集合库,比如 HashMap、ArrayList 等,这些集合库在单线程场景下比老的集合库性能更好。 即使是在多线程场景下,Java 也提供了 ConcurrentHashMap、CopyOnWriteArrayList 等性能更好的线程安全的集合库。

对于使用了新类库的 Java 应用来说,偏向锁带来的收益已不如过去那么明显,而且在当下多线程应用越来越普遍的情况下,偏向锁带来的锁升级操作反而会影响应用的性能。

在废弃偏向锁的提案 JEP 374 中还提到了与 HotSpot 相关的一点

1.偏向锁为整个「同步子系统」引入了大量的复杂度,并且这些复杂度也入侵到了 HotSpot 的其它组件,这导致了系统代码难以理解,难以进行大的设计变更,降低了子系统的演进能力
2.总结下来其实就是 ROI (投资回报率)太低了,考虑到兼容性,所以决定先废弃该特性,最终的目标是移除它。

4.2 轻量级锁

4.2.1 加锁

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

  1. 在线程进入同步代码块时,如果对象头的Mark Word为无锁状态,则将Mark Word复制到线程栈的Lock Record中。

  2. 拷贝成功后,虚拟机将使用CAS操作尝试将对象Mark Word中的Lock Word更新为指向当前线程Lock Record的指针,并将Lock record里的owner指针指向object mark word。如果更新成功,则执行步骤(3),否则执行步骤(4)。 image.png

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

  4. 如果这个更新操作失败了,虚拟机首先会检查对象Mark Word中的Lock Word是否指向当前线程的栈帧,如果是,就说明当前线程已经拥有了这个对象的锁,则这次为锁重入,则再次创建一个Lock Record,只是这次建立的Lock Record中的Displaced Mark Wordnull,记录重入,那就可以直接进入同步块继续执行。

image.png

  1. 如果不是则说明多个线程竞争锁,进入自旋执行(2),若自旋一定次数后仍未获得锁,轻量级锁就要膨胀为重量级锁,将object锁标志的状态值变为“10”,Mark Word中存储的就是指向重量级锁(互斥量)的指针,当前线程以及后面等待锁的线程也要进入阻塞状态。

过程如下图: 轻量级锁竞争升级 (3).jpg

一句话总结获取轻量级锁的原理:将对象的Mark Word复制到当前线程的Lock Record中,并通过CAS将对象的Mark Word更新为指向Lock Record的指针

image.png

如何解锁?

4.2.2 解锁

  1. 当线程退出synchronized代码块的时候,如果获取的是取值为null的锁记录 ,表示有锁重入,这时重置锁记录,表示重入计数减一
  2. 当线程退出synchronized代码块的时候,如果获取的锁记录取值不为 null,那么使用CASMark Word的值恢复给对象

① 如果替换成功,则解锁成功(说明期间没有其他线程访问同步代码块,Mark Word 没有被修改)
② 失败,则说明轻量级锁进行了锁膨胀或已经升级为重量级锁,则需要进入重量级锁解锁流程

4.2.3 锁膨胀

如果在尝试加轻量级锁的过程中,CAS操作无法成功,这是有一种情况就是其它线程已经为这个对象加上了轻量级锁,这时就要进行锁膨胀,将轻量级锁变成重量级锁

从上面的轻量级锁升级图中可以看到,线程1持有了轻量级锁,线程2自旋一定次数后如果还是无法获取锁,此时就会膨胀到重量级锁。

有线程超过10次自旋,可以通过 -XX:PreBlockSpin 参数控制; 或者自旋线程数超过CPU核数的一半, 1.6之后,加入自适应自旋 Adapative Self Spinning , 由JVM自己控制。

image.png

所以,当线程1在尝试解锁时,通过CAS将栈中的Mark Word 替换回 对象头的Mark Word, 此时肯定是无法成功的,因为此时对象头的Mark Word 存储的已经是重量级锁的地址,锁状态位也从00变成了10,那么此时就会进入重量级锁的解锁过程:按照Monitor的地址找到Monitor对象,将_owner设置为null,唤醒EntryList 中的阻塞的线程。

image.png


4.3 重量级锁

前面我们在分析同步方法以及锁升级过程时,都有提到重量级锁概念。Synchronized是通过对象内部的一个叫做 监视器锁(Monitor)来实现的。但是监视器锁本质又是依赖于底层的操作系统的Mutex Lock来实现的。而操作系统实现线程之间的切换这就需要从用户态转换到核心态,这个成本非常高,状态之间的转换需要相对比较长的时间,这就是为什么Synchronized效率低的原因。因此,这种依赖于操作系统Mutex Lock所实现的锁我们称之为 “重量级锁”。

好了,关于偏向锁,轻量级锁,重量级锁我们都知道了,关于整个升级过程,可以用如下图来概括下:

锁升级全过程.jpg

5.锁粗化和锁消除

5.1 锁粗化

加锁的范围应该尽可能小,这样做的目的是为了使同步的操作数量尽可能缩小,缩短阻塞时间,如果存在锁竞争,那么等待锁的线程也可以尽快拿到锁。

但是加锁解锁也需要消耗资源,如果存在一系列的连续加锁解锁操作,可能会导致不必要的性能损耗,锁粗化就是将「多个连续的加锁、解锁操作连接在一起」,扩展成一个范围更大的锁,避免频繁的加锁解锁操作。

image.png JVM会检测到一连串的操作都对同一个对象加锁(for循环100次,没有锁粗化就要进行100次加锁/解锁的动作),此时JVM就会将加锁的范围粗化到这一连串操作的外部(比如for循环体外),使得这一连串操作只需要加一次锁即可。

我们将代码编译后,通过 javap -v Test.class 可以看下字节码指令,发现它只确实只有一次monitorenter/monitorexit 指令。

5.2 锁消除

在动态编译同步块的时候,JIT编译器可以借助逃逸分析来判断 同步块所使用的锁对象是否只能被一个线程访问而没有被发布到其他线程。如果是,那么JIT编译器在编译这个同步块的时候就会取消对这部分代码的同步。这样就大大提高了并发性和性能。这个取消同步的过程,叫做同步省略,也叫锁消除

逃逸分析的基本行为就是分析对象的动态作用域:

  1. 当一个对象在方法中被定义后,对象只在方法内部使用,则认为没有发生逃逸
  2. 当一个对象在方法中被定义后,它被外部方法所引用,则认为发生逃逸。例如,作为调用参数传递到其他地方中。

我们简单看下如下代码: image.png

从开发角度看,上面截图中的这个锁是没有意义的,因为每个线程进来,都是重新 new 了一个对象,即不是同一个对象,所以无法起到多个线程访问同步的效果。但是在开发中这么写,还是有点区别,前者还是比后者还是会消耗一点性能,不过JIT编译器会帮我们优化(Tips:基于逃逸分析,obj对象不会逃出run这个方法的)

基于逃逸分析的同步省略优化后:

image.png

不过在字节码中还是仍然可以看到我们的锁指令,只是在运行时候才会去掉。

javap -v MyTest.class
复制代码

image.png


以上就是我对synchronized关键字的理解,限于本人水平有限,文章难免有错误之处,欢迎大家指正,再次感谢!

感谢以下文章对我的帮助。
参考文档1
参考文档2
参考文档3
参考文档4
参考文档5
参考文档6

分类:
后端
标签:
收藏成功!
已添加到「」, 点击更改