导读

本文通过对JSR133规范的解读,详细的介绍JMM的核心理论,并将开发中常用的关键字的实现原来做了详细的介绍。通过本文读者可以了解到并发的一些基本理论,并对一些同步原语有了更深层次的理解。希望读者在理解了JMM倾诉的对象之后,重点关注第五章:抽丝剥茧-专注JMM。

01

导言

多线程、高并发问题相信是每一位从事Java研发工作的程序员都不可回避的一个重要话题。从启动一个线程,到使用volatile、synchronized、final关键字,到使用wait()、notify()、notifyAll()、join()方法,再到编写复杂的多线程程序,不知道大家有没有思考过这样一个问题,为什么要使用这些API,或者说这些API到底给编程人员提供了什么样的保证,才使得在多线程环境下程序的运行结果能够符合预期。它就是Java Memory Model(后续简称JMM)。本文就带领大家一起,绕道这些API的背后,一探究竟。

02

约法三章-建立共

探讨任何话题都需要探讨者站在一个共识基础之上,否则探讨将混乱不堪。正如一位名人曾经说过:”没有共识的讨论,都是抬杠“。我深以为然,所以在探讨JMM之前,需要建立以下几点共识。

  • JMM只是一个抽象内存模型。

  • JMM和物理机内存模型不是一个范畴。

  • JMM和Java运行时数据区没有直接对应关系。

03

以史为鉴-回看计算机内存模型

1、现代计算机内存模型

物理机遇到的并发问题与Java虚拟机中的情况有不少相似之处,物理机对并发问题的处理方案对虚拟机的实现也有相当大的参考价值。现代计算机中,CPU的指令速度远远超过内存的存取速度,由于计算机的存储设备与CPU的运算速度有几个数量级的差距,所以现在计算机中都不得不加入一层读写速度尽可能接近CPU运算速度的高速缓存(cache)来作为内存和CPU之间的缓冲。

基于高速缓存的存储交互很好的解决了CPU和内存的速度的矛盾,但也引入了一个新的问题,缓存一致性,在多处理器系统中,每个CPU都有自己的高速缓存,而他们又共享同一主内存,当多个处理器运算任务都涉及到同一块主内存区域时,将可能导致各自的缓存数据不一致。为了解决这个问题,需要各个处理器在访问内存时,需要遵循一些协议,例如MSI、EMSI、MOSI等。

图1 计算机内存模型

2、缓存一致性

为了解决这个问题,先后有过两种办法:

  • 总线锁机制

总线锁就是使用CPU提供的一个LOCK#信号,当一个处理器在总线上输出此信号,其他处理器的请求将被阻塞,那么该处理器就可以独占共享锁。这样就保证了数据一致性。

  • 缓存锁机制

但是总线锁定开销太大,我们需要控制锁的力度,所以又有了缓存锁,核心就是缓存一致性协议,不同的CPU硬件厂商实现方式稍有不同,有MSI、MESI、MOSI等。

3、多线程编程面临的问题

多线程编程面临的两个重要的问题是:

  • 线程之间的通信

  • 线程之间的同步

线程之间的通信是指线程之间通过什么方式来交换信息。

同步是指程序用于控制不同线程之间操作发生相对顺序的机制。

线程的通信方式:

  • 共享内存

  • 消息传递

在共享内存的并发模式里,线程之间共享程序的公共状态,线程之间通过读-写内存中的公共状态来实现隐式通信。

在消息传递的并发模式里,同步是显式进行的,程序员必须显式指定某个方法或某段代码需要在线程之间互斥进行。

图2 共享内存并发模型

在消息传递的并发模式里,线程之间没有公共状态,线程之间必须明确发送消息来显式进行通信。

在消息传递的并发模型里,同步是隐式进行的,由于消息发送必然在消息接收之前,因此同步是隐式进行的。

图3 消息传递并发模型

04

师夷长技-直面JSR133

1、JSR133是什么

JSR-133规范,即Java内存模型与线程规范,由JSR-133专家组开发。JSR-133规范是JSR-176(定义Java平台Tiger(5.0)发布版的重要特性)的一部分。本规范的标准内容将合并到Java语言规范、Java虚拟机规范以及java.lang包的类说明中。

2、 JSR133倾诉的对象是谁

身边好多同事反馈看不懂JSR133的内容,一方面是因为文档全部为英文,并且包含大量的专业英语。另外一方面是没有弄明白JSR133倾诉的对象到底是谁。如果弄明白的倾诉的对象,然后对号入座就能理解JSR133在说什么。JSR133倾诉的对象有两个,一个是使用者(程序员),另外一个是JMM的实现方(JVM)。面向程序员,JSR133通过happens-before规则给使用者提供了同步语义的保证。面向实现者,JSR133限制了编译器和处理器的优化,如下图4:

图4 JSR133整体视图

3、JSR133的主要内容是什么

JSR133主要描述了JMM的主要的规则和限制,并详细阐述了一些同步原语的内存语义,详细的请查看下一章节,JSR133的目录,如下图5:

图5 JSR133原文目录

05

抽丝剥茧-专注JMM

1 JMM内存模型概述

前面在第三章节,讲述了共享内存和消息传递并发模型,java采用的是共享内存并发模型。

在java中,所有的实例域,静态域和数组元素都存储在堆内存中,堆内存在线程之间共享。局部变量,方法参数和异常处理器参数不会在线程之间共享,他们不会有内存可见性问题,也不受内存模型的影响。

java线程之间的通信由java内存模型(JMM)控制,JMM决定了一个线程对共享变量的写入何时对另一个线程可见。JMM定义了多线程和主内存之间的抽象关系:线程之间的共享变量存储在主内存中,每个线程都有一个私有的本地化内存,本地内存中存储了该线程用以读/写共享变量的副本。本地内存只是JMM的抽象,并不真实存在,它涵盖了缓存、写缓冲区、寄存器以及其他的硬件和编译器优化。java内存模型的抽象示意,如图6:

图6  JMM内存模型

2 重排序

在执行程序时,为了提高性能,编译器和处理器常常会对指令做重排序。总的来说重排序分成两类:

编译器优化的重排序。编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。

处理器重排序。现在处理器采用了指令级并行技术来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。

这些重排序可能会导致多线程出现内存可见性问题。对于编译器,JMM的编译器重排序规则会禁止特定类型的编译器重排序。对于处理器重排序,JMM的处理器重排序规则会要求Java编译器在生成指令序列时,插入特定类型的内存屏障指令,通过内存屏障指令来禁止特定类型的处理器重排序。

JMM属于语言级的内存模型,它确保在不同的编译器和不同的处理器平台上,通过禁止特定类型的编译器重排序和处理器重排序,为程序员提供一致的内存可见性保证。

重排序对多线程的影响

下面我们从一个很经典的代码例子说明重排序的问题,代码如下:

class RecordExample {
int a = 0 ;
boolean flag = false ;
public void write(){
a = 1 ;                 //步骤1
flage = true ;        //步骤2
}
public void reader(){
if(flag){              //步骤3
int i = a * a;   //步骤4
}
}

flag变量是个标记,用来标识变量a是否已被写入。这里假设有两个线程A和B,A首先执行writer()方法,随后B线程接着执行reader()。线程B在执行操作4时,能否看到线程A在操作1对共享变量a的写入呢?

答案是:不一定能看到。

由于操作1和操作2没有数据依赖关系,编译器和处理器可以对这两个操作重排序;同样,操作3和操作4没有数据依赖关系,编译器和处理器也可以对这两个操作重排序。当操作1和操作2重排序时,可能产生什么效果?如下图7。

图7 程序执行时序图

如上图,操作1和操作2做了重排序。程序执行时,线程A首先写标记变量flag,随后线程B读取这个变量。由于条件判断为真,线程B将读取变量a。此时,变量a还没有被线程A写入,在这里多线程的语义被重排序破坏了!

3 原子性、可见性、有序性

原子性:

一个或多个操作,要么全部执行且在执行过程中不被任何因素打断,要么全部不执行。在java中当我们讨论一个操作具有原子性问题一般是指这个操作会被线程的随机调度打断。比如下面的操作:

int a = 1;                   //原子操作
int a = b;                   //非原子操作,分两步操作第一步读取b的值,第二部将b赋值a
int a = a + 1;               //非原子操作,分两步操作第一步读取a的值,第二部将计算结果赋值给a
a ++ ;                       //非原子操作,同上

JMM对原子性问题的保证如下:

自带原子性保证:在java中,对基本数据类型的变量的读取和赋值操作是原子性操作。

synchronized:synchronized可以保证边界操作结果的原子性。synchronized可以防止多个线程并发的执行同一段代码,从结果上保证原子性。

Lock锁:Lock锁保证原子性的原理和synchronized类似。

原子类操作:JDK提供了很多原子操作类来保证操作的原子性,例如基础类型:AtomicXxx;引用类型AtomicReference等。原子类的底层是使用CAS机制,这个机制对原子性的保证和synchroinized有本质的区别。CAS机制保证了整个赋值操作是原子的不能被打断,二synchronized只能保证代码最终执行结果的正确性,也就是说,synchronized消除了原子性问题对代码最后执行结果的影响。

可见性:

在多线程环境下,一个线程对共享变量的修改,不仅要对本线程可见,而且要对其他线程可见。造成可见性的主要原因是由于CPU多核心和高速缓存(L1,L2,L3)。JMM对可见性问题,提供了如下保证:

volatile:使用volatile关键字修饰一个变量可以保证变量的可见性,大概的保证语义如下(详细的参看volatile的内存语义章节)

  • 线程对共享变量的副本做了修改,会立刻刷新最新值到主内存中。

  • 线程对共享变量的副本做了修改,其他其他线程中对这个变量拷贝的副本会时效;其他线程如果需要对这个共享变量进行读写,必须重新从主内存中加载。

synchronized:使用synchronized代码块或者synchronized方法也可以保证共享变量的可见性。当线程释放锁时,JMM会把该线程对应的本地内存中的共享变量刷新到主内存中。当线程获取锁时,JMM会把该线程对应的本地内存置为无效,从而使得被监听器保护的临界区代码必须从主内存中读取共享变量,从而实现共享变量的可见性。

Lock锁:使用Lock相关实现类也可以保证共享变量的可见性。其原理同synchronized。

原子操作类:原子类底层使用的是CAS机制。java中CAS机制每次都会从主内存中获取最新值进行compare,比较一致之后才会将新值set到主内存中去。而且这个操作是一个原子操作,所以CAS每次操作每次拿到的都是主内存中的最新值,每次set的值也会立即写到主内存中。

有序性:

程序执行的顺序按照代码的先后顺序执行。在JMM允许的重排序环境下,单线程的执行结果和没有重排序的情况下保持一致。JMM中提供一下方式来保证有序性:

happens-before原则:happens-before原则是java内存模型中定义的两项操作之间的偏序关系,如果操作A先行发生于操作B,也就是说发生操作B之前,操作A产生的影响能被操作B观察到。这里的“影响”包括修改共享变量,方法调用。详细的happens-before说明请参看happens-before原则章节。

synchronized机制:synchronized能够保证有序性是因为synchronized可以保证同一时间只有一个线程访问代码块,而单线程环境下,JMM能够保证代码的串行语义;虽然使用synchronized的代码块,还可以发生指令重排序,但是synchronized可以保证只有一个线程执行,所以最后的结果还是正确的。

volatile机制:volatile的底层是使用内存屏障(详细请参看内存屏障章节)来保障有序性的。写volatile变量时,可以确保volatile写之前的操作不会被编译器重排序到volatile写之后。读volatile变量时,可以确保volatile读之后的操作不会被编译器重排序到volatile读之前。

多线程面临的两个问题线程之间的通信和线程之间的同步,这两个问题如果仔细分析,从结果的角度看线程之间的通信就是可见性问题,线程之间的同步就是原子性和有序性的问题。

总结JMM对特性提供的支持如下:

特性

volatile关键字

synchronized关键字

Lock接口

Atomic变量

原子性

无法保障

可以保障

可以保障

可以保障

可见性

可以保障

可以保障

可以保障

可以保障

有序性

一定程度

可以保障

可以保障

无法保障

4 happens-before原则

JSR133使用happens-before来阐述操作之间的内存可见性。在JMM中,如果一个操作的结果需要对另一个操作可见,那么这两个操作之间必然要存在happens-before关系。这里提到的两个操作既可以是一个线程之内,也可以是不同线程之间。

在《并发编程的艺术》一书中,对happens-before的定义如下:

在JMM中,如果一个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须要存在happens-before关系。这里提到的两个操作既可以是一个线程之内,也可以是不同线程之间。两个操作之间具有happens-before关系,并不意味着前一个操作必须要在后一个操作之前执行!happens-before仅仅要求前一个操作(执行的结果)对后一个操作可见,且前一个操作按顺序排在第二个操作之前。

happens-before规则如下:

程序顺序规则(Program Order Rule):一个线程中的每个操作,happens-before于该线程中的任意后续操作。

监视器锁规则(Monitor Lock Rule):对一个锁的解锁,happens-before于随后对这个锁的加锁。

volatile变量规则(Volatile Variable Rule):对一个volatile域的写,happens-before于任意后续对这个volatile域的读。

start()规则(Thread Start Rule):如果线程A执行线程B.start()(启动线程B),那么A线程的B.start()操作happens-before于线程B中的任意操作。

join()规则(Thread Join Rule):如果线程A执行线程B.join()并成功返回,那么线程B中的任意操作happens-before于线程A从B.join()操作成功返回。

程序中断规则(Thread Interruption Rule):对线程interrupt()的调用happens-before于被中断线程的interrupted()或者isInterrupted()。

finalizer规则(Finalizer Rule):一个对象构造函数的结束happens-before于该对象finalizer()的开始。

传递性规则(Transitivity):如果A happens-before B,且B happens-before C ,那么A happens-before C。

了解了happens-before原则,下面举例帮助理解:

private int value = 0;
public void setValue(int value)
{this.value = value;
}
public int getValue()
{return value;
}

假设两个线程A和B,线程A先(在时间上先)调用了这个对象的setValue(1),接着线程B调用了getValue()方法,那么B的返回值是多少?

对照happens-before原则,上面的操作不满下面的条件:

  • 不是同一个线程,所以不涉及:程序顺序规则。

  • 不涉及同步,所以不涉及:监视器锁规则。

  • 没有volatile,所以不涉及:volatile变量规则。

  • 没有线程的启动和中断,所以不涉及:start()规则,join规则,程序中断规则。

  • 没有对象的创建和终结,所以不涉及:finalizer规则。

  • 更没有传递规则。

所以,一条规则都不满足,尽管线程A在时间上与线程B具有先后顺序,但是,却不满足happens-before原则,也就是有序性并不会保障,所以线程B获取到的数据是不安全的!!!这也反向说明了happens-before原则提到的关系和时间的先后顺序没有关系。

时间先后顺序与先行发生原则之间基本没有太大关系,所以我们衡量并发安全问题的时候不要收到时间顺序的干扰,一切必须以先行发生原则为准。只有真正满足了happens-before原则,才能保证安全。

5 内存屏障

内存屏障(Memory Barrier),也称为内存栅障,屏障指令等,是一类同步屏障指令,是CPU或编译器在对内存随机访问的操作中的一个同步点,使得此点之前的所有读写操作都执行后才可以执行此点之后的操作。大多数现代计算机为了提高性能而采取乱序执行,这使得内存屏障成为必须。

语义上,内存屏障之前的所有写操作都要写入内存;内存屏障之后的读操作都可以获得同步屏障之前的写操作的结果。因此,对于敏感的程序块,写操作之后、读操作之前可以插入内存屏障。

CPU层面的内存屏障

CPU层面的内存屏障分为三类:

写屏障(Store Memory Barrier):告诉处理器在写屏障之前的所有已经存储在存储缓存(store bufferes)中的数据同步到主内存,简单来说就是使得写屏障之前的指令的结果对写屏障之后的读或者写是可见的。

读屏障(Load Memory Barrier):处理器在读屏障之后的读操作,都在读屏障之后执行。配合写屏障,使得写屏障之前的内存更新对于读屏障之后的读操作是可见的。

全屏障(Full Memory Barrier):确保屏障前的内存读写操作的结果提交到内存之后,再执行屏障后的读写操作。

JMM层面的内存屏障

在JMM中将内存屏障分为四类:LoadLoad Barrier;StoreStore Barrier;LoadStore Barrier;StoreLoad Barrier,内存屏障的详细解释如下图8(图片来源于《并发编程艺术》):

图8 JMM内存屏障分类

6 volatile的内存语义

volatile是java提供的一种轻量级的同步机制,在并发编程中,它也扮演着比较重要的角色。一方面volatile不会造成上下文切换的开销,另一方面它又不能像synchronized那样保证所有场景下线程安全,因此必须在合适的场景下使用volatile机制。前面一个章节,我们了解到volatile可以支持可见性和有序性,那么它是通过怎样的机制来实现这些特性的?其核心原理就是上一章节描述的内存屏障。

volatile写-读的内存语义

  • 当写一个volatile变量时,JMM会把该线程对应的本地内存中的变量值刷新到主内存。

  • 当读一个volatile变量时,JMM会把该线程对应的本地内存置为无效,线程将从主内存中读取共享变量。

volatile内存语义的实现

为了实现volatile的内存语义,JMM会限制两种类型的重排序,下图是JMM针对编译器指定的volatile重排序规则表:

是否能重排序

第二个操作

第一个操作

普通读/写

volatile读

volatile写

普通读/写

NO

volatile读

NO

NO

NO

volatile写

NO

NO

  • 当第二个操作为volatile写操作时,不管第一个操作是什么,都不能进行重排序。这个规则确保volatile写之前的所有操作都不会被重排序到volatile写之后。

  • 当第一个操作为volatile读操作时,不管第二个操作时什么,都不能进行重排序。这个规则确保volatile读之后的所有操作都不会被重排序到volatile读之前。

  • 当第一个操作时volatile写操作时,第二个操作时读操作,不能进行重排序。

为了实现以上规则,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序,下面是基于保守策略(根据不同虚拟机策略不同)的JMM内存屏障插入策略:

  • 在每个volatile写操作的前面插入一个StoreStore屏障(禁止前面的写与volatile写重排序)。

  • 在每个volatile写操作的后面插入一个StoreLoad屏障(禁止volatile写与后面可能有的读和写重排序)。

  • 在每个volatile读操作的后面插入一个LoadLoad屏障(禁止volatile读与后面的读操作重排序)。

  • 在每个volatile读操作的后面插入一个LoadStore屏障(禁止volatile读与后面的写操作重排序)。

下图为volatile写操作插入内存屏障后生成的指令序列示意图。

图9 volatile写操作的内存屏障示意

下图为volatile读操作插入内存屏障后生成的指令序列示意图:

图10 volatile读操作的内存屏障示意

上述volatile写和volatile读的内存屏障插入策略非常保守,在实际执行时,只要不改变volatile写-读的内存语义,编译器可以根据具体情况忽略不必要的屏障。

7 final的内存语义

在平时的开发过程中常常使用final关键字来修饰方法,保证方法不能被子类重写,那使用final修饰变量又表达什么内存语义呢?

final的内存语义

  • 在构造函数内对一个final域的写入,与随后把这个构造对象的引用赋值给一个引用变量,这两个操作之间不能重排序。

  • 初次读取一个包含final域对象的引用,与随后初次读取这个final域,这两个操作之间不能重排序。

final的内存语义实现

  • 写final域的重排序规则会要求编译器在final域写之后,构造函数返回之前,插入一个StoreStore屏障。

  • 读final域的重排序规则会要求编译器在final域读之前插入一个LoadLoad屏障。

Java内存模型(Java Memory Model,JMM)相关推荐

  1. Java内存模型(Java Memory Molde,JMM)

    文章目录 1.Java内存模型(Java Memory Molde,JMM) 2. 多线程先行发生原则之happens-before 1.Java内存模型(Java Memory Molde,JMM) ...

  2. 第十六章:Java内存模型——Java并发编程实战

    一.什么是内存模型,为什么要使用它 如果缺少同步,那么将会有许多因素使得线程无法立即甚至永远看到一个线程的操作结果 编译器把变量保存在本地寄存器而不是内存中 编译器中生成的指令顺序,可以与源代码中的顺 ...

  3. 【Android 内存优化】Java 内存模型 ( Java 虚拟机内存模型 | 线程私有区 | 共享数据区 | 内存回收算法 | 引用计数 | 可达性分析 )

    文章目录 一. Java 虚拟机内存模型 二. 程序计数器 ( 线程私有区 ) 三. 虚拟机栈 ( 线程私有区 ) 四. 本地方法栈 ( 线程私有区 ) 五. 方法区 ( 共享数据区 ) 1. 方法区 ...

  4. java内存 海子_Android 面试经验 - Java 内存模型 - Java 技术驿站-Java 技术驿站

    Java内存模型 目录 Java执行流程 Java文件被编译成字节码文件之后,由JVM中的类加载器进行加载,加载完毕之后,交由JVM的执行引擎执行.在程序执行的过程中,JVM会用一段空间来存储数据和相 ...

  5. Java 内存模型(Java Memory Model,JMM)

    为了屏蔽各种硬件和操作系统的内存访问差异,JVM制定了一套JMM内存模型来实现同一套Java程序在不同平台上实现一样的运行效果.也就是一次编译到处运行跨平台的效果. JVM内存分配概念 JVM两个重要 ...

  6. 稀疏内存模型sparsemem memory model | 文章

    文章推荐 <sparsemem memory model>https://lwn.net/Articles/134804/ <Physical Memory Model>htt ...

  7. 从底层吃透java内存模型(JMM)、volatile、CAS

    前言 随着计算机的飞速发展,cpu从单核到四核,八核.在2020年中国网民数预计将达到11亿人.这些数据都意味着,作为一名java程序员,必须要掌握多线程开发,谈及多线程,绕不开的是对JMM(Java ...

  8. 高并发编程-重新认识Java内存模型(JMM)

    文章目录 从CPU到内存模型 内存模型如何确保缓存一致性 并发变成需要解决的问题 (原子性.可见性.有序性) 内存模型需要解决的问题 Java内存模型 JMM的API实现 原子性 synchroniz ...

  9. java内存模型 原子性_Java内存模型JMM 高并发原子性可见性有序性简介 多线程中篇(十)...

    JVM运行时内存结构回顾 在JVM相关的介绍中,有说到JAVA运行时的内存结构,简单回顾下 整体结构如下图所示,大致分为五大块 而对于方法区中的数据,是属于所有线程共享的数据结构 而对于虚拟机栈中数据 ...

最新文章

  1. Hibernate框架第二天
  2. 数字信号处理实验三用fft对信号作频谱分析_机器学习中的音频特征:理解Mel频谱图...
  3. NASA投资有远景技术,有望改变未来人类和机器人的勘探任务
  4. 阿里巴巴有一群全年无休从不领工资的高智商员工
  5. java运维工程师做什么_网络工程师和网络运维工程师有什么不同?
  6. 网卡多队列:RPS、RFS、RSS、Flow Director(DPDK支持)
  7. 收集了一些容易出错的题,可能大家也不会注意到的基础知识(js)
  8. 企业沟通工具实现高效信息化沟通模式
  9. sqlserver 软件授权
  10. 上传doc,pdf,ppt,png,jpg,html文件并解析内容
  11. ue4导入abc文件问题
  12. ThinkPadnbsp;E40nbsp;硬盘异响…
  13. hdu1677 转换LIS
  14. iptables之state模块使用
  15. Java练习题12.1
  16. 菜单栏点击显示二级菜单_显示完整菜单
  17. Mybatis中复杂语句标签的简单初步使用
  18. Google Earth Engine ——使用 GEE在QGIS中创建动画地图
  19. 北航计算机和人大统计学,大学计算机学科排名,清华北大谁是第一,北航表现又如何...
  20. NUC 折腾笔记 - 储存能力测试

热门文章

  1. axure 下拉多选 元件_Axure教程:下拉多选列表集合(多选下拉列表+单选下拉列表+分级下拉列表)...
  2. python中bytearray和java中byte[]的区别_Python经典面试题:说说Python中xrange和range的区别?...
  3. 简单介绍使用Nginx限制百度蜘蛛频繁抓取的问题
  4. ASP.NET Razor – C# 变量简介
  5. abb工业机器人指令lf怎么用_工业机器人课程:ABB知识点27 | I/O控制指令
  6. 青岛大学计算机系住哪个校区,青岛大学有几个校区及校区地址
  7. poj1716(差分约束+SPFA)
  8. PTA基础编程题目集-7-4 BCD解密
  9. linux生成md5指定文件名,linux 通过MD5监控指定路径文件的变动
  10. Codeforces Round #699 (Div. 2) (A ~ F)6题全,超高质量良心题解【每日亿题】2021/2/6