剑指JUC原理-8.Java内存模型(中):https://developer.aliyun.com/article/1413627
关键在于 0: getstatic 这行代码在 monitor 控制之外,它就像之前举例中不守规则的人,可以越过 monitor 读取INSTANCE 变量的值。
这时 t1 还未完全将构造方法执行完毕,如果在构造方法中要执行很多初始化操作,那么 t2 拿到的是将是一个未初始化完毕的单例。
对 INSTANCE 使用 volatile 修饰即可,可以禁用指令重排,但要注意在 JDK 5 以上的版本的 volatile 才会真正有效
这里面,前面介绍的synchronized不是太严谨 。能保证 原子 可见 有序
synchronized仍然是可以被重排序的,并不能组织重排序,volatile才能组织重排序,但是如果共享变量完全被synchronized 所保护,那么共享变量在使用的过程中是不会有 原子 可见 有序问题的,就算中间发生了重排序,但是只要完全交给synchronized管理,是不会有有序性问题的。
刚才使用出现问题,是因为共享变量并没有完全的被synchronized保护起来,synchronized外面还有共享变量的使用
double-checked locking 解决
public final class Singleton { private Singleton() { } private static volatile Singleton INSTANCE = null; public static Singleton getInstance() { // 实例没创建,才会进入内部的 synchronized代码块 if (INSTANCE == null) { synchronized (Singleton.class) { // t2 // 也许有其它线程已经创建实例,所以再判断一次 if (INSTANCE == null) { // t1 INSTANCE = new Singleton(); } } } return INSTANCE; } }
字节码上看不出来 volatile 指令的效果
如上面的注释内容所示,读写 volatile 变量时会加入内存屏障(Memory Barrier(Memory Fence)),保证下面两点:
可见性
- 写屏障(sfence)保证在该屏障之前的 t1 对共享变量的改动,都同步到主存当中
- 而读屏障(lfence)保证在该屏障之后 t2 对共享变量的读取,加载的是主存中最新数据
有序性
- 写屏障会确保指令重排序时,不会将写屏障之前的代码排在写屏障之后
- 读屏障会确保指令重排序时,不会将读屏障之后的代码排在读屏障之前
happens-before
happens-before 规定了对共享变量的写操作对其它线程的读操作可见,它是可见性与有序性的一套规则总结,抛开以下 happens-before 规则,JMM 并不能保证一个线程对共享变量的写,对于其它线程对该共享变量的读可见
线程解锁 m 之前对变量的写,对于接下来对 m 加锁的其它线程对该变量的读可见
static int x; static Object m = new Object(); new Thread(()->{ synchronized(m) { x = 10; }},"t1").start(); new Thread(()->{ synchronized(m) { System.out.println(x); }},"t2").start();
线程对 volatile 变量的写,对接下来其它线程对该变量的读可见
volatile static int x; new Thread(()->{ x = 10; },"t1").start(); new Thread(()->{ System.out.println(x); },"t2").start();
线程 start 前对变量的写,对该线程开始后对该变量的读可见
static int x; x = 10; new Thread(()->{ System.out.println(x); },"t2").start();
线程结束前对变量的写,对其它线程得知它结束后的读可见(比如其它线程调用 t1.isAlive() 或 t1.join()等待它结束)
static int x; Thread t1 = new Thread(()->{ x = 10; },"t1"); t1.start(); t1.join(); System.out.println(x);
线程 t1 打断 t2(interrupt)前对变量的写,对于其他线程得知 t2 被打断后对变量的读可见(通过t2.interrupted 或 t2.isInterrupted)
Thread t2 = new Thread(()->{ while(true) { if(Thread.currentThread().isInterrupted()) { System.out.println(x); break; } } },"t2"); t2.start(); new Thread(()->{ try { Thread.sleep(10000); } catch (InterruptedException e) { e.printStackTrace(); } x = 10; t2.interrupt(); },"t1").start();
具有传递性,如果 x hb-> y 并且 y hb-> z 那么有 x hb-> z ,配合 volatile 的防指令重排,有下面的例子
volatile static int x; static int y; new Thread(()->{ y = 10; x = 20; },"t1").start(); new Thread(()->{ // x=20 对 t2 可见, 同时 y=10 也对 t2 可见 System.out.println(x); },"t2").start();
主要是 写屏障之前都同步到主内存
balking 模式习题
希望 doInit() 方法仅被调用一次,下面的实现是否有问题,为什么?
public class TestVolatile { volatile boolean initialized = false; void init() { if (initialized) { return; } doInit(); initialized = true; } private void doInit() { } }
其实这里,在多线程的情况下,因为没有保证多线程之间的原子性,所以会出现问题。
线程安全单例习题
单例模式有很多实现方法,饿汉、懒汉、静态内部类、枚举类,试分析每种实现下获取单例对象(即调用
getInstance)时的线程安全,并思考注释中的问题
- 饿汉式:类加载就会导致该单实例对象被创建
- 懒汉式:类加载不会导致该单实例对象被创建,而是首次使用该对象时才会创建
实现1
// 问题1:为什么加 final // 问题2:如果实现了序列化接口, 还要做什么来防止反序列化破坏单例 public final class Singleton implements Serializable { // 问题3:为什么设置为私有? 是否能防止反射创建新的实例? private Singleton() {} // 问题4:这样初始化是否能保证单例对象创建时的线程安全? private static final Singleton INSTANCE = new Singleton(); // 问题5:为什么提供静态方法而不是直接将 INSTANCE 设置为 public, 说出你知道的理由 public static Singleton getInstance() { return INSTANCE; } public Object readResolve() { return INSTANCE; } }
问题1:怕将来有子类,覆盖某些方法,破坏了单例
问题2:反序列化维护的对象,和单例维护的对象不一样,因为 需要加上
public Obejct readResolve(){
return INSTANCE;
}
反序列化的过程中,一旦发现了readResolve 返回的对象,就会用你返回的对象,而不是反序列化字节码生成的对象。
问题3: 设置成public,别的类能够无限的创建对象了,并不能够防止反射
问题4: 静态成员变量,初始化操作是在类加载的时候。类加载阶段是由jvm来保证这些代码的线程安全性。所以类加载阶段做成员赋值都是线程安全的。
问题5: 用方法说明提供了更好的封装性,可以内部实现懒惰的初始化。还可以对创建的时间有更多的控制。
实现2(※※※重点难点※※※)
// 问题1:枚举单例是如何限制实例个数的 // 问题2:枚举单例在创建时是否有并发问题 // 问题3:枚举单例能否被反射破坏单例 // 问题4:枚举单例能否被反序列化破坏单例 // 问题5:枚举单例属于懒汉式还是饿汉式 // 问题6:枚举单例如果希望加入一些单例创建时的初始化逻辑该如何做 enum Singleton { INSTANCE; }
问题1: 枚举单例是一种实现单例模式的方式,它通过使用枚举类型来限制实例个数为一个。在Java中,枚举类型是保证全局唯一的,因此使用枚举来实现单例可以有效地避免多线程环境下的并发问题,并且在序列化和反序列化中也可以得到保证。
问题2: 是静态成员变量,类加载阶段完成的,不会有并发问题
问题3: 枚举单例是一种实现单例模式的有效方式,因为它可以避免通过反射和序列化等方式破坏单例。枚举类型在Java中是天然的单例,只能在JVM中被实例化一次。当使用反射来访问枚举类型时,JVM会保证每个枚举类型只被实例化一次。这是因为枚举类型在Java中是特殊的类,由JVM特别处理。因此,即使使用反射来访问枚举类型并试图创建一个新的实例,JVM也会返回已经存在的单例。因此,枚举单例不能被反射破坏单例。
问题4: 枚举单例在Java中也可以防止被反序列化破坏单例。当一个枚举类型被序列化并再次反序列化时,JVM会自动确保只存在一个实例。这是因为枚举类型的序列化和反序列化是由JVM处理的,并且JVM会保证对于同一个枚举类型只有一个实例。因此,尝试通过反序列化来破坏枚举单例是无效的,反序列化操作会返回已经存在的单例实例,而不会创建新的实例。总之,枚举单例是一种安全且可靠的单例实现方式,即使在面对反射和序列化等特性时也能保持单例的完整性。
问题5: 饿汉式
问题6: 如果希望在枚举单例创建时加入一些初始化逻辑,可以在枚举中定义一个构造函数,并将初始化逻辑放在其中
public enum SingletonEnum { INSTANCE; private SingletonEnum() { // 这里可以加入单例创建时的初始化逻辑 System.out.println("SingletonEnum has been initialized."); } // 其它方法 }
在上面的示例中,我们定义了一个名为SingletonEnum的枚举,其中INSTANCE是该枚举的唯一实例。同时,我们定义了一个构造函数来进行单例的初始化逻辑。
此处需要注意,在枚举类型中,构造函数必须是私有的。这是因为Java语言规范规定,只能在枚举类型内部定义枚举常量,而枚举常量的创建是由编译器自动生成的。因此,枚举类型的构造函数必须是私有的,以确保只有编译器才能调用它。
实现3:
public final class Singleton { private Singleton() { } private static Singleton INSTANCE = null; // 分析这里的线程安全, 并说明有什么缺点 public static synchronized Singleton getInstance() { if( INSTANCE != null ){ return INSTANCE; } INSTANCE = new Singleton(); return INSTANCE; } }
性能问题:
由于synchronized关键字锁住的是整个方法,在多线程高并发的情况下,可能会导致性能下降。每次调用getInstance()时都需要获取锁,即使实例已经被创建。
实例提前创建:在多线程环境下,如果有一个线程获取锁并创建了实例,其它线程进入方法后会发现实例已被创建,但仍需要等待锁的释放才能继续执行。这样就造成了实例的提前创建,占用了内存资源。
实现4:DCL
public final class Singleton { private Singleton() { } // 问题1:解释为什么要加 volatile ? private static volatile Singleton INSTANCE = null; // 问题2:对比实现3, 说出这样做的意义 public static Singleton getInstance() { if (INSTANCE != null) { return INSTANCE; } synchronized (Singleton.class) { // 问题3:为什么还要在这里加为空判断, 之前不是判断过了吗 if (INSTANCE != null) { // t2 return INSTANCE; } INSTANCE = new Singleton(); return INSTANCE; } } }
问题1: 保障了可见性 和 有序性
可见性
- 写屏障(sfence)保证在该屏障之前的 t1 对共享变量的改动,都同步到主存当中
- 而读屏障(lfence)保证在该屏障之后 t2 对共享变量的读取,加载的是主存中最新数据
有序性
- 写屏障会确保指令重排序时,不会将写屏障之前的代码排在写屏障之后
- 读屏障会确保指令重排序时,不会将读屏障之后的代码排在读屏障之前
问题2: 这种方式在第一次检查时可以避免不必要的锁竞争,提高了性能。并且通过使用volatile关键字修饰实例变量,保证了线程之间对实例的可见性,确保正确的初始化。这样既满足了线程安全性,又提高了性能。
问题3: 其实是为了防止最一开始同时有多个线程 绕过了第一个 不等于 null的判断
实现5
public final class Singleton { private Singleton() { } // 问题1:属于懒汉式还是饿汉式 private static class LazyHolder { static final Singleton INSTANCE = new Singleton(); } // 问题2:在创建时是否有并发问题 public static Singleton getInstance() { return LazyHolder.INSTANCE; } }
这段代码使用了静态内部类的方式实现了单例模式,常被称为“静态内部类单例模式”。
属于懒汉式还是饿汉式?
该实现方式属于懒汉式,因为实例对象的创建发生在静态内部类LazyHolder被调用时。而不是在类加载时就创建实例对象。
在创建时是否有并发问题?
由于静态内部类LazyHolder在类加载时并不会被初始化,只有当getInstance()方法被调用时才会加载,因此不存在并发创建实例的问题。
同时,由于Java虚拟机在加载类时会对类进行加锁,所以多线程同时加载Singleton类也不会导致并发问题。
这种实现方式同时具备了懒汉式和饿汉式的优点。在需要使用实例对象时才会进行实例化,避免了饿汉式可能造成的资源浪费;同时在加载静态内部类时,JVM会自动加锁,保证了线程安全性。因此,该实现方式既满足了线程安全性,又提高了性能和资源利用率。