设计模式--单例Singleton(扩展篇)

2019-10-19  本文已影响0人  jason_czm

本文接着上篇的设计模式--单例Singleton继续扩展,深入谈谈几个知识点。

1.Java 内存模型(JMM)

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

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

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

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

*原子性*

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

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

*可见性*

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

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

*有序性*

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

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

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

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

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

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 做标记,就可以禁用编译器层面的重排序。

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

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

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

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

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

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

从上表我们可以看出:

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

只要不影响程序单线程、顺序执行的结果,就可以对两个指令重排序。
乱序执行技术是处理器为提高运算速度而做出违背代码原有顺序的优化。

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

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()方法来处理,其简要流程如下:

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

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

参考来源

https://www.jianshu.com/p/1e82c75034b7

上一篇 下一篇

猜你喜欢

热点阅读