Java多线程开发(二)Java同步机制

文章目录

    • 引用
    • 线程安全问题的起因
      • 计算机系统的高速缓存体系
      • 缓存体系导致的安全性问题
      • 编译器和处理器导致的有序性问题
    • 解决安全性问题的同步机制

引用

Java程序编译和运行的过程
计算机存储结构分析(寄存器,内存,缓存,硬盘)
Java并发编程实战:第三章、第十五章
Java多线程编程实战指南(核心篇):第三章、第十一章

上一节内容最后提到了影响多线程开发的三个问题:安全性、活跃性、和性能问题。其中活跃性问题和性能问题是因为程序逻辑因素导致的,只能靠开发人员自己解决。而安全性问题是因为硬件和编译器导致的,这就超出我们的影响范围了,因此Java提供了一套机制,用于协调线程间对共享数据的访问和使用(上一节已经提到,线程安全问题就是由于线程间共享了数据导致的),凭借这套机制,我们就能干预编译器和处理器对代码指令的处理,从而达到保障线程安全的目的。

线程安全问题的起因

上一节已经说过,线程安全可以分为三个方面:原子性、可见性和有序性。那么这些问题都是什么原因导致的呢?这一节我们就来深究这个问题。

Java程序从我们写下代码到由系统执行,大体可以分为两个步骤

  1. 源文件(.java)由编译器编译成字节码文件(.class)。
  2. 字节码由java虚拟机动态翻译成本地代码(Native Code,跟平台相关),再由操作系统运行。

三个安全性问题就是产生于这两个步骤中,要分析这些问题,首先我们要了解一下问题发生的环境:现代计算机的存储架构。

计算机系统的高速缓存体系

计算机主要的功能就是运算,但是要完成一个任务,就要读取运算指令,并将结果输出给用户;因此,指令的存储、运算过程中中间状态的存储、结果的保存等都需要存储器。

一般而言,最简单的计算机只需要一个处理器(CPU)和一个存储器(内存),就可以完成计算的功能。但是现代处理器处理能力的提升要远胜于主内存(DRAM)访问速率的提升,主内存执行一次内存读、写操作所需的时间可能足够处理器执行上百条的指令。为了弥补处理器与主内存处理能力之间的鸿沟,硬件设计者在主内存和处理器之间引入了高速缓存( Cache)。 现代处理器一般具有多个层次的高速缓存,相应的高速缓存通常被称为一级缓存( LI Cache)、二级缓存(L2 Cache)、三级缓存(L3 Cache)等。距离处理器越近的高速缓存,其存取速率越快,制造成本越高,因此其容量也越小。距离处理器越远(即距离主内存越近)的高速缓存,其存储速率会越慢,而存储容量则相应地增大。其中一级缓存可能直接被集成在处理器的内核(Core)里,因此其访问效率非常高。

缓存体系导致的安全性问题

了解了计算机的内存体系我们就能发现,多线程程序运作时,由于不同的线程可能运行在不同的处理器,而不同的处理器拥有各自的高速缓存。这些缓存跟主内存之间的数据不保证同一时刻一定相同,因此他们之间的数据需要一个同步机制

比如,当一个线程a需要访问到另一个线程b更新的数据时,需要线程b将数据从它的缓存更新到主内存,然后a再将主内存中的数据更新到它的缓存,以上操作在多线程环境下是不一定会发生的。而如果没有一个可靠的机制来保证在这种时候所有线程所访问和修改的数据同步的话。程序是几乎不可能正确运行的,这就是安全性问题的产生原因。而根据具体错误情况的不同,安全性问题又可以分成以下三个问题:

  • 多个线程并发访问同一个共享变量的时候,如果这些线程在不同的执行处理器上,那么他们各自处理器的高速缓存上就会都保留了一份该共享变量的副本,这就导致了各个线程运行时读取、修改后的共享变量值不一致,即可见性问题
  • 一个线程在操作一个共享变量的使用过程中,一个连续的操作可能被同时运行在不同的处理器上的另一个线程干扰,这种干扰导致了这个线程读取或者更新共享变量时存在了读脏数据(读取了另一个线程更新的数据)和丢失更新(更新数据后又被另一个线程覆盖)的可能,即原子性问题
  • 可见性问题导致的缓存和主内存之间数据进行同步的时间的不可控,还会导致 一个线程操作(所在处理器缓存的)多个数据的顺序跟在另一个处理器上的线程观察到的(本身缓存跟主内存的)数据更新先后顺序可以不一致(这个现象也被称为存储子系统重排序)。导致一个线程所执行的内存访问操作顺序在另外一个处理器上运行的其他线程看来是不可预测的。这被称为有序性问题

编译器和处理器导致的有序性问题

上面已经提到,Java代码执行要经过两次编译:第一次是从源文件到字节码文件,这个过程是由静态编译器(javac)在代码编译阶段完成,第二次是从字节码到机器码,这个过程是由动态编译器(JIT编译器)在Java程序运行完阶段完成。
这两次编译和一次运行的过程中,出于优化的目的,在源代码翻译成字节码和字节码翻译成机器码时,编译器可能改变两个操作的先后顺序;在程序运行时,处理器也可能不会完全依照程序的目标代码所指定的顺序执行指令,这种操作被称作指令重排序

Java语言规范要求JVM在线程中维护一种类似串行(As-if-serial Semantics)的语义:只要程序的最终结果与在严格串行环境中执行的结果相同,那么上述所有操作都是允许的。也就是说,指令重排序无法保证在多线程环境下的正确性。这种指令重排序导致的问题,也属于有序性问题。

解决安全性问题的同步机制

了解了安全性问题出现的原因之后,我们就可以得出我们需要的同步机制至少要拥有的功能:

  1. 在每个线程改变和读取共享数据时保证数据是最新且一致的,即保证可见性和消除存储子系统重排序(部分的有序性)
  2. 在一个线程进行某个操作的时候,可以保证其他线程不能干扰这个操作,也不会获取到这个操作的中间状态(即这个操作对其他线程来说,要么没发生,要么已经完成),即提供原子性
  3. 禁止编译器和处理器进行指令重排序(部分的有序性)

于是,针对这些要求,Java提供了以下工具:

  1. volatile关键字:把变量声明为volatile类型后,编译器不会重排序这个变量。 volatile变量不会被缓存在寄存器或者对其他处理器不可见的地方,因此在读取 volatile类型的变量时总会返回最新写入的值;并且处理器也不会将该变量上的操作与其他内存操作一起重排序。
  2. 加锁机制:包括内部锁:synchronized关键字和显式锁:Lock接口的实现类。锁就像对操作共享数据的许可证,一个线程只有在持有许可证的情况下才能够对这些共享数据进行访问;并且,一个许可证一次只能够被一个线程持有。用这种方式,就可以保证任意时刻都已有一个线程可以操作数据,并且Java平台的锁的获取和释放还隐含了将数据在主内存和缓存中同步更新的操作。
  3. 原子变量类:原子变量类(Atomics)是基于CAS实现的能够保障对共享变量进行read-modify-write更新操作的原子性和可见性的一组工具类。它可以被看作增强型的 volatile变量。

Java提供的这些工具中,显然加锁机制的功能是最强大的,根据它的描述可知,它提供了原子性(一个时刻只能被一个线程使用)、有序性(因为原子性,所由锁保护内的操作对于操作者之外的线程而言都是要么没完成,要么全部完成的)、和可见性(同步主内存和缓存)。因此只需要(正确地)使用锁,就能保证相应操作的线程安全。但是相应的,它的开销也是最大的;

volatile保证了可见性、部分的原子性(只针对volatile修饰的变量的一个读、写操作)、部分的有序性(因为不会将该变量上的操作与其他内存操作一起重排序,所以volatile变量前的操作在修改或读取volatile变量时一定完成了且对所有线程可见)。所以volatile相当于弱化的锁,相应的开销也更小。

而原子变量类 等于volatile + CAS,CAS是对一种比较并交换(Compare and Swap)处理器指令的称呼。这个指令在不同的平台实现不一样,且由平台保证这个指令的原子性。CAS包含了3个操作数——需要读写的内存位置V、进行比较的值A和拟写入的新值B。当且仅当V的值等于A时,CAS才会通过原子方式用新值B来更新V的值,否则不会执行任何操作。无论位置V的值是否等于A,都将返回V原有的值。基于CAS,我们就能保证自增、自减等操作的原子性(这些操作都是复合操作)。而原子变量类内部又使用了volatile,获取到了volatile的特性,所以它就像增强型的volatile,同样的,它的开销也比锁小,但是比单独的volatile大。

依靠这些工具,我们就能实现线程安全的代码了。

你可能感兴趣的:(多线程相关)