1、java内存模型
Java内存模型(Java Memory Model,JMM)是Java编程语言中用于处理并发编程的一组规则和规范。它定义了Java程序中多线程之间如何交互以及内存如何被共享和访问的规则。它定义了主内存,工作内存的抽象概念,底层对应着 CPU 寄存器、缓存、硬件内存、 CPU 指令优化等。
- 主内存(Main Memory):主内存是所有线程共享的内存区域,包含了所有的变量、实例对象和类信息。
- 工作内存(Working Memory):每个线程都有自己的工作内存,用于存储主内存中的部分数据副本。线程对变量的读写操作都在工作内存中进行。
JMM体现在以下几个方面:
- 原子性(Atomicity):指的是对于基本数据类型(例如int、long等)的读写操作是原子的,即不可分割的。这意味着其他线程要么看到完整的操作结果,要么看不到。
- 可见性(Visibility):可见性指的是一个线程对变量的修改能否被其他线程立即看到。在Java内存模型中,如果一个线程修改了共享变量的值,其他线程可能不会立即看到这个变化,除非采取特定的同步机制。
- 有序性(Ordering):指的是程序执行的顺序必须遵循一定的规则。在没有同步机制的情况下,指令的执行顺序可能会被重排序,但是Java内存模型保证适当使用同步机制时指令的顺序不会被打乱。
Q1:主内存和工作内存有什么区别和联系?
主内存和工作内存都是Java内存模型中的概念,用于描述线程之间的内存交互和数据共享。
主内存中的数据是线程之间共享的,所有线程都可以读取和修改主内存中的数据。当一个线程对主内存中的数据进行修改时,其他线程可能不会立即看到这个变化,除非采取特定的同步机制来确保可见性。
此外,主内存是所有线程共享的内存区域,而工作内存是每个线程私有的内存区域。主内存存储了所有的变量和实例对象,而工作内存中只包含了线程需要使用的部分数据副本。线程对变量的读写操作都在工作内存中进行,不同线程之间的操作不会直接影响到主内存,需要通过特定的机制来同步和确保可见性。
2、可见性
2.1、案例一
首先我们来看一个案例:
@Slf4j(topic = "c.Demo1")
public class Demo1 {static boolean flag = true;public static void main(String[] args) throws InterruptedException {Thread t1 = new Thread(() -> {while (flag){}},"t1");t1.start();Thread.sleep(1000);log.info("t1线程停止");flag = false;}
}
此案例期望的结果是,主线程在睡眠1s后将标记设置为false,然后t1线程的条件不满足就停止运行。
最终结果:虽然主线程在睡眠1s后将标记修改成了false,但是t1线程没有停止运行。
2.2、问题分析
为什么会造成上面的结果?答案是与JMM中的可见性有关。
初始状态下,主内存中会记录成员变量run的值为true,此时t1线程读取的是主内存中的值。
因为 t 线程要频繁从主内存中读取 run 的值,JIT 编译器会将 run 的值缓存至自己工作内存中的高速缓存中, 减少对主存中 run 的访问,提高效率。如下图,类似于redis的缓存。
主线程睡眠了1s之后修改run的值为false,此时修改的是主存中的run。但是t线程还是在从工作内存中读取run,造成读取的结果永远是旧的值。类似于redis缓存不一致问题。
2.3、解决方案
@Slf4j(topic = "c.Demo1")
public class Demo1 {//从主内存获取最新值volatile static boolean flag = true;public static void main(String[] args) throws InterruptedException {Thread t1 = new Thread(() -> {while (flag){}},"t1");t1.start();Thread.sleep(1000);log.info("t1线程停止");flag = false;}
}
在成员变量上使用volatile关键字。
它可以用来修饰成员变量和静态成员变量,他可以避免线程从自己的工作缓存中查找变量的值,必须到主存中获取 它的值,线程操作 volatile 变量都是直接操作主存。
2.4、可见性&原子性
可以确保变量的可见性,但不能保证原子性。原子性是指对变量的读取和写入操作是不可分割的,要么全部完成,要么全部不完成。volatile关键字只能保证对被volatile修饰变量的读取和写入操作在内存中是可见的,但不能保证复合操作的原子性。
例如我有一个成员变量volatile int i = 0;虽然加了volatile关键字保证多线程间对于i修改是即时可见的,但是如果我要进行i++操作,因为i++操作在字节码层面不是原子性的,只是加上volatile关键字无法解决线程时间片到期,上下文切换的问题。
Q1:synchronized关键字能够保证可见性吗?
当一个线程获取到对象的锁(或类的锁)并执行进入临界区代码时,它会清空工作内存中的数据,从主内存中重新读取变量的值,确保获取到最新的值。当线程释放锁时,会将对变量的修改刷新回主内存,从而保证了可见性。近似于redis缓存策略的先删除缓存再更新数据库。
具体来说,synchronized保证了以下几点:
- 获取锁时刷新变量值:线程获取锁时会清空工作内存中的数据,并重新从主内存中获取变量的最新值,确保线程获取到的是最新的值。
- 释放锁时刷新变量值:线程在释放锁之前会将对变量的修改刷新回主内存,使得其他线程在获取锁后能够看到最新的值。
- 同一时刻只有一个线程能够执行:对变量的修改操作不会被多个线程同时执行,避免了竞态条件和可见性问题。
但是有一个前提条件:只有被synchronized完全控制的变量才可以保证可见性。
2.5、使用volatile改造两阶段终止模式
@Slf4j(topic = "c.TwoStagesBreak")
public class TwoStagesBreak {public static void main(String[] args) throws InterruptedException {//使用volatiles改造两阶段中止模式log.info(Thread.currentThread().getName() + "->run");Monitor monitor = new Monitor();monitor.startMonitor();//当前线程睡眠3s后执行打断操作Thread.sleep(3000);log.info("stop monitor");monitor.stopMonitor();}
}@Slf4j(topic = "c.Monitor")
class Monitor {private Thread monitor;private volatile Boolean flag = false;private Boolean isStart = false;/*** 开始监控线程*/public void startMonitor() {synchronized (this) {if (isStart) {return;}isStart = true;}monitor = new Thread(() -> {log.info(Thread.currentThread().getName() + "->run");while (true) {if (flag) {log.info("Monitor interrupted");break;}try {Thread.sleep(2000);//睡眠时非正常打断log.info("执行监控");} catch (InterruptedException e) {}}});monitor.start();}/*** 打断线程监控*/public void stopMonitor() {flag = true;monitor.interrupt();}
}
打断线程监控方法中,此时将被volatile关键字修饰的flag改为true,因为flag被volatile修饰,所以修改对监视线程同步可见。
再次调用监视线程的interrupt方法是为了如果在监视线程睡眠时打断,使其进入catch块,不再多执行一次执行监控相关代码。
如果多个线程同时访问startMonitor方法,可能会开启多次监控。此时我们采用了Balking模式解决这个问题:在一个线程发现另一个线程或本线程已经做了某一件相同的事,那么本线程就无需再做 了,直接结束返回。
在上面的案例中,引入了isStart变量。并且对监控线程使用了同步代码块。当线程一进入方法,争抢到了锁,会判断isStart,因为是第一次调用,所以isStart == null,然后线程一会将isStart设置成true,执行剩下的代码并且释放锁,当线程二争抢到锁后,发现此时isStart != null,就直接返回避免重复执行。
3、有序性
有序性指的是程序执行的顺序必须遵循一定的规则。在多线程环境中,由于指令执行的并发性,可能会导致指令的执行顺序发生变化,从而产生意料之外的结果。为了解决这个问题,Java内存模型定义了一系列规则,确保适当使用同步机制时指令的顺序不会被打乱,保证程序的有序性。
3.1、指令重排
而有序性又引出了指令重排的概念:
指令重排是指处理器或编译器为了优化程序执行速度而对指令执行顺序进行重新排序的过程。在现代计算机系统中,为了提高性能,处理器和编译器可能会对指令进行重排,但这种重排不能影响程序的最终结果,必须保证程序的语义不变。
因为i和j都是赋值操作,并且互相不依赖,先执行给i赋值和先执行给j赋值没有必然的先后顺序的联系。所以可以是:
也可以是:
但是在多线程下的指令重排,也可能会造成以下的问题:
- 数据竞争(Data Race):如果指令重排导致多个线程对共享数据的操作顺序发生变化,可能会导致数据竞争问题,从而产生不确定的结果。
- 可见性问题:指令重排可能导致某些变量的修改对其他线程不可见,破坏了程序的可见性。
3.2、案例二
常规情况下可能会有的结果
1、先执行线程二,线程二全部执行完成后执行线程一,此时的值为4。
2、先执行线程一,线程一全部执行完在执行线程二,此时的值为1。
3、线程二执行完成赋值操作,此时切换到线程一,值为1。
但是因为上文所说指令重排序的问题,最终的执行结果还有可能为0。
我们可以使用volatile关键字解决这样的问题。
4、volatile原理
volatile 的底层实现原理是内存屏障,Memory Barrier(Memory Fence)。
-
对 volatile 变量的写指令后会加入写屏障。
-
对 volatile 变量的读指令前会加入读屏障。
写屏障(sfence)保证在该屏障之前的,对共享变量的改动,都同步到主存当中:
而读屏障(lfence)保证在该屏障之后,对共享变量的读取,加载的是主存中最新数据。
写屏障会确保指令重排序时,不会将写屏障之前的代码排在写屏障之后,读屏障会确保指令重排序时,不会将读屏障之后的代码排在读屏障之前,从而保证了有序性。
例如在上面的代码中,ready变量使用了volatile关键字,上一行的num=2就不会排在ready=true的后面。
但是,并不能保证读屏障不会排在写屏障的前面,只能保证写屏障前的所有代码不会排在写屏障的后面。有序性也只能保证本线程中代码的有序,不能控制其他线程。
5、双检锁单例模式问题
5.1、存在的问题&分析
@Slf4j(topic = "c.Demo3")
public final class Singleton {private Singleton() {}private static Singleton INSTANCE = null;public static Singleton getInstance() {if (INSTANCE == null) { // t2// 首次访问会同步,而之后的使用没有 synchronizedsynchronized (Singleton.class) {if (INSTANCE == null) { // t1INSTANCE = new Singleton();}}}return INSTANCE;}
}
上面是一个单例模式的实现。其具有以下特点:
- 懒惰实例化,并非随着类的加载而加载。
- 首次使用 getInstance() 才使用 synchronized 加锁,后续使用时无需加锁。
- 第一个 if 使用了 INSTANCE 变量,是在同步块之外,这就会导致多线程并发运行时会存在问题。
下面从字节码的层面分析一下问题:
字节码的含义:
getstatic #2:获取静态字段
ifnonnull 37:如果静态字段不为 null,则跳转到第 37 行,意味着单例对象已经被创建,无需再次创建。
ldc #3 :将常量池中索引为 3 的类引用推送至栈顶
dup:复制栈顶数值并将复制值压入栈顶,此时栈顶有两个相同的类引用。
astore_0:将栈顶引用类型数值存储到局部变量表中第 0 个位置。
monitorenter:进入对象的监视器(锁定),用于实现线程同步,下面是对单例对象的创建部分,需要加锁。
getstatic #2:再次获取静态字段,检查是否已经被其他线程创建。
ifnonnull 27:如果静态字段不为 null,则跳转到第 27 行,意味着单例对象已经被其他线程创建,无需再次创建。
new #3 :创建一个新实例,并将其引用推送至栈顶。
dup:复制栈顶数值并将复制值压入栈顶,此时栈顶有两个相同的单例对象引用。
invokespecial #4 :调用对象构造函数
<init>
进行初始化。putstatic #2 :将栈顶的单例对象引用存储到静态字段中,完成单例对象的创建。
aload_0:将局部变量表中第 0 个位置的引用类型数值加载到栈顶。
monitorexit:退出对象的监视器(释放锁),解锁完成对象创建。
goto 37:跳转到第 37 行,即整个单例创建流程结束。
astore_1:将栈顶引用类型数值存储到局部变量表中第 1 个位置,用于异常处理。
aload_0:将局部变量表中第 0 个位置的引用类型数值加载到栈顶,即单例对象引用。
monitorexit:在异常处理时,再次退出对象的监视器(释放锁),确保锁的正常释放。
aload_1:将局部变量表中第 1 个位置的引用类型数值加载到栈顶,即异常对象引用。athrow:将栈顶的异常对象引用抛出。
getstatic #2:最后再次获取静态字段,确保单例对象已经被创建。
areturn:将栈顶的单例对象引用返回给调用者。
我们重点看标红的四行。在执行过程中,JIT即时编译器有可能会对指令进行重排序,先将栈顶的单例对象引用存储到静态字段中,完成单例对象的创建。再调用对象构造函数 <init>
进行初始化。
上述的流程,在单线程环境下是没有问题的,但是在多线程环境下,可能存在,t1线程获取到了锁,执行了INSTANCE = new Singleton();但是由于字节码中是先创建了对象,导致INSTANCE不为null,此时t2线程执行到了INSTANCE == null的if判断(此行不在锁范围内)不满足判断条件,直接返回了INSTANCE。此时返回的INSTANCE还没有调用构造方法,一些可能存在的初始化的代码还没有执行,也就是对象没有创建完全。
5.2、解决方案
我们可以通过volatile关键字解决上述问题:
@Slf4j(topic = "c.Demo3")
public final class Singleton {private Singleton() {}private static volatile Singleton INSTANCE = null;public static Singleton getInstance() {if (INSTANCE == null) { // t2// 首次访问会同步,而之后的使用没有 synchronizedsynchronized (Singleton.class) {if (INSTANCE == null) { // t1INSTANCE = new Singleton();}}}return INSTANCE;}
}
为什么加上volatile关键字就能解决问题?此前我们有提到过,voltile是通过读写屏障来保证可见性和有序性。
如上图所示,因为成员变量INSTANCE被volatile修饰,所以INSTANCE = new Singleton();这一行给INSTANCE赋值操作具有写屏障,会保证字节码操作不会出现指令重排序,一定是先调用了构造方法,再给INSTANCE赋值。而INSTANCE == null具有读屏障,会保证读取到的都是主存中最新的值。且不会将读屏障之后的代码排在读屏障之前。
此时即使发生极端情况,线程t1在执行INSTANCE = new Singleton();时线程t2进行了INSTANCE == null的if判断,由于t1未执行完成赋值操作,t2从主存中读取到的依旧是null。
6、happens-before
6.1、基本概念
"Happens-before" 是 Java 内存模型(Java Memory Model,JMM)中的一个概念,用于描述多线程环境下操作之间的可见性和顺序性规则。它指定了对一个变量的写操作对于后续对该变量的读操作是可见的,这是为了保证多线程程序的正确性和可预测性而设计的一种规范。
具体来说,如果一个操作 A happens-before 另一个操作 B,那么 A 在时间上先于 B,并且在执行时会对 B 产生一定的影响,确保 B 能够看到 A 对共享变量所做的修改。
6.2、案例
线程解锁 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):
static int x;
public static void main(String[] args) {Thread t2 = new Thread(()->{while(true) {if(Thread.currentThread().isInterrupted()) {System.out.println(x);break;}}},"t2");t2.start();new Thread(()->{sleep(1);x = 10;t2.interrupt();},"t1").start();while(!t2.isInterrupted()) {Thread.yield();}System.out.println(x);}
对变量默认值(0,false,null)的写,对其它线程对该变量的读可见。
以及具有传递性(如果 A happens-before B,且 B happens-before C,那么 A happens-before C。)
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();
7、线程安全单例案例
解答:
- 使用final标记为最终,可以避免子类继承父类破坏单例。
- 在类中加上Object readResolve()方法,在反序列的过程中,如果该方法返回了对象,就会使用该方法返回的对象,而不是反序列化过程中产生的对象。
- 设置为私有是为了避免外界调用构造方法创建对象,但是不能防止反射创建新的实例(setAccessible(true))
- 可以保证线程安全,静态成员变量的初始化是在类加载时完成,类加载由jvm保证线程安全。
- 封装成方法更加灵活,可以对初始化成员变量的过程中进行一些控制,例如实现懒加载。
解答:
- 枚举中的每个值都相当于其静态成员变量。而上面提到,静态成员变量的初始化是在类加载时完成,类加载由jvm保证线程安全。
- 同上。
- 不能。
- 可以被反序列化,因为枚举类都默认实现了序列化接口,但是在实现中考虑到了这样的问题,所以不会被破坏单例。
- 属于饿汉式,枚举中的每个值都相当于其静态成员变量。而上面提到,静态成员变量的初始化是在类加载时完成。
- 可以加上构造方法进行逻辑处理。
解答:线程是安全的,在静态方法上加锁,相当于锁住类的.class。同一时刻只能有一个线程进入该方法。缺点是锁的范围过大,会影响性能。
解答:
- 为了保证成员变量INSTANCE在初始化时的有序性和可见性,避免字节码操作指令重排序,返回未初始完成的INSTANCE对象。
- 缩小锁的范围有利于提高性能。
- INSTANCE!=null的if判断没有加锁,避免两个线程同时判断为null,其中一个线程获取到了锁,初始化并返回了对象,释放锁,第二个线程拿到锁后再次初始化对象。
解答:
- 属于懒汉式,对象随着类的加载而创建
- 没有,静态成员变量的初始化是在类加载时完成,类加载由jvm保证线程安全。