Java并发之synchronized

一、前言序章

  Java因为实现的是共享数据模型,在多线程操作共享数据时,会引起线程安全问题。Java为了解决线程安全问题,在Jvm层面为我们提供了一把内置锁——synchronized。接下来我将带领大家一起探索synchronized的世界。

二、synchronized基础使用

  synchronized是Jvm层面提供的内置锁,基于Monitor机制实现,它是一把重量级的锁,性能较低。在JDK1.5之后进行了重大优化,比如锁粗化、锁消除、偏向锁、轻量级锁、自适应自旋等技术来减少锁的性能开销,经过相关一系列的优化手段之后,和Lock锁的性能基本不相上下。
而在synchronized的使用方面,则是非常简单,具体使用方式如下图所示:


synchronized使用示意图

归类之后就是两种使用方式:
1、在方法上使用synchronized
当synchronized作用于实例方法时,锁住的就是实例对象;
当synchronized作用于静态方法时,锁住的就是类对象。
2、在代码块中使用synchronized
当synchronized作用于代码块时,如果不是类对象,那么锁住的就是实例对象;
如果相反,那锁住的就是类对象本身;
另有一个特殊点,就是如果是我们自己new的对象,那么锁住的也是这个对象。

三、synchronized底层原理

  当synchronized作用于方法上时,在Jvm指令层面则是通过设置acc_synchronized标志来实现;而在同步代码块中,则是通过monitorenter和monitorexit这两个指令来实现。最终都是依赖操作系统的互斥原语Mutex来实现,被阻塞的线程将被挂起、等待重新调度,而使线程挂起、唤醒会涉及到内核态和用户态之间的切换,性能损耗非常大。
当synchronized作用于方法上:


synchronized作用于方法上

JVM指令解析

当synchronized作用于代码块中:


synchronized作用于代码块

JVM指令解析

3.1 Java中的Monitor(管程)

  管程就是管理共享变量和堆共享变量操作的过程,让它们支持并发。在Java多线程访问共享资源时,会出现原子性、可见性问题,而为了解决这些问题,java就实现了多线程的同步和互斥,用于保证同一时刻只有一个线程可以访问。而这个机制的来源就是Monitor,而对Monitor的具体实现就是MESA模型。

3.2 MESA模型

MESA模型

MESA具体流程就是:当有多个线程竞争共享资源时,只有一个线程可以竞争到,其它竞争失败的线程将进入入口等待队列进行排队等待。而当线程之间需要同步操作时,就会使用条件变量,将锁释放,使自身进入条件等待队列,直到被下一个线程唤醒。

3.3Java实现Monitor机制

Java参考了MESA模型,但是对MESA进行了简化。在Java实现的MESA模型中,只有一个条件变量。


Java实现MESA

而具体的实现则是依赖JVM中ObjectMonitor,其主要数据结构如下:

ObjectMonitor() {
_header       = NULL;
_count        = 0;
_waiters      = 0,
_recursions   = 0;
_object       = NULL;
_owner        = NULL; //线程存放的地方
_WaitSet      = NULL; //低啊用wait后,线程将在这等待
_WaitSetLock  = 0 ;
_Responsible  = NULL ;
_succ         = NULL ;
_cxq          = NULL ;//多线程竞争失败后将在此排队
FreeNext      = NULL ;
_EntryList    = NULL ;//存放用于等待锁的线程
_SpinFreq     = 0 ;
_SpinClock    = 0 ;
OwnerIsThread = 0 ;
}

monitor执行流程

执行流程如下
1.当线程竞争锁失败后,会先存放到_cxq队列中(该队列是栈结构);
2.如果_EntryList为空,则将_cxq中的元素按顺序复制到_EntryList,并唤醒第一个线程去持有锁;如果_EntryList不为空,则直接从_EntryList中唤醒第一个线程;
3.当持有锁的线程调用wait(),线程将释放锁并进入_waitSet进行排队等待;
4.调用notify(),将唤醒的线程根据策略存放_cxq或_EntryList中。

3.4 synchronized锁的记录

在JVM中,对象的内存布局为对象头、实例数据、对其填充。
而在对象头中,有一个MarkWord部份,里面用于记录对象运行时所需的数据,其中锁的状态就记录在里面。64位JVM中MarkWord描述如下:


64位JVM中MarkWord示意图

ptr_to_lock_record:在轻量级锁状态下,指向的是线程栈中锁记录。
ptr_to_heavyweight_monitor:在重量级锁状态下,指向的是Monitor的指针。

四、synchronized锁的状态变化

锁的信息记录在MarkWord中,因此我们并不能直接可以观测到锁的变化信息,而需要引入一个JOL工具。锁状态的变化如下图所示:


synchronized锁状态

4.1 偏向锁

偏向锁是为了消除数据在无竞争情况下把整个同步都消除掉,连CAS都不需要做。
当JVM启用了偏向锁模式,新创建的对象的MarkWord的Thread ID为0,此时处于可偏向但是没有偏向任何线程的状态,称为匿名偏向状态。

4.2 偏向锁延迟偏向

虚拟机启动4秒后,才会对新建的对象开启偏向模式。在虚拟机启动过 程,会大量使用synchronized进行加锁操作,但是这些操作大部分都不是偏向锁,为了减少初始化时间,JVM默认开启延迟偏向。

//关闭延迟开启偏向锁
-XX:BiasedLockingStartupDelay=0
//禁止偏向锁
-XX:-UseBiasedLocking 
//启用偏向锁(默认开启)
-XX:+UseBiasedLocking 

//此demo可以看到延迟偏向状态
 public static void main(String[] args) throws InterruptedException {
        log.debug(ClassLayout.parseInstance(new Object()).toPrintable());
        Thread.sleep(4000);
        log.debug(ClassLayout.parseInstance(new Object()).toPrintable());
    }

执行结果:


执行结果

4.3 轻量级锁

  当偏向锁失败后,虚拟机并不会立即进入重量级锁,而是会先升级成轻量级锁,此时MarkWord也会变为轻量级的结构。轻量级锁适用于线程交替执行的场景。
轻量级锁加锁过程:
  首先在进入同步代码块前,会复制一份当前对象的markword到线程栈的lock record中,同时更新对象markword的指针指向lock record,然后将lock record中的owner指向当前对象。当轻量级锁重入后,会在当前线程栈中添加一份新的lock record,并且这份lock record中的markword为空,而lock record中的owner指向当前对象的位置。
轻量级锁解锁过程:
  线程栈逐步出栈,然后将lock record释放,并更新后面的lock record中的owner值,直到所有lock record都出栈后,对象头markword的将恢复到无锁数据结构。


轻量级锁加锁

4.4重量级锁

  当线程的竞争非常激烈时,使用CAS修改MarkWord失败,将会膨胀为重量级锁,此时会进行CAS自旋获取锁,如果一定时间内还是获取不到,就只能进行阻塞。

五、synchronized锁优化

5.1偏向锁批量重偏向和批量撤销

  当只有一个线程频繁进入同步代码块时,性能损耗可以忽略不计,但是如果有多线程竞争,那么偏向锁就会升级为轻量级锁,而升级锁这个操作只能在safe point中进行,并且会有一定的性能损耗,如果竞争激烈,频繁的升级锁就会使性能下降。于是就引入了批量重偏向和批量撤销机制。
实现的原理
JVM会在class中维护一个偏向锁撤销计数器——epoch,每当发生一次偏向锁撤销,该计数器便+1,当这个值达到20时,JVM就会进行偏向锁批量重偏向。当达到20阈值之后,如果计数器的值还在累加,并且达到了40时,JVM就认为当前class存在多线程竞争,将该class的偏向锁禁用,后续直接走轻量级锁。
具体的流程如下
1.每个class都有epoch,每个处于偏向锁状态的对象也有epoch(初始值就是class的epoch)
2.当达到20阈值,将发生一次批量重偏向,会在safe point中去修改class的epoch+1
3.遍历所有线程栈,找到该class中所有正处于加锁状态的偏向锁(此时当前加锁的对象仍处于synchronized代码块中),修改对象的epoch值为class的epoch值
4.下次获取锁时,判断对象的epoch和class的epoch是否相同,不同cas修改Thread ID
批量重偏向和批量撤销总结
1.批量重偏向和批量撤销针对类的优化,和对象无关
2.偏向锁只能重偏向一次
3.当触发了批量撤销操作后,该类的偏向锁机制将无效,后续创建的对象都是无锁状态

5.2 自适应自旋优化

  重量级锁再竞争锁时,会使用自旋来进行优化,如果自旋过程中获取到锁,那么就可以避免线程挂起和唤醒。线程挂起和唤醒都涉及到内核态和用户态之间的切换,性能损耗高。
而在Java6之后自旋都是自适应的,如果上次通过CAS获取到锁,那么这次自旋次数就会增加,如果上次CAS没有获取到锁,这次就会少自旋甚至不自旋。

5.3 锁粗化

如果JVM检测到有一连串零碎的操作都是对同一对象的加锁,将会扩大加锁同步的范围(即锁粗化)到整个操作序列的外部。

StringBuffer buffer = new StringBuffer();
/**
 * 锁粗化
 */
public void append(){
    buffer.append("aaa").append(" bbb").append(" ccc");
}

  比如上面的代码,经过锁粗化后,只会在第一个append("aaa")加锁,会在append("ccc")进行解锁。通过锁粗化,避免不必要的加锁解锁,以提升性能。

5.4 锁消除

锁消除和锁粗化类似,本质上都是减少不必要的加锁、解锁操作。JVM会通过逃逸分析来进行锁消除。

/**
     * 锁消除
     * -XX:+EliminateLocks 开启锁消除(jdk8默认开启)
     * -XX:-EliminateLocks 关闭锁消除
     * @param str1
     * @param str2
     */
    public void append(String str1, String str2) {
        StringBuffer stringBuffer = new StringBuffer();
        stringBuffer.append(str1).append(str2);
    }

    public static void main(String[] args) throws InterruptedException {
        LockEliminationTest demo = new LockEliminationTest();
        long start = System.currentTimeMillis();
        for (int i = 0; i < 100000000; i++) {
            demo.append("aaa", "bbb");
        }
        long end = System.currentTimeMillis();
        System.out.println("执行时间:" + (end - start) + " ms");
    }

JVM会通过逃逸分析去判断一个对象能否被外部访问到。
如果当前对象只是在某个方法内使用,那么对这个对象的操作可以不考虑同步。
如果这个对象不能被其它线程访问,那么对这个对象的操作也可以不考虑同步。

你可能感兴趣的:(Java并发之synchronized)