Java内存模型:解决可见性、有序性和原子性问题
在并发编程基础:线程安全问题的源头一文中,我们讲了在并发场景中导致线程安全的源头——可见性、原子性、有序性。这三者在编程领域属于共性问题,所有的编程语言都会遇到,Java 在诞生之初就支持多线程,自然也有针对这三者的技术方案,而且在编程语言领域处于领先地位。所以在这一篇中讲一下Java是如何保证多线程下的可见性、原子性、有序性的。
内存模型
每一种CPU的设计实现可能都是不同的,有的可能对CPU利用率和执行效率要求很高,所以它的乱序执行程度很高,有的可能要求更低,所以乱序执行程度很低。假如我们要编写一个跨平台的多线程程序,那么我们必须去深入了解每一款CPU的细节,来保证在正确的位置插入正确的、足够多的内存屏障。对程序员来说,这实在是太难了,我不能为了写一个多线程程序,去把所有的CPU都了解一遍,然后计算在哪个位置添加内存屏障才能保证对所有CPU都适用。
正确的做法应该是制定一套规范,然后让设备厂商去实现这个规范,然后使用这些统一的规范进行编程,然后在不同的平台下让编程语言、编译器来生成合适的内存屏障。因此,我们有了内存模型的概念(内存模型是一种规范)。
为了保证共享内存的正确性(可见性、有序性、原子性),内存模型定义了共享内存系统中多线程程序读写操作行为的规范。通过这些规则来规范对内存的读写操作,从而保证指令执行的正确性。它与处理器有关、与缓存有关、与并发有关、与编译器也有关。它解决了CPU多级缓存、处理器优化、指令重排等导致的内存访问问题,保证了并发场景下的一致性、原子性和有序性。
内存模型解决并发问题主要采用两种方式:限制处理器优化和使用内存屏障。
Java内存模型(JMM)
一、Java 内存模型(JMM)
1、JMM规范了Java虚拟机与计算机内存间协同工作
- 并不是所有的硬件架构都提供了相同的一致性保证(就算是MESI,也只解决CPU缓存层面的问题,没有涉及其他层面)。Java作为一门跨平台语言,JVM需要提供一个统一的语义来规范一致性保证,所以有了JMM;
- Java作为一个跨平台的语言,它的实现要面对不同的底层硬件系统和操作系统。为了统一这种差异性,Java虚拟机规范中定义了Java内存模型,用于屏蔽掉各种硬件和操作系统的内存访问差异,实现让Java程序在各种平台下都能达到一致的并发效果。通俗的来讲,就是描述Java中各种变量(线程共享变量)的访问规则,以及在JVM中将变量存储到内存和从内存中读取变量这样的底层细节。Java内存模型规定了不同线程如何以及何时可以看到其他线程写入共享变量的值以及如何在必要时同步对共享变量的访问。
- 也可以说它规范了 JVM 如何提供按需禁用缓存和编译优化的方法(Java内存模型是一个很复杂的规范,只不过从我们程序员的角度来说更关注这一个方面)。具体来说,这些方法包括 volatile、synchronized 和 final 三个关键字,以及六项 Happens-Before 规则。并且支持大部分的主流硬件平台。
2、从抽象的角度来看,JMM定义了线程和主内存之间的抽象关系:
- 线程之间的共享变量存储在主内存(Main Memory)中;
- 每个线程都有一个私有的工作内存,工作内存是JMM的一个抽象概念,并不真实存在,它涵盖了缓存、写缓冲区、寄存器以及其他的硬件和编译器优化。工作内存中存储了该线程以读/写共享变量的拷贝副本;
- 从更低的层次来说,主内存就是硬件的内存,而为了获取更好的运行速度,虚拟机及硬件系统可能会让工作内存优先存储于寄存器和高速缓存中;
- Java内存模型中的线程的工作内存(working memory)是cpu的寄存器和高速缓存的抽象描述。而JVM的静态内存储模型(JVM内存模型)只是一种对内存的物理划分而已,它只局限在内存,而且只局限在JVM的内存。
3、JMM解决的问题
类似于物理内存模型面临的问题,JMM 也存在以下两个问题:工作内存数据一致性、指令重排序优化。我们知道导致可见性的原因是工作内存数据的不一致,导致有序性的原因是编译优化,那解决可见性、有序性最直接的办法就是禁用缓存和编译优化,但是缓存与编译优化的引入是为了提高CPU的执行效率,禁用虽然解决了问题,但是我们程序的性能会大打折扣。所以合理的方案应该是按需禁用缓存以及编译优化。所谓“按需禁用”其实就是指程序员按照自己的需求来选择是否禁用(对于并发程序,何时禁用缓存以及编译优化只有程序员知道)。所以,为了解决可见性和有序性问题,只需要提供给程序员按需禁用缓存和编译优化的方法即可:
- 工作内存数据一致性 - JMM主要通过一系列的数据同步协议、规则来保证数据的一致性;
- 指令重排序优化 - Java中重排序通常是编译器或运行时环境为了优化程序性能而采取的对指令进行重新排序执行的一种手段。Java中的重排序分为两类:编译期重排序和运行期重排序,分别对应编译时和运行时环境。 同样的,指令重排序不是随意重排序,它需要满足以下两个条件:
- 在单线程环境下不能改变程序运行的结果。即时编译器(和处理器)需要保证程序能够遵守
as-if-serial
属性。通俗地说,就是在单线程情况下,要给程序一个顺序执行的假象。即经过重排序的执行结果要与顺序执行的结果保持一致。 - 存在数据依赖关系的不允许重排序。
- 多线程环境下,如果线程处理逻辑之间存在依赖关系,有可能因为指令重排序导致运行结果与预期不同。
- 在单线程环境下不能改变程序运行的结果。即时编译器(和处理器)需要保证程序能够遵守
二、JMM下的线程间通信
JMM下的线程间通信必须要经过主内存。如果线程A与线程B之间要通信的话,必须要经历下面两个步骤:
- 线程A把本地内存A中更新过的共享变量刷新到主内存中去;
- 线程B到主内存中去读取线程A之前已更新过的共享变量。
关于主内存与工作内存之间的具体交互协议,即一个变量如何从主内存拷贝到工作内存、如何从工作内存同步到主内存之间的实现细节,Java内存模型定义了以下八种操作来完成。JVM 实现时必须保证下面介绍的每种操作都是原子的(对于 double 和 long 型的变量来说,load、store、read、和 write 操作在某些平台上允许有例外 )。
- lock (锁定) - 作用于主内存的变量,它把一个变量标识为一条线程独占的状态;
- unlock (解锁) - 作用于主内存的变量,它把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定;
- read (读取) - 作用于主内存的变量,它把一个变量的值从主内存传输到线程的工作内存中,以便随后的 load 动作使用(从主内存 读取到工作内存中);
- write (写入) - 作用于主内存的变量,它把 store 操作从工作内存中得到的变量的值放入主内存的变量中(将工作内存中的值写回主内存);
- load (载入) - 作用于工作内存的变量,它把 read 操作从主内存中得到的变量值放入工作内存的变量副本中(给工作内存中的副本赋值);
- use (使用) - 作用于工作内存的变量,它把工作内存中一个变量的值传递给执行引擎,每当虚拟机遇到一个需要使用到变量的值得字节码指令时就会执行这个操作(程序执行过程中读取该值时调用);
- assign (赋值) - 作用于工作内存的变量,它把一个从执行引擎接收到的值赋给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作(将运算完成后的新值赋回给工作内存中的变量,相当于修改工作内存中的变量);
- store (存储) - 作用于工作内存的变量,它把工作内存中一个变量的值传送到主内存中,以便随后 write 操作使用(将该值从变量中取出,写入工作内存中)。
JMM还规定了在执行上述八种基本操作时,必须满足如下规则:
- 如果要把一个变量从主内存中复制到工作内存,就需要按顺序地执行read和load操作, 如果把变量从工作内存中同步回主内存中,就要按顺序地执行store和write操作。但JMM只要求上述操作必须按顺序执行,而没有保证必须是连续执行;
- 不允许read和load、store和write操作之一单独出现;
- 不允许一个线程丢弃它的最近assign的操作,即变量在工作内存中改变了之后必须同步到主内存中;
- 不允许一个线程无原因地(没有发生过任何assign操作)把数据从工作内存同步回主内存中;
- 一个新的变量只能在主内存中诞生,不允许在工作内存中直接使用一个未被初始化(load或assign)的变量。即就是对一个变量实施use和store操作之前,必须先执行过了assign和load操作;
- 一个变量在同一时刻只允许一条线程对其进行lock操作,但lock操作可以被同一条线程重复执行多次,多次执行lock后,只有执行相同次数的unlock操作,变量才会被解锁。lock和unlock必须成对出现;
- 如果对一个变量执行lock操作,将会清空工作内存中此变量的值,在执行引擎使用这个变量前需要重新执行load或assign操作初始化变量的值;
- 如果一个变量事先没有被lock操作锁定,则不允许对它执行unlock操作;也不允许去unlock一个被其他线程锁定的变量;
- 对一个变量执行unlock操作之前,必须先把此变量同步到主内存中(执行store和write操作)。
读取执行步骤: 写入执行步骤:
三、Java 内存三大特性
当对象和变量被存放在计算机中各种不同的内存区域中时,就可能会出现一些具体的问题。JMM建立所要解决的问题就是如何保障Java 内存三大特性:原子性、可见性、有序性。上文介绍的Java内存交互的 8 种基本操作,就遵循这三大特性:
1、原子性
在 Java 中,为了保证原子性,提供了两个高级的字节码指令monitorenter和monitorexit。这两个字节码,在 Java 中对应的关键字就是 synchronized。
2、可见性
JMM 是通过 "变量修改后将新值同步回主内存, 变量读取前从主内存刷新变量值" 这种依赖主内存作为传递媒介的方式来实现的。Java 实现多线程可见性的方式有:
- volatile
- synchronized
- final
3、有序性
有序性规则表现在以下两种场景: 线程内和线程间
- 线程内 - 从某个线程的角度看方法的执行,指令会按照一种叫“串行”(
as-if-serial
)的方式执行,此种方式已经应用于顺序编程语言; - 线程间 - 这个线程“观察”到其他线程并发地执行非同步的代码时,由于指令重排序优化,任何代码都有可能交叉执行。唯一起作用的约束是:对于同步方法,同步块(synchronized关键字修饰)以及 volatile字段的操作仍维持相对有序。
在 Java 中,可以使用 synchronized和 volatile来保证多线程之间操作的有序性。实现方式有所区别:
- volatile关键字会禁止指令重排序;
- synchronized则是由“一个变量在同一个时刻只允许一条线程对其进行lock操作”这条规则获得的,这个规则决定了持有同一个锁的两个同步块只能串行地进入。
四、JMM内存规则
Java 内存模型是个很复杂的规范,可以从不同的视角来解读,站在我们这些程序员的视角,本质上可以理解为,Java 内存模型规范了 JVM 如何提供按需禁用缓存和编译优化的方法。具体来说,这些方法包括 volatile、synchronized 和 final 三个关键字,以及六项 Happens-Before 规则,这也是本小节的重点内容。
as-if-serial语义
不管怎么重排序(编译器和处理器为了提高并行度、执行效率),单线程程序的执行结果不能被改变。(编译器重排序、运行时重排序和处理器重排序都必须遵守as-if-serial语义)
Happens-Before
从JDK 5开始,Java使用新的JSR-133内存模型,JSR-133使用happens-before的概念来阐述操作之间的内存可见性:在JMM中,如果一个操作执行的结果需要对另一个操作可见(两个操作既可以是在一个线程之内,也可以是在不同线程之间),那么这两个操作之间必须要存在happens-before关系。Happens-Before 约束了编译器的优化行为,虽允许编译器优化,但是要求编译器优化后一定遵守 Happens-Before 规则。
在 Java 语言里面,Happens-Before 的语义本质上是一种可见性,A Happens-Before B 意味着 A 事件对 B 事件来说是可见的,无论 A 事件和 B 事件是否发生在同一个线程里。例如 A 事件发生在线程 1 上,B 事件发生在线程 2 上,Happens-Before 规则保证线程 2 上也能看到 A 事件的发生。并不是说前面一个操作发生在后续操作的前面,它真正要表达的是:前面一个操作的结果对后续操作是可见的。
先行发生原则非常重要,它是判断数据是否存在竞争、线程是否安全的主要依据,依靠这个原则,我们可以通过几条规则一揽子地解决并发环境下两个操作间是否可能存在冲突的所有问题。
Java 内存模型主要分为两部分,一部分面向你我这种编写并发程序的应用开发人员,另一部分是面向 JVM 的实现人员的,我们可以重点关注前者,也就是和编写并发程序相关的部分,这部分内容的核心就是 Happens-Before 规则。
class VolatileExample {
int x = 0;
volatile boolean v = false;
public void writer() {
x = 42;
v = true;
}
public void reader() {
if (v == true) {
// 这里x会是多少呢?
}
}
}
1、程序顺序性规则 - 这条规则是指在一个线程中,按照程序顺序,前面的操作 Happens-Before 于后续的任意操作(我理解也就是串行语义as-if-serial)。
- 按照程序的顺序,第 5行代码 “x = 42;” Happens-Before 于第 6 行代码 “v = true;”,这就是规则 1 的内容,也比较符合单线程里面的思维:程序前面对某个变量的修改一定是对后续操作可见的;
2、传递性 - 如果 A Happens-Before B,且 B Happens-Before C,那么 A Happens-Before C;
3、volatile 变量规则 - 对一个volatile变量的写操作 Happens-Before 于后续对这个变量的读操作。
- 从示例代码中,我们可以看到:“x=42” Happens-Before 写变量 “v=true” ,这是规则 1 的内容;
- 写变量“v=true” Happens-Before 读变量 “v=true”,这是规则 3 的内容 。
- 再根据传递性规则,我们得到结果:“x=42” Happens-Before 读变量“v=true”。这意味着什么呢?如果线程A执行 writer(),线程 B 执行reader(),当读到了“v=true”,那么线程 A 设置的“x=42”对线程 B 是可见的。也就是说,线程 B 能看到 “x == 42” 。这就是 1.5 版本对 volatile 语义的增强,这个增强意义重大,1.5 版本的并发工具包(java.util.concurrent)就是靠 volatile 语义来搞定可见性的;
4、管程锁定规则 - 一个 unlock 操作先行发生于后面对同一个锁的 lock 操作(管程是一种通用的同步原语,在 Java 中指的就是 synchronized,synchronized 是 Java 里对管程的实现。);
5、线程启动规则 - 如果线程 A 调用线程 B 的 start() 方法(即在线程 A 中启动线程 B),那么该 start() 操作 Happens-Before 于线程 B 中的任意操作;
6、线程等待规则 - 如果在线程 A 中,调用线程 B 的 join() 并成功返回,那么线程 B 中的任意操作 Happens-Before 于该 join() 操作的返回;
- 这条是关于线程等待的。它是指主线程 A 等待子线程 B 完成(主线程 A 通过调用子线程 B 的 join() 方法实现),当子线程 B 完成后(主线程 A 中 join() 方法返回),主线程能够看到子线程的操作。当然所谓的“看到”,指的是对共享变量的操作。
7、线程中断规则 - 对线程 interrupt() 方法的调用先行发生于被中断线程的代码检测到中断事件的发生,可以通过 Thread.interrupted()方法检测到是否有中断发生;
8、对象终结规则 - 一个对象的初始化完成先行发生于它的finalize()方法的开始;
volatile变量
volatile是JMM提供的最轻量级的同步机制。用volatile修饰变量是为了保证变量在多线程中的可见性。而普通变量不能做到这一点,普通变量的值在线程间传递均需要通过主内存来完成。volatile的具体实现方式是在编译期生成字节码时,会在指令序列中增加内存屏障来保证,下面是基于保守策略的 JMM 内存屏障插入策略:
- 在每个 volatile 写操作的前面插入一个 StoreStore 屏障。 该屏障除了保证了屏障之前的写操作和该屏障之后的写操作不能重排序,还会保证了 volatile 写操作之前,任何的读写操作都会先于 volatile 被提交。
- 在每个 volatile 写操作的后面插入一个 StoreLoad 屏障。 该屏障除了使 volatile 写操作不会与之后的读操作重排序外,还会刷新处理器缓存,使 volatile 变量的写更新对其他线程可见。
- 在每个 volatile 读操作的后面插入一个 LoadLoad 屏障。 该屏障除了使 volatile 读操作不会与之前的写操作发生重排序外,还会刷新处理器缓存,使 volatile 变量读取的为最新值。
- 在每个 volatile 读操作的后面插入一个 LoadStore 屏障。 该屏障除了禁止了 volatile 读操作与其之后的任何写操作进行重排序,还会刷新处理器缓存,使其他线程 volatile 变量的写更新对 volatile 读操作的线程可见。
volatile变量具有以下两种特性:
- 保证变量对所有线程的可见性。
- 禁止进行指令重排序
语义1 保证变量对所有线程的可见性
这里的可见性是指当一条线程修改了 volatile 变量的值,新值对于其他线程来说是可以立即得知的。当然要说使用volatile修饰过的变量是线程安全的,也不全对。因为volatile是要分场景来说的:如果多个线程操作volatile修饰的变量,且此时的“操作”是原子性的,那么是线程安全的,否则不是。volatile只能保证变量内部操作的原子性,但是不能保证对该变量的非原子性操作。比如:
volatile int i=0;
线程A:for(;i++;i<=200);
线程B:for(;i++;i<=200);
最后 i 的结果不一定会是200(线程不安全),因为i++操作不是原子性操作,它涉及到了三个子操作:从主内存取出i、i+1、将结果同步回主内存。那么就有可能一个线程拿到最新值,正开始执行第二个子操作,而值还未来得及改变时,第二个线程就已经拿到同样的值开始执行第二个子操作了。这样一来,就有可能两个线程给同一个值加了一次1。
这时,我们应该使用synchronize或concurrent原子类来保证“操作”的原子性。故volatile的使用场景应该是:只是对该变量进行重新赋值操作。
语义 2 禁止进行指令重排序
禁止重排序的规则如下:
- 当程序执行到volatile变量的读操作或者写操作时,在其前面的操作的更改肯定全部已经进行,且结果已经对后面的操作可见;在其后面的操作肯定还没有进行;
- 在进行指令优化时,不能将在对volatile变量访问的语句放在其后面执行,也不能把volatile变量后面的语句放到其前面执行。
普通的变量仅仅会保证该方法的执行过程中所有依赖赋值结果的地方都能获取到正确的结果,而不能保证赋值操作的顺序与程序代码中的执行顺序一致。
final 型变量遵循的重排序规则
1、先说一下什么是this逃逸:
- this逃逸是指当一个对象还没有完成构造(构造方法尚未返回)的时候,其他线程就已经可以获得到该对象的引用,并可以通过该引用操作该对象。由于对象尚未完整构造,所以此时访问到的对象尚未完全初始化,通过该引用调用对象方法或者访问实例数据可能会带来意想不到的结果。
2、final域初始化规范
final变量有成员变量或者是本地变量(方法内的局部变量),在类成员中final经常和static一起使用,作为类常量使用。其中类常量必须在声明时初始化,final成员常量可以声明时初始化,也可以在构造函数中初始化。除了在声明时和构造函数中进行初始化,其他初始化方式都会报错。
3、final 域遵循的重排序规则
- 之所以这样是因为对于 final 域,JMM规定编译器和处理器要遵守以下两个重要的排序规则:
- 在构造函数内对一个 final 域的写入,与随后把这个被构造对象的引用赋值给一个引用变量,这两个操作之间不能重排序(也就是说被构造对象的引用在被赋值使用之前,final域肯定要被先初始化完成);
- 初次读一个包含 final 域的对象的引用,与随后初次读这个 final 域,这两个操作之间不能重排序。
- 注:初次读对象引用与初次读该对象包含的 final 域,这两个操作之间存在间接依赖关系。由于编译器遵守间接依赖关系,因此编译器不会重排序这两个操作。大多数处理器也会遵守间接依赖,大多数处理器也不会重排序这两个操作。但有少数处理器允许对存在间接依赖关系的操作做重排序(比如 alpha 处理器),这个规则就是专门用来针对这种处理器。所以我们一般主要关注第一条规则。
- 具体的操作是:
- JMM在final域写入和构造函数返回之前,插入一个StoreStore内存屏障,禁止处理器将final域重排序到构造函数之外;
- JMM在初次读包含 final 域的对象的引用和读对象内final域之间插入一个LoadLoad内存屏障。
4、final关键字解决this逃逸问题
在Java中,final关键字是非常重要的,但是事实上却经常被忽视其作为同步的作用。之所以出现上述两个规则就是为了解决多线程下的this逃逸问题,也就是同步问题。在我们new一个对象时至少有以下3个步骤:
- 在堆中申请一块内存空间
- 对象进行初始化
- 将内存空间的引用赋值给一个引用变量,可以理解为调用invokespecial指令
普通成员变量在初始化时可以重排序为1-3-2,即被重拍序到构造函数之外去了(此时如果其他线程拿到该成员变量的引用,但是该变量可能还没有初始化,此时访问该变量可能拿到的是一个null值,而不是初始化后的值),而final变量可以防止此类事情的发生:如果某个成员是final的,JVM规范做出如下明确的保证:一旦对象引用对其他线程可见,则其final成员也必须正确的赋值了(也就是说final变量初始化时必须为1-2-3)。
JMM提供的锁技术:synchronized 关键字
我们知道,原子性问题的源头是线程切换,如果能够禁用线程切换那不就能解决这个问题了吗?而操作系统做线程切换是依赖 CPU 中断的,所以禁止 CPU 发生中断就能够禁止线程切换。
在早期单核 CPU 时代,这个方案的确是可行的,但是并不适合多核场景。
- 在单核 CPU 场景下,同一时刻只有一个线程执行,禁止 CPU 中断,意味着操作系统不会重新调度线程,也就是禁止了线程切换,获得 CPU 使用权的线程就可以不间断地执行,所以两次写操作一定是:要么都被执行,要么都没有被执行,具有原子性;
- 但是在多核场景下,同一时刻,有可能有两个线程同时在执行,一个线程执行在 CPU-1 上,一个线程执行在 CPU-2 上,此时禁止 CPU 中断,只能保证 CPU 上的线程连续执行,并不能保证同一时刻只有一个线程执行,如果这两个线程同时操作共享变量,就可能会有bug产生。“同一时刻只有一个线程执行”这个条件非常重要,我们称之为互斥。如果我们能够保证对共享变量的修改是互斥的,那么,无论是单核 CPU 还是多核 CPU,就都能保证原子性了。
说到互斥,我们就会想到加锁:
锁是一种通用的技术方案,Java 语言提供的 synchronized 关键字,就是锁的一种实现。synchronized 关键字可以用来修饰方法,也可以用来修饰代码块。Java 编译器会在 synchronized 修饰的方法或代码块前后自动加上加锁 lock() 和解锁 unlock(),这样做的好处就是加锁 lock() 和解锁 unlock() 一定是成对出现的,毕竟忘记解锁 unlock() 可是个致命的 Bug(意味着其他线程只能死等下去了)。
那 synchronized 里的加锁 lock() 和解锁 unlock() 锁定的对象在哪里呢?上面的代码我们看到只有修饰代码块的时候,锁定了一个 obj 对象,那修饰方法的时候锁定的是什么呢?这个也是 Java 的一条隐式规则:
- 当修饰静态方法的时候,锁定的是当前类的 Class 对象(Class X);
- 当修饰非静态方法的时候,锁定的是当前实例对象 this。
被 synchronized 修饰后的代码块,无论是单核 CPU 还是多核 CPU,只有一个线程能够执行,所以一定能保证原子操作,那是否有可见性问题呢?要回答这问题,就要说一下 Happens-Before 的管程锁定规则。管程锁定规则:对一个锁的解锁 Happens-Before 于后续对这个锁的加锁。
管程,就是我们这里的 synchronized,我们知道 synchronized 修饰的临界区是互斥的,也就是说同一时刻只有一个线程执行临界区的代码;而所谓“对一个锁解锁 Happens-Before 后续对这个锁的加锁”,指的是前一个线程的解锁操作对后一个线程的加锁操作可见,综合 Happens-Before 的传递性原则,我们就能得出前一个线程在临界区修改的共享变量(该操作在解锁之前),对后续进入临界区(该操作在加锁之后)的线程是可见的。
所以,通过上面的介绍,我们知道synchronized关键字能够保证临界区中代码的原子性和临界区中共享变量的可见性。但是不能保证临界区中代码执行的有序性,比如我们之前说的Java中利用双重检查创建单例对象:
public class Singleton {
static Singleton instance;
static Singleton getInstance(){
if (instance == null) {
synchronized(Singleton.class) {
if (instance == null)
instance = new Singleton();
}
}
return instance;
}
}
在上述获取单例的算法存在一个严重bug,就是 instance = new Singleton();这个操作其实在实际执行过程中有三个操作,这三个操作有可能因为重排序,导致多线程下,当发生线程切换其他线程获取单例时,获取到未初始化的instance。所以我们还要给instance变量加一个volatile关键字保证他创建的有序性。
对于锁的选定问题,在此就不再叙述了,这个根据自己的业务需求,大家可以灵活选用,但是我再此要说一个问题,就是对于可变对象的锁竞争与释放是什么样的呢?比如下面的转账过程,如果账户余额用 this.balance 作为互斥锁,是否可行?
class Account {
private String password;
private Integer balance;
// 转账
void transfer(Account target, int amt){
synchronized(this.balance) {//todo
}
}
}
}
答案是不可行的,举个例子,假如this.balance = 10 ,多个线程同时竞争同一把锁this.balance,此时只有一个线程拿到了锁,其他线程等待,拿到锁的线程进行this.balance -= 1操作,this.balance = 9。 该线程释放锁, 之前等待锁的线程继续竞争this.balance=10的锁,新加入的线程竞争this.balance=9的锁,导致多个锁对应一个资源。所以可变对象是不能作为锁的,因为无法保证互斥性。
五、总结
- 写先于读指的是不会因为cpu缓存,导致a线程已经写了,但是b线程没读到的情况。而不是b要读,一定要等a写完才行;
- synchronized关键字能够保证临界区中代码的原子性和临界区中共享变量的可见性,但是不能保证临界区中代码执行的有序性;
- 原子性的本质其实不是不可分割,不可分割只是外在表现,其本质是多个资源间有一致性的要求,操作的中间状态对外不可见。例如,在 32 位的机器上写 long 型变量有中间状态(只写了 64 位中的 32 位),在银行转账的操作中也有中间状态(账户 A 减少了 100,账户 B 还没来得及发生变化)。所以解决原子性问题,是要保证中间状态对外不可见;
- 可变对象不能作为锁;