您现在的位置是:首页 >技术交流 >【JMM】并发编程Bug的源头——可见性/有序性/原子性问题网站首页技术交流
【JMM】并发编程Bug的源头——可见性/有序性/原子性问题
本文目录( ̄∇ ̄)/
附? volatile ? synchronized
可见性问题
可见性指的是一个线程对共享变量的修改对其他线程是可见的
可见性问题是指当多个线程并发访问共享变量时,一个线程对共享变量的修改对其他线程可能不可见的情况,而可见性问题是由于线程之间的独立性和本地缓存的存在而引起的
在现代计算机体系结构中,每个线程都有自己的本地缓存,这样可以提高读写操作的性能,当一个线程修改了共享变量的值时,它可能会将修改后的值暂存在自己的本地缓存中,而不是立即写回到主存中,其他线程在读取该共享变量时,可能会直接从自己的本地缓存中读取,而不是从主存中获取最新的值,这样就可能导致线程之间对共享变量的修改不可见。
可见性问题可能导致以下情况发生:
-
脏读(Dirty Read):一个线程读取到了另一个线程未提交的修改结果
-
重复读(Lost Update):多个线程同时读取同一个共享变量,并进行修改后写回,但由于可见性问题,某些线程的修改结果被覆盖,导致部分修改丢失
-
无效读(Stale Read):一个线程读取到了另一个线程已经修改的结果,但该结果已经过时
总所周知,volatile 关键字可以解决能够及时可见的问题,使得修改过的数据能立刻被看到,
其实现不同线程之间的可见性方法有很多,可以通过内存屏障,或者通过上下文切换,不管表面形式如何,其底层原理就是通过某些方式/语句触发内存缓存同步刷新,即需要满足两点
-
对线程本地变量的修改可以立刻刷新回主内存
-
同时使得其他线程中该变量的缓存失效
对有关 “volatile 关键字解决可见性的原理是什么?”“以及还有什么方式也可以解决线程间的可见性问题?”问题感兴趣的客官可以移步这篇
【JMM】保证线程间的可见性,还只知道volatile?_AQin1012的博客-CSDN博客
有序性问题
有序性问题是指在多线程环境下,多个线程可以同时执行,它们之间的操作和指令可能会交错执行,从而导致程序执行的顺序可能与期望的顺序不一致(即执行顺序的不确定性),进一步导致结果不正确或不符合预期
为什么会进行指令重排序/乱序执行?
为了提高效率
乱序存在的条件
- as-if-serial(看着像是序列化)
- 不影响单线程的最终一致性
this对象的溢出
不要构造方法中启动线程,可能会造成对象溢出
原子性问题
原子性指的是一个操作是不可分割的,要么全部执行成功,要么全部不执行
原子性问题是指在多线程环境下,一个或多个操作在执行过程中不被中断的特性。原子性问题出现的原因是多线程环境中,多个线程对共享资源进行读取、写入、修改等操作时,可能会发生竞态条件(Race Condition),导致部分操作被其他线程中断或覆盖,从而产生错误的结果。
原子性问题可能导致以下情况发生:
- 丢失更新(Lost Update):多个线程同时读取同一个共享变量,并进行修改后写回,但由于原子性问题,某些线程的修改结果被覆盖,导致部分修改丢失
- 覆盖写(Overwriting):多个线程同时写入同一个共享变量,其中一个线程的写入操作会覆盖其他线程已经写入的结果,导致部分数据丢失或错误
- ABA问题:在使用CAS(Compare and Swap)等原子操作时,一个线程读取到一个共享变量的值,然后其他线程对该变量进行了一系列修改,最后又回到了原始值,使得读取线程无法察觉到这个过程,导致出现不一致的结果
主存和工作内存交互的8大原子性操作(JVM级别)
-
数据安全
- lock(锁定)
- unlock(解锁)
-
数据交互
- read(读取)
- load(加载)
- use(使用)
- assign(赋值)
- store(存储)
- write(写入)
可以按下图捋捋流程
如何保证原子性?
-
上锁?(如synchronized关键字、Lock接口)来确保同一时间只有一个线程对共享资源进行操作,从而保证原子性
-
使用原子变量(如AtomicInteger、AtomicLong、AtomicReference等)进行操作,这些变量提供了一些原子操作方法,可以确保读取、写入、比较等操作的原子性(一些线程安全的集合对象内部也是使用了原子变量)
上锁的本质是使并发操作序列化,因此会降低效率,接下来我们主要介绍下synchronized关键字(synchronized保证了可见性,但不能保证有序性)
synchronized 原理简介
synchronized内置锁是一种对象锁,锁的是对象,而非引用,作用粒度是对象,可以用来实现对临界资源的同步互斥访问,并且可重入。
在 Java 早期版本中,synchronized属于重量级锁,效率低下,因为内部对象Monitor(监视器锁)是依赖于底层的操作系统的 Mutex Lock
实现的,Java 的线程是映射到操作系统的原生线程之上的。如果要挂起或者唤醒一个线程,都需要切换到操作系统的内核态来完成,这个状态切换导致早期的 synchronized 效率较低。
JDK1.6 JVM 层面对synchronized 实现了较大优化,对锁的实现引入如自旋锁、偏向锁、轻量级锁等技术来减少锁操作的开销
加锁的方式
- 同步实例方法 -> 锁的当前实例对象(this)
- 同步类方法 -> 锁的当前类对象
- 同步代码块 -> 锁的是括号里面的对象
那么问题来了,JVM是如何知道当前对象有没有加锁呢??
这就需要提一下JVM中对象的内存布局了
对象的内存布局
Java的锁是加在对象上的,来咱们用图说话( ̄∇ ̄)/
锁状态有:无锁态、轻量级锁、偏向锁、重量级锁,考虑到空间利用效率,Mark Word的部分会根据锁状态的不同而有所区别(详见下图)
锁状态 | 是否禁用偏向 | 锁标志位 | |||
无锁态(new) | 对象的HashCode | age(分代年龄) | 0 | 01 | |
偏向锁 | Epoch | ThreadID (当前获取偏向锁的线程ID) | age (分代年龄) | 1 | 01 |
轻量级锁 自旋锁 | 指针(指向栈中锁记录LockRecord) | 00 | |||
重量级锁 | 指针(指向重量级锁(互斥量)) | 10 | |||
GC标记 | 空 | 11 |
可以看到,在对象的header的Mark Word中(synchronized优化的过程和Mark Word息息相关)记录了锁的状态,从无锁态到偏向锁、再到轻量级锁、最后重量级锁,这其实就是JVM优化了synchronized后的锁升级过程,接下来我们详细的介绍下这个过程。
锁升级过程
先来看一张图
在JVM刚启动时,会有一个几秒的延迟(默认4秒,可以使用-XX:BiasedLockingStartupDelay=0
来取消延时),在这几秒钟为无锁态,随后会进入到匿名偏向的状态中,此时当有一个线程来加锁,匿名偏向就会升级成偏向锁,此时如果再来其他线程就会升级成轻量级锁,当达到特定的线程自旋次数,或者自旋线程数即“竞争激烈”的就会升级成重量级锁。
锁粗化
多个锁结构优化成一个锁机构(效果一样)
锁消除
是虚拟机的一种锁的优化,虚拟机在JIT编译时(简单理解就是当某段代码第一次进行编译,即及时编译),通过对运行上下文的扫描,去除不可能出现资源竞争的锁。通过这种方式消除没有必要的锁,可以节省时间。锁消除的依据是逃逸分析的数据支持。
逃逸分析(JDK1.7起默认开启)
Java中的逃逸分析是指编译器分析对象的作用域,确定它是否可以在方法调用之后被其他代码引用。
如果对象没有逃逸,则可以将其存储在栈上而不是堆上,这可以提高程序的性能。逃逸分析可以在运行时对程序进行优化,因为它可以帮助编译器更好地了解代码的执行情况并进行针对性的优化。通过逃逸分析,编译器可以推断出哪些对象可以安全地存储在栈上,以便在程序运行时减少堆内存的使用,从而提高程序的性能和效率。
使用-XX:+DoEscapeAnalysis
开启逃逸分析,编译器可以对代码作如下优化
-
栈上分配:对于一些局部对象,编译器可以在栈上分配空间,避免了在堆上进行内存分配和回收的开销
- 因此并非所有的对象都会在堆内存分配空间
- 同步省略:如果一个对象被发现只能从一个线程被访问到,那么这个对象的操作可以考虑不同步
-
分离对象或标量替换
- 直接把对象中的标量创建在栈中,可以有效减少GC
Java中的8大基本数据类型称为标量;对象称为聚合量(由基本数据类型组成)
锁重入
- 偏向锁的锁记录在线程栈中,每重入一次,压入栈中一个LockRecord对象,解锁的时候一次向外弹,LockRecord对象弹完,锁解开;
- 轻量级锁与偏向锁类似;
- 重量级锁会记录在ObjectMonitor的一个字段上
其他相关问题
JVM启动时为什么不直接设置偏向锁,而是进行了延时操作?
因为JVM 虚拟机有很多自己默认启动的线程,里面有很多sync代码,启动时这些代码就会进行竞争,直接使用偏向锁就会不断的进行锁撤销和锁升级(线程竞争会导致撤销偏向锁,升级轻量级锁),影响效率。
线程如何获取到锁?
线程在自己的线程栈中生成LockRecord,用CAS操作将MarkWord中的特定位置设置为指向自己这个线程LockRecord的指针,设置成功者就获取到了锁?
偏向锁什么时候升级为轻量级锁 ?
- 当偏向锁启动时,只要有一个对象来抢,偏向锁就会升级成轻量级锁
- 当偏向锁为启动时,会直接升级成轻量级锁
-
调用对象的hashCode也会导致偏向锁升级成轻量级锁
- 轻量级锁的hashCode记录在线程栈中的LockRecord的MarkWord中
- 重量级锁的hashCode记录在Monitor中
- Monitor可以理解为一个同步工具/同步机制(由ObjectMonitor实现的),通常被描述为一个对象,与一切皆对象一样,所有的Java对象是天生的Monitor,即每一个对象都有成为Monitor的潜质(这也是Java v中任何对象都可以作为锁的原因)。在Java的设计中,每个对象都天生带了一把看不见的锁,称为内部锁或者Monitor锁,也就是常说的synchronized的对象锁。当MarkWord中锁标识位为10的对象,其指针指向的是Monitor对象的起始地址。
- Monitor监视器可以确保监视器上的数据在同一时刻只有一个线程在访问
值得注意的是:偏向锁调用wait()
后会直接升级成重量级锁
自旋锁什么时候升级为重量级锁 ?
根据特定的线程自旋次数,或者自旋线程数来作为“竞争激烈与否”的标准
JDK1.6以后采用自适应自旋(Adaptive Self Spinning),由JVM自己控制(无需人工调整)
升级重量级锁需要向操作系统申请资源,用户态切换至内核态(80中断 int 0x80
),线程挂起,进入等待队列,等待操作系统的调度,然后再映射回用户空间。
为什么有自旋锁了还需要重量级锁?
自旋是占用CPU资源的,如果锁的时间长或者线程多会非常消耗CPU资源,而将未获取到锁的线程放入重量级锁中的队列中则不会消耗CPU资源
如何解决CAS的ABA问题?
ABA问题是指其他线程修改数次后最后的值与原值相同,使得我们无法仅通过对变量值的比较来判断该变量是否被修改过
对于这种ABA问题,可以通过加版本号进行解决(只要修改版本号就会改变)
如何保证CAS的原子性?
CAS相关的方法都使用的是Atomic类中的变量,Atomic类的底层是通过调用Unsafe类的方法来实现的,而Unsafe中的方法有调用了C++编写的本地方法,到汇编层面,会发现汇编指令中支持一条原语:lock cmpxchg
,即可以理解为CAS修改变量名的最终实现是cmpxchg
指令,但是cmpxchg
指令并不是原子的(可以通过查?汇编指令的操作手册来确定某条指令是否是原子的),所以前面要加lock
,即在执行cmpxchg
时,锁住总线、或者该数据所在的缓存行(lock
是总线锁还是缓存锁视情况而定),等执行完了,别的线程才能访问
所以,底层还是加锁,被锁住的部分称为“临界区(critical section)”,临界区执行时间长,语句多,称为锁的粒度比较粗,反之,比较细。
附? volatile ? synchronized
由上面的情景分析我们可以分别概括下volatile和synchronized的主要特性
-
volatile可以保证可见性和有序性(禁止指令重排序),但是不能保证原子性;volatile在修饰引用类型的数据只能保证引用本身的可见性,而不能保证其内部字段的可见性
-
volatile底层是通过lock前缀指令+缓存一致性协议实现的可见性;通过内存屏障(?️止指令重排序)实现的有序性
-
synchronized可以保证可见性和原子性,但是不能保证有序性
-
synchronized底层是lock语句
-
synchronized内部会根据实际情况进行自适应升级的锁升级
-
synchronized是可重入锁,可重入锁的重入次数必须记录,因为要解锁几次必须要对应