1. volatile关键字的概览
1.1 多线程下变量的不可见性
概述:
在多线程并发执行下,多个线程修改共享的成员变量,会出现一个线程改变了共享变量饿之后,另一个线程不能直接看到该线程修改后的变量最新值【也就是说在多个线程访同一个变量的时候,当其中一个线程改变这个变量值,但是其它线程未获取到已经改变的最新值,出现变量值不统一,不同同步问题】
变量不可见性案例:
package volatiles;
/**目标:研究一下多线程下变量访问的不可见性现象
* 准备内容:
* 1. 准备两个线程
* 2. 定义一个成员变量
* 3. 开启两个线程,其中一个线程负责修改,另外一个负责读取
*
*/
public class volatiledome01 {
//main 方法,作为主线程
public static void main(String[] args) {
//开启一个线程
MyThread myThread = new MyThread();
myThread.start();
//主线程执行
while (true) {
if (myThread.isFlag() ) {
System.out.println("主线程进入执行----------");
}
}
}
}
class MyThread extends Thread {
//定义成员变量
private boolean flag = false;
@Override
public void run() {
try {
Thread.sleep(1000);
//当修改flag的时间比较晚,在主线程获取成员变量之后,被修改,则主线程就不会读取到最新数据,就发生变量不可见性
} catch (InterruptedException e) {
e.printStackTrace();
}
//触发修改共享成员变量
flag = true;
System.out.println("flag = " + flag);
}
public boolean isFlag() {
return flag;
}
public void setFlag(boolean flag) {
this.flag = flag;
}
}
输出结果:
flag = true
我们可以看到,子线程run方法中,已经将flag设置为true,但是main方法中为及时读取到最新值,从而无法进入if语句,所有也就没有是如何打印。
1.2 变量不可见性内存语义
概述:
在介绍多线程并发修改变量不可见线程的原因之前,我们需要了解回顾一下javan内存模型(和java并发编程有关的模型):JMM
- JMM(Java Memory Model):java内存模型,是java虚拟机规范中所定义的一种内存模型,java内存模型是标准化得,屏蔽掉了底层不同计算机的区别。
- JAVA内存模型描述了java程序中各种变量(线程共享变量)的访问规则,以及在JVM中将变量存储到内存和从内存中读取变量这样的底层细节
JVMM有一下规定:
- 所有的共享变量都存储在主内存中,这里说的变量指的是实例变量和类变量,不包含局部变量,因为局部变量是线程私有的,不存在竞争问题,即不存在不可见性。
- 每一个线程还存在自己的工作内存,线程的工作内存,保留了被线程使用的变量的工作副本
- 线程对变量的所有操作(读和取)都必须在工作内存中完成,而不能直接读写主内存中的变量
- 不同线程之间不能直接访问对方工作内存中的变量,线程间变量的值得传递需要通过主内存中转类完成
本地内存和主内存的关系
案例分析
分析:
1. 子线程t从主内存读取到数据放入其对应的工作内存
2. 将flag的值更好改为true,但是这个时候flag的值还没有写回主内存,
3. 此时main方法读取到了flag的值为false
4. 当子线程t将flag的值写回去后,但是main函数直面的while(true)调用的是系统比较底层的代码,速度快,快到没有时间再到主内存中读取主内存中的值,所以while(true)读取到的值一直是false。(如果有一个时刻main线程从主内存中读取到false的最新值,那么if语句就可以执行,main线程何时从主内存中读取最新值,我们无法控制)
小结
可见性问题的原因:所有共享变量存在于主内存中,每个线程由自己的本地内存,而且线程读写共享数据也是通过本地内存交换的,所以才导致了可见性问题
1.3 变量不可见性解决方案
如何实现在多线程下访问共享变量的可见性:也就是实现一个线程修改变量后,对其他线程可见?
方案:
- 加锁
- 使用volatile关键字
案例实现
package volatiles;
/** 目标 解决多线程下并发修改变量的不可加性问题
*
* 解决方案:
* 1.加锁
* 2. 使用volatile关键字修饰
**/
public class volatiledome02 {
//main 方法,作为主线程
public static void main(String[] args) {
//开启一个线程
MyThread02 myThread = new MyThread02();
myThread.start();
//主线程执行
while (true) {
// synchronized (myThread){
if (myThread.isFlag() ) {
System.out.println("主线程进入执行----------");
}
// }
/* 方案一 :synchronized 加锁后执行过程
1. 线程获得锁对象
2. 清空当前线程的工作内存
3. 从主内存中重新拷贝共享变量最新的值到线程的工作内存中成为副本
4. 代码块中的代码
5. 将修改后的副本的值,刷新回主内存中
6. 线程释放锁
*/
}
}
}
class MyThread02 extends Thread {
//定义成员变量
private volatile boolean flag = false;
/* 方案二、使用volatile关键字修饰
* 工作原理:
* 1. 子线程从主内存中读取到数据放入其对应的工作内存
* 2. 将flag的值修改为true,但这个时候flag的最新值还未写回到主内存中
* 3. 此时main方法读取到的flag还是false
* 4. 当子线程叫flag的值写回去,失效其他线程对此变量副本
* 5. 再次对flag进行操作的时候线程会从主内存读取最新的值,放入到工作内存中
*
* 总结:volatile保证不同呢线程对共享变量的操作的可见性,也就是说一个线程修改了volatile修饰的变量,当修改写回主内存时,另外一个线程立刻可见最新值
*
*/
@Override
public void run() {
try {
Thread.sleep(1000);
//当修改flag的时间比较晚,在主线程获取成员变量之后,被修改,则主线程就不会读取到最新数据,就发生变量不可见性
} catch (InterruptedException e) {
e.printStackTrace();
}
//触发修改共享成员变量
flag = true;
System.out.println("flag = " + flag);
}
public boolean isFlag() {
return flag;
}
public void setFlag(boolean flag) {
this.flag = flag;
}
}
2. volatile的其他特性
2.1 volatile的特性概述
volatile除了可以实现并发下共享变量的可见性,还有一些其他特性:
- volatile的原子性问题:volatile不能保证原子性操作
- 禁止指令重排序:volatile可以防止指令重排序操作
2.2 volatile不保证原子性
所谓的原子性是指在一次操作或者多次操作中,要么所有的操作全部都得到了执行并且不会受到任何因素的干扰,而中断,要么所有的操作都不执行,volatile不保证原子性(我们可以把原子性理解为,并发修改下的一种安全机制)
案例演示:
package volatiles;
/** 目标:研究volatile的原子性操作
*
* 基本观点:volatile不能保证原子性操作
*
* 1. 定义一个共享变量
* 2. 开启100个线程,每一个线程负责为变量累加10000次
* 3. 在线程执行完毕之后看变量的结果
*
**/
public class volatiledome03 {
public static void main(String[] args) {
//1. 创建一个线程任务对象
ThreadTarget threadTarget = new ThreadTarget();
//2. 开始100个线程对象执行这个任务(100个线程执行同一个任务)
for (int i = 1; i <=100 ; i++) {
new Thread(threadTarget,"第"+i+"个线程").start();
}
}
}
//线程任务类
class ThreadTarget implements Runnable{
//定义一个共享变量 volatile修饰
private volatile int count = 0;
@Override
public void run() {
for (int i = 1; i <=10000 ; i++) {
count++;
System.out.println(Thread.currentThread().getName() + "线程count =============>>>>>>> " + count);
}
}
}
输出结果:
多次运用,我们会发现,多个线程执行,结果无法实现累加结果为1000000
第一次执行:
第90个线程线程count =============>>>>>>> 998860
第90个线程线程count =============>>>>>>> 998861
第90个线程线程count =============>>>>>>> 998862
第90个线程线程count =============>>>>>>> 998863
第90个线程线程count =============>>>>>>> 998864
第二次执行:
第3个线程线程count =============>>>>>>> 999941
第3个线程线程count =============>>>>>>> 999942
第3个线程线程count =============>>>>>>> 999943
第3个线程线程count =============>>>>>>> 999944
第三次执行:
第22个线程线程count =============>>>>>>> 999572
第22个线程线程count =============>>>>>>> 999573
第22个线程线程count =============>>>>>>> 999574
第22个线程线程count =============>>>>>>> 999575
第22个线程线程count =============>>>>>>> 999576
案例问题分析:
以上问题主要是发生在count++
操作上:
count++
操作包含3个操作步骤:
- 从主内存中读取数据到工作内存
- 对工作内存中的数据进行++操作
- 将工作内存中的数据写回到主内存
count++操作不是一个原子性操作,也就是说在某一个时刻对某一个操作的执行,有可能被其他的线程打断
- 假设此时X的值是100,线程A需要对改变量进行自增1的操作,首先它需要从主内存中读取变量X的值。由于CPU的切换关系,此时CPU的执行权被切换到了B线程。A线程就处于就绪状态,B线程处于运行状态
- 线程B也需要从主内存中读取X变量的值,由于线程A没有对X值做任何修改因此此时B读取到的数据还是100
- 线程B工作内存中X执行了+1操作,但是未刷新之主内存中
- 此时CPU的执行权切换到A线程上,由于此时线程B没有将工作内存中的数据刷新到主内存,因此A线程工作内存中的变量值还是100,没有失效。A线程对工作内存中的数据进行了+1操作
- 线程B将101写入主内存
- 线程A将101写入到主内存,虽然计算了2次,但是只进行了一次修改
小结:
在多线程环境下,volatile关键字只能保证共享变量的可见性,但是并不能保证对数据操作的原子性,也就是说在多线程环境下volatile修饰的变量也是线程不安全的
在多线程环境下,要保证数据的安全线,我们还需要使用锁机制
问题解决方案:
- 方案一:使用锁机制
- 方案二:原子类
使用锁机制
/** 目标:使用加锁机制保证volatile修饰的变量的原子性操作
* @date 2021/7/2 10:54
**/
public class volatiledome04 {
public static void main(String[] args) {
//1. 创建一个线程任务对象
ThreadTarget01 threadTarget = new ThreadTarget01();
//2. 开始100个线程对象执行这个任务(100个线程执行同一个任务)
for (int i = 1; i <=100 ; i++) {
new Thread(threadTarget,"第"+i+"个线程").start();
}
}
}
//线程任务类
class ThreadTarget01 implements Runnable{
//定义一个共享变量 volatile修饰
private volatile int count = 0;
@Override
public void run() {
//加锁保证每次只有一次线程可以获得该锁
synchronized (ThreadTarget01.class){ //锁对象为唯一
for (int i = 1; i <=10000 ; i++) {
count++;
System.out.println(Thread.currentThread().getName() + "线程count =============>>>>>>> " + count);
}
}
}
}
输出显示:
第2个线程线程count =============>>>>>>> 999995
第2个线程线程count =============>>>>>>> 999996
第2个线程线程count =============>>>>>>> 999997
第2个线程线程count =============>>>>>>> 999998
第2个线程线程count =============>>>>>>> 999999
第2个线程线程count =============>>>>>>> 1000000
原子类
概述:Java从JDK1.5开始提供了java.util.concurrent.atomic
包(简称Atomic包),这个包的原子操作类提供了一种做法简单,性能高效,线程安全地更新一个变量的方式
案例
import java.util.concurrent.atomic.AtomicInteger;
/** 目标:使用原子类来实现变量修改的原子性操作
* @date 2021/7/2 10:54
**/
public class volatiledome05 {
public static void main(String[] args) {
//1. 创建一个线程任务对象
ThreadTarget02 threadTarget = new ThreadTarget02();
//2. 开始100个线程对象执行这个任务(100个线程执行同一个任务)
for (int i = 1; i <=100 ; i++) {
new Thread(threadTarget,"第"+i+"个线程").start();
}
}
}
//线程任务类
class ThreadTarget02 implements Runnable{
//提供一个原子类 是基于cas乐观锁的机制
private AtomicInteger atomicInteger = new AtomicInteger();
@Override
public void run() {
for (int i = 1; i <=10000 ; i++) { //incrementAndGet先递增在获取
System.out.println(Thread.currentThread().getName() + "线程count =============>>>>>>> " + atomicInteger.incrementAndGet());
}
}
}
输出显示:
第11个线程线程count =============>>>>>>> 999996
第11个线程线程count =============>>>>>>> 999997
第11个线程线程count =============>>>>>>> 999998
第11个线程线程count =============>>>>>>> 999999
第11个线程线程count =============>>>>>>> 1000000
2.3 volatile禁止指令重排序
概述:
什么是重排序:
为了提高性能,编译器和处理器常常会对既定的代码执行顺序进行指令重排序
原因:
一个好的内存模型实际上会放松对处理器和编译器规则的束缚,也就是说软件技术和硬件技术都为同一个目标而进行奋斗:在不改变程序执行结果的前提下,尽可能提高执行效率。JMM对底层尽量减少约束,使其能够发挥自身优势。因此,在执行程序时,为了提高性能,编译器和处理器常常会对指令进行重排序。一般重排序可以分为如下三种:
1. 编译器优化的重排序。编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序;
2. 指令级进行的重排序。现代处理器采用了指令级并行技术来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序;
3. 内存系统的重排序。由于处理器使用缓存和读/写缓冲区,这使的加载和存储操作看上去可能是乱序执行的。
重排序的优点:
- 重排序可以提高处理的速度
重排序的问题
引入:
重排序虽然可以提交执行的效率,但是在并发执行下,JVM虚拟机底层并不能保证重排序下带来的安全性等问题
案例:
/**
* 目标:研究重排序情况下带来的问题
**/
public class OutOFOrderDome {
//新建几个静态变量
public static int a = 0, b = 0;
public static int i = 0, j = 0;
public static void main(String[] args) throws Exception {
int count = 0;
while (true) {
count++;
a =0;
b =0;
i =0;
j =0;
//定义两个线程
//线程A
Thread t1 = new Thread(new Runnable() {
@Override
public void run() {
a = 1;
i = b;
}
});
//线程B
Thread t2 = new Thread(new Runnable() {
@Override
public void run() {
b = 1;
j = a;
}
});
t1.start();
t2.start();
t1.join();//让t1线程优先执行完毕
t2.join();//让t2线程优先执行完毕
//得到线程执行完毕以后,变量的结果
System.out.println("第"+count+"次输出"+ "i = " + i + " , j = " + j);
if (i == 0 && j==0) {
break;
}
}
}
}
案例中的四行代码执行顺序决定了最终 i 和 j 的值,在执行的过程中可能会出现三种情况如下:
但是有一种情况大家可能没有发现,经过测试如下结果:
现象分析:
- 按照以前的观点:代码执行的顺序是不会改变的,也就第一个线程是a = 0 是在 i = b 之前执行的,第二线程 b = 1 是在 j = a 之前执行的。
- 发生了重排序:在线程A和线程B内部的两行代码的实际执行顺序和代码在java文件中的顺序不一致的,代码指令并不是严格按照代码顺序执行的,他们的顺序改变了,这样就发生了重排序,这里颠倒的是 a = 1, i = b 以及 j = a ,b = 1的顺序,从而发生了指令重排序。直接获取了 i= b(0) ,j = a(0)的值,显然这个值是不对的。
解决重排序并发问题:
volatile修饰变量后可以实现禁止重排序
/**
* 目标:使用volatile修饰变量从而防止指令重排序带来的并发问题
**/
public class OutOFOrderDome01 {
//新建几个静态变量 使用volatile修饰变量
public volatile static int a = 0, b = 0;
public volatile static int i = 0, j = 0;
public static void main(String[] args) throws Exception {
int count = 0;
while (true) {
count++;
a =0;
b =0;
i =0;
j =0;
//定义两个线程
//线程A
Thread t1 = new Thread(new Runnable() {
@Override
public void run() {
a = 1;
i = b;
}
});
//线程B
Thread t2 = new Thread(new Runnable() {
@Override
public void run() {
b = 1;
j = a;
}
});
t1.start();
t2.start();
t1.join();//让t1线程优先执行完毕
t2.join();//让t2线程优先执行完毕
//得到线程执行完毕以后,变量的结果
System.out.println("第"+count+"次输出"+ "i = " + i + " , j = " + j);
if (i == 0 && j==0) {
break;
}
}
}
}
3. volatile的内存语义
3.1 volatile读写建立的happens-before关系
概念
- 上面的内容简述了重排序的原则,为了提高处理速度,jvm会对代码进行编译优化,也就是指令重排序优化,并发编程洗指令重排序会带来一些安全隐患:如指令重排序导致多个线程操作之间的不可见性。
- 从JDK5开始,剔除可
happens-before
的概念,通过这个概念来阐述操作之间的内存不可见性,如果一个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须存在happens-before关系。这里提到的两个操作既可以是在一个线程之中,也可以是在不同线程之间 - 所有为了解决多线程可见性问题,就搞出了happens-before原则,让线程之间遵守这些原则,编译器还会优化我们的语句,所有等于是给编译器优化的约束,不能让它优化的不知道东南西北了
- 简单来说:happens-before应该翻译成:前一个操作的结果可以被后续的操作获取,讲白点就是前面一个操作变量a赋值为1,那么后面一个操作肯定能知道a已经变成了1
Happens-before规定
具体的一共有六项规则:
- 程序顺序规则(单线程规则)
- 解释:一个线程中的每个操作,happens-before于该线程中的任意后续操作(同一个线程中前面的所有写操作对后面的操作可见)
- 锁规则(Synchronized,Lock等)
- 解释:对一个锁的解锁,happens-before于随后对这个锁的加锁(如果线程1解锁了monitor a ,接着线程2锁定了a,那么线程1解锁a之前的写写操作都对线程2可见,线程1和线程2可以是同一个线程)
- volatile变量规则
- 解释:对一个volatile域的写,happens-before于任意后续对这个volatile域的读【如果线程1写入了volatile变量V(临界资源),接着线程2读取了V,那么,线程1写入v及之前的写操作都对线程2可见,线程1和线程2可以是同一个线程】
- 传递性
- 解释:如果A happens-before B,且B happens-before C, 那么A happens-before C(A h-b B, B h-b C,那么可以得到A h-b C)
- start()规则
- 解释:如果线程A执行操作ThreadB.start()来启动线程B,那么A线程的ThreadB.start()操作happens-before 于线程B中的任意操作
- 假定线程A在执行过程中,通过执行ThreadB.start()来启动线程B,那么线程A对共享变量的修改在接下来线程B开始执行前对线程B可见。注意:线程B启动之后,线程A在对变量修改线程B未必可见
join()规则
写volatile变量时,无论前一个操作是什么,都不能重排序
- 读volatile变量时,无论后一个操作时什么,都不能重排序
- 当先写volatile变量,后读volatile比那里时,不能重排序
4.volatile相关问题
4.1 long和double的原子性
概述
在java中,long和double都是8个字节,共64位(一个字节=8bit),那么如果是一个32位的系统,读写long或者double变量时就会涉及到原子性问题,因为32位的系统要读完一个64位的变量,需要分两步执,每次读取32位,这样就对double和long变量的赋值就会出问题:
如果有两个线程同时写一个变量内存,一个进程写低32位,而另一个写高32位,这样将导致获取的64位数据是失效的数据
案例演示 ```java
public class LongAndDouble implements Runnable{
private volatile static long along = 0;
private volatile long value;
public LongAndDouble(long value){
this.setValue(value);
}
public static long getAlong() {
return along;
}
public static void setAlong(long along) {
LongAndDouble.along = along;
}
public long getValue() {
return value;
}
public void setValue(long value) {
this.value = value;
}
@Override
public void run() {
int i = 0 ;
while (i < 100000){
LongAndDouble.along = this.getValue();
i++;
//赋值操作
long temp = LongAndDouble.along;
//取值操作
if (temp != 1L && temp !=-1L) {
System.out.println("出现错误结果 temp = " + temp);
System.exit(0);
}
}
System.out.println("运算正确");
}
public static void main(String[] args) throws InterruptedException {
//获取并打印当前JVM是32位还是64位
String sysNum = System.getProperty("sun.arch.data.model");
System.out.println(" 系统位数 sysNum = " + sysNum);
LongAndDouble t1 = new LongAndDouble(1);
LongAndDouble t2 = new LongAndDouble(-1);
Thread thread = new Thread(t1);
Thread thread1 = new Thread(t2);
thread.start();
thread1.start();
thread.join();
thread1.join();
}
}
**测试结果:**<br />上面的代码在32位环境和64位环境执行的结果是不一样的:
- 32位环境:出现错误结果
- 原因:32位环境无法一次读取long类型数据,多线程环境下Long变量的读写是不完整的,导致temp变量,几部等于1也不等于-1,出现了long和double读写原子性问题
- 64位环境:运行正确<br />**小结**
1. 结论:
> 如果是在64位的系统中,那么对64位的long和double变量的读写都是原子操作的。即可以一次性读写long或double的整个64bit。如果在32位jvm上,long和double就不在是原子性操作了。
2. 解决方法
> **需要使用volatile关键字来防止此类现象**
- 对于64位的long和double,如果没有被volatile修饰,那么对其操作可以不是原子的。在操作的时候,可以分成两步,每次对32位操作
- 如果实现volatile修饰long和double,那么其读写都是原子操作
- 在实现JVM时,可以自由选择是否把读写long和double作为原子操作
- java中对于long和double类型的写操作不是原子操作,而是分成两个32位的写操作。读操作是否也分成了两个32位的读呢,在JSR-133之前的规范中,读也是分成两个32位,但是重JSR-133规范开始,JDK5开始,读操作也都具有原子性
- java中对于其他类型的读写操作都是原子操作(除了long和double类型以外)
- 对于引用类型的读写操作都是原子操作,无论引入类型的实际类型是32位的值,还是64位的值
- java商业虚拟机已经解决了long和double的读写操作的原子性
<a name="ffec1890"></a>
#### 4.2 volatile在双重检查锁的单例中的应用
**单例概述**<br />单例是需要在内存中永远只能创建一个类的实例<br />单例的作用:节约内存和保证共享计算的结果正确,以及方便管理<br />单例模式的适用场景:
- 全局信息类:例如任务管理器对象,或者需要一个对象记录整个网站在线流量等信息
- 无状态工具类:类似于整个系统的日志对象等,我们只需要一个单例日志对象负责记录,管理系统日志信息
**八种单例模式**<br />单例模式我们可以提供出8种写法,有很多时候我么存在饿汉式单例的概念,以及懒汉式单例的概念。
- 饿汉式单例的含义是:在获取单例对象之前已经创建完成了
- 懒汉式单例是指,在真正需要单例的时候才创建对该对象
**饿汉式单例的2种写法**
1. 饿汉式(静态常量)
```java
/**
* 目标:饿汉式(静态常量)
* 步骤:
* 1. 构造器私有
* 2. 定义一个静态常量保证一个唯一的实例对象(单例)
**/
public class Singleton1 {
//2. 定义一个静态常量保存一份唯一的实例对象,(单例)
public static final Singleton1 INSTANCE = new Singleton1();
//1. 构造器私有
private Singleton1(){
}
//3. 提供一个方法返回单例对象
public static Singleton1 getInstance(){
return INSTANCE;
}
}
class Test01{
public static void main(String[] args) {
Singleton1 t1 = Singleton1.getInstance();
Singleton1 t2 = Singleton1.getInstance();
System.out.println("t2 = " + t2);
System.out.println("t1 = " + t1);
}
}
- 饿汉式(静态代码块)
```java
/**
- 目标:饿汉式(静态代码块)
- 步骤:
- 构造器私有
- 定义一个静态常量保证一个唯一的实例对象(单例),可以通过静态代码块初始化单例对象
- 提供一个方法返回单例对象 **/
public class Singleton2 {
//2. 定义一个静态常量保存一份唯一的实例对象,(单例)
public static final Singleton2 INSTANCE ;
static {
//静态代码块初始化单例对象
INSTANCE = new Singleton2();
}
//1. 构造器私有
private Singleton2(){
}
//3. 提供一个方法返回单例对象
public static Singleton2 getInstance(){
return INSTANCE;
}
}
class Test02{ public static void main(String[] args) { Singleton2 t1 = Singleton2.getInstance(); Singleton2 t2 = Singleton2.getInstance(); System.out.println(“t2 = “ + t2); System.out.println(“t1 = “ + t1); } }
**懒汉式单例的4种写法**<br />**特点**
> 在真正需要单例的时候才创建出该对象,在java程序中,有时候需要推迟一些高开销的初始化操作,并且只有在使用这些对象的时候才初始化,此时程序员可能会采用延迟初始化<br />**值得注意的是**:要正确的实现线程安全的延迟初始化还需要一些技巧,否则很容易出现问题。
1. 懒汉式(线程不安全)不推荐
```java
/** 目标:懒汉式(线程不安全的写法)
* 步骤:
* 1. 构造器私有
* 2. 定义一个静态的变量存储单例对象(定义的时候不初始化该对象)
* 3. 定义一个获取单例的方法,每次返回单例对象的时候先询问是否有对象,有直接返回,没有就创建一个新的单例对象
*
*/
public class Singleton3 {
//2. 定义一个静态的变量存储单例对象(定义的时候不初始化该对象)
private static Singleton3 INSTANCE;
//1. 构造器私有
private Singleton3(){ }
//3. 定义一个获取单例的方法,每次返回单例对象的时候先询问是否有对象,有直接返回,没有就创建一个新的单例对象
public static Singleton3 getINSTANCE() {
if (INSTANCE == null) {
INSTANCE = new Singleton3();
}
return INSTANCE;
}
}
懒汉式(线程安全的)
分析:使用synchronized关键字修饰方法包装线程安全,但是性能差,并发下只能有一个线程进入获取单例对象·/** 目标:懒汉式(线程安全的写法)
* 步骤:
* 1. 构造器私有
* 2. 定义一个静态的变量存储单例对象(定义的时候不初始化该对象)
* 3. 定义一个获取单例的方法,每次返回单例对象的时候先询问是否有对象,有直接返回,没有就创建一个新的单例对象
* 4. 加锁synchronized关键字,保证线程安全
*/
public class Singleton3 {
//2. 定义一个静态的变量存储单例对象(定义的时候不初始化该对象)
private static Singleton3 INSTANCE;
//1. 构造器私有
private Singleton3(){ }
//3. 定义一个获取单例的方法,每次返回单例对象的时候先询问是否有对象,有直接返回,没有就创建一个新的单例对象
public static synchronized Singleton3 getINSTANCE() {
if (INSTANCE == null) {
INSTANCE = new Singleton3();
}
return INSTANCE;
}
}
懒汉式(加锁优化,不安全) ```java
/** 目标:懒汉式(线程不安全的写法)
- 步骤:
- 构造器私有
- 定义一个静态的变量存储单例对象
- 定义一个获取单例的方法,每次返回单例对象 */ public class Singleton4 { //2. 定义一个静态的变量存储单例对象(定义的时候不初始化该对象) private static Singleton4 INSTANCE; //1. 构造器私有 private Singleton4(){ } //3. 定义一个获取单例的方法,每次返回单例对象 public static Singleton4 getINSTANCE() { if (INSTANCE == null) { //会有很多线程进入判断 //性能得到优化,但是依然不能保证第一次线程安全性 synchronized (Singleton4.class){ INSTANCE =new Singleton4(); } } return INSTANCE; } } ```
- 懒汉式(volatile双重检查模式) ```java
/**
- 目标: 双重检查机制,以及是使用volatile 修饰(最好,最安全)
- 构造器私有
- 定义一个静态的变量存储单例对象
- 提供一个方法进行双重检查机制返回单例对象
- 必须使用volatile修饰静态的变量 */
public class Singleton5 { //必须使用volatile修饰静态的变量 private volatile static Singleton5 INSTANCE ;
//1. 构造器私有
private Singleton5(){ }
//3. 定义一个获取单例的方法,每次返回单例对象
public static Singleton5 getINSTANCE() {
if (INSTANCE == null) {
synchronized (Singleton5.class){
//二次验证,判断单例对象是否为null
if (INSTANCE == null) {
INSTANCE =new Singleton5();
}
}
}
return INSTANCE;
}
}
**分析,底层可能进行重排序:**<br />双重检查的优点:线程安全延迟加载,效率高
> **为什么要使用volatile保证安全?**
1. 禁止指令重排序
- 对象实际创建要经过一下几个步骤
- 分配内存空间
- 调用构造器,初始化示例
- 返回地址给引用
- 所以,new Singleto() 是一个非原子操作,编译器可能会重排序【构造函数可能整个对象初始化完成前执行完毕,即赋值操作(只是在内存中开辟一片存储区后直接返回内存的引用)再初始化对象完成】。而线程C子在线程A赋值完成时判断单例对象不为null了,此时C拿到的将是一个没有初始化完成的半成品。这样是很危险的。因为极有可能线程C会即系拿着这个没有初始化的对象中的数据进行操作,此时容易触发"NPE异常",图解<br />![](https://img2020.cnblogs.com/blog/2026387/202107/2026387-20210706181837088-918443688.jpg#crop=0&crop=0&crop=1&crop=1&id=rNnZP&originHeight=517&originWidth=1234&originalType=binary&ratio=1&rotation=0&showTitle=false&status=done&style=none&title=)
2. 保证可见性
- 由于可见性问题,线程A在自己的工作线程内创建了实例,但此时还未同步到主内存中;此时线程C在主内存中判断单例对象是否为null,那么线程C有将在自己的工作线程中,创建一个实例,这样就创建了多个实例。
- 如果加上了volatile修饰,判断之后,保证了可见性,一旦线程A 返回了实例,线程C可以立刻发现,单例对象不为null。
**静态内部类**<br />引入:<br />JVM在类初始化阶段(即在class被加载后,且线程使用之前),会执行类的初始化。在执行类的初始化期间,JVM会去获得一个锁。这个锁可以同步多个线程对同一个类的初始化<br />基于这个特性,可以实现另一种线程安全的延迟初始化方案
```java
/**
* 描述: 静态内部类方式,
* 基于类的初始化实现延迟加载和线程安全的单例设计
**/
public class Singleton6 {
private Singleton6(){ }
private static class SingletonTnstance{
private static final Singleton6 INSTANCE = new Singleton6();
}
public static Singleton6 getInstance(){
return SingletonTnstance.INSTANCE;
}
}
小结
- 静态内部类似在被调用时才会被加载,这种方式实现了懒汉单例的一种思想,需要用到的时候才去创建单例,加载JVM,这种方式又实现了线程安全的创建单例对象
- 通过对比基于volatile的双重判定锁方案和基于类初始化方案的对比,我们会发现基于类初始化的方案的实现代码更加简洁。但是基于volaile的双重检查锁定方案还有一个额外的优势:除了可以对静态字段实现延迟加载初始化外,还可以对实例字段实现延迟初始化
枚举实现单例
/**
*枚举方式实现单例
* 引入: 枚举实际上是一个多例的模式,如果我们直接定义一个实例,就相当于是单例了,java机制是保证其线程安全
**/
public enum Singleton7 {
INSTANCE;
public void whatever(){}
}