• 并发知识点总结: 共享模型之内存


    Java内存模型

    JMM指的是Java memory model,它定义了主存,工作内存等抽象概念,相当于做一个隔离层,将底层CPU寄存器,缓存,硬件内存,CPU指令优化提供的功能通过一个简单接口给使用者调用
    JMM体现在以下几个方面:
    原子性:保证指令不会受到线程上下文切换的影响
    可见性:保证指令不会受到cpu缓存的影响
    有序性:保证指令不会受cpu指令并行优化的影响

    可见性

    一个小例子:退不出的循环

    1. @Slf4j(topic = "ch.Cha05JMMTest01")
    2. public class Cha05JMMTest01 {
    3. static boolean run = true;
    4. public static void main(String[] args) throws InterruptedException {
    5. Thread t = new Thread(() -> {
    6. while (run) {
    7. // log.debug("循环结束,run is {}",run);
    8. }
    9. log.debug("循环结束,run is {}",run);
    10. }, "t1");
    11. t.start();
    12. Thread.sleep(1000);
    13. run = false;
    14. log.debug("run is {}", run);
    15. }
    16. }

    可以发现虽然主线程把run改成了false,但是t线程仍然不会退出

    原因分析如下:
    1.初始状态,t线程刚开始从主内存读取了run的值到工作内存

    2.因为t线程要频繁从主内存中读取run的值,JIT编译器会将run的值缓存到自己工作内存中的高速缓存中,减少对主存中run的访问,提高效率
    这里主存相当于物理内存,工作内存相当于比物理内存更快的高速缓存,所以可以提高访问效率

    3.1s之后,main线程修改了run的值并同步到了主存,而t是从自己工作内存中的高速缓存中读取这个变量的值,所以结果永远是旧值

    这里就可以引入解决方法:volatile关键字
    翻译过来是易变的,可以用来修饰成员变量和静态成员变量,可以避免线程从自己的工作缓存中查找变量的值,必须到主存中获取它的值,线程操作volatile变量都是直接操作主存

    可见性vs原子性

    volatile保证的是在多个线程之间,一个线程对volatile变量的修改对另一个线程可见,不能保证原子性,仅用在一个写线程写,多个读线程读的情况。比较一下线程安全时举的例子,两个线程一个做i++,一个做i--,volatile只能保证每个线程看到的都是内存上的最新值,不能解决指令交错

    注意

    synchronized语句块既可以保证代码块的原子性,也同时保证代码块内变量的可见性,但缺点是synchronized属于重量级操作,性能相对更低。

    两阶段终止设计模式

    之前学过是利用Interrupt来打断运行或者sleep或者wait状态的线程,现在也可以使用停止标记来打断其他线程
    方法一(interrupt)

    1. @Slf4j(topic = "ch.Cha05TwpPhaseStop01")
    2. public class Cha05TwpPhaseStop01 {
    3. public static void main(String[] args) throws InterruptedException {
    4. TPTInterrupt test = new TPTInterrupt();
    5. test.start();
    6. Thread.sleep(3000);
    7. log.debug("结束");
    8. test.stop();
    9. }
    10. }
    11. @Slf4j(topic = "ch.TPTInterrupt")
    12. class TPTInterrupt {
    13. private Thread thread;
    14. public void start() {
    15. thread = new Thread(() -> {
    16. while (true) {
    17. Thread current = Thread.currentThread();
    18. if (current.isInterrupted()) {
    19. log.debug("回收资源");
    20. break;
    21. }
    22. try {
    23. Thread.sleep(1);
    24. } catch (InterruptedException e) {
    25. current.interrupt();
    26. log.debug("睡眠状态被打断会清除打断标记");
    27. e.printStackTrace();
    28. }
    29. }
    30. }, "t1");
    31. thread.start();
    32. }
    33. public void stop() {
    34. thread.interrupt();
    35. }
    36. }

    方法二:利用打断标记

    1. //利用打断标记完成两阶段终止
    2. @Slf4j(topic = "ch.Cha05TwpPhaseStop02")
    3. public class Cha05TwpPhaseStop02 {
    4. public static void main(String[] args) throws InterruptedException {
    5. TPTVolatile test = new TPTVolatile();
    6. test.start();
    7. Thread.sleep(1000);
    8. test.stop();
    9. }
    10. }
    11. @Slf4j(topic = "ch.TPTVolatile")
    12. class TPTVolatile {
    13. private Thread thread;
    14. private volatile boolean stop = false;
    15. public void start() {
    16. thread = new Thread(() -> {
    17. while (true) {
    18. if (stop) {
    19. log.debug("回收资源");
    20. break;
    21. }
    22. try {
    23. Thread.sleep(5000);
    24. log.debug("睡眠免得监控线程一直运行");
    25. } catch (InterruptedException e) {
    26. e.printStackTrace();
    27. }
    28. }
    29. }, "监控线程");
    30. thread.start();
    31. }
    32. public void stop() {
    33. stop = true;
    34. log.debug("中断");
    35. thread.interrupt();
    36. }[]()
    37. }

    balking设计模式

    定义

    balking(犹豫)模式用在一个线程发现另一个线程或者本线程已经做了某一件相同的事,那么本线程就不需要再做了,可以直接结束返回

    实现

    一般被用来实现单例模式

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

    有序性

    JVM在会在不影响正确性的前提下,可以调整语句的执行顺序,比如:

    1. static int i;
    2. static int j;
    3. // 在某个线程内执行如下赋值操作
    4. i = ...;
    5. j = ...;

    在上面的那段代码中,先执行i还是先执行j,对最终的结果不会产生影响,所以,上面代码真正执行时,可以是先做i的操作,也可以是先做j的操作,这种特性被称为指令重排,多线程下的指令重排会影响正确性。

    附:指令重排的原理

    现代处理器借鉴了流水线的思想,我们可以把指令的执行再划分为一个个更小的阶段,例如:每条指令都可以分为:取指令-指令译码-执行指令-内存访问-数据写回这5个阶段
    取指令:instruction fetch
    指令译码:instruction decode
    执行指令:execute
    内存访问:memory access
    数据写回:register write back

    我们在不改变程序结果的前提下,这些指令的各个阶段可以通过排序和组合实现指令间的并行,分工和分阶段可以很大程度上提高程序执行的效率

    指令重排也是有前提的,那就是不能影响结果

    1. // 可以重排的例子
    2. int a = 10; // 指令1
    3. int b = 20; // 指令2
    4. System.out.println( a + b );
    5. // 不能重排的例子
    6. int a = 10; // 指令1
    7. int b = a - 5; // 指令2

    一个实例看多线程下的指令重排

    1. int num = 0;
    2. boolean ready = false;
    3. // 线程1 执行此方法
    4. public void actor1(I_Result r) {
    5. if(ready) {
    6. r.r1 = num + num;
    7. } else {
    8. r.r1 = 1;
    9. }
    10. }
    11. // 线程2 执行此方法
    12. public void actor2(I_Result r) {
    13. num = 2;
    14. ready = true;
    15. }

    I_result是一个对象,有一个属性r1用来保存结果,问,可能的结果有几种?
    情况一:
    线程1先执行,此时ready的值是false,所以进入else分支,结果为1
    情况2:
    线程2先执行到num = 2,但没执行ready = true,,所以还是进入else分支,结果为1
    情况3:
    线程2执行了ready = true,此时num = 2,所以r1这个时候是4
    其实还有一种情况,线程2先执行ready = true,但是没有执行num = 2,因为这个时候发生了指令重排,这个时候,r1的值就是0.

    这种现象就是指令重排,是JIT编译器在运行时的一些优化,这个现象需要大量测试才能发现,我们可以借助Java的压测工具jcstress。

    解决方法:
    使用volatile修饰变量可以禁用指令重排序。

    1. //改之前
    2. boolean ready = false;
    3. //改之后
    4. volatile boolean ready = false

    volatie的原理

    volatile的底层实现原理是内存屏障:
    对volatile变量的写指令之后会加入写屏障
    对volatile变量的读指令之前会加入读屏障
    volatile可以设置读写屏障

    保证可见性

    写屏障保证在该屏障之前的,任何对于共享变量的改动,都会同步到主存当中

    1. public void actor2(I_Result r) {
    2. num = 2;
    3. ready = true; // ready 是 volatile 赋值带写屏障
    4. // 写屏障
    5. }

    意思就是ready = true是个对volatile变量进行的写操作,所以在这之前执行的指令,必须先ready = true之前,将num = 2执行并写入主存

    读屏障保证的是在该屏障之后,对共享变量的读取,加载的是主存中的最新数据

    如何保证有序性

    写屏障会确保重排序时,不会将写屏障之前的代码排在写屏障之后

    1. public void actor2(I_Result r) {
    2. num = 2;
    3. ready = true; // ready 是 volatile 赋值带写屏障
    4. // 写屏障
    5. }

    num=2就别想在ready = true之后执行

    读屏障会确保指令重排序时,不会将读屏障之后的代码排在读屏障之前

    但是读写屏障是不能解决指令交错问题的,就是可以保证有序性和可见性,不能保证原子性

    double checked locking问题

    1. public final class Singleton {
    2. private Singleton() {
    3. }
    4. private static Singleton INSTANCE = null;
    5. public static Singleton getInstance() {
    6. if (INSTANCE == null) { // t2
    7. // 首次访问会同步,而之后的使用没有 synchronized
    8. synchronized (Singleton.class) {
    9. if (INSTANCE == null) { // t1
    10. INSTANCE = new Singleton();
    11. }
    12. }
    13. }
    14. return INSTANCE;
    15. }
    16. }

    以上单例模式实现的特点
    懒汉式:用到了才会实例化
    首次使用 getInstance() 才使用 synchronized 加锁,后续使用时无需加锁
    if (INSTANCE == null)这里使用了instance变量,是在同步块之外的,但在多线程环境下,是可能出现问题的

    getInstance方法对应的字节码如下:

    1. 0: getstatic #2 // Field INSTANCE:Lcn/itcast/n5/Singleton;
    2. 3: ifnonnull 37
    3. 6: ldc #3 // class cn/itcast/n5/Singleton
    4. 8: dup
    5. 9: astore_0
    6. 10: monitorenter
    7. 11: getstatic #2 // Field INSTANCE:Lcn/itcast/n5/Singleton;
    8. 14: ifnonnull 27
    9. 17: new #3 // class cn/itcast/n5/Singleton
    10. 20: dup
    11. 21: invokespecial #4 // Method "":()V
    12. 24: putstatic #2 // Field INSTANCE:Lcn/itcast/n5/Singleton;
    13. 27: aload_0
    14. 28: monitorexit
    15. 29: goto 37
    16. 32: astore_1
    17. 33: aload_0
    18. 34: monitorexit
    19. 35: aload_1
    20. 36: athrow
    21. 37: getstatic #2 // Field INSTANCE:Lcn/itcast/n5/Singleton;
    22. 40: areturn

    其中,
    17表示创建对象,将对象的引用入栈
    20表示复制一份对象引用
    21表示利用一个对象引用,调用构造方法
    24表示利用一个对象引用,赋值给static INSTANCE

    jvm可能会优化成:先执行24,再执行21,那么其他线程可能会在同步代码块之外拿到一个未初始化完毕的单例,这时候就可以用volatile来解决,保证JVM不会重排序造成执行上的问题

    happens-before原则

    happens-before规定了对共享变量的写操作对于其他线程的读操作可见,它是可见性和有序性的一套规则的总结抛开以下happens-before规则,JMM并不能保证一个线程对共享变量的写,对于其他线程对该共享变量的读可见

    case1

    1. static int x;
    2. static Object m = new Object();
    3. new Thread(()->{
    4. synchronized(m) {
    5. x = 10;
    6. }
    7. },"t1").start();
    8. new Thread(()->{
    9. synchronized(m) {
    10. System.out.println(x);
    11. }
    12. },"t2").start();

    线程对m解锁之前对于x变量的写,之后对m加锁的其他线程是可以读到这个写操作的,就是会输出10,10又是之前加锁m写入的x

    case2:

    1. volatile static int x;
    2. new Thread(()->{
    3. x = 10;
    4. },"t1").start();
    5. new Thread(()->{
    6. System.out.println(x);
    7. },"t2").start();

    线程对于volatile变量的写操作,接下来,任何线程对于该volatile变量的读操作,都是可以读到的

    case3:线程start前对变量的写,对该线程开始后对该变量的读可见

    1. static int x;
    2. x = 10;
    3. new Thread(()->{
    4. System.out.println(x);
    5. },"t2").start();

    case4:

    1. static int x;
    2. Thread t1 = new Thread(()->{
    3. x = 10;
    4. },"t1");
    5. t1.start();
    6. t1.join();
    7. System.out.println(x);

    t1线程结束前对x的写操作,在t1线程结束后,其他线程都能读到这个写操作的结果

    case5:

    1. static int x;
    2. public static void main(String[] args) {
    3. Thread t2 = new Thread(()->{
    4. while(true) {
    5. if(Thread.currentThread().isInterrupted()) {
    6. System.out.println(x);
    7. break;
    8. }
    9. }
    10. },"t2");
    11. t2.start();
    12. new Thread(()->{
    13. sleep(1);
    14. x = 10;
    15. t2.interrupt();
    16. },"t1").start();
    17. while(!t2.isInterrupted()) {
    18. Thread.yield();
    19. }
    20. System.out.println(x);
    21. }

    线程t1打断t2前对变量的写,对于其他线程得知t2被打断后(通过t2.interrupted 或 t2.isInterrupted来得知)的读操作,是可见的

    case6:对变量默认值(0,false,null)的写操作,对于其他线程对于这个变量的读操作是可见的

    case7:可见性具有传递性,如果x对y可见,y对z可见,那么x对z也会可见

    1. volatile static int x;
    2. static int y;
    3. new Thread(()->{
    4. y = 10;
    5. x = 20;
    6. },"t1").start();
    7. new Thread(()->{
    8. // x=20 对 t2 可见, 同时 y=10 也对 t2 可见
    9. System.out.println(x);
    10. },"t2").start();

    从上面程序可以看出,首先,y=10肯定是对于x=20是可见的,然后x是被volatile修饰的,x=20对t2是可见的,所以,y=10也对t2可见

    一些思考题:

    1. public class TestVolatile {
    2. volatile boolean initialized = false;
    3. void init() {
    4. if (initialized) {
    5. return;
    6. }
    7. doInit();
    8. initialized = true;
    9. }
    10. private void doInit() {
    11. }
    12. }

    希望doInit方法只被调用一次,上面的实现是否有问题?
    有问题的,假设有两个线程t1,t2并行执行init方法,t1执行完if (initialized)时间片做出线程切换,t2也来执行if (initialized),那么他两对于条件的判断都是false,都会调用doInit,所以要把init放入同步代码块中,才能满足需求

    单例模式的一些实现以及一些问题:

    单例模式的第一种实现方式

    1. // 问题1:为什么加 final
    2. // 问题2:如果实现了序列化接口, 还要做什么来防止反序列化破坏单例
    3. public final class Singleton implements Serializable {
    4. // 问题3:为什么设置为私有? 是否能防止反射创建新的实例?
    5. private Singleton() {
    6. }
    7. // 问题4:这样初始化是否能保证单例对象创建时的线程安全?
    8. private static final Singleton INSTANCE = new Singleton();
    9. public static Singleton getInstance() {
    10. return INSTANCE;
    11. }
    12. public Object readResolve() {
    13. return INSTANCE;
    14. }
    15. }

    问题1:为什么加final?
    避免子类继承父类修改父类中的方法影响单例

    问题2:如果实现了序列化接口, 还要做什么来防止反序列化破坏单例?
    Java也可以通过反序列化创建一个对象,所以当你实现了序列化接口之后,我们可以通过反序列化来创建一个对象,从而影响单例,所以我们需要加上一个readResolve方法,当反序列化时,如果存在对象,就会返回你已经创建好的对象INSTANCE

    问题3:为什么设置为私有? 是否能防止反射创建新的实例?
    设置为私有可以避免其他类调用其构造方法创建对象破坏单例,但是也不能避免反射来创建新的实例

    问题4:这样初始化是否能保证单例对象创建时的线程安全?
    可以,由于静态成员变量的初始化是在jvm类加载时候完成,jvm会保证对象创建时的线程安全

    单例模式的第二种实现方式

    1. // 问题1:枚举单例是如何限制实例个数的
    2. // 问题2:枚举单例在创建时是否有并发问题
    3. // 问题3:枚举单例能否被反射破坏单例
    4. // 问题4:枚举单例能否被反序列化破坏单例
    5. // 问题5:枚举单例属于懒汉式还是饿汉式
    6. enum Singleton {
    7. INSTANCE;
    8. }

    问题一:枚举单例是如何限制个数的?
    INSTANCE在反编译之后就是枚举类的一个静态成员变量。

    问题二:枚举单例在创建时是否有并发问题?
    枚举单例是静态成员变量,它是在类加载的时候创建的,线程安全性是由JVM保证的。

    问题三:枚举单例是否能用反射来破坏单例呢?
    枚举单例是不能用反射来破坏单例的

    问题四:枚举单例是否能被反序列化破坏单例
    枚举虽然实现了序列化接口,但是考虑到了被反序列化破坏单例,所以是预防了这一情况

    问题五:枚举单例属于懒汉式还是饿汉式
    由于枚举变量实际上就是静态成员变量,在类加载时就会完成创建,所以其实枚举单例属于是饿汉式

    单例模式的第三种实现方式

    1. public final class Singleton {
    2. private Singleton() { }
    3. private static Singleton INSTANCE = null;
    4. // 分析这里的线程安全, 并说明有什么缺点
    5. public static synchronized Singleton getInstance() {
    6. if( INSTANCE != null ){
    7. return INSTANCE;
    8. }
    9. INSTANCE = new Singleton();
    10. return INSTANCE;
    11. }
    12. }

    首先这个一定是线程安全的,因为这个synchronized是在static方法上加锁,就是对这个类对象加锁,锁的粒度是比较大的,多线程运行时,一定只有一个线程运行了getInstance方法,然后就会得到一个instance,之后才会解锁,之后的线程进入时就一定会拿到已经生成的INSTANCE,但是也存在缺点,那就是后面线程进入的时候,还会对这个类对象上锁。

    单例模式的第四种实现

    1. public final class Singleton {
    2. private Singleton() { }
    3. // 问题1:解释为什么要加 volatile ?
    4. private static volatile Singleton INSTANCE = null;
    5. // 问题2:对比实现3, 说出这样做的意义
    6. public static Singleton getInstance() {
    7. if (INSTANCE != null) {
    8. return INSTANCE;
    9. }
    10. synchronized (Singleton.class) {
    11. // 问题3:为什么还要在这里加为空判断, 之前不是判断过了吗
    12. if (INSTANCE != null) { // t2
    13. return INSTANCE;
    14. }
    15. INSTANCE = new Singleton();
    16. return INSTANCE;
    17. }
    18. }
    19. }

    问题1:解释为什么要加 volatile ?
    synchronized代码块中的程序可能会出现指令重排序,如果重排序先做了赋值操作,再调用构造方法,那么其他线程有可能拿到没有调用构造方法的INSTANCE引用

    问题2:对比实现3, 说出这样做的意义
    第一次调用getInstance方法之后,之后调用getInstance方法会直接走判断然后返回instance,if判断没有加synchronized,所以性能会更高

    问题3:为什么还要在这里加为空判断, 之前不是判断过了吗?
    这是为了避免多个线程第一次去创建INSTANCE对象出现问题。

    单例模式的第五种实现

    1. public final class Singleton {
    2. private Singleton() { }
    3. // 问题1:属于懒汉式还是饿汉式
    4. private static class LazyHolder {
    5. static final Singleton INSTANCE = new Singleton();
    6. }
    7. // 问题2:在创建时是否有并发问题
    8. public static Singleton getInstance() {
    9. return LazyHolder.INSTANCE;
    10. }
    11. }

    问题1:属于懒汉式还是饿汉式?
    是通过静态内部类的方式完成一个懒汉式的单例创建,类只会在第一次被用到时才会出发类加载操作,用到getInstance方法才会触发内部的类加载操作,没有执行类加载的话,静态内部类里面的静态变量也不会进行初始化操作

    问题2:在创建时是否有并发问题?
    类加载时对静态变量的赋值操作可以由JVM来保障线程安全性。

    好了, 以上是本文所有内容,希望对大家有所帮助,也希望大家对码农之家多多支持,你们的支持是我创作的动力!祝大家生活愉快!   

  • 相关阅读:
    经典问题--超大字符串型整数加减
    vscode调试webpack项目的方法
    22.Python函数(七)【Python模块】
    faster python——dataclass&cache
    HUDI概述
    Linux command: ps and netstat
    mockjs的案例
    【UE5】游戏框架GamePlay
    Java面试之Java基础篇(offer 拿来吧你)
    从mysql 数据库表导入数据到elasticSearch的几种方式
  • 原文地址:https://blog.csdn.net/wuxiaopengnihao1/article/details/126686362