还撩单例?枚举小姐姐你永远撩不动

1.Java 内存模型(JMM)

不同架构的物理计算机可以有不一样的内存模型,Java 虚拟机也有自己的内存模型。

  • Java 虚拟机规范中试图定义一种 Java 内存模型(Java Memory Model,简称  JMM)来屏蔽掉各种硬件和操作系统的内存访问差异,以实现让 Java  程序在各种平台下都能达到一致的内存访问效果,不必因为不同平台上的物理机的内存模型的差异,对各平台定制化开发程序。

  • Java 内存模型提出目标在于,定义程序中各个变量的访问规则,即在虚拟机中将变量存储到内存和从内存中取出变量这样的底层细节。

Java 线程之间的通信由 Java 内存模型(JMM)控制,JMM 决定一个线程对共享变量(实例域、静态域和数组)的写入何时对其它线程可见。

  • 从抽象的角度来看,JMM 定义了线程和主内存 Main Memory(堆内存)之间的抽象关系:线程之间的共享变量存储在主内存中,每个线程都有自己的本地内存(工作内存) Local Memory(只是一个抽象概念,物理上不存在),存储了该线程的共享变量副本。

  • 本地内存是 JMM 的一个抽象概念,并不真实存在。它涵盖了缓存,写缓冲区,寄存器以及其他的硬件和编译器优化。

    还撩单例?枚举小姐姐你永远撩不动

  • 线程 1 和线程 2 之间需要通信的话,必须经过两个步骤:

    • 线程 1 把本地内存(工作内存) 1 中更新过的共享变量副本刷新到主内存中。

    • 线程 2 到主内存中读取线程1 之前更新过的共享变量。

  • 两个步骤实质上是线程1再向线程 2 发送消息,而这个通信过程必须经过主内存。

    • JMM 通过控制主内存与每个线程的本地内存(工作内存)之间的交互,来为 Java 程序员提供内存可见性保证。

关于主内存与工作内存(本地内存)之间的具体交互协议,即一个变量如何从主内存拷贝到工作内存、如何从工作内存同步回主内存之类的实现细节。

Java 内存模型中定义了下面 8 种操作来完成。

还撩单例?枚举小姐姐你永远撩不动

还撩单例?枚举小姐姐你永远撩不动

内存交互基本操作的 3 个特性。

原子性

  • 原子性即一个操作或者多个操作,要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。原子是世界上的最小单位,具有不可分割性。

  • 在 Java 中,对基本数据类型的变量的读取和赋值操作是原子性操作,即这些操作是不可被中断的,要么执行,要么不执行。

可见性

  • 可见性是指当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值。

  • JMM 是通过在线程 1 变量工作内存修改后将新值同步回主内存,线程 2 在变量读取前从主内存刷新变量值,这种依赖主内存作为传递媒介的方式来实现可见性。

有序性

  • 有序性规则表现在以下两种场景。

    Java 内存模型的一系列运行规则,都是围绕原子性、可见性、有序性特征建立。是为了实现共享变量的在多个线程的工作内存的数据一致性,多线程并发,指令重排序优化的环境中程序能如预期运行。

    • 线程内,从某个线程的角度看方法的执行,指令会按照一种叫 " 串行 "(as-if-serial)的方式执行,此种方式已经应用于顺序编程语言。

    • 线程间,这个线程 " 观察 " 到其他线程并发地执行非同步的代码时,由于指令重排序优化,任何代码都有可能交叉执行。

    • 唯一起作用的约束是:对于同步方法,同步块(synchronized 关键字修饰)以及 volatile 字段的操作仍维持相对有序。

JMM 属于语言级的内存模型,它确保在不同的编译器和不同的处理器平台之上,通过插入特定类型的 Memory Barrier(内存屏障)来禁止特定类型的编译器重排序和处理器重排序,为上层提供一致的内存可见性保证.

内存屏障(Memory Barrier),又称内存栅栏,是一个 CPU 指令, 有两个作用:

(1)阻止屏障两侧的指令重排序,插入一条 Memory Barrier 会告诉编译器和 CPU,不管什么指令都不能和这条 Memory Barrier 指令重排序。

(2)强制把写缓冲区/高速缓存中的脏数据等写回主内存,让缓存中相应的数据失效。如一个 Write-Barrier(写入屏障)将刷出所有在 Barrier 之前写入 cache 的数据,因此,任何  CPU 上的线程都能读取到这些数据的最新版本。

JMM把内存屏障指令分为下列四类:

还撩单例?枚举小姐姐你永远撩不动

内存屏障阻碍了 CPU 采用优化技术来降低内存操作延迟,因此必定会带来性能损失。

2.指令重排

在执行程序时,为了提高性能,处理器和编译器会对指令做重排序。

重排序不是随意重排序,它需要满足以下两个条件。

(1)数据依赖性

如果两个操作访问同一个变量,其中一个为写操作,此时这两个操作之间存在数据依赖性。编译器和处理器不会改变存在数据依赖性关系的两个操作的执行顺序,即不会重排序。

(2)as-if-serial

所有的动作(Action)都可以为了优化而被重排序,但是必须保证它们重排序后的结果和程序代码本身(单线程下的执行)的应有结果是一致的,编译器、runtime 和处理器都必须遵守 as-if-serial 语义。

处理器和编译器会在满足上述2个条件的基础上对指令做重排序优化。

还撩单例?枚举小姐姐你永远撩不动

1:编译器优化的重排序编译器 在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。

编译器在编译过程中,会进行指令优化,有时与其等待阻塞指令(如等待缓存刷入)完成,不如先执行其他指令。与处理器层面的乱序执行相比,编译器重排序能够完成更大范围、效果更好的乱序优化。

编译器层面的重排序,自然可以由编译器控制。使用 volatile 做标记,就可以禁用编译器层面的重排序。

可以回忆下上篇你真的了解单例模式吗?一文中说到的"懒汉写法-双重检查锁定DCL"存在的重排序案例。

下面就来聊聊volatile作用以及它是怎么防止编译器层面指令重排的:

volatile是一个变量修饰符,只能用来修饰变量。

volatile写:当写一个volatile变量时,JMM会把该线程对应的本地内存中的共享变量刷新到主内存。

volatile读:当读一个volatile变量时,JMM会把该线程对应的本地内存置为无效。线程接下来将从主内存中读取共享变量。

JMM针对编译器制定的volatile重排序规则表

还撩单例?枚举小姐姐你永远撩不动

从上表我们可以看出:

  • 当第二个操作是volatile写时,不管第一个操作是什么,都不能重排序。这个规则确保volatile写之前的操作不会被编译器重排序到volatile写之后。

  • 当第一个操作是volatile读时,不管第二个操作是什么,都不能重排序。这个规则确保volatile读之后的操作不会被编译器重排序到volatile读之前。

  • 当第一个操作是volatile写,第二个操作是volatile读/volatile写时,不能重排序。

    volatile变量读写前后插入内存屏障以避免重排序,保证了有序性:

  • 在每个volatile写操作的前面插入一个StoreStore屏障。

    • 对于这样的语句Store1 StoreStore Store2,在Store2及后续写入操作执行前,保证Store1的写入操作对其它处理器可见

  • 在每个volatile写操作的后面插入一个StoreLoad屏障。

    • 对于这样的语句Store1 StoreLoad Load2,在Load2及后续所有读取操作执行前,保证Store1的写入对所有处理器可见

  • 在每个volatile读操作的后面插入一个LoadLoad屏障。

    • 对于这样的语句Load1 LoadLoad Load2,在Load2及后续读取操作要读取的数据被访问前,保证Load1要读取的数据被读取完毕

  • 在每个volatile读操作的后面插入一个LoadStore屏障。  

    • 对于这样的语句Load1 LoadStore Store2,在Store2及后续写入操作被刷出前,保证Load1要读取的数据被读取完毕

2:指令级并行的重排序(处理器)。如果不存在数据依赖性,处理器 可以改变语句对应机器指令的执行顺序。

还撩单例?枚举小姐姐你永远撩不动

只要不影响程序单线程、顺序执行的结果,就可以对两个指令重排序。

乱序执行技术是处理器为提高运算速度而做出违背代码原有顺序的优化。

3:内存系统的重排序(处理器)。处理器使用 缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。

3.枚举类型为什么是最优单例模式

public enum EnumSingleton {
  INSTANCE;
}

Joshua Bloch, Effective Java 2nd Edition p.18

A single-element enum type is the best way to implement a singleton

单元素枚举类型是实现单例的最佳方法

为什么说枚举是(一般情况下)最好的Java单例实现呢?他也做出了简单的说明:

It is more concise, provides the serialization machinery for free,  and provides an ironclad guarantee against multiple instantiation, even  in the face of sophisticated serialization or reflection attacks.

大意就是,枚举单例可以有效防御两种破坏单例(即使单例产生多个实例)的行为:反射攻击序列化攻击

枚举单例的防御机制

(1)对反射的防御

所有Java枚举都隐式继承自Enum抽象类,而Enum抽象类根本没有无参构造方法,只有如下一个构造方法:

protected Enum(String name, int ordinal) {
    this.name = name;
    this.ordinal = ordinal;
}

如果想通过反射来获取枚举的实例

Constructor con = EnumSingleton.class.getDeclaredConstructor(String.class, int.class);

测试直接就会抛出异常

Exception in thread "main" java.lang.IllegalArgumentException: Cannot reflectively create enum objects

可见,JDK反射机制内部完全禁止了用反射创建枚举实例的可能性。

(2)对序列化的防御

EnumSingleton instanceA = EnumSingleton.getInstance();
ObjectOutputStream oos = new ObjectOutputStream(new         FileOutputStream("sersingle_file"));
oos.writeObject(instanceA);

File file = new File("sersingle_file");
ObjectInputStream ois = new ObjectInputStream(new FileInputStream(file));
EnumSingleton instanceB = (EnumSingleton) ois.readObject();
System.out.println(singletonA.equals(singletonB));

换成枚举进行测试后,发现返回结果是true。这是因为ObjectInputStream类中,对枚举类型有一个专门的readEnum()方法来处理,其简要流程如下:

  • 通过类描述符取得枚举单例的类型EnumSingleton;

  • 取得枚举单例中的枚举值的名字(这里是INSTANCE);

  • 调用Enum.valueOf()方法,根据枚举类型和枚举值的名字,获得最终的单例。

这种处理方法与readResolve()方法大同小异,都是以绕过反射直接获取单例为目标。不同的是,枚举对序列化的防御仍然是JDK内部实现的。

综上所述,枚举单例确实是目前最好的单例实现了,不仅写法非常简单,并且JDK能够保证其安全性,不需要我们做额外的工作。

点击查看更多高质量编程视频

发布了22 篇原创文章 · 获赞 37 · 访问量 2万+

猜你喜欢

转载自blog.csdn.net/fengzongfu/article/details/103636167
今日推荐