java并发学习笔记 第二章 Synchronized

228 阅读11分钟

       java并发设计的目的是提高系统的效率,但是在并发运行的过程中往往会涉及到资源共享。而且发生资源共享时往往很容易得到非预期的结果。如果运行的结果是错误的,那么运行的效率再高也是没有意义的。java设计并发思想之初就提供了解决方法——干预资源共享,synchronized就是最初的解决方案。synchronized可以确保一个共享资源同一时刻只会被一个线程操作,这样在合理使用synchronized的情况,既可以保有并发带来的运行效率,也能保证程序运行的正确性。

1. synchronized的使用

synchronized的三种使用场景:

  • 修饰一段代码块
  • 修饰一个实例方法
  • 修饰一个静态方法

示例代码如下:

public class Demo02 {
    private Object obj = new Object();
    //修饰一段代码块
    public void method1(){
        synchronized (obj){
            //do something
        }
    }
    //修饰一个实例方法
    public synchronized void method2(){
        //do something
    }
    //修饰一个静态方法
    public synchronized static void method3(){
        //do something
    }
}

2. synchronized的设计思想

        文章开头说到synchronized的作用是确保一个共享资源同一时刻只会被一个线程操作,它是怎么做到都呢?简单来讲,synchronized是通过排他锁来实现这一目的。

        java在设计层面为一切的对象赋予了锁操作的属性(java对象的对象头部分有一个标记字段(Mark Word),该字段中有一部分数据是锁标志相关的),而且这个锁同一时刻只能被唯一一个线程持有,但是同一线程可以又可以多次获得这个锁。获得了锁的线程可以继续往下运行;未获得锁的线程将会阻塞,直至拥有锁的线程释放了锁且自己获得到了锁才可以往下执行。

3. synchronized的底层实现

        synchronized是属于java的底层实现,看它的具体实现,可以通过查看字节码探究。通过以下命令获取上述demo的class文件的字节码:

javap -p -v -c Demo02.class

        对比那些没有被synchronized修饰的字节码文件发现,synchronized修饰代码块的字节码部分增加了monitorenter 和 monitorexit字节码指令,修饰实例方法和修饰静态方法的字节码在方法标志位增加了ACC_SYNCHRONIZED。那么monitorenter和monitorexit指令到底做了什么呢?

        java的每个对象都有一个monitor,monitor和java对象一同创建或销毁。monitor是使用C++来实现的(下面附上部分monitor的C++源码),依赖底层操作系统的mutex lock来实现互斥。线程获取monitor成功其实就是获取mutex lock成功。因为mutex lock的互斥特性,其他线程将不能再获取mutex lock。monitorenter和monitorexit其实就是对monitor的获取和释放,而JVM一旦执行到ACC_SYNCHRONIZED修饰的方法时也会隐性的调用monitorenter和monitorexit指令,所有说到底synchronized就是通过对monitor的获取和释放来发挥作用的。

ObjectMonitor() {
    _header       = NULL;
    _count        = 0;
    _waiters      = 0,
    _recursions   = 0;
    _object       = NULL;
    _owner        = NULL;
    _WaitSet      = NULL;
    _WaitSetLock  = 0 ;
    _Responsible  = NULL ;
    _succ         = NULL ;
    _cxq          = NULL ;
    FreeNext      = NULL ;
    _EntryList    = NULL ;
    _SpinFreq     = 0 ;
    _SpinClock    = 0 ;
    OwnerIsThread = 0 ;
    _previous_owner_tid = 0;
}

       现在知道了synchronzied是通过对monitor的争夺发挥作用的,那么monitor的工作原理又是怎样的呢?

         通过上面ObjectMonitor的部分源码发现monitor内部维护了两个集合_WaitSet 和 _EntryList,当一个线程A尝试争夺monitor时,这时如果monitor已经被其他线程B占有时,该线程A将会进入_EntryList集合中,等待线程B释放所之后再尝试争夺monitor;另一个集合_WaitSet是当线程调用了wait()方法之后线程进入该集合,进入_WaitSet集合的线程不会再参与monitor的争夺,直到其他线程调用了notify()或者notifyAll()方法且成功将其唤醒,被唤醒的线程将进入_EntryList集合中,之后重新参与monitor的争夺。(wait和notify相关的知识将在下一章学习)

      现在知道了monitor的大致原理,现在来看下面一个问题。

_EntryList和_WaitSet中的线程都处于阻塞状态,线程被阻塞之后便会由之前的用户态进入内核态,获得锁之后又会从内核态切换到用户态。而线程在用户态和内核态的切换时非常消耗性能的,这也是大家之前一直诟病Synchronized性能差的原因。java开发者从1.6开始解决了这些问题,下面就看下是怎么处理这个问题的。

4. synchronized优化 — 锁升级

       java开发者优化Synchronzied的总体的策略是针对并发成都的不同,采用不同的monitor获取策略。根据并发从低到高将锁逐步升级,避免低并发也使用性能损耗严重的锁,这个过程可以理解为锁升级。

       synchronized的锁升级是从偏向锁开始,随着锁竞争的不断升级,逐步升级为轻量级锁,最后变成重量级锁。底层的实现主要是通过Mark Workd 中的锁标志位和偏向锁标志位来达成的。下面来看下这几种锁:

Mark Word

       Mark Word 是java的对象头的三个组成部分之一,另外两部分是指向类的指针(说明对象是哪个类的实例)和数组长度,它记录了对象、锁及垃圾回收相关的信息。

4.1 偏向锁

       java开发者调查发现,其实在大多数情况下,锁的获取不存在竞争,而且往往是同一个线程在进行锁的获取和释放。同一个线程不断对锁进行获取和释放,锁获取和释放产生的性能消耗都是浪费,偏向锁就是针对这种情况的优化。下面看下偏向锁是怎么起到优化作用的:

       初成偏向锁:当一个线程在java对象处于无锁的状态下获取了该对象锁,这时对象头中的Mark word中将会对偏向锁进行标记,并且会存储当前线程的ID。

       发挥作用:当再次有线程尝试获得同一把锁的时候,首先只是判断下Mark Word中存储的线程ID是否是自己。如果是,线程则不需要重新获得锁(或者理解为已经获得了锁),直接执行同步代码或者同步方法;如果不是,则需要再判断Mark Word中的偏向锁位是否处于标记状态。如果未被标记,执行CAS竞争锁(成偏向锁之前);如果被标记,新来的这个线程将会尝试使用CAS将对象头的偏向锁指向当前线程。如果指向成功锁依然保持偏向锁状态,如果指向失败,偏向锁就将被撤销。

       偏向锁撤销:当原持有偏向锁的线程到达全局安全点时被暂停,然后检查线程的存活状态,如果线程处于非活动状态,原持有偏向锁的线程释放锁(偏向锁采用的是等到竞争出现时才释放锁的机制),直接将对象头设置为无锁状态;如果线程处于活动状态,锁升级为轻量级锁。

4.2 轻量级锁

       当锁升级为轻量级锁之后,这时候JVM会在当前线程(新竞争线程或升级前活动的持有偏向锁的线程)的栈帧中开辟一个Lock Record空间,然后copy一份对象头的Mark Word信息到Lock Record,该过程即Displaced Mark Word。

       然后线程尝试执行CAS操作将对象头中的Mark Word替换为指向Lock Record的指针。如果CAS执行成功则当前线程获得锁;如果CAS执行失败,当前线程会进入自旋。

       在线程自旋的过程中,如果锁被其他线程释放,自旋线程则可能成功获得锁;如果自旋线程达到自旋最大阈值时尚未获得锁,则锁升级为重量级锁。

自旋:线程在争夺重量级锁时,争抢线程的过程中往往发生用户态到内核态的切换,用户态到内核态的切换非常耗费资源,因此应该尽量避免锁升级为重量级锁,线程的自旋就是来解决这个问题的。

          像上面说到的情况,线程获取轻量级锁失败,先进行自旋,待锁被其他线程释放,线程就可能获得锁。这种策略在锁的持有时间很短,竞争不是很激烈的情况,以线程的短时间自旋换取线程状态切换带来的消耗,性价比是非常高的。

          但是,线程的自旋时会消耗CPU的,当自旋的时间过长就得不偿失了,因此JVM为自旋次数设置了阈值。默认大小为10次,开发者也可以通过参数-XX:PreBlockSpin自行设定这个值。

4.3 重量级锁

       当一个锁升级为重量级锁,如果有新的线程来尝试获得锁时,都会被阻塞,进入内核态,当持有锁的线程释放了锁之后唤醒被阻塞的线程,然后被唤醒的线程重新开始争夺锁。

5. CAS (compare and swap)

       笔记前面的内容多次提到CAS操作,那么CAS到底是什么呢?有什么作用呢?

5.1 CAS的概念

       CAS属于一种乐观锁策略,这种策略是假定所有线程对共享资源的访问不会出现并发,线程在访问共享资源时不会发生阻塞停顿,就像只有自己一个线程在使用共享资源。执行完更改共享资源状态之前先判断共享资源的状态是否已经发生了变化,无变化则执行变更,有变化则重试以上操作,直到成功为止。

       CAS过程中涉及3个变量:当前实际值V,期望值O(旧值),将赋予的新值N。CAS就是判断实际值V和期望值O是否一致,如果一致将值更新为新值N,否则重新执行对于共享资源的操作后再进行CAS。整个过程为重复执行,直至成功。

       当然,CAS也不可避免的存在以下一些问题:

  1. ABA问题:CAS判断是否可执行更新操作的依据是实际值V和期望值O的对比,如果在对比前,实际值V经过了变成他值再变成旧值的历程,这种情况下进行实际值和期望值的对比,结果是实际值没有发生变化,可以更新为新值;但真实情况不是这样,而且不可以进行值的更新,这就是ABA问题。JVM的解决方法是为共享资源添加版本号,这样进行实际值和旧值的比对时添加上版本号,就可以避免ABA的问题。参照AtomicStampedReference的实现
  2. 重复执行时间过长问题:CAS的核心是重复执行直至成功,这里如果重复次数过多,甚至进入死循环,将会大大消耗CPU资源。
  3. 只能保证一个共享变量的原子操作:无法保证多个共享变量的原子操作。JVM的解决方案是将几个共享变量整合到一个对象中,然后对整个对象做CAS操作保证对象的原子性,就解决了多个共享变量操作的原子性。参照AtomicReference

学习参考文章:

《java 高并发程序设计》
juejin.cn/post/684490…
juejin.cn/post/684490…