synchronized知识合集2

电子说

1.3w人已加入

描述

synchronized修饰代码块

以上文SynchronizedTest2类为例子,其中synchronized关键字修饰代码块

获取SynchronizedTest2.class的字节码:

javac -encoding utf-8 SynchronizedTest2.java
javap -c -v SynchronizedTest2.class

Classfile /D:/ideaProjects/src/main/java/com/zj/ideaprojects/demo/test2/SynchronizedTest2.class
  Last modified 2022-10-28; size 575 bytes
  MD5 checksum ac915d460a3da67f6c76c5ed2aae01f1
  Compiled from "SynchronizedTest2.java"
public class com.zj.ideaprojects.demo.test2.SynchronizedTest2
  minor version: 0
  major version: 52
  flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
   #1 = Methodref          #6.#18         // java/lang/Object."
   #2 = Fieldref           #19.#20        // java/lang/System.out:Ljava/io/PrintStream;
   #3 = String             #21            // synchronized ▒▒▒▒ ▒▒▒▒▒
   #4 = Methodref          #22.#23        // java/io/PrintStream.println:(Ljava/lang/String;)V
   #5 = Class              #24            // com/zj/ideaprojects/demo/test2/SynchronizedTest2
   #6 = Class              #25            // java/lang/Object
   #7 = Utf8

我们可以发现:synchronized 同步语句块的在字节码中的实现,是使用了 monitorenter 和 monitorexit 指令,其中 monitorenter 指令指向同步代码块的开始位置,monitorexit 指令则指明同步代码块的结束位置。

  1. 每个对象都拥有一个monitor,当monitor被占用时,就会处于锁定状态,线程执行monitorenter指令时会获取monitor的所有权。
  2. 当monitor计数为0时,说明该monitor还未被锁定,此时线程会进入monitor并将monitor的计数器设为1,并且该线程就是monitor的所有者。如果此线程已经获取到了monitor锁,再重新进入monitor锁的话,那么会将计时器count的值加1。
  3. 如果有线程已经占用了monitor锁,此时有其他的线程来获取锁,那么此线程将进入阻塞状态,待monitor的计时器count变为0,这个线程才会获取到monitor锁。
  4. 只有拿到了monitor锁对象的线程才能执行monitorexit指令。在执行 monitorexit 指令后,将锁计数器设为 0,表明锁被释放,其他线程可以尝试获取锁。
  5. 如果获取对象锁失败,那当前线程就要阻塞等待,直到锁被另外一个线程释放为止

有个奇怪的现象不知道大家有没有发现?为什么monitorenter指令只出现了一次,但是monitorexit指令却出现了2次?

因为编译器必须保证无论同步代码块中的代码以何种方式结束,代码中每次调用monitorenter必须执行对应的monitorexit指令。如果没有执行 monitorexit指令,monitor一直被占用,其他线程都无法获取,这是非常危险的。

这个就很像"try catch finally"中的finally,不管程序运行结果如何,必须要执行monitorexit指令,释放monitor所有权

小结一下:

  1. 同步代码块是通过monitorenter和monitorexit指令来实现;同步方式是通过方法中的access_flags中设置ACC_SYNCHRONIZED标识符来实现,ACC_SYNCHRONIZED标识符会去隐式调用这两个指令:monitorenter和monitorexit
  2. synchronized修饰方法、修饰代码块 ,归根到底,都是通过竞争monitor所有权来实现同步的
  3. 每个java对象都会与一个monitor相关联,可以由线程获取和释放
  4. monitor通过维护一个计数器来记录锁的获取,重入,释放情况

锁优化

为什么说JDK早期,Synchronized是重量级锁呢?在JVM中monitorenter和monitorexit字节码依赖于底层的操作系统的Mutex Lock来实现的,但是由于使用Mutex Lock需要将 当前线程挂起并从用户态切换到内核态来申请锁资源,还需要经过一个中断的调用,申请完之后还需要从内核态返回到用户态 。整个切换过程是非常消耗资源的,如果程序中存在大量的锁竞争,那么会引起程序频繁的在用户态和内核态进行切换,严重影响到程序的性能。

在Linux系统架构中可以分为用户空间和内核,我们的程序都运行在用户空间,进入用户运行状态就是所谓的用户态。在用户态可能会涉及到某些操作如I/O调用,就会进入内核中运行,此时进程就被称为内核运行态,简称内核态。

  1. 内核: 本质上可以理解为一种软件,控制计算机的硬件资源,并提供上层应用程序运行的环境。
  2. 用户空间: 上层应用程序活动的空间。应用程序的执行必须依托于内核提供的资源,包括CPU资源、存储资源、I/O资源等。
  3. 系统调用: 为了使上层应用能够访问到这些资源,内核必须为上层应用提供访问的接口:即系统调用。

为了解决这一问题,在JDK1.6对Synchronized进行大量的优化 锁自旋、锁粗化、锁消除,锁膨胀等技术,在这部分扩展内容比较多,我们接下来一一道来。

自旋锁

在jdk1.6前多线程竞争锁时,当一个线程A获取锁时,它会阻塞其他所有正在竞争的线程,这样对性能带来了极大的影响。在挂起线程和恢复线程的操作都需要转入内核态中完成,这些操作对系统的并发性能带来了很大的压力。由于在实际环境中, 很多线程的锁定状态只会持续很短的一段时间,会很快释放锁 ,为了如此短暂的时间去挂起和阻塞其他所有竞争锁的线程,是非常浪费资源的,我们完全可以让另一个没有获取到锁的线程在门外等待一会(自旋),但 不放弃CPU的执行时间 ,等待持有锁的线程A释放锁,就里面去获得锁。这其实就是自旋锁

但是我们也无法保证线程获取锁之后,就一定很快释放锁。万一遇到有线程,长时间不释放锁,其会带来更多的性能开销。因为在线程自旋时,始终会占用CPU的时间片,如果锁占用的时间太长,那么自旋的线程会消耗掉CPU资源。 所以我们需要对锁自旋的次数有所限制,如果自旋超过了限定的次数仍然没有成功获取到锁,就应该重新使用传统的方式去挂起线程了 。在JDK定义中,自旋锁默认的自旋次数为10次,用户可以使用参数-XX:PreBlockSpin来更改。

后来也有改进型的 自适应自旋锁, 自适应意味着自旋的次数不在固定,而是由前一次在同一个锁上的自旋时间和锁的拥有者的状态共同决定。如果在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也是很可能再次成功的,进而它将会允许线程自旋相对更长的时间。如果对于某个锁,线程很少成功获得过,则会相应减少自旋的时间甚至直接进入阻塞的状态,避免浪费处理器资源。笔者感觉这个跟CPU的分支预测,有异曲同工之妙

锁粗化

一般来说,同步块的作用范围应该尽可能小,缩短阻塞时间,如果存在锁竞争,那么等待锁的线程也能尽快获取锁 但某些情况下,可能会对同一个锁频繁访问,或者有人在循环里面写上了synchronized关键字,为了降低短时间内大量的锁请求、释放带来的性能损耗,Java虚拟机发现了之后会 适当扩大加锁的范围,以避免频繁的拿锁释放锁的过程 。将多个锁请求合并为一个请求,这就是锁粗化

public class LockCoarseningTest {
 public String test() {
  StringBuffer sb = new StringBuffer();
  for(int i = 0; i < 100; i++) {
   sb.append("test");
  }
  return sb.toString();
 }
}

append() 为同步方法,短时间内大量进行锁请求、锁释放,JVM 会自动进行锁粗化,将加锁范围扩大至 for 循环外部,从而只需要进行一次锁请求、锁释放

锁消除

锁消除:通过运行时JIT编译器的逃逸分析来消除一些没有在当前同步块以外被其他线程共享的数据的锁保护,通过逃逸分析也可以在线程本的Stack上进行对象空间的分配(同时还可以减少Heap上的垃圾收集开销)。其实就是即时编译器通过对运行上下文的扫描,对不可能存在共享资源竞争的锁进行消除,从而节约大量的资源开销,提高效率

public class LockEliminateTest {
 static int i = 0;
 
 public void method1() {
  i++;
 }
 
 public void method2() {
  Object obj = new Object();
  synchronized (obj) {
   i++;
  }
 }
}

method2() 方法中的 obj 为局部变量,显然不可能被共享,对其加锁也毫无意义,故被即时编译器消除

锁膨胀

锁膨胀方向:无锁 → 偏向锁 → 轻量级锁 → 重量级锁偏向锁、轻量级锁,这两个锁既是一种优化策略,也是一种膨胀过程,接下来我们分别聊聊

偏向锁

在大多数情况下虽然加了锁,但是没有锁竞争的发生,甚至是同一个线程反复获得这个锁,那么多次的获取锁和释放锁会带来很多不必要的性能开销和上下文切换。偏向锁就为了针对这种情况而出现的

偏向锁指, 锁偏向于第一个获取他的线程 ,若接下来的执行过程中,该锁一直没有被其他线程获取,则持有偏向锁的线程永远不需要再进行同步。 这样就在无锁竞争的情况下避免在锁获取过程中执行不必要的获取锁和释放锁操作

偏向锁的具体过程:

  1. 首先JVM要设置为可用偏向锁。然后当一个进程访问同步块并且获得锁的时候,会在对象头和栈帧的锁记录里面存储取得偏向锁的线程ID。
  2. 等下一次有线程尝试获取锁的时候,首先检查这个对象头的MarkWord是不是储存着这个线程的ID。如果是,那么直接进去而不需要任何别的操作。
  3. 如果不是,那么分为两种情况:
  • 对象的偏向锁标志位为0(当前不是偏向锁),说明发生了竞争,已经膨胀为轻量级锁,这时使用CAS操作尝试获得锁。
  • 偏向锁标志位为1,说明还是偏向锁不过请求的线程不是原来那个了。这时只需要使用CAS尝试把对象头偏向锁从原来那个线程指向目前求锁的线程。

轻量级锁

在实际情况中,大部分的锁,在整个同步生命周期内都不存在竞争,在无锁竞争的情况下完全可以避免调用操作系统层面的 重量级互斥锁, 可以通过CAS原子指令就可以完成锁的获取及释放。当存在锁竞争的情况下,执行CAS指令失败的线程将调用操作系统互斥锁进入到阻塞状态,当锁被释放的时候被唤醒。当升级为轻量级锁之后,MarkWord的结构也会随之变为轻量级锁结构。JVM会利用CAS尝试把对象原本的MarkWord 更新为Lock Record的指针,成功就说明加锁成功,改变锁标志位为00,然后执行相关同步操作。轻量级锁所适应的场景是 线程交替执行同步块的场合 ,如果存在同一时间访问同一锁的场合,就会导致轻量级锁就会失效,进而膨胀为重量级锁。

CAS (Compare-And-Swap):顾名思义 比较并替换 。这是一个由CPU硬件提供并实现的原子操作.可以被认为是一种 乐观锁 ,会以一种更加乐观的态度对待事情,认为自己可以操作成功。当多个线程操作同一个共享资源时,仅能有一个线程同一时间获得锁成功,在乐观锁中,其他线程发现自己无法成功获得锁,并不会像悲观锁那样阻塞线程,而是直接返回,可以去选择再次重试获得锁,也可以直接退出

CAS机制所保证的只是一个变量的原子性操作,无法保证整个代码块的原子性

最后再小结一下,锁的优缺点对比:

优点 缺点 使用场景
偏向锁 加锁和解锁不需要CAS操作,没有额外的性能消耗,和执行非同步方法相比仅存在纳秒级的差距 如果线程间存在锁竞争,会带来额外的锁撤销的消耗 适用于只有一个线程访问同步块的场景
轻量级锁 竞争的线程不会阻塞,提高了响应速度 如线程成始终得不到锁竞争的线程,使用自旋会消耗CPU性能 追求响应时间,同步块执行速度非常快
重量级锁 线程竞争不适用自旋,不会消耗CPU 线程阻塞,响应时间缓慢,在多线程下,频繁的获取释放锁,会带来巨大的性能消耗 追求吞吐量,同步块执行速度较长

最高效的是偏向锁,尽量使用偏向锁,如果不能(发生了竞争)就膨胀为轻量级锁,当发生锁竞争时,轻量级锁的CAS操作会自动失效,锁再次膨胀为重量级锁。 锁一般是只能升级但不能降级 ,这种锁升级却不能降级的策略,目的是 为了提高获得锁和释放锁的效率。( hotspot其实是可以发生锁降级的,但触发锁降级的条件比较苛刻**)**

偏向锁,轻量级锁,只需在用户态就可以实现,而不需要进行用户态和内核态之间的切换

经过如此多的锁优化,如今的 synchronized 锁效率非常不错,目前不论是各种开源框架还是 JDK 源码都大量使用了 synchronized 关键字。

synchronized关键字实现单例模式

我们来看一个经典的例子,利用synchronized关键字实现单例模式

/**
 * 懒汉 - 双层校验锁
 */
public class SingleDoubleCheck {
    private static SingleDoubleCheck instance = null;

    private SingleDoubleCheck(){}//将构造器 私有化,防止外部调用

    public static SingleDoubleCheck getInstance() {
        if (instance == null) { //part 1
            synchronized (SingleDoubleCheck.class) {
                if (instance == null) { //part 2
                    instance = new SingleDoubleCheck();//part 3
                }
            }
        }
        return instance;
    }
}

对单例模式感兴趣的话,见拓展:https://mp.weixin.qq.com/s/TyiCfVMeeDwa-2hd9N9XJQ

synchronized 和 volatile 的区别?

synchronized 关键字和 volatile 关键字是两个互补的存在,而不是对立的存在

  1. volatile 关键字是线程同步的轻量级实现,所以 volatile性能肯定比synchronized关键字要好 。但是 volatile 关键字只能用于变量而 synchronized 关键字可以修饰方法以及代码块 。
  2. volatile 关键字能保证数据的可见性,但不能保证数据的原子性。synchronized 关键字两者都能保证。
  3. volatile关键字主要用于解决变量在多个线程之间的可见性,而 synchronized 关键字解决的是多个线程之间访问资源的同步性。
  4. volatile只能修饰实例变量和类变量,而synchronized可以修饰方法,以及代码块。

尾语

本文拓展内容确实有点多,很开心你能看到最后,我们再简明地回顾一下synchronized 的特性

  1. 原子性:确保线程互斥的访问同步代码。synchronized保证只有一个线程拿到锁,进入同步代码块操作共享资源,因此具有原子性。
  2. 可见性:保证共享变量的修改能够及时可见。当某线程进入synchronized代码块前后,线程会获得锁,清空工作内存,从主内存拷贝共享变量最新的值到工作内存成为副本,执行代码,将修改后的副本的值刷新回主内存中,线程释放锁。其他获取不到锁的线程会阻塞等待,所以变量的值一直都是最新的。
  3. 有序性:synchronized内的代码和外部的代码禁止排序,至于内部的代码,则不会禁止排序,但是由于只有一个线程进入同步代码块,因此在同步代码块中相当于是单线程的,根据 as-if-serial 语义,即使代码块内发生了重排序,也不会影响程序执行的结果。
  4. 悲观锁:synchronized是悲观锁。每次使用共享资源时都认为会和其他线程产生竞争,所以每次使用共享资源都会上锁。
  5. 独占锁(排他锁):synchronized是独占锁(排他锁)。该锁一次只能被一个线程所持有,其他线程被阻塞。
  6. 非公平锁:synchronized是非公平锁。线程获取锁的顺序可以不按照线程的阻塞顺序。允许新来的线程有可能立即获得监视器,而在等待区中等候已久的线程可能再次等待。这样有利于提高性能,但是也可能会导致饥饿现象
  7. 可重入锁:synchronized是可重入锁。持锁线程可以再次获取自己的内部的锁,可一定程度避免死锁。

参考资料:

https://openjdk.org/groups/hotspot/docs/HotSpotGlossary.html

《深入理解java虚拟机》

《Java并发编程的艺术》

https://www.cnblogs.com/qingshan-tang/p/12698705.html

https://www.cnblogs.com/jajian/p/13681781.html

打开APP阅读更多精彩内容
声明:本文内容及配图由入驻作者撰写或者入驻合作网站授权转载。文章观点仅代表作者本人,不代表电子发烧友网立场。文章及其配图仅供工程师学习之用,如有内容侵权或者其他违规问题,请联系本站处理。 举报投诉

全部0条评论

快来发表一下你的评论吧 !

×
20
完善资料,
赚取积分