重学Java——Synchronized底层实现原理

4,133 阅读9分钟

深入Synchronized底层原理

对于synchronized大家应该都很熟悉,主要作用是在多线程并发时,保证线程访问共享数据时的线程安全。

它的作用有三点:

  1. 确保线程互斥的访问同步代码
  2. 保证共享为师的修改及时可见
  3. 有效解决指令重排(synchronized同步中的代码,JVM不会轻易优化重排序)

Synchronized使用

它的用法主要是从两个维度上来区分:

  • 根据修饰对象的分类
    • 修饰代码块
      • synchronized(this|object)
      • synchronized(类.class)
    • 修饰方法
      • 修饰非静态方法
      • 修饰静态方法
  • 根据获取的锁来分类
    • 获取对象锁
      • synchronized(this|object)
      • 修改非静态方法
    • 获取类锁
      • synchronized(类.class)
      • 修饰静态方法

1.对象锁

这个对象是新建的,跟其他对象无关:

public class SynchronizeDemo implements Runnable {

    @Override
    public void run() {
        test1();
    }

    private void test1(){
        System.out.println(Thread.currentThread().getName() + "_: " + new SimpleDateFormat("HH:mm:ss").format(new Date()));
        synchronized (new SynchronizeDemo()){
            try {
                System.out.println(Thread.currentThread().getName() + "_start_: " + new SimpleDateFormat("HH:mm:ss").format(new Date()));
                Thread.sleep(2000);
                System.out.println(Thread.currentThread().getName() + "_end_: " + new SimpleDateFormat("HH:mm:ss").format(new Date()));
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }

    public static void main(String[] args) {
        SynchronizeDemo sd1 = new SynchronizeDemo();
        Thread thread1 = new Thread(new SynchronizeDemo(),"thread1");
        Thread thread2 = new Thread(new SynchronizeDemo(),"thread2");
        Thread thread3 = new Thread(sd1,"thread3");
        Thread thread4 = new Thread(sd1,"thread4");
        thread1.start();
        thread2.start();
        thread3.start();
        thread4.start();
    }
}

运行结果如图

四个线程同时开始,同时结束,因为作为锁的对象与线程是属于不同的实例

2.类锁

无所谓哪个类,都会被拦截

    private void test2(){
        System.out.println(Thread.currentThread().getName() + "_: " + new SimpleDateFormat("HH:mm:ss").format(new Date()));
        synchronized (SynchronizeDemo.class){
            try {
                System.out.println(Thread.currentThread().getName() + "_start_: " + new SimpleDateFormat("HH:mm:ss").format(new Date()));
                Thread.sleep(2000);
                System.out.println(Thread.currentThread().getName() + "_end_: " + new SimpleDateFormat("HH:mm:ss").format(new Date()));
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }

运行结果如下:

可以看到,类锁一次只能通过一个。

3.this对象锁

就是把synchronized (SynchronizeDemo.class)改为synchronized (this)

控制台打印结果

可能这显示结果有点歧义,其实多运行几次我们会发现,1和2是同时结束的,3和4永远有先后,因为3,4同属于一个实例

4.synchronized修饰方法

    private synchronized void test4(){
        ...
    }

打印的结果如下:

thread1_: 22:42:04
thread3_: 22:42:04
thread2_: 22:42:04
thread3_start_: 22:42:04
thread1_start_: 22:42:04
thread2_start_: 22:42:04
thread1_end_: 22:42:06
thread3_end_: 22:42:06
thread2_end_: 22:42:06
thread4_: 22:42:06
thread4_start_: 22:42:06
thread4_end_: 22:42:08

对于非静态方法,同一个实例的线程访问会被拦截,非同一实例可以同时访问,即此时默认的就是对象锁(this)

5.修饰静态方法的结果

在上面方法上加static

thread1_: 22:42:42
thread1_start_: 22:42:42
thread1_end_: 22:42:44
thread4_: 22:42:44
thread4_start_: 22:42:44
thread4_end_: 22:42:46
thread3_: 22:42:46
thread3_start_: 22:42:46
thread3_end_: 22:42:48
thread2_: 22:42:48
thread2_start_: 22:42:48
thread2_end_: 22:42:50

一样的可以看出来,静态方法默认使用的就是类锁

synchronized使用小结

  • 对于静态方法,由于此时对象还没生成,所以默认采用的就是类锁(5)
  • 而采用类锁,就会拦截所有线程,只能让一个线程访问(2)
  • 对于对象锁this,如果是同一实例,那么按顺序执行,如果不是同一实例,就可以同时访问(3,4)
  • 如果对象锁与访问的对象无关,那么就会都同时访问(1)

Synchronized原理

实际上,在JVM中,只区分两种不同的用法,修饰代码块与修饰方法,我们可以查看SE8规范docs.oracle.com/javase/spec…

(英文不好,我有小助手怕不怕)大意是:Java虚拟机中的同步是通过显式(通过使用监视器输入和监视器输出指令)或隐式(通过方法调用和返回指令)的监视器输入和退出来实现的。 显示就是使用monitorenter和monitorexit来控制同步代码块;隐式是修饰方法,在运行时常量池中通过ACC_SYNCHRONIZED来标志。

多说无益,直接看它的字节码

public class Test {
    public static void main(String[] args) {
    }
    public synchronized void test1() {
    }

    public void test2() {
        synchronized (this) {
        }
    }
}

最简单的程序,通过使用javap -v Test.class来查看它的字节码(注意是class文件,不是java文件)

  public synchronized void test1();
    descriptor: ()V
    flags: ACC_PUBLIC, ACC_SYNCHRONIZED
    Code:
      stack=0, locals=1, args_size=1
         0: return
      LineNumberTable:
        line 8: 0
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0       1     0  this   LTest;

  public void test2();
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
      stack=2, locals=3, args_size=1
         0: aload_0
         1: dup
         2: astore_1
         3: monitorenter   //监视器进入,获取锁
         4: aload_1
         5: monitorexit   //监视器退出,释放锁
         6: goto          14
         9: astore_2
        10: aload_1
        11: monitorexit
        12: aload_2
        13: athrow
        14: return

可以看到,果然字节码中,synchronized修饰代码块时,是使用monitorentermonitorexit来控制,而synchronized修饰方法的时候,是使用ACC_SYNCHRONIZED标识。

本质上都是对一个对象的monitor进行获取,而这个获取的过程是排他的,也就是同一时刻只能有一个线程获得同步块对象的监视器monitor。

线程执行到monitorenter指令时,会尝试获取对象所对应的monitor所有权,也就是尝试获取锁,执行到monitorexit,也就是释放所有权,释放锁。

要想理清synchronized的锁的原理,需要掌握两个重要的概念:

  1. 对象头
  2. monitor

java对象头

在Hotspot虚拟机中,对象在内存中的存储布局,可以分为三块:对象头Header,实例数据Instance Data,对齐填充Padding。

Hotspot虚拟机的对象头包含了两部分信息:

  1. Mark Word,用于存储对象自身的运行时数据,比如hash,gc分代年龄,锁状态的标志,线程持有锁,偏向ID,偏向时间戳等等
  2. Klass Pointer:对象指向它的类的元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。

32位HotSpot虚拟机的对象头存储结构如下

img

为了验证上图的正确,我们可以查看hotspot的源码

在线地址hg.openjdk.java.net/jdk8u/jdk8u…

 public:
  // Constants
  enum { age_bits                 = 4,//分代年龄
         lock_bits                = 2,//锁标识
         biased_lock_bits         = 1,//是否偏向锁
         max_hash_bits            = BitsPerWord - age_bits - lock_bits - biased_lock_bits,
         hash_bits                = max_hash_bits > 31 ? 31 : max_hash_bits,//hask
         cms_bits                 = LP64_ONLY(1) NOT_LP64(0),
         epoch_bits               = 2//偏向时间戳
  };

hash:保存对象的哈希码

age:对象的分代年龄

biased_lock:偏向锁标识位

lock:锁状态标识位

JavaThread*:保存持有偏向锁的线程ID

epoch:保存偏向时间戳

所以,对象头中的Mark Word,synchronized源码就是用了对象头中的Mark Word来标识对象加锁状态。

monitor

Monitor Record是线程私有的数据结构,每一个线程都有一个可用monitor record列表,同时还有一个全局的可用列表。每一个被锁住的对象都会和一个monitor record关联(对象头的MarkWord中的LockWord指向monitor record的起始地址),同时monitor record中有一个Owner字段存放拥有该锁的线程的唯一标识,表示该锁被这个线程占用。如下图所示为Monitor Record的内部结构

线程唯一标识,当锁被释放时又设置为NULL; EntryQ:关联一个系统互斥锁(semaphore),阻塞所有试图锁住monitor record失败的线程。 RcThis:表示blocked或waiting在该monitor record上的所有线程的个数。 Nest:用来实现重入锁的计数。 HashCode:保存从对象头拷贝过来的HashCode值(可能还包含GC age)。

Candidate:用来避免不必要的阻塞或等待线程唤醒,因为每一次只有一个线程能够成功拥有锁,如果每次前一个释放锁的线程唤醒所有正在阻塞或等待的线程,会引起不必要的上下文切换(从阻塞到就绪然后因为竞争锁失败又被阻塞)从而导致性能严重下降。Candidate只有两种可能的值0表示没有需要唤醒的线程1表示要唤醒一个继任线程来竞争

总结

简单总结一下,同步块使用monitorenter和monitorexit指令,而同步方法是依靠方法修饰符上的flag——ACC_SYNCHRONIZED来完成的。其本质都是对一个对象监视器monitor进行获取,这个获取过程是排他的,也就是同一时刻只能有一个线程获得由synchronized所保护的对象的监视器。而这个监视器,也可以理解为一个同步工具,它是由java对象进行描述的,在Hotspor中,是通过ObjectMonitor来实现,每个对象中天然都内置了一个ObjectMonitor对象。

在java中,synchronized在编译后,会在同步块的前后分别形成一个monitorenter和monitorexit这两个字节码指令,这两个字节码都需要一个reference类型的参数来指明要锁定和解锁的对象,如果java程序中明确指定了对象,那就是这个对象的reference,如果没有指明,那么根据synchronized修饰的是实例方法还是类方法,去取对应的对象实例或者类Class对象来做锁对象。

在执行monitorenter时,首先会尝试获取对象的锁,如果这个对象没有锁,或者当前线程已经拥有了这个对象的锁,那个锁的计数器加1,相应的,在执行monitorexit时指令时,会将锁计数器减1,当计数器为0时,这个锁就被释放。如果获取对象锁失败,那当前线程就要阻塞等待,直到对象锁被另一个线程释放为止。

扩展

synchronized同步块对同一线程来说是可重入的,不会出现自己把自己锁死的情况,其次,同步块在已进入的线程执行完成前,会阻塞后面的其他线程进入。我们知道,Java的线程是映射到操作系统中的的原生线程上的,如果要阻塞或者唤醒一个线程,都需要操作系统来帮忙,这就需要我们从用户态切换到核心态,因此这个状态转换是非常耗费CPU。如果这个代码非常简单的同步块,可能切换状态的时间比代码执行时间还长。所以synchronized是一个重量级的操作,虚拟机本身也做了大量的优化,引入了偏向锁,轻量级锁,重量级锁等,这一部分锁的升级,可以等以后有时间了,再慢慢探讨。当然还可以引入重入锁,解决synchronized过于重量的问题。


参考

jdk源码剖析三:锁Synchronized

Java中synchronized的实现原理与应用

《深入理解Java虚拟机》


我的CSDN

下面是我的公众号,欢迎大家关注我