理解Java多线程

本文首发于“雨夜随笔”公众号,欢迎关注。

理解Java多线程,让我们从关键字volatile说起。

内存模式

了解过操作系统知识的知道,计算机的每条指令都是在CPU中执行。而执行的过程就涉及到数据的读取和写入。程序运行产生的临时数据当然都是保存在内存中,但是从内存中读取数据和往内存中写入数据的速度和CPU执行速度相比要慢的很多。所以为了提高运行效率,CPU中也就有了高速缓存。

这个意思就是说,当程序运行时,会将需要的数据从内存中拷贝到CPU中的高速缓存中,当CPU进行运算时将数据从高速缓存中读取和写入。运算结束后再将高速缓存中的数据刷新到内存中。

这个就涉及到多线程中可能出现的问题了,那就是每个线程都有自己的高速缓存。当读取数据进行分别计算后,最终刷新到内存中的数据可能并不是我们想象中的值。

i = i + 1

如果有两个线程执行这段代码,假设初始值是0,我们希望两个线程执行完后变成2.但是实际情况可能不是如此,存在一种情况:初始时,两个线程分别读取i的值存入自己的高速缓存中,然后线程1执行后,将最新值1刷新到内存中,然后线程2的高速缓存中i的值还是0,执行后将结果1刷新到内存中,最终结果是1,而不是2。

这个就是著名的缓存一致性问题。为了解决这个问题,人们试着提出很多方案,通常来说有两种方案:

1. 通过在总线加LOCK锁的方式

2. 通过缓存一致性协议(CCP)

而这两种是硬件产商采取的方案。但是总线加锁的方式会阻碍其他CPU访问内存,造成效率低下。而通过缓存一致性协议,虽然保存了一致性,但是无法保证实时性。

并发编程

并发编程中,需要保证三个事情:原子性,可见性和有序性。所以在并发编程中会出现这三个方面的问题。

原子性

原子性是指一个操作或者多个操作要么全部执行并且执行的过程不会被其他因素打断,要么就全部不执行。

所以在设计并发编程时,首先要考虑需要拆分成多线程的逻辑是不是原子性的,如果是的,就尽量不要设计成多线程。这一点我就犯了个错误,比如有一个操作是发信息给另一个服务,如果成功后把信息保存到数据库中。刚开始我觉得这两个可以分开,毕竟通过网络发送和存储信息到数据库中都是挺耗时的操作。但是其实这个想法是错误的,因为这个操作是原子性操作,如果发送不成功,那么就没有必要存储到数据库中。换言之,存储信息需要依赖网络发送的结果。两个要成功都成功,要失败就都失败。所以为了保证原子性,尽量不用多线程。

可见性

可见性是值多个线程访问同一个变量时,一个线程修改这个变量后,其他线程能够立即得到修改后的值。

上面说的 i=i+1 就存在这个问题,线程1修改后并不能保证线程2一定能够获取到修改后的值。

有序性

有序性是指程序执行的顺序按照代码的先后顺序执行。这点对于Java来说不是件容易的事情。因为JVM在真正执行代码的时候会进行指令重排序。因为为了提高程序运行效率,会对数据代码进行优化。虽然不保证语句的执行顺序和代码顺序一致,但是会保证最终的结果是预期的值。

而上面的保证只在单线程中实现,多线程访问时,准确性就有可能出错。

综上所述,并发编程想要保证程序正确执行,要同时保证原子性、可见性和有序性。

Java内存模型

Java虚拟机为了屏蔽硬件和操作系统的内存访问差异,达到程序在各种平台都能达到一致的内存访问效果,定义了一种Java内存模型(JMM)。

Java内存模型定义了程序变量的访问规则,所有的变量都是存在主存中,然后每个线程有自己的工作内存。线程对变量的所有操作都必须在自己的工作内存中进行,不能直接对主存进行操作,并且不能访问其他线程的工作内存。

那么Java是如何保证原子性、可见性和有序性呢?

原子性

在Java中,对基本数据类型的读取和赋值都是原子性的,这点由Java进行保证。而要实现更大规模的原子性操作,需要通过synchronized和Lock来进行实现。因为这两个关键词能够保证统一时刻只要一个线程访问执行代码块,那么就可以保证原子性。

可见性

这一点就说到了我们一开始提到的volatile关键字。Java提供了这个关键字以保证共享变量在修改时可以立即刷新到主存中,以便其他线程读取时可以获取最新值。

当然synchronized和Lock也可以保证可见性。

有序性

上面说到Java指令重排序会影响多线程的准确性,所以volatile也保证了一定的有序性,这就涉及了Java的排序规则:

程序次序规则:一个线程内,按照代码顺序,书写在前面的操作先行发生于书写在后面的操作

1. 程序次序规则:一个线程内,按照代码顺序,书写在前面的操作先行发生于书写在后面的操作

2. 锁定规则:一个unLock操作先行发生于后面对同一个锁额lock操作

3. volatile变量规则:对一个变量的写操作先行发生于后面对这个变量的读操作

4. 传递规则:如果操作A先行发生于操作B,而操作B又先行发生于操作C,则可以得出操作A先行发生于操作C

5. 线程启动规则:Thread对象的start()方法先行发生于此线程的每个一个动作

6. 线程中断规则:对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生

7. 线程终结规则:线程中所有的操作都先行发生于线程的终止检测,我们可以通过Thread.join()方法结束、Thread.isAlive()的返回值手段检测到线程已经终止执行

8. 对象终结规则:一个对象的初始化完成先行发生于他的finalize()方法的开始

这些规则来自《深入理解Java虚拟机》,主要是为了保证重排序不会对结果产生影响。

Volatile

那么就正式来说以volatile关键字,volatile能够保证两点:

1. 不同线程对变量操作的可见性,一个线程修改了某个变量的值后,其他线程能够立即可见。

2. 禁止对该代码进行指令重排序。

但是从上面的介绍来看,volatile并不能保证原子性,这是因为如果一个线程只是读取了变量,并没有修改,而其他线程修改变量并不能刷新该线程已经读取的变量。所以如果源操作不是原子性的,那使用volatile也无法保证是原子性。

实现方式

下面这段话摘自《深入理解Java虚拟机》:

“观察加入volatile关键字和没有加入volatile关键字时所生成的汇编代码发现,加入volatile关键字时,会多出一个lock前缀指令”

lock前缀指令实际上相当于一个内存屏障(也成内存栅栏),内存屏障会提供3个功能:

1)它确保指令重排序时不会把其后面的指令排到内存屏障之前的位置,也不会把前面的指令排到内存屏障的后面;即在执行到内存屏障这句指令时,在它前面的操作已经全部完成;

2)它会强制将对缓存的修改操作立即写入主存;

3)如果是写操作,它会导致其他CPU中对应的缓存行无效。

总结

Java多线程编程时,要时时考虑保证原子性、可见性和有序性。能够保证这三个,那么多线程的很多问题都可以得到化解。而volatile关键字的使用可以在某些场景中避免直接使用synchronized和Lock带来的效率问题。

参考:

https://www.cnblogs.com/dolphin0520/p/3920373.html

https://zhuanlan.zhihu.com/p/29138099



你可能感兴趣的:(理解Java多线程)