Skip to content
体验新版
项目
组织
正在加载...
登录
切换导航
打开侧边栏
氷泠
CSBookNotes
提交
da669068
C
CSBookNotes
项目概览
氷泠
/
CSBookNotes
上一次同步 1 年多
通知
2
Star
0
Fork
0
代码
文件
提交
分支
Tags
贡献者
分支图
Diff
Issue
0
列表
看板
标记
里程碑
合并请求
0
Wiki
0
Wiki
分析
仓库
DevOps
项目成员
Pages
C
CSBookNotes
项目概览
项目概览
详情
发布
仓库
仓库
文件
提交
分支
标签
贡献者
分支图
比较
Issue
0
Issue
0
列表
看板
标记
里程碑
合并请求
0
合并请求
0
Pages
分析
分析
仓库分析
DevOps
Wiki
0
Wiki
成员
成员
收起侧边栏
关闭侧边栏
动态
分支图
创建新Issue
提交
Issue看板
前往新版Gitcode,体验更适合开发者的 AI 搜索 >>
提交
da669068
编写于
5月 16, 2021
作者:
2
2293736867
浏览文件
操作
浏览文件
下载
电子邮件补丁
差异文件
Java Concurrency Chapter4
上级
157772d4
变更
3
隐藏空白更改
内联
并排
Showing
3 changed file
with
384 addition
and
0 deletion
+384
-0
JavaConcurrency/Chapter4/README.md
JavaConcurrency/Chapter4/README.md
+383
-0
JavaConcurrency/Chapter4/pic.odg
JavaConcurrency/Chapter4/pic.odg
+0
-0
JavaConcurrency/README.md
JavaConcurrency/README.md
+1
-0
未找到文件。
JavaConcurrency/Chapter4/README.md
0 → 100644
浏览文件 @
da669068
# Table of Contents
*
[
1 来源
](
#1-来源
)
*
[
2 `CPU`缓存
](
#2-cpu缓存
)
*
[
2.1 缓存模型
](
#21-缓存模型
)
*
[
2.2 缓存一致性问题
](
#22-缓存一致性问题
)
*
[
2.2.1 总线加锁
](
#221-总线加锁
)
*
[
2.2.2 缓存一致性协议
](
#222-缓存一致性协议
)
*
[
3 `JMM`
](
#3-jmm
)
*
[
4 并发编程的三个特性
](
#4-并发编程的三个特性
)
*
[
4.1 原子性
](
#41-原子性
)
*
[
4.2 可见性
](
#42-可见性
)
*
[
4.3 有序性
](
#43-有序性
)
*
[
5 `volatile`
](
#5-volatile
)
*
[
5.1 语义
](
#51-语义
)
*
[
5.2 如何保证可见性以及有序性
](
#52-如何保证可见性以及有序性
)
*
[
5.2.1 可见性
](
#521-可见性
)
*
[
5.2.2 有序性
](
#522-有序性
)
*
[
5.2.3 原子性
](
#523-原子性
)
*
[
5.3 实现原理
](
#53-实现原理
)
*
[
5.4 使用场景
](
#54-使用场景
)
*
[
5.5 与`synchronized`的区别
](
#55-与synchronized的区别
)
# 1 来源
-
来源:《Java高并发编程详解 多线程与架构设计》,汪文君著
-
章节:第十二、十三章
本文是两章的笔记整理。
# 2 `CPU`缓存
## 2.1 缓存模型
计算机中的所有运算操作都是由
`CPU`
完成的,
`CPU`
指令执行过程需要涉及数据读取和写入操作,但是
`CPU`
只能访问处于内存中的数据,而内存的速度和
`CPU`
的速度是远远不对等的,因此就出现了缓存模型,也就是在
`CPU`
和内存之间加入了缓存层。一般现代的
`CPU`
缓存层分为三级,分别叫
`L1`
缓存、
`L2`
缓存和
`L3`
缓存,简略图如下:
![
在这里插入图片描述
](
https://img-blog.csdnimg.cn/20210515234450950.png
)
-
`L1`
缓存:三级缓存中访问速度最快,但是容量最小,另外
`L1`
缓存还被划分成了数据缓存(
`L1d`
,
`data`
首字母)和指令缓存(
`L1i`
,
`instruction`
首字母)
-
`L2`
缓存:速度比
`L1`
慢,但是容量比
`L1`
大,在现代的多核
`CPU`
中,
`L2`
一般被单个核独占
-
`L3`
缓存:三级缓存中速度最慢,但是容量最大,现代
`CPU`
中也有
`L3`
是多核共享的设计,比如
`zen3`
架构的设计
![
在这里插入图片描述
](
https://img-blog.csdnimg.cn/20210516000524728.png
)
缓存的出现,是为了解决
`CPU`
直接访问内存效率低下的问题,
`CPU`
进行运算的时候,将需要的数据从主存复制一份到缓存中,因为缓存的访问速度快于内存,在计算的时候只需要读取缓存并将结果更新到缓存,运算结束再将结果刷新到主存,这样就大大提高了计算效率,整体交互图简略如下:
![
在这里插入图片描述
](
https://img-blog.csdnimg.cn/20210516001151927.png
)
## 2.2 缓存一致性问题
虽然缓存的出现,大大提高了吞吐能力,但是,也引入了一个新的问题,就是缓存不一致。比如,最简单的一个
`i++`
操作,需要将内存数据复制一份到缓存中,
`CPU`
读取缓存值并进行更新,先写入缓存,运算结束后再将缓存中新的刷新到内存,具体过程如下:
-
读取内存中的
`i`
到缓存中
-
`CPU`
读取缓存
`i`
中的值
-
对
`i`
进行加1操作
-
将结果写回缓存
-
再将数据刷新到主存
这样的
`i++`
操作在单线程不会出现问题,但在多线程中,因为每个线程都有自己的工作内存(也叫本地内存,是线程自己的缓存),变量
`i`
在多个线程的本地内存中都存在一个副本,如果有两个线程执行
`i++`
操作:
-
假设两个线程为A、B,同时假设
`i`
初始值为0
-
线程A从内存中读取
`i`
的值放入缓存中,此时
`i`
的值为0,线程B也同理,放入缓存中的值也是0
-
两个线程同时进行自增操作,此时A、B线程的缓存中,
`i`
的值都是1
-
两个线程将
`i`
写入主内存,相当于
`i`
被两次赋值为1
-
最终结果是
`i`
的值为1
这个就是典型的缓存不一致问题,主流的解决办法有:
-
总线加锁
-
缓存一致性协议
### 2.2.1 总线加锁
这是一种悲观的实现方式,具体来说,就是通过处理器发出
`lock`
指令,锁住总线,总线收到指令后,会阻塞其他处理器的请求,直到占用锁的处理器完成操作。特点是只有一个抢到总线锁的处理器运行,但是这种方式效率低下,一旦某个处理器获取到锁其他处理器只能阻塞等待,会影响多核处理器的性能。
### 2.2.2 缓存一致性协议
图示如下:
![
在这里插入图片描述
](
https://img-blog.csdnimg.cn/20210516002914904.png
)
缓存一致性协议中最出名的就是
`MESI`
协议,
`MESI`
保证了每一个缓存中使用的共享变量的副本都是一致的。大致思想是,
`CPU`
操作缓存中的数据时,如果发现该变量是一个共享变量,操作如下:
-
读取:不做其他处理,只是将缓存中数据读取到寄存器中
-
写入:发出信号通知其他
`CPU`
将该变量的缓存行设置为无效状态(
`Invalid`
),其他
`CPU`
进行该变量的读取时需要到主存中再次获取
具体来说,
`MESI`
中规定了缓存行使用4种状态标记:
-
`M`
:
`Modified`
,被修改
-
`E`
:
`Exclusive`
,独享的
-
`S`
:
`Shared`
,共享的
-
`I`
:
`Invalid`
,无效的
有关
`MESI`
详细的实现超出了本文的范围,想要详细了解可以参考
[
此处
](
https://www.cnblogs.com/yanlong300/p/8986041.html
)
或
[
此处
](
http://www2.in.tum.de/hp/file?fid=1276
)
。
# 3 `JMM`
看完了
`CPU`
缓存再来看一下
`JMM`
,也就是
`Java`
内存模型,指定了
`JVM`
如何与计算机的主存进行工作,同时也决定了一个线程对共享变量的写入何时对其他线程可见,
`JMM`
定义了线程和主内存之间的抽象关系,具体如下:
-
共享变量存储于主内存中,每个线程都可以访问
-
每个线程都有私有的工作内存或者叫本地内存
-
工作内存只存储该线程对共享变量的副本
-
线程不能直接操作主内存,只有先操作了工作内存之后才能写入主内存
-
工作内存和
`JMM`
内存模型一样也是一个抽象概念,其实并不存在,涵盖了缓存、寄存器、编译期优化以及硬件等
简略图如下:
![
在这里插入图片描述
](
https://img-blog.csdnimg.cn/20210516104445340.png
)
与
`MESI`
类似,如果一个线程修改了共享变量,刷新到主内存后,其他线程读取工作内存的时候发现缓存失效,会从主内存再次读取到工作内存中。
而下图表示了
`JVM`
与计算机硬件分配的关系:
![
在这里插入图片描述
](
https://img-blog.csdnimg.cn/20210516192712715.png
)
# 4 并发编程的三个特性
文章都看了大半了还没到
`volatile`
?别急别急,先来看看并发编程中的三个重要特性,这对正确理解
`volatile`
有很大的帮助。
## 4.1 原子性
原子性就是在一次或多次操作中:
-
要么所有的操作全部都得到了执行,且不会受到任何因素的干扰而中断
-
要么所有的操作都不执行
一个典型的例子就是两个人转账,比如A向B转账1000元,那么这包含两个基本的操作:
-
A的账户扣除1000元
-
B的账户增加1000元
这两个操作,要么都成功,要么都失败,也就是不能出现A账户扣除1000但是B账户金额不变的情况,也不能出现A账户金额不变B账户增加1000的情况。
需要注意的是两个原子性操作结合在一起未必是原子性的,比如
`i++`
。本质上来说,
`i++`
涉及到了三个操作:
-
`get i`
-
`i+1`
-
`set i`
这三个操作都是原子性的,但是组合在一起(
`i++`
)就不是原子性的。
## 4.2 可见性
另一个重要的特性是可见性,可见性是指,一个线程对共享变量进行了修改,那么另外的线程可以立即看到修改后的最新值。
一个简单的例子如下:
```
java
public
class
Main
{
private
int
x
=
0
;
private
static
final
int
MAX
=
100000
;
public
static
void
main
(
String
[]
args
)
throws
InterruptedException
{
Main
m
=
new
Main
();
Thread
thread0
=
new
Thread
(()->{
while
(
m
.
x
<
MAX
)
{
++
m
.
x
;
}
});
Thread
thread1
=
new
Thread
(()->{
while
(
m
.
x
<
MAX
){
}
System
.
out
.
println
(
"finish"
);
});
thread1
.
start
();
TimeUnit
.
MILLISECONDS
.
sleep
(
1
);
thread0
.
start
();
}
}
```
线程
`thread1`
会一直运行,因为
`thread1`
把
`x`
读入工作内存后,会一直判断工作内存中的值,由于
`thread0`
改变的是
`thread0`
工作内存的值,并没有对
`thread1`
可见,因此永远也不会输出
`finish`
,使用
`jstack`
也可以看到结果:
![
在这里插入图片描述
](
https://img-blog.csdnimg.cn/20210516121151277.png
)
## 4.3 有序性
有序性是指代码在执行过程中的先后顺序,由于
`JVM`
的优化,导致了代码的编写顺序未必是代码的运行顺序,比如下面的四条语句:
```
java
int
x
=
10
;
int
y
=
0
;
x
++;
y
=
20
;
```
有可能
`y=20`
在
`x++`
前执行,这就是指令重排序。一般来说,处理器为了提高程序的效率,可能会对输入的代码指令做一定的优化,不会严格按照编写顺序去执行代码,但可以保证最终运算结果是编码时的期望结果,当然,重排序也有一定的规则,需要严格遵守指令之间的数据依赖关系,并不是可以任意重排序,比如:
```
java
int
x
=
10
;
int
y
=
0
;
x
++;
y
=
x
+
1
;
```
`y=x+1`
就不能先优于
`x++`
执行。
在单线程下重排序不会导致预期值的改变,但在多线程下,如果有序性得不到保证,那么将可能出现很大的问题:
```
java
private
boolean
initialized
=
false
;
private
Context
context
;
public
Context
load
(){
if
(!
initialized
){
context
=
loadContext
();
initialized
=
true
;
}
return
context
;
}
```
如果发生了重排序,
`initialized=true`
排序到了
`context=loadContext()`
的前面,假设两个线程A、B同时访问,且
`loadContext()`
需要一定耗时,那么:
-
线程A通过判断后,先设置布尔变量的值为
`true`
,再进行
`loadContext()`
操作
-
线程B中由于布尔变量被设置为
`true`
,会直接返回一个未加载完成的
`context`
# 5 `volatile`
好了终于到了
`volatile`
了,前面说了这么多,目的就是为了能彻底理解和明白
`volatile`
。这部分分为四个小节:
-
`volatile`
的语义
-
如何保证有序性以及可见性
-
实现原理
-
使用场景
-
与
`synchronized`
区别
先来介绍一下
`volatile`
的语义。
## 5.1 语义
被
`volatile`
修饰的实例变量或者类变量具有两层语义:
-
保证了不同线程之间对共享变量操作时的可见性
-
禁止对指令进行重排序操作
## 5.2 如何保证可见性以及有序性
先说结论:
-
`volatile`
能保证可见性
-
`volatile`
能保证有序性
-
`volatile`
不能保证原子性
下面分别进行介绍。
### 5.2.1 可见性
`Java`
中保证可见性有如下方式:
-
`volatile`
:当一个变量被
`volatile`
修饰时,对共享资源的读操作会直接在主内存中进行(准确来说也会读取到工作内存中,但是如果其他线程进行了修改就必须从主内存重新读取),写操作是先修改工作内存,但是修改结束后立即刷新到主内存中
-
`synchronized`
:
`synchronized`
一样能保证可见性,能够保证同一时刻只有一个线程获取到锁,然后执行同步方法,并且确保锁释放之前,变量的修改被刷新到主内存中
-
使用显式锁
`Lock`
:
`Lock`
的
`lock`
方法能保证同一时刻只有一个线程能够获取到锁然后执行同步方法,并且确保锁释放之前能够将对变量的修改刷新到主内存中
具体来说,可以看一下之前的例子:
```
java
public
class
Main
{
private
int
x
=
0
;
private
static
final
int
MAX
=
100000
;
public
static
void
main
(
String
[]
args
)
throws
InterruptedException
{
Main
m
=
new
Main
();
Thread
thread0
=
new
Thread
(()->{
while
(
m
.
x
<
MAX
)
{
++
m
.
x
;
}
});
Thread
thread1
=
new
Thread
(()->{
while
(
m
.
x
<
MAX
){
}
System
.
out
.
println
(
"finish"
);
});
thread1
.
start
();
TimeUnit
.
MILLISECONDS
.
sleep
(
1
);
thread0
.
start
();
}
}
```
上面说过这段代码会不断运行,一直没有输出,就是因为修改后的
`x`
对线程
`thread1`
不可见,如果在
`x`
的定义中加上了
`volatile`
,就不会出现没有输出的情况了,因为此时对
`x`
的修改是线程
`thread1`
可见的。
### 5.2.2 有序性
`JMM`
中允许编译期和处理器对指令进行重排序,在多线程的情况下有可能会出现问题,为此,
`Java`
同样提供了三种机制去保证有序性:
-
`volatile`
-
`synchronized`
-
显式锁
`Lock`
另外,关于有序性不得不提的就是
`Happens-before`
原则。
`Happends-before`
原则说的就是如果两个操作的执行次序无法从该原则推导出来,那么就无法保证有序性,
`JVM`
或处理器可以任意重排序。这么做的目的是为了尽可能提高程序的并行度,具体规则如下:
-
程序次序规则:在一个线程内,代码按照编写时的次序执行,编写在后面的操作发生与编写在前面的操作之后
-
锁定规则:如果一个锁处于锁定状态,则
`unlock`
操作要先行发生于对同一个锁的
`lock`
操作
-
`volatile`
变量规则:对一个变量的写操作要早于对这个变量之后的读操作
-
传递规则:如果操作A先于操作B,操作B先于操作C,那么操作A先于操作C
-
线程启动规则:
`Thread`
对象的
`start()`
方法先行发生于对该线程的任何动作
-
线程中断规则:对线程执行
`interrupt()`
方法肯定要优于捕获到中断信号,换句话说,如果收到了中断信号,那么在此之前必定调用了
`interrupt()`
-
线程终结规则:线程中所有操作都要先行发生于线程的终止检测,也就是逻辑单元的执行肯定要发生于线程终止之前
-
对象终结规则:一个对象初始化的完成先行发生于
`finalize()`
之前
对于
`volatile`
,会直接禁止对指令重排,但是对于
`volatile`
前后无依赖关系的指令可以随意重排,比如:
```
java
int
x
=
0
;
int
y
=
1
;
//private volatile int z;
z
=
20
;
x
++;
y
--;
```
在
`z=20`
之前,先定义
`x`
或先定义
`y`
并没有要求,只需要在执行
`z=20`
的时候,可以保证
`x=0,y=1`
即可,同理,
`x++`
或
`y--`
具体先执行哪一个并没有要求,只需要保证两者执行在
`z=20`
之后即可。
### 5.2.3 原子性
在
`Java`
中,所有对基本数据类型变量的读取赋值操作都是原子性的,对引用类型的变量读取和赋值也是原子性的,但是:
-
将一个变量赋值给另一个变量的操作不是原子性的,因为涉及到了一个变量的读取以及一个变量的写入,两个原子性操作结合在一起就不是原子性操作
-
多个原子性操作在一起就不是原子性操作,比如
`i++`
-
`JMM`
只保证基本读取和赋值的原子性操作,其他的均不保证,如果需要具备原子性,那么可以使用
`synchronized`
或
`Lock`
,或者
`JUC`
包下的原子操作类
也就是说,
`volatile`
并不能保证原子性,例子如下:
```
java
public
class
Main
{
private
volatile
int
x
=
0
;
private
static
final
CountDownLatch
latch
=
new
CountDownLatch
(
10
);
public
void
inc
()
{
++
x
;
}
public
static
void
main
(
String
[]
args
)
throws
InterruptedException
{
Main
m
=
new
Main
();
IntStream
.
range
(
0
,
10
).
forEach
(
i
->
{
new
Thread
(()
->
{
for
(
int
j
=
0
;
j
<
1000
;
j
++)
{
m
.
inc
();
}
latch
.
countDown
();
}).
start
();
});
latch
.
await
();
System
.
out
.
println
(
m
.
x
);
}
}
```
最后输出的
`x`
的值会少于
`10000`
,而且每次运行的结果也并不相同,至于原因,可以从两个线程A、B开始分析,图示如下:
![
在这里插入图片描述
](
https://img-blog.csdnimg.cn/202105161331204.png
)
-
`0-t1`
:线程A将
`x`
读入工作内存,此时
`x=0`
-
`t1-t2`
:线程A时间片完,
`CPU`
调度线程B,线程B将
`x`
读入工作内存,此时
`x=0`
-
`t2-t3`
:线程B对工作内存中的
`x`
进行自增操作,并更新到工作内存中
-
`t3-t4`
:线程B时间片完,
`CPU`
调度线程A,同理线程A对工作内存中的
`x`
自增
-
`t4-t5`
:线程A将工作内存中的值写回主内存,此时主内存中的值为
`x=1`
-
`t5`
以后:线程A时间片完,
`CPU`
调度线程B,线程B也将自己的工作内存写回主内存,再次将主内存中的
`x`
赋值为1
也就是说,多线程操作的话,会出现两次自增但是实际上只进行一次数值修改的操作。想要
`x`
的值变为
`10000`
也很简单,加上
`synchronized`
即可:
```
java
new
Thread
(()
->
{
synchronized
(
m
)
{
for
(
int
j
=
0
;
j
<
1000
;
j
++)
{
m
.
inc
();
}
}
latch
.
countDown
();
}).
start
();
```
## 5.3 实现原理
前面已经知道,
`volatile`
可以保证有序性以及可见性,那么,具体是如何操作的呢?
答案就是一个
`lock;`
前缀,该前缀实际上相当于一个内存屏障,该内存屏障会为指令的执行提供如下几个保障:
-
确保指令重排序时不会将其后面的代码排到内存屏障之前
-
确保指令重排序时不会将其前面的代码排到内存屏障之后
-
确保执行到内存屏障修饰的指令时前面的代码全部执行完成
-
强制将线程工作内存中的值修改刷新到主存中
-
如果是写操作,会导致其他线程工作内存中的缓存数据失效
## 5.4 使用场景
一个典型的使用场景是利用开关进行线程的关闭操作,例子如下:
```
java
public
class
ThreadTest
extends
Thread
{
private
volatile
boolean
started
=
true
;
@Override
public
void
run
()
{
while
(
started
){
}
}
public
void
shutdown
(){
this
.
started
=
false
;
}
}
```
如果布尔变量没有被
`volatile`
修饰,那么很可能新的布尔值刷新不到主内存中,导致线程不会结束。
## 5.5 与`synchronized`的区别
-
使用上的区别:
`volatile`
只能用于修饰实例变量或者类变量,但是不能用于修饰方法、方法参数、局部变量等,另外可以修饰的变量为
`null`
。但
`synchronized`
不能用于对变量的修饰,只能修饰方法或语句块,而且
`monitor`
对象不能为
`null`
-
对原子性的保证:
`volatile`
无法保证原子性,但是
`synchronized`
可以保证
-
对可见性的保证:
`volatile`
与
`synchronized`
都能保证可见性,但是
`synchronized`
是借助于
`JVM`
指令
`monitor enter`
/
`monitor exit`
保证的,在
`monitor exit`
的时候所有共享资源都被刷新到主内存中,而
`volatile`
是通过
`lock;`
机器指令实现的,迫使其他线程工作内存失效,需要到主内存加载
-
对有序性的保证:
`volatile`
能够禁止
`JVM`
以及处理器对其进行重排序,而
`synchronized`
保证的有序性是通过程序串行化执行换来的,并且在
`synchronized`
代码块中的代码也会发生指令重排的情况
-
其他区别:
`volatile`
不会使线程陷入阻塞,但
`synchronized`
会
JavaConcurrency/Chapter4/pic.odg
0 → 100644
浏览文件 @
da669068
文件已添加
JavaConcurrency/README.md
浏览文件 @
da669068
...
...
@@ -3,3 +3,4 @@
-
[
第一章:Thread 详解
](
https://github.com/2293736867/CSBookNotes/blob/main/JavaConcurrency/Chapter1/README.md
)
-
[
第二章:线程安全与ThreadGroup
](
https://github.com/2293736867/CSBookNotes/blob/main/JavaConcurrency/Chapter2/README.md
)
-
[
第三章:类加载
](
https://github.com/2293736867/CSBookNotes/blob/main/JavaConcurrency/Chapter3/README.md
)
-
[
第四章:volatile
](
https://github.com/2293736867/CSBookNotes/blob/main/JavaConcurrency/Chapter4/README.md
)
编辑
预览
Markdown
is supported
0%
请重试
或
添加新附件
.
添加附件
取消
You are about to add
0
people
to the discussion. Proceed with caution.
先完成此消息的编辑!
取消
想要评论请
注册
或
登录