栏目分类:
子分类:
返回
名师互学网用户登录
快速导航关闭
当前搜索
当前分类
子分类
实用工具
热门搜索
名师互学网 > IT > 软件开发 > 后端开发 > Java

Java并发之彻底搞懂synchronized

Java 更新时间: 发布时间: IT归档 最新发布 模块sitemap 名妆网 法律咨询 聚返吧 英语巴士网 伯小乐 网商动力

Java并发之彻底搞懂synchronized

前言

我们开始向Java多线程模块发起进攻。由于Java线程这部分知识点非常多,并且网上也有相关的一些教程,如果要求小编把内容全部梳理完并不现实,我们重点介绍一些面试常考知识点,此外,还要给大家打个预防针,就是关于那种高大上的如何支撑千万级并发架构设计咱也不会说,毕竟没有在实际的系统中接触过这类知识的话,光靠在面试里面背概念不仅没有任何帮助,反而会给自己带来不少麻烦,做过就是做过,没做过就是没做过,并不是人人都有处理这种量级高并发的机会。希望大家不要被这类噱头唬住,毕竟万变不离其宗还是学习原理为主,所谓高并发也是各类基础知识外加经验的组合。

话不多说我们直接开干!

synchronized

在java多线程编程中,我们需要关注一个重中之重的问题,就是线程安全问题,而造成线程安全问题的主要原因有两点:

  • 存在共享数据(也称为临界资源)
  • 存在多条线程共同操作这些共享数据

解决问题的根本方法:
同一时刻有且只有一个线程在操作共享数据,其他线程必须等到该线程处理完数据后再对共享数据进行操作。

此时,便引入了互斥锁,该锁能达到互斥访问的目的。也就是说,当一个共享数据被当前正在访问的线程加上互斥锁时,在同一个时刻,其他线程只能处于等待的状态直到当前线程处理完释放该锁后,才有可能获得操作该共享数据的权力。
该锁机制有如下两种特性:

  • 互斥性:即在同一时间只允许一个线程持有某个对象锁,通过这种特性来实现多线程的协调机制,这样在同一时间只有一个线程对需要同步的代码块(复合操作)进行访问。互斥性也称为操作的原子性。
  • 可见性:必须确保在锁被释放前,对共享变量所做的修改,对于随后获得该锁的另一个线程是可见的(即在获得锁时应获得最新共享变量的值),否则另一个线程可能是在本地缓存的某个副本上继续操作,从而引起不一致。

对于java来讲,通过关键字synchronized满足了上述的要求,在线程同步中就扮演了非常重要的角色,它可以保证在同一个时刻只有一个线程可以执行某个方法,或者某个代码块。主要是对方法或代码块中存在共享数据的操作。
同时synchronized也可以保证一个线程的变化,主要是共享数据的变化,保证这个变化被其他线程所看到,也就是说保证共享数据的可见性,跟我们后面要介绍的volitel目的是一样的。
首先我们先来明确一点,synchronized锁的不是代码,锁的是对象。
熟悉JVM的同学可能知道,堆是线程共享的,因此恰当合理的给一个对象上锁是解决线程安全问题的关键。

根据获取锁的分类可以分为以下两种:

  • 获取对象锁
  • 获取类锁

对于获取对象锁来讲,主要有两种用法:

  1. 同步代码块(synchronized (this),synchronized(类实例对象))锁是小括号()中的实例对象。
  2. 同步非静态方法(synchronized method),锁是当前对象的实例对象。

获取类锁的两种方法:

  1. 同步代码块(synchronized(类.class)),锁是小括号()中的类对象(Class对象)。
  2. 同步静态方法(synchronized static method),锁是当前对象的类对象(Class对象)。
对象锁与类锁总结
  • 有线程访问对象的同步代码块时,另外的线程可以访问该对象的非同步代码块;
  • 若锁住的是同一个对象,一个线程在访问对象的同步代码块时,另一个访问对象的同步代码块的线程会被阻塞。
  • 若锁住的是同一个对象,一个线程在访问对象的同步方法时,另一个访问对象同步方法的线程会被阻塞。
  • 若锁住的是同一个对象,一个线程在访问对象的同步代码块时,另一个访问对象同步方法的线程会被阻塞,反之亦然。
  • 同一个类的不同对象的对象锁互不干扰;
  • 类锁由于也是一种特殊的对象锁,因此表现和上述的1,2,3,4一致,而由于一个类只有一把对象锁,所以同一个类的不同对象使用类锁将会是同步的;
  • 类锁和对象锁互不干扰。
synchronized底层实现原理

在了解synchronized的实现原理之前,我们需要清楚,Java的对象头和Monitor是实现synchronized的基础。

接下来,我们对这两者进行详细的探讨。

Hotspot虚拟机中对象在内存中的布局分为三块区域:

  • 对象头
  • 实例数据
  • 对齐填充
java对象头

我们先了解一下对象头:

虚拟机位数头对象结构说明
32/64 bitMark Word默认存储对象的hashCode、分代年龄、锁类型、锁标志位等信息
32/64 bitClass metadata Address类型指针向对象的类元数据,JVM通过这个指针确定该对象是哪个类的数据

synchronized使用的锁对象是存储在java的对象头里的,其主要结构是由Mark Word和Class metadata Address 组成其中Class metadata Address是对象指向他的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例,而Mark Word则是用来存储对象自身的运行时数据,它是实现轻量级锁和偏向锁的关键。

Mark Word在默认情况下,存储着对象的hashCode、分代年龄、锁类型、锁标志位等,由于对象头的信息是由对象自身定义的数据没有关系的额外存储成本,因此考虑JVM的空间效率,Mark Word被设计成了一个非固定的数据结构以便存储更多有效的数据,它会根据对象本身的状态,复用自己的存储空间。


其中轻量级锁和偏向锁是java6后对synchronized进行优化后新增加的,稍后会提及。

Monitor

简单了解完对象头后,我们再来看一下Monitor。
在java的设计中,每一个对象在刚创建时就自带了一种看不见的锁,它叫做内部锁或者Monitor锁,也称之为监视器锁。

再看上图中,我们主要分析一下重量级锁,锁的标志位是10,其中指针指向的是指向的是Monitor对象的起始地址,每个对象都存在着一个Monitor与之关联,当一个Monitor被某个线程持有后,将处于锁定状态。

在Hotspot虚拟机中,Monitor是由ObjectMonitor来实现的,位于Hotspot源码,即objectMonitor.hpp里面,它是通过c++来实现的。
源码地址

我们可以发现,在ObjectMonitor里面有两个队列,一个是WaitSet另一个是EntryList,我们就可以和等待池与锁池关联起来,他们就是用来保存ObjectWaiter的对象列表。其中有个字段owner它是指向持有ObjectMonitor对象的线程,当多个线程同时访问同一个同步代码的时候,首先会进入到EntryList集合里面,当线程获取到对象的Monitor后,会把owner设置为当前线程,同时Monitor中的计数器就会加一,如果线程调用了wait方法,会释放当前持有的Monitor,owner就会被恢复成NULL,计数器也会被减一,同时该线程即ObjectWaiter实例就会进入到WaitSet集合中等待被唤醒,若当前线程被执行完毕它也将释放Monitor锁,并赋为当前变量的值,以便其他线程进入并获取到Monitor锁。

由此看来,Monitor对象存在于每个Java对象的对象头当中,synchronized锁便是使用这种方式获取锁的,这也是java中任意对象都可以作为锁的原因。
有了上述知识基础后,我们再进一步分析synchronized在字节码层面具体实现。
看下面一段代码:

首先,我们通过javac编译成class文件,然后通过javap -v -p SyncBlokTest > ./2.txt把字节码文件输出到一个txt文件中,然后打开,我们先查看一下syncsTask的部分:

从字节码中可知,同步语句块的实现使用的是 monitorenter和monitorexit指令,其中monitorenter指令指向同步代码块的开始位置。

它首先去获取PrintStream这个类,然后传入aaa的参数,再调用里面的println打印相关的aaa内容。

而monitorexit指令则指明同步代码块的结束位置,当执行monitorenter指令时当前线程会试图获取对象锁,当计数器为0时,线程就可以成功的获取到monitor并将计数器设置为1,表示取锁成功,如果当前线程在之前已经拥有monitor的持有权,它可以重入这个monitor。

这里打断一下,说一下什么是重入:

从互斥锁的设计上来说,当一个线程试图操作一个由其他线程持有的对象锁的临界资源时,将会处于阻塞状态,但当一个线程再次请求自己持有的对象锁的临界资源时,这种情况属于重入。

在java中synchronized基于原子性的内部锁机制是可重入的,因此在一个线程调用synchronized方法的同时,在其方法体内部调用该对象的另一个synchronized方法,也就是说一个线程得到一个对象锁之后再次请求对象锁是允许的,这就是synchronized的可重入性。

倘若其他线程已经先于当前线程拥有了monitor锁的持有权,那么当前线程将会被阻塞到monitorenter指令,直到持有该锁的线程之心完毕,即monitorexit指令被执行,执行线程将释放monitor锁,并设置计数器为0,其他线程将有机会持有monitor,为了保证在方法异常完成时,monitorenter和monitorexit指令依然可以正确配对并执行,编译器会自动产生一个异常处理器,这个异常处理器可以用来处理所有的异常,目的就是为了执行monitorexit指令。


从字节码中也可以看出,它多了一个monitorexit指令,它就是异常结束时,被执行的释放monitor的指令。

分析完了synchronized代码块,咱们再来看看synchronized方法的究竟。

可以看到,它并没有monitorenter和monitorexit并且字节码也比较短,我们可以看到有一个ACC_SYNCHRONIZED这样一个访问标志,用来区分一个方法是否是同步方法,当方法调用时,调用指令将会检查方法的ACC_SYNCHRONIZED访问标志是否被设置,如果设置了,那么执行线程将持有monitor,然后再执行方法,最后无论方法正常完成还是非正常完成都会释放掉monitor,如果在方法执行期间抛出异常,并且在方法内部无法处理此异常,那么这个同步方法所持有的monitor将在异常抛到同步方法之外时自动释放。

以上就是synchronized的基本原理,那么为什么有人会对synchronized嗤之以鼻?
在早期的版本中,synchronized属于重量级锁,依赖于操作系统底层的Mutex Lock实现,效率低下。
而操作系统实现线程之间切换时,需要从用户态切换到核心态,这个状态之间的转换需要相对比较长的时间,时间成本较高。

后来Hotspot对synchronize尝试做了很多优化,主要是为了减少重量级锁的使用,Java6之后,从JVM层面做了较大优化,synchronized性能得到了很大提升。
Hotspot花费了大量精力去实现各种锁优化技术,如:

  • 自适应自旋(Adaptive Spinning)
  • 锁消除(Lock Eliminate)
  • 锁粗化(Lock Coarsening)
  • 轻量级锁(Lightweight Locking)
  • 偏向锁(Biased Locking)

这些技术都是为了在线程之间更高效的共享数据以及解决竞争问题从而提升程序的执行效率。

自旋锁与自适应自旋锁 自旋锁

许多情况下,共享数据的锁定状态必须时间较短,为了这段时间挂起和恢复线程并不值得。
通过让线程执行忙循环等待锁释放,不让出CPU
如果锁被占用时间非常短,那么自旋锁的性能就非常好,相反就会带来很多性能开销,因为在自旋过程中,始终会占用CPU的时间片,如果锁占用时间太长,自旋的线程会白白消耗掉CPU资源。因此自旋等待的时间必须要有一定的限度,如果超过了限定的尝试次数仍然没有成功获取到锁,那么就应该使用传统的方式去挂起线程了。
在JDK的定义中,用户可以使用PreBlockSpin的参数来更改。

自适应自旋锁

在java6中引入了自适应自旋锁,这就意味着自旋的次数不再固定了,而是由前一次在同一个锁上的自旋时间以及锁拥有者的状态来决定。
如果在同一个锁对象上自旋等待刚刚成功获取过锁,并且持有锁的线程正在运行中,那么JVM会认为该锁自旋获取到锁的可能性很大,会自动增加等待时间,相反如果对于某个锁,自旋很少成功获取到锁,那么以后在获取到这个锁时将可能省略掉自旋过程,以避免浪费处理器资源。

有了自适应自旋,JVM对锁的状态预测会越来越精准。

锁消除

锁消除是虚拟机另外一种锁优化,这种优化更彻底。
java虚拟机在JIT编译时,对运行上下文进行扫描,去除不可能存在竞争的锁。
看一下这段代码:

我们都知道StringBuffer是线程安全的,可以点进append方法里看看:

由于sb只会在sppend方法中使用,没有作为方法的返回值返回,不可能被其他线程引用,因此sb属于不可能共享资源,JVM会自动消除内部的锁。

这种机制就是JVM的锁消除机制,通过锁消除进一步提升程序的性能。

锁粗化

交代完锁消除这个极端后,我们再来将锁优化推向另外一个极端,即锁粗化。
原则上,我们都知道,在加同步锁的时候,应尽可能将作用块的同步范围限制到尽量小的范围,即只在共享数据的实际作用域中才进行同步,这样是为了使得需要同步的操作数量尽可能变小,在存在锁同步竞争中,也可以使处于等待状态中的线程尽早的拿到锁。那大部分上述情况是没有问题的,但是如果存在一连串系列操作都对同一个对象反复加锁和解锁,甚至加锁操作出现在循环体中,即使没有线程竞争,这样频繁的进行互斥同步锁操作也会导致不必要的性能开销。

此时我们就可以想到一个方法:
通过扩大加锁范围,避免反复加锁和解锁。

我们再来看一个示例:

这里定义了一个test方法,通过while循环,不断往sb中添加元素,最后返回。

那么像这种连续的append操作就属于这类情况了,JVM会检测到这种操作会一直对同一对象加同步锁的情况,那么此时JVM就会将加锁的同步范围粗化到这个操作的外部,使整个一连串的append的操作只加一次锁就可以完成。

锁升级

聊完上述几种优化之后,我们再来看看锁升级。
锁升级的过程是这样的:无锁—>偏向锁—>轻量级锁—>重量级锁
其中无锁比较容易理解,就是没有加入任何锁,此时的目标共享数据是没有被任何线程占用的。而重量级锁我们前面也说过了,这里重点说一下轻量级锁和偏向锁。

经过研究我们会发现,偏向锁在大多数情况下,锁不存在多线程竞争,总是由同一线程多次获得。
偏向锁的核心思想是:
如果一个线程获得了该锁,那么锁就进入偏向模式,此时Mark Word的结构也变为偏向锁结构,当该线程再次请求锁时,无需再做任何同步操作,即获取锁的过程只需要检查Mark Word的锁标记位为偏向锁以及当前线程Id等于Mark Word的ThreadID即可,这样就省去了大量有关锁申请操作。

也就是说,当一个线程访问同步块并获取锁时会在对象头和栈帧中的锁记录里存储锁偏向的线程Id,以后该线程在进入和退出同步块时不需要进行CAS操作来加锁和解锁,从而提升程序的性能。
所以对于没有锁竞争的场合,偏向锁有很好的优化效果,毕竟极有可能连续多次都是同一个线程申请相同的锁,但是对于锁竞争比较激烈的场合,偏向锁就失效了,因为这种场合每次申请锁的线程都是不相同的,因此这种场合下,不应该使用偏向锁,否则会得不偿失。

这个时候,偏向锁会升级为轻量级锁。
偏向锁运行在一个线程进入同步块的情况下,当第二个线程加入锁争用的时候,偏向锁就会升级为轻量级锁。
在探讨轻量级锁执行过程之前,需要明白一点:轻量级锁适应的场景适用于线程交替执行同步块的情况
如果存在同一时间访问同一锁的情况,就会导致轻量级锁升级为重量级锁。

我们先来看一下轻量级锁的加锁过程:
(1)在代码进入同步块的时候,如果同步对象锁状态为无锁状态(锁标志位为“01”状态,虚拟机首先将当前线程的栈帧中建立一个名为锁记录(Locak Record)的空间,用于存储对象目前的Mark Word的拷贝,官方称为“Displaced Mark Word” 这时候线程堆栈与对象头的状态如图所示:

(2)拷贝对象头中Mark Word复制到锁记录中。
(3)拷贝成功后,虚拟机将使用CAS的操作尝试将对象的Mark Word更新为指向Lock Record的指针,并将Lock record里的owner指针指向object mark word。如果更新成功,则执行步骤(4),否则执行步骤(5)。
(4)如果这个更新操作成功了,那么这个线程就拥有了该对象的锁,并且对象Mark Word的锁标志位设置为“00”,即表示此对象处于轻量级锁状态,这时候线程堆栈与对象头的状态如图所示:

(5)如果这个更新操作失败了,虚拟机首先会检查对象的Mark Word是否指向当前线程的栈帧,如果是就说明了当前线程已经拥有了这个对象的锁,那么就可以直接进入同步块继续执行。否则就说明多个线程竞争锁,轻量级锁就要升级为重量级锁,锁标志的状态值变为“10”,Mark Word中存储的就是指向重量级锁的指针,后面等待锁的线程也要进入阻塞状态。而当前线程便尝试使用自旋来取锁,自旋咱们之前也说过,就是为了不让线程阻塞,而采用循环去获取锁的过程。

最后,再来说一下解锁过程:
(1)通过CAS操作尝试把线程中复制的Displaced Mark Word 对象替换当前Mark Word。
(2)如果替换成功,整个同步过程就完成了。
(3)如果替换失败,说明有其他线程尝试获取该锁(此时锁已经升级)那么就要在释放锁的同时,唤醒被挂起的线程。

大家看完上面的分析,尤其是轻量级锁解锁过程有些懵逼,为什么替换成功整个同步过程就完成了呢?
这里有必要解释一下锁的内存语义:
当线程释放锁时,Java内存模型会把该线程对应的本地内存中的共享变量刷新到主内存中。
而当线程获取锁时,Java内存模型,会把该线程对应的本地内存置为无效,从而使得被监视器保护的临界区代码必须从主内存中读取共享变量。

转载请注明:文章转载自 www.mshxw.com
本文地址:https://www.mshxw.com/it/439362.html
我们一直用心在做
关于我们 文章归档 网站地图 联系我们

版权所有 (c)2021-2022 MSHXW.COM

ICP备案号:晋ICP备2021003244-6号