《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

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

  • 写屏障仅仅是保证之后的读能够读到新的结果,但不能保证读跑到它前面去
  • 而有序性的保证也只是保证了本线程内相关代码不被重排序
相关文章
|
6天前
|
存储 NoSQL Java
一天五道Java面试题----第十一天(分布式架构下,Session共享有什么方案--------->分布式事务解决方案)
这篇文章是关于Java面试中的分布式架构问题的笔记,包括分布式架构下的Session共享方案、RPC和RMI的理解、分布式ID生成方案、分布式锁解决方案以及分布式事务解决方案。
一天五道Java面试题----第十一天(分布式架构下,Session共享有什么方案--------->分布式事务解决方案)
|
1天前
|
缓存 算法 Java
聚焦Java应用程序的内存管理和调优技巧
在现代软件开发中,性能优化对提升用户体验和系统稳定性至关重要。本文聚焦Java应用程序的内存管理和调优技巧。从理解Java内存模型入手,深入探讨堆内存的管理与优化,揭示如何避免内存泄漏,利用工具检测问题,并介绍高效字符串处理及数据结构选择的方法。同时,解析垃圾回收机制及其调优策略,包括不同回收器的选择与配置。此外,还介绍了调整堆大小、运用对象池和缓存技术等高级技巧。通过这些方法,开发者能有效提升应用性能和稳定性。
10 1
|
1天前
|
easyexcel Java 关系型数据库
阿里巴巴-EasyExcel 基于Java的简单、省内存的读写Excel
该文章主要介绍了在Java应用中如何使用EasyExcel技术完成对Excel文件的导入和导出操作,包括环境搭建、基本概念、快速入门、进阶操作和综合应用等内容,并提供了相关代码示例和注意事项。
15 0
 阿里巴巴-EasyExcel 基于Java的简单、省内存的读写Excel
|
5天前
|
存储 Java 开发者
Java 内存模型与垃圾回收机制的深度剖析
在Java的世界里,理解其内存模型和垃圾回收机制是提升编程效率的关键。本文将通过浅显易懂的语言,带你深入了解Java的内存分配原理和垃圾回收过程,让你对Java的性能调优有更深刻的认识。
|
8天前
|
缓存 Java 数据处理
Java中的并发编程:解锁多线程的力量
在Java的世界里,并发编程是提升应用性能和响应能力的关键。本文将深入探讨Java的多线程机制,从基础概念到高级特性,逐步揭示如何有效利用并发来处理复杂任务。我们将一起探索线程的创建、同步、通信以及Java并发库中的工具类,带你领略并发编程的魅力。
|
8天前
|
安全 Java 程序员
深入浅出Java内存模型:探索JMM的奥秘
在Java编程世界中,理解其内存模型(JMM)是提升代码性能和确保线程安全的关键。本文将带你走进Java内存模型的大门,通过浅显易懂的方式揭示其工作原理,并指导你如何在实际开发中有效利用JMM来避免常见的并发问题。
|
10天前
|
Java 调度 开发者
Java并发编程:解锁多线程同步的奥秘
在Java的世界里,并发编程是提升应用性能的关键所在。本文将深入浅出地探讨Java中的并发工具和同步机制,带领读者从基础到进阶,逐步掌握多线程编程的核心技巧。通过实例演示,我们将一起探索如何在多线程环境下保持数据的一致性,以及如何有效利用线程池来管理资源。无论你是初学者还是有一定经验的开发者,这篇文章都将为你打开新的视野,让你对Java并发编程有更深入的理解和应用。
|
4天前
|
存储 监控 算法
掌握Java内存管理:从入门到精通
在Java的世界里,内存管理是程序运行的心脏。本文将带你走进Java内存管理的奥秘,从基础概念到高级技巧,一步步揭示如何优化你的Java应用。准备好迎接挑战,让我们共同揭开高效内存使用的面纱!
|
5天前
|
安全 Java 开发者
Java中的并发编程:从基础到高级
本文将深入浅出地介绍Java并发编程的核心概念,包括线程安全、同步机制、锁和线程池等。我们将从简单的多线程示例出发,逐步深入到高级并发工具类的应用,最后探讨性能优化技巧。无论你是初学者还是有经验的开发者,这篇文章都将为你提供有价值的知识和实践建议。
5 0
|
7天前
|
存储 缓存 安全
Java内存模型详解
该文章主要介绍了Java内存模型的相关概念和技术细节,包括Java内存模型的定义、缓存一致性策略、内存交互操作、内存屏障等。