【并发】4、synchronized

解决线程并发安全问题

实际上,所有的并发模式在解决线程安全问题时,采用的方案都是序列化访问临界资源。即在同一时刻,只能有一个线程访问临界资源,也称作同步互斥访问。
有一点需要区别的是:当多个线程执行一个方法时,该方法内部的局部变量并不是临界资源,因为这些局部变量是在每个线程的

私有栈中,因此不具有共享性,不会导致线程安全问题。
Java 中,提供了两种方式来实现同步互斥访问:synchronized和Lock

  • synchronized
    synchronized是jvm提供的内置锁,也叫隐式锁。叫隐式锁的原因是他的加锁与解锁由jvm自动搞定,不需要人为搞定。
    在这里插入图片描述
  • ReentrantLock
    ReentrantLock是显示锁
    在这里插入图片描述

synchronized和ReentrantLock发展历程

  • jdk1.6以前的synchronized
    jdk1.6前 直接会访问操作系统Mutex互斥量,这样每次都会从用户态和内核态相互切换,效率特别低、
  • ReentrantLock
    因为synchronized性能太低了,所以doug li就基于AQS(实现高并发的框架)实现了ReentrantLock,性能远高于synchronized
    而且还有自己的特点公平性(这个synchronized是没有的)
    在这里插入图片描述
  • jdk1.6后的synchronized
    • 只有一个线程就用偏向锁
    • 有多个线程且竞争不是很激烈的时候就用轻量级锁
      大家都不会占用这个锁太久
    • Mutex互斥量应该在竞争非常激烈的时候才用

在这里插入图片描述


spin:
相当于T1在外面搞一个循环,判断lock的情况,如果T0释放了则T1从循环出来竞争去拿锁。相当于通过一个while循环一直占着CPU。

如果在规定的循环次数内T0还是没有释放,则进行锁的升级 升级成重量级锁Mutex互斥量。
原因是:

可以用spin的前提是T0执行的特别快,他执行完后马上另一个线程T1就可以执行,占着CPU浪费的一点资源 相比于 阻塞再被唤醒 这种切换线程的效率要高得多

synchronized用法

synchronized内置锁是一种对象锁(锁的是对象而非引用),作用粒度是对象,可以用来实现对临界资源的同步互斥访问,是可重入的。

  • 同步实例方法(加在方法上),锁是当前实例对象
    锁是加在当前对象即this上的
    在这里插入图片描述

  • 同步类方法(加在静态方法上),锁是当前类对象
    相当于这个锁是在加在了类对象Juc_LockOnClass.Class上
    在这里插入图片描述

  • 同步代码块,锁是括号里面的对象
    加在方法内部的同步块
    在这里插入图片描述


System.out.println() 的内部实现是这样的 而System.out 是单例的,也就是多个线程加锁实际上用的是同一个对象,这样就特别影响性能,所以我们的项目中要避免写System.out.println()
在这里插入图片描述

synchronized底层原理详解

  • synchronized是基于JVM内置锁实现,通过内部对象Monitor(监视器锁)实现,基于进入与退出Monitor对象实现方法与代码块同步。

    • 监视器锁的实现依赖底层操作系统的Mutex lock(互斥锁)实现(JDK1.5),它是一个重量级锁性能较低。
    • 当然,JVM内置锁在1.5之后版本做了重大的优化,如锁粗化(Lock Coarsening)、锁消除(Lock Elimination)、轻量级锁(Lightweight Locking)、 偏向锁(Biased Locking)、适应性自旋(Adaptive Spinning)等技术来减少锁操作的开销,,内置锁的并发性能已经基本与Lock持平。

Monitor监视器锁 -》Mutex互斥锁的实现

如何通过内部对象Monitor(监听器锁)实现

  • synchronized关键字被编译成字节码后会被翻译成monitorenter和monitorexit两条指令分别在同步块逻辑代码的起始位置与结束位置。
    在这里插入图片描述
  • 每个同步对象都有一个自己的Monitor(监视器锁),加锁过程如下图所示:
    可以看出如果获取锁失败会进入阻塞
    在这里插入图片描述

Synchronized在JVM里的实现

Synchronized在JVM里的实现:

  • monitorenter和monitorexit
  • ACC_SYNCHRONIZED标志

两种同步方式本质上没有区别,只是方法的同步是一种隐式的方式来实现,无需通过字节码来完成。两个指令的执行是JVM通 过调用操作系统的互斥原语mutex来实现,被阻塞的线程会被挂起、等待重新调度,会导致“用户态和内核态”两个态之间来回切 换,对性能有较大影响。

monitorenter和monitorexit

任何一个对象都有一个Monitor与之关联,当且一个Monitor被持有后,它将处于锁定状态。
Synchronized在JVM里的实现都是 基于进入和退出Monitor对象来实现方法同步和代码块同步,虽然具体实现细节不一样,但是都可以通过成对的MonitorEnter和 MonitorExit指令来实现。

  • monitorenter:每个对象都是一个监视器锁(monitor)。当monitor被占用时就会处于锁定状态,线程执行 monitorenter指令时尝试获取monitor的所有权,过程如下:
    • 如果monitor的进入数为0,则该线程进入monitor,然后将进入数设置为1,该线程即为monitor 的所有者;
    • 如果线程已经占有该monitor,只是重新进入,则进入monitor的进入数加1;
    • 如果其他线程已经占用了monitor,则该线程进入阻塞状态,直到monitor的进入数为0,再重新尝 试获取monitor的所有权;
  • monitorexit执行monitorexit的线程必须是objectref所对应的monitor的所有者。指令执行时,monitor的进入数减 1,如果减1后进入数为0,那线程退出monitor,不再是这个monitor的所有者。其他被这个monitor阻塞的线程可以尝试去 获取这个 monitor 的所有权。
    monitorexit,指令在字节码中出现了两次,第1次为同步正常退出释放锁;第2次为发生异步退出释放锁;

Synchronized的语义底层是通过一个monitor的对象来完成,其实wait/notify等方法也依赖于monitor对象,这就是为什么只有在同步的块或者方法中才能调用wait/notify等方法,否则 会抛出java.lang.IllegalMonitorStateException的异常的原因。

ACC_SYNCHRONIZED标志

举个同步方法的例子:

package it.yg.juc.sync;
public class SynchronizedMethod {
public synchronized void method() {
	System.out.println("Hello World!");
	}
}

在方法上加synchronized的,转成字节码后会给这个方法加ACC_SYNCHRONIZED,并没有通过指令 monitorenter 和 monitorexit 来完成(理论上其实也可以通过这两条指令来实现),不过相对于普通方法,其常量池中多了 ACC_SYNCHRONIZED 标示符。JVM就是根据该标示符来实现方法的同步的:
当方法调用时,调用指令将会检查方法的 ACC_SYNCHRONIZED 访问标志是否被设置,如果设置了,执行线程将先获取 monitor,获取成功之后才能执行方法体,方法执行完后再释放monitor。在方法执行期间,其他任何线程都无法再获得同一个 monitor对象。
在这里插入图片描述

什么是monitor?

可以把monitor理解为 一个同步工具,也可以描述为一种同步机制,它通常被描述为一个对象。与一切皆对象一样,所有的Java对象是天生的Monitor,每一个Java对象都有成为Monitor的潜质,因为在Java的设计中 ,每一个Java对象自打娘胎里出来就带了一把看不见的锁 在对象头中,它叫做内部锁或者Monitor锁。也就是通常说Synchronized的对象锁,MarkWord锁标识位为10,其中指针指向的 是Monitor对象的起始地址
在Java虚拟机(HotSpot)中,Monitor是由ObjectMonitor实现的,其主要数据结构如下(位于 HotSpot虚拟机源码ObjectMonitor.hpp文件,C++实现的):

ObjectMonitor() {
	_header = NULL;
	_count = 0; // 记录个数
	_waiters = 0,
	_recursions = 0;
	_object = NULL;
	_owner = NULL;//指向持有ObjectMonitor对象的线程
	_WaitSet = NULL; // 处于wait状态的线程,会被加入到_WaitSet
	_WaitSetLock = 0 ;
	_Responsible = NULL ;
	_succ = NULL ;
	_cxq = NULL ;
	FreeNext = NULL ;
	_EntryList = NULL ; // 处于等待锁block状态的线程,会被加入到该列表
	_SpinFreq = 0 ;
	_SpinClock = 0 ;
	OwnerIsThread = 0 ;
}

ObjectMonitor中有两个队列,_WaitSet 和 _EntryList,用来保存ObjectWaiter对象列表( 每个等待锁的线程都会被封装成 ObjectWaiter对象 ),_owner指向持有ObjectMonitor对象的线程,当多个线程同时访问一段同步代码时:

  • 首先会进入 _EntryList 集合,当线程获取到对象的monitor后,进入 _Owner区域并把monitor中的owner变量设置为当前线程,同时monitor中的计数器count加1;
  • 若线程调用 wait() 方法,将释放当前持有的monitor,owner变量恢复为null,count自减1,同时该线程进入 WaitSet 集合中等待被唤醒;
  • 若当前线程执行完毕,也将释放monitor(锁)并复位count的值,以便其他线程进入获取monitor(锁);

Monitor对象存在的位置

Monitor对象存在于每个Java对象的对象头Mark Word中(存储的指针的指向),Synchronized锁便是通过这种方式 获取锁的,也是为什么Java中任意对象可以作为锁的原因,同时notify/notifyAll/wait等方法会使用到Monitor锁对象,所以必须 在同步代码块中使用。

监视器Monitor同步方式

监视器Monitor有两种同步方式:互斥与协作。多线程环境下线程之间如果需要共享数据,需要解决互斥访问数据的问题,监视器可以确保监视器上的数据在同一时刻只会有一个线程在访问。

对象的内存布局

HotSpot虚拟机中,对象在内存中存储的布局可以分为三块区域:对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)。

  • 对象头:比如 hash码,对象所属的年代,对象锁,锁状态标志,偏向锁(线程)ID,偏向时间,数组长度(数组对象) 等。Java对象头一般占有2个机器码(在32位虚拟机中,1个机器码等于4字节,也就是32bit,在64位虚拟机中,1个机器码 是8个字节,也就是64bit),但是 如果对象是数组类型,则需要3个机器码,因为JVM虚拟机可以通过Java对象的元数据信 息确定Java对象的大小,但是无法从数组的元数据来确认数组的大小,所以用一块来记录数组长度。
  • 实例数据:存放类的属性数据信息,包括父类的属性信息;
  • 对齐填充:由于虚拟机要求 对象起始地址必须是8字节的整数倍。填充数据不是必须存在的,仅仅是为了字节对齐;

在这里插入图片描述

对象头

  • HotSpot虚拟机的对象头包括两部分信息,第一部分是“Mark Word”,用于存储对象自身的运行时数据, 如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等等,它是实现轻量级锁和偏向锁的关键,这部分数据的长度在32位和64位的虚拟机(暂不考虑开启压缩指针的场景)中分别为32个和64个Bits,官方称它为“Mark Word”。

  • 对象需要存储的运行时数据很多,其实已经超出了32、64位Bitmap结构所能记录的限度,但是对象头信息是与对象自身定义的数据无关的额外存储成本,考虑到虚拟机的空间效率,Mark Word被设计成一个非固定的数据结构以便在极小的空间内存储尽量多的信息,它会根据对象的状态复用自己的存储空间。

  • 32位的HotSpot虚拟机:
    对象锁的状态 就记录在对象头的mark word(4kb)中,mark word会根据锁的状态不断更换自己内部存储信息
    在这里插入图片描述
    JVM启动的时候会延迟启动偏向锁,因为jvm启动的时候内部会有十多个线程启动且有synchronized同步块,为了避免影响偏向锁,所以就延迟一会儿启动。

  • 64位虚拟机
    在这里插入图片描述

指针压缩

现在我们虚拟机基本是64位的,而64位的对象头有点浪费空间,JVM默认会开启指针压缩,所以基本上也是按32位的形式记录对象头的。

手动设置‐XX:+UseCompressedOops

哪些信息会被压缩?

  • 对象的全局静态变量(即类属性)
  • 对象头信息:64位平台下,原生对象头大小为16字节,压缩后为12字节
  • 对象的引用类型:64位平台下,引用类型本身大小为8字节,压缩后为4字节
  • 对象数组类型:64位平台下,数组类型本身大小为24字节,压缩后16字节

注意:heap size堆内存大于32GB是用不了压缩指针的,对象引用会额外占用20%左右的堆空间,也就意味着要38GB的内存才相当于开启了指针压缩的32GB堆空间。


oop里的MetaData元数据指针会指向Class对象,这个Class对象又会指向KClass 对象


锁的膨胀升级过程(JDK1.5后的优化)

锁的状态总共有四种,无锁状态、偏向锁、轻量级锁和重量级锁。随着锁的竞争,锁可以从偏向锁升级到轻量级锁,再升级的重量级锁,但是锁的升级是单向的,也就是说只能从低到高升级,不会出现锁的降级。
从JDK 1.6 中默认是开启偏向锁和轻量级锁的,可以通过-XX:-UseBiasedLocking来禁用偏向锁。下图为锁的升级全过程:
在这里插入图片描述

流程图解读

  • 获得偏向锁
    当目前锁状态是01

    • 倒数第三位是1也就是本来就是偏向锁,如果mark word记录的是当前线程的id,就获得偏向锁
      这也就是偏向锁存在的目的,同一线程进来就直接获取了
    • 倒数第三位是0,CAS操作替换Thread id成功则获取偏向锁
  • 获得轻量级锁
    当前线程CAS操作替换Thread id失败后,

    • 原持有偏向锁的线程->轻量级锁的过程
      暂停原持有偏向锁的线程,如果原持有锁的线程未退出同步代码块,就会升级为轻量级锁。
      • 升级为轻量级锁即将原持有偏向锁的栈中分配锁记录,拷贝对象头中的Mark Word(Thread id|epoch|age|1|01)到原持有偏向锁线程的锁记录中。原持有偏向锁的线程获取轻量级锁。(指向原持有偏向锁线程锁记录的指针|00)。
      • 唤醒原持有偏向锁的线程,从安全点继续执行,最后进行轻量级锁解锁。
    • 当前线程->轻量级锁的过程
      • 原持有线程获取轻量级锁后,当前线程在当前线程的栈中分配锁记录,拷贝对象头中的Mark Word(hash|age|0|01)到当前线程的锁记录中
        这时对象头的Mark word 是0 01
      • CAS操作将对象头的Mark Word中锁记录指针指向当前线程锁记录成功即获得轻量级锁,如果不成功就自旋继续CAS操作,如果自旋到一定次数还没成功,就会升级成重量级锁。
        这时对象头的Mark word是00
  • 获得重量级锁
    CAS操作将对象头的Mark Word中锁记录指针指向当前线程锁记录成功即获得轻量级锁,如果不成功就自旋继续CAS操作,CAS自旋到一定次数还没成功,就会升级成重量级锁。(指向重量级锁monitor的指针|10)。mutex就会挂起当前线程

  • 轻量级锁解锁过程
    当获得轻量级锁的线程代码块执行完成后,就会开始轻量级锁解锁。
    CAS操作以下两个步骤:

    • 对象头中的Mark word中的锁记录指针是否仍然指向当前线程锁记录
    • 拷贝在当前线程锁记录的Mark word信息是否与对象头中的Mark word 一致
      上述CAS操作成功即释放锁,失败的话释放锁后还要唤醒被挂起的线程,开始新一轮锁竞争。

另一张流程图写的是CAS修改Mark Word,失败后释放锁,并且唤醒被阻塞的线程
在这里插入图片描述

锁升级的条件

  • 无锁->偏向锁
    一个线程CAS替换Thread id成功,就从无锁到偏向锁。
  • 偏向锁->轻量级锁
    有竞争就会升级为轻量级锁,具体就是当一个线程CAS替换Thread id失败时,就会暂停原有持有偏向锁的线程,如过原持有偏向锁的线程没有退出同步代码块 就会被升级成轻量级锁。
  • 轻量级锁->重量级锁
    竞争比较激烈就会升级为重量级锁,具体就是当用CAS操作将对象头的Mark Word中的锁记录指针指向当前线程锁记录失败后,会一直自旋再尝试,当尝试50或100个循环后 说明这时候竞争比较激烈,就会升级为重量级锁。

不同锁状态的表示:
在这里插入图片描述

  • 偏向锁
    偏向锁是Java 6之后加入的新锁,它是一种针对加锁操作的优化手段,经过研究发现,在大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,因此为了减少同一线程获取锁(会涉及到一些CAS操作,耗时)的代价而引入偏向锁
    偏向锁的核心思想是,如果一个线程获得了锁,那么锁就进入偏向模式,此时Mark Word 的结构也变为偏向锁结构,当这个线程再次请求锁时,无需再做任何同步操作,即获取锁的过程,这样就省去了大量有关锁申请的操作,从而也就提供程序的性能。
    等待竞争开始出现,偏向锁就失效了,失效会升级为轻量级锁。
    • 默认开启偏向锁
      开启偏向锁:-XX:+UseBiasedLocking -XX:BiasedLockingStartupDelay=0
    • 关闭偏向锁:-XX:-UseBiasedLocking
  • 轻量级锁
    倘若偏向锁失败,虚拟机并不会立即升级为重量级锁,它还会尝试使用一种称为轻量级锁的优化手段(1.6之后加入的),此时Mark Word 的结构也变为轻量级锁的结构。
    轻量级锁所适应的场景是线程交替执行同步块的场合,竞争比较激烈(自旋获取轻量级锁50次还没获取成功),就会导致轻量级锁膨胀为重量级锁。
  • 自旋锁
    轻量级锁失败后,虚拟机为了避免线程真实地在操作系统层面挂起,还会进行一项称为自旋锁的优化手段。这是基于在大多数情况下,线程持有锁的时间都不会太长,如果直接挂起操作系统层面的线程可能会得不偿失,毕竟操作系统实现线程之间的切换时需要从用户态转换到核心态,这个状态之间的转换需要相对比较长的时间,时间成本相对较高,因此自旋锁会假设在不久将来,当前的线程可以获得锁,因此虚拟机会让当前想要获取锁的线程做几个空循环(这也是称为自旋的原因),一般不会太久,可能是50个循环或100循环,在经过若干次循环后,如果得到锁,就顺利进入临界区。如果还不能获得锁,那就会将线程在操作系统层面挂起,这就是自旋锁的优化方式,这种方式确实也是可以提升效率的。最后没办法也就只能升级为重量级锁了。
  • 重量级锁 Mutex互斥锁
    就是1.6之前synchronized的原始实现方式monitor。用户态和核心态之间需要切换,消耗大量时间。

对象的hashcode在不同的锁状态下存在哪里:

  • 偏向锁:应该没有存的地方
    调用偏向锁的对象的hashcode()方法,下一次再加锁就会把偏向锁升级成轻量级锁(老师推断,偏向锁应该是调用System.lazyHashCode()去得到hashcode,才导致了锁的升级)
    在这里插入图片描述
    在这里插入图片描述

  • 轻量级锁:存在对应线程栈栈帧的lock record
    在这里插入图片描述

  • 重量级锁,记录在monitor里

对象头分析工具

  • 运行时对象头锁状态分析工具JOL,他是OpenJDK开源工具包,引入下方maven依赖

    <dependency>
    <groupId>org.openjdk.jol</groupId>
    <artifactId>jol‐core</artifactId>
    <version>0.10</version>
    </dependency>
    
  • 打印markword
    然后通过下面的代码打印一个对象的内存信息

    System.out.println(ClassLayout.parseInstance(object).toPrintable());
    object为我们的锁对象
    

    在这里插入图片描述

操作系统分为大端模式和小端模式,windows和linux都采用的是小端模式,所以要把二进制码反过来

  • 手动关了偏向锁 的结果
    在这里插入图片描述
  • 没关偏向锁 无锁状态(匿名偏向)
    匿名偏向即做好偏向的准备了,所以锁的状态是101即偏向锁,但是具体的进程id还没有
    在这里插入图片描述
  • 有同步块

在这里插入图片描述

jvm锁的优化

锁的粗化

通过逃逸分析 把对同一个对象加锁的同步代码块就可以合并到一起

减少每次都要加锁再释放的次数

锁的消除

逃逸分析会分析出来object1是局部变量,是线程私有的,不会被其他线程访问到,所以这里加的锁没有什么意义,jvm就会把这个同步代码块优化掉,直接执行代码块里面的代码。
在这里插入图片描述

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值