笔记:Java并发编程的艺术

本文深入探讨Java并发编程中的关键概念,包括Volatile的Lock前缀指令、缓存一致性协议、内存语义,以及Synchronized的锁升级过程和CAS操作。讲解了Volatile如何保证可见性和有序性,以及锁的优化策略,如偏向锁、轻量级锁到重量级锁的转换。同时,介绍了CAS操作在并发中的作用及其与AQS的关系。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

笔记:Java并发编程的艺术

本篇文章不打算详细描述并发基础,只是记录学习过程中的关键点。(会持续追加)

lock前缀指令

    有volatile变量修饰的共享变量进行写操作的时候会多出第二行汇编代码,
通过查IA-32架 构软件开发者手册可知,Lock前缀的指令在多核处理器下会引发了两件事情。 
	1)将当前处理器缓存行的数据写回到系统内存。 
	2)这个写回内存的操作会使在其他CPU里缓存了该内存地址的数据无效。 为了提高处理速度,
	处理器不直接和内存进行通信,而是先将系统内存的数据读到内部
	
    缓存(L1,L2或其他)后再进行操作,但操作完不知道何时会写到内存。
如果对声明了volatile的 变量进行写操作,JVM就会向处理器发送一条Lock前缀的指令,
将这个变量所在缓存行的数据 写回到系统内存。但是,就算写回到内存,
如果其他处理器缓存的值还是旧的,再执行计算操 作就会有问题。所以,在多处理器下,
为了保证各个处理器的缓存是一致的,就会实现缓存一致性协议,
每个处理器通过嗅探在总线上传播的数据来检查自己缓存的值是不是过期了,
当 处理器发现自己缓存行对应的内存地址被修改,就会将当前处理器的缓存
行设置成无效状 态,当处理器对这个数据进行修改操作的时候,会重新从系统内存
中把数据读到处理器缓存里。

下面来具体讲解volatile的两条实现原则。
1)Lock前缀指令会引起处理器缓存回写到内存。Lock前缀指令导致在执行指令期间,声言处理器的LOCK#信号。在多处理器环境中,LOCK#信号确保在声言该信号期间,处理器可以 独占任何共享内存[2]。但是,在最近的处理器里,LOCK#信号一般不锁总线,而是锁缓存,毕 竟锁总线开销的比较大。在8.1.4节有详细说明锁定操作对处理器缓存的影响,对于Intel486和 Pentium处理器,在锁操作时,总是在总线上声言LOCK#信号。但在P6和目前的处理器中,如果 访问的内存区域已经缓存在处理器内部,则不会声言LOCK#信号。相反,它会锁定这块内存区 域的缓存并回写到内存,并使用缓存一致性机制来确保修改的原子性,此操作被称为“缓存锁 定”,缓存一致性机制会阻止同时修改由两个以上处理器缓存的内存区域数据。
2)一个处理器的缓存回写到内存会导致其他处理器的缓存无效。IA-32处理器和Intel 64处 理器使用MESI(修改、独占、共享、无效)控制协议去维护内部缓存和其他处理器缓存的一致性。在多核处理器系统中进行操作的时候,IA-32和Intel 64处理器能嗅探其他处理器访问系统 内存和它们的内部缓存。处理器使用嗅探技术保证它的内部缓存、系统内存和其他处理器的 缓存的数据在总线上保持一致。例如,在Pentium和P6 family处理器中,如果通过嗅探一个处理器来检测其他处理器打算写内存地址,而这个地址当前处于共享状态,那么正在嗅探的处理 器将使它的缓存行无效,在下次访问相同内存地址时,强制执行缓存行填充。

Intel的手册对lock前缀的说明如下

  1. 确保对内存的读-改-写操作原子执行。在Pentium及其之前的处理器中,带有lock前缀的指令在执行期间会锁住总线,使得其他处理器暂时无法通过总线访问内存。很显然,这会带来昂贵的开销。从Pentium4等处理器开始,Intel使用缓存锁定来保证指令执行的原子性。缓存锁定将大大降低lock前缀指令的开销。
  2. 禁止该指令与之前和之后的读和写指令重排序。
  3. 把写缓冲区中的所有数据刷新到内存中。

上面的第2点和第3点所具有的内存屏障效果,足以同时实现Volatile读和Volatile写的内存语义。

锁、Volatile、CAS内存语义

  1. 当线程释放锁时,JMM会把该线程对应的本地内存中的共享变量刷新到主内存中。
  2. 当线程获取锁时,JMM会把该线程对应的本地内存置为无效。从而使得被监视器保护的临界区代码必须从主内存中读取共享变量。
  3. 锁释放和Volatle写有相同的内存语义;锁获取与volatile读有相同的内存语义。volatile也是通过lock前缀指令实现的
  4. 一个CAS操作同时具有volatile的读和volatile写内存语义。
    - 为什么这么说呢?因为CAS底层通过CPU指令cmpxchg指令实现,在c/c++代码上,如果为多核CPU则会给这个指令前面加上一条Lock前缀的指令。

Volatile原理

并发编程三大特性:原子、有序、可见。Volatile众所周知的特性为:

  1. 可见性:通过语义来间接使用MESI(modify exclusive shared invalid)即缓存一致性协议,如果写,则写入主内存,并另其他cpu对该变量的缓存失效;如果读,则从主内存中读。
  2. 有序性:实现方式为内存屏障,可以禁止指令重排。

且Volatile变量的非复合操作也是原子的,即读写操作是原子的(个人感觉好像只有读写操作才是非复合操作),而i++,new Singlton()都是复合操作。

Synchronized原理

  1. 锁定对象的时候,会在编译后生成monitorenter和monitorexit,往往一个monitorenter对应多个monitorexit,在退出方法或者异常抛出的时候释放锁,来避免死锁。而Synchronized方法的实现是通过方法的标志位ACC_AYNCHRONIZED来标识
  2. 比较值得关注的是重量级锁的实现原理:
monitor是一种syncronized的构造,其允许线程相互之间排斥和协作;
排斥同竞争锁,协作如生产和消费模式(wait,notify模式)。当一个线程进入monitor是有三个时期(如下图);

1. 进入monitor,此时是被分配在entry set 中,等待lock的拥有者释放锁;

2. 当线程获得lock就会成为lock的拥有者(仅有一个线程可以拥有),此时处于 the owner 区域;

3. 当释放lock就会进入wait set ,但是处于wait set 中的线程还是有机会获得lock的拥有权。

在这里插入图片描述

  1. Synchronized经典锁升级过程(懂的都懂 八股文 这里只祭出一张图就完事了)
    在这里插入图片描述

最详细的锁膨胀过程

  1. 线程在执行同步块之前,JVM会先在当前线程的栈桢中创建用于存储锁记录的空间,并将对象头中的Mark Word复制到锁记录中,官方称为Displaced Mark Word。

偏向锁

  1. 如果有线程A来,先检查Mark Word中的锁状态为01无锁且偏向锁为0,所以线程A将其设置为偏向锁的Mark Word,并把锁记录也存储自己的线程ID。 继续运行自己的逻辑
    在这里插入图片描述
  2. 如果线程B此时到来,发现是偏向锁且线程ID不是自己,就使用CAS想把偏向锁线程ID设置成自己,如果成功说明线程A已经出了同步代码块,如果失败说明线程A仍在运行,就需要进入偏向锁撤销阶段
  3. 此时各保持各的状态,等到全局安全点,先暂停线程A,检查A是否活着,如果A挂了,则线程B直接拥有锁,如果A或者,就会遍历A的锁记录,如果退出了同步代码块锁的对象的锁记录应该就没了,于是B获取锁,如果A还在同步代码块中,则标记锁住的对象不适合是使用偏向锁,应该膨胀了。
  4. 然后线程A、B都尝试使用CAS将对象头中的Mark Word替换为指向锁记录的指针。如果成功,当前线程获得锁,如果失败,表示其他线程竞争锁,当前线程便尝试使用自旋来获取锁。
  5. 如果有线程自旋失败,所就会膨胀,会标记锁为重量级锁,然后失败者进入sleep,

CAS原理

在这里插入图片描述

AQS与OS信号量

AQS使用一个整型的volatile变量(命名state)来维护同步状态。说到这里,觉不觉得这个定义和操作系统中的信号量非常类似,如果能保证PV操作(UP和DOWN操作)的原子性和可见性和有序性,信号量就实现了。然而在操作系统层面,PV操作的原子性是通过CPU指令来实现的,但是在Java中,原子性通过CAS实现,而可见性和有序性通过volatile来实现,尽管CAS和Volatle底层还是通过CPU指令实现。但是有了AQS,能实现的同步骚操作的范围更大,可以通过模板方法模式实现各种同步功能,ReentrantLock、CountDownLatch等。

评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值