浅入并发编程
三个核心概念
在并发编程中,我们通常会遇到以下三个问题:原子性问题,可见性问题,有序性问题。我们先看具体看一下这三个概念:
1.原子性
原子性:即一个操作或者多个操作 要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。
举个最简单的例子,大家想一下假如为一个32位的变量赋值过程不具备原子性的话,会发生什么后果?
i = 9;
假若一个线程执行到这个语句时,暂且假设为一个32位的变量赋值包括两个过程:为低16位赋值,为高16位赋值。
那么就可能发生一种情况:当将低16位数值写入之后,突然被中断,而此时又有一个线程去读取i的值,那么读取到的就是错误的数据。
2.可见性
可见性是指当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值。
举个简单的例子,看下面这段代码:
//线程1执行的代码
int i = 0;
i = 10;
//线程2执行的代码
j = i;
假若执行线程1、线程2。由上面的分析可知,当线程1执行 i =10这句时,会先把i的初始值加载到本地内存,然后赋值为10,那么在线程1的本地内存当中i的值变为10了,却没有立即写入到主存当中。
此时线程2执行 j = i,它会先去主存读取i的值并加载到线程2的本地内存当中,注意此时内存当中i的值还是0,那么就会使得j的值为0,而不是10.
这就是可见性问题,线程1对变量i修改了之后,线程2没有立即看到线程1修改的值。
3.有序性
有序性:即程序执行的顺序按照代码的先后顺序执行。
举个简单的例子,看下面这段代码:
int i = 0;
boolean flag = false;
i = 1; //语句1
flag = true; //语句2
上面代码定义了一个int型变量,定义了一个boolean类型变量,然后分别对两个变量进行赋值操作。从代码顺序上看,语句1是在语句2前面的,那么JVM在真正执行这段代码的时候会保证语句1一定会在语句2前面执行吗?不一定,为什么呢?这里可能会发生指令重排序(Instruction Reorder)。
下面解释一下什么是指令重排序,一般来说,处理器为了提高程序运行效率,可能会对输入代码进行优化,它不保证程序中各个语句的执行先后顺序同代码中的顺序一致,但是它会保证程序最终执行结果和代码顺序执行的结果是一致的,它只会对不存在数据依赖性的指令进行重排序。
比如上面的代码中,语句1和语句2谁先执行对最终的程序结果并没有影响,那么就有可能在执行过程中,语句2先执行而语句1后执行。
虽然重排序不会影响单个线程内程序执行的结果,但是多线程呢?
下面看一个例子:
//线程1:
context = loadContext(); //语句1
inited = true; //语句2
//线程2:
while(!inited ){
sleep()
}
doSomethingwithconfig(context);
上面代码中,由于语句1和语句2没有数据依赖性,因此可能会被重排序。假如发生了重排序,在线程1执行过程中先执行语句2,而此是线程2会以为初始化工作已经完成,那么就会跳出while循环,去执行doSomethingwithconfig(context)方法,而此时context并没有被初始化,就会导致程序出错。
从上面可以看出,指令重排序不会影响单个线程的执行,但是会影响到线程并发执行的正确性。
JMM
概述:JMM(Java Memory Model)Java内存模型,是java虚拟机规范中所定义的一种内存模型。
为什么要遵守这些并发相关的原则和规范呢? 这是因为并发编程下,像 CPU 多级缓存和指令重排这类设计可能会导致程序运行出现一些问题。就比如说我们上面提到的指令重排序就可能会让多线程程序的执行出现问题,为此,JMM 抽象了 happens-before 原则(后文会详细介绍到)来解决这个指令重排序问题。
Java内存模型(Java Memory Model)描述了Java程序中各种变量(线程共享变量)的访问规则,以及在JVM中将变量存储到内存和从内存中读取变量这样的底层细节。
特点:
-
所有的共享变量都存储于主内存(计算机的RAM)这里所说的变量指的是实例变量和类变量。不包含局部变量,因为局部变量是线程私有的,因此不存在竞争问题。
-
每一个线程还存在自己的工作内存,线程的工作内存,保留了被线程使用的变量的工作副本。
-
线程对变量的所有的操作(读,写)都必须在工作内存中完成,而不能直接读写主内存中的变量,不同线程之间也不能直接访问对方工作内存中的变量,线程间变量的值的传递需要通过主内存完成。
线程 1 与线程 2 之间如果要进行通信的话,必须要经历下面 2 个步骤:
- 线程 1 把本地内存中修改过的共享变量副本的值同步到主内存中去。
- 线程 2 到主存中读取对应的共享变量的值。
也就是说,JMM 为共享变量提供了可见性的保障。
关于主内存与工作内存直接的具体交互协议,即一个变量如何从主内存拷贝到工作内存,如何从工作内存同步到主内存之间的实现细节,Java 内存模型定义来以下八种同步操作(了解):
-
锁定(lock): 作用于主内存中的变量,将他标记为一个线程独享变量。
-
解锁(unlock): 作用于主内存中的变量,解除变量的锁定状态,被解除锁定状态的变量才能被其他线程锁定。
-
read(读取):作用于主内存的变量,它把一个变量的值从主内存传输到线程的工作内存中,以便随后的 load 动作使用。
-
load(载入):把 read 操作从主内存中得到的变量值放入工作内存的变量的副本中。
-
use(使用):把工作内存中的一个变量的值传给执行引擎,每当虚拟机遇到一个使用到变量的指令时都会使用该指令。
-
assign(赋值):作用于工作内存的变量,它把一个从执行引擎接收到的值赋给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作。
-
store(存储):作用于工作内存的变量,它把工作内存中一个变量的值传送到主内存中,以便随后的 write 操作使用。
-
write(写入):作用于主内存的变量,它把 store 操作从工作内存中得到的变量的值放入主内存的变量中。
除了这 8 种同步操作之外,还规定了下面这些同步规则来保证这些同步操作的正确执行(了解):
-
不允许一个线程无原因地(没有发生过任何 assign 操作)把数据从线程的工作内存同步回主内存中。
-
一个新的变量只能在主内存中 “诞生”,不允许在工作内存中直接使用一个未被初始化(load 或 assign)的变量,换句话说就是对一个变量实施 use 和 store 操作之前,必须先执行过了 assign 和 load 操作。
-
一个变量在同一个时刻只允许一条线程对其进行 lock 操作,但 lock 操作可以被同一条线程重复执行多次,多次执行 lock 后,只有执行相同次数的 unlock 操作,变量才会被解锁。
-
如果对一个变量执行 lock 操作,将会清空工作内存中此变量的值,在执行引擎使用这个变量前,需要重新执行 load 或 assign 操作初始化变量的值。
-
如果一个变量事先没有被 lock 操作锁定,则不允许对它执行 unlock 操作,也不允许去 unlock 一个被其他线程锁定住的变量。
了解JMM内存模型后,我们回过头来看并发编程的三个概念:
1.原子性
观察下面代码:
x = 10; //语句1
y = x; //语句2
x++; //语句3
x = x + 1; //语句4
可能会说上面的4个语句中的操作都是原子性操作。其实只有语句1是原子性操作,其他三个语句都不是原子性操作。
-
语句1是直接将数值10赋值给x,也就是说线程执行这个语句的会直接将数值10写入到工作内存中。
-
语句2实际上包含2个操作,它先要去读取x的值,再将x的值写入工作内存,虽然读取x的值以及 将x的值写入工作内存 这2个操作都是原子性操作,但是合起来就不是原子性操作了。
-
同样的,x++和 x = x+1包括3个操作:读取x的值,进行加1操作,写入新的值。
所以上面4个语句只有语句1的操作具备原子性。
也就是说,只有简单的读取、赋值(而且必须是将数字赋值给某个变量,变量之间的相互赋值不是原子操作)才是原子操作。
从上面可以看出,Java内存模型只保证了基本读取和赋值是原子性操作,如果要实现更大范围操作的原子性,可以通过synchronized和Lock来实现。由于synchronized和Lock能够保证任一时刻只有一个线程执行该代码块,那么自然就不存在原子性问题了,从而保证了原子性。
2.可见性
对于可见性,Java提供了volatile关键字来保证可见性。
1. 当一个共享变量被volatile修饰时,它会保证修改的值会立即被更新到主存,当有其他线程需要读取时,它会去内存中读取新值。
2. 而普通的共享变量不能保证可见性,因为普通共享变量被修改之后,什么时候被写入主存是不确定的,当其他线程去读取时,此时内存中可能还是原来的旧值,因此无法保证可见性。
3. 另外,通过synchronized和Lock也能够保证可见性,synchronized和Lock能保证同一时刻只有一个线程获取锁然后执行同步代码,并且在释放锁之前会将对变量的修改刷新到主存当中。因此可以保证可见性。
3.有序性:
在Java内存模型中,允许编译器和处理器对指令进行重排序,但是重排序过程不会影响到单线程程序的执行,却会影响到多线程并发执行的正确性。
在Java里面,可以通过volatile关键字来保证一定的“有序性”(具体原理在volatile关键字会详细解释)。另外可以通过synchronized和Lock来保证有序性,很显然,synchronized和Lock保证每个时刻是有一个线程执行同步代码,相当于是让线程顺序执行同步代码,自然就保证了有序性。
另外,Java内存模型具备一些先天的“有序性”,即不需要通过任何手段就能够得到保证的有序性,这个通常也称为 happens-before 原则。如果两个操作的执行次序无法从happens-before原则推导出来,那么它们就不能保证它们的有序性,虚拟机可以随意地对它们进行重排序。
浅聊hanppens-before原则
-
程序次序规则:一个线程内,按照代码顺序,书写在前面的操作先行发生于书写在后面的操作
-
锁定规则:一个unLock操作先行发生于后面对同一个锁的lock操作
-
volatile变量规则:对一个变量的写操作先行发生于后面对这个变量的读操作
-
传递规则:如果操作A先行发生于操作B,而操作B又先行发生于操作C,则可以得出操作A先行发生于操作C
-
线程启动规则:Thread对象的start()方法先行发生于此线程的每个一个动作
-
线程中断规则:对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生
-
线程终结规则:线程中所有的操作都先行发生于线程的终止检测,可以通过Thread.join()方法结束、Thread.isAlive()的返回值手段检测到线程已经终止执行
-
对象终结规则:一个对象的初始化完成先行发生于他的finalize()方法的开始
Volatile关键字
其实浅入并发编程到目前的知识都是在对Volatile关键字做铺垫
下面我们直接开始看看他的详解:
一旦一个共享变量(类的成员变量、类的静态成员变量)被volatile修饰之后,那么就具备了两层语义:
-
保证了不同线程对这个变量进行操作时的可见性,即一个线程修改了某个变量的值,这新值对其他线程来说是立即可见的(保证可见性)。
-
禁止进行指令重排序(保证有序性)。
先看一段代码,假如线程1先执行,线程2后执行:
//线程1
boolean stop = false;
while(!stop){
doSomething();
}
//线程2
stop = true;
很多人在中断线程时可能都会采用这种标记办法。但是事实上,这段代码会完全运行正确么?即一定会将线程中断么?不一定,也许在大多数时候,这个代码能够把线程中断,但是也有可能会导致无法中断线程(虽然这个可能性很小,但是只要一旦发生这种情况就会造成死循环了)。
下面解释一下这段代码为何有可能导致无法中断线程。在前面已经解释过,每个线程在运行过程中都有自己的工作内存,那么线程1在运行的时候,会将stop变量的值拷贝一份放在自己的工作内存当中。
那么当线程2更改了stop变量的值之后,但是还没来得及写入主存当中,线程2转去做其他事情了,那么线程1由于不知道线程2对stop变量的更改,因此还会一直循环下去。
但是用volatile修饰之后就变得不一样了:
-
使用volatile关键字会强制将修改的值立即写入主存;
-
使用volatile关键字的话,当线程2进行修改时,会导致线程1的工作内存中缓存变量stop的缓存行无效(反映到硬件层的话,就是CPU的L1或者L2缓存中对应的缓存行无效);
-
由于线程1的工作内存中缓存变量stop的缓存行无效,所以线程1再次读取变量stop的值时会去主存读取。
那么在线程2修改stop值时(当然这里包括2个操作,修改线程2工作内存中的值,然后将修改后的值写入内存),会使得线程1的工作内存中缓存变量stop的缓存行无效,然后线程1读取时,发现自己的缓存行无效,它会等待缓存行对应的主存地址被更新之后,然后去对应的主存读取最新的值。
那么线程1读取到的就是最新的正确的值。
volatile是否保证原子性?
点击查看代码
package com.vayne.thread;
public class Test {
public volatile int inc = 0;
public void increase() {
inc++;
}
public static void main(String[] args) throws InterruptedException {
final Test test = new Test();
for(int i=0;i<10;i++){
new Thread(){
public void run() {
for(int j=0;j<1000;j++)
test.increase();
};
}.start();
}
//这里规定线程数大于2,一般有GC线程以及main主线程
while (Thread.activeCount()>2){
Thread.yield();
}
System.out.println(test.inc);
}
}
上面是对变量inc进行自增操作,由于volatile保证了可见性,那么在每个线程中对inc自增完之后,在其他线程中都能看到修改后的值,所以有10个线程分别进行了1000次操作,那么最终inc的值应该是1000*10=10000。
但是结果总是会小于10000,这是为什么呢?
这里面就有一个误区了,volatile关键字能保证可见性没有错,但是上面的程序错在没能保证原子性。可见性只能保证每次读取的是最新的值,但是volatile没办法保证对变量的操作的原子性。
在前面已经提到过,自增操作是不具备原子性的,它包括读取变量的原始值、进行加1操作、写入工作内存。那么就是说自增操作的三个子操作可能会分割开执行,就有可能导致下面这种情况出现:
假如某个时刻变量inc的值为100:
线程1对变量进行自增操作,线程1先读取了变量inc的原始值,然后线程1被阻塞了;
然后线程2对变量进行自增操作,线程2也去读取变量inc的原始值,由于线程1只是对变量inc进行读取操作,而没有对变量进行修改操作,所以不会导致线程2的工作内存中缓存变量inc的缓存行无效,所以线程2会直接去主存读取inc的值,发现inc的值时100,然后进行加1操作,并把101写入工作内存,最后写入主存,此时volatile发出同步主存信号,但是线程1还没有接到的时候,线程1同时进行加1操作,由于已经读取了inc的值,注意此时在线程1的工作内存中inc的值仍然为100,所以线程1对inc进行加1操作后inc的值为101,然后将101写入工作内存,最后写入主存。
那么两个线程分别进行了一次自增操作后,inc只增加了1。
解释到这里,可能有朋友会有疑问,不对啊,前面不是保证一个变量在修改volatile变量时,会让缓存行无效吗?然后其他线程去读就会读到新的值,对,这个没错。这个就是上面的happens-before规则中的volatile变量规则,但是要注意,线程1对变量进行读取操作之后,被阻塞了的话,并没有对inc值进行修改。然后虽然volatile能保证线程2对变量inc的值读取是从内存中读取的,但是线程1没有进行修改,所以线程2根本就不会看到修改的值。
更加底层一点的来说:
虽然volatile保证了多线程间共享变量操作可见性,但是没有办法保证原子性。 i++是非原子性。这样就可能被线程调度所影响。
i=i+1;
线程1读取 i 的值之后,有可能被线程调度器剥夺了执行权,进入了就续状态,让线程2进入运行状态。
线程2读取 i 的值之后,并使其+1,更新回主内存。
此时线程1的 i 的值确实已经过期了,但是线程1不会再次读取 i ,而是使用上次读取的值+1交给 i。
一个线程是一个顺序执行流,当这个顺序执行流执行过半,被线程调度器剥夺执行权后,等再获得执行权,其上方的代码是不会再执行一遍的。
这是不会再读取 i 的根本原因。
根源就在这里,自增操作不是原子性操作,而且volatile也无法保证对变量的任何操作都是原子性的。
把上面的代码改成以下任何一种都可以达到效果:
方案一:synchronized同步代码块
点击查看代码
package com.vayne.thread;
public class Test {
public int inc = 0;
public synchronized void increase() {
inc++;
}
public static void main(String[] args) throws InterruptedException {
final Test test = new Test();
for(int i=0;i<10;i++){
new Thread(){
public void run() {
for(int j=0;j<1000;j++)
test.increase();
};
}.start();
}
while (Thread.activeCount()>2){
Thread.yield();
}
System.out.println(test.inc);
}
}
方案二:lock锁
点击查看代码
package com.vayne.thread;
import java.util.concurrent.locks.ReentrantLock;
public class Test {
public int inc = 0;
public ReentrantLock lock = new ReentrantLock();
public void increase() {
lock.lock();
try {
inc++;
}finally {
lock.unlock();
}
}
public static void main(String[] args) throws InterruptedException {
final Test test = new Test();
for(int i=0;i<10;i++){
new Thread(){
public void run() {
for(int j=0;j<1000;j++)
test.increase();
};
}.start();
}
while (Thread.activeCount()>2){
Thread.yield();
}
System.out.println(test.inc);
}
}
方案三:使用Atomic原子类
点击查看代码
package com.vayne.thread;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.locks.ReentrantLock;
public class Test {
// public int inc = 0;
public AtomicInteger inc = new AtomicInteger();
public void increase() {
inc.incrementAndGet();
}
public static void main(String[] args) throws InterruptedException {
final Test test = new Test();
for(int i=0;i<10;i++){
new Thread(){
public void run() {
for(int j=0;j<1000;j++)
test.increase();
};
}.start();
}
while (Thread.activeCount()>2){
Thread.yield();
}
System.out.println(test.inc);
}
}
在java 1.5的java.util.concurrent.atomic
包下提供了一些原子操作类,即对基本数据类型的 自增(加1操作),自减(减1操作)、以及加法操作(加一个数),减法操作(减一个数)进行了封装,保证这些操作是原子性操作。atomic是利用CAS来实现原子性操作的(Compare And Swap),CAS实际上是利用处理器提供的CMPXCHG指令实现的,而处理器执行CMPXCHG指令是一个原子性操作。
对于原子类下文会详细解释其原理
volatile能保证有序性吗?
在前面提到volatile关键字能禁止指令重排序,所以volatile能在一定程度上保证有序性。
volatile关键字禁止指令重排序有两层意思:
-
当程序执行到volatile变量的读操作或者写操作时,在其前面的操作的更改肯定全部已经进行,且结果已经对后面的操作可见;在其后面的操作肯定还没有进行;
-
在进行指令优化时,不能将在对volatile变量访问的语句放在其后面执行,也不能把volatile变量后面的语句放到其前面执行。
//x、y为非volatile变量
//flag为volatile变量
x = 2; //语句1
y = 0; //语句2
flag = true; //语句3
x = 4; //语句4
y = -1; //语句5
由于flag变量为volatile变量,那么在进行指令重排序的过程的时候,不会将语句3放到语句1、语句2前面,也不会讲语句3放到语句4、语句5后面。但是要注意语句1和语句2的顺序、语句4和语句5的顺序是不作任何保证的。
并且volatile关键字能保证,执行到语句3时,语句1和语句2必定是执行完毕了的,且语句1和语句2的执行结果对语句3、语句4、语句5是可见的。
volatile的原理和实现机制
前面讲述了源于volatile关键字的一些使用,下面我们来探讨一下volatile到底如何保证可见性和禁止指令重排序的。
下面这段话摘自《深入理解Java虚拟机》:
“观察加入volatile关键字和没有加入volatile关键字时所生成的汇编代码发现,加入volatile关键字时,会多出一个lock前缀指令”
lock前缀指令实际上相当于一个内存屏障(也成内存栅栏),内存屏障会提供3个功能:
-
它确保指令重排序时不会把其后面的指令排到内存屏障之前的位置,也不会把前面的指令排到内存屏障的后面;即在执行到内存屏障这句指令时,在它前面的操作已经全部完成;
-
它会强制将对缓存的修改操作立即写入主存;
-
如果是写操作,它会导致其他CPU中对应的缓存行无效
(使得本 CPU的 Cache 写人了内存该写人动作也会引起别的 CPU 或者别的内核无效化(Invalidate)其 Cache)。
使用volatile的场景
synchronized关键字是防止多个线程同时执行一段代码,那么就会很影响程序执行效率,而volatile关键字在某些情况下性能要优于synchronized,但是要注意volatile关键字是无法替代synchronized关键字的,因为volatile关键字无法保证操作的原子性。通常来说,使用volatile必须具备以下2个条件:
-
对变量的写操作不依赖于当前值
-
该变量没有包含在具有其他变量的不变式中
对变量的写操作不依赖于当前值如何理解?
就是如果变量a定义为volatile变量,做诸如
a++;
a = a + 2;
a = 6 * a;
a = a * a;
这些操作,在多线程场景下会出现共享变量不一致的情形。
原因就是volatile无法保证原子性。如果指令执行到中间被打断,就会出现共享变量不一致的情形。
该变量没有包含在具有其他变量的不变式中如何理解?
点击查看代码
public class NumberRange {
private int lower, upper;
public int getLower() {
return lower;
}
public int getUpper() {
return upper;
}
public void setLower(int value) {
if (value > upper)
throw new IllegalArgumentException(...);
lower = value;
}
public void setUpper(int value) {
if (value < lower)
throw new IllegalArgumentException(...);
upper = value;
}
}
代码显示了一个非线程安全的数值范围类。它包含了一个不变式 —— 下界总是小于或等于上界。
这种方式限制了范围的状态变量,因此将 lower 和 upper 字段定义为 volatile 类型不能够充分实现类的线程安全;从而仍然需要使用同步。否则,如果凑巧两个线程在同一时间使用不一致的值执行 setLower 和 setUpper 的话,则会使范围处于不一致的状态。例如,如果初始状态是 (0, 5),同一时间内,线程 A 调用 setLower(4) 并且线程 B 调用 setUpper(3),显然这两个操作交叉存入的值是不符合条件的,那么两个线程都会通过用于保护不变式的检查,使得最后的范围值是 (4, 3) —— 一个无效值。至于针对范围的其他操作,我们需要使 setLower() 和 setUpper() 操作原子化 —— 而将字段定义为 volatile 类型是无法实现这一目的的。
使用场景一:状态标记量
volatile boolean flag = false;
while(!flag){
doSomething();
}
public void setFlag() {
flag = true;
}
使用场景二:double check
class Singleton{
private volatile static Singleton instance = null;
private Singleton() {
}
public static Singleton getInstance() {
if(instance==null) {
synchronized (Singleton.class) {
if(instance==null)
instance = new Singleton();
}
}
return instance;
}
}
为什么会有doublecheck?
我们会以一个经典案例:单例模式,来探讨一下
我们不使用double check时候的单例模式,真的访问的实例就是一致的吗?
点击原始代码
package com.vayne.threadVolatile;
/**
* @author vayne
* @date 2023-11-02
*/
public class Singleton {
private static Singleton instance;
private Singleton (){}
//获取实例
public static Singleton getInstance(){
if(instance == null){
instance = new Singleton();
}
return instance;
}
}
package com.vayne.threadVolatile;
/**
* @author vayne
* @date 2023-11-02
*/
-------------------------------------------------------------------------------------------
class TestSingleton implements Runnable {
private static Singleton singleton;
@Override
public void run() {
int count = 0;
while (count < 10000) {
count++;
}
singleton = Singleton.getInstance();
}
public static Singleton getSingle() {
while (true) {
if (singleton != null) break;
}
return singleton;
}
}
public class Test {
public static void main(String[] args) {
TestSingleton one = new TestSingleton();
TestSingleton two = new TestSingleton();
TestSingleton three = new TestSingleton();
new Thread(one).start();
new Thread(two).start();
new Thread(three).start();
System.out.println("one和two的比较:"+(one.getSingle() == two.getSingle()));
System.out.println("one和three的比较:"+(one.getSingle() == three.getSingle()));
System.out.println("two和three的比较:"+(two.getSingle() == three.getSingle()));
}
}
查看控制台输出结果:发现
说明这个单例模式存在问题,那么问题的根源在于:
我们知道Java的多线程是开启线程资源,每一个线程是通过抢占CPU时间片执行的:
第一种情况:暂不考虑发生指令重排,此时没有任何锁的限制,也就是:
public class Singleton {
private static Singleton instance;
private Singleton() {
}
public static Singleton getInstance() {
if (instance == null) {
instance = new Singleton();
}
return instance;
}
}
当线程一通过判断进入到获取实例的代码中的时候还没执行赋值的时候,这时候线程二正好拿到时间片执行发现这个实例为空,于是它也进入到了创建实例的代码块中,所以创建了两个对象,从而导致创建了两个不同的对象。
那么解决的方案就是要加上synchronized关键字
但是我们加上synchronized之后会大大降低系统的性能
所以我们会在锁外加上一个if判断
此时就成了这样:
public class Singleton {
private static Singleton instance;
private Singleton() {
}
public static Singleton getInstance() {
//第一次检测
if (instance == null) {
//同步
synchronized (Singleton.class) {
if (instance == null) {
instance = new Singleton();
}
}
}
return instance;
}
}
但是,这时就有会有隐藏问题:执行时发生了指令重排
一个对象的初始化不是原子性的操作,可以分为 3 步:
1.分配内存空间
2.初始化对象
3.设置 instance 指向分配对象的内存地址
但是我们也知道了为了提升处理速度带来的指令重排,那么此时执行的结果可能就是:
1.分配内存空间
2.设置 instance 指向分配对象的内存地址,但是对象还没初始化,但此时 instance != null
3.初始化对象
我们假设线程 A 先执行 getInstance() 方法,当执行完指令 2 时恰好发生了线程切换,切换到了线程 B 上;如果此时线程 B 也执行 getInstance() 方法,那么线程 B 在执行第一个判断时会发现 instance != null,所以直接返回 instance,而此时的 instance 是没有初始化过的,如果我们这个时候访问 instance 的成员变量就可能触发空指针异常或jvm的其他异常。
解决方法是使用volatile修饰instance变量,禁止指令重排序即可。
所以出现了终极方案:
public class Singleton {
private volatile static Singleton instance;
private Singleton() {
}
public static Singleton getInstance() {
//第一次检测
if (instance == null) {
//同步
synchronized (Singleton.class) {
if (instance == null) {
instance = new Singleton();
}
}
}
return instance;
}
}
如果没有使用double checked locking,则在多线程环境下可能会出现重复创建对象的问题。这是因为两个线程同时进入if语句块时,无法判断另一个线程是否已经创建了对象,因此可能会导致重复创建对象。所以我们先在判断前加入了synchronized关键字,但是同步锁会大大降低系统性能,因此我们在锁的外面加上了if判断,但是此时可能会发生指令重排,导致变量被赋予了内存地址,但是没有实际对象,所以volatile解决了这个问题。
double-check中volatile关键字的必要性
而在double checked locking中,如果没有使用volatile关键字,则无法保证多线程环境下的线程安全。这是因为如果没有使用volatile关键字,两个线程无法同步访问和修改对象的状态,可能会导致一个线程看到的对象状态不是最新的,从而导致线程安全问题。因此,在使用double checked locking时,建议使用volatile关键字来修饰对象的状态,以确保多线程环境下的线程安全。
volatile和synchronized的区别
-
volatile 本质是在告诉 JVM 当前变量在寄存器(工作内存)中的值是不确定的,需要从主存中读取;synchronized 这是锁定当前变量,只有当前线程可以访问该变量,其他线程被阻塞住,直到该线程完成变量操作为止。
-
volatile 仅能用在变量级别;synchronized 则可以用在变量、方法和类级别
-
volatile 仅能实现变量的修改可见性,不能保证原子性;而 synchronized 则可以保证变量修改的可见性和原子性
-
volatile 不会造成线程的阻塞;synchronized 可能会造成线程的阻塞
-
volatile 标记的变量不会被编译器优化;synchronized 标记的变量可能会被编译器优化
原子类
我们以AtomicInteger
为例,深入理解原子类的用法和原理:
概述:java从JDK1.5开始提供了java.util.concurrent.atomic包(简称Atomic包),这个包中的原子操作类提供了一种用法简单,性能高效,线程安全地更新一个变量的方式。
因为变量的类型有很多种,所以在Atomic包里一共提供了13个类,属于4种类型的原子更新方式,分别是原子更新基本类型、原子更新数组、原子更新引用和原子更新属性(字段)。
本次我们只讲解使用原子的方式更新基本类型,使用原子的方式更新基本类型Atomic包提供了以下3个类:
AtomicBoolean: 原子更新布尔类型
AtomicInteger: 原子更新整型
AtomicLong: 原子更新长整型
以上3个类提供的方法几乎一模一样,所以本节仅以AtomicInteger为例进行讲解,AtomicInteger的常用方法如下:
public AtomicInteger(): 初始化一个默认值为0的原子型Integer
public AtomicInteger(int initialValue): 初始化一个指定值的原子型Integer
int get(): 获取值
int getAndIncrement(): 以原子方式将当前值加1,注意,这里返回的是自增前的值。
int incrementAndGet(): 以原子方式将当前值加1,注意,这里返回的是自增后的值。
int addAndGet(int data): 以原子方式将输入的数值与实例中的值(AtomicInteger里的value)相加,并返回结果。
int getAndSet(int value): 以原子方式设置为newValue的值,并返回旧值。
其原子性作用前面展示过:
不使用原子类时:
点击查看代码
<details>
<summary>点击查看代码</summary>
package com.vayne.thread;
public class Test {
public volatile int inc = 0;
public void increase() {
inc++;
}
public static void main(String[] args) throws InterruptedException {
final Test test = new Test();
for(int i=0;i<10;i++){
new Thread(){
public void run() {
for(int j=0;j<1000;j++)
test.increase();
};
}.start();
}
//这里规定线程数大于2,一般有GC线程以及main主线程
while (Thread.activeCount()>2){
Thread.yield();
}
System.out.println(test.inc);
}
}
</details>
使用Atomic原子类
点击查看代码
package com.vayne.thread;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.locks.ReentrantLock;
public class Test {
// public int inc = 0;
public AtomicInteger inc = new AtomicInteger();
public void increase() {
inc.incrementAndGet();
}
public static void main(String[] args) throws InterruptedException {
final Test test = new Test();
for(int i=0;i<10;i++){
new Thread(){
public void run() {
for(int j=0;j<1000;j++)
test.increase();
};
}.start();
}
while (Thread.activeCount()>2){
Thread.yield();
}
System.out.println(test.inc);
}
}
AtomicInteger原理
AtomicInteger的本质:自旋锁 + CAS算法
我们查看Atomic Integer源码发现
// cas算法的实现类
private static final Unsafe unsafe = Unsafe.getUnsafe();
CAS的全称是: Compare And Swap(比较再交换); 是现代CPU广泛支持的一种对内存中的共享数据进行操作的一种特殊指令。CAS可以将read-modify-write转换为原子操作,这个原子操作直接由处理器保证。CAS有3个操作数,内存值V,旧的预期值A,要修改的新值B。当且仅当旧预期值A和内存值V相同时,将内存值V修改为B并返回true,否则什么都不做,并返回false。
举例说明:
- 在内存值V当中,存储着值为10的变量。
- 此时线程1想要把变量的值增加1。对线程1来说,旧的预期值 A = 10 ,要修改的新值 B = 11。
- 在线程1要提交更新之前,另一个线程2抢先一步,把内存值V中的变量值率先更新成了11。
- 线程1开始提交更新,首先进行A和内存值V的实际值比较(Compare),发现A不等于V的值,提交失败。
- 线程1重新获取内存值V作为当前A的值,并重新计算想要修改的新值。此时对线程1来说,A = 11,B = 12。这个重新尝试的过程被称为自旋。
- 这一次比较幸运,没有其他线程改变V的值。线程1进行Compare,发现A和V的值是相等的。
- 线程1进行SWAP,把内存V的值替换为B,也就是12。
而CAS会引发哪些问题呢?
1.ABA问题。因为CAS需要在操作值的时候检查下值有没有发生变化,如果没有发生变化则更新,但是如果一个值原来是A,变成了B,又变成了A,那么使用CAS进行检查时会发现它的值没有发生变化,但是实际上却变化了。ABA问题的解决思路就是使用版本号。在变量前面追加上版本号,每次变量更新的时候把版本号加一,那么A-B-A 就会变成1A-2B-3A。
2.循环时间长开销大。自旋CAS如果长时间不成功,会给CPU带来非常大的执行开销。如果JVM能支持处理器提供的pause指令那么效率会有一定的提升,pause指令有两个作用,第一它可以延迟流水线执行指令(de-pipeline),使CPU不会消耗过多的执行资源,延迟的时间取决于具体实现的版本,在一些处理器上延迟时间是零。第二它可以避免在退出循环的时候因内存顺序冲突(memory order violation)而引起CPU流水线被清空(CPU pipeline flush),从而提高CPU的执行效率。
3.只能保证一个共享变量的原子操作。当对一个共享变量执行操作时,我们可以使用循环CAS的方式来保证原子操作,但是对多个共享变量操作时,循环CAS就无法保证操作的原子性,这个时候就可以用锁,或者有一个取巧的办法,就是把多个共享变量合并成一个共享变量来操作。比如有两个共享变量i=2,j=a,合并一下ij=2a,然后用CAS来操作ij。从Java1.5开始JDK提供了AtomicReference类来保证引用对象之间的原子性,你可以把多个变量放在一个对象里来进行CAS操作。
CAS与Synchronized的比较
CAS和Synchronized都可以保证多线程环境下共享数据的安全性。那么他们两者有什么区别?
Synchronized是从悲观的角度出发:
总是假设最坏的情况,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会阻塞直到它拿到锁(共享资源每次只给一个线程使用,其它线程阻塞,用完后再把资源转让给其它线程)。因此Synchronized我们也将其称之为悲观锁。jdk中的ReentrantLock也是一种悲观锁。
CAS是从乐观的角度出发:
总是假设最好的情况,每次去拿数据的时候都认为别人不会修改,所以不会上锁,但是在更新的时候会判断一下在此期间别人有没有去更新这个数据。CAS这种机制我们也可以将其称之为乐观锁。
final 关键字
final的基本使用:
修饰类
当某个类的整体定义为final的时候,就表明你不打算继承该类,而且不允许别人这么做。即这个类是不能有子类的。
注意:final类中的所有方法都隐式为final,因为无法覆盖他们,所以在final类中给任何方法添加final关键字是没有任何意义的。
修饰方法
- private 方法是隐式的final
- final方法是可以被重载的
类中所有private方法都隐式地指定为final的,由于无法取用private方法,所以也就不能覆盖它。可以对private方法增添final关键字,但这样做并没有什么好处。
public class Base {
private void test() {
}
}
public class Son extends Base{
public void test() {
}
public static void main(String[] args) {
Son son = new Son();
Base father = son;
//father.test();
}
}
Base和Son都有方法test(),但是这并不是一种覆盖,因为private所修饰的方法是隐式的final,也就是无法被继承,所以更不用说是覆盖了,在Son中的test()方法不过是属于Son的新成员罢了,Son进行向上转型得到father,但是father.test()是不可执行的,因为Base中的test方法是private的,无法被访问到。
final方法是可以被重载的
修饰参数
所有的final修饰的字段都是编译期常量吗?
public class Test {
//编译期常量
final int i = 1;
final static int J = 1;
final int[] a = {1,2,3,4};
//非编译期常量
Random r = new Random();
final int k = r.nextInt();
public static void main(String[] args) {
}
}
k的值由随机数对象决定,所以不是所有的final修饰的字段都是编译期常量,只是k的值在被初始化后无法被更改。
static final
一个既是static又是final 的字段只占据一段不能改变的存储空间,它必须在定义的时候进行赋值,否则编译器将不予通过。
因为static关键字所修饰的字段并不属于一个对象,而是属于这个类的。也可简单的理解为static final所修饰的字段仅占据内存的一个一份空间,一旦被初始化之后便不会被更改。
blank final
Java允许生成空白final,也就是说被声明为final但又没有给出定值的字段,但是必须在该字段被使用之前被赋值,这给予我们两种选择:
-
在定义处进行赋值(这不叫空白final)
-
在构造器中进行赋值,保证了该值在被使用前赋值。
这增强了final的灵活性。
public class Test {
final int i1 = 1;
final int i2;//空白final
public Test() {
i2 = 1;
}
public Test(int x) {
this.i2 = x;
}
}
可以看到i2的赋值更为灵活。但是请注意,如果字段由static和final修饰,仅能在声明时赋值或声明后在静态代码块中赋值,因为该字段不属于对象,属于这个类。
final域重排序规则
上面我们聊的final使用,应该属于Java基础层面的,当理解这些后我们就真的算是掌握了final吗? 有考虑过final在多线程并发的情况吗? 在java内存模型中我们知道java内存模型为了能让处理器和编译器底层发挥他们的最大优势,对底层的约束就很少,也就是说针对底层来说java内存模型就是一弱内存数据模型。同时,处理器和编译为了性能优化会对指令序列有编译器和处理器重排序。那么,在多线程情况下,final会进行怎样的重排序? 会导致线程安全的问题吗? 下面,就来看看final的重排序
final域为基本类型时
public class FinalDemo {
private int a; //普通域
private final int b; //final域
private static FinalDemo finalDemo;
public FinalDemo() {
a = 1; // 1. 写普通域
b = 2; // 2. 写final域
}
public static void writer() {
finalDemo = new FinalDemo();
}
public static void reader() {
FinalDemo demo = finalDemo; // 3.读对象引用
int a = demo.a; //4.读普通域
int b = demo.b; //5.读final域
}
}
写final域重排序规则
写final域的重排序规则禁止对final域的写重排序到构造函数之外,这个规则的实现主要包含了两个方面:
-
JMM禁止编译器把final域的写 重排序 到 构造函数 之外;
-
编译器会在 final域写 之后,构造函数return 之前,插入一个storestroe屏障。这个屏障可以禁止处理器把final域的写重排序到构造函数之外。
假设线程A在执行writer()方法,线程B执行reader()方法。
我们来分析writer方法,虽然只有一行代码,但实际上做了两件事情:
-
构造了一个FinalDemo对象;
-
把这个对象赋值给成员变量finalDemo
由于a,b之间没有数据依赖性,普通域(普通变量)a可能会被重排序到构造函数之外,线程B就有可能读到的是普通变量a初始化之前的值(零值),这样就可能出现错误。而final域变量b,根据重排序规则,会禁止final修饰的变量b重排序到构造函数之外,从而b能够正确赋值,线程B就能够读到final变量初始化后的值。因此,写final域的重排序规则可以确保:在对象引用为任意线程可见之前,对象的final域已经被正确初始化过了,而普通域就不具有这个保障。比如在上例,线程B有可能就是一个未正确初始化的对象finalDemo。
读final域重排序规则
读final域重排序规则为:在一个线程中,初次读对象引用和初次读该对象包含的final域,JMM会禁止这两个操作的重排序。(注意,这个规则仅仅是针对处理器),处理器会在读final域操作的前面插入一个LoadLoad屏障。实际上,读对象的引用和读该对象的final域存在间接依赖性,一般处理器不会重排序这两个操作。但是有一些处理器会重排序,因此,这条禁止重排序规则就是针对这些处理器而设定的。
read()方法主要包含了三个操作:
-
初次读引用变量finalDemo;
-
初次读引用变量finalDemo的普通域a;
-
初次读引用变量finalDemo的final域b;
读对象的普通域被重排序到了读对象引用的前面就会出现线程B还未读到对象引用就在读取该对象的普通域变量,这显然是错误的操作。而final域的读操作就“限定”了在读final域变量前已经读到了该对象的引用,从而就可以避免这种情况。读final域的重排序规则可以确保:在读一个对象的final域之前,一定会先读这个包含这个final域的对象的引用。
final域为引用类型
针对引用数据类型,final域写针对编译器和处理器重排序增加了这样的约束:在构造函数内对一个final修饰的对象的成员域的写入,与随后在构造函数之外把这个被构造的对象的引用赋给一个引用变量,这两个操作是不能被重排序的。注意这里的是“增加”也就说前面对final基本数据类型的重排序规则在这里还是使用。这句话是比较拗口的,下面结合实例来看。
public class FinalReferenceDemo {
final int[] arrays;
private FinalReferenceDemo finalReferenceDemo;
public FinalReferenceDemo() {
arrays = new int[1]; //1
arrays[0] = 1; //2
}
public void writerOne() {
finalReferenceDemo = new FinalReferenceDemo(); //3
}
public void writerTwo() {
arrays[0] = 2; //4
}
public void reader() {
if (finalReferenceDemo != null) { //5
int temp = finalReferenceDemo.arrays[0]; //6
}
}
}
针对上面的实例程序,线程A执行wirterOne方法,执行完后线程B执行writerTwo方法,然后线程C执行reader方法。
下图就以这种执行时序出现的一种情况来讨论。
由于对final域的写禁止重排序到构造方法外,因此1和3不能被重排序。由于一个final域的引用对象的成员域写入不能与随后将这个被构造出来的对象赋给引用变量重排序,因此2和3不能重排序。
对final修饰的对象的成员域读操作
JMM可以确保线程C至少能看到写线程A对final引用的对象的成员域的写入,即能看下arrays[0] = 1,而写线程B对数组元素的写入可能看到可能看不到。JMM不保证线程B的写入对线程C可见,线程B和线程C之间存在数据竞争,此时的结果是不可预知的。如果可见的,可使用锁或者volatile。
关于final重排序的总结
按照final修饰的数据类型分类:
基本数据类型:
-
final域写:禁止final域写与构造方法重排序,即禁止final域写重排序到构造方法之外,从而保证该对象对所有线程可见时,该对象的final域全部已经初始化过。
-
final域读:禁止初次读对象的引用与读该对象包含的final域的重排序。
引用数据类型:
额外增加约束:禁止在构造函数对一个final修饰的对象的成员域的写入与随后将这个被构造的对象的引用赋值给引用变量 重排序
标签:03,变量,编程,public,并发,线程,内存,volatile,final From: https://www.cnblogs.com/vaynebeself/p/17912694.html