Synchronized的底层实现原理(看这篇就够了)

Synchronized

  synchronized关键字解决的是多个线程之间访问资源的同步性,synchronized 翻译为中文的意思是同步,也称之为”同步锁“。

  synchronized的作用是保证在同一时刻, 被修饰的代码块或方法只会有一个线程执行,以达到保证并发安全的效果。

Java内存的可见性问题

在了解synchronized关键字的底层原理前,需要先简单了解下Java的内存模型,看看synchronized关键字是如何起作用的。

这里的本地内存并不是真实存在的,只是Java内存模型的一个抽象概念,它包含了控制器、运算器、缓存等。同时Java内存模型规定,
线程对共享变量的操作必须在自己的本地内存中进行,不能直接在主内存中操作共享变量。这种内存模型会出现什么问题呢?

  1.线程A获取到共享变量X的值,此时本地内存A中没有X的值,所以加载主内存中的X值并缓存到本地内存A中,线程A修改X的值为1,

     并将X的值刷新到主内存中,这时主内存及本地内存中的X的值都为1。

  2.线程B需要获取共享变量X的值,此时本地内存B中没有X的值,加载主内存中的X值并缓存到本地内存B中,此时X的值为1。

     线程B修改X的值为2,并刷新到主内存中,此时主内存及本地内存B中的X值为2,本地内存A中的X值为1。

  3.线程A再次获取共享变量X的值,此时本地内存中存在X的值,所以直接从本地内存中A获取到了X为1的值,

     但此时主内存中X的值为2,到此出现了所谓内存不可见的问题。

Java内存模型通过synchronized关键字和volatile关键字就可以解决该问题,那么synchronized关键字是如何解决的呢,其实进入synchronized块就是把在synchronized块内使用到的变量从线程的本地内存中擦除,这样在synchronized块中再次使用到该变量就不能从本地内存中获取了,需要从主内存中获取,解决了内存不可见问题。

synchronized关键字三大特性是什么?

synchronized关键字可以保证并发编程的三大特性:原子性、可见性、有序性,而volatile关键字只能保证可见性和有序性,不能保证原子性,也称为是轻量级的synchronized

  原子性:一个或多个操作全部执行成功或者全部执行失败。synchronized关键字可以保证只有一个线程拿到锁,访问共享资源。

  可见性:当一个线程对共享变量进行修改后,其他线程可以立刻看到。

      执行synchronized时,会对应执行lockunlock原子操作,保证可见性。

  有序性:程序的执行顺序会按照代码的先后顺序执行。

synchronized关键字可以实现什么类型的锁?

  悲观锁:synchronized关键字实现的是悲观锁,每次访问共享资源时都会上锁。

  非公平锁:synchronized关键字实现的是非公平锁,即线程获取锁的顺序并不一定是按照线程阻塞的顺序。

  可重入锁:synchronized关键字实现的是可重入锁,即已经获取锁的线程可以再次获取锁。

  独占锁或者排他锁:synchronized关键字实现的是独占锁,即该锁只能被一个线程所持有,其他线程均被阻塞。

synchronized关键字的使用方式

synchronized主要有三种使用方式:修饰普通同步方法、修饰静态同步方法、修饰同步方法块。

修饰普通同步方法(实例方法)

class syncTest implements Runnable {
 ​
     private static int i = 0;   //共享资源
 ​
     private synchronized void add() {
         i++;
     }
 ​
     @Override
     public void run() {
         for (int j = 0; j < 10000; j++) {
             add();
         }
     }
 ​
     public static void main(String[] args) throws Exception {
 ​
         syncTest syncTest = new syncTest();
         Thread t1 = new Thread(syncTest);
         Thread t2 = new Thread(syncTest);
 ​
         t1.start();
         t2.start();
 ​
         t1.join();
         t2.join();
 ​
         System.out.println(i);
     }
 }

这是一个非常经典的例子,多个线程操作i++会出现线程不安全问题,这段代码的结果很容易得到:20000

大家再看下面这段代码,猜一猜它的运行结果

class syncTest implements Runnable {
 ​
     private static int i = 0;   //共享资源
 ​
     private synchronized void add() {
         i++;
     }
 ​
     @Override
     public void run() {
         for (int j = 0; j < 10000; j++) {
             add();
         }
     }
 ​
     public static void main(String[] args) throws Exception {
 ​
 //      syncTest syncTest = new syncTest();
         Thread t1 = new Thread(new syncTest());
         Thread t2 = new Thread(new syncTest());
 ​
         t1.start();
         t2.start();
 ​
         t1.join();
         t2.join();
 ​
         System.out.println(i);
     }
 }

结果为:18726

第二个示例中的add()方法虽然也使用synchronized关键字修饰了,但是因为两次new syncTest()操作建立的是两个不同的对象,也就是说存在两个不同的对象锁,线程t1和t2使用的是不同的对象锁,所以不能保证线程安全。那这种情况应该如何解决呢?因为每次创建的实例对象都是不同的,而类对象却只有一个,如果synchronized关键字作用于类对象,即用synchronized修饰静态方法,问题则迎刃而解。

修饰静态方法

只需要在add()方法前用static修饰即可,即当synchronized作用于静态方法,锁就是当前的class对象。

class syncTest implements Runnable {
 ​
     private static int i = 0;   //共享资源
 ​
     private static synchronized void add() {
         i++;
     }
 ​
     @Override
     public void run() {
         for (int j = 0; j < 10000; j++) {
             add();
         }
     }
 ​
     public static void main(String[] args) throws Exception {
 ​
 //      syncTest syncTest = new syncTest();
         Thread t1 = new Thread(new syncTest());
         Thread t2 = new Thread(new syncTest());
 ​
         t1.start();
         t2.start();
 ​
         t1.join();
         t2.join();
 ​
         System.out.println(i);
     }
 }

结果为:20000

修饰同步代码代码块

如果某些情况下,整个方法体比较大,需要同步的代码只是一小部分,如果直接对整个方法体进行同步,会使得代码性能变差,这时只需要对一小部分代码进行同步即可。代码如下:

class syncTest implements Runnable {
 ​
     static int i = 0;   //共享资源
 ​
     @Override
     public void run() {
         //其他操作.......
         synchronized (this){   //this表示当前对象实例,这里还可以使用syncTest.class,表示class对象锁
             for (int j = 0; j < 10000; j++) {
                 i++;
             }
         }
 ​
     }
 ​
     public static void main(String[] args) throws Exception {
 ​
         syncTest syncTest = new syncTest();
         Thread t1 = new Thread(syncTest);
         Thread t2 = new Thread(syncTest);
 ​
         t1.start();
         t2.start();
 ​
         t1.join();
         t2.join();
 ​
         System.out.println(i);
     }
 }

输出结果:20000

Synchronized的底层实现

Synchronized的底层实现是完全依赖JVM虚拟机的,所以谈synchronized的底层实现,就不得不谈数据在JVM内存的存储:Java对象头,以及Monitor对象监视器。

JVM 中,对象在内存中分为三块区域

对象头

  Mark Word(标记字段):用于存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、

              偏向线程 ID、偏向时间戳等等,它是实现轻量级锁和偏向锁的关键,很明显synchronized 使用的锁对象

              是存储在Java对象头里的标记字段里。

  Klass Point(类型指针):对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。

实例数据:这部分主要是存放类的数据信息,父类的信息。

对齐填充:由于虚拟机要求对象起始地址必须是8字节的整数倍,填充数据不是必须存在的,仅仅是为了字节对齐

重量级锁的底部实现原理:Monitor

  在jdk1.6之前,synchronized只能实现重量级锁,Java虚拟机是基于Monitor对象来实现重量级锁的,所以首先来了解下Monitor,在Hotspot虚拟机中,Monitor是由ObjectMonitor实现的,其源码是用C++语言编写的,首先我们先下载Hotspot的源码,源码下载链接:,找到ObjectMonitor.hpp文件,路径是src/share/vm/runtime/objectMonitor.hpp,这里只是简单介绍下其数据结构

ObjectMonitor() {
    _header       = NULL;
    _count        = 0;  //锁的计数器,获取锁时count数值加1,释放锁时count值减1
    _waiters      = 0,  //等待线程数
    _recursions   = 0;  // 线程重入次数
    _object       = NULL;  // 存储Monitor对象
    _owner        = NULL;  // 持有当前线程的owner
    _WaitSet      = NULL;  // wait状态的线程列表
    _WaitSetLock  = 0 ;
    _Responsible  = NULL ;
    _succ         = NULL ;
    _cxq          = NULL ;  // 阻塞在EntryList上的单向线程列表
    FreeNext      = NULL ;
    _EntryList    = NULL ;  // 处于等待锁状态block状态的线程列表
    _SpinFreq     = 0 ;
    _SpinClock    = 0 ;
    OwnerIsThread = 0 ;
    _previous_owner_tid = 0;
  }

其中 _owner、_WaitSet和_EntryList 字段比较重要,它们之间的转换关系如下图  

从上图可以总结获取Monitor和释放Monitor的流程如下:

  1. 当多个线程同时访问同步代码块时,首先会进入到EntryList中,然后通过CAS的方式尝试将Monitor中的owner字段设置为当前线程,同时count加1,若发现之前的owner的值就是指向当前线程的,recursions也需要加1。如果CAS尝试获取锁失败,则进入到EntryList中。

  2. 当获取锁的线程调用wait()方法,则会将owner设置为null,同时count减1,recursions减1,当前线程加入到WaitSet中,等待被唤醒。

  3. 当前线程执行完同步代码块时,则会释放锁,count减1,recursions减1。当recursions的值为0时,说明线程已经释放了锁。

synchronized作用于同步代码块的实现原理

  前面已经了解Monitor的实现细节,而Java虚拟机则是通过进入和退出Monitor对象来实现方法同步和代码块同步的。这里为了更方便看程序字节码执行指令,我先在IDEA中安装了一个jclasslib Bytecode viewer插件。我们先来看这个synchronized作用于同步代码块的代码。

 public void run() {
        //其他操作.......
        synchronized (this){   //this表示当前对象实例,这里还可以使用Test.class,表示class对象锁
            for (int j = 0; j < 10000; j++) {
                i++;
            }
        }
    }

查看代码字节码指令如下:

dup
astore_1
monitorenter     //进入同步代码块的指令
iconst_0
istore_2
iload_2
sipush 10000
if_icmpge 27 (+17)
getstatic #2 <com/company/syncTest.i>
iconst_1
iadd
putstatic #2 <com/company/syncTest.i>
iinc 2 by 1
goto 6 (-18)
aload_1
monitorexit     //结束同步代码块的指令
goto 37 (+8)
astore_3
aload_1
monitorexit     //遇到异常时执行的指令
aload_3
athrow
return

从上述字节码中可以看到同步代码块的实现是由monitorentermonitorexit指令完成的,其中monitorenter指令所在的位置是同步代码块开始的位置,第一个monitorexit指令是用于正常结束同步代码块的指令,第二个monitorexit指令是用于异常结束时所执行的释放Monitor指令。 

synchronized作用于同步方法原理

 private synchronized void add() {
        i++;
    }

查看代码字节码指令如下:

getstatic #2 <com/company/syncTest.i>
iconst_1
iadd
putstatic #2 <com/company/syncTest.i>
return

发现这个没有monitorenter 和 monitorexit 这两个指令了,而在查看该方法的class文件的结构信息时发现了Access flags后边的synchronized标识,该标识表明了该方法是一个同步方法。Java虚拟机通过该标识可以来辨别一个方法是否为同步方法,如果有该标识,线程将持有Monitor,在执行方法,最后释放Monitor。  

原理大概就是这样,最后总结一下,面试中应该简洁地如何回答synchroized的底层原理这个问题。

答:Java虚拟机是通过进入和退出Monitor对象来实现代码块同步和方法同步的,代码块同步使用的是monitorenter和 monitorexit 指令实现的,而方法同步是通过Access flags后面的标识来确定该方法是否为同步方法。

Jdk1.6为什么要对synchronized进行优化?

  因为Java虚拟机是通过进入和退出Monitor对象来实现代码块同步和方法同步的,而Monitor是依靠底层操作系统的Mutex Lock来实现的,操作系统实现线程之间的切换需要从用户态转换到内核态,这个切换成本比较高,对性能影响较大。

jDK1.6对synchronized做了哪些优化?

锁的升级

在JDK1.6中,为了减少获得锁和释放锁带来的性能消耗,引入了偏向锁和轻量级锁,锁的状态变成了四种,如下图所示。锁的状态会随着竞争激烈逐渐升级,但通常情况下,锁的状态只能升级不能降级。这种只能升级不能降级的策略是为了提高获得锁和释放锁的效率。

升级方向:

锁解决了数据的安全性,但是同样带来了性能的下降,hotspot 虚拟机的作者经过调查发现,大部分情况下,加锁的代码不仅仅不存在多线程竞争,而且总是由同一个线程多次获得。

所以基于这样一个概率,synchronized 在JDK1.6 之后做了一些优化,为了减少获得锁和释放锁来的性能开销,引入了偏向锁、轻量级锁,锁的状态根据竞争激烈的程度从低到高不断升级。

1.无锁

无锁没有对资源进行锁定,所有的线程都能访问并修改同一个资源,但同时只有一个线程能修改成功。

2.偏向锁

偏向锁是JDK6中引入的一项锁优化,大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,为了让线程获得锁的代价更低而引入了偏向锁。

偏向锁会偏向于第一个获得它的线程,若在接下来的执行过程中,该锁没有被其他的线程获取,则持有偏向锁的线程将永远不需要同步

3.轻量级锁

当锁是偏向锁的时候,被另外的线程所访问,就会升级为轻量级锁,其他线程会通过自旋的形式尝试获取锁,不会阻塞,从而提高性能

4.重量级锁

原始的Synchronized的实现,重量级锁的特点:其他线程试图获取锁时,都会被阻塞,只有持有锁的线程释放锁之后才会唤醒这些线程。

偏向锁

常见面试题:偏向锁的原理(或偏向锁的获取流程)、偏向锁的好处是什么(获取偏向锁的目的是什么)

引入偏向锁的目的:减少只有一个线程执行同步代码块时的性能消耗,即在没有其他线程竞争的情况下,一个线程获得了锁。

偏向锁的获取流程:

  1. 检查对象头中Mark Word是否为可偏向状态,如果不是则直接升级为轻量级锁。
  2. 如果是,判断Mark Work中的线程ID是否指向当前线程,如果是,则执行同步代码块。
  3. 如果不是,则进行CAS操作竞争锁,如果竞争到锁,则将Mark Work中的线程ID设为当前线程ID,执行同步代码块。
  4. 如果竞争失败,升级为轻量级锁。

偏向锁的获取流程如下图:

 

偏向锁的撤销:

只有等到竞争,持有偏向锁的线程才会撤销偏向锁。偏向锁撤销后会恢复到无锁或者轻量级锁的状态。

  1. 偏向锁的撤销需要到达全局安全点,全局安全点表示一种状态,该状态下所有线程都处于暂停状态。
  2. 判断锁对象是否处于无锁状态,即获得偏向锁的线程如果已经退出了临界区,表示同步代码已经执行完了。重新竞争锁的线程会进行CAS操作替代原来线程的ThreadID。
  3. 如果获得偏向锁的线程还处于临界区之内,表示同步代码还未执行完,将获得偏向锁的线程升级为轻量级锁。

一句话简单总结偏向锁原理:使用CAS操作将当前线程的ID记录到对象的Mark Word中。

轻量级锁

引入轻量级锁的目的:在多线程交替执行同步代码块时(未发生竞争),避免使用互斥量(重量锁)带来的性能消耗。但多个线程同时进入临界区(发生竞争)则会使得轻量级锁膨胀为重量级锁。

轻量级锁的获取流程:首先判断当前对象是否处于一个无锁的状态,如果是,Java虚拟机将在当前线程的栈帧建立一个锁记录(Lock Record),用于存储对象目前的Mark Word的拷贝,如图所示。

将对象的Mark Word复制到栈帧中的Lock Record中,并将Lock Record中的owner指向当前对象,并使用CAS操作将对象的Mark Word更新为指向Lock Record的指针,如图所示。

如果第二步执行成功,表示该线程获得了这个对象的锁,将对象Mark Word中锁的标志位设置为“00”,执行同步代码块。

如果第二步未执行成功,需要先判断当前对象的Mark Word是否指向当前线程的栈帧,如果是,表示当前线程已经持有了当前对象的锁,这是一次重入,直接执行同步代码块。如果不是表示多个线程存在竞争,该线程通过自旋尝试获得锁,即重复步骤2,自旋超过一定次数,轻量级锁升级为重量级锁。

轻量级锁的解锁:

  轻量级的解锁同样是通过CAS操作进行的,线程会通过CAS操作将Lock Record中的Mark Word(官方称为Displaced Mark Word)替换回来。如果成功表示没有竞争发生,成功释放锁,恢复到无锁的状态;如果失败,表示当前锁存在竞争,升级为重量级锁。

  一句话总结轻量级锁的原理:将对象的Mark Word复制到当前线程的Lock Record中,并将对象的Mark Word更新为指向Lock Record的指针。

自旋锁

Java锁的几种状态并不包括自旋锁,当轻量级锁的竞争就是采用的自旋锁机制。

什么是自旋锁:当线程A已经获得锁时,线程B再来竞争锁,线程B不会直接被阻塞,而是在原地循环 等待,当线程A释放锁后,线程B可以马上获得锁。

引入自旋锁的原因:因为阻塞和唤起线程都会引起操作系统用户态和核心态的转变,对系统性能影响较大,而自旋等待可以避免线程切换的开销。

自旋锁的缺点:自旋等待虽然可以避免线程切花的开销,但它也会占用处理器的时间。如果持有锁的线程在较短的时间内释放了锁,自旋锁的效果就比较好,如果持有锁的线程很长时间都不释放锁,自旋的线程就会白白浪费资源,所以一般线程自旋的次数必须有一个限制,该次数可以通过参数-XX:PreBlockSpin调整,一般默认为10。

自适应自旋锁:JDK1.6引入了自适应自旋锁,自适应自旋锁的自旋次数不在固定,而是由上一次在同一个锁上的自旋时间及锁的拥有者的状态来决定的。如果对于某个锁对象,刚刚有线程自旋等待成功获取到锁,那么虚拟机将认为这次自旋等待的成功率也很高,会允许线程自旋等待的时间更长一些。如果对于某个锁对象,线程自旋等待很少成功获取到锁,那么虚拟机将会减少线程自旋等待的时间。

了解锁消除吗?

  锁消除是指Java虚拟机在即时编译时,通过对运行上下的扫描,消除那些不可能存在共享资源竞争的锁。锁消除可以节约无意义的请求锁时间。

了解锁粗化吗

  一般情况下,为了提高性能,总是将同步块的作用范围限制到最小,这样可以使得需要同步的操作尽可能地少。但如果一系列连续的操作一直对某个对象反复加锁和解锁,频繁地进行互斥同步操作也会引起不必要的性能消耗。

  如果虚拟机检测到有一系列操作都是对某个对象反复加锁和解锁,会将加锁同步的范围粗化到整个操作序列的外部。可以看下面这个经典案例。

for(int i=0;i<n;i++){
    synchronized(lock){
    }
}

这段代码会导致频繁地加锁和解锁,锁粗化后

synchronized(lock){
    for(int i=0;i<n;i++){
    }
}
posted @ 2022-08-30 15:23  江南大才子  阅读(4755)  评论(0编辑  收藏  举报