并发编程原理与实战(十四)详解线程安全与java内存模型

前面系列文章讲解了多线程经典的并发协同的方式和多个并发协同利器。并发协同确保了多线程执行程序的‌正确性‌、‌高效率‌和‌可维护性‌,如果不做并发协同控制那么会产生哪些线程安全问题?jvm底层是如何保证并发协同的的正确性和高效性的?接下来我们来深入探讨这两个问题。

线程安全问题

多线程环境下对共享变量的操作如果不做并发同步控制,可能会导致程序出现不可预期的结果,也就是所谓的线程安全问题。那么会产生哪些线程问题,下面例子来说明。

变量可见性问题

首先,先来回顾下java中变量的概念。在Java中,局部变量和共享变量是两种重要的变量类型。局部变量主要是指定义在方法、构造函数或代码块内部的变量,仅在声明它的方法或者代码块内可见;生命周期仅限于方法或者代码块的执行期间,必须显式初始化后才能使用;存储在栈内存中。共享变量主要是指定义在类中但在方法外的变量,整个类中可见(取决于访问修饰符);生命周期与对象实例相同,有默认初始值(如int为0,对象为null等);存储在堆内存中。

变量可见性问题是指多线程环境下对同一个共享变量进行读或写操作,一个线程修改了一个变量的值,另一个线程看不到该变量最新的值。

public class VisibilityDemo {
    // 共享标志,未做任何多线程访问控制
    static boolean flag = true;
    public static void main(String[] args) {
        new Thread(() -> {
            while (flag) {
            }
            // 线程可能永远看不到主线程的修改
            System.out.println("子线程退出");
        }).start();
        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        // 主线程修改,但子线程可能不可见
        flag = false;
    }
}

上面例子中,共享变量flag未做任何多线程访问控制,运行该程序我们期望是主线程修改变量flag的值为false后,子线程应该终止循环并输出"子线程退出",但实际结果一直没有输出,程序也没有终止,子线程一直处于无限的while循环中,说明子线程并没有读取到最新的flag的值,也就是变量flag修改后的值对子线程不可见。这就是通常说的变量可见性问题。

指令重排问题

指令重排是现代计算机系统中一种关键的‌性能优化技术‌,它改变了指令的执行顺序,保证在单线程环境下‌最终结果不变,但在多线程环境中结果不可以预测。java 编程语言的语义允许编译器和微处理器对未进行同步的代码的执行顺序进行优化。

public class ReorderingDemo {
    static int x = 0;
    static int y = 0;
    static int a = 0;
    static int b = 0;
    public static void main(String[] args) throws InterruptedException {
        for (int i = 0; ; i++) {
            x = y = a = b = 0;
            Thread t1 = new Thread(() -> {
                // 语句1
                a = 1;
                // 语句2(可能重排到语句1前)
                x = b;
            });
            Thread t2 = new Thread(() -> {
                // 语句3
                b = 1;
                // 语句4(可能重排到语句3前)
                y = a;
            });
            t1.start();
            t2.start();
            //主线程等待t1执行完成
            t1.join();
            //主线程等待t2执行完成
            t2.join();
            if (x == 0 && y == 0) {  
                System.out.println("第" + i + "次重排序发生");
            }
        }
    }
}

运行结果

第796次重排序发生
第23588次重排序发生
第117591次重排序发生
第125528次重排序发生
第143241次重排序发生
第210746次重排序发生
...

上面例子中,如果发生指令重排,那么代码的执行顺序如下:

x = y = a = b = 0;
Thread t1 = new Thread(() -> {
    x = b;//x=b=0
    a = 1;
});
Thread t2 = new Thread(() -> {
    y = a;//y=a=0
    b = 1;
});

指令重排后,初始x = y = a = b = 0,由于4个共享变量都未做任何多线程访问控制,t1,t2修改了a,b变量的值后,对方都有可能没有读取到最新的值,所以x,y的值仍然可能同时是0。指令重排后,也有可能t1在t2执行b = 1之前已经执行完成了,那么x=0;t2在t1执行a = 1之前已经执行完成了,那么y=0。

原子性问题

原子性问题是指‌在多线程环境下,某些操作因线程切换或并发执行被打断,导致操作未能完整执行而产生的数据不一致问题。

public class AtomicityDemo {
    static int counter = 0;
    public static void main(String[] args) throws InterruptedException {
        for (int j = 0; ; j++) {
            counter = 0;
            Thread t1 = new Thread(() -> {
                for (int i = 0; i < 10000; i++) counter++;
            });
            Thread t2 = new Thread(() -> {
                for (int i = 0; i < 10000; i++) counter++;
            });
            t1.start();
            t2.start();
            //主线程等t1执行完成
            t1.join();
            //主线程等t2执行完成
            t2.join();
            //结果可能小于20000
            if (counter < 20000) {
                System.out.println("第" + j + "次,最终结果: " + counter);
            }
        }
    }
}

上面的例子中,两个线程同时对共享变量counter进行加1操作,各执行10000次,按我们的期望,结果应该是20000,但由于没有做任何的同步控制,实际运行结果可能小于20000。运行结果如下:

1次,最终结果: 160923次,最终结果: 155784次,最终结果: 114295次,最终结果: 179206次,最终结果: 166799次,最终结果: 19894
...

从上面的三个例子可以看出,多线程环境下对共享变量的访问如果不加同步控制,那么程序的运行结果将是不可控的,这是非常可怕的的事情。那问题又来了,为什么会产生线程安全问题?产生线程安全的根本原因是什么?这得从java的内存模型说起。

‌Java 内存模型‌(JMM)

什么是JMM?JMM全称是Java Memory Model‌,即 ‌Java 内存模型‌。下面摘自官方文档中对JMM的定义。

A memory model describes, given a program and an execution trace of that program, whether the execution trace is a legal execution of the program. The Java programming language memory model works by examining each read in an execution trace and checking that the write observed by that read is valid according to certain rules.

The memory model describes possible behaviors of a program. An implementation is free to produce any code it likes, as long as all resulting executions of a program produce a result that can be predicted by the memory model.

一个内存模型描述了给定一个程序和程序的执行轨迹,不管这个执行轨迹是否是一个合法的执行。Java编程语言内存模型的工作原理是检查执行轨迹中的每个读取,并根据某些规则检查该读取观察到的写入是否有效。

内存模型描述了程序的可能行为,提供了一个可以自由地生成任何代码的实现,并且程序的所有执行结果都能通过内存模型进行预测。

The memory model determines what values can be read at every point in the
program. The actions of each thread in isolation must behave as governed by the
semantics of that thread, with the exception that the values seen by each read are
determined by the memory model. When we refer to this, we say that the program
obeys intra-thread semantics. Intra-thread semantics are the semantics for singlethreaded programs, and allow the complete prediction of the behavior of a thread
based on the values seen by read actions within the thread. To determine if the
actions of thread t in an execution are legal, we simply evaluate the implementation
of thread t as it would be performed in a single-threaded context, as defined in the
rest of this specification.

内存模型决定了在程序执行的每一个点什么值可以读取。线程独立执行时必须按照该线程的语义规则运行,唯一例外是‌每次读取操作所见的值由内存模型决定‌。我们将此称为程序‌遵循线程内语义(intra-thread semantics)。
线程内语义即单线程程序的语义,它能基于线程内读取操作所见的值,‌完全预测该线程的行为‌。为判定某次执行中线程 t 的操作是否合法,只需‌在单线程上下文中评估线程 t 的实现‌。

Each time the evaluation of thread t generates an inter-thread action, it must match
the inter-thread action a of t that comes next in program order. If a is a read, then
further evaluation of t uses the value seen by a as determined by the memory model.

每当线程 t 的执行过程‌生成一个线程内操作(inter-thread action)时,该操作必须与其程序顺序中的下一个线程内操作 a 相匹配‌。若 a 是读操作,则线程 t 的后续执行将‌使用内存模型为 a 确定的值‌。

总的来讲,Java内存模型(JMM)定义了多线程环境下共享变量的访问规则,通过一系列规则协议确保线程安全。

Java 内存模型‌原理

上面官方对jvm内存模型中的描述,重点强调了线程必须按编程语言的语义规范执行,所以JMM本质上是规范而不是物理结构,其定义了一系列多线程并发访问规则协议。官方并没有提供可视化的JMM原理图,但通过文字描述可以抽象归纳出JMM的核心组件:主内存、工作内存、操作指令。

主内存

官方jls文档17章的开头和17.4.1章节描述了主内存(共享内存)的定义。

...
the Java Virtual Machine can support many threads of execution
at once. These threads independently execute code that operates on values and
objects residing in a shared main memory. Threads may be supported by having
many hardware processors, by time-slicing a single hardware processor, or by timeslicing many hardware processors.
...    

Java虚拟机能够同时支持‌多执行线程‌(Multiple Threads of Execution)。这些线程独立运行操作‌共享主内存‌(Shared Main Memory)中数据值与对象的代码。多线程可通过以下方式实现支持:多个硬件处理器并行执行,单个硬件处理器的时间片调度(Time-slicing),多个硬件处理器的时间片混合调度。

Memory that can be shared between threads is called shared memory or heap
memory.All instance fields, static fields, and array elements are stored in heap memory.
In this chapter, we use the term variable to refer to both fields and array elements.

线程间可共享的内存称为共享内存(Shared Memory)或堆内存(Heap Memory)‌。所有实例字段(Instance Fields)、静态字段(Static Fields)以及数组元素(Array Elements)均存储于堆内存中。线程对变量的修改必须通过主内存对其他线程可见。

工作内存

Local variables (§14.4), formal method parameters (§8.4.1), and exception handler
parameters (§14.20) are never shared between threads and are unaffected by the
memory model.

每个线程私有的内存区域,存储线程操作的‌主内存变量副本‌,以及其他线程私有的局部变量、方法参数及异常处理参数等,工作内存中变量永不跨线程共享‌,且不受内存模型的影响。线程的所有读写操作必须在工作内存中进行,禁止直接访问主内存或其他线程的工作内存。

主内存和工作内存交互示意图

了解了主内存、工作内存和内存操作指令的相关定义后,我们抽象出主内存和工作内存交互示意图,如下:
在这里插入图片描述

从上述示意图中可以看出,如果主存有一个共享变量a=0,三个线程同时从主存中读取了a到自己的工作内存中,然后对a进行加1,然后再写回会主存,这个结果会是等于3吗?很明显,如果没有对线程的读写操作进行有序的控制,那么这个结果并不能保证等于3。因为如果三个线程同时读到a=0,然后在自己的工作内存中对a进行加1。在每个线程的工作内存中a就等于1,然后线程写回主存的时候a也是等于1。很显然这个结果并不是我们期望的结果,产生的这个问题就是线程安全的问题。

内存操作指令

JMM的特性产生了变量的可见性和线程安全的问题,为了避免这些问题,JMM定义了一系列的共享变量的同步协议来约束线程间的操作。

An inter-thread action is an action performed by one thread that can be detected or
directly influenced by another thread. There are several kinds of inter-thread action
that a program may perform:Read (normal, or non-volatile). Reading a variable.Write (normal, or non-volatile). Writing a variable.Synchronization actions, which are:Volatile read. A volatile read of a variable.Volatile write. A volatile write of a variable.Lock. Locking a monitor
– Unlock. Unlocking a monitor.The (synthetic) first and last action of a thread.Actions that start a thread or detect that a thread has terminated
• External Actions. An external action is an action that may be observable outside
of an execution, and has a result based on an environment external to the
execution.Thread divergence actions (§17.4.9). A thread divergence action is only
performed by a thread that is in an infinite loop in which no memory,
synchronization, or external actions are performed. If a thread performs a thread
divergence action, it will be followed by an infinite number of thread divergence
actions.

线程间的操作‌(Inter-thread Action)是指可由一个线程执行,且能被其他线程‌探测或直接影响‌的操作。程序可执行的线程间的操作包括以下几类:

1、读取‌(Read,常规或者非volatile):读取一个变量
2、写入‌(Write,常规或者非volatile):写入一个变量
3、同步操作‌(Synchronization Actions):
(1) ‌volatile读‌(Volatile read):volatile变量的读取
(2) ‌volatile写‌(Volatile write):volatile变量的写入
(3) ‌加锁‌(Lock):获取对象监视器(monitor)锁
(4) ‌解锁‌(Unlock):释放对象监视器(monitor)锁
(5) 线程的(合成)启动与终止动作
(6) 启动线程或检测线程终止的操作
4‌、外部操作‌(External Actions):可被执行环境外部观测,其结果取决于外部环境的操作(如I/O)
‌5、线程发散操作‌(Thread Divergence Actions):仅在‌无限循环且不执行内存/同步/外部操作‌的线程中发生。一旦触发,将引发无限次发散操作。

内存同步协议中定义了8种数据原子操作。

操作指令作用约束规则
lock标记主内存变量为线程独占状态(进入同步块时触发)同一变量仅允许一个线程lock
unlock释放被锁定的变量(退出同步块时触发)必须与lock成对出现
read将主内存变量值传输至线程工作内存(未加载)必须与load连续执行
loadread传输的值存入工作内存的变量副本新变量必须通过load初始化
use将工作内存变量值传递给执行引擎(如CPU计算)执行前需先load变量
assign将执行引擎结果赋值给工作内存变量副本赋值后必须同步回主内存
store将工作内存变量值传输至主内存(未写入)必须与write连续执行
writestore传输的值写入主内存变量,完成更新lock的变量写入可能被覆盖

核心规则:
‌顺序性‌:lock→unlock、read→load、store→write操作必须成对顺序执行。
‌可见性‌:变量修改后必须通过store→write操作从工作内存同步至主内存。
‌原子性‌:除了long/double类型的变量外所有操作默认具有原子性(32位JVM中long/double非原子)。

总结

本文开头先是用三个例子来说明多线程环境下变量的可见性、指令重排、原子性问题,由此引出线程安全问题,然后从官方的角度讲解了java内存模型的概念和核心原理,希望通过理解了java内存模型的核心原理来深刻理解产生线程安全问题的根本原因。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

帧栈

您的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值