深入理解 synchronized

在多线程并发编程中synchronized一直是元老级角色,被很多人称为重量级锁。但是,这都是JDK1.6之前的事了,随着JDK1.6对synchronized进行了各种优化之后,其性能得到了很大的提升,重量级只是部分情况了。

synchronized 的实现原理

synchronized实现同步的基础:Java中的每一个对象都可以作为锁。表现为以下3种形式:

  • 对于普通同步方法,锁是当前实例对象

  • 对于静态同步方法,锁是当前类的Class对象

  • 对于同步方法块,锁是Synchronized括号里配置的对象

当一个线程试图访问同步代码块时,它首先必须得到锁,退出或抛出异常时必须释放锁。看如下源码和字节码:

//源码
public class Synchronized {

    public static void main(String[] args) {

        synchronized (Synchronized.class){

        }

        m();
    }
    public static synchronized void m(){

    }
}

//对应的Java字节码
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: ldc           #2                  // class Synchronized
     2: dup
     3: astore_1
     4: monitorenter             //插入同步代码块的开始位置
     5: aload_1
     6: monitorexit              //插入同步代码块的结束位置
     7: goto          15
    10: astore_2
    11: aload_1
    12: monitorexit              
    13: aload_2
    14: athrow
    15: invokestatic  #3                  // Method m:()V
    18: return
  Exception table:
     from    to  target type
         5     7    10   any
        10    13    10   any
  LineNumberTable:
    line 5: 0
    line 7: 5
    line 9: 15
    line 10: 18
  StackMapTable: number_of_entries = 2
    frame_type = 255 /* full_frame */
      offset_delta = 10
      locals = [ class "[Ljava/lang/String;", class java/lang/Object ]
      stack = [ class java/lang/Throwable ]
    frame_type = 250 /* chop */
      offset_delta = 4

public static synchronized void m();
descriptor: ()V
flags: ACC_PUBLIC, ACC_STATIC, ACC_SYNCHRONIZED
Code:
  stack=0, locals=0, args_size=0
     0: return
  LineNumberTable:
    line 14: 0
}

 Synchronized在JVM中的实现原理:JVM基于进入和退出Monitor对象来实现方法同步和代码块同步(对象监视器机制)。monitorenter指令是在编译后插入同步代码块的开始位置,monitorexit指令插入到方法结束处和异常处,monitorenter和monitorexit时成对出现的。任何对象都有一个monitor与之关联,当一个monitor被持有之后,该对象将处于锁定状态。线程执行到monitorenter指令时,将会尝试获取对象所对应的monitor的所有权,即尝试获得该对象的锁。

Java对象头

在说synchronized实现的锁之前,先说一下Java的对象头, synchronized用的锁是存在Java对象头里的。

HotSpot虚拟机对象头包括两部分信息:

  • 第一部分用于存储对象自身的运行时数据,如哈希吗(HashCode),GC分代年龄,锁状态标志,线程持有的锁,偏向线程ID,偏向时间戳等。这部分数据的长度在32位和64位的虚拟机中分别为32Bits和64Bits,官方称为 “Mark Word”,它是实现轻量级锁和偏向锁的关键。

  • 第二部分用于存储指向方法区对象类型数据的指针,如果是数组对象的话,还会有一个额外的部分用于存储数组长度。

image

 对象头信息是与对象自身定义的数据无关的额外的存储成本,考虑到虚拟机的空间效率,Mark Word 被设计成一个非固定的数据结构以便在极小的空间内存存储尽量多的信息,它会根据对象的状态复用自己的存储空间。

32位JVM的Mark Word的默认存储结构如下表:

锁状态 25bit 4bit 1bit是否是偏向锁 2bit锁标志位
无状态锁 对象的HashCode 对象分代年龄 0 01

64位JVM的Mark Word的默认存储结构如下表:

image

HotSpot虚拟机对象头中Mark Word中的存储内容,标志位和状态:

存储内容 标志位 状态
对象哈希吗, 对象分代年龄 01 未锁定
指向锁记录的指针 00 轻量级锁定
指向重量级锁的指针 10 膨胀(重量级锁定)
空,不需要记录信息 11 GC标记
偏向线程ID,偏向时间戳,对象分代年龄 01 可偏向

32位HotSpot虚拟机Mark Word的状态变化

image

锁的状态

 JDK1.6为了减少获得锁和释放锁带来的性能消耗,引入“偏向锁”和“轻量级锁”,JDK1.6中的锁一共有四种状态,级别从低到高依次是:无锁状态,偏向锁状态,轻量级锁状态,重量级锁状态,这几个状态会随着竞争情况逐渐升级。锁可以升级但不能降级,如果偏向锁升级成轻量级锁,将无法降级为偏向锁。这种锁升级但不降级的策略是为了提高获得锁和释放锁的效率。

偏向锁

 偏向锁的目的是消除数据在无竞争情况下的同步原语,进一步提高程序的运行性能。如果说轻量级锁是在无竞争的情况下使用CAS操作去消除同步使用的互斥量,那偏向锁就是在无竞争的情况下把整个同步都消除掉,连CAS操作都不做

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

image

  • 偏向锁的撤销

 偏向锁使用了一种等到竞争出现才释放锁的机制&,所以当其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁。偏向锁的撤销,需要等待全局安全点(在这个时间点上没有正在执行的字节码)。它会首先暂停拥有偏向锁的线程,然后检查持有偏向锁的线程是否活着,如果线程不处于活动状态,则将对象头设置成无锁状态(置为 01);如果线程仍然活着,拥有偏向锁的栈会被执行,遍历偏向对象的锁记录,栈中的锁记录和对象头的Mark Word要么重新偏向于其他线程,要么恢复到无锁或者标记对象不适合作为偏向锁,最后唤醒暂停的线程。下图中的线程1演示了偏向锁初始化的流程,线程2演示了偏向锁撤销的流程:

image

  • 偏向锁的开启与关闭

偏向锁是默认启用的。关闭偏向锁后,程序默认会进入轻量级锁状态。

//关闭偏向锁激活延迟:
-XX:BaisedLockingStartupDelay=0

//关闭偏向锁
-XX:UseBiasedLocking=false

//开启偏向锁
-XX:UseBiasedLocking=true
轻量级锁

轻量级锁的目的:在没有多线程竞争的前提下,减少传统的重量级锁使用操作系统互斥量产生的性能消耗。

  • 轻量级锁加锁

 在代码进入同步快的时候,如果此时同步对象吗,没有被锁定(锁标志为“01”状态),虚拟机首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的Mark Word拷贝(Displaced Mark Word)。然后把Lock Record的地址使用CAS放到Mark Word当中,并且把锁标志位改为00, 表示此对象已经处于轻量级锁定状态,可以继续进入临界区执行。

image

 如果更新操作失败了,虚拟机首先会检查对象的Mark Word是否指向当前线程的栈帧,如果指向说明当前线程已经拥有了这个对象的锁,那就可以直接进入同步块继续执行,否则说明这个锁对象已经被其他线程抢占了。如果有两条以上的线程争用同一把锁,那轻量级锁就不再有效,要膨胀为重量级锁,锁标志的状态值变为”10”,Mark Word中存储的就是指向重量级锁(互斥量:Mutex。重量级锁需要操作系统的帮忙,依赖操作系统底层的Mutex Lock)的指针,后面等待锁的线程也要进入阻塞状态。

  • 轻量级锁及膨胀流程如下

image

 因为自旋会消耗CPU,为了避免无用的自旋(比如获得锁的线程被阻塞住了),一旦锁升级成重量级锁,就不会再恢复到轻量级锁状态。当锁处于这个状态下,其他线程试图获取锁时,都会被阻塞住,当持有锁的线程释放锁之后会唤醒这些线程,被唤醒的线程就会进行新一轮的夺锁之争。

偏向锁,轻量级锁,重量级锁的状态转化以及对象Mark Word的关系图如下:

image

锁的优缺点对比
优点 缺点 适用场景
偏向锁 加锁解锁不需要额外的消耗,和执行非同步方法相比仅存在纳秒级的差距 如果线程间存在锁竞争,会带来额外的锁撤销的消耗 适用于只有一个线程访问同步块场景
轻量级锁 竞争的线程不会阻塞,提高了程序的响应速度 如果始终得不到锁竞争的线程,适用自旋会消耗CPU 追求响应时间
重量级锁 线程竞争不使用自旋,不会消耗CPU 线程阻塞,响应时间缓慢 追求吞吐量 同步块执行速度较慢

锁的内存语义(synchronized)

锁除了让临界区互斥执行外,还可以让释放锁的线程向获取同一个锁的线程发送消息(线程间通信)

synchronized建立的happens-before关系
//假设线程A执行writer方法,线程B执行reader方法
class MonitorExample {
    int a = 0;

    public synchronized void writer() { // 1 线程A获取锁
        a++;                // 2 线程A执行临界区代码
    }                       // 3 线程A释放锁

    public synchronized void reader() { // 4 线程B获取同一把锁
        int i = a;          // 5 线程B执行临界区中的代码
    ……
    }                       // 6 线程B释放锁
}

上面代码执行的过程包含的happens-before关系可以分为3类:

  • 根据程序次序规则:1 happens-before 2,2 happens-before 3;4 happens-before 5,5 happensbefore 6。

  • 根据监视器锁规则:3 happens-before 4。

  • 根据happens-before的传递性:2 happens-before 5。

锁的释放和获取的内存语义
  • 当线程释放锁时,JMM会把该线程对应的本地内存中的共享变量刷新到主内存中

image

  • 当线程获取锁时,JMM会把该线程对应的本地内存置为无效,从而使得被监视器保护的临界区代码必须从主内存中读取共享变量

image

对比锁释放——获取的内存语义和volatile写-读的内存语义:锁释放与volatile写有相同的内存语义;锁获取与volatile读有相同的内存语义。

  • 线程A释放一个锁,实质上是线程A向接下来将要获取这个锁的某个线程发出了(线程A
    对共享变量所做修改的)消息。

  • 线程B获取一个锁,实质上是线程B接收了之前某个线程发出的(在释放这个锁之前对共
    享变量所做修改的)消息。

  • 线程A释放锁,随后线程B获取这个锁,这个过程实质上是线程A通过主内存向线程B发
    送消息。

参考

猜你喜欢

转载自blog.csdn.net/king123456man/article/details/81750732
今日推荐