《Java 并发编程》共享模型之内存

简介: 《Java 并发编程》共享模型之内存

Java 内存模型(Java Memory Model,JMM),定义了主存、工作内存抽象概念,底层对应着 CPU 寄存器、缓存、硬件内存和 CPU 指令优化等。2e718f6de6d94987ae2f2182c528d9c8.png

JMM 体现在以下几个方面:

  • 原子性:保证指令不受到线程上下文的影响。
  • 可见性:保证指令不会受 CPU 缓存的影响。
  • 有序性:保证指令不会受 CPU 指令并行优化的影响。

🚀1. 原子性

原子性(Atomicity):由 Java 内存模型来直接保证的原子性变量操作包括 read、load、assign、use、store 和 write,基本数据类型的访问读写是具备原子性的(除了 long 和 double 的非原子性协定)。


问题:

两个线程对初始值为 0 的静态变量一个做自增,一个做自减,各做 5000 次,结果是 0 吗?


问题分析:

以上的结果可能是正数、负数、零,因为 Java 中对静态变量的自增,自减并不是原子操作。


例如对于 i++ 而言(i 为静态变量),实际会产生如下的 JVM 字节码指令:

getstatic i // 获取静态变量i的值 
iconst_1 // 准备常量1 
iadd // 加法 
putstatic i // 将修改后的值存入静态变量i

而对应 i-- 也是类似:

getstatic i // 获取静态变量i的值 
iconst_1 // 准备常量1 
isub // 减法 
putstatic i // 将修改后的值存入静态变量i

而 Java 的内存模型如下,完成静态变量的自增、自减需要在主存和线程内存中进行数据交换:2e718f6de6d94987ae2f2182c528d9c8.png

如果是单线程以下 8 行代码是顺序执行(不会交错)没有问题:

// 假设i的初始值为0 
getstatic i // 线程1-获取静态变量i的值 线程内i=0 
iconst_1 // 线程1-准备常量1 
iadd // 线程1-自增 线程内i=1 
putstatic i // 线程1-将修改后的值存入静态变量i 静态变量i=1 
getstatic i // 线程1-获取静态变量i的值 线程内i=1 
iconst_1 // 线程1-准备常量1 
isub // 线程1-自减 线程内i=0 
putstatic i // 线程1-将修改后的值存入静态变量i 静态变量i=0

但多线程下这 8 行代码可能交错运行,出现负数的情况:

// 假设i的初始值为0 
getstatic i // 线程1-获取静态变量i的值 线程内i=0 
getstatic i // 线程2-获取静态变量i的值 线程内i=0 
iconst_1 // 线程1-准备常量1 
iadd // 线程1-自增 线程内i=1 
putstatic i // 线程1-将修改后的值存入静态变量i 静态变量i=1 
iconst_1 // 线程2-准备常量1 
isub // 线程2-自减 线程内i=-1 
putstatic i // 线程2-将修改后的值存入静态变量i 静态变量i=-1

出现正数的情况:

// 假设i的初始值为0 
getstatic i // 线程1-获取静态变量i的值 线程内i=0 
getstatic i // 线程2-获取静态变量i的值 线程内i=0 
iconst_1 // 线程1-准备常量1 
iadd // 线程1-自增 线程内i=1 
iconst_1 // 线程2-准备常量1 
isub // 线程2-自减 线程内i=-1 
putstatic i // 线程2-将修改后的值存入静态变量i 静态变量i=-1 
putstatic i // 线程1-将修改后的值存入静态变量i 静态变量i=1

解决方法

使用 synchronized (关键字)

语法:

synchronized(对象) {
  要作为原子操作的代码
}

加上 synchronized 关键字后的案例代码:

public class Demo4_1 {
    static int i = 0;
    static Object obj = new Object();
    public static void main(String[] args) throws InterruptedException{
        Thread t1 = new Thread(() -> {
            for (int j = 0; j < 5000; j++) {
                synchronized (obj) {
                    i++;
                }
            }
        });
        Thread t2 = new Thread(() -> {
            for (int j = 0; j < 5000; j++) {
                synchronized (obj) {
                    i--;
                }
            }
        });
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println(i);
    }
}

🚀2. 可见性

案例:main 线程对 run 变量的修改对于 t 线程不可见,导致了 t 线程无法停止:

public class Demo4_2 {
    static boolean run = true;
    public static void main(String[] args) throws InterruptedException{
        Thread t = new Thread(() -> {
            while (run) {
            }
        });
        t.start();
        Thread.sleep(1000);
        run = false;  // 线程t不会如预想的停下来
    }
}

原因分析:

  1. 初始状态, t 线程刚开始从主内存读取了 run 的值到工作内存。

2e718f6de6d94987ae2f2182c528d9c8.png

.因为 t 线程要频繁从主内存中读取 run 的值,JIT 编译器会将 run 的值缓存至自己工作内存中的高速缓存中,减少对主存中 run 的访问,提高效率。2e718f6de6d94987ae2f2182c528d9c8.png

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

2e718f6de6d94987ae2f2182c528d9c8.png

解决方法:


使用 volatile 关键字。volatile 用来修饰成员变量和静态成员变量,它可以避免线程从自己的工作缓存中查找变量的值,必须到主存中获取它的值,线程操作 volatile 变量都是直接操作主存。


退不出循环的例子体现的实际就是可见性,它保证的是在多个线程之间,一个线程对 volatile 变量的修改对另一个线程可见, 不能保证原子性,仅用在一个写线程,多个读线程的情况:上例从字节码理解是这样的:2e718f6de6d94987ae2f2182c528d9c8.png

解决方法:


使用 volatile 关键字。volatile 用来修饰成员变量和静态成员变量,它可以避免线程从自己的工作缓存中查找变量的值,必须到主存中获取它的值,线程操作 volatile 变量都是直接操作主存。


退不出循环的例子体现的实际就是可见性,它保证的是在多个线程之间,一个线程对 volatile 变量的修改对另一个线程可见, 不能保证原子性,仅用在一个写线程,多个读线程的情况:上例从字节码理解是这样的:

getstatic run // 线程 t 获取 run true 
getstatic run // 线程 t 获取 run true 
getstatic run // 线程 t 获取 run true 
getstatic run // 线程 t 获取 run true 
putstatic run // 线程 main 修改 run 为 false, 仅此一次 
getstatic run // 线程 t 获取 run false

与 synchronized 不同的是,例如当使用 volatile 作用之前的线程安全案例时,两个线程一个 i++ 和一个 i-- ,只能保证看到最新值,不能解决指令交错。

// 假设i的初始值为0 
getstatic   // 线程2-获取静态变量i的值 线程内i=0
getstatic i // 线程1-获取静态变量i的值 线程内i=0 
iconst_1 // 线程1-准备常量1 
iadd // 线程1-自增 线程内i=1 
putstatic i // 线程1-将修改后的值存入静态变量i 静态变量i=1 
iconst_1 // 线程2-准备常量1 
isub // 线程2-自减 线程内i=-1 
putstatic i // 线程2-将修改后的值存入静态变量i 静态变量i=-1

需要注意的是,synchronized 语句块既可以保证代码块的原子性,也同时保证代码块内变量的可见性。但缺点是 synchronized 是属于重量级操作,性能相对更低,如果在前面示例的死循环中加入 System.out.println() 会发现即使不加 volatile 修饰符,线程 t 也能正确看到对 run 变量的修改了。

public class Demo4_2 {
    volatile static boolean run = true;
    public static void main(String[] args) throws InterruptedException{
        Thread t = new Thread(() -> {
            while (run) {
                System.out.println();
            }
        });
        t.start();
        Thread.sleep(1000);
        run = false;  // 线程t不会如预想的停下来
    }
}

需要注意的是,synchronized 语句块既可以保证代码块的原子性,也同时保证代码块内变量的可见性。但缺点是 synchronized 是属于重量级操作,性能相对更低,如果在前面示例的死循环中加入 System.out.println() 会发现即使不加 volatile 修饰符,线程 t 也能正确看到对 run 变量的修改了。

public class Demo4_2 {
    volatile static boolean run = true;
    public static void main(String[] args) throws InterruptedException{
        Thread t = new Thread(() -> {
            while (run) {
                System.out.println();
            }
        });
        t.start();
        Thread.sleep(1000);
        run = false;  // 线程t不会如预想的停下来
    }
}

这是因为,println 方法底层加了 synchronized 关键字,保证了可见性。

/**
 * Prints an integer and then terminate the line.  This method behaves as
 * though it invokes <code>{@link #print(int)}</code> and then
 * <code>{@link #println()}</code>.
 *
 * @param x  The <code>int</code> to be printed.
 */
public void println(int x) {
    synchronized (this) {
        print(x);
        newLine();
    }
}

🚁2.1 模式之两阶段终止

两阶段终止(Two Phase Termination),在一个线程 t1 如何 “优雅” 终止线程 t2?这里的 “优雅” 是指给 t2 一个 “结束前处理的机会”。


❌错误思路


使用线程对象的 stop() 方法停止线程:stop 会真正杀死线程,如果这时线程锁住了共享资源,那么当它被杀死后就再也没有机会释放锁,其他线程将永远无法获取锁。

使用 System.exit() 方法停止线程:目的仅是停止一个线程,但这种做法会让整个程序都停止。

✅两阶段终止


利用 isInterrupted

interrupt 可以打断正在执行的线程,无论这个线程是在 sleep,wait 还是正常运行

public class TPTInterrupt {
    private Thread thread;
    public void start() {
        thread = new Thread(()->{
            while (true) {
                Thread current = Thread.currentThread();
                if (current.isInterrupted()) {
                    System.out.println("结束前处理");
                    break;
                }
                try {
                    Thread.sleep(1000);
                    System.out.println("将结果保存");
                } catch (InterruptedException e) {
                    current.interrupt();
                }
                //执行监控
            }
        }, "监控线程");
        thread.start();
    }
    public void stop() {
        thread.interrupt();
    }
    public static void main(String[] args) throws InterruptedException {
        TPTInterrupt t = new TPTInterrupt();
        t.start();
        Thread.sleep(4000);
        System.out.println("stop");
        t.stop();
    }
}

运行结果

将结果保存
将结果保存
将结果保存
stop
结束前处理
  1. 利用停止标记
public class TPTVolatile {
    private Thread thread;
    private volatile boolean stop = false;
    public void start() {
        thread = new Thread(()->{
            while (true) {
                Thread current = Thread.currentThread();
                if (stop) {
                    System.out.println("结束前处理");
                    break;
                }
                try {
                    Thread.sleep(1000);
                    System.out.println("将结果保存");
                } catch (InterruptedException e) {
                }
                //执行监控
            }
        }, "监控线程");
        thread.start();
    }
    public void stop() {
        stop = true;
        thread.interrupt();
    }
    public static void main(String[] args) throws InterruptedException {
       TPTVolatile t = new TPTVolatile();
       t.start();
       Thread.sleep(4000);
       System.out.println("stop");
       t.stop();
    }
}

运行结果

将结果保存
将结果保存
将结果保存
stop
结束前处理

🚁2.2 同步模式之犹豫模式

定义::犹豫(Balking)模式用在一个线程发现另外一个线程或本线程已经做了某一件相同的事,那么本线程就无需再做,直接结束返回。

  • 用一个标记来判断该任务是否已经被执行过了
  • 需要避免线程安全问题。加锁的代码块要尽量的小,以保证性能

实现

public class MonitorService {
    public static void main(String[] args) throws InterruptedException {
        Monitor monitor = new Monitor();
        monitor.start();
        monitor.start();
        monitor.start();
        monitor.start();
        Thread.sleep(3500);
        monitor.stop();
    }
}
class Monitor {
    Thread monitor;
    //设置标记,用于判断是否被终止了
    private volatile boolean stop = false;
    //设置标记,用于判断是否已经启动过了
    private boolean starting = false;
    /**
     * 启动监控器线程
     */
    public void start() {
        //上锁,避免多线程运行时出现线程安全问题
        synchronized (this) {
            if (starting) {
                //已被启动,直接返回
                System.out.println("监控线程已启动?"+starting);
                return;
            }
            //启动监视器,改变标记
            System.out.println("监控器已启动?"+starting);
            starting = true;
        }
        //设置监控器线程,用于监控线程状态
        monitor = new Thread(() -> {
            //开始不停的监控
            while (true) {
                if(stop) {
                    System.out.println("处理后续任务");
                    break;
                }
                System.out.println("监控器运行中...");
                try {
                    //线程休眠
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    System.out.println("被打断了");
                }
            }
        });
        monitor.start();
    }
    /**
     *  用于停止监控器线程
     */
    public void stop() {
        //打断线程
        monitor.interrupt();
        stop = true;
    }
}

运行结果

监控器已启动?false
监控线程已启动?true
监控线程已启动?true
监控线程已启动?true
监控器运行中...
监控器运行中...
监控器运行中...
监控器运行中...
被打断了
处理后续任务

还可以用来实现线程安全的单例

public final class Singleton {
    private Singleton() {}
    private static Singleton INSTANCE = null;
    public static synchronized Singleton getInstance() {
        // 实例没创建,才会进入内部的 synchronized代码块
        if (INSTANCE == null) {
            return INSTANCE;
            }
        }
        INSTANCE = new INSTANCE();
        return INSTANCE;
    }
}

🚀3. 有序性

🚁3.1 指令重排

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

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

可以看到,至于先执行 i 还是先执行 j,对最终的结果不会产生影响,因此,上面代码真正执行时,既可以是

i = ...;
j = ...;

也可以是

j = ...;
i = ...;

这种特性称之为【指令重排】,多线程下【指令重排】会影响正确性。 例如著名的 double-checkedlocking 模式实现单例:

public final class Singleton {
    private Singleton() {}
    private static Singleton INSTANCE = null;
    public static Singleton getInstance() {
        // 实例没创建,才会进入内部的 synchronized代码块
        if (INSTANCE == null) {
            synchronized (Singleton.class) {
                // 也许有其它线程已经创建实例,所以再判断一次
                if (INSTANCE == null) {
                    INSTANCE = new Singleton();
                }
            }
        }
        return INSTANCE;
    }
}

以上的实现特点是:

  • 懒惰实例化
  • 首次使用 getInstance() 才使用 synchronized 加锁,后续使用时无需加锁

但在多线程环境下,上面的代码是有问题的, INSTANCE = new Singleton() 对应的字节码

0: new #2 // class com/hzz/t4/Singleton 
3: dup 
4: invokespecial #3 // Method "<init>":()V 
7: putstatic #4 // Field

其中 4 和 7 两步的顺序不是固定的,也许 JVM 会优化为:先将引用地址赋值给 INSTANCE 变量后,再执行构造方法,如果两个线程 t1,t2 按如下时间序列执行

时间1 t1 线程执行到 INSTANCE = new Singleton(); 
时间2 t1 线程分配空间,为 Singleton对象生成了引用地址(0 处) 
时间3 t1 线程将引用地址赋值给 INSTANCE,这时 INSTANCE != null(7 处) 
时间4 t2 线程进入 getInstance() 方法,发现 INSTANCE != null(synchronized块外),直接 返回 INSTANCE 
时间5 t1 线程执行Singleton的构造方法(4 处)

这时 t1 还未完全将构造方法执行完毕,如果在构造方法中要执行很多初始化操作,那么 t2 拿到的是将是一个未初始化完毕的单例。


对 INSTANCE 使用 volatile 修饰即可,可以禁用指令重排,但要注意在 JDK 5 以上的版本的 volatile 才会真正有效。

🚁3.2 指令重排序优化

事实上,现代处理器会设计为一个时钟周期完成一条执行时间最长的 CPU 指令,将其再划分成为一个个更小的阶段。例如,每条指令都可以分为:取指令--指令译码--执行指令--内存访问--数据写回 这 5 个阶段。2e718f6de6d94987ae2f2182c528d9c8.png

.术语参考

instruction fetch(IF)

instruction decode(ID)

execute(EX)

memory access(MEM)

register write back(WB)

在不改变程序结果的前提下,这些指令的各个阶段可以通过重排序组合来实现指令级并行

指令重排的前提是,重排指令不能影响结果,例如:

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

🚁3.3 支持流水线的处理器

现代 CPU 支持多级指令流水线,例如支持同时执行取指令--指令译码--执行指令--内存访问--数据写回的处理器,就可以称之为五级指令流水线。这时 CPU 可以在一个时钟周期内,同时运行五条指令的不同阶段(相当于一条执行时间最长的复杂指令),IPC = 1,本质上,流水线技术并不能缩短单条指令的执行时间,但它变相地提高了指令吞吐率。2e718f6de6d94987ae2f2182c528d9c8.png

在多线程环境下,指令重排序可能导致出现意料之外的结果。

解决方法

使用 volatile 修饰的变量,可以禁用指令重排序。

  • 禁止的是加 volatile 关键字变量之前的代码被重排序

🚀4. 内存屏障

可见性


写屏障(sfence)保证在该屏障之前的,对共享变量的改动,都同步到主存当中

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

有序性


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

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

🚀5. volatile 原理

volatile 的底层实现原理是内存屏障(Memory Barrier(Memory Fence)


对 volatile 变量的写指令后会加入写屏障

对 volatile 变量的读指令前会加入读屏障

🚁5.1 如何保证可见性

写屏障(sfence)保证在该屏障之前的,对共享变量的改动,都同步到主存当中

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

而读屏障(lfence)保证在该屏障之后,对共享变量的读取,加载的是主存中新数据2e718f6de6d94987ae2f2182c528d9c8.png

🚁5.2 如何保证有序性

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

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

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

public void actor1(I_Result r) {
  //读屏障
  //ready是volatile读取值带读屏障
  if(ready) {
    r.r1 = num + num;
  } else {
    r.r1 = 1;
  }
}

2e718f6de6d94987ae2f2182c528d9c8.png

但是不能解决指令交错问题

  • 写屏障仅仅是保证之后的读能够读到新的结果,但不能保证读跑到它前面去
  • 而有序性的保证也只是保证了本线程内相关代码不被重排序
相关文章
|
15天前
|
存储 Java 编译器
Java内存模型(JMM)深度解析####
本文深入探讨了Java内存模型(JMM)的工作原理,旨在帮助开发者理解多线程环境下并发编程的挑战与解决方案。通过剖析JVM如何管理线程间的数据可见性、原子性和有序性问题,本文将揭示synchronized关键字背后的机制,并介绍volatile关键字和final关键字在保证变量同步与不可变性方面的作用。同时,文章还将讨论现代Java并发工具类如java.util.concurrent包中的核心组件,以及它们如何简化高效并发程序的设计。无论你是初学者还是有经验的开发者,本文都将为你提供宝贵的见解,助你在Java并发编程领域更进一步。 ####
|
26天前
|
缓存 easyexcel Java
Java EasyExcel 导出报内存溢出如何解决
大家好,我是V哥。使用EasyExcel进行大数据量导出时容易导致内存溢出,特别是在导出百万级别的数据时。以下是V哥整理的解决该问题的一些常见方法,包括分批写入、设置合适的JVM内存、减少数据对象的复杂性、关闭自动列宽设置、使用Stream导出以及选择合适的数据导出工具。此外,还介绍了使用Apache POI的SXSSFWorkbook实现百万级别数据量的导出案例,帮助大家更好地应对大数据导出的挑战。欢迎一起讨论!
141 1
|
10天前
|
缓存 算法 Java
本文聚焦于Java内存管理与调优,介绍Java内存模型、内存泄漏检测与预防、高效字符串拼接、数据结构优化及垃圾回收机制
在现代软件开发中,性能优化至关重要。本文聚焦于Java内存管理与调优,介绍Java内存模型、内存泄漏检测与预防、高效字符串拼接、数据结构优化及垃圾回收机制。通过调整垃圾回收器参数、优化堆大小与布局、使用对象池和缓存技术,开发者可显著提升应用性能和稳定性。
32 6
|
14天前
|
存储 缓存 安全
Java内存模型(JMM):深入理解并发编程的基石####
【10月更文挑战第29天】 本文作为一篇技术性文章,旨在深入探讨Java内存模型(JMM)的核心概念、工作原理及其在并发编程中的应用。我们将从JMM的基本定义出发,逐步剖析其如何通过happens-before原则、volatile关键字、synchronized关键字等机制,解决多线程环境下的数据可见性、原子性和有序性问题。不同于常规摘要的简述方式,本摘要将直接概述文章的核心内容,为读者提供一个清晰的学习路径。 ####
35 2
|
15天前
|
存储 安全 Java
什么是 Java 的内存模型?
Java内存模型(Java Memory Model, JMM)是Java虚拟机(JVM)规范的一部分,它定义了一套规则,用于指导Java程序中变量的访问和内存交互方式。
36 1
|
21天前
|
存储 运维 Java
💻Java零基础:深入了解Java内存机制
【10月更文挑战第18天】本文收录于「滚雪球学Java」专栏,专业攻坚指数级提升,希望能够助你一臂之力,帮你早日登顶实现财富自由🚀;同时,欢迎大家关注&&收藏&&订阅!持续更新中,up!up!up!!
27 1
|
24天前
|
机器学习/深度学习 算法 物联网
大模型进阶微调篇(一):以定制化3B模型为例,各种微调方法对比-选LoRA还是PPO,所需显存内存资源为多少?
本文介绍了两种大模型微调方法——LoRA(低秩适应)和PPO(近端策略优化)。LoRA通过引入低秩矩阵微调部分权重,适合资源受限环境,具有资源节省和训练速度快的优势,适用于监督学习和简单交互场景。PPO基于策略优化,适合需要用户交互反馈的场景,能够适应复杂反馈并动态调整策略,适用于强化学习和复杂用户交互。文章还对比了两者的资源消耗和适用数据规模,帮助读者根据具体需求选择最合适的微调策略。
|
24天前
|
存储 算法 Java
Java虚拟机(JVM)的内存管理与性能优化
本文深入探讨了Java虚拟机(JVM)的内存管理机制,包括堆、栈、方法区等关键区域的功能与作用。通过分析垃圾回收算法和调优策略,旨在帮助开发者理解如何有效提升Java应用的性能。文章采用通俗易懂的语言,结合具体实例,使读者能够轻松掌握复杂的内存管理概念,并应用于实际开发中。
|
24天前
|
安全 Java
Java多线程通信新解:本文通过生产者-消费者模型案例,深入解析wait()、notify()、notifyAll()方法的实用技巧
【10月更文挑战第20天】Java多线程通信新解:本文通过生产者-消费者模型案例,深入解析wait()、notify()、notifyAll()方法的实用技巧,包括避免在循环外调用wait()、优先使用notifyAll()、确保线程安全及处理InterruptedException等,帮助读者更好地掌握这些方法的应用。
16 1
|
1月前
|
存储 监控 算法
Java中的内存管理与垃圾回收机制解析
本文深入探讨了Java编程语言中的内存管理方式,特别是垃圾回收机制。我们将了解Java的自动内存管理是如何工作的,它如何帮助开发者避免常见的内存泄漏问题。通过分析不同垃圾回收算法(如标记-清除、复制和标记-整理)以及JVM如何选择合适的垃圾回收策略,本文旨在帮助Java开发者更好地理解和优化应用程序的性能。