3.1 JMM

3.1.1 JMM内存模型

Java线程的通信由JMM控制,JMM的主要目的是定义程序中各种变量的访问规则。变量包括实例字段、静态字段,但不包括局部变量与方法参数,因为它们是线程私有的,不存在多线程竞争。

JMM遵循一个基本原则:只要不改变程序执行结果,编译器和处理器怎么优化都可以。

JMM规定所有变量都存储在主内存,每条线程都有自己的工作内存,工作内存中保存被该线程使用的变量的主内存副本,线程对变量的所有操作都必须在工作空间进行,不能直接读写主内存数据。不同线程间无法直接访问对方工作内存中的变量,线程通信必须经过主内存。

Java内存模型跟CPU缓存模型类似,是基于CPU缓存模型来建立的,Java线程内模型是标准化的,屏蔽了底层不同操作系统的区别。每个线程都拥有自己的工作内存,一般不会直接操作主内存,在读取的时候把主内存中的共享变量复制到自己的工作内存。

关于主内存与工作内存的交互,即变量如何从主内存拷贝到工作内存、从工作内存同步回主内存,JMM 定义了 8 种原子操作:

操作 作用变量范围 作用
lock 主内存 把变量标识为线程独占状态
unlock 主内存 释放处于锁定状态的变量
read 主内存 把变量值从主内存传到工作内存
load 工作内存 把 read 得到的值放入工作内存的变量副本
user 工作内存 把工作内存中的变量值传给执行引擎
assign 工作内存 把从执行引擎接收的值赋给工作内存变量
store 工作内存 把工作内存的变量值传到主内存
write 主内存 把 store 取到的变量值放入主内存变量中

3.1.2 as-if-serial

不管怎么重排序,单线程程序的执行结果不能改变,编译器和处理器必须遵循 as-if-serial 语义。

为了遵循 as-if-serial,编译器和处理器不会对存在数据依赖关系的操作重排序,因为这种重排序会改变执行结果。但是如果操作之间不存在数据依赖关系,这些操作就可能被编译器和处理器重排序。

as-if-serial 把单线程程序保护起来,给程序员一种幻觉:单线程程序是按程序的顺序执行的。

3.1.3 happens-before

先行发生原则,JMM 定义的两项操作间的偏序关系,是判断数据是否存在竞争的重要手段。
JMM 将 happens-before 要求禁止的重排序按是否会改变程序执行结果分为两类。对于会改变结果的重排序 JMM 要求编译器和处理器必须禁止,对于不会改变结果的重排序,JMM 不做要求。
JMM 存在一些天然的 happens-before 关系,无需任何同步器协助就已经存在。如果两个操作的关系不在此列,并且无法从这些规则推导出来,它们就没有顺序性保障,虚拟机可以对它们随意进行重排序。

  • 程序次序规则:一个线程内写在前面的操作先行发生于后面的。
  • 管程锁定规则: unlock 操作先行发生于后面对同一个锁的 lock 操作。
  • volatile 规则:对 volatile 变量的写操作先行发生于后面的读操作。
  • 线程启动规则:线程的 start 方法先行发生于线程的每个动作。
  • 线程终止规则:线程中所有操作先行发生于对线程的终止检测。
  • 对象终结规则:对象的初始化先行发生于 finalize 方法。
  • 传递性:如果操作 A 先行发生于操作 B,操作 B 先行发生于操作 C,那么操作 A 先行发生于操作 C 。

3.2 可见性

3.2.1 退不出的循环

先看一个现象,main线程对run变量的修改对于t线程不可见,导致了t线程无法停止:

  1. @Slf4j(topic = "While")
  2. public class WhileDemo {
  3. static boolean run = true;
  4. public static void main(String[] args) throws InterruptedException {
  5. Thread t = new Thread(() -> {
  6. while (run) { }
  7. }, "t");
  8. t.start();
  9. TimeUnit.SECONDS.sleep(1);
  10. run = false;
  11. }
  12. }

3.2.2 分析

  1. 初始状态,t线程刚开始从主内存读取了run的值到工作内存。image-20210423224812159.png

  2. 因为t线程要频繁地从主内存中读取run的值,JIT编译器会将run的值缓存至自己工作内存中的高速缓存中,减少对主存中run的访问,提高效率。
    image-20210423225409195.png

  3. 1秒之后,main线程修改了run的值,并同步至主存,而t是从自己工作内存中的高速缓存中读取这个变量的值,结果永远是旧值。

image-20210423230248092.png

3.2.3 解决办法

(1)给变量run加上修饰符volatile

它可以用来修饰成员变量和静态成员变量,他可以避免线程从自己的工作缓存中查找变量的值,必须到主存中获取它的值,线程操作volitale变量都说直接操作主存。

(2)使用synchronized

  1. @Slf4j(topic = "While")
  2. public class WhileDemo {
  3. private static volatile boolean run = true;
  4. private static final Object lock = new Object();
  5. public static void main(String[] args) throws InterruptedException {
  6. Thread t = new Thread(() -> {
  7. while (run) {
  8. synchronized (lock) { }
  9. }
  10. }, "t");
  11. t.start();
  12. TimeUnit.SECONDS.sleep(1);
  13. run = false;
  14. synchronized (lock) {
  15. run = false;
  16. }
  17. }
  18. }

(3)在while循环中使用System.out.println()也会终止循环,原因可以看println()的源代码,其中执行的是PrintStream#newLine()方法:

  1. private void newLine() {
  2. try {
  3. synchronized (this) { // 加锁
  4. ensureOpen();
  5. textOut.newLine();
  6. textOut.flushBuffer();
  7. charOut.flushBuffer();
  8. if (autoFlush)
  9. out.flush();
  10. }
  11. }
  12. catch (InterruptedIOException x) {
  13. Thread.currentThread().interrupt();
  14. }
  15. catch (IOException x) {
  16. trouble = true;
  17. }
  18. }

3.2.5 synchronized/volatile

  • synchronized用于修饰方法或者代码块,volatile只能修饰变量。
  • synchronized保证操作的原子性,同时保证变量的可见性,volatile保持变量的可见性。
  • synchronized通常适用于写多读少的场景,会造成线程阻塞,volatile通常适用于写少读多的场景,不会造成线程阻塞。

3.3 模式

3.3.1 终止模式之两阶段终止模式

使用volatile实现两阶段终止:

  1. public class TwoStageTerminationDemo {
  2. public static void main(String[] args) throws InterruptedException {
  3. TwoStageTermination tst = new TwoStageTermination();
  4. tst.start();
  5. Thread.sleep(3500);
  6. tst.stop();
  7. }
  8. }
  9. @Slf4j(topic = "TwoStageTermination")
  10. class TwoStageTermination {
  11. // 监控线程
  12. private Thread monitorThread;
  13. // 是否被打断
  14. private volatile boolean stop = false;
  15. public void start() {
  16. monitorThread = new Thread(() -> {
  17. while (true) {
  18. Thread current = Thread.currentThread();
  19. // 是否被打断
  20. if (stop) {
  21. log.debug("料理后事");
  22. break;
  23. }
  24. try {
  25. TimeUnit.SECONDS.sleep(1);
  26. log.info("执行监控...");
  27. } catch (InterruptedException e) {
  28. // 睡眠被打断被清除打断标记,需要重新设置
  29. current.interrupt();
  30. }
  31. }
  32. }, "monitor");
  33. monitorThread.start();
  34. }
  35. public void stop() {
  36. stop = true;
  37. }
  38. }

3.3.2 同步模式之Balking

Balking(犹豫)模式用在一个线程发现另一个线程发现另一个线程或本线程已经做了某一件相同的事,那么本线程就无需再做了,直接结束返回,实际上是一种单例模式。

修改两阶段终止如下,监控线程执行一次之后就不再执行。

  1. public class BalkingTwoStageTerminationDemo {
  2. public static void main(String[] args) throws InterruptedException {
  3. BalkingTwoStageTermination btst = new BalkingTwoStageTermination();
  4. btst.start();
  5. TimeUnit.SECONDS.sleep(3);
  6. btst.stop();
  7. TimeUnit.SECONDS.sleep(1);
  8. btst.start();
  9. TimeUnit.SECONDS.sleep(3);
  10. btst.stop();
  11. }
  12. }
  13. @Slf4j(topic = "BalkingTwoStageTermination")
  14. class BalkingTwoStageTermination {
  15. // 监控线程
  16. private Thread monitorThread;
  17. // 是否被打断
  18. private volatile boolean stop = false;
  19. // 是否已执行
  20. private volatile boolean started = false;
  21. // 启动
  22. public void start() {
  23. synchronized (this) {
  24. if (started) {
  25. return;
  26. }
  27. stop = false;
  28. started = true;
  29. }
  30. monitorThread = new Thread(() -> {
  31. while (true) {
  32. Thread current = Thread.currentThread();
  33. // 是否被打断
  34. if (stop) {
  35. log.debug("停止监控");
  36. break;
  37. }
  38. try {
  39. TimeUnit.SECONDS.sleep(1);
  40. } catch (InterruptedException e) {
  41. e.printStackTrace();
  42. }
  43. log.info("执行监控...");
  44. }
  45. }, "monitor");
  46. monitorThread.start();
  47. }
  48. // 暂停
  49. public void stop() {
  50. stop = true;
  51. started = false;
  52. }
  53. }

3.4 有序性

3.4.1 指令重排序

JIT即时编译器的优化,可能会导致指令重排。JVM会在不影响正确性的前提下,调整语句的执行顺序。
分为三种:

  1. 编译器优化的重排序:编译器在不改变单线程程序语义的情况下,可以对语句的执行顺序进行重新排序;
  2. 指令级并行的重排序:现代处理器多采用指令级并行技术将多条指令重叠执行。对于不存在数据依赖的程序,处理器可以对机器指令的执行顺序进行重新排列。
  3. 内存系统的重排序:因为处理器使用缓存和读/写缓冲区,使得加载和存储看上去像是在乱序执行。

(1)例如以下代码:

  1. static int i, j;
  2. // 操作
  3. i = 0;
  4. j = 1;

对于ij的赋值操作顺序,对最终的结果都没有影响。因此在真正执行的时候,可能i也可能是j先被赋值。但是在多线程情况下指令重排序会影响正确性。

(2)比如new一个对象: 一般顺序为:

  • 分配内存
  • 对象初始化
  • 建立指针对应关系

高并发情况下顺序可能乱成132,对象初始化时就被另一个线程读取造成问题。

3.4.2 诡异的结果

在如下代码中,I_Result 是一个对象,有一个属性r1用来保存结果。

线程1执行actor1方法,线程2执行actor2方法,思考r1的可能结果。

  1. int num = 0;
  2. boolean ready = false;
  3. public void actor1(I_Result r) {
  4. if (ready) {
  5. r.r1 = num + num;
  6. } else {
  7. r.r1 = 1;
  8. }
  9. }
  10. public void actor2(I_Result r) {
  11. num = 2;
  12. ready = true;
  13. }
  1. 线程1先执行,线程2后执行,此时结果为1
  2. 线程2先执行,线程1后执行,此时结果为4
  3. 线程2先执行,执行到num = 2,线程1开始执行,此时结果为1
  4. 线程2先执行,但是指令重排,先执行ready = true,线程1开始执行,进入if (ready),此时结果为2

解决方法:给ready加上修复符volatile

3.4.3 重排序规则

  • 指令重排序不会对存在数据依赖关系的操作进行重排序。比如:a= 1; b = a;
  • 重排序是为了优化性能,但是不管如何重排,单线程下程序的执行结果不能改变。
  • 指令重排序保证单线程模式下的结果正确性,但是不保证多线程模式下的正确性。
  • 解决方法:volatile修饰的变量,可以禁用指令重排。

3.5 volatile原理

3.5.1 double-checked locking

以著名的DCL(double-checked locking)单例模式为例

  1. public class DCLSingleton {
  2. private DCLSingleton() { }
  3. private static DCLSingleton INSTANCE = null;
  4. /**
  5. * 在第一次线程调用getInstance(),直接在synchronized外,判断instance对象是否存在
  6. * 如果不存在,才会去获取锁,然后创建单例对象,并且返回;第二个线程调用getInstance(),
  7. * 会进行instance的空判断,如果已经有单例对象就不会去同步块中获取锁,提高效率。
  8. */
  9. private static DCLSingleton getInstance() {
  10. if (INSTANCE == null) {
  11. // 这个判断并不在synchronized同步代码块中,
  12. // 不能保证原子性、可见性和有序性,可能导致指令重排
  13. synchronized (DCLSingleton.class) {
  14. if (INSTANCE == null) {
  15. INSTANCE = new DCLSingleton();
  16. }
  17. }
  18. }
  19. return INSTANCE;
  20. }
  21. }

以上实现的特点:

  • 延迟实例化
  • 首次使用getInstance()才使用synchronbized加锁,后续使用时无需加锁。
  • 有隐含的,但很关键的一点:第一个if使用了INSTANCE变量,是在同步块之外。

但是在多线程环境下,上面的代码是有问题的,getInstance()方法对应的字节码为:

  1. 0 getstatic #2 <top/parak/jmm/DCLSingleton.INSTANCE> # 获取静态变量INSTANCE
  2. 3 ifnonnull 37 (+34) # 判断INSTANCE是否为空
  3. 6 ldc #3 <top/parak/jmm/DCLSingleton> # 从运行时常量池中将DCLSingeton的class对象推入操作数栈
  4. 8 dup # 复制操作数栈栈顶的值,即DCLSingleton的class对象
  5. 9 astore_0 # 将DCLSingleton的class对象存入局部变量表索引为0的位置
  6. 10 monitorenter # 进入INSTANCE对象的monitor
  7. 11 getstatic #2 <top/parak/jmm/DCLSingleton.INSTANCE> # 获取静态变量INSTANCE
  8. 14 ifnonnull 27 (+13) # 判断INSTANCE是否为空
  9. 17 new #3 <top/parak/jmm/DCLSingleton> # new一个DCLSingleton
  10. 20 dup # 复制操作数栈栈顶的值,即DCLSingleton的class对象
  11. 21 invokespecial #4 <top/parak/jmm/DCLSingleton.<init>># 调用DCLSingeton的构造方法
  12. 24 putstatic #2 <top/parak/jmm/DCLSingleton.INSTANCE> # 将新对象赋值给静态变量INSTANCE
  13. 27 aload_0 # 从局部变量表导出索引为0位置的元素
  14. 28 monitorexit # 退出INSTANCE对象的monitor
  15. 29 goto 37 (+8) # goto37行处理
  16. 32 astore_1 # 32-35是异常
  17. 33 aload_0
  18. 34 monitorexit
  19. 35 aload_1
  20. 36 athrow
  21. 37 getstatic #2 <top/parak/jmm/DCLSingleton.INSTANCE> # 获取静态变量INSTANCE
  22. 40 areturn

重点关注17-24行:

  • 17:创建DCLSingleton对象
  • 20:复制DCLSingleton的class对象
  • 21:调用class对象的默认构造方法
  • 24:将新对象赋值给静态变量INSTANCE

也许JVM会优化为:先执行24,再执行21,即先赋值,再调构造。

可能造成:一个线程正在造对象,对象还为空的时候就被另一线程拿去使用。

解决方法:

  1. public class DCLSingleton {
  2. private DCLSingleton() { }
  3. private volatile static DCLSingleton INSTANCE = null;
  4. private static DCLSingleton getInstance() {
  5. if (INSTANCE == null) {
  6. synchronized (DCLSingleton.class) {
  7. if (INSTANCE == null) {
  8. INSTANCE = new DCLSingleton();
  9. }
  10. }
  11. }
  12. return INSTANCE;
  13. }
  14. }

从字节码上看不出volatile的效果,但是我们从屏障的角度分析:

  1. # ====================================================> 加入对INSTANCE的读屏障
  2. # 保证此后的读取,加载的都是主存中的最新数据
  3. 0 getstatic #2 <top/parak/jmm/DCLSingleton.INSTANCE>
  4. 3 ifnonnull 37 (+34)
  5. 6 ldc #3 <top/parak/jmm/DCLSingleton>
  6. 8 dup
  7. 9 astore_0
  8. 10 monitorenter # =====================================> 保证原子性和可见性
  9. 11 getstatic #2 <top/parak/jmm/DCLSingleton.INSTANCE>
  10. 14 ifnonnull 27 (+13)
  11. 17 new #3 <top/parak/jmm/DCLSingleton>
  12. 20 dup
  13. 21 invokespecial #4 <top/parak/jmm/DCLSingleton.<init>>
  14. 24 putstatic #2 <top/parak/jmm/DCLSingleton.INSTANCE>
  15. # ====================================================> 加入对INSTANCE的写屏障
  16. # 保证此前的写入,都同步到主存当中
  17. 27 aload_0
  18. 28 monitorexit # =====================================> 保证原子性和可见性
  19. 29 goto 37 (+8)
  20. 32 astore_1
  21. 33 aload_0
  22. 34 monitorexit
  23. 35 aload_1
  24. 36 athrow
  25. 37 getstatic #2 <top/parak/jmm/DCLSingleton.INSTANCE>
  26. 40 areturn

image-20210425100017250.png

3.5.2 缓存一致性协议

Intel处理器对应的缓存一致性协议为MESI。

CPU缓存行存在四种状态:

状态 描述
M(modifed) 缓存行有效,数据被修改,与内存中的数据不一致,数据只存在于该CPU缓存行中。
E(exclusive) 缓存行有效,数据与内存中的数据一致,数据只存在于该CPU缓存行中。
S(shared) 缓存行有效,数据与内存中的数据一致,数据存在于很多CPU缓存行中。
I(invalid): 缓存行无效。

缓存一致性协议需要与多处理器的总线嗅探机制结合使用。 开启了缓存一致性协议后,处理器需要对总线进行嗅探,监听总线中这个处理器所感兴趣的数据。 如果主内存中所感兴趣的数据被其他处理器修改,那么线程工作内存中的对应数据会被立即失效。

3.5.3 实现可见性原理

JVM中volatile需要实现的内存屏障,在源代码bytecodeInterpreter中实现。

  1. OrderAccess:storeload();

这个方法根据操作系统和CPU的不同会有不同的实现(跨平台就是实现了不同平台的指令集的屏蔽),比如Linux_X86的实现:

  1. inline void OrderAccess::storeload() { fence(); }

fence的实现调用汇编指令:

  1. inline void OrderAccess::fence() {
  2. if (os::is_MP()) { // 判断是否多核处理器,如果是才有必要增加内存屏障
  3. // always use locked addl since mfence is sometimes expensive
  4. #ifdef AMD64
  5. // __asm__ volatile嵌入汇编指令
  6. __asm__ volatile ("lock; addl $0, 0(%%rsp)" : : : "cc", "memory");
  7. #else
  8. __asm__ volatile ("lock; addl $0, 0(%%esp)" : : : "cc", "memory");
  9. #endif
  10. }
  11. }

lock前缀指令在多核处理器下会引发两件事情:

  1. 将当前CPU缓存行的数据写回到系统内存。
  2. 这个写回内存的操作会使在其他CPU里缓存了该内存地址的数据无效。

为了提高提高处理速度,CPU不直接和内存进行通信,而是先将系统内存的数据读到内部缓存(L1、L2或者L3)后再进行操作,但操作完不知道何时会写到内存。如果对声明了volatile的变量进行写操作,JVM就会向CPU发送Lock前缀的指令,将这个变量所在缓存行的数据写回到系统内存。但是,就算写回到内存,如果其他CPU缓存的值还是旧的,再执行计算操作就会有问题。所以,在多核处理器下,为了保证各个CPU的缓存是一致的,就会实现缓存一致性协议,每个CPU通过嗅探在总线上传播的数据来检查自己缓存的值是否过期,当CPU发现自己缓存行对应的内存地址被修改,就会将当前CPU的缓存行设置成无效状态,当CPU对这个数据进行修改操作的时候,会重新从系统内存中把数据读到CPU缓存里。

volatile的两条实现原则:
1)Lock前缀指令会引起CPU缓存回写到内存。
Lock前缀指令导致在执行指令期间,声言CPU的LOCK#信号。在多核处理器环境中,LOCK#信号确保在声言该信号期间,CPU可以独占任何共享内存。但是,在最近的处理器里,LOCK#信号一般不锁总线,而是锁缓存,毕竟锁总线开销的比较大。
对于Intel486和Pentium处理器,在锁操作时,总是在总线上声言LOCK#信号。但在P6和目前的处理器中,如果访问的内存区域已经缓存在CPU内部,则不会声音LOCK#信号。相反,它会锁定这块内存区域的缓存并回写到内存,并使用缓存一致性机制来确保修改的原子性,此操作被成为“缓存锁定”,缓存一致性机制会阻止同时修改两个以上CPU缓存的内存区域数据。
2)一个CPU的缓存回写到内存会导致其他CPU的缓存失效。
IA-32处理器和Intel 64处理器使用MESI控制协议去维护内存缓存和其他CPU缓存的一致性。在多核处理器系统中进行操作的时候,IA-32和Intel 64处理器能嗅探其他处理器访问系统内存和它们的内部缓存。CPU通过嗅探技术保证它的内部缓存、系统内存和其他处理器的缓存的数据在总线上保持一致。
例如,在Pentium和P6 family处理器中,如果通过嗅探一个CPU来检测其他CPU打算写内存地址,而这个地址当前处于共享状态,那么正在嗅探的处理器将使它的缓存行无效,在下次访问相同内存地址时,强制执行缓存行填充。

3.5.4 实现有序性原理

为了实现volatile的内存语义,编译器在生成字节码时会通过插入内存屏障来禁止指令重排序。

内存屏障:内存屏障是一种CPU指令,它的作用是对该指令前和指令后的一些操作产生一定的约束,保证一些操作按顺序执行。

插入内存屏障的策略:

屏障类型 指令示例 说明
LoadLoad Barriers Load1;LoadLoad;Load2 保证Load1数据的读取先于Load2及后续所有读取指令的执行
StoreStore Barriers Store1;StoreStore;Store2 保证Store1数据刷新到主内存先于Store2及后续所有存储指令
LoadStore Barriers Load1;LoadStore;Store2 保证Load1数据的读取先于Store2及后续的所有存储指令刷新到主内存
StoreLoad Barriers Store1;StoreLoad;Load2 保证Store1数据刷新到主内存先于Load2及后续所有读取指令的执行

Java内存模型对编译器指定的volatile重排序规则为:

  • 当第一个操作是volatile读时,无论第二个操作是什么都不能进行重排序;
  • 当第二个操作是volatile写时,无论第一个操作是什么都不能进行重排序;
  • 当第一个操作是volatile写时,第二个操作为volatile读时,不能进行重排序。

volatile读:在每个volatile读后面分别插入LoadLoad屏障及LoadStore屏障。
3. 共享模型之内存 - 图5

  • LoadLoad屏障的作用:禁止上面所有的普通读操作和上面的volatile读操作进行重排序。
  • LoadStore屏障的作用:禁止下面的普通写和上面的volatile读进行重排序。

volatile写:在每个volatile写前面插入一个StoreStore屏障,在每个volatile写后面插入一个StoreLoad屏障。
3. 共享模型之内存 - 图6

  • StoreStore屏障的作用:禁止下面的普通写和下面的volatile写重排序。
  • StoreLoad屏障的作用:防止上面的volatile写与下面可能出现的volatile读/写重排序。

3.6 习题

3.6.1 balking模式

希望doInit()方法仅被调用一次,下面的实现是否有问题,为什么?

  1. @Slf4j(topic = "BalkingPractice")
  2. public class BalkingPractice {
  3. static volatile boolean initialized = false;
  4. private static void init() {
  5. if (initialized) {
  6. return;
  7. }
  8. doInit();
  9. initialized = true;
  10. }
  11. private static void doInit() {
  12. log.debug("init...");
  13. }
  14. }

有问题,volatile无法保证原子性,当多个线程同时调用init()方法时,此时都进入到if判断,都调用doInit()方法,此时就调用了多次。

解决方法:对init()方法的方法体,通过synchronized加锁,防止多个线程共享initialized

  1. @Slf4j(topic = "BalkingPractice")
  2. public class BalkingPractice {
  3. static volatile boolean initialized = false;
  4. final static Object lock = new Object();
  5. private static void init() {
  6. synchronized (lock) {
  7. if (initialized) {
  8. return;
  9. }
  10. doInit();
  11. initialized = true;
  12. }
  13. }
  14. private static void doInit() {
  15. log.debug("init...");
  16. }
  17. }

3.6.2 线程安全单例

饿汉式

  1. // 问题1:为什么加final?
  2. // 答:防止子类继承修改。
  3. public final class Singleton implaments Serializable {
  4. // 问题2:为什么构造函数设为私有?是否能防止反射创建新的实例?
  5. // 答:防止其他类中使用new生成新的实例,不能防止反射创建新的实例。
  6. private Singleton() { }
  7. // 问题3:这样初始化是否能保证单例对象创建时的线程安全?
  8. // 答:能,类变量在JVM类加载的初始化(clinit)阶段完成赋值,JVM保证此操作的线程安全性。
  9. private static Singleton INSTANCE = new Singleton();
  10. // 问题4:为什么提供静态方法而不是直接将INSTANCE设置为public?
  11. // 答:(1)提供更好的封装性(2)提供泛型的支持
  12. public static Singleton getInstance() {
  13. return INSTANCE;
  14. }
  15. // 问题5:这个方法有什么作用?
  16. // 答:防止反序列化时生成不同的单例对象。
  17. public Object readResolve() {
  18. return INSTANCE;
  19. }
  20. }

DCL懒汉式

  1. public final class DCLSingleton {
  2. private DCLSingleton() { }
  3. // 问题1:为什么要给单例变量加上volatile关键字修饰?
  4. // 答:防止第一次创建对象时指令重排序。
  5. private volatile static DCLSingleton INSTANCE = null;
  6. private static DCLSingleton getInstance() {
  7. if (INSTANCE == null) {
  8. synchronized (DCLSingleton.class) {
  9. // 问题3:前面已经进行一次判空,为什么还要在这里加上为空判断?
  10. // 答:防止多线程并发导致不安全的问题:单例对象重复创建。
  11. // 比如:有两个线程。都进入了第一次判空,它们都判断单例为空。
  12. // (1)t1线程获取到单例对象锁,然后创建对象,释放锁。
  13. // (2)t2线程获取到单例对象锁,由于第一次判空为真,
  14. // 如果没有进行第二次判空,它也会创建单例对象,破坏单例。
  15. if (INSTANCE == null) {
  16. INSTANCE = new DCLSingleton();
  17. }
  18. }
  19. }
  20. return INSTANCE;
  21. }
  22. }

静态内部类

  1. public final class Singleton {
  2. private Singleton() { }
  3. // 问题1:静态内部类属于饿汉式还是懒汉式?
  4. // 答:属于懒汉式,当Sinleton加载的时候,SingletonHolder并没有加载进内存,只要当第一次调用getInstance的时候,SingletonHolder才会加载进内存,并且初始化INSTANCE实例。
  5. private static class SingletonHolder {
  6. // 问题2:这样创建单例对象是否存在线程安全问题?
  7. // 答:类加载时JVM会保证线程安全。
  8. static final Singleton INSTANCE = new Singleton();
  9. }
  10. public static Singleton getInstance() {
  11. return Singletonolder.INSTANCE;
  12. }
  13. }

枚举类

  1. // 问题1:枚举单例是如何限制实例个数的?
  2. // 答:创建枚举类的时候就已经定义好了,每个枚举常量其实就是枚举类的一个静态成员变量。
  3. // 问题2:枚举单例在创建时是否有并发问题?
  4. // 答:没有并发问题,枚举类成员底层是一个静态成员边框,在类加载时就创建了,JVM会保证线程安全。
  5. // 问题3:枚举单例能否被反射破坏单例?
  6. // 答:不能,反射在newInstance的时候会检查,如果是枚举类型就会抛出异常。
  7. // 问题4:枚举单例能否被反序列化破坏单例?
  8. // 答:不能,枚举类的实现考虑到了这一点,反序列化后依然是从前的单例。
  9. // 问题5:枚举单例如果希望加入一些单例创建时的初始化逻辑应该如何做?
  10. // 答:加构造方法。
  11. enum Singleton{
  12. INSTANCE;
  13. }