《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

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

  • 写屏障仅仅是保证之后的读能够读到新的结果,但不能保证读跑到它前面去
  • 而有序性的保证也只是保证了本线程内相关代码不被重排序
相关文章
|
3天前
|
程序员 编译器 C++
【C++核心】C++内存分区模型分析
这篇文章详细解释了C++程序执行时内存的四个区域:代码区、全局区、栈区和堆区,以及如何在这些区域中分配和释放内存。
13 2
|
9天前
|
监控 算法 Java
Java中的内存管理:理解Garbage Collection机制
本文将深入探讨Java编程语言中的内存管理,特别是垃圾回收(Garbage Collection, GC)机制。我们将从基础概念开始,逐步解析垃圾回收的工作原理、不同类型的垃圾回收器以及它们在实际项目中的应用。通过实际案例,读者将能更好地理解Java应用的性能调优技巧及最佳实践。
33 0
|
3天前
|
存储 缓存 Java
java线程内存模型底层实现原理
java线程内存模型底层实现原理
java线程内存模型底层实现原理
|
1天前
|
Java 开发者
深入探索Java中的并发编程
本文将带你领略Java并发编程的奥秘,揭示其背后的原理与实践。通过深入浅出的解释和实例,我们将探讨Java内存模型、线程间通信以及常见并发工具的使用方法。无论是初学者还是有一定经验的开发者,都能从中获得启发和实用的技巧。让我们一起开启这场并发编程的奇妙之旅吧!
|
5天前
|
Java 编译器
深入理解Java内存模型:从基础到高级
本文旨在通过通俗易懂的方式,引导读者深入理解Java内存模型(JMM)的核心概念和工作原理。我们将从简单的基础知识入手,逐步探讨重排序、顺序一致性问题以及volatile关键字的实现机制等高级主题。希望通过这篇文章,你能够对Java内存模型有一个清晰、全面的认识,并在实际编程中有效地避免并发问题。
|
3天前
|
存储 算法 Java
深入理解Java内存管理
本文将通过通俗易懂的语言,详细解析Java的内存管理机制。从JVM的内存结构入手,探讨堆、栈、方法区等区域的具体作用和原理。进一步分析垃圾回收机制及其调优方法,最后讨论内存泄漏的常见场景及防范措施。希望通过这篇文章,帮助读者更好地理解和优化Java应用的内存使用。
|
3天前
|
算法 安全 Java
Java中的并发编程是如何实现的?
Java中的并发编程是通过多线程机制实现的。Java提供了多种工具和框架来支持并发编程。
12 1
|
7天前
|
监控 算法 Java
Java中的内存管理与垃圾回收机制
本文将深入探讨Java编程语言中的内存管理方式,特别是垃圾回收(Garbage Collection, GC)机制。我们将了解Java虚拟机(JVM)如何自动管理内存,包括对象创建、内存分配以及不使用对象的回收过程。同时,我们还将讨论不同的垃圾回收算法及其在不同场景下的应用。
|
6天前
|
监控 算法 Java
深入理解Java中的垃圾回收机制在Java编程中,垃圾回收(Garbage Collection, GC)是一个核心概念,它自动管理内存,帮助开发者避免内存泄漏和溢出问题。本文将探讨Java中的垃圾回收机制,包括其基本原理、不同类型的垃圾收集器以及如何调优垃圾回收性能。通过深入浅出的方式,让读者对Java的垃圾回收有一个全面的认识。
本文详细介绍了Java中的垃圾回收机制,从基本原理到不同类型垃圾收集器的工作原理,再到实际调优策略。通过通俗易懂的语言和条理清晰的解释,帮助读者更好地理解和应用Java的垃圾回收技术,从而编写出更高效、稳定的Java应用程序。
|
13天前
|
监控 算法 Java
Java中的内存管理:理解垃圾回收机制的深度剖析
在Java编程语言中,内存管理是一个核心概念。本文将深入探讨Java的垃圾回收(GC)机制,解析其工作原理、重要性以及优化方法。通过本文,您不仅会了解到基础的GC知识,还将掌握如何在实际开发中高效利用这一机制。

热门文章

最新文章