synchronized和volatile原理解析

synchronizedvolatile是java提供的两个解决并发问题的关键字,本文将深入解析synchronizedvolatile的实现原理,并可从中了解内存屏障、对象头、自旋锁、偏向锁等内容。

volatile特性

volatile是java虚拟机提供的最轻量级的同步机制。当一个变量定义为volatile之后,它将具备两种特性:
1、保证此变量对所有线程的可见性,即当一个线程修改了这个变量的值,新值对于其他线程来说是可以立即得知的。
2、禁止指令重排序

volatile如何保证可见性

以单例模式的demo为例:

public class LazyDoubleSyn implements Serializable {
    private static volatile LazyDoubleSyn lazyDoubleSyn = null;

    private LazyDoubleSyn(){};

    public static LazyDoubleSyn getLazyDoubleSyn(){
        if(lazyDoubleSyn == null){
            synchronized (LazyDoubleSyn.class){
                if(lazyDoubleSyn == null){
                    lazyDoubleSyn  = new LazyDoubleSyn();
                }
            }
        }
        return lazyDoubleSyn;
    }
}

通过生成汇编代码,可以清晰的看到加入volatile和未加入volatile的差别。volatile变量修饰的共享变量,在进行写操作的时候会多出一个lock前缀的汇编指令,这个指令会触发总线锁或者缓存锁,通过缓存一致性协议来解决可见性问题。(可从Java内存模型简介了解缓存一致性协议)

0x01a3de1d:movb $0x0,0x1104800(%esi)  ; ...c6860048 100100
0x01a3de24:lock addl $0x0,(%esp)  ; ...f0830424 00
volatile如何保证有序性

在分析保证有序性前,有必要了解一下内存屏障。内存屏障(Memory Barriers,Intel称之Memory Fence)指令是指,重排序时不能把后面的指令重排序到内存屏障之前的位置。CPU把内存屏障分成三类:写屏障(store barrier)、读屏障(load barrier)和全屏障(Full Barrier)。
写屏障store barrier相当于storestore barrier, 强制所有在storestore内存屏障之前的所有执行,都要在该内存屏障之前执行,并发送缓存失效的信号。所有在storestore barrier指令之后的store指令,都必须在storestore barrier屏障之前的指令执行完后再被执行。


读屏障load barrier相当于loadload barrier,强制所有在load barrier读屏障之后的load指令,都在loadbarrier屏障之后执行。

全屏障full barrier相当于storeload,是一个全能型的屏障,因为它同时具备前面两种屏障的效果。强制了所有在storeload barrier之前的store/load指令,都在该屏障之前被执行,所有在该屏障之后的的store/load指令,都在该屏障之后被执行。

在JMM中把内存屏障指令分为4类:
LoadLoad Barriers,load1 ; LoadLoad; load2 ,确保load1数据的装载优先于load2及所有后续装载指令的装载。
StoreStore Barriers,store1; storestore;store2 ,确保store1数据对其他处理器可见优先于store2及所有后续存储指令的存储。
LoadStore Barries, load1;loadstore;store2,确保load1数据装载优先于store2以及后续的存储指令刷新到内存。
StoreLoad Barries, store1; storeload;load2, 确保store1数据对其他处理器变得可见, 优先于load2及所有后续装载指令的装载;这条内存屏障指令是一个全能型的屏障同时具有其他3条屏障的效果。

编译器在生成字节码时,会在volatile指令序列中插入内存屏障来禁止特定类型的处理器重排序。对于编译器来说,发现一个最优布置来最小化插入屏障的总数几乎不可能。为此,JMM采取保守策略。下面是基于保守策略的JMM内存屏障插入策略。
在每个volatile写操作的前面插入一个StoreStore屏障。
在每个volatile写操作的后面插入一个StoreLoad屏障。
在每个volatile读操作的前面插入一个LoadLoad屏障。
在每个volatile读操作的后面插入一个LoadStore屏障。

volatile为什么不能保证原子性

下面以volatile变量自增为例:

public class VolatileDemo {
    public static volatile int race = 0;

    public static void increase(){
        race++;
    }

    public static void main(String[] args) throws InterruptedException {
        Thread[] threads = new Thread[20];
        for (int i=0 ;i<20;i++){
            threads[i] = new Thread(new Runnable() {
                @Override
                public void run() {
                    for (int i=0;i<1000;i++){
                        increase();
                    }
                }
            });
            threads[i].start();
        }

        while (Thread.activeCount() >1){
            Thread.yield();
            System.out.println(race);
        }
    }
}

反编译字节码文件可以看到,自增操作会分为三个步骤:1.读取volatile变量的值到local;2.增加变量的值;3.把local的值写回让其他线程可见。可以看到,volatile不能保证原子性。

 Code:
       0: iconst_0
       1: istore_1
       2: iload_1
       3: sipush        1000
       6: if_icmpge     18
       9: invokestatic  #2     

synchronized特性

在多线程并发编程中synchronized一直是元老级角色,很多人都会称呼它为重量级锁。通过反编译成字节码指令可以看到,synchronized会在同步块的前后分别形成monitorentermonitorexit这两个字节码指令。根据虚拟机规范的要求,在执行monitorenter指令时,首先要尝试获取对象的锁。如果这个对象没被锁定,或者当前线程已经拥有了那个对象的锁,把锁的计数器加1,相应的,在执行monitorexit指令时会将锁计算器减1,当计数器为0时,锁就被释放。

Java的线程是映射到操作系统的原生线程上的,如果要阻塞或唤醒一个线程,都需要操作系统来帮忙完成,这就需要从用户态转换到核心态,状态转换需要耗费很多处理器的时间。这也是为什么synchronized被称为重量级锁的原因。JDK1.6后加入了很多针对锁的优化措施,包含偏向锁、轻量级锁、重量级锁; 在了解synchronized锁之前,需要了解两个重要的概念,一个是对象头,另一个是monitor。

Java对象头

在HotSpot虚拟机中,对象在内存中的布局分为三块区域:对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)。
HotSpot虚拟机的对象头包括两部分信息,第一部分用于存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程 ID、偏向时间戳等等,这部分数据的长度在32位和64位的虚拟机中分别为32bit和64bit,官方称它为Mark Word。考虑到虚拟机的空间效率,Mark Word被设计成一个非固定的数据结构,下图为32位MarkWord的数据结构,synchronized源码实现就用了Mark Word来标识对象加锁状态。

对象头的另一部分是类型指针,即对象指向它的类元数据的指针,虚拟机通过这个指针来确定对象是哪个类的实例。

Monitor

Java中每个对象都有一个监视器ObjectMonitor,监视器锁的数据结构为:
oopDesc--继承-->markOopDesc--方法monitor()-->ObjectMonitor-->enter、exit 获取、释放锁
1、oopDesc,openjdk\hotspot\src\share\vm\oops\oop.hpp下oopDesc类是JVM对象的顶级基类,故每个object都包含markOop。如下图所示:

 1 class oopDesc {
 2   friend class VMStructs;
 3  private:
 4   volatile markOop  _mark;//markOop:Mark Word标记字段
 5   union _metadata {
 6     Klass*      _klass;//对象类型元数据的指针
 7     narrowKlass _compressed_klass;
 8   } _metadata;
 9 
10   // Fast access to barrier set.  Must be initialized.
11   static BarrierSet* _bs;
12 
13  public:
14   markOop  mark() const         { return _mark; }
15   markOop* mark_addr() const    { return (markOop*) &_mark; }
16 
17   void set_mark(volatile markOop m)      { _mark = m;   }
18 
19   void    release_set_mark(markOop m);
20   markOop cas_set_mark(markOop new_mark, markOop old_mark);
21 
22   // Used only to re-initialize the mark word (e.g., of promoted
23   // objects during a GC) -- requires a valid klass pointer
24   void init_mark();
25 
26   Klass* klass() const;
27   Klass* klass_or_null() const volatile;
28   Klass** klass_addr();
29   narrowKlass* compressed_klass_addr();
}

2、markOopDesc,openjdk\hotspot\src\share\vm\oops\markOop.hpp下markOopDesc继承自oopDesc,并拓展了自己的方法monitor(),如下图

1 ObjectMonitor* monitor() const {
2     assert(has_monitor(), "check");
3     // Use xor instead of &~ to provide one extra tag-bit check.
4     return (ObjectMonitor*) (value() ^ monitor_value);
5   }

3、ObjectMonitor,在HotSpot虚拟机中,最终采用ObjectMonitor类实现monitor openjdk\hotspot\src\share\vm\runtime\objectMonitor.hpp源码如下:


 1 ObjectMonitor() {
 2     _header       = NULL;//markOop对象头
 3     _count        = 0;
 4     _waiters      = 0,//等待线程数
 5     _recursions   = 0;//重入次数
 6     _object       = NULL;//监视器锁寄生的对象。锁不是平白出现的,而是寄托存储于对象中。
 7     _owner        = NULL;//指向获得ObjectMonitor对象的线程或基础锁
 8     _WaitSet      = NULL;//处于wait状态的线程,会被加入到wait set;
 9     _WaitSetLock  = 0 ;
10     _Responsible  = NULL ;
11     _succ         = NULL ;
12     _cxq          = NULL ;
13     FreeNext      = NULL ;
14     _EntryList    = NULL ;//处于等待锁block状态的线程,会被加入到entry set;
15     _SpinFreq     = 0 ;
16     _SpinClock    = 0 ;
17     OwnerIsThread = 0 ;// _owner is (Thread *) vs SP/BasicLock
18     _previous_owner_tid = 0;// 监视器前一个拥有者线程的ID
19   }

每个线程都有两个ObjectMonitor对象列表,分别为free和used列表,如果当前free列表为空,线程将向全局global list请求分配ObjectMonitor。

ObjectMonitor对象中有两个队列:_WaitSet 和 _EntryList,用来保存ObjectWaiter对象列表;


synchronized锁的获取和升级过程

在jdk1.6中对锁的实现引入了大量的优化来减少锁操作的开销:
锁粗化(Lock Coarsening):将多个连续的锁扩展成一个范围更大的锁,用以减少频繁互斥同步导致的性能损耗。

锁消除(Lock Elimination):JVM及时编译器在运行时,通过逃逸分析,如果判断一段代码中,堆上的所有数据不会逃逸出去从来被其他线程访问到,就可以去除这些锁。

轻量级锁(Lightweight Locking):JDK1.6引入。在没有多线程竞争的情况下避免重量级互斥锁,只需要依靠一条CAS原子指令就可以完成锁的获取及释放。

偏向锁(Biased Locking):JDK1.6引入。目的是消除数据再无竞争情况下的同步原语。使用CAS记录获取它的线程。下一次同一个线程进入则偏向该线程,无需任何同步操作。

适应性自旋(Adaptive Spinning):为了避免线程频繁挂起、恢复的状态切换消耗。产生了忙循环(循环时间固定),即自旋。JDK1.6引入了自适应自旋。自旋时间根据之前锁自旋时间和线程状态,动态变化,用以期望能减少阻塞的时间。

锁升级:自适应自旋锁--》偏向锁--》轻量级锁--》重量级锁

自旋锁和自适应自旋锁:
虚拟机的开发团队注意到在许多应用上,共享数据的锁定状态只会持续很短的一段时间,为了这段时间去挂起和恢复线程并不值得。如果能让两个或以上的线程同时并行执行,我们就可以让后面请求锁的那个线程“稍等一下”,但不放弃处理器的执行时间,看看持有锁的线程是否很快就会释放锁。这项技术就是所谓的自选锁。
自旋等待的时间必须要有一定的限度,如果自旋超过了限定的次数仍然没有获取到锁,则该线程应该被挂起。在JDK1.6中引入了自适应的自旋锁,自适应意味着自旋的时间不再固定,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。

偏向锁:
大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,为了让线程获得锁的代价更低而引入了偏向锁。当一个线程访问同步块并获取锁时,会在对象头和栈帧中的锁记录里存储锁偏向的线程ID,以后该线程在进入和退出同步块时不需要进行CAS操作来加锁和解锁,只需简单地测试一下对象头的Mark Word里是否存储着指向当前线程的偏向锁。如果测试成功,表示线程已经获得了锁。如果测试失败,则需要再测试一下Mark Word中偏向锁的标识是否设置成1(表示当前是偏向锁):如果没有设置,则使用CAS竞争锁;如果设置了,则尝试使用CAS将对象头的偏向锁指向当前线程。


轻量级锁:
如果MarkWord不是自己的ThreadId,锁升级,这时候,用CAS来执行切换,新的线程根据MarkWord里面现有的ThreadId,通知之前线程暂停,之前线程将Markword的内容置为空。
两个线程都把对象的Mark Word复制到自己新建的用于存储锁的记录空间(在当前线程的栈帧中建立一个名为Lock Record的空间,官方把这份拷贝称为Displaced Mark Word),接着开始通过CAS操作,把共享对象的MarKword更新为指向Lock Record的指针。

重量级锁:
自旋的线程在自旋过程中,成功获得资源(即之前获的资源的线程执行完成并释放了共享资源),则整个状态依然处于轻量级锁的状态,如果自旋失败则进入重量级锁的状态,这个时候,自旋的线程进行阻塞,等待之前线程执行完成并唤醒自己,需要从用户态切换到内核态实现。

1)只有一个线程进入临界区,偏向锁

2)多个线程交替进入临界区,轻量级锁

3)多线程同时进入临界区,重量级锁

你可能感兴趣的:(synchronized和volatile原理解析)