文章目录
- 1、并发编程会出现原子性、可见性、有序性问题。
- 2、JVM内存模型
- 3、主内存与工作内存的交互
- 4、synchronized如何保证可见性、原子性、有序性?
- 5、synchronized的特性
- 5.1 可重入
- 5.2 不可中断
- 6、synchronized的原理(jdk1.6以前)
- 6.1 synchronized代码块:
- JVM规范对monitorenter的描述:
- JVM规范对monitorexit的描述:
- 6.2 synchronized方法:
- 6.3 总结:
- 7、synchronized的优化(jdk1.6及以后)
- 7.1 锁升级
- 偏向锁延迟及匿名偏向锁:
- 偏向锁的加锁和撤销流程:
- 轻量级锁的加锁流程:
- 轻量级锁解锁流程:
- **总结:**
- 7.2 锁消除:
- 7.3 锁粗化:
- 8、平时代码中对synchronized进行优化的建议:
1、并发编程会出现原子性、可见性、有序性问题。
原子性:在一次或多次操作中,要么所有的操作都执行并且不会受其它因素干扰而中断,要么所有的操作都不执行。在多线程环境下,线程对共享变量的操作,要么成功,要么失败,不会受其它线程的干扰。
可见性:在多线程环境下,某个线程对共享变量的修改,其它的线程可以知道并获取最新修改的值。
有序性:指程序中代码的执行顺序,java在编译时和运行时对代码进行优化,会导致程序的最终执行顺序不一定是我们Java代码编写时的顺序。
2、JVM内存模型
不同 CPU 硬件,原生的指令重排、数据可见行为不一样。统一的多线程内存语义,就是 Java 定义一套固定的并发行为规则。开发者使用 volatile、synchronized 时,无论跑在 x86 还是 ARM,多线程的可见性、有序性表现完全相同。底层硬件差异交给 JVM,由 JVM 根据当前 CPU,生成对应的内存屏障等硬件指令,开发者不需要感知底层硬件区别。
Java内存模型,是Java虚拟机规范中所定义的一种内存模型,Java内存模型是标准化的,屏蔽掉了底层不同计算机的区别。
Java内存模型是一套规范,描述了Java程序中各种变量(线程共享变量)的访问规则,以及在JVM中将变量存储到内存和从内存中读取变量这样的底层细节,具体如下。
- 主内存
主内存是所有线程都共享的,都能访问的。所有的共享变量都存储于主内存。
- 工作内存
每一个线程有自己的工作内存,工作内存只存储该线程对共享变量的副本。线程对变量的所有的操作(读,取)都必须在工作内存中完成,而不能直接读写主内存中的变量,不同线程之间也不能直接访问对方工作内存中的变量。
Java内存模型的作用:Java内存模型是一套在多线程读写共享数据时,对共享数据的可见性、有序性、和原子性的规则和保障。主要就是synchronized和volatile。
3、主内存与工作内存的交互
Java内存模型中定义了以下8种操作来完成,主内存与工作内存之间具体的交互协议,即一个变量如何从主内存拷贝到工作内存、如何从工作内存同步回主内存之类的实现细节,虚拟机实现时必须保证下面提及的每一种操作都是原子的、不可再分的。
只有使用synchronized才会有Lock和unlock操作。
- 如果对一个变量执行lock操作,将会清空工作内存中此变量的值
- 对一个变量执行unlock操作之前,必须先把此变量同步到主内存中
4、synchronized如何保证可见性、原子性、有序性?
保证可见性:
由于使用synchronized加锁时,会清空工作内存中的变量的值,导致线程重新读取主存中的最新的值。
从而保证了线程的可见性。使用volatile也可以保证可见性。
保证原子性:
使用synchronized加锁,线程需要获取对象的锁,由于对象的锁,只有一把,同一时间只有一个线程能操作加锁的代码,从而保证了原子性。使用volatile无法保证原子性。
保证有序性:
加了synchronized依然会发生指令重排序,但是我们的synchronized会保证同步块在同一时间只有一个线程访问,就是单线程访问同步代码块,那么在单线程情况下,管你指令怎么重排,指令执行的结果都是一样的,如果指令重排导致在单线程下执行结果不一致,那么也就不会发生指令重排了。从而保证了有序性。使用volatile也可以保证有序性。
为什么要发生指令重排?
为了提高程序的执行效率,编译器和CPU会对程序中的代码进行重排序。
什么情况下可以发生指令重排序?
我们要满足as-if-serial语义:即不管编译器和CPU如何进行重排序,必须保证在单线程环境下,程序的结果是正确的。
5、synchronized的特性
5.1 可重入
一个线程可以重复获取某个对象的锁,即重复获取同一把锁。
可重入原理:
synchronized的锁对象中有一个计数器(recursions变量)会记录线程获得几次锁,获取锁计数器+1,释放锁计数器-1。
可重入的好处:
- 可以避免死锁
- 可以使我们更好的封装代码
5.2 不可中断
不可中断是指一个线程在获取锁之后,另一个线程想要获取锁,获取不到处于阻塞或等待状态,如果第一个线程不释放锁,那么阻塞或等待的线程就一直等,并且不可被中断。
synchronized:是不可中断的
Lock:
- lock()方法:不可中断
- tryLock()方法:可中断
6、synchronized的原理(jdk1.6以前)
synchronized是JVM内置锁,基于Monitor机制实现。依赖底层操作系统的互斥原语Mutex(互斥量)。
Monitor,直译为“监视器”,而操作系统领域一般翻译为“管程”。管程是指管理共享变量以及对共享变量操作的过程,让它们支持并发。
Java(HotSpot)中的Monitor是基于C++实现的,由ObjectMonitor实现的。
// 初始化monitor,除了semaphore,其他字段都是简单的int或者指针类型ObjectMonitor(){_header=NULL;_count=0;_waiters=0,_recursions=0;_object=NULL;_owner=NULL;_WaitSet=NULL;_WaitSetLock=0;_Responsible=NULL;_succ=NULL;_cxq=NULL;FreeNext=NULL;_EntryList=NULL;_SpinFreq=0;_SpinClock=0;OwnerIsThread=0;_previous_owner_tid=0;}ObjectMonitor 的主要参数都在里面,从名字上慢慢分析。
- _owner:指向持有ObjectMonitor对象的线程
- _WaitSet:存放处于wait状态的队列
- _EntryList:存放处于等待锁block状态的队列
- _recursions:锁的重入次数
- _count:用来记录该线程获取锁的次数
_WaitSet和 _EntryList有什么区别呢?
当多个线程同时访问同步代码时,首先进入的就是_EntryList 。当获得对象的monitor时,_owner 指向当前线程,_count进行加1。
若持有monitor的线程调用wait() ,则释放持有的monitor ,_owner 变为null ,_count 减1。
同时该线程进入_WaitSet 等待被唤醒。如果执行完毕,也释放monitor。
6.1 synchronized代码块:
将synchronized代码块使用javap对字节码进行反编译时,会发现在代码块的前后插入一个monitorenetr和monitorexit的指令。
JVM规范对monitorenter的描述:
每一个对象都会和一个监视器monitor关联。监视器被占用时会被锁住,其他线程无法来获
取该monitor。 当JVM执行某个线程的某个方法内部的monitorenter时,它会尝试去获取当前对象对应
的monitor的所有权。其过程如下:
- 若monior的进入数为0,线程可以进入monitor,并将monitor的进入数置为1。当前线程成为
monitor的owner(所有者) - 若线程已拥有monitor的所有权,允许它重入monitor,则进入monitor的进入数加1
- 若其他线程已经占有monitor的所有权,那么当前尝试获取monitor的所有权的线程会被阻塞,直
到monitor的进入数变为0,才能重新尝试获取monitor的所有权。
小结:
synchronized的锁对象会关联一个monitor,这个monitor对象不是我们创建的,是JVM的线程在执行到这个同步代码块时,如果发现没有monitor与锁对象关联,则会创建该对象(C++的对象),这个对象有两个成员变量:owner:记录拥有该monitor的线程,recursions:记录线程拥有锁的次数,当一个线程拥有monitor后,其他线程只能等待。
JVM规范对monitorexit的描述:
- 能执行monitorexit指令的线程一定是拥有当前对象的monitor的所有权的线程。
- 执行monitorexit时会将monitor的进入数减1。当monitor的进入数减为0时,当前线程退出
monitor,不再拥有monitor的所有权,此时其他被这个monitor阻塞的线程可以尝试去获取这个
monitor的所有权。
小结:
monitorexit插入在方法结束处和异常处,JVM保证每个monitorenter必须有对应的monitorexit。
6.2 synchronized方法:
同步方法在通过反汇编可以看到会增加ACC_SYNCHRONIZED修饰,会隐式调用monitorenetr和monitorexit。在执行同步方法前会调用monitorenter,在执行完同步方法后会调用monitorexit。本质还是monitor。
6.3 总结:
通过反汇编可以看到在synchronized同步代码是由monitorenter指令开始,monitorexit指令结束,每个锁对象会关联一个ObjectMonitor对象(它才是真正的锁对象),这个ObjectMonitor对象是由JVM创建的,里面有两个成员变量:owner:拥有该锁对象的线程,recursions:该线程获取锁的次数,当JVM执行到某个线程的某个方法内的monitorenter指令,jvm会判断当前锁对象是否有ObjectMonitor关联,没有就创建,并将owner设置为当前线程,recursions+1,该线程每重入一次,recursions就加一,当执行到monitorexit指令时,recursions-1,直到减为0,说明执行到monitorexit了,这个线程就会释放锁,其他被monitor对象阻塞的线程就可以竞争该锁。
ObjectMonitor对象中有enter和exit方法,当T1线程准备获取锁时,发现ObjectMonitor:owner拥有锁的线程不是自己时,就会调用enter把自己加入到用户态的一个等待队列中,并调用park(JVM)—> mutex_lock(库api)—> 调用内核的FUTEX_WAIT原语,将自己挂起。当其他线程释放锁时,根据用户态的等待队列获取队头等待线程,然后调用unpark(JVM)—>mutex_lock(库api)—> 调用内核FUTEX_WAKE原语,将其唤醒,进行争抢锁。
Linux:futex(FUTEX_WAIT/FUTEX_WAKE) 不通系统的阻塞和唤醒原语不同
7、synchronized的优化(jdk1.6及以后)
重量级锁通过对象内部的监视器(monitor)实现,其中monitor的本质是依赖于底层操作系统的futex(FUTEX_WAIT/FUTEX_WAKE)原语实现,线程挂起和唤醒需要涉及系统调用,需要用户态到内核态的切换,切换成本非常高。
主要是,当系统检查到锁是重量级锁之后,会把等待想要获得锁的线程进行阻塞,被阻塞的线程不会消耗cpu。但是阻塞或者唤醒一个线程时,都需要操作系统来帮忙,这就需要从用户态转换到内核态,而转换状态是需要消耗很多时间的,有可能比用户执行代码的时间还要长。所以说synchronized是一个重量级锁,重量级的操作。
针对synchorized的重量级操作及RetrantLock轻量级锁的出现,JDK在1.6版本对synchronized进行了优化,主要有三方面:
7.1 锁升级
无锁 —> 偏向锁 —> 轻量级锁 —> 自旋锁 —> 重量级锁
jdk1.6对synchronized进行优化,减低了直接升级为重量级锁而耗费大量的资源,降低线程获取锁的代价,synchronized的优化会涉及对象头。它是将锁保存在对象头中。
对象头组成:
- Mark World:存储对象运行时一些信息,如:hashCode()、分代年龄、锁标志位、偏向锁标志、ThreadID,它的大小和虚拟机的位长一致,如果虚拟机是32位的,它就是32位,虚拟机是64位,他就是64位。
- Klass pointer:类型指针,这个指针确定这个对象是哪个类的实例。大小和虚拟机的位长也是一致,虚拟机还会对其进行指针压缩,减少该指针占用的空间。
64位虚拟机Mark World的存储结构:
对象头= Mark Word + 类型指针(未开启指针压缩的情况下)
- 在32位系统中,Mark Word = 4 bytes,类型指针 = 4bytes,对象头 = 8 bytes = 64 bits;
- 在64位系统中,Mark Word = 8 bytes,类型指针 = 8bytes,对象头 = 16 bytes = 128bits;
实例数据:类中定义的成员变量
字节填充:不一定有,Hotspot虚拟机对象的大小是8字节整数倍,不是8字节整数倍会进行字节填充。
偏向锁延迟及匿名偏向锁:
虚拟机默认在4秒后开启偏向锁功能:这叫做偏向锁延迟
- 在4s内创建对象,此时对象的对象头的锁标志位:01,偏向标志位:0
- 在4s后创建对象,此时对象的对象头的锁标志位:01,偏向标志位:1 (
匿名偏向锁,没有存储线程id)
偏向锁在升级为轻量级锁时,会涉及到偏向锁撤销,需要等到一个安全点(STW),才可以做偏向锁撤销,在明知道有并发情况,就可以选择不开启偏向锁,或者是设置偏向锁延迟开启。
因为JVM在启动时,需要加载大量的.class文件到内存中,这个操作会涉及到synchronized的使用,为了避免出现偏向锁撤销操作,JVM启动初期,有一个延迟4s开启偏向锁的操作
偏向锁的加锁和撤销流程:
- ①创建锁对象(对象头的锁标志位:01,偏向标志位:1)
- ②当线程访问同步代码块时:
- ③如果是无锁状态或匿名偏向锁(锁标志位01,偏向标志位0 或 锁标志位01,偏向标志位1):(对象没有存储线程id)
- 设置锁标志位:01
- 偏向标志位:1
- 使用cas将当前线程id记录在对象头mark word的前54位中
- ④如果是匿名偏向锁:(对象有存储线程id)
- ⑤判断当前线程的线程id是否与锁对象对象头的线程id是否一致:
- ⑥一致:当前线程直接进入代码块执行
- ⑦执行过程中没有其他线程要执行同步代码块:
- 代码块执行完毕,结束,锁对象对象头还是偏向锁标志
- ⑧执行过程中存在其他线程要执行同步代码块:
- 回到⑤
- ⑦执行过程中没有其他线程要执行同步代码块:
- ⑨不一致:
- ⑩撤销偏向锁为无锁状态(需等待全局安全点)
- 如果目标偏向锁没有正在使用,则恢复成无锁状态
- ⑪撤销偏向锁升级为轻量级锁
- 如果偏向锁正在使用,则膨胀成轻量级锁,走轻量级锁加锁流程
- ⑩撤销偏向锁为无锁状态(需等待全局安全点)
- ⑥一致:当前线程直接进入代码块执行
- ⑤判断当前线程的线程id是否与锁对象对象头的线程id是否一致:
- ③如果是无锁状态或匿名偏向锁(锁标志位01,偏向标志位0 或 锁标志位01,偏向标志位1):(对象没有存储线程id)
轻量级锁的加锁流程:
- 虚拟机首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的Mark Word的拷贝
- 使用CAS操作尝试把对象的Mark Word更新为指向Lock Record的指针
- 更新成功:
- 当前线程加轻量级锁成功,锁对象的锁标志位变为“00”
- 更新失败:
- 说明至少存在一条线程与当前线程竞争获取该对象的锁
- 检查对象的Mark Word是否指向当前线程的栈帧
- 是:说明当前线程拥有这个对象的锁(可重入),直接进入同步块执行
- 否:说明这个锁对象已经被其他线程抢占,抢锁线程自旋,自旋一定次数后轻量级锁膨胀为重量级锁
- 检查对象的Mark Word是否指向当前线程的栈帧
- 说明至少存在一条线程与当前线程竞争获取该对象的锁
- 更新成功:
轻量级锁解锁流程:
- 如果对象的Mark Word仍然指向线程的锁记录,那就用CAS操作把对象当前的Mark Word和线程中复制的Displaced Mark Word替换回来
- 替换成功:说明同步过程就顺利完成,锁对象转为无锁状态
- 替换失败:说明有其他线程尝试过获取该锁,并且将锁升级为重量级锁,在释放锁的同时,唤醒被挂起的线程
总结:
当对象处于锁状态时,收到计算hashCode的请求:
- 无锁状态首次计算hashCode → 写入hashcode,不膨胀,但永久丧失偏向能力,无法进入偏向锁
- 已偏向状态计算hashCode → 必须先撤销偏向锁为无所,写入hashcode,有其他线程竞争则膨胀为轻量级锁,重量级锁
- 轻量级锁状态计算hashCode → 撤销轻量级锁为无锁,写入hashcode,有其他线程竞争则膨胀为重量级锁
- 重量级锁状态计算hashCode → 保持重量级锁不变(如果还未计算则计算,已计算则直接返回)
锁升级:无锁 —> 偏向锁 —> 轻量级锁(自旋锁) —> 重量级锁,锁升级是单向的,不能回退,只能偏向锁能退到无锁再进行升级,轻量级锁和重量级锁即使没线程执行,隔断时间来了,还是轻量级和重量级锁起步,除非锁对象被GC回收,不然永远不会回退。
- 无锁—>偏向锁:对象首次被 synchronized 进入,且偏向锁启用(-XX:+UseBiasedLocking,默认 true,JDK 15 已废除),当前无线程竞争,JVM 把线程 ID 写进 Mark Word。
- 偏向锁—>轻量级锁:第二个线程尝试获得同一把锁,CAS 替换线程 ID 失败;JVM 在全局安全点暂停原持有线程,撤销偏向到无锁,升级为轻量级锁(两个线程在自己线程栈上建立 Lock Record,看哪个线程cas将Mark Word指向自己的LockRecord,谁成功就谁获取到锁)
- 轻量级锁—>重量级锁:第二个线程没获取到锁则自旋尝试获取锁,当自旋次数到达一个阈值时或者有第三个线程加入竞争锁时,则由竞争线程把对象头得markword cas为指向Objectmointor(原线程持有)的指针,然后竞争线程尝试获取锁,获取失败则挂起。原线程执行的时候不知道锁已经升级为重量级锁了,当执行完后发现锁升级为重量级锁了,则执行mointorexit。
锁撤销:
- 偏向锁的重偏向:偏向锁格式不变,仅CAS把线程ID从A改成B,无需撤销为无锁,也无需全局暂停。这种操作对于多个线程交替获取锁时有较好的性能。
- 偏向锁—>无锁:
- 1.存在竞争且原持有线程仍存活并处于同步块内 → 直接升轻量级锁,偏向被撤销。
- 2.原持有线程已退出同步块或死亡 → 设置无锁状态,对象可被重新偏向给新线程。
- 3.收到计算hashCode请求。
- 轻量级锁—>无锁/偏向锁:
- 1.正常/异常释放。
- 2.收到计算hashCode请求。
- 重量级锁:
- 正重量级锁一旦膨胀,无法降级。monitorexit 释放锁,仅仅释放 monitor 所有权,MarkWord 仍然保持重量级锁标记,不会变回无锁 / 轻量级 / 偏向锁。
jdk15关闭了偏向锁,jdk18彻底移除偏向锁,主要原因:
- 现代 CPU 的 CAS 原子指令开销大幅下降
偏向锁诞生年代(JDK1.6),CAS 代价很高,所以做偏向锁优化,尽量避免 CAS。现在 CPU 硬件进步,CAS 已经很快,偏向锁带来的性能收益很小。 - 偏向锁撤销(revoke)代价极高,需要 STW 全局暂停
一旦发生锁竞争,触发偏向撤销就要 STW 暂停所有线程。
在线程池场景(大量线程交替复用锁),频繁触发撤销,反而性能变差,很多业务关掉偏向锁性能更高。 - JVM 同步子系统代码极其复杂,维护成本高
偏向锁增加了大量复杂的分支逻辑(重偏向、批量重偏向、批量撤销),给后续 JVM 锁优化、并发改造带来巨大阻碍,为了简化 HotSpot 同步代码,决定删掉这个优化。
JIT即时编译做了哪些优化?
7.2 锁消除:
锁消除是指虚拟机即时编译器(JIT)在运行时,对一些代码上要求同步,但是被检测到不可能存在共享数据竞争的锁进行消除。锁消除的主要判定依据来源于逃逸分析的数据支持,如果判断在一段代码中,堆上的所有数据都不会逃逸出去从而被其他线程访问到,那就可以把它们当做栈上数据对待,认为它们是线程私有的,同步加锁自然就无须进行。
publicclassDemo01{publicstaticvoidmain(String[]args){contactString("aa","bb","cc");}publicstaticStringcontactString(Strings1,Strings2,Strings3){returnnewStringBuffer().append(s1).append(s2).append(s3).toString();}}7.3 锁粗化:
原则上,我们在编写代码的时候,总是推荐将同步块的作用范围限制得尽量小,只在共享数据的实际作用域中才进行同步,这样是为了使得需要同步的操作数量尽可能变小,如果存在锁竞争,那等待锁的线程也能尽快拿到锁。大部分情况下,上面的原则都是正确的,但是如果一系列的连续操作都对同一个对象反复加锁和解锁,甚至加锁操作是出现在循环体中的,那即使没有线程竞争,频繁地进行互斥同步操作也会导致不必要的性能损耗。
publicclassDemo01{publicstaticvoidmain(String[]args){StringBuffersb=newStringBuffer();for(inti=0;i<100;i++){sb.append("aa");}System.out.println(sb.toString());}}8、平时代码中对synchronized进行优化的建议:
减少synchronized代码块的范围,减少同步代码块的执行时间,减少锁的竞争。
降低锁的粒度:
- ConcurrentHashMap
- LinkedBlockingQueue入队和出队使用不同的锁,相对于读写只有一个锁效率要高。
读写分离
读取时不加锁,写入和删除时加锁,如:ConcurrentHashMap,CopyOnWriteArrayList和CopyOnWriteSet
引用:
https://blog.csdn.net/v123411739/article/details/117401299?spm=1001.2014.3001.5501
https://blog.csdn.net/qq_40788718/article/details/106450724
https://www.bilibili.com/read/cv14772871/
https://juejin.cn/post/6844903640197513230
https://xiaohuang.blog.csdn.net/article/details/129848342