查看原文
其他

文末福利|Java并发编程-Volatile和Syncronize关键字

培优运营研发团队 好未来技术 2023-03-15

前言:

Java并发编程学习分享的目标

  • Java并发编程中常用的工具和类用途与用法

  • Java并发编程工具的实现原理与设计思路

  • 并发编程中遇到的常见问题与解决方案

  • 如何根据实际情景选择更合适的工具完成高效的设计方案

学习分享团队
学而思培优-运营研发团队
Java并发编程分享小组:

@沈健 @曹伟伟 @张俊勇 @田新文 @张晨
本章分享人:@沈健

本章课程简介:

  • Volatile关键字

  • Synchronized关键字

01

Volatile篇

目标:

搞清楚Java并发工具, JMM, MESI以及硬件之间的关系

Volatile的用法

修饰变量

private volatile long value;

Volatile的作用

  • 用于保证变量在全局可见

  • 防止指令重排导致的问题

可能有的同学就要问了,为什么要保证变量全局可见性,不保证全局可见性会出什么问题呢?

接下来我用一个例子解释:

下面是一个简单的田径比赛的场景,主线程是发令员,10个子线程是10个运动员,发令员发出指令之后比赛才开始,哪位同学预测一下下面代码执行完之后会发生什么结果。

package com.company; public class Main { boolean start = false; public static void main(String[] args) { new Main().test(); } public void test() { Thread[] threads = new Thread[10]; for (int i = 0; i < 10; i++) { threads[i] = new Thread(() -> { int wait = 0; while (!start) { wait++; } System.out.println(Thread.currentThread().getName() + "run after second: " + wait); }); threads[i].start(); } start = true; try { Thread.sleep(20000); } catch (InterruptedException e) { e.printStackTrace(); } }}

我本地运行的结果如下:

可以看到只有线程9感知到了指令,触发了响应的动作。

这是为什么呢?原因是由于各层缓存的存在, 相当于Java每个线程会有自己的一份内存数据,因此主线程对标志位的修改对于子线程来说并不总是可见的。这种情况就可以用volatile关键字优化了,添加了volatile关键字之后,执行结果如下:

Java内存模型的细节,我们后面再详细讲述

好的,解释完可见性了,可能又有的同学(说这个同学是不是你)就要问了,为什么要注意防止指令重排问题呢,不保证会出什么问题呢,接下来我用一个例子解释:

package com.company; public class RearrangeTest { public static void main(String[] args) { for (int i = 0; i < 10000; i++) { new RearrangeTest().test(); } } public void test() { ReorderExample example = new ReorderExample(); Thread write = new Thread(() -> { example.writer(); }); Thread read = new Thread(() -> { example.reader(); }); write.start(); read.start(); } class ReorderExample { int a = 0; boolean flag = false; public void writer() { a = 1; // 1 flag = true; // 2 } public void reader() { if (flag) { // 3 int i = a * a; // 4 if (i != 1) { System.out.println(i); } } } }}

在这个例子中,由于指令重排的问题,执行过后的值是不确定的。那么可见性问题是怎么来的呢,接下来我在JMM模型与MESI中介绍。

JMM模型与MESI

上一小节中,我们介绍了指令重排与变量可见性两个问题,这一小节,我们来剖析一下导致这两个问题的原因。

首先是第一个问题,为什么会有可见性的问题。这个就要谈到内存模型了。JMM是Java内存模型的简称,主要目的是用于控制了一个线程的写入何时应该对于另一个线程可见。在JMM模型中,各线程之间共享的变量会放在主内存中,每个线程会拷贝一份内存到线程私有内存,线程中对共享变量的修改只会在私有内存中生效,并不会对其他线程可见。这就是可见性问题的来源,如下图所示:

同时JMM模型中提供了多种机制,来控制变量在多个线程间的可见性,这个问题我们在volatile原理小节详细介绍。

下面我们还是介绍一下JMM这套系统是怎么实现的,JMM真的会拷贝一份内存到本地吗,如果我们启动几十个线程,岂不是要拷贝几十分。这样做未免也太浪费内存了。其实JMM只是一个虚拟的概念,并不是真实存在的。JMM抽象的唯一目的是给开发者提供一个保证,在什么情况下,变量的修改一定会对其他线程可见。这套机制是通过处理器缓存,写缓存,寄存器,编译器以及其他硬件配合完成的。相当于在MESI模型的基础上进行了一次封装。

接下来我们再往下研究一层,看看最底层的MESI协议

缓存一致性协议给缓存行(通常为64字节)定义了个状态:独占(exclusive)/共享(share)/修改(modified)/失效(invalid),用来描述该缓存行是否被多处理器共享、是否修改。所以缓存一致性协议也称MESI协议。

  • 独占(exclusive):仅当前处理器拥有该缓存行,并且没有修改过,是最新的值。

  • 共享(share):有多个处理器拥有该缓存行,每个处理器都没有修改过缓存,是最新的值。

  • 修改(modified):仅当前处理器拥有该缓存行,并且缓存行被修改过了,一定时间内会写回主存,会写成功状态会变为S。

  • 失效(invalid):缓存行被其他处理器修改过,该值不是最新的值,需要读取主存上最新的值。

协议协作如下:

  • 一个处于M状态的缓存行,必须时刻监听所有试图读取该缓存行对应的主存地址的操作,如果监听到,则必须在此操作执行前把其缓存行中的数据写回CPU。

  • 一个处于S状态的缓存行,必须时刻监听使该缓存行无效或者独享该缓存行的请求,如果监听到,则必须把其缓存行状态设置为I。

  • 一个处于E状态的缓存行,必须时刻监听其他试图读取该缓存行对应的主存地址的操作,如果监听到,则必须把其缓存行状态设置为S。

  • 当CPU需要读取数据时,如果CPU缓存中没有缓存, 则会从主缓存中读取,并置为E状态,如果其缓存行的状态是I的,则需要从内存中读取,并把自己状态变成S,如果不是I,则可以直接读取缓存中的值,但在此之前,必须要等待其他CPU的监听结果,如其他CPU也有该数据的缓存且状态是M,则需要等待其把缓存更新到内存之后,再读取。

  • 当CPU需要写数据时,只有在其缓存行是M或者E的时候才能执行,否则需要发出特殊的RFO指令(Read Or Ownership,这是一种总线事务),通知其他CPU置缓存无效(I),这种情况下会性能开销是相对较大的。在写入完成后,修改其缓存状态为M。

下面通过一张图来说明:

上面就是MESI的内容了,总的来说Java并发依赖于JMM,JMM依赖于MESI,MESI依赖于硬件。

假设一个场景,如果JMM某语义需要某变量在多线程间可见,则Java编译器会在生成的代码中插入一条操作MESI CPU缓存的指令,借用MESI来实现其语义。
对于这个过程,我在下面volatile原理小节介绍。

Volatile的原理

public class VolatileCompileTest { volatile int v1 = 0; int a = 0; public void write() { a = v1; v1 = v1 + 1; } public int read() { v1 = 0; return a; } public static void main(String[] args) { VolatileCompileTest ins = new VolatileCompileTest(); for (int i = 0; i < 1000 * 1000; i++) { ins.write(); } System.out.println(ins.v1); }}

如上图所示代码中,当操作了一个加了volatile关键字的时候会发生什么呢?我们可以将代码的汇编结果输出来看一下:

下面是不加volatile关键字的汇编代码:

rsi地址的变量即是this的地址,0xc(%rsi) 代表的就是变量v1,可以看到在加了volatile关键字之后,v1计算完之后,添加了一条lock addl指令。

这条指令的作用就是控制CPU将当前处理器缓存行写回系统内存,同时会将缓存标志标记为M,这时候其他处理器的缓存会嗅探到这一状态而将自己缓存对应行标记为I,下次读取就会重新从主存读取了。

以上即是MESI层的实现,接下来我们说一下JMM在MESI上的封装
对于可见性问题,JMM的解决方式就是通过编译代码中插入lock指令解决

对于重排序问题,JMM抽象的对Javaer提供了两个承诺:happens-beforeas-if-serial规则

happens-before规则
如果一个操作A happens-before 另一个操作B,则A在B之前执行,并且执行结果对B可见。如果两个操作存在happens-before关系这并不意味着程序一定会按照指定的关系来执行,只要执行过后的结果一直,JMM是允许这种排序的。
有哪些操作可以控制happens-before规则呢?JSR-133中定义的Java内存模型中定义了如下happens-before规则

as-if-serial规则
编译器编译完代码之后以及jit编译之后所产生的代码单线程执行时与编程者变写的代码之间的原始指令可以不同,但是执行结果必须一致。

为了实现上面的约定,Java编译器会在字节码中插入如下屏障:

  • 在每个volatile写操作的前面插入一个StoreStore屏障,防止前面的写操作与volatile写重排序,防止另一个线程里对两个变量以相反的顺序写

  • 在每个volatile写操作的后面插入一个StoreLoad屏障,防止后面的读和volatile写操作重排序

  • 在每个volatile读操作的后面插入一个LoadLoad屏障,防止volatile后面的读和volatile读重排序

  • 在每个volatile读操作的后面插入一个LoadStore屏障,防止volatile后面的写和volatile的读重排序
    当然这些屏障也是虚拟的存在,只是为了知道编译器在生成代码时怎么处理指令重排序问题,以保证在任何平台上任意程序中都能得到正确的volatile语义

下面以一个例子说明:

学习Volatile设计理念, 我总结了三条原则
用最小的代价完成任务

有了锁,我们为什么还需要volatile呢,这是因为volatile不需要加锁,在某些情况下volatile会取得比锁更高的效率。在我们设计方案的过程中,每个问题都会有很多可行方案,很多时候我们需要在这些可行方案中选择代价最小的一个。这个能力使我们成为开发专家的路上很重要的一个能力,需要我们不断的学习总结。

public class VisibilityTest1 { boolean start = false; public synchronized boolean isStart() { return start; } public synchronized void setStart(boolean start) { this.start = start; } public static void main(String[] args) { new VisibilityTest1().test(); } public void test() { Thread[] threads = new Thread[100]; for (int i = 0; i < threads.length; i++) { threads[i] = new Thread(() -> { int wait = 0; while (!isStart()) { wait++; } System.out.println(Thread.currentThread().getName() + " run after second: " + wait); }); threads[i].start(); } setStart(true); try { Thread.sleep(20000); } catch (InterruptedException e) { e.printStackTrace(); } }}

了解底层原理才能写出极致的性能

并发大师Doug Lea在JDK1.7开发LinkedTransfe rQueue时,在使用volatile变量时,为了提高效率,在类中追加了很多无用的对象,代码如下:

Doug Lea为什么这么做呢,原因是当时主要的处理器缓存行都是64个字节,将节点追加到64个字节,每个节点就恰好占据了一个缓存行,防止了同时读写某个缓存行时会出现的高速缓存锁定现象降低效率。

当然,对于我们大部分编程过程中我们是不需要去追求这么极端的。但是,必须认识到,业内流行的软件,很多都是追求极限的性能才成为行业内公认的技术选型的,如kafka的零拷贝技术,clickHouse的向量运算等等。

02

Synchronized篇

提到Synchronized关键字,想必大家都不陌生。可以说是Java编程领域老工具人了,从Java语言发布一直服务到现在,而且还在不断进化中。

章节目标:

了解如何根据实际情况选择合适的锁,如果和设计一个自适应的锁

为什么需要Synchronized

volatile可以解决可见性与指令重排,但是却无法解决程序运行过程中的原子性问题,举个例子说:

private volatile long value; value = 1000; // 线程安全的 value++; // 非线程安全

因为对于value++,转换为字节码以后,可以看到, 程序其实做了多个操作, 而这些操作的执行过程中是有可能发生线程切换, 导致执行结果不准确的。

Synchronized的用法

  • 修饰方法

  • 修饰代码块

public synchronized void test1() { start++;} public void test2() { synchronized (this) { start++; }}

Synchronized原理

在讨论Synchronized关键资源里之前,我们先来观测一下,添加了Synchronized关键字之后,会有什么现象:

public synchronized void test1(); descriptor: ()V flags: (0x0021) ACC_PUBLIC, ACC_SYNCHRONIZED Code: stack=5, locals=1, args_size=1 0: aload_0 1: dup 2: getfield #2 // Field start:J 5: lconst_1 6: ladd 7: putfield #2 // Field start:J 10: return LineNumberTable: line 6: 0 line 7: 10 LocalVariableTable: Start Length Slot Name Signature 0 11 0 this Lcom/company/SyncTest1; public void test2(); descriptor: ()V flags: (0x0001) ACC_PUBLIC Code: stack=5, locals=3, args_size=1 0: aload_0 1: dup 2: astore_1 3: monitorenter 4: aload_0 5: dup 6: getfield #2 // Field start:J 9: lconst_1 10: ladd 11: putfield #2 // Field start:J 14: aload_1 15: monitorexit 16: goto 24 19: astore_2 20: aload_1 21: monitorexit 22: aload_2 23: athrow 24: return Exception table: from to target type 4 16 19 any 19 22 19 any

我们可以看到,对于直接在方法中加Synchronize d关键字的方法,编译成字节码之后会有一个ACC_SYNCHRONIZED的flag,对于在代码块中加Synchronized关键字的方法,编译出来的字节码中,加入了monitorenter和monitorexit指令。

那么这两种方式有什么不同呢,其实这两种方法效果是一样的,区别是在方法上添加Synchronized关键字时,是由JVM帮我们调用的monitorenter和monitorexit。

再观察我们的monitorenter和monitorexit指令,可以看到这两个操作是消费一个参数,产生0个参数,

  • 修饰方法,参数为方法对象

  • 修饰静态方法,参数为类对象

  • 修饰代码块,参数为显示指定的对象

也就是说,monitor是和对象关联的,一个对象只会有一个monitor,也就是说下面的两个例子里,write和read方法是互斥的。

public class SynchronizedExample1 { int a = 0; public synchronized void write(int i) { a = i; } public synchronized int read() { return a; }}public class SynchronizedExample2 { int a = 0; public void write(int i) { synchronized (Integer.valueOf(1)) { a = i; } } public synchronized int read() { synchronized (Integer.valueOf(1)) { return a; } }}

每个Java对象都有唯一的一个monitor相关联,monitor对象结构如下图所示:

_owner指的是正在获取锁的线程,这里是用cas来做的
_waitSet 指的是调用了Object.wait之后处于等待状态的线程集合
_EntryList指的是等待状态的线程集合

接下来我给大家解释一下,调用monitorenter或monitorexit时为什么要带一个对象参数呢?因为Synchronized锁状态是存储在对象头中的。

下面我给大家说一下java对象头的组成,以64位机为例。

如图所示,是一个SynchronizedExample1对象在堆中所占内存,内存中前8个字节,锁数据就是存放在MarkWork里。MarkWord的详细结构如下:

可以看到,在不同锁情况下,markwork中的内容是不一样的。但是各种等级的锁下,都会有两个bit的lock区域,这两个bit是用来标记锁的种类的。也就是说,当monitor检查了63-64bit之后,就可以知道目前该对象上加的是什么锁了,确定锁的种类,就可以确定markwork中数据的分布,进行对应的处理了。例如,如果经确定目前锁的状态是偏向锁,则就可以读取1~54bit的数据,找到当前获取偏向锁的线程id,来确定是直接放行还是升级成轻量级锁。

上表就是锁种类和标志位对应关系了。可以看到未锁定和偏向锁是一样的都是01,怎么区分两个锁呢,靠的就是上上个图里的biase_lock标志位。

下面这一小节,我们来讲一下Synchronized锁升级的过程。

Synchronized升级过程

学习Synchronized设计理念, 我总结了三条原则
如果可以不使用锁,那就不要使用锁。
为什么Synchronized首先加的是偏向锁呢,因为偏向锁基本相当于无锁,假如临界区代码运行在单线程的情况下,使用偏向锁就可以实现近乎不加锁的性能。

大家看看下面的例子里程序有什么问题,有什么优化方法:

public class SynchronizedExample3 { static int a = 0; private static Object lock = new Object(); public static void main(String[] args) { Thread[] threads = new Thread[10]; for (Thread t : threads) { t = new Thread(() -> { synchronized (lock) { a += new Random().nextInt(10); } }); t.start(); } for (Thread t : threads) { try { t.join(); } catch (InterruptedException e) { e.printStackTrace(); } } System.out.println("result is: " + a); }}

低并发使用乐观锁, 高并发使用悲观锁
在研究Synchronized锁升级的时候,我们可以看到,Synchronized使用的是多种锁结合使用的方式来达到不同应用场景下都能达到较高的效率。当临界区数据被多个线程同时访问的时候,Synchronized首先做的是将锁升级为轻量级锁。如果临界区数据竞争不是很激烈,通过有限次cas就可以获取锁,那么就可以免除加互斥锁的开销,因为互斥锁的开销往往是很大的。但是如果临界区竞争特别激烈,多次获取乐观锁仍不能获取的情况下,乐观锁的开销就很大了,因为线程要一直空自旋,占用了大量CPU资源。Synchronized的做法是将锁升级成重量级锁,也就是悲观锁。这给我们带来的启示是,不同锁适应于不同的运行条件,竞争不激烈,很快就可以获取锁的情况下,用乐观锁会好一些,竞争比较激烈,要长时间才能获取锁的场合下,用悲观锁合适一些。

通用工具为了追求通用性往往会做很多自适应工作,如果我们要追求极致性需要在通用组件的基础上针对实际应用情况做针对性优化
从Synchronized的升级过程我们可以看到,作为一个通用组件,Synchronized为了在不同应用场景下都能获得较好的性能,做了很多额外的工作。但是如果我们明确知道我们的应用场景,那么这些额外的工作就不仅不会提高性能反而会消耗性能了,例如如果我们是一个并发比较激烈的场景,或者大任务的场景,则轻量级锁cas的过程就应该避免了,应该直接使用重量级的锁。我们可以在jvm启动的时候通过参数关闭轻量级锁的环节。

03

课后练习

用CAS实现一个和Synchronized类似的锁

目标:

  • 在单线程运行时,效率与无锁类似

  • 在弱并发的情况下,效率优于直接加互斥锁

  • 在强并发的情况下,效率优于乐观锁


参考资料 & 扩展阅读:《Java并发编程的艺术》

关注公众号后私信「Java并发」可获取电子书下载链接


福利来袭


🎉🔥好未来技术交流群建立啦!!🔥🎉

为了给大家提供更好、更快的即时交流平台~

好未来技术交流微信群在今天正式成立‼️‼️‼️


在这里...

有定期的线上、线下福利活动等您参与!

齐聚行业内各个方向的技术大牛!!

还有好未来技术线一手的招聘资讯!!!

如果你还有其他想要的,欢迎随时私聊小编📢

🌟你想要的小编都有🌟


新群初建,福利来袭🥳🥳

率先进群的前50位伙伴,可以获得专属限定礼品

礼品(💃🏻/🕺🏼)有限,先到先得🤩🤩

还愣着干嘛⁉️⁉️  冲🦆!!!


- 也许你还想看 -

世界读书日|好未来技术免费送书啦!

好未来数据中台|中台全域特征池能力初探

Java并发编程-知识前瞻(第一章)



我知道你“在看”哟~


您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存