什么是线程安全性

当多个线程访问某个类时,这个类始终都能表现出正确的行为,那么就称这个类是线程安全的。 《Java 并发编程实战》[P13]

解释:当多个线程访问某个类时,不管运行时环境采用任何调度方式或者这些线程如何交替执行,并且在主调代码中不需要任何额外的同步或协同,这个类都能表现出正确的行为,那么就称这个类是线程安全的。

原子性

原子性是指:所有操作要么不间断地全部被执行,要么一个也没有执行。

产生原子性的原因

早期的操作系统基于进程来调度 CPU,不同进程间是不共享内存空间的,所以进程要做任务切换就要切换内存映射地址,而一个进程创建的所有线程,都是共享一个内存空间的,所以线程做任务切换成本就很低了。现代的操作系统都基于更轻量的线程来调度,现在我们提到的“任务切换”都是指“线程切换”。 [极客时间-Java 并发编程实战]

Java 并发程序都是基于线程的,而我们现在使用的基本都是高级语言,一条高级语言底层往往对应着多条 CPU 指令。操作系统在进行任务切换时是基于 CPU 指令为单位的。
例如:count += 1 底层至少可以分为三条 CPU 指令。

  • 指令 1:首先,需要把变量 count 从内存加载到 CPU 的寄存器;
  • 指令 2:在寄存器中执行 +1 操作;
  • 指令 3:将结果写入内存(缓存机制导致可能写入的是 CPU 缓存而不是内存)。

如果 在多核 CPU 中,A 线程执行了指令 1 ,但是未执行了指令2,此时操作系统发生了线程切换,切换到了 B 线程,此时它作了同样的 count += 1 操作,B 线程完成了3条指令。现在 count 从原来的 0 增加了 1。这时线程重新却换回了 A 线程。A 线程继续执行完了剩余两条指令。但是此时 A 线程 CPU 中缓存 count 增加后依然将 1 写回了内存。但是他却不知道在他暂停的期间内存中的 count 值已经被 B 线程发生了改变。这就导致了明明进行了两次操作却产生了错误的值。

所以这就体现出了原子性的重要性,我们需要在必要的时候保证某些操作不可被中断。

有序性

有序性是指:即程序执行的顺序按照代码的先后顺序执行

产生有序性的原因

编译器为了优化性能,有时候会改变程序中的语句的先后顺序

指令重排规则

as-if-serial规则:在单线程执行过程中,程序的执行结果永远都不会变,这条规则被java编译器,处理器及内存模型同时遵守。
例如:

  1. int a = 1;
  2. int b = 2;
  3. int c = a;

语句1语句2没有直接的联系。所以 CPU 会将他们的顺序进行重新排序,而语句3的执行依赖于语句1,所以语句1一定优先于语句3执行。

可以发现这一种编译器的优化,在单线程中不会影响到最后的结果。如果将语句1语句3放在两个线程中执行,这样可能就会导致语句3语句1先执行,就会产生意想不到BUG。

解决方式:synchronized,volatile

可见性

可见性是指:当一个线程修改了共享变量的值时,其他线程能够立即得知这个修改。

产生可见性的原因

先了解一些物理计算机的硬件问题

由于计算机的存储设备与处理器的运算速度有着几个数量级的差距,所以现代计算机系统都不得不加入一层或多层读写速度尽可能接近处理器运算速度的高速缓存(Cache)来作为内存与处理器之间的缓冲。 基于高速缓存的存储交互很好的解决了处理器与内存速度之间的矛盾,但是也为计算机系统带来更高的复杂度,它引入了一个新的问题:缓存一致性(Cache Coherence)。 《深入理解 Java 虚拟机》周志明 第三版 [P439]

在多路处理器系统中,每个处理器都有自己的高速缓存,而它们又共享同一主内存。在运算时将需要使用的数据复制到缓存中,让运算能快速进行,当运算结束后再从缓存同步回内存之中,这样处理器就无需等待缓慢的内存读写了。如下图所示。
image.png
如果多个处理器同时(其他处理器没有将数据修改后写回主内存之前)从内存中拷贝出了数据到自己的高速缓存中,即他们拿到的时同样值的副本,对其进行计算后,重新写回主内存时,主内存应该以谁的依据为准呢?也就是说此时,各个线程对内存的操作对于其他线程是不可见的。
这时候,就出现许多的协议,来解决这个缓存一致性的问题。例如:MSI、MESI、MOSI、Synapse等。
所以就引出了“内存模型”这一概念,可以理解为在特定的操作协议下,对特定的内存或高速缓存进行读写访问的过程抽象。

Java内存模型(JMM)

《Java 虚拟机规范》中曾试图定义一种“Java内存模型”来屏蔽各种硬件和操作系统的内存访问差异,以让Java程序再各种平台下都能达到一致的内存访问效果。 《深入理解 Java 虚拟机》周志明 第三版 [P440]

Java内存模型图如下:
image.png
可以看到该图与之前的处理器主内存交互图几乎类似,所以他们之间的各个元素可以做到类比的操作。
我们称物理硬件图为图1,Java内存模型图为图2。

  • 主内存:图1中的内存模型可以类比于图2中的主内存,它们的区别是,Java 内存模型中的主内存只是虚拟机内存中的一部分也仅仅代表物理内存的一部分。
  • 工作内存:线程私有,它保存了被该线程使用的变量主内存副本,各个线程之间无法直接互相访问彼此的工作内存。可以类比图1中的高速缓存。(注意:JMM只是定义了工作内存的概念和其作用,具体工作内存是指哪一块缓存,或则还是使用特定的寄存器,JMM并没有限制)

    解决方案

    加锁保证可见性

    如果多个线程需要访问同一个变量,那么对这个变量的所有操作进行上锁。这样当一个线程在访问一个变量的过程中,不允许有其他的线程对其访问。只能等待访问变量的线程执行操作结束,其他线程才能访问。
    这样的互斥操作也保证了线程的可见性。
    线程安全性 - 图3

    Volatile 变量

    Java 语言提供了一种稍弱的同步机制,即 volatile 变量,它主要有两个作用,1. 用来确保将变量的更新操作通知其他线程。2. 禁止指令重排序优化

仅仅使用 Voltile 变量来保证并发编程的线程安全性是不可靠的。因为 Voltile 虽然可以保证,线程的可见性和有序性,但是无法保证其原子性。

总结

以上是笔者对最近Java并发知识学习的简单记录。有引用的地方,都已经再末尾标注出来了。如果需要更加详细系统的学习Java并发知识,可以查看《Java 并发编程实战》、《深入理解 Java 虚拟机》、极客时间的相关栏目等。
注意:以上内容仅仅只是笔者自己总结,如果又不严谨或错误的地方欢迎指出讨论。