Synchronized的实现原理(汇总)

 

一、Java中synchronized关键字的作用

      总所周知,在并发环境中多个线程对同一个资源进行访问很可能出现脏读等一系列线程安全问题。这时我们可以用加锁的方式对访问共享资源的代码块进行加锁,以确保同一时间段内只能有一个线对资源进行访问,在它释放锁之前其他竞争锁的线程只能等待。而synchronized关键字是加锁的一种方式。 
      举个通俗易懂的例子:比如你上厕所之后,你要锁门,此时其他人只能在外面等待,直到你出来后,下一个人才能进去。这就是现实中一个加锁和释放锁的例子。

二、Java中synchronized关键字的运用

synchronized关键字的运用主要包括三方面:

  • 锁代码块(锁对象可指定,可为this、XXX.class、全局变量)
  • 锁普通方法(锁对象是this,即该类实例本身)
  • 锁静态方法(锁对象是该类,即XXX.class)

接下来,我们具体分析一下以上三种情况的运用。

1、锁代码块

代码:

public class Sync{
    private int a = 0;
    public void add(){  
        synchronized(this){
            System.out.println("a values " + ++a);
        }
    }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9

反编译结果:

这里写图片描述

      由反编译结果可以看出:synchronized代码块主要是靠monitorenter和monitorexit这两个原语来实现同步的。当线程进入monitorenter获得执行代码的权利时,其他线程就不能执行里面的代码,直到锁Owner线程执行monitorexit释放锁后,其他线程才可以竞争获取锁。

在这里,我们先阐释一下Java虚拟机规范中相关内容:

(1)、monitorenter

每个对象有一个监视器锁(monitor)。当monitor被占用时就会处于锁定状态,线程执行monitorenter指令时尝试获取monitor的所有权,过程如下:

  1. 如果monitor的进入数为0,则该线程进入monitor,然后将进入数设置为1,该线程即为monitor的所有者。
  2. 如果线程已经占有该monitor,只是重新进入,则进入monitor的进入数加1.
  3. 如果其他线程已经占用了monitor,则该线程进入阻塞状态,直到monitor的进入数为0,再重新尝试获取monitor的所有权。

上述第2点就涉及到了可重入锁,意思就是说当一个线程已经获取一个锁时,它可以再获取无数次,从代码的角度上将就是有无数个相同的synchronized语句块嵌套在一起。在进入时,monitor的进入数+1;退出时就-1,直到为0的时候才可以被其他线程竞争获取。

(2)、monitorexit

执行monitorexit的线程必须是objectref所对应的monitor的所有者。

指令执行时,monitor的进入数减1,如果减1后进入数为0,那线程退出monitor,不再是这个monitor的所有者。其他被这个monitor阻塞的线程可以尝试去获取这个 monitor 的所有权。

2、锁普通方法

代码:

public class Sync{
    private int a = 0;
    public synchronized void add(){ 
        System.out.println("a values " + ++a);
    }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6

反编译结果:

这里写图片描述

      从上图可以看出,这里并没有monitorenter和monitorexit,但是常量池中多了ACC_SYNCHRONIZED标示符。JVM就是根据该标示符来实现方法的同步的:当方法调用时会检查方法的 ACC_SYNCHRONIZED 访问标志是否被设置,如果设置了,执行线程将先获取monitor,获取成功之后才能执行方法体,方法执行完后再释放monitor。在方法执行期间,其他任何线程都无法再获得同一个monitor对象。这种方式与语句块没什么本质区别,都是通过竞争monitor的方式实现的。只不过这种方式是隐式的实现方法。

      在这里,我们将以上两种方法进行一下说明: 
      首先是代码块,当程序运行到monitorenter时,竞争monitor,成功后继续运行后续代码,直到monitorexit才释放monitor;而ACC_SYNCHRONIZED则是通过标志位来提示线程去竞争monitor。也就是说,monitorenter和ACC_SYNCHRONIZED只是起标志作用,并无实质操作。

3、锁静态方法

代码:

public class Sync{
    private static int a = 0;
    public synchronized static void add(){  
        System.out.println("a values " + ++a);
    }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6

反编译结果:

这里写图片描述

      常量池中用ACC_STATIC标志了这是一个静态方法,然后用ACC_SYNCHRONIZED标志位提醒线程去竞争monitor。由于静态方法是属于类级别的方法(即不用创建对象就可以被调用),所以这是一个类级别(XXX.class)的锁,即竞争某个类的monitor。

三、锁的竞争过程

      上面只是阐述了如何提醒线程去争夺锁,所以接下来我们阐述一下线程是怎样竞争锁的。其实总的来说,JVM中是通过队列来控制线程去竞争锁的。

这里写图片描述

  • (1)、多个线程请求锁,首先进入Contention List,它可以接纳所有请求线程,而且是一个后进先出(LIFO)的虚拟队列,通过结点Node和next指针构造。
  • (2)(3)、ContentionList会被线程并发访问,EntryList为了降低线程对ContentionList队尾的争用而构造出来。当Owner释放锁时,会从ContentionList中迁移线程到EntryList,并会指定EntryList中的某个线程(一般为Head结点)为Ready Thread,也就是说某个时刻最多只有一个线程正在竞争锁。
  • (4)、Owner并不是直接把锁交给OnDeck线程,而是将竞争锁的权利交给OnDeck(将锁释放了),然后让OnDeck自己去竞争。竞争成功后,OnDeck线程就变成Owner;否则继续留在EntryList的队头。
  • (5)(6)、当线程调用wait方法被阻塞时,进入WaitSet;当其他线程调用notifyAll()(notify())方法后,阻塞队列的(某个)线程就会进入EntryList中。

      处于ContetionList、EntryList、WaitSet的线程均处于阻塞状态。而线程被阻塞涉及到用户态与内核态的切换(Liunx),系统切换严重影响锁的性能。解决这个问题的办法就是自旋。自旋就是线程不断进行内部循环,即for循环什么也不做,防止线程wait()阻塞,在自旋过程中不断尝试获取锁,如果自旋期间,Owner刚好释放锁,此时自旋线程就可以去竞争锁。如果自旋了一段时间还没获取到锁,那没办法,只能调用wait()阻塞了。 
      为什么自旋了一段时间后又调用wait()方法呢?因为自旋是要消耗CPU的,而且还有线程上下文切换,因为CPU还可以调度线程,只不过执行的是空的for循环罢了。 
      对自旋锁周期的选择上,HotSpot认为最佳时间应是一个线程上下文切换的时间,但目前并没有做到。 
      所以,synchronized是什么时候进行自旋的?答案是在进入ContetionList之前,因为它自旋一定时间后还没获取锁,最后它只好在ContetionList中阻塞等待了。

四、通过JVM了解synchronized

      把锁说得那么玄乎,到底锁是何方神圣呢?首先,我们来了解一下对象头。

这里写图片描述

      从图中可以看到,Java对象Mark Word中的是否含偏向锁、锁标志位都与锁有关。是否含偏向锁很明显与偏向锁有关,而锁标记位指的是用了什么锁。接下来用一张图表示不同状态的锁下各个部分的含义。

这里写图片描述

      为了减少锁释放带来的消耗,锁有一个升级的机制,从轻到重依次是:无锁状态 ——> 偏向锁 ——> 轻量级锁 ——>重量级锁。

1、偏向锁

(1)、运行原理

      重量级锁使用互斥量实现同步;轻量级锁使用CAS操作,避免重量级锁的互斥量;而偏向锁则是在无竞争条件下把整个同步都删除掉,连CAS都不用做了(在设置偏向锁的时候只需要一步CAS操作)。 
      偏向锁,在无其它线程与它竞争的情况下,持有偏向锁的线程永远也不需要同步。它的加锁过程很简单:线程访问同步代码块时检查偏向锁中线程ID是否指向自己,如果是表明该线程已获得锁;否则,检测偏向锁标记是否为1,不是的话则CAS竞争锁,如果是就将对象头中线程ID指向自己。

      当存在线程竞争锁时,偏向锁才会撤销,转而升级为轻量级锁。而这个撤销过程则需要有一个全局安全点(即这个时间点上没有正在执行的字节码)。过程如下:

这里写图片描述

      在撤销锁的时候,栈中对象头的Mark Word要么偏向于其他线程,要么恢复到无锁或者轻量级锁。

(2)、分析

  • 优点:加锁和解锁无需额外消耗
  • 缺点:锁进化时会带来额外锁撤销的消耗
  • 适用场景:只有一个线程访问同步代码块

3、轻量级锁

(1)、运行原理

这里写图片描述

(2)、分析

  • 优点:竞争的线程不阻塞,也就是不涉及到用户态与内核态的切换(Liunx),减少系统切换锁带来的开销
  • 缺点:如果长时间竞争不到锁,自旋会消耗CPU
  • 适用场景:追求响应时间、同步块执行速度非常快

3、重量级锁

      它是传统意义上的锁,通过互斥量来实现同步,线程阻塞,等待Owner释放锁唤醒。

(2)、分析

  • 优点:线程竞争不自旋,不消耗CPU
  • 缺点:线程阻塞,响应时间慢
  • 适用场景:追求吞吐量、同步块执行时间较长

五、总结

      Java的synchronized关键字可实现同步功能,在多个线程请求统一资源时,可以只允许一个线程访问,在Owner释放锁之前其他线程都不能访问。 
      synchronized的同步机制是通过竞争monitor实现的,多个竞争线程可通过队列来协调。 
      每个Java对象的头部都有关于锁的标志位,这里存放了锁的有关信息。为了提高效率,锁有一个粗话过程,从轻到重依次是:无锁状态 ——> 偏向锁 ——> 轻量级锁 ——>重量级锁。

推荐阅读书籍: 
《Java并发编程的艺术》 
《深入理解Java虚拟机》

原文地址:https://www.cnblogs.com/shoshana-kong/p/10877555.html