上文我们总结了 synchronized 关键字的基本用法以及作用,并未涉及 synchronized 底层是如何实现的,所谓刨根问底,本文我们就开始 synchronized 原理的探索之旅吧(*>﹏<*)。

1. 对象锁是什么

   不同于ReentrantLock的显式加锁,synchronized 的加锁方式属于隐式加锁,从代码中看我们只知道当线程执行到被synchronized包围的代码块时会获取锁,那这把锁到底是什么?如何获取?其实在前面的学习中,我们可以有个直观的感觉,这把锁是一个对象(类的当前实例对象、类的class对象或者指定的某个任意对象),但是是这样吗?

  既然锁和对象有很大关系,那我们不妨考虑一下对象,什么是Java对象?

  我的回答是存在于虚拟机堆上的一系列字节,我觉可以从这个层面来解释。在HotSpot虚拟机中,对象在内存中存储的布局可以分为3块区域:对象头(Header)、实例数据(Instance Data)、对其填充(Padding)。其中,对象头包括两部分(有关这部分的详细内容总结见--Java读书笔记之内存管理):

  • 第一部分用于存储对象自身的运行时数据,如哈希码、GC分代年龄、锁状态标志线程持有的锁、偏向线程ID、偏向时间戳等;
  • 第二部分是类型指针,指向该对象的类的元数据的指针;

  看到了吗,锁相关的信息其实是存储在对象头中,在对象处于各种状态下(未锁定、轻量级锁定、重量级锁定、GC标记、偏斜锁)对象头中存储的内容见下表:

  当对象处于重量级锁定时(为了简单起见,我们暂且考虑这一种情况,后文有更详细论述不同级别的锁)对象头中存储的内容是指向重量级锁的指针(我们暂且先忽略重量级),也就是说,对象头中存有一个指针,指向一把锁,这把锁也就是synchronized的对象锁,这其实是一个monitor对象(C++实现),里面会记录获取锁的线程以及竞争线程的一些相关信息,我们可以大致了解一下:

ObjectMonitor(){
   _count         = 0;
   _owner         = NULL;
   _WaitSet       = NULL;
   _WaitSetLock   = 0;
   _EntryList     = NULL;
}

  在HotSpot中,monitor是由ObjectMonitor实现的,如上是其中的几个关键属性,当多个线程访问同一段同步代码时,会将其先存放到_EntryList队列中,当某个线程获取到对象的monitor后会将_owner变量设置为指向持有ObjectMonitor对象的线程也就是当前线程,同时_count会加1,如果线程调用wait()则会释放持有的monitor,_owner会被置为null,_count减1,并且该线程进入_WaitSet队列中,等待下一次被唤醒。若当前执行完毕,也将释放monitor,同时_ownner置空,_count减1,线程退出。

2. 如何加锁

   现在我们知道synchronized所使用的对象锁是什么东西了(虽然monitor是基于C++实现的,而本文并没有深入到C++源码级别来探讨monitor的实现原理O__O"),至少有了一个更直观上的认识,我们可以从字节码层面来看一下加了synchronized关键字后多了什么操作。

   这里先写一个小demo:

public class STest {
    public static void main(String[] args) {
        int i = 0;
        synchronized(STest.class) {
            i++;
        }
    }

    public synchronized void testMethod() {
        int i = 0;
        i ++ ;
    }
}

  

  然后进入cmd命令窗口,在对应class文件所在目录下输入:javap -verbose STest,输出字节码文件如下(这里只截取了部分):

  public static void main(java.lang.String[]);
    descriptor: ([Ljava/lang/String;)V
    flags: ACC_PUBLIC, ACC_STATIC
    Code:
      stack=2, locals=3, args_size=1
         0: iconst_0
         1: istore_1
         2: ldc           #1                  // class testPackage/STest
         4: dup
         5: astore_2
         6: monitorenter
         7: iinc          1, 1
        10: aload_2
        11: monitorexit
        12: goto          18
        15: aload_2
        16: monitorexit
        17: athrow
        18: return
      Exception table:
         from    to  target type
         。。。

  public synchronized void testMethod();
    descriptor: ()V
    flags: ACC_PUBLIC, ACC_SYNCHRONIZED
    Code:
      stack=1, locals=2, args_size=1
         0: iconst_0
         1: istore_1
         2: iinc          1, 1
         5: return
      LineNumberTable:
        line 12: 0
        。。。

  在main方法中有一个同步代码块,里面完成了一个自增操作,对应的字节码是第6行的monitorenter和第11以及16行的monitorexit这两个指令,所以被同步块包围的代码在生成字节码时会被monitorenter、monitorexit这对指令包围,我们可以理解为线程执行到monitorenter时会获取锁,执到monitorexit时则会释放锁。JVM会保证每一个monitorenter指令都有一个monitorexit指令与之相对应,即只要获取锁就有释放锁操作与之对应。

   而对于方法testMethod(),字节码中并没有出现monitorenter和monitorexit这对指令,对于被synchronized修饰的方法,JVM是通过标识符ACC_SYNCHRONIZED该方法是一个同步方法,从而执行如上类似的操作。

3. synchronized如何保证线程安全

  好了,现在我们清楚了synchronized使用的锁是什么以及虚拟机在字节码层面是如何实现加锁以及释放锁的,我们再来理解synchronized是如何保证原子性、可见性以及有序性就更容易了。

原子性

  当一个线程获取一把锁(执行monitorenter指令)后,其他线程如果尝试获取同一把锁则会阻塞,直到锁被释放(执行monitorexit并且_count值减为0)才会重新获取锁,获取锁成功的线程则会开始执行同步代码,这就保证了同一时刻只有一个线程在执行一段代码,并且从线程获取锁到释放锁这个过程中,该线程是不会被其他线程打断的,这也就保证了线程在执行这段代码时的原子性。

可见性

   同步块保证可见性主要是通过:

  • 线程获取锁时,JVM会把该线程对应的被同步块保护的共享变量在本地的副本置为无效,并从主存中读取;
  • 线程释放锁时,JVM会把该线程对应的被同步块保护的共享变量从本地内存中更新到主内存中;

  这就使得程序进入同步块时,从主存中获取共享变量最新数据至线程本地副本,退出同步块时将共享变量本地副本更新至主存中,从而保证可见性。

有序性

   关于有序性,synchronized的实现方式和volatile关键字是不一样的,前者是关键字本身就有禁止指令重排序的语义,而synchronized是靠“一个变量同一时刻只允许一条线程对其进行lock操作”这条规则来保证线程操作之间的有序性,可以理解为持有同一把锁的两个同步块只能串行地进入。我们先举一个例子:

//线程1:
context = loadContext();   //语句1
inited = true;             //语句2

//线程2:
while(!inited ){
  sleep();
}
doSomethingwithconfig(context);

  上面代码中,由于语句1和语句2没有数据依赖性,因此可能会被重排序。假如发生了重排序,在线程1执行过程中先执行语句2,而此时线程2会以为初始化工作已经完成,那么就会跳出while循环,去执行doSomethingwithconfig(context)方法,但此时context可能并没有初始化完成,就会导致程序出错。

  这里如果给变量inited添加volatile关键字修饰,就可以解决问题,但是如果用synchronized怎么解决呢?我的理解是对inited的赋值操作通过同步块来保护,因为在线程获取synchronized锁时会强制将本地的变量更新回主存中,对应如上代码就是会将context更新回内存中,这代表context已经载入了,当退出synchronized时会把inited更新回主存中,所以线程2监控到inited为true的时候context已经初始化完毕了,再执行doSomethingwithconfig就没有问题了。

//线程1:
context = loadContext();       //语句1
synchronized(Object.class){
     inited = true;              //语句2
}

//线程2:
while(!inited ){
  sleep();
}
doSomethingwithconfig(context);

  

4. synchronized优化

  前面我们我到synchronized经过编译之后,会在同步块的前后分别形成monitorenter和monitorexit这两个字节码指令,在执行monitorenter时,会尝试获取对象的锁,如果成功就执行同步块中的代码,在锁被释放前,其他试图获取锁的线程将阻塞。而Java的线程是映射到操作系统的原生线程之上的,如果要阻塞或唤醒一个线程,都需要操作系统来帮忙,这需要从用户态转换到核心态中,这会耗费很多的处理器时间,是一个重量级操作,所以JDK1.5以后,JVM对此进行了大刀阔斧的改进,如自旋锁(Adaptive Spinning)、锁消除(Lock Elimination)、锁粗化(Lock Coarsening)、偏斜锁(Biased Locking)、轻量级锁(Lightweight Locking)等。这些技术都是为了在线程间更高效地共享数据,以及解决竞争问题,从而提高程序的执行效率。

自旋锁

   在利用synchronized进行线程间互斥同步时,阻塞的实现是一个很耗性能的操作,这会给系统的并发性能带来很大压力。并且在许多应用上,共享数据的锁定状态只会持续很短的一段时间,为了这段时间去挂起和恢复线程并不值得。如果物理机器有一个以上的处理器,能让两个或以上的线程同时并行执行,我们就可以让后面请求锁的那个线程“稍等一下”,看看持有锁的线程是否很快就会释放锁。为了让线程等待,我们只需让线程执行一个忙循环(自旋),这项技术就是所谓的自旋。

  自旋等待本身虽然避免了线程切换的开销,但是它是要占用CPU时间的,如果锁被占用的时间很长,那只会白白消耗处理器资源,反而会带来性能上的浪费,因此自旋等待的时间必须要有一定的限度,超过一定次数就应该使用传统方式来挂起线程,默认值是10次,可以使用参数-XX:PreBlockSpin来更改。

轻量级锁

   轻量级锁是JDK1.6之中加入的新型锁机制,它名字中的“轻量级“是相对于使用操作系统互斥量来实现的传统锁而言的(即我们马上要介绍的重量级锁)。

  要理解轻量级锁,以及后面会讲到的偏斜锁的原理和运作过程,必须了解虚拟机的对象(对象头部分)的内存布局,前面我们有提到。 对象头中包含用于存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄(Generational GC Age)等,官方称它为“Mark Word”,它是实现偏斜锁和轻量级锁的关键。

  对象头信息是与对象自身定义的数据无关的额外存储成本,考虑到虚拟机的空间效率,Mark Word被设计成一个非固定的数据结构以便在极小的空间内存储尽量多的信息,它会根据对象的状态复用自己的存储空间。例如,在32位的HotSpot虚拟机中对象未被锁定的状态下, Mark Word的32bit空间中的25bit用于存储对象哈希码(HashCode),4bit用于存储对象分代年龄,2bit用于存储锁标志位,1bit固定为0,在其他状态下的详细存储内容见下表:

synchronized底层实现学习-LMLPHP

  简单介绍完对象的内存布局后,我们再回到轻量级锁的执行过程上。在代码进入同步块的时候,如果此同步对象没有被锁定(锁标志位为“01”状态),虚拟机首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的Mark Word的拷贝(官方把这份拷贝加了一个Displaced前缀,即Displaced Mark Word),这时候线程堆栈与对象头的状态如下图左侧所示:

  然后,虚拟机将使用CAS操作尝试将对象的Mark Word更新为指向Lock Record的指针。如果这个更新动作成功了,那么这个线程就拥有了该对象的锁,并且对象Mark Word的锁标志位(Mark Word的最后2bit)将转变为“00”,即表示此对象处于轻量级锁定状态,这时候线程堆栈与对象头的状态如下图右侧所示。

synchronized底层实现学习-LMLPHP

  如果这个更新操作失败了,虚拟机首先会检查对象的Mark Word是否指向当前线程的栈帧,如果是,说明当前线程已经拥有了这个对象的锁,那就可以直接进入同步块继续执行,否则说明这个锁对象已经被其他线程抢占了。

  上面描述的是轻量级锁的加锁过程,它的解锁过程也是通过CAS操作来进行的,如果对象的Mark Word任然指向着线程的锁记录,那就用CAS操作将对象当前的Mark Word替换为获取锁时保存在线程栈帧中的Displaced Mark Word,如果替换成功,整个同步过程就完成了。如果替换失败,说明有其他线程尝试过获取该锁,那就要在释放锁的同时,唤醒被挂起的线程。

偏斜锁

  偏斜锁是JDK1.6中引入的一项锁优化,它的目的是消除数据在无竞争情况下的同步原语,提高程序的运行性能。

  偏斜锁,顾名思义,就是这个锁会偏向于第一个获得它的线程,如果在接下来的执行过程中,该锁没有被其他的线程获取,则持有偏斜锁的线程将永远不需要再进行同步。

  假设当前虚拟机启用了偏斜锁(启用参数-xx:+Use Biased Locking,这是JDK1.6的默认值),那么,当锁对象第一次被线程获取的时候,虚拟机将会把对象头中的标志位设为“01”,即偏斜模式。同时使用CAS操作把获取到这个锁的线程的ID记录在对象的Mark Word之中,如果CAS操作成功,持有偏斜锁的线程以后每次进入这个锁相关的同步块时,虚拟机都可以不再进行任何同步操作(例如Locking、 Unlocking及对Mark Word的Update等)。
  当有另外一个线程去尝试获取这个锁时偏斜模式就宣告结束。根据锁对象目前是否处于被锁定的状态,撤销偏斜(Revoke Bias)后恢复到未锁定(标志位为“01”)或轻量级锁定(标志位为“00”)的状态,后续的同步操作就如上面介绍的轻量级锁那样执行。偏向锁、轻量级锁的状态转化及对象 Mark Word的关系如下图所示。

synchronized底层实现学习-LMLPHP

重量级锁

   如果有两条以上的线程争用同一个锁,那轻量级锁就不再有效,要膨胀为重量级锁,锁标志的状态值变为“10”,Mark Word中存储的就是指向重量级锁(互斥量)的指针,后面等待锁的线程也要进入阻塞状态。这里的重量级锁就是本开开头所说的monitor对象,早期的虚拟机中,synchronized的获取锁操作仅此一种,因为比较消耗性能,所以称为重量级锁,其获取过程上文有论述。

  综上,整个获取锁的过程可以总结如下(此处为个人理解,如有不对,欢迎指正^_^):

  1. 如果虚拟机开启偏斜锁,会先获取偏斜锁,如果没有则会直接获取轻量级锁;
  2. 这时如果有另一个线程尝试获取锁,首先它会自旋一定次数,如果自旋结束锁依旧没有释放,则它会尝试获取锁;
  3. 这时步骤1中如果是获取的偏斜锁,则会升级成为轻量级锁,如果这是依然存在竞争,则会升级成为重量级锁;

5. 总结

  本文我们学习了synchronized是如何实现的,有什么作用, 以及现代JVM对synchronized所做的优化。

  • synchronized可以实现原子性、可见性、有序性;
  • synchronized获取monitor是发生在进入同步块时执行monitorenter指令时;
  • 现代JVM对synchronized进行了大量优化,提供了三种不同的monitor实现:偏斜锁、轻量级锁、重量级锁;

  轻量级锁能提升程序同步性能的依据是“对于绝大部分的锁,在整个同步周期内都是不存在竞争的”,这是一个经验数据。如果没有竞争,轻量级锁使用CAS操作避免了使用互斥量的开销,但如果存在锁竞争,除了互斥量的开销外,还额外发生了CAS操作,因此在有竞争的情况下,轻量级锁会比传统的重量级锁更慢。

  轻量级锁并不是用来代替重量级锁的,它的本意是在没有多线程竞争的前提下,减少传统的重量级锁使用操作系统互斥量产生的性能消耗。 

  如果说轻量级锁是在无竞争的情况下使用CAS操作去消除同步使用的互斥量,那偏斜锁就是在无竞争的情况下把整个同步都消除掉,连CAS操作都不做了。

参考文献:

Moniter的实现原理

<<深入理解Java虚拟机:JVM高级特性与最佳实践>>--周志明

12-23 19:36