LJ的Blog

学海无涯苦做舟

0%

浅析并发

写在前面

最近因为自己毕业的一些事情断更了简书,一转眼已经有两个月了,是时候给自己充一波电了。本篇主要 复习 总结一些多线程中的基础知识,开篇打算理清一些概念性的东西,如有理解错误的地方,欢迎各位指正。

什么是并发,为什么要用并发?

并发与并行是一对相似而又有区别的的两个概念。并行是指两个或多个事件在同一时刻发生,只有在多CPU环境下才有可能发生。并发是指在一段时间内宏观上有多个程序在同时运行,但实际上每个程序只是在CPU分配的时间片内运行,每一时刻也只能由一道程序执行。

使用并发编程的目的是为了让程序运行的更快,但是,并不是启动更多的线程就能让程序运行的更快,这取决于代码的质量和应用场景。抛开并发代码的质量不谈,如果应用场景不得当,并发也不一定比串行的程序快,因为线程有创建和上下文切换的开销。这里不再深入,如果感兴趣可以《Java并发编程艺术》第一章中找到例子。

Thread和一些问题

首先上一段最基本的对于线程的使用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class ThreadTest {
public static void main(String... args){
new Thread(()-> System.out.println(Thread.currentThread().getName()))
.start();

new Thread(new Runnable() {
@Override
public void run() {
System.out.println(Thread.currentThread().getName());
}
}).start();
}
}
// 输出:
// Thread-0
// Thread-1

上面的例子创建了两个线程,分别打印了两条线程的名字。Thread的构造函数可以接受一个Runable参数,这个Runnable主要就是执行用户任务的代码。代码很简单,没什么好多说的。接下来还是来看一段代码和输出结果,来引入并发可能引发的问题。

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
public class SaleTicket implements Runnable {
public static final int TICKET_TOTAL = 100;
private int tickets = 100;

@Override
public void run() {
while (tickets > 0) {
try {
sale();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}

public void sale() throws InterruptedException {
if (tickets > 0) {
tickets--;
System.out.println(Thread.currentThread().getName() +
"卖出 第" + (TICKET_TOTAL - tickets) + "张票");
Thread.sleep(100);
}
}

public static void main(String... args) {
SaleTicket saleTicket = new SaleTicket();
new Thread(saleTicket, "一号窗口").start();
new Thread(saleTicket, "二号窗口").start();
new Thread(saleTicket, "三号窗口").start();

}
}

卖票.png

代码比较简单,模拟卖票,只要有余票就可以继续卖。可以看到当我使用三个线程模拟三个窗口卖票时出现了一个奇怪的现象,就是有一个时刻三个窗口都卖出了第八张票,这显然不符合正常的预期。聪明的你肯定能想到是我代码写的不对,的确,我的代码并不是线程安全的代码。关于线程安全,有很多定义,在维基百科上是这么说的:指某个函数函数库多线程环境中被调用时,能够正确地处理多个线程之间的共享变量,使程序功能正确完成。非常正确,然而看完什么信息也没得到的定义……不过没事,现在就算放一个信息量巨大的定义给你,你也看不懂,先了解下就好。

接下来需要思考一些问题,为什么上面的代码会不安全?这个锅我们可以轻易的丢给多线程、并发,“因为多线程并发访问修改tickets变量,导致结果不可预期”,这么说也没错,不过太笼统了。这段代码是在我的电脑上跑的,我的电脑只有一个CPU,虽说是并发,但是到CPU层面上,也是串行执行的,那么为什么会出现上面图片中奇怪的情况呢?

  • 在Java中线程有自己的工作内存,工作内存中保存了被该线程使用到的变量的主内存副本,所以对该副本的操作并不能直接影响到主内存,还需要将这个操作的结果同步到主内存中,其他线程才能“感知到”。

  • cpu在执行指令的过程中很有由于这个线程时间片耗尽而切换线程。虽然tickets–只是一行代码,但是,** 他并非原子操作 **。所以很有可能出现的情况就是这个操作虽然让这个线程里的i减了1,但是还没有来得及同步到主内存中,CPU便切换线程导致下一个线程中的i还是未减1的值。

上面两点的信息量还是比较大的,要理解上面两点,首先得了解一下Java的内存模型。

Java内存模型

关于Java的内存模型,我只会简单的介绍一下,不会当然暂时也没那个能力深入……

计算机的存储设备与处理器的运算速度有几个数量级的差距,所以现代计算机系统都不得不加入一层读写速度尽可能接近处理器运算速度的高速缓存来作为内存与处理器之间的缓冲。基于高速缓存的存储交互很好的解决了处理器与内存的速度矛盾,但是也为计算机系统带来了更高的复杂度,因为它引入了一个新的问题:缓存一致性。

在多处理器系统中,每个处理器的运算任务都有自己的高速缓存,而它们又共享同一块主内存。当多个处理器的运算任务都涉及同一块主内存区域时,将可能导致各自的缓存数据不一致,如果真的发生这种情况,那同步回到主内存时以谁为准?

所以就出现了缓存一致性协议。最出名的就是Intel 的MESI协议,MESI协议保证了每个缓存中使用的共享变量的副本是一致的。它核心的思想是:当CPU写数据时,如果发现操作的变量是共享变量,即在其他CPU中也存在该变量的副本,会发出信号通知其他CPU将该变量的缓存行置为无效状态,因此当其他CPU需要读取这个变量时,发现自己缓存中缓存该变量的缓存行是无效的,那么它就会从内存重新读取。

图

原子操作与volatile关键字

之前提到i–并不是原子操作,下面一段代码和输出可以证明i++并非原子操作:

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 AtomicTest implements Runnable {
private int serialNum = 0;

public int getSerialNum() {
return serialNum++;
}


@Override
public void run() {
try {
Thread.sleep(200);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName() + ":" + getSerialNum());
}

public static void main(String... args){
AtomicTest atomicTest = new AtomicTest();
for (int i = 0; i < 10; i++) {
new Thread(atomicTest).start();
}
}
}

输出
这里的输出出现了同样的serialNum,这说明在A线程进行serialNum++的读改操作,而尚未写入时,另一个线程B进行了读改操作,由此证明i++的读改写操作不具有原子性。

说了这么多原子性,还没有正式的说一下原子性的概念:原子操作是不可分割的,在执行完毕之前不会被任何其它任务或事件中断。

原子性对于并发来说非常有意义,涉及到线程安全就会考虑到他。对于线程安全还有两点非常重要:可见性和有序性。

  • 可见性:可见性是指当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看到修改值。

关于可见性,之前简单介绍过Java内存模型结合我之前的例子就能理解,在买车票时,3个线程共享tickets变量,但由于每个线程都在自己的工作内存中操作变量,可能有没有及时同步到主内存的,所以有的线程没有读取到最新的值,导致操作和我们的预期不一样。

Java中,使用volatile关键字可以保证变量的“可见性”。但是使用volatile可以解决我上面卖车票的问题吗?答案是否定的,volatile可以解决变量可见性的问题,但是sale方法是非原子操作的问题还是存在的。volatile的另一个作用就是禁止指令重排序优化。那么什么是指令重排呢?

指令重排是指CPU采用了允许将多条指令不按程序规定的顺序分开发送给各相应电路单元处理。当然这种重排肯定是按照一定规则进行的,不然毫无规则的重排,也没谁能编程了。以下是一些处理器的重排规则:

重排规则

指令重排这里就不再深入的探讨了,有序性是指指令重排不会影响到单线程程序的执行,但多线程并发执行的正确性会受到影响。

在平时写非并发代码时我们从未考虑过指令重排这事,所以前半句我们可以根据自己的经验认为他是对的,那么后半句该怎么理解呢?在《深入理解Java虚拟机》是这么说的:如果在一个线程中观察另一个线程,所有的操作都是无序的。下面以一个简单的例子说明:

1
2
3
4
5
6
7
8
9
10
11

//线程1:
context = loadContext(); //语句1
inited = true; //语句2

//线程2:
while(!inited ){
sleep()
}

doSomethingwithconfig(context);

语句1和2没有数据依赖,是可以被重排的,如果发生了重排,对于线程1来说没关系,操作都是有序的。而对于线程2来说则可能是观测到inited为true,但是因为指令重排的关系,context还没有被初始化,而导致使用了错误的context。

综上,可知对于线程安全来说,需要综合考虑原子性、可见性以及有序性。在Java内存模型(Java Memory Model,简称JMM)中有一个很重要的概念:happens-before,一般翻译为“先行先发生”,如果一个动作happens-before另一个动作,则第一个动作对第二个动作可见,且第一个动作的执行顺序排在第二个操作之前。但是如果冲排序之后的执行结果与按happens-before关系来执行的结果一直,那么这种重排序并不非法(允许这种重排)。关于这事不难理解,作为应用开发者,我们并不关心底层到底怎么实现,只要保证程序运行时的语义不发生变化就可以了。happens-before是判断是否存在数据竞争、线程是否安全的主要依据。

在JSR133文档中介绍了以下几种包含了happens-before的操作/规则:

  • 一个线程中的每个操作,happens-before于该线程中的任意后续操作。
  • 对一个锁的解锁happens-before后续的加锁。
  • 对某个volatile字段的写操作happens-before任意后续对字段的读操作。
  • 在某个线程对象上调用start()方法happens-before该启动了的线程中的任意动作。
  • 如果A线程执行操作ThreadB.join()并成功返回,那么线程B中的任意操作happens-before于线程A从ThreadB.join()操作成功的返回。
  • 如果A happens-before B,且B happens-before C,那么A happens-before C。

关于happens-before也是可以大写特写的,但是我这里就不多写了,等我有更多的实践再来吹一番。

本次学习总结,感觉还是学习了不少东西,刚开始写的时候几乎坚持不下去,因为我发现基本上就只能写各种资料上的东西,也因为我几乎没有实践经验。不过怎么说呢,有些东西光阅读大量的资料,自己理解了多少其实还是未知,但是如果你能把这些写出来(不是无脑抄),那一定经过了自己的思考,可以吸收更多的东西。在Java中的并发还是比较复杂的,关于并发介绍的比较清楚的当属《Java并发编程的艺术》,如果有能力的话可以直接去阅读JSR133文档。我都没有读完,如果以后真的有使用的场景,我会更深入的去学习的。

参考资料