synchronized、volatile的实现与底层原理

synchronized、volatile的实现与底层原理

在讨论这两个关键字之前,首先讨论线程安全性,我们在编写线程安全的代码时,核心在于对状态访问操作进行管理,特别是对共享的和可变的状态的访问。“共享”意味着可以由多个线程同时访问,而“可变”意味着变量的值在其生命周期上可以发生变化。一个对象是否需要线程安全,取决于它是否被多个线程访问。要确保对象的线程安全性,则需要采用同步机制来协同对对象可变状态的访问。Java中主要的同步机制是关键字synchronized,它提供了一种独占的加锁机制,但同步这个术语还包括volatile类型的变量,显式锁和原子变量。

1. synchronized

1.1 概述

synchronized关键字的使用主要分为两种,同步方法和同步代码块。使用场景如下图:

Java中,每一个对象都可以作为锁:

  • 对于普通同步方法,锁是当前实例对象
  • 对于静态同步方法,锁的是当前类的Class对象
  • 对于同步方法快,锁是Synchonized括号里的对象

1.2 具体实现

需要注意的是,如果锁的是类对象的话,尽管new多个实例对象,但他们仍然属于同一个类,依然会被锁住,而如果锁的是实例对象,则不同的实例对象拥有不同的锁。

1.2.1 同步方法
public synchronized void method()
{
   // todo
}
public static synchronized void method()
{
   // todo
}
1.2.2 同步代码块
public void method()
{
   synchronized(this) {
      // todo   }
}

1.3 底层原理

在了解底层原理之前,首先要理解下Monitor(管程):

Monitor:

Monitor机制需要几个元素的配合:

1、临界区

2、monitor对象及锁

3、条件变量

操作系统中,管程是一种高级同步原语,是语言概念。管程有一个很重要的特性,即在任一时刻管程中只能有一个活跃线程。为了能够实现这个重要特性,需要一个monitor object来协助,这个 monitor object 内部会有相应的数据结构,例如列表,来保存被阻塞的线程;同时由于 monitor 机制本质上是基于 mutex 这种基本原语的,所以 monitor object 还必须维护一个基于 mutex 的锁。管程是编程语言的组成部分,编译器可以采用与其他过程调用不同的方法来处理对管程的调用。典型的处理方法是,当一个进程调用管程过程时,该过程中的前几条指令将检查在管程中 是否有其他的活跃进程。如果有,调用进程将被挂起,直到另一个进程离开管程将其唤醒。如果没有活跃进 程在使用管程,则该调用进程可以进入。

通过管程可以很容易的实现互斥,但是我们还需要一种方法可以在适当的时候能够阻塞和唤醒 进程/线程。解决的方法是引入条件变量(condition variables)以及相关的两个操作:wait和signal。当一个管程过程 发现它无法继续运行时(例如,生产者发现缓冲区满),它会在某个条件变量上(如full)执行wait操作。 该操作导致调用进程自身阻塞,并且还将另一个以前等在管程之外的进程调入管程。

JAVA中对Monitor的实现:

通过Monitor的三个元素来分析具体实现:

1、临界区的界定

不难理解,被synchronized关键字修饰的方法/代码块,就是 monitor 机制的临界区。

2、monitor object

通过上面的分析,我们可以发现在使用synchronized关键字的时候,往往需要一个指定对象与之关联。例如 synchronized(this)synchronized 如果修饰的是实例方法,那么其关联的对象实际上是 this,如果修饰的是类方法,那么其关联的对象是 this.class。总之,synchronzied 需要关联一个对象,而这个对象就是 monitor object

monitor 的机制中,monitor object 充当着维护 互斥以及定义 wait/signal条件变量 来管理线程的阻塞和唤醒的角色。
Java 语言中的 java.lang.Object 类,便是满足这个要求的对象,任何一个 Java 对象都可以作为 monitor 机制的 monitor object

Java 对象存储在堆内存中,分别分为三个部分,即对象头、实例数据和对齐填充,而在其对象头中的MarkWord,保存了锁的各种标识(锁状态标志、线程持有的锁、偏向线程ID等);同时,java.lang.Object 类定义了wait(),notify(),notifyAll()方法,这些方法的具体实现,依赖于一个叫 ObjectMonitor 模式的实现,这是 JVM 内部基于 C++ 实现的一套机制。

ObjectMonittor的具体结构如下:

//结构体如下
ObjectMonitor::ObjectMonitor() {  
  _header       = NULL;  
  _count       = 0;  
  _waiters      = 0,  
  _recursions   = 0;       //线程的重入次数
  _object       = NULL;  
  _owner        = NULL;    //标识拥有该monitor的线程
  _WaitSet      = NULL;    //等待线程组成的双向循环链表,_WaitSet是第一个节点
  _WaitSetLock  = 0 ;  
  _Responsible  = NULL ;  
  _succ         = NULL ;  
  _cxq          = NULL ;    //多线程竞争锁进入时的单向链表
  FreeNext      = NULL ;  
  _EntryList    = NULL ;    //_owner从该双向循环链表中唤醒线程结点,_EntryList是第一个节点
  _SpinFreq     = 0 ;  
  _SpinClock    = 0 ;  
  OwnerIsThread = 0 ;  
} 

通过下文对锁升级的分析,可以更深的理解JAVA中定义的monitor对象及锁。

3、条件变量

Java中线程的阻塞和唤醒有多种实现,常见的有java.lang.Object 类中定义了wait(),notify(),notifyAll()方法Lock类中的Condition对象等。

**同步方法: **

在方法级的同步中,并不需要字节码指令来控制,它实现在方法调用中和方法返回。JVM通过方法 Class类文件中的方法表的方法访问标志中的ACC_SYNCHRONIZED来区分一个方法是否为synchronized的。当JVM调用一个方法时,它会检查方法的synchronized访问标志是否被设置,如果设置了,执行线程则会获得Monitor(对于普通同步方法,锁的是当前实例对象;而静态同步方法,锁的是类的Class对象),然后再执行方法,最后在方法完成后(无论是正常退出还是非正常退出)释放Monitor。在方法执行过程中,执行线程持有了Monitor,其他任何线程都无法获得同一个Monitor

​ 测试代码如下:

public class Test {
synchronized public static void testMethod() {
	}
	public static void main(String[] args) throws InterruptedException {
	testMethod();
	}
}

​ 使用javac编译后讲class文件转化为字节码文件如下:

public synchronized void myMethod();
descriptor: ()V
flags: ACC_PUBLIC, ACC_SYNCHRONIZED //可以看到为方法添加了ACC_SYNCHRONIZED标志。
Code:
stack=1, locals=2, args_size=1
0: bipush 100
2: istore_1
3: return
LineNumberTable:
line 5: 0
line 6: 3
LocalVariableTable:
Start Length Slot Name Signature
0 4 0 this Ltest56/Test;
3 1 1 age I

**同步代码块: **

代码块同步是使用硬件层面的monitorentermonitorexit指令实现的。JVM要保证每个monitorenter必须有对应的monitorexit与之配对。任何对象都有 一个monitor与之关联,当且一个monitor被持有后,它将处于锁定状态。线程执行到monitorenter 指令时,将会尝试获取对象所对应的monitor的所有权,即尝试获得对象的锁。

​ 测试代码:

public class Test2 {
	public void myMethod() {
		synchronized (this) {
		int age = 100;
		}
	}
public static void main(String[] args) throws InterruptedException {
	Test2 test = new Test2();
	test.myMethod();
	}
}

字节码指令:

public void myMethod();
descriptor: ()V
ags: ACC_PUBLIC
Code:
stack=2, locals=3, args_size=1
0: aload_0
1: dup
2: astore_1
3: monitorenter  //可以看到方法是使用monitorenter和monitorexit进入和退出同步代码块的。
4: bipush 100
6: istore_2
7: aload_1
8: monitorexit //--
9: goto 15
12: aload_1
13: monitorexit
14: athrow—179—
15: return

1.4 锁升级

java中锁一共有4种状态,分为无锁、偏向锁、轻量级锁、重量级锁。这几个状态随着竞争情况而逐渐升级。

自旋锁与自适应锁:

在互斥同步中,阻塞的实现需要挂起和恢复线程,而这两种操作都需要转为内核态完成,对性能影响较大。如果当某个锁定操作只会持续很短的时间,那么通过挂起和恢复线程来阻塞线程就会变得得不偿失,为了解决这种消耗,JVM引入了自旋锁,即可以让后面请求的线程“多等一会”,但不放弃处理器时间。为了让线程等待,我们只需让线程执行一个忙循环(自旋),这项技术就是自旋锁。

自旋等待虽然避免了挂起和恢复线程,但是当线程等待时间过长,则会占用较长的CPU处理时间。因此自旋等待必须有一个限定,比如自旋到某个时间段还没有获得锁,就应当使用传统的方式去挂起线程。在JDK6中对自旋锁的与优化,引入了自适应的自旋,可以通过前一次在该锁上自旋的时间来控制本次自旋的限度。

轻量级锁:

轻量级锁是JDK6中加入的新型锁,它名字中的轻量级是相对于操作系统使用的传统互斥量来实现的传统锁(重量级锁)而言的,但是轻量级锁的设定并不是用来取代重量级锁的,它的设计初衷是在没有多线程的竞争的前提下,减少传统的重量级锁使用操作系统互斥量带来的性能消耗。

再继续理解轻量级锁之前,需要对HOTSPOT虚拟机中对象的内存布局有所了解(前文已经有所概述,在此不再赘述)。下图为HotSpot虚拟机对象头MarkWord

markword

可以看到对象除了未被锁定的正常状态外,还有轻量级锁定,重量级锁定,GC标记,可偏向等几种不同的状态。所以说,在JAVA中任何对象都可以当做锁,可以把对象和锁“等同”起来,因为每个对象的内存布局中都有锁标记,可以说一个对象拥有一个锁的状态,即一个对象持有一个锁。

接下来介绍轻量级锁的工作工程:在代码即将进入同步块的时,如果此同步对象没有被锁定(锁的标志位为“01”状态),虚拟机首先在当前线程的栈帧中创建一个名为锁记录的空间,用于存储锁对象目前的mark word的拷贝。然后,虚拟机将使用cas操作尝试将对象的mark word更新指向锁记录的指针,如果这个更新成功了,说明该线程成功获得了该对象的锁,并且对象的mark word的锁标记位指向00,表示这个对象处于轻量级锁定状态。如果更新失败了,说明至少存在一条线程与当前线程获得该对象的锁。虚拟机首先会检查对象的MARK WORD是否指向当前线程的调用栈中的锁记录,如果是说明该线程已经持有这个对象的锁,如果不是,说明这个对象已经被其他线程抢占了。如果出现两条以上的线程争用同一个锁,那么轻量级锁将不再有效,必须要膨胀到重量级锁。

偏向锁:

偏向锁是JDK6中引入的一项锁优化措施,目的是为了消除在无竞争情况下的同步原语,偏向锁在无竞争的情况下把整个同步都消除掉,连CAS操作都不做了。

偏向锁的“偏”的意思是该锁会偏向于第一个获得它的线程,如果在接下来的过程中,该锁都没有被其他线程获取,那么持有锁的线程将永远都不需要同步。

当虚拟机启用了偏向锁,那么当锁对象第一次被线程获取的时候,虚拟机将会把对象头中的标记位设置为“01”、偏向模式设置为“1”,表示进入偏向模式。同时使用CAS操作把获取到这个线程的锁的线程ID记录到对象的MARK WORD之中。一旦出现另外一个线程尝试去获得这个锁,偏向模式马上宣告结束。根据锁对象是否处于被锁定的状态决定是否撤销偏向,撤销后标志位恢复到未锁定(标志位为“01”)或轻量级锁定(标志位为“00”)的状态。

下面用大白话解释一下锁升级的过程,有以下元素:厕所所有者(JVM),小明(线程A),小高(线程B)。
很久之前有个厕所,小明发现了厕所,发现了厕所上有个牌子,只要写在了xxx的厕所,就可以使用该厕所,于是小明在牌子上写上了自己的名字(使用了cas方法把线程id写到了mark word中,这就是偏向锁),从此以后小明每次去上厕所,只要看到厕所上的牌子是自己的名字就可以直接上厕所。直到有一天,小高也发现了这个厕所,他也发现了厕所上有个牌子。(这是会有三个情况):
情况A:厕所没人,小高把小明的名字擦去,写上了自己的名字(判断方法同样也是使用cas,如果成功说明擦去了名字),下次小高回来,看到牌子上还是自己的名字,可以直接进去上厕所(偏向锁)。
情况B:小高发现厕所没人,于是擦去名字写上自己的名字,等小高上完厕所离开,小明刚好回来,但是他们两个没有碰面,小明发现牌子上的名字不是自己的,同时敲门发现没人,擦除牌子上的名字写上自己的,上完厕所就走了。这时小红又来了,如此往复。厕所所有者看这两个人有问题,于是直接把牌子丢了,谁也不能写,于是小明、小高以后上厕所只能自己带锁把门锁上,谁带了锁,厕所就是谁的。(变成了轻量级锁)
情况C:小高在上厕所的时候,小明刚好回来,小明打电话给厕所所有者说明明这厕所给我了却还被人用了。于是厕所所有者商量自己带锁,谁带锁了厕所就是谁的(轻量级锁),小明叫小高继续上厕所,但是小明一直敲门,如果敲10次还没结束就搞你(自旋锁)。
如果敲了10声还没反应,则厕所锁的规定取消,以后由所有者控制,谁在上厕所,外面的人要等着,叫都不许叫。(升级重量级锁)

下图是锁升级的具体过程,结合上面对锁的介绍以及大白话的解释,应该可以很好理解这张图。

锁升级

2. volatile

2.1 概述

Java语言提供了一种稍弱的同步机制,即volatile变量,用来确保将变量的更新操作通知到其他线程上。当把变量声明为volatile类型后,编译期和运行时都会注意到这个变量是共享的,因此不会将该变量的操作与其他内存操作一起重排序。volatile变量不会被缓存在寄存器或者其他处理器不可见的地方,因此在读取volatile变量时总能返回最新的值。在访问volatile变量时,不会对线程执行加锁操作,因此也不会让线程阻塞,所以volatile变量是一个比synchronized关键字更轻量级的同步机制。

2.2 具体使用

使用volatile变量确保了内存可见性(只能确保可见性,加锁机制可以确保原子性和可见性)。从内存可见性的角度看,读取volatile变量相当于进入了同步代码块,而写入volatile变量则相当于退出同步代码块。但是我们不能过度依赖volatile变量提供的内存可见性,仅当volatile变量能简化代码的实现以及对同步策略的验证时,才应该使用他们。通常,volatile变量用做某个操作完成、中断或结束的标志。

volatile boolean asleep;    ...        while (!asleep)();    ...

2.3 底层原理

在使用volatile变量修饰的共享变量进行写操作的时会添加一个Lock前缀的指令,Lock前缀的指令会在多核处理器下引发了两件事情。
1)将当前处理器缓存行的数据写回系统内存。
2)这个写回内存的操作会使在其他CPU里缓存了该内存地址的数据无效。

通过这两件事情,可以实现Volatile的内存语义,在2.4和2.5中会具体进行分析。

测试代码如下:

instance = new Singleton(); // instance是volatile变量

汇编代码如下:

0x01a3de1d: movb $0×0,0×1104800(%rsi);0x01a3de24: lock addl $0×0,(%rsp);

可以看到在addl操作码之前加了lock前缀指令。

2.4 volatile内存语义

在介绍volatile的内存语义之前,首先要简单了解下java内存模型(JMM)和happens-before规则。

JMM

JMM规定了所有的变量都存储在主内存中,每条线程还有自己的工作内存,工作内存是一个对缓存、寄存机等的抽象,并不实际存在,线程的工作内存保存了该线程使用的变量的主内存副本,线程对变量的所有操作都必须在工作内存中进行,而不能直接读取主内存的数据。不同的线程之间也无法直接访问对方工作内存中的变量,线程间的通信都要依靠主内存来进行。

下图是对JMM的具体抽象:

JMM抽象

HAPPENS-BEFORE:

java语言中有一个happens-before规则,也就是操作A先行发生于操作B,但是更好的理解应该是发生操作B之前,操作A产生的影响能够被操作B观察到,即前一个操作的结果对后一个操作可见。
以下为happens-before的具体规则:
1)程序顺序规则:一个线程中的每个操作,happens-before于该线程中的任意后续操作。
2)监视器锁规则:对一个锁的解锁,happens-before于随后对这个锁的加锁。
3)volatile变量规则:对一个volatile域的写,happens-before于任意后续对这个volatile域的
读。
4)传递性:如果A happens-before B,且B happens-before C,那么A happens-before C。
5)start()规则:如果线程A执行操作ThreadB.start()(启动线程B),那么A线程的
ThreadB.start()操作happens-before于线程B中的任意操作。
6)join()规则:如果线程A执行操作ThreadB.join()并成功返回,那么线程B中的任意操作
happens-before于线程A从ThreadB.join()操作成功返回。

volatile的内存语义:

在使用volatile变量时,读取volatile变量相当于进入了同步代码块,而写入volatile变量则相当于退出同步代码块。而在happens-before中,一个解锁操作happens-before于对同一个锁的加锁操作,即前一个操作的结果对后一个操作可见。由此可见,对一个volatile的读,总是能看到任意线程对这个volatile变量的写入。

从内存上看,当写一个volatile变量时,JMM会把该线程对应的本地内存中的所有的共享变量刷新到主内存中(对应Lock指令的第一条操作),而当读一个volatile变量时,JMM会把该线程中对应的本地内存置为无效,,接下来会从主内存中读取共享变量(对应Lock指令的第二条操作)。所以说当一个A线程写一个volatile变量后,B线程读同一个变量。在A线程在写volatile变量之前所有的可见共享变量,在B线程读同一个volatile变量后,将立即变得对B线程可见。

下图是根据happens-before规则推导volatile的内存语义:

volatile内存语义

参考:

《深入理解JAVA虚拟机》、

《JAVA并发编程的艺术》、

《JAVA多线程编程核心技术》、

《JAVA并发编程实战》、

以及CSDN、博客园、知乎等的具体博客。

posted @ 2021-05-20 21:53  exile464  阅读(377)  评论(0)    收藏  举报