高谈风月

不共青山一笑,不与黄花一醉

0%

数据结构

在了解锁原理之前我们先来看一下ZK的数据结构,具体如下:

在 Zookeeper 中,每一个数据节点都是一个 ZNode,上图根目录下有两个节点,分别是:app1 和 app2,其中 app1 下面又有三个子节点。那么我们来看看 ZNode 数据结构到底是什么样子的呢。首先我们来了解 ZNode 的类型。

Zookeeper 节点类型可以分为三大类:持久性节点(Persistent)、瞬时性节点(Ephemeral)、顺序性节点(Sequential)。现实开发中在创建节点的时候通过组合可以生成以下四种节点类型:持久节点、持久顺序节点、瞬时节点、瞬时有序节点。

(1) 持久节点:节点被创建后会一直存在服务器,直到删除操作主动清除,这种节点也是最常见的类型。

(2) 持久顺序节点:有顺序的持久节点,节点特性和持久节点是一样的,只是额外特性表现在顺序上。顺序特性实质是在创建节点的时候,会在节点名后面加上一个数字后缀,来表示其顺序。

(3) 瞬时节点:会被自动清理掉的节点,它的生命周期和客户端会话绑在一起,客户端会话结束,节点会被删除掉。与持久性节点不同的是,临时节点不能创建子节点。

(4)瞬时有顺序节点:有顺序的临时节点,和持久顺序节点相同,在其创建的时候会在名字后面加上数字后缀。

那么此次我们的ZK分布式锁就是基于ZK的临时有序节点实现的,也就是上述的第四种节点。当然光凭借第四种临时有序节点是不够的,我们还需要用到ZK的另外一个比较重要的概念,那就是“ZK观察器”。

ZK观察器

ZK观察器可以监测到节点的变动,如果节点发生变更会通知到客户端。我们可以设置观察器的三个方法:getData(),getChildrean(),exists()。观察器有一个比较重要的特性就是只能监控一次,再监控需要重新设置。

原理流程

(1)利用ZK的瞬时有序节点的特性。

(2)多线程并发创建瞬时节点时,得到有序的序列。

(3)序号最小的线程获得锁。

(4)其他的线程则监听自己节点序号的前一个序号。

(5)前一个线程执行完成,删除自己序号的节点。

(6)下一个序号的线程得到通知,继续执行。

(7)依次类推

通过上述流程大家就会发现,其实在创建节点的时候,就已经确定了线程的执行顺序。大家看完这个流程可能有点模糊,咱们继续看下面的图解,老猫相信大家心里就会有一个更加清晰的认知。

流程一】我们有四个线程,分别是线程A、线程B、线程C、线程D。此时线程并发运行,这样就会在我们的ZK中创建四个临时有序节点,按照先来后到的顺序分别是1、2、3、4。此时按照我们流程描述中的第三点描述由于线程A对应的序号最小,所以A优先获取锁。

【流程二】再依次看第二个流程,此时当A获取锁之后,线程B的监听器会去监听1节点的执行情况,线程C的监听器会去监听2节点的执行情况,线程D的监听器会去监听3节点的执行情况依次类推。

【流程三】当线程A执行完毕之后会删除相关的节点1,此时会被线程B监听到,于是线程B开始执行,有线程C监听等待着线程B节点的释放,依次类推,直到这四个线程都执行完毕。

通过以上的图解,老猫觉得很多小伙伴对ZK锁的实现原理应该已经知道了,当然对ZK还是比较陌生的小伙伴也可以专门抽时间去熟悉一下ZK。接下来就和老猫一起来看一下具体的代码又是如何实现的吧。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
public class DistributeLocker implements AutoCloseable, Watcher {
private ZooKeeper zooKeeper;
private String zNode;

public DistributeLocker() throws IOException {
this.zooKeeper = new ZooKeeper("localhost:2181",60000,this);
}

public boolean getLock(String businessCode) {
try {
// 首先创建业务根节点
Stat stat = zooKeeper.exists("/"+businessCode,false);
if(stat == null) {
//表示创建一个业务根目录,此节点为持久节点,另外的由于在本地搭建的zk没有设置密码,所以采用OPEN_ACL_UNSAFE模式
zooKeeper.create("/" +businessCode,businessCode.getBytes(), ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.PERSISTENT);
}

//创建该目录下的有序瞬时节点,假如我们的订单业务编号是"xxx",那么第一个有序瞬时节点应该是/xxx/xxx_0000001
zNode =zooKeeper.create("/" + businessCode + "/" + businessCode + "_", businessCode.getBytes(), ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.EPHEMERAL_SEQUENTIAL);
// 获取 /xxx节点下的所有子节点,无需观察
List<String> childrenNodes = zooKeeper.getChildren("/"+businessCode,false);
//子节点排序
Collections.sort(childrenNodes);
//获取序号最小的子节点
String minNode = childrenNodes.get(0);
//如果创建的节点是最小序号的节点,那么就获得锁
if(zNode.endsWith(minNode)){
return true;
}
String lastNode = minNode;
for (String node : childrenNodes){
//如果临时节点不是第一个节点,那么监听前一个节点
if(zNode.endsWith(node)){
zooKeeper.exists("/"+businessCode+"/"+lastNode,true);
break;
}else {
lastNode = node;
}
}
//并发情况下wait方法让出锁,但是由于并发情景下,为了避免释放的时候错乱因此加上synchronized
synchronized (this){
wait();
}
//当被唤起
return true;
} catch (InterruptedException e) {
e.printStackTrace();
return false;
} catch (KeeperException e) {
e.printStackTrace();
return false;
}
}

@Override
public void close() throws Exception {
zooKeeper.delete(zNode,-1);
zooKeeper.close();
}

@Override
public void process(WatchedEvent event) {
//如果监听到节点被删除,那么则会通知下一个线程
if(event.getType() == Event.EventType.NodeDeleted){
synchronized (this){
notify();
}
}
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@Service
public class ZKLockService {
@Autowired
private DistributeLocker locker;
private String ORDER_KEY = "order_test";
public Integer createOrder() throws Exception{
log.info("进入了方法");
try {
if (locker.getLock(ORDER_KEY)) {
log.info("拿到了锁");
//此处为了手动演示并发,所以我们暂时在这里休眠
Thread.sleep(6000);
}
}catch (Exception e){
e.printStackTrace();
}finally {
locker.close();
}
log.info("方法执行完毕");
return 1;
}
}

curator客户端的使用

其实关于ZK锁的话还有可以用封装比较完善的客户端,那就是curator。这个客户端本身就已经实现了ZK的分布式锁,具体代码实现如下:

1
2
3
4
5
<dependency>
<groupId>org.apache.curator</groupId>
<artifactId>curator-recipes</artifactId>
<version>4.3.0</version>
</dependency>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
@Service
@Slf4j
public class CuratorLockService {
private String ORDER_KEY = "order_test";
@Autowired
private CuratorFramework client;
public Integer createOrder() throws Exception{
log.info("进入了方法");
InterProcessMutex lock = new InterProcessMutex(client, "/"+ORDER_KEY);
try {
if (lock.acquire(30, TimeUnit.SECONDS)) {
log.info("拿到了锁");
//此处为了手动演示并发,所以我们暂时在这里休眠6s
Thread.sleep(6000);
}
}catch (Exception e){
e.printStackTrace();
}finally {
try {
log.info("我释放了锁!!");
lock.release();
} catch (Exception e) {
e.printStackTrace();
}
}
log.info("方法执行完毕");
return 1;
}
}

分布式锁的对比

到此,我们将分布式系统的锁的解决方案都已经和大家分享过了,最终咱们来进行一个对比,具体如下:

看了上面这个比较之后,其实在我们的实际项目中,还是推荐现成的 curator 实现方式以及redisson实现方式,因为毕竟目前来说是相当成熟的方案,不推荐由我们自己的代码去实现。

1、安装gcc g++的依赖库

1
2
sudo apt-get install build-essential
sudo apt-get install libtool

2、安装pcre依赖库s

1
sudo apt-get install libpcre3 libpcre3-dev

3、安装zlib依赖库

1
sudo apt-get install zlib1g-dev

4、安装ssl依赖库

1
sudo apt-get install openssl libssl-dev

5下载安装包

1
wget http://nginx.org/download/nginx-1.19.6.tar.gz

6、解压

1
tar zxvf ./nginx-1.19.6.tar.gz

7、进入解压目录

1
cd nginx-1.19.6/ 

8、安装

1
2
3
./configure --prefix=/usr/local/nginx --with-http_stub_status_module --with-http_ssl_module 
sudo make
sudo make install

9、启动

1
/usr/local/nginx/sbin/nginx

常用Command

1
2
3
4
5
6
7
8
9
10
11
12
#启动nginx
start nginx
#修改配置后重新加载生效
nginx -s reload 
#重新打开日志文件
nginx -s reopen 
#测试nginx配置文件是否正确
nginx -t -c /path/to/nginx.conf
#快速停止nginx
nginx -s stop 
#完整有序的停止nginx
nginx -s quit 

第9步已经启动了。可以通过 ps ef|grep nginx可以看到有两个进程。主进程和工作进程。

默认配置文件(nginx.conf)在/usr/local/nginx/conf/nginx.conf

所以第九步的另一种启动方式是(指定配置文件启动) /usr/local/nginx/sbin/nginx -c /usr/local/nginx/conf/nginx.conf

由于默认配置文件路径是 /usr/local/nginx/conf/nginx.conf 所以第九步简写了

下载

国内:【 https://mirrors.cloud.tencent.com】

1
wget https://mirrors.cloud.tencent.com/apache/zookeeper/zookeeper-3.6.2/apache-zookeeper-3.6.2-bin.tar.gz

安装目录

1
2
3
4
5
6
7
8
9
10
11
mkdir /opt/zookeeper
cd /opt/zookeeper
tar -zxvf /opt/zookeeper-3.6.2.tar.gz
cp -rf /opt/zookeeper/zookeeper-3.6.2 /opt/zookeeper/zoo-node1
cp -rf /opt/zookeeper/zookeeper-3.6.2 /opt/zookeeper/zoo-node2
cp -rf /opt/zookeeper/zookeeper-3.6.2 /opt/zookeeper/zoo-node3
rm -rf /opt/zookeeper/zookeeper-3.6.2
# 这样,zookeeper的三个节点运行目录分别为:
/opt/zookeeper/zoo-node1
/opt/zookeeper/zoo-node2
/opt/zookeeper/zoo-node3

配置

zoo.cfg配置文件

zookeeper的运行,需要在conf目录配置zoo.cfg文件。我们可以参考zoo_sample.cfg进行配置。

节点1配置如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#zookeeper时间单元,单位为毫秒
tickTime=2000
#集群中的follower服务器(F)与leader服务器(L)之间 初始连接 时能容忍的最多心跳数(tickTime的数量)。
initLimit=10
# 集群中的follower服务器(F)与leader服务器(L)之间 请求和应答 之间能容忍的最多心跳数(tickTime的数量)。
syncLimit=5
# data数据目录
dataDir=/opt/zookeeper/zoo-node1/data
# 客户端连接端口
clientPort=2181
# 客户端最大连接数
#maxClientCnxns=60
# 需要保留的快照数目
#autopurge.snapRetainCount=3
# 是否开启自动清理事务日志和快照功能 0 不开启,1表示开启
#autopurge.purgeInterval=1
#集群配置
server.1=127.0.0.1:2888:3888
server.2=127.0.0.1:2889:3889
server.3=127.0.0.1:2890:3890

节点2配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#zookeeper时间单元,单位为毫秒
tickTime=2000
#集群中的follower服务器(F)与leader服务器(L)之间 初始连接 时能容忍的最多心跳数(tickTime的数量)。
initLimit=10
# 集群中的follower服务器(F)与leader服务器(L)之间 请求和应答 之间能容忍的最多心跳数(tickTime的数量)。
syncLimit=5
# data数据目录
dataDir=/opt/zookeeper/zoo-node2/data
# 客户端连接端口
clientPort=2182
# 客户端最大连接数
#maxClientCnxns=60
# 需要保留的快照数目
#autopurge.snapRetainCount=3
# 是否开启自动清理事务日志和快照功能 0 不开启,1表示开启
#autopurge.purgeInterval=1
#集群配置
server.1=127.0.0.1:2888:3888
server.2=127.0.0.1:2889:3889
server.3=127.0.0.1:2890:3890

节点3配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#zookeeper时间单元,单位为毫秒
tickTime=2000
#集群中的follower服务器(F)与leader服务器(L)之间 初始连接 时能容忍的最多心跳数(tickTime的数量)。
initLimit=10
# 集群中的follower服务器(F)与leader服务器(L)之间 请求和应答 之间能容忍的最多心跳数(tickTime的数量)。
syncLimit=5
# data数据目录
dataDir=/opt/zookeeper/zoo-node3/data
# 客户端连接端口
clientPort=2183
# 客户端最大连接数
#maxClientCnxns=60
# 需要保留的快照数目
#autopurge.snapRetainCount=3
# 是否开启自动清理事务日志和快照功能 0 不开启,1表示开启
#autopurge.purgeInterval=1
#集群配置
server.1=127.0.0.1:2888:3888
server.2=127.0.0.1:2889:3889
server.3=127.0.0.1:2890:3890

分别将上述配置文件放置在三个节点对应的conf目录下。另外,需要新建data目录

1
2
3
mkdir /opt/zookeeper/zoo-node1/data
mkdir /opt/zookeeper/zoo-node2/data
mkdir /opt/zookeeper/zoo-node3/data

myid配置文件

zookeeper还需要在data目录中配置myid文件。myid文件中存放对应节点的序号

1
2
3
echo '1'  >  /opt/zookeeper/zoo-node1/data/myid
echo '2' > /opt/zookeeper/zoo-node2/data/myid
echo '3' > /opt/zookeeper/zoo-node3/data/myid

日志路径配置

修改 bin 目录下的zkEnv.sh
将ZOO_LOG_DIR 修改为:ZOO_LOG_DIR="../logs/"

按上述方法分别修改三个节点中的zkEnv.sh文件

启动

1
2
3
/opt/zookeeper/zoo-node1/bin/zkServer.sh start
/opt/zookeeper/zoo-node2/bin/zkServer.sh start
/opt/zookeeper/zoo-node2/bin/zkServer.sh start

如此分别启动三个节点即可。
启动效果如下:

1
2
3
4
[root@localhost bin]# /opt/zookeeper/zoo-node1/bin/zkServer.sh start
ZooKeeper JMX enabled by default
Using config: /opt/zookeeper/zoo-node1/bin/../conf/zoo.cfg
Starting zookeeper ... STARTED

1
wget https://mirrors.tuna.tsinghua.edu.cn/apache/kafka/2.6.0/kafka_2.12-2.6.0.tgz
  1. 执行解压命令
1
2
tar -zxvf kafka_2.12-2.6.0.tgz -C /opt/kafka
mv kafka_2.12-2.6.0 kafka
  1. 复制配置修改server1.properties配置
1
2
3
4
cd /opt/kafka/config
cp config/server.properties config/server-1.properties
cp config/server.properties config/server-2.properties
cp config/server.properties config/server-3.properties

复制1的配置到2 3

1
2
3
mkdir -p /opt/kafka/data/kafka-logs-3
mkdir -p /opt/kafka/data/kafka-logs-2
mkdir -p /opt/kafka/data/kafka-logs-1
  1. 修改 每个server 的配置文件 server-x.properties
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#server-1.properties
broker.id=0
listeners=PLAINTEXT://localhost:9092
log.dirs=/opt/kafka/kafka-logs/kafka01
zookeeper.connect=localhost:2181,localhost:2182,localhost:2183
#server-2.properties
broker.id=1
listeners=PLAINTEXT://localhost:9093
log.dirs=/opt/kafka/kafka-logs/kafka02
zookeeper.connect=localhost:2181,localhost:2182,localhost:2183
#server-3.properties
broker.id=2
listeners=PLAINTEXT://localhost:9094
log.dirs=/opt/kafka/kafka-logs/kafka03
zookeeper.connect=localhost:2181,localhost:2182,localhost:2183
  1. 启动集群及测试
1
2
3
./kafka-server-start.sh -daemon ../config/server1.properties
./kafka-server-start.sh -daemon ../config/server2.properties
./kafka-server-start.sh -daemon ../config/server3.properties

注:如果单机伪集群内存不够,可以修改启动脚本,将红框内的内存改小些,默认为1G

vim ./bin/kafka-server-start.sh

img

如果在启动中报错,kafka会在安装目录下生成一个错误log,可以通过查看该文件排错直至启动

img

  1. 查看是否启动成功:
1
2
3
netstat -nltp | grep 9092
netstat -nltp | grep 9093
netstat -nltp | grep 9094

连接zookeeper测试

/usr/local/zookeeper/bin/zkCli.sh -server 127.0.0.1:2182,127.0.0.1:2183,127.0.0.1:2184

鉴于网上很多代码都是同一篇文章转载过来的基本验证无效,以下是经过项目测试后可用的方案

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
jar.doFirst {
manifest {
attributes 'Class-Path': configurations.compileClasspath.files.collect { "lib"+File.separator+"$it.name" }.join(' ')
attributes 'Main-Class': 'com.halower.dynamicdb.DynamicDatasourceLab'
}
}

task thinJar(type: Copy) {
def dir = new File( "$buildDir"+File.separator+"libs");
if(!dir.exists()) dir.mkdir()
dir.eachDir{d -> d.delete()}
from configurations.compileClasspath
into "$buildDir"+File.separator+"libs"+File.separator+"lib"
print('依赖包提取完成')
}

jar.enabled = true
bootJar.enabled = false


bootJar {
dependsOn thinJar
}

写在前面

可见性有序性,Happens-before来搞定 文章中,happens-before 的原则之一: volatile变量规则

对一个 volatile 域的写, happens-before 于任意后续对这个 volatile 域的读

按理说了解了这个规则,对 volatile 的使用就,,,,,,,,,已经足够了,但是面试官可是喜欢刨根问到底的,为了更透彻的了解 volatile 的内存语义与读写语义,为了面试多一些谈资进而获得一些加分项,同时尽早填补前序文章留下的坑,于是乎这篇文章就这样尴尬的诞生了

happens-before 之 volatile 变量规则

下面的表格你还记得吗?(是的,你记得😂)

能否重排序 第二个操作 第二个操作 第二个操作
第一个操作 普通读/写 volatile 读 volatile 写
普通读/写 - - NO
volatile 读 NO NO NO
volatile 写 - NO NO

上面的表格是 JMM 针对编译器定制的 volatile 重排序的规则,那 JMM 是怎样禁止重排序的呢?答案是内存屏障

内存屏障 (Memory Barriers / Fences)

无论你听过这个名词与否都没关系,很简单,且看

为了实现 volatile 的内存语义,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序

这句话有点抽象,试着想象内存屏障是一面高墙,如果两个变量之间有这个屏障,那么他们就不能互换位置(重排序)了,变量有读(Load)有写(Store),操作有前有后,JMM 就将内存屏障插入策略分为 4 种:

  1. 在每个 volatile 写操作的前面插入一个 StoreStore 屏障
  2. 在每个 volatile 写操作的后面插入一个 StoreLoad 屏障
  3. 在每个 volatile 读操作的后面插入一个 LoadLoad 屏障
  4. 在每个 volatile 读操作的后面插入一个 LoadStore 屏障

1 和 2 用图形描述以及对应表格规则就是下面这个样子了:

3 和 4 用图形描述以及对应表格规则就是下面这个样子了:

其实图形也是表格内容的体现,只不过告诉大家内存屏障是如何禁止指令重排序的,所以大家只要牢记表格内容即可

一段程序的读写通常不会像上面两种情况这样简单,这些屏障组合起来如何使用呢?其实一点都不难,我们只需要将这些指令带入到文章开头的表格中,然后再按照程序顺序拼接指令就好了

来看一小段程序:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class VolatileBarrierExample {

private int a;
private volatile int v1 = 1;
private volatile int v2 = 2;

void readAndWrite(){
int i = v1; //第一个volatile读
int j = v2; //第二个volatile读
a = i + j; //普通写
v1 = i + 1; //第一个volatile写
v2 = j * 2; //第二个volatile写
}
}

将屏障指令带入到程序就是这个样子:

我们将上图分几个角度来看:

  1. 彩色是将屏障指令带入到程序中生成的全部内容,也就是编译器生成的「最稳妥」的方案
  2. 显然有很多屏障是重复多余的,右侧虚线框指向的屏障是可以被「优化」删除掉的屏障

到这里你应该了解了 volatile 是如何通过内存屏障保证程序不被”擅自”排序的,那 volatile 是如何保证可见性的呢?

volatile 写-读的内存语义

回顾一下之前文章内容中的程序,假定线程 A 先执行 writer 方法,随后线程 B 执行 reader 方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class ReorderExample {

private int x = 0;
private int y = 1;
private volatile boolean flag = false;

public void writer(){
x = 42; //1
y = 50; //2
flag = true; //3
}

public void reader(){
if (flag){ //4
System.out.println("x:" + x); //5
System.out.println("y:" + y); //6
}
}
}

到这里你是否还记得之前说过的 JMM,是的,你还记得😂,当线程 A 执行 writer 方法时,且看下图:

线程 A 将本地内存更改的变量写回到主内存中

volatile 读的内存语义:

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

所以当线程 B 执行 reader 方法时,图形结构就变成了这个样子:

线程 B 本地内存变量无效,从主内存中读取变量到本地内存中,也就得到了线程 A 更改后的结果,这就是 volatile 是如何保证可见性的

如果你看过前面的文章你就不难理解上面的两张图了,综合起来说:

  1. 线程 A 写一个volatile变量, 实质上是线程 A 向接下来将要读这个 volatile 变量的某个线程发出了(其对共享变量所做修改的)消息
  2. 线程 B 读一个 volatile 变量,实质上是线程 B 接收了之前某个线程发出的(在写这个 volatile 变量之前对共享变量所做修改的)消息。
  3. 线程 A 写一个 volatile 变量, 随后线程 B 读这个 volatile 变量, 这个过程实质上是线程 A 通过主内存向线程B 发送消息。

到这里,面试 volatile 时,你应该有一些谈资了,同时也对 volatile 的语义有了更深层次的了解

彩蛋

之前的文章提到过这样一句话:

从内存语义的角度来说, volatile 的写-读与锁的释放-获取有相同的内存效果;volatile 写和锁的释放有相同的内存语义; volatile 读与锁的获取有相同的内存语义

记住文中最后两张图, 当我们说到 synchronized 的时候,你就会猛的理解这句话的含义了, 感兴趣的可以自己先了解 synchronized 的写-读语义

接下来我们就聊一聊锁相关的内容了,敬请期待…

灵魂追问

  1. 如果 volatile 写之后直接 return,那还会生成 StoreLoad 指令吗?
  2. synchronized 是怎样逐步被优化的?

上一篇文章 可见性有序性,Happens-before来搞定,解决了并发三大问题中的两个,今天我们就聊聊如何解决原子性问题

img

原子性问题的源头就是 线程切换,但在多核 CPU 的大背景下,不允许线程切换是不可能的,正所谓「魔高一尺,道高一丈」,新规矩来了:

互斥: 同一时刻只有一个线程执行

实际上,上面这句话的意思是: 对共享变量的修改是互斥的,也就是说线程 A 修改共享变量时其他线程不能修改,这就不存在操作被打断的问题了,那么如何实现互斥呢?

对并发有所了解的小伙伴马上就能想到 这个概念,并且你的第一反应很可能就是使用 synchronized,这里列出来你常见的 synchronized 的三种用法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class ThreeSync {

private static final Object object = new Object();

public synchronized void normalSyncMethod(){
//临界区
}

public static synchronized void staticSyncMethod(){
//临界区
}

public void syncBlockMethod(){
synchronized (object){
//临界区
}
}
}

三种 synchronized 锁的内容有一些差别:

  • 对于普通同步方法,锁的是当前实例对象,通常指 this
  • 对于静态同步方法,锁的是当前类的 Class 对象,如 ThreeSync.class
  • 对于同步方法块,锁的是 synchronized 括号内的对象

我特意在三种 synchronized 代码里面添加了「临界区」字样的注释,那什么是临界区呢?

临界区: 我们把需要互斥执行的代码看成为临界区

说到这里,和大家串的知识都是表层认知,如何用锁保护有效的临界区才是关键,这直接关系到你是否会写出并发的 bug,了解过本章内容后,你会发现无论是隐式锁/内置锁 (synchronized) 还是显示锁 (Lock) 的使用都是在找寻这种关系,关系对了,一切就对了,且看

上面锁的三种方式都可以用下图来表达:

线程进入临界区之前,尝试加锁 lock(), 加锁成功,则进入临界区(对共享变量进行修改),持有锁的线程执行完临界区代码后,执行 unlock(),释放锁。针对这个模型,大家经常用抢占厕所坑位来形容:

在学习 Java 早期我就是这样记忆与理解锁的,但落实到代码上,我们很容易忽略两点:

  1. 我们锁的是什么?
  2. 我们保护的又是什么?

将这两句话联合起来就是你的锁能否对临界区的资源起到保护的作用?所以我们要将上面的模型进一步细化

现实中,我们都知道自己的锁来锁自己需要保护的东西 ,这句话翻译成你的行动语言之后你已经明确知道了:

  1. 你锁的是什么
  2. 你保护的资源是什么

CPU 可不像我们大脑这么智能,我们要明确说明我们锁的是什么,我们要保护的资源是什么,它才会用锁保护我们想要保护的资源(共享变量)

拿上图来说,资源 R (共享变量) 就是我们要保护的资源,所以我们就要创建资源 R 的锁来保护资源 R,细心的朋友可能发现上图几个问题:

LR 和 R 之间有明确的指向关系
我们编写程序时,往往脑子中的模型是对的,但是忽略了这个指向关系,导致自己的锁不能起到保护资源 R 的作用(用别人家的锁保护自己家的东西或用自己家的锁保护别人家的东西),最终引发并发 bug,所以在你勾画草图时,要明确找到这个关系

左图 LR 虚线指向了非共享变量
我们写程序的时候很容易这么做,不确定哪个是要保护的资源,直接大杂烩,用 LR 将要保护的资源 R 和没必要保护的非共享变量一起保护起来了,举两个例子来说你就明白这么做的坏处了

  1. 编写串行程序时,是不建议 try…catch 整个方法的,这样如果出现问是很难定位的,道理一样,我们要用锁精确的锁住我们要保护的资源就够了,其他无意义的资源是不要锁的
  2. 锁保护的东西越多,临界区就越大,一个线程从走入临界区到走出临界区的时间就越长,这就让其他线程等待的时间越久,这样并发的效率就有所下降,其实这是涉及到锁粒度的问题,后续也都会做相关说明

作为程序猿还是简单拿代码说明一下心里比较踏实,且看:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class ValidLock {

private static final Object object = new Object();

private int count;

public synchronized void badSync(){
//其他与共享变量count无关的业务逻辑
count++;
}

public void goodSync(){
//其他与共享变量count无关的业务逻辑
synchronized (object){
count++;
}
}
}

这里并不是说 synchronized 放在方法上不好,只是提醒大家用合适的锁的粒度才会更高效

在计数器程序例子中,我们会经常这么写:

1
2
3
4
5
6
7
8
9
10
11
12
public class SafeCounter {

private int count;

public synchronized void counter(){
count++;
}

public synchronized int getCount(){
return count;
}
}

下图就是上面程序的模型展示:

这里我们锁的是 this,可以保护 this.count。但有些同学认为 getCount 方法没必要加 synchronized 关键字,因为是读的操作,不会对共享变量做修改,如果不加上 synchronized 关键字,就违背了我们上一篇文章 happens-before 规则中的监视器锁规则:

对一个锁的解锁 happens-before 于随后对这个锁的加锁
也就是说对 count 的写很可能对 count 的读不可见,也就导致脏读

上面我们看到一个 this 锁是可以保护多个资源的,那用多个不同的锁保护一个资源可以吗?来看一段程序:

1
2
3
4
5
6
7
8
9
10
11
12
public class UnsafeCounter {

private static int count;

public synchronized void counter(){
count++;
}

public static synchronized int calc(){
return count++;
}
}

睁大眼睛仔细看,一个锁的是 this,一个锁的是 UnsafeCounter.class, 他们都想保护共享变量 count,你觉得如何?下图就是行面程序的模型展示:

两个临界区是用两个不同的锁来保护的,所以临界区没有互斥关系,也就不能保护 count,所以这样加锁是无意义的

总结

  1. 解决原子性问题,就是要互斥,就是要保证中间状态对外不可见
  2. 锁是解决原子性问题的关键,明确知道我们锁的是什么,要保护的资源是什么,更重要的要知道你的锁能否保护这个受保护的资源(图中的箭头指向)
  3. 有效的临界区是一个入口和一个出口,多个临界区保护一个资源,也就是一个资源有多个并行的入口和多个出口,这就没有起到互斥的保护作用,临界区形同虚设
  4. 锁自己家门能保护资源就没必要锁整个小区,如果锁了整个小区,这严重影响其他业主的活动(锁粒度的问题)

本文以 synchronized 锁举例来说明如何解决原子性问题,主要是帮助大家建立宏观的理念,用于解决原子性问题,这样后续你看到无论什么锁,只要脑海中回想起本节说明的模型,你会发现都是换汤不换药,学习起来就非常轻松了.

到这里并发的三大问题 有序性,可见性,原子性都有了解决方案,这是远看并发,让大家有了宏观的概念;但面试和实战都是讲求细节的,接下来我们由远及近,逐步看并发的细节,顺带说明那些面试官经常会问到的问题

灵魂追问

  1. 多个锁锁一个资源一定会有问题吗?
  2. 什么时候需要锁小区,而不能锁某一户呢?
  3. 银行转账,两人互转和别人给自己转,用什么样的锁粒度合适呢?

写在前面

上一篇文章谈到了可见性/原子性/有序性三个问题,这些问题通常违背我们的直觉和思考模式,也就导致了很多并发 Bug

  • 为了解决 CPU,内存,IO 的短板,增加了缓存,但这导致了可见性问题
  • 编译器/处理器擅自优化 ( Java代码在编译后会变成 Java 字节码, 字节码被类加载器加载到 JVM 里, JVM执行字节码, 最终需要转化为汇编指令在 CPU 上执行) ,导致有序性问题

初衷是好的,但引发了新问题,最有效的办法就禁止缓存和编译优化,问题虽然能解决,但「又回到最初的起点,呆呆地站在镜子前」是很尴尬的,我们程序的性能就堪忧了.

解决方案

  1. 作为我们程序猿不想写出 bug 影响 KPI,所以希望内存模型易于理解、易于编程。这就需要基于一个强内存模型来编写代码
  2. 作为编译器和处理器不想让外人说它处理速度很慢,所以希望内存模型对他们束缚越少越好,可以由他们擅自优化,这就需要基于一个弱内存模型

俗话说:「没有什么事是开会解决不了的,如果有,那就再开一次」😂

JSR-133 的专家们就有了新想法,既然不能完全禁止缓存和编译优化,那就按需禁用缓存和编译优化,按需就是要加一些约束,约束中就包括了上一篇文章简单提到过的 volatile,synchronized,final 三个关键字,同时还有你可能听过的 Happens-Before 原则(包含可见性和有序性的约束),Happens-before 规则也是本章的主要内容

为了满足二者的强烈需求,照顾到双方的情绪,于是乎: JMM 就对程序猿说了一个善意的谎言: 「会严格遵守 Happpen-Befores 规则,不会重排序」让程序猿放心,私下却有自己的策略:

  1. 对于会改变程序执行结果的重排序,JMM要求编译器和处理器必须禁止这种重排序。
  2. 对于不会改变程序执行结果的重排序, JMM对编译器和处理器不做要求 (JMM允许这种重排序)。

我们来用个图说明一下:

这就是那个善意的谎言,虽是谎言,但还是照顾到了程序猿的利益,所以我们只需要了解 happens-before 规则就能得到保证 (图画了好久,不知道是否说明了谎言的所在😅,欢迎留言)

Happens-before

Happens-before 规则主要用来约束两个操作,两个操作之间具有 happens-before 关系, 并不意味着前一个操作必须要在后一个操作之前执行,happens-before 仅仅要求前一个操作(执行的结果)对后一个操作可见, (the first is visible to and ordered before the second)

说了这么多,先来看一小段代码带你逐步走进 Happen-Befores 原则,看看是怎样用该原则解决 可见性有序性 的问题:

1
2
3
4
5
6
7
8
9
10
11
12
13
class ReorderExample {
int x = 0;
boolean flag = false;
public void writer() {
x = 42; //1
flag = true; //2
}
public void reader() {
if (flag) { //3
System.out.println(x); //4
}
}
}

假设 A 线程执行 writer 方法,B 线程执行 reader 方法,打印出来的 x 可能会是 0,上一篇文章说明过: 因为代码 1 和 2 没有数据依赖关系,所以可能被重排序

1
2
flag = true;    //2
x = 42; //1

所以,线程 A 将 flag = true 写入但没有为 x 重新赋值时,线程 B 可能就已经打印了 x 是 0

那么为 flag 加上 volatile 关键字试一下:

1
volatile boolean flag = false;

即便加上了 volatile 关键字,这个问题在 java1.5 之前还是没有解决,但 java1.5 和其之后的版本对 volatile 语义做了增强,问题得以解决,这就离不开 Happens-before 规则的约束了,总共有 6 个规则,且看

程序顺序性规则

一个线程中的每个操作, happens-before 于该线程中的任意后续操作
第一感觉这个原则是一个在理想状态下的”废话”,并且和上面提到的会出现重排序的情况是矛盾的,注意这里是一个线程中的操作,其实隐含了「as-if-serial」语义: 说白了就是只要执行结果不被改变,无论怎么”排序”,都是对的

这个规则是一个基础规则,happens-before 是多线程的规则,所以要和其他规则约束在一起才能体现出它的顺序性,别着急,继续向下看

volatile变量规则

对一个 volatile 域的写, happens-before 于任意后续对这个 volatile 域的读

我将上面的程序添加两行代码作说明:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class ReorderExample {

private int x = 0;
private int y = 1;
private volatile boolean flag = false;

public void writer(){
x = 42; //1
y = 50; //2
flag = true; //3
}

public void reader(){
if (flag){ //4
System.out.println("x:" + x); //5
System.out.println("y:" + y); //6
}
}
}

这里涉及到了 volatile 的内存增强语义,先来看个表格:

能否重排序 第二个操作 第二个操作 第二个操作
第一个操作 普通读/写 volatile 读 volatile 写
普通读/写 - - NO
volatile 读 NO NO NO
volatile 写 - NO NO

从这个表格 最后一列 可以看出:

如果第二个操作为 volatile 写,不管第一个操作是什么,都不能重排序,这就确保了 volatile 写之前的操作不会被重排序到 volatile 写之后
拿上面的代码来说,代码 1 和 2 不会被重排序到代码 3 的后面,但代码 1 和 2 可能被重排序 (没有依赖也不会影响到执行结果),说到这里和 程序顺序性规则是不是就已经关联起来了呢?

从这个表格的 倒数第二行 可以看出:

如果第一个操作为 volatile 读,不管第二个操作是什么,都不能重排序,这确保了 volatile 读之后的操作不会被重排序到 volatile 读之前
拿上面的代码来说,代码 4 是读取 volatile 变量,代码 5 和 6 不会被重排序到代码 4 之前

volatile 内存语义的实现是应用到了 「内存屏障」,因为这完全够单独写一章的内容,这里为了不掩盖主角 Happens-before 的光环,保持理解 Happens-before 的连续性,先不做过多说明

到这里,看这个规则,貌似也没解决啥问题,因为它还要联合第三个规则才起作用

传递性规则

如果 A happens-before B, 且 B happens-before C, 那么 A happens-before C
直接上图说明一下上面的例子

从上图可以看出

  • x =42y = 50 Happens-before flag = true, 这是规则 1
  • 写变量(代码 3) flag=true Happens-before 读变量(代码 4) if(flag),这是规则 2

根据规则 3传递性规则,x =42 Happens-before 读变量 if(flag)

谜案要揭晓了: 如果线程 B 读到了 flag 是 true,那么 x =42y = 50 对线程 B 就一定可见了,这就是 Java1.5 的增强 (之前版本是可以普通变量写和 volatile 变量写的重排序的)

通常上面三个规则是一种联合约束,到这里你懂了吗?规则还没完,继续看

监视器锁规则

对一个锁的解锁 happens-before 于随后对这个锁的加锁

这个规则我觉得你应该最熟悉了,就是解释 synchronized 关键字的,来看

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class SynchronizedExample {
private int x = 0;

public void syncBlock(){
// 1.加锁
synchronized (SynchronizedExample.class){
x = 1; // 对x赋值
}
// 3.解锁
}

// 1.加锁
public synchronized void syncMethod(){
x = 2; // 对x赋值
}
// 3. 解锁
}

先获取锁的线程,对 x 赋值之后释放锁,另外一个再获取锁,一定能看到对 x 赋值的改动,就是这么简单,请小伙伴用下面命令查看上面程序,看同步块和同步方法被转换成汇编指令有何不同?

1
javap -c -v SynchronizedExample

这和 synchronized 的语义相关,小伙伴可以先自行了解一下,锁的内容时会做详细说明

start()规则

如果线程 A 执行操作 ThreadB.start() (启动线程B), 那么 A 线程的 ThreadB.start() 操作 happens-before 于线程 B 中的任意操作,也就是说,主线程 A 启动子线程 B 后,子线程 B 能看到主线程在启动子线程 B 前的操作,看个程序就秒懂了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public class StartExample {
private int x = 0;
private int y = 1;
private boolean flag = false;

public static void main(String[] args) throws InterruptedException {
StartExample startExample = new StartExample();

Thread thread1 = new Thread(startExample::writer, "线程1");
startExample.x = 10;
startExample.y = 20;
startExample.flag = true;

thread1.start();

System.out.println("主线程结束");
}

public void writer(){
System.out.println("x:" + x );
System.out.println("y:" + y );
System.out.println("flag:" + flag );
}
}

运行结果:

1
2
3
4
5
6
主线程结束
x:10
y:20
flag:true

Process finished with exit code 0

线程 1 看到了主线程调用 thread1.start() 之前的所有赋值结果,这里没有打印「主线程结束」,你知道为什么吗?这个守护线程知识有关系

join()规则

如果线程 A 执行操作 ThreadB.join() 并成功返回, 那么线程 B 中的任意操作 happens-before 于线程 A 从 ThreadB.join() 操作成功返回,和 start 规则刚好相反,主线程 A 等待子线程 B 完成,当子线程 B 完成后,主线程能够看到子线程 B 的赋值操作,将程序做个小改动,你也会秒懂的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
public class JoinExample {
private int x = 0;
private int y = 1;
private boolean flag = false;

public static void main(String[] args) throws InterruptedException {
JoinExample joinExample = new JoinExample();

Thread thread1 = new Thread(joinExample::writer, "线程1");
thread1.start();

thread1.join();

System.out.println("x:" + joinExample.x );
System.out.println("y:" + joinExample.y );
System.out.println("flag:" + joinExample.flag );
System.out.println("主线程结束");
}

public void writer(){
this.x = 100;
this.y = 200;
this.flag = true;
}
}

运行结果:

1
2
3
4
5
6
复制x:100
y:200
flag:true
主线程结束

Process finished with exit code 0

「主线程结束」这几个字打印出来喽,依旧和线程何时退出有关系

总结

  1. Happens-before 重点是解决前一个操作结果对后一个操作可见,相信到这里,你已经对 Happens-before 规则有所了解,这些规则解决了多线程编程的可见性与有序性问题,但还没有完全解决原子性问题(除了 synchronized)
  2. start 和 join 规则也是解决主线程与子线程通信的方式之一
  3. 从内存语义的角度来说, volatile 的写-读与锁的释放-获取有相同的内存效果;volatile 写和锁的释放有相同的内存语义; volatile 读与锁的获取有相同的内存语义,⚠️⚠️⚠️(敲黑板了) volatile 解决的是可见性问题,synchronized 解决的是原子性问题,这绝对不是一回事,后续文章也会说明

灵魂追问

  1. 同步块和同步方法在编译成 CPU 指令后有什么不同?
  2. 线程有 Daemon(守护线程)和非 Daemon 线程,你知道线程的退出策略吗?
  3. 关于 Happens-before 你还有哪些疑惑呢?

写在前面

  • 生活中你一定听说过——能者多劳
  • 作为 Java 程序员,你一定听过——这个功能请求慢,能加一层缓存或优化一下 SQL 吗?
  • 看过中国古代神话故事的也一定听过——天上一天,地上一年

一切设计来源于生活,上一章 中有讲过,作为”资本家”,你要尽可能的榨取 CPU,内存与 IO 的剩余价值,但三者完成任务的速度相差很大,CPU > 内存 > IO,CPU 是天,那内存就是地,内存是天,那 IO 就是地,那怎样平衡三者,提升整体速度呢?

  1. CPU 增加缓存,还不止一层缓存,平衡内存的慢
  2. CPU 能者多劳,通过分时复用,平衡 IO 的速度差异
  3. 优化编译指令

上面的方式貌似解决了木桶短板问题,但同时这种解决方案也伴随着产生新的可见性,原子性,和有序性的问题,且看

可见性

一个线程对共享变量的修改,另外一个线程能够立刻看到,我们称为可见性

谈到可见性,要先引出 JMM (Java Memory Model) 概念, 即 Java 内存模型,Java 内存模型规定,将所有的变量都存放在 主内存 中,当线程使用变量时,会把主内存里面的变量 复制 到自己的工作空间或者叫作 私有内存 ,线程读写变量时操作的是自己工作内存中的变量。

用 Git 的工作流程理解上面的描述就很简单了,Git 远程仓库就是主内存,Git 本地仓库就是自己的工作内存

文字描述有些抽象,我们来图解说明:

看这个场景:

  1. 主内存中有变量 x,初始值为 0
  2. 线程 A 要将 x 加 1,先将 x=0 拷贝到自己的私有内存中,然后更新 x 的值
  3. 线程 A 将更新后的 x 值回刷到主内存的时间是不固定的
  4. 刚好在线程 A 没有回刷 x 到主内存时,线程 B 同样从主内存中读取 x,此时为 0,和线程 A 一样的操作,最后期盼的 x=2 就会编程 x=1

这就是线程可见性的问题

JMM 是一个抽象的概念,在实际实现中,线程的工作内存是这样的:

为了平衡内存/IO 短板,会在 CPU 上增加缓存,每个核都只有自己的一级缓存,甚至有一个所有 CPU 都共享的二级缓存,就是上图的样子了,都说这么设计是硬件同学留给软件同学的一个坑,但能否跳过去这个坑也是衡量软件同学是否走向 Java 进阶的关键指标吧……

小提示

从上图中你也可以看出,在 Java 中,所有的实例域,静态域和数组元素都存储在堆内存中,堆内存在线程之间共享,这些在后续文章中都称之为「共享变量」,局部变量,方法定义参数和异常处理器参数不会在线程之间共享,所以他们不会有内存可见性的问题,也就不受内存模型的影响

一句话,要想解决多线程可见性问题,所有线程都必须要刷取主内存中的变量
怎么解决可见性问题呢?Java 关键字 volatile 帮你搞定,后续章节会分析……

原子性

原子(atom)指化学反应不可再分的基本微粒,原子性操作你应该能感受到其含义:

所谓原子操作是指不会被线程调度机制打断的操作;这种操作一旦开始,就一直运行到结束,中间不会有任何 context switch

小品「钟点工」有一句非常经典的台词,要把大象装冰箱,总共分几步?

来看一小段程序:

多线程情况下能得到我们期盼的 count = 20000 的值吗? 也许有同学会认为,线程调用的 counter 方法只有一个 count++ 操作,是单一操作,所以是原子性的,非也。在线程第一讲中说过我们不能用高级语言思维来理解 CPU 的处理方式,count++ 转换成 CPU 指令则需要三步,通过下面命令解析出汇编指令等信息:

1
javap -c UnsafeCounter

截取 counter 方法的汇编指令来看:

解释一下上面的指令,
16 : 获取当前 count 值,并且放入栈顶
19 : 将常量 1 放入栈顶
20 : 将当前栈顶中两个值相加,并把结果放入栈顶
21 : 把栈顶的结果再赋值给 count

由此可见,简单的 count++ 不是一步操作,被转换为汇编后就不具备原子性了,就好比大象装冰箱,其实要分三步:

第一步,把冰箱门打开;第二步,把大象放进去;第三步,把冰箱门带上

结合 JMM 结构图理解,说明一下为什么很难得到 count=20000 的结果:

多线程计数器,如何保证多个操作的原子性呢?最粗暴的方式是在方法上加 synchronized 关键字,比如这样:

问题是解决了,如果 synchronized 是万能良方,那么也许并发就没那么多事了,可以靠一个 synchronized 走天下了,事实并不是这样,synchronized 是独占锁 (同一时间只能有一个线程可以调用),没有获取锁的线程会被阻塞;另外也会带来很多线程切换的上下文开销

所以 JDK 中就有了非阻塞 CAS (Compare and Swap) 算法实现的原子操作类 AtomicLong 等工具类,看过源码的同学也许会发现一个共同特点,所有原子类中都有下面这样一段代码:

1
private static final Unsafe unsafe = Unsafe.getUnsafe();

这个类是 JDK 的 rt.jar 包中的 Unsafe 类提供了 硬件级别 的原子性操作,类中的方法都是 native 修饰的,后面介绍原子类之前也会先说明这个类中的几个方法,这里先简单介绍有个印象即可。

有同学不理解我刚刚提到的线程上下文切换开销很大是什么意思,举 2个例子你就懂了:

  • 你(CPU)在看两本书(两个线程),看第一本书很短时间后要去看第二本书,看第二本书很短时间后又回看第一本书,并要精确的记得看到第几行,当初看到了什么(CPU 记住线程级别的信息),当让你 “同时” 看 10 本甚至更多,切换的开销就很大了吧
  • 综艺节目中有很多游戏,让你一边数钱,又要一边做其他的事,最终保证多样事情都做正确,大脑开销大不大,你试试就知道了😊

有序性

生活中你问候他人「吃了吗你?」和「你吃了吗?」是一个意思,你写的是下面程序:

1
2
3
4
a = 1
b = 2;
System.out.println(a);
System.out.println(b);

编译器优化后可能就变成了这样:

1
2
3
4
b =  2;
a = 1
System.out.println(a);
System.out.println(b);

这个情况,编译器调整了语句顺序没什么影响,但编译器 擅自 优化顺序,就给我们埋下了雷,比如应用双重检查方式实现的单例

一切又很完美是不是,非也,问题出现在 instance = new Singleton();,这 1 行代码转换成了 CPU 指令后又变成了 3 个,我们理解 new 对象应该是这样的:

  1. 分配一块内存 M
  2. 在内存 M 上初始化 Singleton 对象
  3. 然后 M 的地址赋值给 instance 变量

但编译器擅自优化后可能就变成了这样:

  1. 分配一块内存 M
  2. 然后将 M 的地址赋值给 instance 变量
  3. 在内存 M 上初始化 Singleton 对象

首先 new 对象分了三步,给 CPU 留下了切换线程的机会;另外,编译器优化后的顺序可能导致问题的发生,来看:

  1. 线程 A 先执行 getInstance 方法,当执行到指令 2 时,恰好发生了线程切换
  2. 线程 B 刚进入到 getInstance 方法,判断 if 语句 instance 是否为空
  3. 线程 A 已经将 M 的地址赋值给了 instance 变量,所以线程 B 认为 instance 不为空
  4. 线程 B 直接 return instance 变量
  5. CPU 切换回线程 A,线程 A 完成后续初始化内容

我们还是画个图说明一下:

如果线程 A 执行到第 2 步,线程切换,由于线程 A 没有把红色箭头执行完全,线程 B 就会得到一个未初始化完全的对象,访问 instance 成员变量的时候就可能发生 NPE,如果将变量 instance 用 volatile 或者 final 修饰(涉及到类的加载机制,问题就解决了.

总结

你所看到的程序并不一定是编译器优化/编译后的 CPU 指令,大象装冰箱是是个程序,但其隐含三个步骤,学习并发编程,你要按照 CPU 的思维考虑问题,所以你需要深刻理解 可见性/原子性/有序性 ,这是产生并发 Bug 的源头

本节说明了三个问题,下面的文章也会逐个分析解决以上问题的办法,以及相对优的方案

灵魂追问

  1. 为什么用 final 修饰的变量就是线程安全的了呢?
  2. 你会经常查看 CPU 汇编指令吗?
  3. 如果让你写单例,你通常会采用哪种实现?

「横看成岭侧成峰,远近高低各不同」,远看看轮廓,近看看细节,不断切换思维或视角来学习

并发编程可以抽象成三个核心问题: 分工、同步/协作、互斥

如果你已经工作了,那么你一定听说过或者正在应用敏捷开发模式来交付日常的工作任务,我们就用你熟悉的流程来解释这三个核心问题

分工

将当前 Sprint 的 Story 拆分成「合适」大小的 Task,并且安排给「合适」的 Team Member 去完成

这里面用了两个「合适」,将 Story 拆分成大小适中,可完成的 Task 是非常重要的。拆分的粒度太粗,导致这个任务完成难度变高,耗时长,不易与其他人配合;拆分的粒度太细,又导致任务太多,不好管理与追踪,浪费精力和资源。(合适的线程才能更好的完成整块工作,当然一个线程可以轻松搞定的就没必要多线程);安排给合适的人员去完成同样重要,UX-UE 问题交给后端人员处理,很显然是有问题的 (主线程应该做的事交给子线程显然是解决不了问题的,每个线程做正确的事才能发挥作用)

关于分工,常见的 Executor,生产者-消费者模式,Fork/Join 等,这都是分工思想的体现

同步/协作

任务拆分完毕,我要等张三的任务,张三要等李四的任务,也就是说任务之间存在依赖关系,前面的任务执行完毕,后面的任务才可以执行,人高级在可以通过沟通反复确认,确保自己的任务可以开始执行。但面对程序,我们需要了解程序的沟通方式,一个线程执行完任务,如何通知后续线程执行

所有的同步/协作关系我们都可以用你最熟悉的 If-then-else 来表示:

1
2
3
4
5
if(前序任务完成){
execute();
}else{
wait();
}

上面的代码就是说:当某个条件不满足时,线程需要等待;当某个条件满足时,线程需要被唤醒执行,线程之间的协作可能是主线程与子线程的协作,可能是子线程与子线程的合作, Java SDK CountDownLatchCyclicBarrier 就是用来解决线程协作问题的

互斥

分工和同步强调的是性能,但是互斥是强调正确性,就是我们常常提到的「线程安全」,当多个线程同时访问一个共享变量/成员变量时,就可能发生不确定性,造成不确定性主要是有可见性原子性有序性这三大问题,而解决这些问题的核心就是互斥

互斥

同一时刻,只允许一个线程访问共享变量

来看下图,主干路就是共享变量,进入主干路一次只能有一辆车,这样你是否理解了呢?「天下大事,分久必合

同样 Java SDK 也有很多互斥的解决方案,比如你马上就能想到 synchronized 关键字,LockThreadLocal 等就是互斥的解决方案

总结

资本家疯狂榨取劳动工人的剩余价值,获得最大收益。当你面对 CPU,内存,IO 这些劳动工人时,你就是那个资本家,你要思考如何充分榨取它们的价值

当一个工人能干的活,绝不让两个人来干(单线程能满足就没必要为了多线程)
当多个工人干活时,就要让他们分工明确,合作顺畅,没矛盾

当任务很大时,由于 IO 干活慢,CPU 干活快,就没必要让 CPU 死等当前的 IO,转而去执行其他指令,这就是榨取剩余价值,如何最大限度的榨取其价值,这就涉及到后续的调优问题,比如多少线程合适等

分工是设计,同步和互斥是实现,没有好的设计也就没有好的实现,所以在分工阶段,强烈建议大家勾划草图,了解瓶颈所在,这样才会有更好的实现,后续章节的内容,我也会带领大家画草图,分析问题,逐步养成这个习惯

本章内容可以用下面的图来简单概括,叶子结点的内容我们会逐步点亮,现阶段不用过分关注(如果你上来就啃 JDK 源码,也许你会痛苦的迷失,并最终放弃你的进阶之路的)

理解三大核心问题,你要充分结合生活中的实际,程序中的并发问题,基本上都能在实际生活中找得到原型

下一篇文章的内容,我们就要聊聊,引起线程安全的三个问题:「可见性,原子性,有序性」,这涉及到 JMM 的一点内容,可以提前了解一下的,这样我们才能更好的碰撞

灵魂追问

  1. 工作中多线程编程的场景多吗?
  2. 想到多线程,只会想到 synchronized 吗?
  3. Java 并发包各个类,你有了解底层实现和设计理念吗?