欢迎访问 生活随笔!

生活随笔

当前位置: 首页 >

带你玩转关键字Synchronized

发布时间:2025/3/20 50 豆豆
生活随笔 收集整理的这篇文章主要介绍了 带你玩转关键字Synchronized 小编觉得挺不错的,现在分享给大家,帮大家做个参考.

synchronized关键字是Java并发编程中线程同步的常用手段之一,当多个线程同时访问某个线程间的共享变量时,我们可以使用synchronized来保证线程安全。synchronized可以保证互斥性,可见性和有序性:

  • 互斥性:确保线程互斥的访问同步代,锁自动释放,多个线程操作同个代码块或函数必须排队获得锁;
  • 可见性:保证共享变量的修改能够及时可见,获得锁的线程操作完毕后会将所数据刷新到共享内存区;
  • 有序性:有效解决指令重排问题。

接下来我们一步一步来了解synchronized的底层实现原理。

synchronized使用方式

有如下程序,有两个线程需要对共享变量i进行加1的操作,每个线程都加到10000,最终需要输出20000。

/*** @Author likangmin* @create 2020/12/11 13:36*/ public class Thread4 implements Runnable{//共享资源(临界资源)static int i=0;public void increase(){i++;}public void run() {for(int j=0;j<10000;j++){increase();}}public static void main(String[] args) throws InterruptedException {Thread4 t=new Thread4();Thread t1=new Thread(t);Thread t2=new Thread(t);t1.start();t2.start();t1.join();//主线程等待t1执行完毕t2.join();//主线程等待t2执行完毕System.out.println(i);} }

在不使用synchronized关键字的时候,我们看一下最后执行的结果:

发现最终的结果是小于20000的,显然结果是不正确的。这个时候就需要使用synchronized关键字, 一共有三种使用的方法:直接修饰某个实例方法,直接修饰某个静态方法,修饰代码块。每个类都有一个类锁,类的每个对象也有一个内置锁,它们是互不干扰的,也就是说一个线程可以同时获得类锁和该类实例化对象的内置锁,当线程访问synchronzied修饰的方法时,依据修饰的不同类型获取不同的锁。

修饰实例方法

synchronized关键词作用在方法的前面,用来锁定方法,默认锁定的是this对象。synchronized修饰的实例方法,多线程并发访问时,只能有一个线程进入,获得对象内置锁,其他线程阻塞等待,但在此期间线程仍然可以访问其他方法。
我们还是以上面的例子来进行测试,在实例方法上加上synchronized:

/*** @Author likangmin* @create 2020/12/11 13:35*/ public class Thread1 implements Runnable{//共享资源(临界资源)static int i=0;//synchronized关键字public synchronized void increase(){i++;}public void run() {for(int j=0;j<10000;j++){increase();}}public static void main(String[] args) throws InterruptedException {Thread1 t=new Thread1();Thread t1=new Thread(t);Thread t2=new Thread(t);t1.start();t2.start();t1.join();//主线程等待t1执行完毕t2.join();//主线程等待t2执行完毕System.out.println(i);} }

看一下输出,的确是20000,符合最终的结果。

修饰静态方法

synchronized修饰在方法上,不过修饰的是静态方法,等价于锁定的是Class对象。synchronized修饰的静态方法,多线程并发访问时,只能有一个线程进入,获得类锁,其他线程阻塞等待,但在此期间线程仍然可以访问其他方法。
我们还是以上面的例子来进行测试,在静态方法上加上synchronized:

/*** @Author likangmin* @create 2020/12/11 13:35*/ public class Thread2 {//共享资源(临界资源)static int i = 0;public static synchronized void increase() {i++;}public static void main(String[] args) throws InterruptedException {Thread t1 = new Thread(new Runnable() {public void run() {for (int j = 0; j < 10000; j++) {increase();}}});Thread t2 = new Thread(new Runnable() {@Overridepublic void run() {for (int j = 0; j < 10000; j++) {increase();}}});t1.start();t2.start();t1.join();//主线程等待t1执行完毕t2.join();//主线程等待t2执行完毕System.out.println(i);} }

看一下输出,的确是20000,符合最终的结果。

修饰代码块

在函数体内部对于要修改的参数区间用synchronized来修饰,相比与锁定函数这个范围更小,可以指定锁定什么对象。synchronized修饰的代码块,多线程并发访问时,只能有一个线程进入,根据括号中的对象或者是类,获得相应的对象内置锁或者是类锁。
我们还是以上面的例子来进行测试,在代码块上加上synchronized:

/*** @Author likangmin* @create 2020/12/11 13:36*/ public class Thread3 implements Runnable{//共享资源(临界资源)static int i = 0;@Overridepublic void run() {for (int j = 0; j < 10000; j++) {//获得了String的类锁synchronized (String.class) {i++;}}}public static void main(String[] args) throws InterruptedException {Thread3 t = new Thread3();Thread t1 = new Thread(t);Thread t2 = new Thread(t);t1.start();t2.start();t1.join();t2.join();System.out.println(i);} }

看一下输出,的确是20000,符合最终的结果。

有的同学可能会问,synchronized修饰方法和修饰代码块有什么区别?这个我们在后面会解答,请你耐心往后看。

synchronized底层原理

synchronized内存模型

讲清 synchronized 关键字的原理前需要理清 Java 对象在内存中的表示方法。我们知道在Java的JVM内存区域中一个对象在堆区创建,创建后的对象由三部分组成:

这三部分功能如下:

  • 对象头:主要包括两部分 Klass Point跟 Mark Word,如果是数组,还包括数组的长度;
  • 实例变量:存放类的属性数据信息,包括父类的属性信息,这部分内存按4字节对齐;
  • 填充数据:由于虚拟机要求对象起始地址必须是8字节的整数倍。填充数据不是必须存在的,仅仅是为了字节对齐。

synchronized不论是修饰方法还是代码块,都是通过持有修饰对象的锁来实现同步,synchronized锁对象是存在对象头Mark Word。

Mark Word 中的某些字段发生变化,就可以代表锁不同的状态。Mark Word状态表示位如下:

其中轻量级锁和偏向锁是Java6对synchronized锁进行优化后新增加的,这里我们主要分析一下重量级锁也就是通常说synchronized的对象锁,锁标识位为10,其中指针指向的是monitor对象(也称为管程或监视器锁)的起始地址。

每个对象都存在着一个 monitor与之关联,而monitor可以被线程拥有或释放,在Java虚拟机(HotSpot)中,monitor是由ObjectMonitor实现的,其主要数据结构如下(位于HotSpot虚拟机源码ObjectMonitor.hpp文件,C++实现的):

ObjectMonitor() {_count = 0; //记录数_recursions = 0; //锁的重入次数_owner = NULL; //指向持有ObjectMonitor对象的线程 _WaitSet = NULL; //调用wait后,线程会被加入到_WaitSet_EntryList = NULL ; //等待获取锁的线程,会被加入到该列表 }

monitor运行图如下:

依据以上图示我们简单梳理一下执行过程:

  • 当多个线程同时访问synchronized修饰的方法(代码块)时,这些线程会先被放进_EntryList队列,此时线程处于blocked状态;
  • 当一个线程获取到了对象的monitor后,那么就可以进入running状态,执行方法块,此时,ObjectMonitor对象的_owner指向当前线程,_count加1表示当前对象锁被一个线程获取;
  • 当running状态的线程调用wait()方法,那么当前线程释放monitor对象,进入waiting状态,ObjectMonitor对象的_owner变为null,_count减1,同时线程进入_WaitSet队列,直到有线程调用notify()方法唤醒该线程,则该线程进入_EntryList队列,竞争到锁再进入_owner区。
  • 如果当前线程执行完毕,那么也释放monitor对象,ObjectMonitor对象的_owner变为null,_count减1。
  • 因为监视器锁(monitor)是依赖于底层的操作系统的Mutex Lock来实现的,而操作系统实现线程之间的切换时需要从用户态转换到核心态,这个状态之间的转换需要相对比较长的时间,时间成本相对较高,这也是早期的synchronized效率低的原因。在Java 6之后Java官方对从JVM层面对synchronized较大优化最终提升显著,Java 6之后,为了减少获得锁和释放锁所带来的性能消耗,引入了锁升级的概念。

    synchronizedd修改方法与代码块区别

    在介绍锁升级之前,我们再回到最开始一个问题,synchronized修饰方法和修饰代码块有什么区别。针对这两种情况,Java 编译时的处理方法并不相同。我们可以通过反汇编看下同步方法跟同步方法块在汇编语言级别是什么样的指令。
    对之前的Thread1,即synchronized修改方法的类,在终端执行javap -v Thread1.calss,得到的字节码文件部分如下:

    对之前的Thread3,即synchronized修改代码块,在终端执行javap -v Thread1.calss,得到的字节码文件部分如下:


    我们可以看到:

    • 第一种情况,编译器会为其自动生成了一个 ACC_SYNCHRONIZED 关键字用来标识。在 JVM 进行方法调用时,当发现调用的方法被 ACC_SYNCHRONIZED 修饰,则会先尝试获得锁,然后开始执行方法,方法执行之后再释放监视器锁。这时如果其他线程来请求执行方法,会因为无法获得监视器锁而被阻断住。值得注意的是,如果在方法执行过程中,发生了异常,并且方法内部并没有处理该异常,那么在异常被抛到方法外面之前监视器锁会被自动释放。
    • 第二种情况,编译时在代码块开始前生成对应的1个 monitorenter 指令,代表同步块进入。2个 monitorexit 指令,前一个代表同步块正常退出,后一个在同步块异常时退出。每个对象维护着一个记录着被锁次数的计数器。未被锁定的对象的该计数器为0,当一个线程获得锁(执行monitorenter)后,该计数器自增变为 1 ,当同一个线程再次获得该对象的锁的时候,计数器再次自增。当同一个线程释放锁(执行monitorexit指令)的时候,计数器再自减。当计数器为0的时候。锁将被释放,其他线程便可以获得锁。

    CAS算法

    在讲其他之前,我们还要给大家介绍一个算法- CAS 算法。CAS 算法全称为 Compare And Swap。顾名思义,该算法涉及到了两个操作,比较(Compare)和交换(Swap)。其基本流程如下图:

    在对共享变量进行多线程操作的时候,难免会出现线程安全问题。对该问题的一种解决策略就是对该变量加锁,保证该变量在某个时间段只能被一个线程操作。但是这种方式的系统开销比较大,因此提出了一种新的算法-CAS 算法。算法的思路如下:

  • 对当前读取变量值 E 和内存中的变量旧值 V 进行比较
  • 如果相等,就代表其他线程没有对该变量进行修改,就将变量值更新为新值 N
  • 如果不等,就认为在读取值 E 到比较阶段,有其他线程对变量进行过修改,不进行任何操作。
  • 当线程运行 CAS 算法时,该运行过程是原子操作,原子操作的含义就是线程开始跑这个函数后,运行过程中不会被别的程序打断。

    锁升级

    synchronized锁有四种状态,无锁、偏向锁、轻量级锁、重量级锁。这几个状态会随着竞争状态逐渐升级,锁可以升级但不能降级,但是偏向锁状态可以被重置为无锁状态。

    偏向锁

    HotSpot的作者大量研究发现大多数时候是不存在锁竞争的,经常是一个线程多次获得同一个锁,因此如果每次都要竞争锁会增大很多没有必要付出的代价,为了降低获取锁的代价,引入偏向锁。以下为偏向锁的 Mark Word 字段。

    如果一个线程获得了锁,那么锁就进入偏向模式,此时Mark Word 的结构也变为偏向锁结构。当这个线程再次请求锁时,无需再做任何同步操作,即不用再去重复获取锁的过程,这样就省去了大量有关锁申请的操作,从而也就提供程序的性能。偏向锁的申请流程:

  • 有线程1和线程2,当线程1访问代码块并获取锁对象时,会在java对象头和栈帧中记录偏向的锁的threadID;
  • 偏向锁不会主动释放锁,因此线程1再次获取锁的时候,需要比较当前线程的threadID和Java对象头中的threadID是否一致;
  • 如果一致(还是线程1获取锁对象),则无需使用CAS来加锁、解锁,直接进入代码块执行;
  • 如果不一致(其他线程,如线程2要竞争锁对象,而偏向锁不会主动释放因此还是存储的线程1的threadID),那么需要查看Java对象头中记录的线程1是否存活;
  • 如果没有存活,那么锁对象被重置为无锁状态,其它线程(线程2)可以利用 CAS 算法将对象的 Mark Word 进行更改,使线程 ID 部分换成本线程 ID。如果更换成功,则重偏向完成,获得偏向锁,如果失败,则说明有多线程竞争,升级为轻量级锁;
  • 如果存活,那么立刻查找该线程(线程1)的栈帧信息,如果还是需要继续持有这个锁对象,那么暂停当前线程1,撤销偏向锁,升级为 轻量级锁;
  • 如果线程1不再使用该锁对象,那么将锁对象状态设为无锁状态,重新偏向新的线程。
  • 偏向锁使用场景

    • 对于没有锁竞争的场合,偏向锁有很好的优化效果,毕竟极有可能连续多次是同一个线程申请相同的锁;
    • 对于锁竞争比较激烈的场合,偏向锁就失效了,因为这样场合极有可能每次申请锁的线程都是不相同的,因此这种场合下不应该使用偏向锁,否则会得不偿失;
    • 偏向锁失败后,并不会立即膨胀为重量级锁,而是先升级为轻量级锁。

    轻量级锁

    轻量级锁考虑的是竞争锁对象的线程不多,而且线程持有锁的时间也不长的情景。因为阻塞线程需要高昂的耗时实现CPU从用户态转到内核态的切换,如果刚刚阻塞不久这个锁就被释放了,那这个代价就有点得不偿失了,因此这个时候就干脆不阻塞这个线程,让它自旋这等待锁释放。如果当前对象是轻量级锁状态,对象的 Mark Word 如下图所示:

    该对象头Mark Word分为两个部分。第一部分是指向栈中的锁记录的指针,第二部分是锁标记位,针对轻量级锁该标记位为 00。轻量级锁的申请流程:

  • 如果当前这个对象的锁标志位为 01(即无锁状态或者轻量级锁状态),线程1在执行同步块之前,JVM 会先复制一份锁对象的对象头MarkWord在当前的线程的栈帧中用于存储锁记录的空间(称为DisplacedMarkWord);
  • 使用CAS对这个对象的 Mark Word 进行修改,把对象头中的内容替换为线程1存储的锁记录(DisplacedMarkWord)的地址;如果修改成功,那该线程就拥有了这个对象的锁;
  • 如果在线程1复制对象头的同时(在线程1CAS之前),线程2也准备获取锁,复制了对象头到线程2的锁记录空间中,但是在线程2CAS的时候,发现线程1已经把对象头换了,线程2的CAS失败,那么线程2就尝试使用自旋锁来等待线程1释放锁。自旋锁简单来说就是让线程2在循环中不断CAS尝试获得锁对象;
  • 如果自旋的「时间太长」也不行,因为自旋是要消耗CPU的,因此自旋的次数是有限制的,比如10次或者100次,如果自旋次数到了线程1还没有释放锁,或者线程1还在执行,线程2还在自旋等待,那么这个时候轻量级锁就会膨胀为重量级锁。重量级锁把除了拥有锁的线程都阻塞,防止CPU空转。
  • 重量级锁

    在 Java 的早期版本中,synchronized 锁属于重量级锁,此时对象的 Mark Word 如图所示:

    该对象头的 Mark Word 分为两个部分,第一部分是指向重量级锁的指针,第二部分是锁标记位。这里所说的指向重量级锁的指针就是 monitor,monitor 是监视器,Java 中每个对象会对应一个监视器,这个监视器其实也就是监控锁有没有释放,释放的话会通知下一个等待锁的线程去获取。monitor 的成员变量比较多,我们可以将 monitor 简单理解成两部分,第一部分表示当前占用锁的线程,第二部分是等待这把锁的线程队列。

    如果当前占用锁的线程把锁释放了,那就需要在线程队列中唤醒下一个等待锁的线程。但是阻塞或唤醒一个线程需要依赖底层的操作系统来实现,Java 的线程是映射到操作系统的原生线程之上的。而操作系统实现线程之间的切换需要从用户态转换到核心态,这个状态转换需要花费很多的处理器时间,甚至可能比用户代码执行的时间还要长。由于这种效率太低,所以提出了偏向锁,轻量级锁等的改进优化。

    总结

    最后给大家总结一下各个锁的优缺点以及各自适用的场景:

    锁状态优点缺点适用场景
    偏向锁加锁解锁无需额外消耗,跟非同步方法时间相差纳秒级别如果竞争线程多,会带来额外的锁撤销的消耗基本没有其他线程竞争的同步场景
    轻量级锁竞争的线程不会阻塞而是在自旋,可提高程序响应速度如果一直无法获得会自旋消耗CPU少量线程竞争,持有锁时间不长,追求响应速度
    重量级锁线程竞争不会导致CPU自旋跟消耗CPU资源线程阻塞,响应时间长很多线程竞争锁,切锁持有时间长,追求吞吐量时候

    最后再奉上锁升级大图(感谢unbelievableme大神的绘制):

    想看更多文章,请点击此处

    参考文章:
    https://www.cnblogs.com/kundeg/p/8422557.html

    总结

    以上是生活随笔为你收集整理的带你玩转关键字Synchronized的全部内容,希望文章能够帮你解决所遇到的问题。

    如果觉得生活随笔网站内容还不错,欢迎将生活随笔推荐给好友。