《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

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

  • 写屏障仅仅是保证之后的读能够读到新的结果,但不能保证读跑到它前面去
  • 而有序性的保证也只是保证了本线程内相关代码不被重排序
相关文章
|
22天前
|
安全 Java 程序员
深入理解Java内存模型与并发编程####
本文旨在探讨Java内存模型(JMM)的复杂性及其对并发编程的影响,不同于传统的摘要形式,本文将以一个实际案例为引子,逐步揭示JMM的核心概念,包括原子性、可见性、有序性,以及这些特性在多线程环境下的具体表现。通过对比分析不同并发工具类的应用,如synchronized、volatile关键字、Lock接口及其实现等,本文将展示如何在实践中有效利用JMM来设计高效且安全的并发程序。最后,还将简要介绍Java 8及更高版本中引入的新特性,如StampedLock,以及它们如何进一步优化多线程编程模型。 ####
24 0
|
12天前
|
人工智能 物联网 C语言
SVDQuant:MIT 推出的扩散模型后训练的量化技术,能够将模型的权重和激活值量化至4位,减少内存占用并加速推理过程
SVDQuant是由MIT研究团队推出的扩散模型后训练量化技术,通过将模型的权重和激活值量化至4位,显著减少了内存占用并加速了推理过程。该技术引入了高精度的低秩分支来吸收量化过程中的异常值,支持多种架构,并能无缝集成低秩适配器(LoRAs),为资源受限设备上的大型扩散模型部署提供了有效的解决方案。
38 5
SVDQuant:MIT 推出的扩散模型后训练的量化技术,能够将模型的权重和激活值量化至4位,减少内存占用并加速推理过程
|
24天前
|
存储 监控 算法
Java内存管理深度剖析:从垃圾收集到内存泄漏的全面指南####
本文深入探讨了Java虚拟机(JVM)中的内存管理机制,特别是垃圾收集(GC)的工作原理及其调优策略。不同于传统的摘要概述,本文将通过实际案例分析,揭示内存泄漏的根源与预防措施,为开发者提供实战中的优化建议,旨在帮助读者构建高效、稳定的Java应用。 ####
37 8
|
22天前
|
存储 监控 算法
深入探索Java虚拟机(JVM)的内存管理机制
本文旨在为读者提供对Java虚拟机(JVM)内存管理机制的深入理解。通过详细解析JVM的内存结构、垃圾回收算法以及性能优化策略,本文不仅揭示了Java程序高效运行背后的原理,还为开发者提供了优化应用程序性能的实用技巧。不同于常规摘要仅概述文章大意,本文摘要将简要介绍JVM内存管理的关键点,为读者提供一个清晰的学习路线图。
|
26天前
|
存储 算法 Java
Java 内存管理与优化:掌控堆与栈,雕琢高效代码
Java内存管理与优化是提升程序性能的关键。掌握堆与栈的运作机制,学习如何有效管理内存资源,雕琢出更加高效的代码,是每个Java开发者必备的技能。
53 5
|
24天前
|
存储 算法 Java
Java内存管理深度解析####
本文深入探讨了Java虚拟机(JVM)中的内存分配与垃圾回收机制,揭示了其高效管理内存的奥秘。文章首先概述了JVM内存模型,随后详细阐述了堆、栈、方法区等关键区域的作用及管理策略。在垃圾回收部分,重点介绍了标记-清除、复制算法、标记-整理等多种回收算法的工作原理及其适用场景,并通过实际案例分析了不同GC策略对应用性能的影响。对于开发者而言,理解这些原理有助于编写出更加高效、稳定的Java应用程序。 ####
|
24天前
|
安全 Java 程序员
Java内存模型的深入理解与实践
本文旨在深入探讨Java内存模型(JMM)的核心概念,包括原子性、可见性和有序性,并通过实例代码分析这些特性在实际编程中的应用。我们将从理论到实践,逐步揭示JMM在多线程编程中的重要性和复杂性,帮助读者构建更加健壮的并发程序。
|
29天前
|
算法 Java 开发者
Java内存管理与垃圾回收机制深度剖析####
本文深入探讨了Java虚拟机(JVM)的内存管理机制,特别是其垃圾回收机制的工作原理、算法及实践优化策略。不同于传统的摘要概述,本文将以一个虚拟的“城市环卫系统”为比喻,生动形象地揭示Java内存管理的奥秘,旨在帮助开发者更好地理解并调优Java应用的性能。 ####
|
21天前
|
存储 监控 算法
Java内存管理的艺术:深入理解垃圾回收机制####
本文将引领读者探索Java虚拟机(JVM)中垃圾回收的奥秘,解析其背后的算法原理,通过实例揭示调优策略,旨在提升Java开发者对内存管理能力的认知,优化应用程序性能。 ####
36 0
|
1月前
|
缓存 Prometheus 监控
Elasticsearch集群JVM调优设置合适的堆内存大小
Elasticsearch集群JVM调优设置合适的堆内存大小
291 1

热门文章

最新文章