购买
下载掌阅APP,畅读海量书库
立即打开
畅读海量书库
扫码下载掌阅APP

1.2 面试官:介绍JMM与线程安全的关系

JMM是理解线程安全的核心概念,它定义了线程和主内存之间的抽象关系,以及线程如何通过内存进行通信。掌握JMM的相关知识对于编写线程安全的代码至关重要。面试官提出“介绍JMM与线程安全的关系”这个问题,旨在考查求职者对Java多线程编程的理解程度,以及在并发控制领域的知识水平。

面试官提出这个问题背后的目的是检测求职者是否理解在并发编程中保证操作可见性、原子性和有序性的重要性,这些都是JMM正确运行的关键保证。作为求职者,我们在面试时应该重点讲述JMM的主要组成部分,如它的工作原则、内存屏障、happens-before原则等。同时,应该强调自己如何使用同步机制来保证线程安全,举例说明如何在实际编程中遵循JMM来避免数据竞争等问题。这样的答案能够向面试官展示深厚的理论基础和丰富的实践经验。

我们可以针对面试官的考查目的对这个问题进行拆解,将其拆分成多个问题点再进行解答,解答要点如下。

(1)什么是JMM?它有哪些特征和作用?

JMM(Java内存模型)是一个抽象的概念,旨在定义程序中各种变量的访问规范,以及线程与主内存之间的交互方式。它的特征包括可见性、原子性和有序性;作用是解决并发编程中的可见性问题和原子性问题,确保程序运行的正确性和性能。

(2)JMM和Java内存结构有什么区别?

JMM与Java内存结构(堆、栈、方法区等)不同,JMM关注的是变量之间的相互作用和线程如何通过内存进行通信,而Java内存结构关注的则是数据存储、分配和管理的物理层面。

(3)JMM内存是如何交互的?都有哪些操作?

在JMM中,线程与主内存之间的交互主要通过读取、写入、锁定等操作进行。每个线程都有自己的工作内存,它会先从主内存复制变量到工作内存中进行读写操作,再将更新后的变量写回主内存。

(4)什么是happens-before原则?它有什么作用?

happens-before原则是JMM中的一个核心概念,它用于确定内存操作的顺序关系,确保程序的有序性。如果一个操作与另一个操作之间存在happens-before关系,那么第一个操作的结果对第二个操作来说是可见的。

(5)什么是指令重排序和内存屏障?

指令重排序是编译器或处理器为了优化程序性能而采用的一种技术,能够改变程序指令的执行顺序。内存屏障是一种机制,用于防止指令重排序,保证特定操作的执行顺序,从而维护happens-before原则。

(6)如何保证程序的可见性、原子性和有序性?

保证程序的可见性、原子性和有序性通常通过同步机制来实现,如使用volatile关键字可以保证变量修改的可见性,使用synchronized关键字或锁机制(如ReentrantLock)可以保证操作的原子性和有序性。此外,利用final关键字也可以在某些场景下保证程序的可见性和有序性。

为了让大家对JMM与线程安全内容有更深入的掌握和理解,灵活应对面试细节,接下来我们对上述解答要点逐个进行详解。

1.2.1 什么是JMM?它有哪些特征和作用?

JMM是Java Memory Model(Java内存模型)的缩写,与JVM内存结构不同,它是一个抽象的概念,描述的是一组与多线程相关的规范,需要各个JVM的实现来遵守,开发者可以利用这些规范,更方便地开发多线程程序。在使用JMM的情况下,即便同一个程序在不同的虚拟机上运行,得到的程序结果也是一致的。

JMM定义了程序中的操作如何在多线程环境下交互,以及线程如何通过内存进行通信。当有多个线程操作内存中的共享数据时,JMM定义了线程与主内存之间的抽象关系以及同步这些操作的方式,确保线程安全性、内存的可见性、原子性和有序性,以下是JMM规范定义的主要内容。

(1)变量的存储。

JMM描述了程序中的变量如何存储在内存中以及如何通过线程访问这些变量。所有变量存放在主内存中,而每个线程有自己的工作内存,工作内存用于存放该线程使用到的主内存变量副本。线程对变量的操作都在工作内存中进行。线程不能直接读写主内存中的变量。每个线程的工作内存都是独立的,线程只能先在工作内存中操作变量,然后将变量同步到主内存,如图1-2所示。

图1-2

(2)操作的原子性。

JMM规定哪些操作是原子性的,即不可中断的。例如,对于非long或double类型的变量的读写操作通常是原子性的,但这些操作的复合操作(如递增操作)不是原子性的。

(3)变量的可见性。

JMM规定何时以及如何将更新后的变量值从工作内存同步到主内存,以及从主内存更新到各个线程的工作内存,确保一个线程对共享变量的修改对其他线程可见。

(4)变量修改的有序性。

JMM规定在不影响单线程程序执行结果的前提下,允许编译器和处理器对操作顺序进行重排序,但必须遵守特定的规则(比如使用volatile关键字、final关键字和synchronized块/方法)以保证在多线程环境中程序的有序性和正确性。

(5)锁的语义。

JMM定义了锁和同步的语义,确保获取锁的线程能看到由先前持有同一锁(并已释放该锁)的其他线程所作的修改。

整个JMM实际上是围绕着以下3个特征建立起来的,这3个特征可谓是整个Java并发编程的基础。

(1)原子性(Atomicity)。

原子性是指一个或一系列操作是不可中断的,即使是在多线程同时执行的情况下,一个操作(或对某个变量的操作)要么完全执行,要么完全不执行,不会停留在中间某个步骤。JMM只能保证基本的原子性,如果要保证一个代码块的原子性,可以通过synchronized或java.util.concurrent包中的原子类(如AtomicInteger)来保证。

(2)可见性(Visibility)。

可见性是指如果一个线程修改了共享变量的值,其他线程能够立刻得知这个修改。Java提供了volatile关键字来保证变量的可见性,用volatile修饰一个共享变量可以保证对这个变量的读写都是直接操作主内存,而不是线程的工作内存。

(3)有序性(Ordering)。

有序性是指程序按照代码的先后顺序执行。在JMM中,由于编译器优化和处理器优化,可能会出现指令重排序,打乱原来的代码执行顺序。为了解决这个问题,JMM提出了happens-before原则来保证程序的有序性。通过synchronized或volatile也可以保证多线程之间操作的有序性。

总之,JMM规范屏蔽掉了各种硬件和操作系统的内存访问差异与实现细节,这些细节对于Java开发者而言是透明的,理解JMM提供的规则和保障对编写正确的并发程序至关重要。通过遵循JMM规范,开发者可以编写出既安全又高效的多线程Java程序,并且让Java程序在不同平台上都能达到一致的内存访问效果,这就是JMM的意义。

1.2.2 JMM和Java内存结构有什么区别?

JMM和Java内存结构很容易让人混淆,但它们是Java中两个截然不同的概念,关注的领域和目的各不相同,下面我们进行详细介绍。

(1)JMM。

JMM是一个抽象的概念,它定义了JVM在多线程环境中如何处理内存的读写操作,以及线程如何通过内存进行交互。JMM关注的是变量之间的相互作用和线程如何通过内存进行通信。它提供了一套规则,确保在多核处理器的环境下,程序执行的正确性得以保障。

JMM的主要功能和目标如下。

● 定义共享变量的读写如何在线程间传递。

● 确保多线程环境下,程序执行的一致性和安全性。

● 为开发者提供一种机制,使得开发者在编写并发程序时能够考虑到硬件和编译器的内存访问优化。

(2)Java内存结构。

Java内存结构,又被称为JVM运行时数据区,是JVM在执行Java程序时用来存储数据和管理内存的实际架构。它定义了JVM在执行Java程序时如何使用内存,包括各种运行时数据区的划分,如方法区(Method Area)、堆(Heap)空间、栈(Stack)空间、程序计数器(Program Counter)和本地方法栈(Native Method Stack)。

Java内存结构的主要功能和目标如下。

● 定义方法区来存储类信息、常量、静态变量等。

● 定义堆空间来存储Java对象实例。

● 定义栈空间来存放局部变量、操作数栈、方法出入口等。

● 定义程序计数器来为每个线程保留当前执行的指令地址。

● 定义本地方法栈来支持本地方法执行。

(3)JMM和Java内存结构的区别。

从本质上讲,JMM是关于线程并发执行时内存操作的规范,它解决的问题是如何在多线程环境中安全有效地进行内存交互。而Java内存结构解决的是程序数据存储的物理或逻辑结构问题,主要用于指导JVM应该如何管理内存。

简而言之,JMM是关于线程如何交互和内存访问规则的高层规范,而Java内存结构是关于JVM如何存储数据和管理内存的实际架构。

1.2.3 JMM内存是如何交互的?都有哪些操作?

在JMM中,所有的变量都存储在主内存中,每个线程有自己的工作内存。线程的工作内存中保存了该线程使用到的变量,它们是从主内存复制的副本。线程对变量的所有操作(比如读取、赋值等)都必须在工作内存中进行,而不能直接在主内存中进行,并且每个线程不能访问其他线程的工作内存。为了实现JMM这个特性,JMM定义了8种内存操作,具体如下。

● lock:锁定操作,作用于主内存的变量,它标记一个变量开始处于独占状态。

● unlock:解锁操作,作用于主内存的变量,它标记一个变量结束独占状态。

● read:读取操作,作用于主内存的变量,它将一个变量的值从主内存传输到线程的工作内存中,以便随后的载入操作使用。

● load:载入操作,作用于工作内存的变量,它在读取操作之后执行,将读取操作得到的值放入工作内存的主内存变量副本中。

● use:使用操作,作用于工作内存的变量,它将工作内存中的一个变量的值传递给线程使用。

● assign:赋值操作,作用于工作内存的变量,线程通过它将一个值赋给工作内存中的变量。

● store:存储操作,作用于工作内存的变量,它将工作内存中的一个变量的值传递到主内存中,以便随后的写入操作使用。

● write:写入操作,作用于主内存的变量,它在存储操作之后执行,将存储操作得到的值放入主内存的变量中。

上述这些内存操作必须按照特定的顺序执行,这个顺序由happens-before原则来定义,具体交互过程如图1-3所示。

图1-3

JMM还规定了执行上述8种内存操作时必须满足的规则,具体如下。

● 如果要把一个变量从主内存中复制到工作内存,就需要按顺序执行读取和载入操作;如果要把一个变量从工作内存同步回主内存,就需要按顺序执行存储和写入操作。但JMM只要求上述操作必须按顺序执行,而没有要求必须连续执行。

● 不允许读取和载入、存储和写入操作之一单独出现。

● 不允许一个线程丢弃它最近的赋值操作,即变量在工作内存中改变了之后必须同步到主内存中。

● 不允许一个线程无原因(没有发生过任何赋值操作)地把数据从工作内存同步到主内存中。

● 一个新的变量只能在主内存中诞生,不允许在工作内存中直接使用一个未被初始化(载入或赋值)的变量,即对一个变量实施使用和存储操作之前,必须先执行赋值和载入操作。

● 对于一个变量,在同一时刻只允许一个线程对其进行锁定操作,但锁定操作可以被同一个线程重复执行多次,多次执行锁定操作后,只有执行相同次数的解锁操作,变量才会被解锁。锁定和解锁操作必须成对出现。

● 如果对一个变量执行锁定操作,将会清空工作内存中此变量的值,在执行引擎使用这个变量前需要重新执行载入或赋值操作初始化变量的值。

● 如果一个变量事先没有被锁定操作锁定,则不允许对它执行解锁操作;也不允许对一个被其他线程锁定的变量执行解锁操作。

● 对一个变量执行解锁操作之前,必须把此变量同步到主内存中。

JMM通过上述操作,结合happens-before原则,定义了线程通过主内存交互的方式,同步变量到工作内存的方式,以及工作内存与主内存之间的关系,等等。这些原则确保了在多线程环境中,共享变量的更新能够被其他线程看到,从而使得线程间的通信变得可靠和高效。这些操作基本上构成了线程间通过共享内存进行通信的基础,保证了Java程序在多线程环境中能有正确的并发行为。

1.2.4 什么是happens-before原则?它有什么作用?

happens-before(先行发生)是JMM中的一个核心概念,它定义了一组规则,用来确定内存操作之间的顺序。1.2.3小节讲到的JMM内存操作必须要满足一定的规则,happens-before就是定义这些规则的一个等效判断原则。简而言之,如果操作A happens-before操作B,则可以保证操作A产生的结果对操作B是可见的,即操作B不会看到操作A的执行结果之前的状态。

happens-before的作用是解决并发环境下的内存可见性和有序性问题,确保多线程程序的正确性。如果两个操作满足happens-before原则,那么不需要进行同步操作,JVM能够保证操作的有序性,但此时不能随意进行指令重排序;否则,JVM无法保证操作的有序性,就能进行指令重排序。

happens-before原则定义的规则具体如下。

(1)程序代码顺序规则。

在同一个线程中,按照程序代码顺序,前面的操作发生在后面的操作之前。例如在同一线程内,如果我们先写入一个变量,再读取同一个变量,那么写入操作happens-before读取操作。

int x=0;//写入操作
int y=x;//读取操作,这里能看到x=0

注意,程序代码顺序要考虑分支、循环等结构,因此该顺序确切来讲应该是程序控制流顺序。

(2)监视器锁规则。

解锁发生在加锁之前,且必须针对同一个锁。例如synchronized块,解锁happens-before加锁。

synchronized(lock) {
   sharedVar = 1; // 在锁内的写入操作
}//lock解锁happens-before加锁
 
synchronized(lock) {
   int r = sharedVar; // 在另一个锁内的读取操作,这里能看到sharedVar=1
}

(3)volatile变量规则。

对一个volatile变量的写入操作发生在读取操作之前,示例如下。

volatile int flag = 0;
// 线程A
flag = 1; // 写入操作
 
// 线程B
int f = flag; // 读取操作,这里能看到flag=1

(4)线程启动规则。

Thread对象的start()方法发生在线程的每一个后续操作之前,示例如下。

Thread t = new Thread(new Runnable() {
   public void run() {
       int readX = x; // 线程中的任何操作,能看到start()之前的写入操作
   }
});
 
x = 10; // 主线程写入操作
t.start(); // start() happens-before子线程中的所有操作

(5)线程终止规则。

线程中的所有操作,例如读取、写入和加锁等,都发生在这个线程终止之前,也就是说,当我们观察到一个线程终止时,就可以确认该线程的所有操作都已经完成了。例如,如果线程A在终止之前修改了一个共享变量,当我们通过join()方法等待线程A终止或者使用isAlive()方法检查到线程A已经不再活动时,就可以确信线程A中的所有操作都已经执行完毕,包括对共享变量的修改。示例如下。

Thread threadA = new Thread(() -> {
    // 这里是线程 A 的操作
    someSharedVariable = 123; // 对共享变量的写入操作
});
 
threadA.start();  // 启动线程 A
threadA.join();   // 等待线程 A 终止
 
// 当 threadA.join() 结束后
// 可以确信threadA对someSharedVariable 的写入操作已经完成
assert someSharedVariable == 123; // 这里可以安全地检查共享变量的值

在上述代码中,使用assert表达式检查someSharedVariable是否为123是安全的,因为threadA.join()保证了所有线程A中的操作在主线程观察到线程A终止之前都已经完成。

(6)线程中断规则。

对一个线程调用interrupt()方法,实际上是设置了该线程的中断状态,主线程的interrupt()调用发生在子线程检测到中断之前,示例如下。

Thread t = new Thread(new Runnable() {
   public void run() {
     while (!Thread.currentThread().isInterrupted()) {
       // 业务处理逻辑
     }
     // 能看到中断状态
   }
});
t.start();
t.interrupt(); // 主线程的interrupt()调用发生在子线程检测到中断之前

(7)对象终结规则。

一个对象的初始化完成,即构造函数的执行完成,发生在finalize()方法之前,示例如下。

public class ExampleObject {
   private int x;
   public ExampleObject() {
     x = 10; // 构造函数的写操作
   }
   protected void finalize() {
     int readX = x; // 在finalize()中,可以看到构造函数的写操作结果
   }
}

(8)传递性。

如果A操作发生在B操作之前,且B操作发生在C操作之前,则A操作发生在C操作之前,示例如下。

volatile int flag = 0;
int a = 0;
// 线程A
a = 1; // A操作
flag = 1; // B操作
 
// 线程B
if (flag == 1) { // C操作
    int readA = a; // 这里可以保证readA = 1,因为A happens-before B happens-before C
}

上述这些规则,为Java程序员在多线程环境中编写线程安全的代码提供了一个清晰的框架。通过理解和运用这些规则,可以避免数据竞争和内存一致性错误。

总之,happens-before是理解和正确使用JMM的关键,通过happens-before定义的规则我们可以更好地理解多线程间的内存操作如何互相影响。

1.2.5 什么是指令重排序和内存屏障?

指令重排序是编译器和处理器为了优化程序性能而采用的一种技术。这种技术能够改变程序指令执行的顺序,但保证在单线程环境中最终结果的一致性。

根据发生的层面,指令重排序可以分为3种,分别为编译器优化重排序、指令级并行重排序、内存系统重排序。重排序流程如图1-4所示,后面两种为处理器级别。

图1-4

● 编译器优化重排序:在编译时,编译器可能会改变语句的顺序来提高执行效率,同时保证程序的行为不变。

● 指令级并行重排序:现代处理器采用指令级并行(Instruction Level Parallelism,ILP)技术将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。

● 内存系统重排序:其为伪重排序,也就是说只是看起来像在乱序执行而已。对于现代处理器来说,在CPU和主内存之间都存在高速缓存,高速缓存的作用主要为减少CPU和主内存的交互。在CPU进行读取操作时,如果缓存中没有相关数据则从主内存取;而对于写入操作,先将数据写入缓存中,最后一次性写入主内存,这样做的作用是减少跟主内存交互时CPU的短暂卡顿,从而提升性能,但是延时写入可能会导致数据不一致问题。

理解指令重排序有助于开发者预见和避免潜在的并发问题。编译器和处理器并非在任何场景下都会进行指令重排序的优化,而是会遵循一定的原则,as-if-serial语义就是重排序都需要遵循的原则。as-if-serial语义规定在单线程中,只要不改变程序的最终执行结果,为了提升性能就可以改变指令执行的顺序。但是,在多线程程序中,指令重排序可能会导致一些问题。例如,一个线程对共享变量的修改可能由于重排序而未按预期顺序对其他线程可见,从而导致数据竞争和不一致的问题。为了解决这些问题,在编译器方面使用volatile关键字可以禁止指令重排序,但在硬件方面,需要使用JMM定义的内存屏障(Memory Barrier)来实现禁止指令重排序。

内存屏障也称为内存栅栏,是一种同步机制,可以确保指令执行的顺序满足特定的一致性要求。它在编译器优化和处理器执行指令时发挥作用,防止这些环节中的指令重排序引发问题。内存屏障可确保在屏障之前的所有操作完成后才开始执行屏障之后的操作。内存屏障在硬件层面和JVM层面都有实现,具体如下。

(1)硬件层内存屏障。

硬件层内存屏障有加载屏障(Load Barrier)和存储屏障(Store Barrier)两种特定类型,这两种屏障主要用于编译器和处理器级别,避免由指令重排序导致的多线程程序中的数据不一致问题。

● 加载屏障:确保所有对内存的读取操作在屏障指令之后的读取操作执行前完成。这意味着,加载屏障后的读取操作必须等待所有先前的读取操作完成,确保得到的数据是最新的。加载屏障主要用于防止指令重排序中的读取操作被提前执行。

i = a;
LoadBarriers;
//其他操作

上述代码中,LoadBarriers可以确保程序在执行其他操作之前,从主内存中读取a的变量值并且刷新到缓存中。

● 存储屏障:确保所有的写入操作在屏障指令之后的写入操作执行前完成。这确保了屏障之前的所有写入操作对接下来的写入操作可见。存储屏障用于防止写入操作重排序,确保按照程序的预期顺序执行写入操作。

a = 1;
b = 2;
c = 3;
StoreBarriers;
// 其他操作

上述代码中,StoreBarriers可以确保在执行其他操作之前,写入缓存中的a、b、c这3个变量值同步到主内存中,并且其他线程可以观察到变量的变化。

在多处理器系统中,这两种屏障特别重要,因为它们帮助维护跨不同处理器的数据的一致性。例如,如果一个处理器更新了共享数据,通过使用适当的屏障,可以确保这些更新对在其他上运行的线程立即可见。

在实际应用中,这两种屏障经常与其他类型的内存屏障一起使用,如全屏障(Full Barrier),它同时包括加载屏障和存储屏障的功能,确保所有的读写操作都在屏障之后的操作之前完成。

(2)JVM内存屏障。

在JVM中,内存屏障是一种底层同步机制,用于实现JMM规定的内存可见性和有序性保证。这些屏障不是由Java语言直接提供的,而是由JVM实现的,并且通常在编译器生成的机器代码中插入,确保正确读写操作,以及锁的正确获取和释放。

JVM内存屏障大致可以分为以下4种。

● LoadLoad屏障:放在两个读取操作之间,确保第一个读取操作的结果在第二个读取操作开始之前必须被获取。

int i = a;
LoadLoad;
int j = b; 

上述代码中,LoadLoad可以确保int i=a读取操作在int j=b读取操作之前,禁止它们进行重排序。

● StoreStore屏障:放在两个写入操作之间,确保第一个写入操作的结果在第二个写入操作开始之前必须被刷新到主内存。

a = 1;
StoreStore;
b = 10;

上述代码中,StoreStore可以确保a=1写入操作的结果在b=10写入操作开始之前被刷新到主内存,禁止它们进行重排序。

● LoadStore屏障:放在读取操作之后、写入操作之前,确保读取操作的结果对接下来的写入操作可见。

int i = a;
LoadStore;
b = 10;

上述代码中,LoadStore可以确保int i=a读操作在int b=10写操作之前,禁止它们进行重排序。

● StoreLoad屏障:最昂贵的屏障,确保之前的所有写入操作完成之后,才执行后续的读取操作。

a = 1;
StoreLoad;
int i = b;

上述代码中,StoreLoad可以确保a=1写入操作在int i =b读取操作之前,禁止它们进行重排序。

这些JVM内存屏障在使用volatile关键字、synchronized关键字和java.util.concurrent包中的锁时都会被用到。当定义一个volatile变量时,JVM会在写操作之后插入一个StoreStore屏障,以确保这次写操作对其他线程立即可见;同时,可能还会插入一个StoreLoad屏障来保证写操作之后的读操作不会读取到旧值。虽然我们在编写代码时不需要直接应用这些内存屏障,因为它们由JVM底层自动处理,但是理解它们的存在和作用对于编写并发和多线程程序是很关键的,特别是在调试和性能优化时。

1.2.6 如何保证程序的可见性、原子性和有序性?

可见性、原子性和有序性是并发的三大特征,也是JMM的特征,为了保证并发程序的正确性,我们需要考虑这3个关键特征,下面我们详细介绍它们面临的问题及其解决方案。

(1)可见性。

可见性指的是当一个线程修改了共享变量的值后,其他线程能够立即知道这个修改。导致可见性问题的原因主要有以下几点。

● 缓存一致性问题:在多处理器系统中,每个处理器通常都有自己的本地缓存(L1缓存、L2缓存等),本地缓存用以加速处理器对内存的访问。当多个处理器的缓存中都存储了同一个内存变量的副本时,一个处理器对副本的修改可能不会立即反映到其他处理器的缓存中。

● 编译器优化:为了提高程序性能,编译器可能会重排指令执行顺序,这可能导致其他线程在不适当的时候看到共享变量的数据。

● JMM的延迟特性:即使采用不带缓存的系统,JMM本身也可能导致其他处理器或线程看到过时的数据。

为解决可见性问题,我们可以采用以下常见方案。

● 使用volatile关键字:当一个变量使用volatile修饰后,所有对这个变量的写入操作都将立即同步到主内存中,同时所有对这个变量的读取操作都将直接从主内存中读取,从而保证了变量的可见性。

● 使用synchronized关键字:当一个变量处于synchronized同步代码块中时,程序执行进入块时将清空工作内存中的变量值,在需要时会从主内存中重新读取;退出块时将工作内存中的变量值刷新回主内存,从而保证了变量的可见性。

● 使用final关键字:对于使用final修饰的字段,一旦被初始化后其值就不能修改,其他线程总是能够看到final字段的初始化值。

(2)原子性。

原子性是指一个或一系列操作是不可中断的,即使是在多线程同时执行的情况下,一个操作(或对某个变量的操作)要么完全执行,要么完全不执行,不会停留在中间某个步骤。导致原子性问题的原因主要有以下几点。

● 线程上下文切换:在多线程环境中,线程可以在任意时间被操作系统挂起并切换到另一个线程。如果这种切换发生在一个复合操作(如递增操作)的中间,那么其他线程可能会看到一个不一致的状态。

● 非原子性操作:计算机的指令集通常只保证基本读写操作的原子性。对于复合操作,例如“检查再运行”(check-then-act)或“读取-修改-写入”(read-modify-write),不通过特定的同步机制是无法保证操作的原子性的。

为解决原子性问题,我们可以采用以下常见方案。

● 使用synchronized块:synchronized块(或方法)可以确保在同一时间只有一个线程执行该代码块,保证了操作的原子性。

● 使用锁:比如ReentrantLock,锁可以提供比synchronized更复杂和灵活的操作来实现同步。

● 使用原子类:比如AtomicInteger,Java的java.util.concurrent.atomic包提供了一系列原子类,通过CAS(Compare And Swap,比较并交换)操作保证了原子性。

(3)有序性。

有序性指的是程序按照代码的先后顺序执行,从而保证程序的正确性。导致有序性问题的原因主要有以下几点。

● 编译器优化:为了提高程序执行效率,编译器在生成机器代码时可能会调整指令的顺序。这种重排序对单线程程序来说通常是安全的,但在多线程程序中可能会导致严重问题。

● 处理器优化:现代处理器为了更高效地利用处理器资源和执行单元,会对输入的指令流进行动态重排序。这种指令重排序可能会导致指令执行顺序与程序代码中的顺序不一致。

● 内存系统:不同类型的内存访问有不同的访问速度,处理器可能会通过重排序内存访问指令来优化性能,这可能导致指令执行的顺序和程序中的顺序不一致。

为解决有序性问题,我们可以采用以下常见方案。

● 使用volatile:除了保证可见性外,volatile还可以防止指令重排序。编译器和处理器在遇到volatile变量时,会在读写操作前后添加内存屏障,防止其前后的操作重排序。

● 使用synchronized关键字和锁:这些同步措施会限制多个线程之间操作的执行顺序,它们可以保证锁定同一监视器的同步代码块只能串行执行。

● 遵循happens-before原则:JMM通过happens-before原则保证程序的有序性。例如,每个volatile写入操作之前的所有操作都将在volatile写入操作和后续的volatile读取操作之间对其他线程可见。

总之,为了解决上述问题,JMM定义了一系列happens-before原则来保障多线程之间的内存可见性、原子性和有序性。开发者也需要根据实际情况选用synchronized、volatile、final、锁等机制来确保并发环境下的正确性。最后,简单总结几种常见解决方案的区别,如表1-1所示。

表1-1 CDFwHSfI0buDmIdCQuSn1atFcRui54qIzBEZQMP50ljxDLwkFC5W4spa4KOITbfb

点击中间区域
呼出菜单
上一章
目录
下一章
×