synchronized 关键字的使用及其底层实现原理

synchronized 的使用

Java 提供了两种锁机制来控制多个线程对共享资源的互斥访问,第一个是 JVM 实现的 synchronized(关键字),而另一个是 JDK 实现的 ReentrantLock(类)。

应该明确一个概念,那就是 synchronized 锁的都是对象,而非代码!Java 中的每一个对象都可以作为锁。

synchronized 有四种使用方式。这四种使用方式又可以被分为两类:

  • 对象锁:它只作用于同一个对象,如果调用两个同一个类的对象上的同步代码块,就不会进行同步。
  • 类锁:锁住的是当前类的 Class 对象,它会作用于整个类,也就是说两个线程调用同一个类的不同对象上的这种同步语句,也会进行同步。

一、对象锁

1. 同步代码块

public void test() {
    synchronized (this) {
        // ...
    }
}

上述例子中,锁是当前的实例对象。

public void test() {
    final Object obj = new Object();
    synchronized (obj) {
        // ...
    }
}

上述例子中,锁是 obj 引用的实例对象。

2. 同步非静态方法

public synchronized void test() {
    // ...
}

锁是当前实例对象。
 

二、类锁

1. 同步代码块

public void test() {
    synchronized (Test.class) {
        // ...
    }
}

锁是 Test 类的 Class 对象,此时不同线程调用同 Test 类的不同对象上的同步语句,也会进行同步。

2. 同步静态方法

public synchronized static void test() {
    // ...
}

锁是 Test 类的 Class 对象,此时不同线程调用同 Test 类的不同对象上的同步语句,也会进行同步。

 

三、总结

  • 有线程访问对象的同步代码块时,其他线程是可以访问该对象的非同步代码块的。
  • 若锁住的是同一个对象,一个线程在访问对象的同步代码块(同步方法)时,另一个访问对象的同步代码块(同步方法)的线程会被阻塞。
  • 若锁住的是同一个对象,一个线程在访问对象的同步代码块时,另一个访问对象同步方法的线程会被阻塞,反之亦然(这点在后续介绍 synchronized 底层原理之后你就会明白原因了)。
  • 同一个类的不同对象的对象锁互不干扰
  • 类锁作用于整个类

 

synchronized 的底层实现原理

在讨论 synchronized 实现原理之前,首先要了解两个实现 synchronized 的基础概念:

  • Java 对象头
  • Monitor

一、Java 对象头

在 HotSpot 虚拟机中,对象在内存中的布局分为三块区域,分别如下:

  • 对象头
  • 实例数据
  • 对齐填充

本文仅来介绍一下对象头的内容,它的结构与内容如下图所示。
synchronized 关键字的使用及其底层实现原理_第1张图片
如上图所示,Java 对象头里主要有两个部分(这里讨论的是非数组类型,如果是数组类型还会有第三个结构),一个 Mark Word,另一个是 Class Metadata Address,这个内容的说明在图中已经解释了,这里不多赘述。

我们主要来关注一下这个 Mark Word,它是实现锁的关键,synchronized 用的锁就是存放在这里的。

Java 对象头里的 Mark Word 里默认存储对象的 HashCode、分代年龄和锁标记位。32 位 JVM 的 Mark Word 的默认存储结构如下图所示。

在这里插入图片描述

由于对象头的信息是与对象自己本身定义的数据没有关系的额外存储成本,因此考虑到 JVM 的空间效率,Mark Word 被设计为一个非固定的数据结构。

在运行期间,Mark Word 里存储的数据会随着锁标志位的变化而变化。Mark Word 可能变化为存储以下 5 种数据。

synchronized 关键字的使用及其底层实现原理_第2张图片

  • 第一行,无锁状态就是刚刚提及的 Mark Word 的默认存储结构。
  • 第二行,轻量级锁是 JDK1.6 后引入的锁状态,一种对锁的优化。
  • 第三行,重量级锁就是我们最熟悉的锁,这里不展开介绍。
  • 第四行,垃圾回收标志,这里不展开介绍。
  • 第五行,同轻量级锁一样,偏向锁是 JDK1.6 后引入的锁状态,一种对锁的优化。

注:上述表格中的五行,都是独立的,换句话说,Mark Word 只能处于这几种状态中的一种。

 
二、Monitor

每个 Java 对象天生自带了一把看不见的锁,它就是 Monitor 锁,通常 Monitor 也被描述为一个对象。

我们再回过头来看看,刚才对 Mark Word 的介绍,关注重量级锁即可,如下图所示。

注:偏向锁和轻量级锁是乐观锁,跟 Monitor 无关,所以本文所讨论的 synchronized 锁的原理就是讨论重量级锁的底层实现原理,但请明确一点,从 JDK1.6 以后,synchronized 可不仅仅是重量级的锁,它还有偏向锁和轻量级锁这两种状态

在这里插入图片描述

上图中所说的 “指向重量级锁的指针”,其实就是指向的 Monitor 对象的起始地址,每个对象都存在着一个 Monitor 对象与之关联。对象与其 Monitor 之间存在多种实现方式,比如在对象创建时,其 Monitor 对象随之创建,销毁时也如此,或者当线程试图获取对象锁时,自动创建 Monitor 对象。

当一个 Monitor 被某个线程持有后,它就会处于锁定状态,所谓的获取锁本质上就是获取 Monitor 对象的持有权,在 HotSpot 虚拟机中,Monitor 是由 ObjectMonitor 来实现的,其源码(C++ 实现)地址如下,:

http://hg.openjdk.java.net/jdk8u/jdk8u/hotspot/file/36a743eee672/src/share/vm/runtime/objectMonitor.hpp

简单的来看一下 ObjectMonitor 的构造函数

synchronized 关键字的使用及其底层实现原理_第3张图片
我们重点关注 WaitSet、EntryList 、owner、count 这四个变量。

1. WaitSet、EntryList

这里先要额外介绍两个概念:

  • 等待池:假设线程 A 调用了某个对象的 wait() 方法,线程 A 就会释放该对象的锁,同时线程 A 会进入该对象的等待池中,进入到等待池中的线程不会去竞争该对象的锁。
  • 锁池:假设线程 A 已经拥有了某个对象的锁,而线程 B、C 想要调用这个对象的某个同步方法(或者同步代码块),但是线程 B、C 在进入同步方法(或者同步代码块)之前,必须先要获得该对象锁的拥有权,而恰巧此时该对象的锁被线程 A 持有,那么线程 B、C 就会被阻塞,进入一个地方去等待锁的释放,这个地方就是该对象的锁池。

上图源码中圈红的:WaitSet 和 EntryList 这两个队列,就是代表某个对象的等待池和锁池。它们就是用来保存 ObjectWaiter 对象的列表,那么 ObjectWaiter 又是个啥?别急,接着往下看,ObjectWaiter 源码如下:
synchronized 关键字的使用及其底层实现原理_第4张图片
每一个等待该对象锁的线程都会被封装成一个 ObjectWaiter 对象。

2. owner

owner 这个字段它是指向持有 ObjectMonitor 对象的线程,也就是获得该对象锁的线程。

3. count

就是互斥量,有 0/1 两种取值,1 代表锁正被占有。

 

锁的获取与释放

了解完了对象头和 Monitor 这两个概念之后,我们从底层原理的角度来阐述一下锁的获取与释放的具体过程。

当多个线程同时访问同一段同步代码的时候,首先它们会进入 EntryList 里面,当线程获取到该对象的 Monitor 对象后,就会进入临界区执行同步代码,并把 owner 变量设置为指向当前线程,同时 count 字段会执行加 1。

此时如果该线程调用 wait() 方法,那么它就会释放当前持有的 Monitor 对象,同时 owner 变量恢复成 null、count 也会执行减 1,并进入 WaitSet 里面等待被唤醒 。

若当前线程执行完毕,它也会释放当前持有的 Monitor 对象,同时复位 owner、count 变量,以便其他线程获取锁。

 

从字节码层次看 synchronized 的实现

JVM 基于进入和退出 Monitor 对象来实现方法同步和代码块同步,但两者的实现细节不一样。代码块同步是使用 monitorenter 和 monitorexit 指令实现的,而方法同步是使用另外一种方式实现的,细节在 JVM 规范里并没有详细说明。但是,方法的同步同样可以使用这两个指令来实现。

看一下代码块同步的同步实现,写一个简单的代码块同步代码如下:

public class SynchronizedTest {
	
	public void test() {
		synchronized (this) {
			System.out.println("test");
		}
	}
	
}

之后,使用javac SynchronizedTest.java指令获取编译后的字节码文件:SynchronizedTest.class

获取字节码文件之后再使用javap -verbose SynchronizedTest.class指令反编译字节码文件来查看具体指令。结果如下所示

 public void test();
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
      stack=2, locals=3, args_size=1
         0: aload_0
         1: dup
         2: astore_1
         3: monitorenter
         4: getstatic     #2                  // Field java/lang/System.out:Ljava/io/PrintStream;
         7: ldc           #3                  // String test
         9: invokevirtual #4                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
        12: aload_1
        13: monitorexit
        14: goto          22
        17: astore_2
        18: aload_1
        19: monitorexit
        20: aload_2
        21: athrow
        22: return
        ....

可以看出,monitorenter 指令是在编译后插入到同步代码块的开始位置(3: monitorenter),而 monitorexit 是插入到方法结束处(13: monitorexit)和异常处(19: monitorexit),第二个 monitorexit 是为了保证程序抛出异常时,依然能有一个 monitorexit 与 monitorenter 配对,以确保 Monitor 对象能够正常的释放 。

你可能感兴趣的:(Java并发)