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

《Java 并发编程》专栏索引 👉 《Java 并发编程》进程与线程 👉《Java 并发编程》共享模型之管程 👉《Java 并发编程》共享模型之内存 👉《Java 并发编程》共享模型之无锁 👉《Java 并发编程》共享模型之不可变 👉《Java 并发编程》线程池

@[TOC](《Java 并发编程》共享模型之内存) Java 内存模型(Java Memory Model,JMM),定义了主存、工作内存抽象概念,底层对应着 CPU 寄存器、缓存、硬件内存和 CPU 指令优化等。

JMM 体现在以下几个方面:

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

🚀1. 原子性

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

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

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

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

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

而对应 i-- 也是类似:

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

而 Java 的内存模型如下,完成静态变量的自增、自减需要在主存和线程内存中进行数据交换: 如果是单线程以下 8 行代码是顺序执行(不会交错)没有问题:

java 复制代码
// 假设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 行代码可能交错运行,出现负数的情况:

java 复制代码
// 假设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

出现正数的情况:

java 复制代码
// 假设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 (关键字)

语法:

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

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

java 复制代码
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 线程无法停止:

java 复制代码
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 的值到工作内存。
  2. 因为 t 线程要频繁从主内存中读取 run 的值,JIT 编译器会将 run 的值缓存至自己工作内存中的高速缓存中,减少对主存中 run 的访问,提高效率。
  3. 1 秒之后,main 线程修改了 run 的值,并同步至主存,而 t 是从自己工作内存中的高速缓存中读取这个变量的值,结果永远是旧值。

解决方法:

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

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

java 复制代码
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-- ,只能保证看到最新值,不能解决指令交错。

java 复制代码
// 假设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 变量的修改了。

java 复制代码
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 关键字,保证了可见性。

java 复制代码
/**
 * 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() 方法停止线程:目的仅是停止一个线程,但这种做法会让整个程序都停止。

两阶段终止

  1. 利用 isInterrupted interrupt 可以打断正在执行的线程,无论这个线程是在 sleep,wait 还是正常运行
java 复制代码
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();
    }
}

运行结果

Bash 复制代码
将结果保存
将结果保存
将结果保存
stop
结束前处理
  1. 利用停止标记
java 复制代码
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();
    }
}

运行结果

Bash 复制代码
将结果保存
将结果保存
将结果保存
stop
结束前处理

🚁2.2 同步模式之犹豫模式

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

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

实现

java 复制代码
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;
    }
}

运行结果

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

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

java 复制代码
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 会在不影响正确性的前提下,可以调整语句的执行顺序,例如下面的代码:

java 复制代码
static int i;
static int j;

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

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

java 复制代码
i = ...;
j = ...;

也可以是

java 复制代码
j = ...;
i = ...;

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

java 复制代码
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() 对应的字节码

java 复制代码
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 按如下时间序列执行

java 复制代码
时间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 个阶段。

术语参考: instruction fetch(IF) instruction decode(ID) execute(EX) memory access(MEM) register write back(WB)

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

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

java 复制代码
//可以重排的例子
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,本质上,流水线技术并不能缩短单条指令的执行时间,但它变相地提高了指令吞吐率。 在多线程环境下,指令重排序可能导致出现意料之外的结果。

解决方法

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

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

🚀4. 内存屏障

可见性

  • 写屏障 (sfence)保证在该屏障之前的,对共享变量的改动,都同步到主存当中
  • 读屏障 (lfence)保证在该屏障之后,对共享变量的读取,加载的是主存中新数据

有序性

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

🚀5. volatile 原理

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

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

🚁5.1 如何保证可见性

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

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

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

🚁5.2 如何保证有序性

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

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

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

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

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

  • 写屏障仅仅是保证之后的读能够读到新的结果,但不能保证读跑到它前面去
  • 而有序性的保证也只是保证了本线程内相关代码不被重排序
相关推荐
海绵波波1073 小时前
flask后端开发(10):问答平台项目结构搭建
后端·python·flask
网络风云4 小时前
【魅力golang】之-反射
开发语言·后端·golang
Q_19284999064 小时前
基于Spring Boot的电影售票系统
java·spring boot·后端
运维&陈同学5 小时前
【Kibana01】企业级日志分析系统ELK之Kibana的安装与介绍
运维·后端·elk·elasticsearch·云原生·自动化·kibana·日志收集
Javatutouhouduan8 小时前
如何系统全面地自学Java语言?
java·后端·程序员·编程·架构师·自学·java八股文
后端转全栈_小伵8 小时前
MySQL外键类型与应用场景总结:优缺点一目了然
数据库·后端·sql·mysql·学习方法
编码浪子9 小时前
Springboot高并发乐观锁
后端·restful
uccs9 小时前
go 第三方库源码解读---go-errorlint
后端·go
Mr.朱鹏9 小时前
操作002:HelloWorld
java·后端·spring·rabbitmq·maven·intellij-idea·java-rabbitmq
编程洪同学11 小时前
Spring Boot 中实现自定义注解记录接口日志功能
android·java·spring boot·后端