1 synchronized 常见使用方式

synchronized 在使用的时候,为了更细粒度地控制加锁的范围,以达到更高的并发效率,需要开发人员熟悉 sychronized 常用的使用方式以及每种方式对应的范围。

synchronized 锁包含两个方面:一种是对某个实例对象加锁,另外一种是对这个进行加锁。对类加锁,也是在针对一个对象实例进行加锁,其实他的意思就是对那个类的Class对象进行加锁。

总而言之,synchronized 可以对两种对象加锁,

  1. 对象实例
  2. Class 对象

1.1 synchronized 修饰普通方法

synchronized 修饰普通方法,那么就是对当前这个对象实例在加锁,访问同一个对象实例的synchronized方法,同一时间只有一个线程可以做到,如果是下面那种synchronized代码片段,也是这个意思。

synchronized(myObject) {
  // do something
}

但是如果是两个线程,分别进入不同的对象的synchronized方法或者代码片段,这两个线程并不会互相干扰,因为是在不同的对象上加锁。

1.2 synchronized 修饰 this 对象

我们也可以使用 synchronized 来修饰 this 对象,其实意思就是基于当前这个对象实例来加锁,如下代码所示:

synchronized(this) {
	// do something
}

1.3 synchronized 修饰一个静态方法

synchronized 修饰一个静态方法,就是对这个类的Class对象加锁,每个类都对应了一个Class对象,那么对同一个类的synchronized静态方法,同一时间只能有一个线程加锁进入其中,下面的那个代码片段,也是这个意思:

synchronized(MyObject.class) {

}

synchronized 能保证原子性、有序性和可见性。下面从分析一下 synchronized 是如何从底层实现这三个特性的。

2 synchronized 底层如何保证原子性

2.1 从字节码角度聊聊 synchronized 关键字

其实 synchronized 底层的原理,是跟 jvm 指令和 monitor 有关系的。先大致说几个概念,后面我们再解释。

  • monitor 对象
  • 反编译工具:javap
  • monitorenter 和 monitorexit 指令
  • 可重入锁

你如果用到了synchronized关键字,在底层编译后的jvm指令中,会有monitorenter和monitorexit两个指令

 monitorenter
// 代码对应的指令
monitorexit

我们可以来看个示例,如下为一个最简单的代码示例:

public class Test {
    public static void main(String[] args) {
        synchronized (Test.class) {
            System.out.println("hello world");
        }
    }
}

编译之后得到 Test.class 文件,我们可以使用内置的反编译工具 javap来看看具体的内容:

javap 命令的具体使用方法我在这里也列一下,也可以使用 javap -help 来查看:

用法: javap <options> <classes>
其中, 可能的选项包括:
  -help  --help  -?        输出此用法消息
  -version                 版本信息
  -v  -verbose             输出附加信息
  -l                       输出行号和本地变量表
  -public                  仅显示公共类和成员
  -protected               显示受保护的/公共类和成员
  -package                 显示程序包/受保护的/公共类
                           和成员 (默认)
  -p  -private             显示所有类和成员
  -c                       对代码进行反汇编
  -s                       输出内部类型签名
  -sysinfo                 显示正在处理的类的
                           系统信息 (路径, 大小, 日期, MD5 散列)
  -constants               显示最终常量
  -classpath <path>        指定查找用户类文件的位置
  -cp <path>               指定查找用户类文件的位置
  -bootclasspath <path>    覆盖引导类文件的位置

执行命令javap -verbose -p Test.class,这里限于篇幅我只截取了部分输出结果:

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 com/panson/Test
         2: dup
         3: astore_1
         4: monitorenter
         5: getstatic     #3                  // Field java/lang/System.out:Ljava/io/PrintStream;
         8: ldc           #4                  // String hello world
        10: invokevirtual #5                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
        13: aload_1
        14: monitorexit
        15: goto          23
        18: astore_2
        19: aload_1
        20: monitorexit
        21: aload_2
        22: athrow
        23: return

可以定位到:

				 4: monitorenter
         5: getstatic     #3                  // Field java/lang/System.out:Ljava/io/PrintStream;
         8: ldc           #4                  // String hello world
        10: invokevirtual #5                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
        13: aload_1
        14: monitorexit

确实是使用了 monitorenter 和 monitorexit 两个指令。

那么monitorenter指令执行的时候会干什么呢?

每个对象都有一个关联的 monitor,比如一个对象实例就有一个monitor,一个类的Class对象也有一个monitor,如果要对这个对象加锁,那么必须获取这个对象关联的monitor的lock锁

底层原理和思路大概是这样的:monitor里面有一个计数器,从0开始的。如果一个线程要获取monitor的锁,就看看他的计数器是不是0,如果是 0 的话,那么说明没人获取锁,他就可以获取锁了,然后对计数器加 1。

2.2 synchronized 可重入性在字节码上的表现形式

我们还是使用一个简单的代码示例,来看看反编译之后的代码有什么不一样,在该代码示例中,我们使用了两层加锁机制。

代码示例:

public class Test {
    public static void main(String[] args) {
        synchronized (Test.class) {
            System.out.println("hello world");
            synchronized (Test.class) {
                System.out.println("peace");
            }
        }
    }
}

反编译之后的代码:

public static void main(java.lang.String[]);
    descriptor: ([Ljava/lang/String;)V
    flags: ACC_PUBLIC, ACC_STATIC
    Code:
      stack=2, locals=5, args_size=1
         0: ldc           #2                  // class com/panson/Test
         2: dup
         3: astore_1
         4: monitorenter
         5: getstatic     #3                  // Field java/lang/System.out:Ljava/io/PrintStream;
         8: ldc           #4                  // String hello world
        10: invokevirtual #5                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
        13: ldc           #2                  // class com/panson/Test
        15: dup
        16: astore_2
        17: monitorenter
        18: getstatic     #3                  // Field java/lang/System.out:Ljava/io/PrintStream;
        21: ldc           #6                  // String peace
        23: invokevirtual #5                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
        26: aload_2
        27: monitorexit
        28: goto          36
        31: astore_3
        32: aload_2
        33: monitorexit
        34: aload_3
        35: athrow
        36: aload_1
        37: monitorexit
        38: goto          48
        41: astore        4
        43: aload_1
        44: monitorexit

从反编译之后的代码可以看到,monitorenter 和 monitorexit 各自出现了两次,对应着两次 synchronized。可重入锁的直观展示便如上述代码所示。

2.3 synchronized 可重入的加锁与释放锁流程

synchronized(myObject) {
	// do something
	synchronized(myObject) {
	// do something
	}
}

如果一个线程第一次synchronized那里,获取到了 myObject 对象的 monitor 的锁,计数器加 1 ,然后第二次 synchronized 那里,会再次获取 myObject 对象的 monitor 的锁,这个就是重入加锁了,然后计数器会再次加 1,变成 2。

这个时候,其他的线程在第一次synchronized那里,会发现说myObject对象的monitor锁的计数器是大于0的,意味着被别人加锁了,然后此时线程就会进入block阻塞状态,什么都干不了,就是等着获取锁。

接着如果出了synchronized修饰的代码片段的范围,就会有一个monitorexit的指令。此时获取锁的线程就会对那个对象的monitor的计数器减1,如果有多次重入加锁就会对应多次减1,直到最后,计数器是0。

然后后面block住阻塞的线程,会再次尝试获取锁,但是只有一个线程可以获取到锁。

总结:sychronized 从底层来说,就是在进入加锁代码块的时候加一个monitorenter的指令,然后针对锁对象关联的monitor累加加锁计数器,同时标识自己这个线程加了锁,同时通过monitor里的加锁计数器可以实现可重入的加锁。

2.4 monitor 对 wait 和 notify 的支持

其实wait和notify关键字的实现也是依托于monitor实现的,有线程执行wait之后,自己会加入一个waitset中等待唤醒获取锁,notifyall操作会从monitor的waitset中唤醒所有的线程,让他们竞争获取锁。

MyObject lock = new MyObject();
synchronized(lock) {

}

Java对象都是分为对象头实例变量两块的,其中实例变量就是大家平时看到的对象里的那些变量数据。然后对象头包含了两块东西,一个是 Mark Word包含hashCode、锁数据、GC数据,等等),另一个是 Class Metadata Address包含了指向类的元数据的指针)。

在Mark Word里就有一个指针,是指向了这个对象实例关联的monitor的地址,这个monitor是c++实现的,不是java实现的。这个monitor实际上是c++实现的一个ObjectMonitor对象。

  1. ObjectMonitor 里面包含了一个 _owner指针,指向了持有锁的线程

  2. ObjectMonitor 里还有一个 entrylist,想要加锁的线程全部先进入这个entrylist等待获取机会尝试加锁,实际有机会加锁的线程,就会设置_owner指针指向自己,然后对_count计数器累加1次。

各个线程尝试竞争进行加锁,此时竞争加锁是在JDK 1.6以后优化成了基于CAS来进行加锁,理解为跟之前的Lock API的加锁机制是类似的,CAS操作,操作_count计数器,比如说将_count值尝试从0变为1。

然后释放锁的时候,先是对_count计数器递减1,如果为0了就会设置_owner为null,不再指向自己,代表自己彻底释放锁。

如果获取锁的线程执行wait,就会将计数器递减,同时_owner设置为null,然后自己进入waitset中等待唤醒,别人获取了锁执行notify的时候就会唤醒waitset中的线程竞争尝试获取锁。

有人会问,那尝试加锁这个过程,也就是对_count计数器累加操作,是怎么执行的?如何保证多线程并发的原子性呢?很简单,JDk 1.6之后,对synchronized内的加锁机制做了大量的优化,这里就是优化为CAS加锁的。

3 保证可见性和有序性

int b = 0;
int c = 0;

synchronized(this) { -> monitorenter

Load内存屏障
Acquire内存屏障

int a = b;
c = 1; => synchronized代码块里面还是可能会发生指令重排

Release内存屏障

} -> monitorexit

Store内存屏障

java的并发技术底层很多都对应了内存屏障的使用,包括synchronized,他底层也是依托于各种不同的内存屏障来保证可见性和有序性的。

按照可见性来划分的话,内存屏障可以分为Load屏障和Store屏障

  • Load屏障的作用是执行refresh处理器缓存的操作,说白了就是对别的处理器更新过的变量,从其他处理器的高速缓存(或者主内存)加载数据到自己的高速缓存来,确保自己看到的是最新的数据。

  • Store屏障的作用是执行flush处理器缓存的操作,说白了就是把自己当前处理器更新的变量的值,都刷新到高速缓存(或者主内存)里去。

在monitorexit指令之后,会有一个Store屏障,让线程把自己在同步代码块里修改的变量的值都执行flush处理器缓存的操作,刷到高速缓存(或者主内存)里去;然后在monitorenter指令之后会加一个Load屏障,执行refresh处理器缓存的操作,把别的处理器修改过的最新值加载到自己高速缓存里来。

所以说通过Load屏障和Store屏障,就可以让synchronized保证可见性。

按照有序性保障来划分的话,还可以分为Acquire屏障和Release屏障。

  • 在monitorenter指令之后,Load屏障之后,会加一个Acquire屏障,这个屏障的作用是禁止读操作和读写操作之间发生指令重排序。
  • 在monitorexit指令之前,会加一个Release屏障,这个屏障的作用是禁止写操作和读写操作之间发生重排序。

所以说,通过 Acquire屏障和Release屏障,就可以让synchronzied保证有序性,只有synchronized内部的指令可以重排序,但是绝对不会跟外部的指令发生重排序。

总结 synchronized:

  • 原子性:加锁和释放锁,ObjectMonitor
  • 可见性:加了Load屏障和Store屏障,释放锁flush数据,加锁会refresh数据
  • 有序性:Acquire屏障和Release屏障,保证同步代码块内部的指令可以重排,但是同步代码块内部的指令和外面的指令是不能重排的

4 锁消除、锁粗化、偏向锁、轻量级锁、适应锁

4.1 锁消除

锁消除是JIT编译器对synchronized锁做的优化,在编译的时候,JIT会通过逃逸分析技术,来分析synchronized锁对象,是不是只可能被一个线程来加锁,没有其他的线程来竞争加锁,这个时候编译就不用加入monitorenter和monitorexit的指令。
这就是,仅仅一个线程争用锁的时候,就可以消除这个锁了,提升这段代码的执行的效率,因为可能就只有一个线程会来加锁,不涉及到多个线程竞争锁。

4.2 锁粗化

synchronized(this) {

}

synchronized(this) {

}

synchronized(this) {

}

这个意思就是,JIT编译器如果发现有代码里连续多次加锁释放锁的代码,会给合并为一个锁,就是锁粗化,把一个锁给搞粗了,避免频繁多次加锁释放锁。

4.3 偏向锁

这个意思就是说,monitorenter和monitorexit是要使用CAS操作加锁和释放锁的,开销较大,因此如果发现大概率只有一个线程会主要竞争一个锁,那么会给这个锁维护一个偏好(Bias),后面他加锁和释放锁,基于Bias来执行,不需要通过CAS,性能会提升很多。
但是如果有偏好之外的线程来竞争锁,就要收回之前分配的偏好。

4.4 轻量级锁

如果偏向锁没能成功实现,就是因为不同线程竞争锁太频繁了,此时就会尝试采用轻量级锁的方式来加锁,就是将对象头的Mark Word里有一个轻量级锁指针,尝试指向持有锁的线程,然后判断一下是不是自己加的锁,如果是自己加的锁,那就执行代码就好了,如果不是自己加的锁,那就是加锁失败,说明有其他人加了锁,这个时候就是升级为重量级锁。

4.5 适应锁

这是JIT编译器对锁做的另外一个优化,如果各个线程持有锁的时间很短,那么一个线程竞争锁不到,就会暂停,发生上下文切换,让其他线程来执行。但是其他线程很快释放锁了,然后暂停的线程再次被唤醒。也就是说在这种情况下,线程会频繁的上下文切换,导致开销过大。所以对这种线程持有锁时间很短的情况,是可以采取忙等策略的,也就是一个线程没竞争到锁,进入一个while循环不停等待,不会暂停不会发生线程上下文切换,等到机会获取锁就继续执行好了。

这样可以大幅度减少线程上下文的切换,而这种自旋等待获取锁的方式,就是所谓自旋锁,就是不断的自旋尝试获取锁。

如果一个线程持有锁的时间很长,那么其他线程获取不到锁,就会暂停,发生上下文切换,让其他线程来执行,这种自己暂停获取锁的方式,就是所谓的重量级锁。这个根据不同情况自动调整的过程,就是适应锁的意思。