深入理解 Java 虚拟机(十二)Java 内存模型与线程

版权声明:本文为博主原创文章,未经博主允许不得转载。 https://blog.csdn.net/u011330638/article/details/82817459

概述

多任务处理是现在计算机操作系统必备的功能,在许多情况下,让计算机同时做几件事,不仅是因为计算机的运算能力强大了,更重要的是计算机的运算速度与它的存储和通信子系统速度的差距太大,大量的时间都花费在磁盘 I/O、网络通信或者数据库访问上。因此才有了并发,以尽可能充分地利用处理器的运算能力。

另一个更具体的并发应用场景是一个服务端同时对多个客户端提供服务,衡量一个服务性能的高低好坏,每秒事务处理数(Transactions Per Second,TPS)是最重要的指标之一,它代表着一秒内服务端平均能响应的请求总数,TPS 值与程序的并发能力有着非常密切的关系。

硬件的效率与一致性

让计算机并发执行并没有想象中那么简单,一个重要的复杂性是绝大多数的运算任务都不能只靠处理器计算就能完成,处理器至少要与内存交互,由于内存与处理器的运算速度相差巨大,因此现在计算机系统不得不加入一层接近处理器运算速度的高速缓存来作为内存与处理器之间的缓冲:将运算需要用到的数据复制到缓存中,让运算能快速进行,当运算结束后再从缓存同步回内存之中。

但高速缓存也带来了一个新的问题:缓存一致性,因为每个处理器都有自己的高速缓存,而它们又共享同一主内存。为了解决这个问题,每个处理器访问缓存时都需要遵守例如 MSI 之类的协议。

缓存一致性

除此之外,为了使得处理器内部的运算单元能尽量被充分利用,处理器可能会对输入代码进行乱序优化,处理器会在计算之后将乱序执行的结果重组,保证该结果与顺序执行的结果是一致的,但并不保证程序中各个语句计算的先后顺序与输入代码中的顺序一致。因此,如果存在一个计算任务依赖另外一个计算任务的中间结果,那么其顺序不能靠代码的先后顺序来保证。类似的,Java 虚拟机的即时编译器也有指令重排序优化。

Java 内存模型

Java 虚拟机规范试图定义一种 Java 内存模型(Java Memory Model,JMM)来屏蔽掉各种硬件和操作系统的内存访问差异,以实现让 Java 程序在各种平台下都能达到一致的内存访问效果。

主内存与工作内存

Java 内存模型的主要目标是定义程序中各个变量的访问规则,即在虚拟机中将变量存储到内存和从内存中取出变量这样的底层细节。变量指实例字段、静态字段和构成数组对象的元素,不包括局部变量和方法参数。

Java 内存模型规定了所有的变量都存储在主内存中(可类比为物理硬件中的主内存),每条线程还有自己的工作内存(可类比为高速缓存),线程的工作内存中保存了被该线程使用到的变量的主内存副本拷贝,线程对变量的所有操作都必须在工作内存中进行,而不能直接读写主内存中的变量,不同线程之间也无法直接访问对方工作内存中的变量,线程间变量值的传递均需要通过主内存来完成。线程、主内存、工作内存三者的交互关系如图:

线程 - 工作内存 - 主内存交互

内存间交互操作

关于主内存和工作内存之间的交互,Java 内存模型中定义了以下 8 中操作来完成,虚拟机实现时必须保证这些操作都是原子的、不可再分的:

  1. lock,作用于主内存中的变量,把一个变量标识为一条线程独占的状态

  2. unlock,作用于主内存中的变量,释放一个处于锁定状态的变量

  3. read,作用于主内存中的变量,把一个变量的值从主内存传输到线程的工作内存中

  4. load,作用于工作内存的变量,把 read 操作从主内存得到的变量值放入工作内存的变量副本中

  5. use,作用于工作内存的变量,把工作内存中一个变量的值传递给执行引擎,每当虚拟机遇到一个需要使用到变量的值的字节码指令时执行这个操作

  6. assign,作用于工作内存的变量,把一个从执行引擎收到的值赋给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作

  7. store,作用于工作内存的变量,把工作内存中一个变量的值传递到主内存中

  8. write,作用于主内存的变量,把 store 操作从工作内存中得到的变量的值放入主内存的变量中

同时,这 8 种操作必须满足以下规则:

  1. read 和 load、store 和 write 必须成对出现

  2. 不允许一个线程丢弃它的最近的 assign 操作,即变量在工作内存中改变了之后必须把该变化同步回主内存中

  3. 不允许一个线程无原因地(没有发生过任何 assign 操作)把数据从线程的工作内存同步回主内存中

  4. 新的变量只能在主内存中诞生,不允许在工作内存中直接使用一个未被初始化的变量,即,对一个变量实施 use、store 操作之前,必须先执行过 assign 和 load 操作

  5. 一个变量同一时刻只允许一条线程对其进行 lock 操作,同一条线程可以执行多次 lock,之后执行相同次数的 unlock 才会解锁该变量

  6. 对一个变量执行 lock 操作后,将会清空工作内存中此变量的值,执行引擎使用这个变量之前,需要重新执行 load 或 assign 操作初始化变量的值

  7. 不允许 unlock 一个未被 lock 的变量

  8. 对一个变量执行 unlock 操作之前,必须先把此变量同步回主内存中(store & write)

对 volatile 变量的特殊规则

当一个变量被定义为 volatile 之后,它将具备两种特性,第一是保证此变量对所有线程的可见性,可见性指当一条线程修改了这个变量的值,新值对于其它线程而言是可以立即获知的,而普通变量在线程间的值传递需要通过主内存来完成。但 Java 里面的运算并非原子操作,因此 volatile 变量的运算在并发下一样是不安全的,比如:

public class VolatileTest {

    public static volatile int race = 0;

    public static void increase() {
        race++;
    }

    private static final int THREADS_COUNT = 20;

    public static void main(String[] args) {
        Thread[] threads = new Thread[THREADS_COUNT];
        for (int i = 0; i < THREADS_COUNT; i++) {
            threads[i] = new Thread(new Runnable() {
                @Override
                public void run() {
                    for (int i = 0; i < 10000; i++) {
                        increase();
                    }
                }
            });
            threads[i].start();
        }

        // 等待所有累加线程都结束
        while (Thread.activeCount() > 1)
            Thread.yield();

        System.out.println(race);
    }
}

只有一行代码的 increase() 方法在 Class 文件中是由 4 条字节码指令(使用字节码指令来分析问题是不严谨的,因为字节码指令在解释执行时可能会转化成多条本地机器码指令,即使字节码指令只有一条,也不一定是原子操作,这里只是为了方便说明问题)构成的:

  public static void increase();
    descriptor: ()V
    flags: ACC_PUBLIC, ACC_STATIC
    Code:
      stack=2, locals=0, args_size=0
         0: getstatic     #2                  // Field race:I
         3: iconst_1
         4: iadd
         5: putstatic     #2                  // Field race:I
         8: return

volatile 只能保证执行 getstatic 操作时,变量值是正确的,但不能保证在执行 iconst_1、iadd 时,其它线程是否把变量的值加大了。

因此,在不符合以下两条规则的运算中,仍然需要通过加锁来保证原子性:

  1. 运算结果不依赖变量的当前值,或者确保只有单一的线程修改变量的值
  2. 变量不需要与其它的状态变量共同参与不变约束

而像下面这种场景,则适合使用 volatile:

volatile boolean shouldShutdown = false;

// 假设这个方法在线程 A 执行
public void shutdown() {
    shouldShutdown = true;
}

// 假设这个方法在线程 B 执行
public void doWork() {
    while (!shouldShutdown) {
        // do something
    }
}

volatile 的第二个特性是能够禁止指令重排序优化,比如:

Map configOptions;
char[] configText;
// 这个变量必须定义为 volatile
volatile boolean initialized = false;

// 假设以下代码在线程 A 执行
configOptions = new HashMap();
configText = readConfigFile(fileName);
processConfigOptions(configText, configOptions);
initialized = true;

// 假设以下代码在线程 B 执行
while (!initialized) {
    sleep();
}
// 使用线程 A 配置好的数据
doSomethingWithConfig();

如果 initialized 没有使用 volatile 修饰,就可能由于指令重排序优化,而导致位于线程 A 的最后一句代码 initialized = true 被提前执行,进而导致在线程 B 中使用的配置信息出错。

在某些情况下,volatile 的同步机制的性能优于锁,但由于虚拟机对锁实行的许多消除和优化,很难量化地认为 volatile 就会比 synchronized 快多少。但可以确定一个原则:volatile 变量读操作的性能消耗与普通变量几乎没有区别,但是写操作可能会慢一些,因为它需要在本地代码中插入许多内存屏障指令来保证处理器不会发生乱序执行。因此,大多数场景下,volatile 的总开销仍然比锁要低,选择 volatile 还是锁的唯一依据是 volatile 是否能满足需求。

对于 long 和 double 型变量的特殊规则

对于 64 位的数据类型,Java 内存模型定义了一条相对宽松的规定:允许虚拟机将没有被 volatile 修饰的 64 位数据的读写操作划分为两次 32 位的操作来进行,即允许虚拟机可以不保证 64 位数据的 load、store、read 和 write 这 4 个操作的原子性,这就是所谓的 long 和 double 的非原子性协定。

如果有多个线程共享一个未被声明为 volatile 的 long 或 double 类型的数据,并且同时对它们进行读取或修改操作,那么某些线程可能会读到一个既非原值,也不是其它线程修改值的代表了“半个变量”的数值。不过这种情况十分罕见,Java 内存模型强烈建议 Java 虚拟机把 long 和 double 变量的读写操作实现为原子操作,实际开发中,目前各种平台下的虚拟机几乎都选择遵循这个建议。

原子性、可见性与有序性

原子性:由 Java 内存模型来直接保证原子性的变量操作包括 read、load、assing、use、store 和 write,基本可以认为基本数据类型的访问读写是具备原子性的。如果需要保证更大范围的原子性,可以使用关键字 synchronized,synchronized 对应的字节码指令是 moniterenter、moniterexit。

可见性:指当一个线程修改了变量的值,其它线程能够立即获知这个修改。Java 内存模型是通过在变量修改后将新值同步回主内存,在变量读取前从主内存刷新变量值这种依赖主内存作为传递媒介的方式来实现可见性的,无论是普通变量还是 volatile 变量都是如此,区别在于,volatile 可以保证多线程操作时变量的可见性。除了 volatile 之外,Java 还有两个关键字能实现可见性:synchronized、final。

有序性:Java 程序天然的有序性可以总结为:如果在本线程内观察,所有操作都是有序的;如果在其它线程观察,所有操作都是无序的(比如上面的 initialzied 的例子)。Java 提供了 volatile 和 synchronized 来保证线程之间操作的有序性。

先行发生原则

如果 Java 内存模型中所有的有序性都要靠 volatile 或 synchronized 来保证,那么一些操作将会变得很繁琐,但我们编写代码时没有感觉到这一点,是因为 Java 中有一个先行发生的原则,这个原则是判断数据是否存在竞争、线程是否安全的主要依据。

先行发生是 Java 内存模型中定义的两项操作之间的偏序关系,如果有操作 A 先行发生于操作 B,那么操作 A 产生的影响能被操作 B 观察到,影响包括内存中共享变量的值、发送了消息、调用了方法等。例如:

i = 1; // 在线程 A 执行
j = i; // 在线程 B 执行
i = 2; // 在线程 C 执行

假如线程 A 中的操作 i = 1 先行发生于线程 B 中的操作 j = 1,则 j 必然等于 1,但如果线程 C 也在线程 A 之后执行,但线程 C 和线程 B 之间没有先行发生关系,则 j 的值是不确定的。

Java 内存模型中天然的先行发生关系有:

  1. 程序次序规则:在一个线程内,书写在前面的操作先行发生于书写在后面的操作

  2. 管程锁定规则:一个 unlock 操作先行发生于后面对同一个锁的 lock 操作

  3. volatile 变量规则:对一个 volatile 变量的写操作先行发生于后面对这个变量的读操作

  4. 线程启动规则:线程中的所有操作都先行发生于对此线程的终止检测

  5. 线程中断规则:对线程 interrupt() 方法的调用先行发生于被中断线程的代码检测到中断事件的发生

  6. 对象终结原则:一个对象的初始化完成先行发生于它的 finalize 方法的开始

  7. 传递性:如果操作 A 先行发生于操作 B,操作 B 先行发生于操作 C,那么操作 A 先行发生于操作 C

如果两个操作之间的关系不在此列,则虚拟机可以对它们随意地进行重排序

Java 与线程

线程的实现

实现线程主要有 3 种方式:使用内核线程实现、使用用户线程实现、使用用户线程加轻量级进程混合实现

使用内核线程实现

内核线程就是直接由操作系统内核支持的线程,这种线程由内核来完成线程切换,内核通过操作调度器对线程进行调度,并负责将线程的任务映射到各个处理器上,每个内核线程可以视为内核的一个分身。

程序一般不会直接使用内核线程,而是使用内核线程的一种高级接口——轻量级进程(Light Weight Process,LWP),轻量级进程就是通常意义上的线程,每个轻量级进程都由一个内核线程支持:

在这里插入图片描述

轻量级进程基于内核线程实现,因此各种线程操作,包括创建、析构和同步,都需要进行系统调用,调用代价较高,且需要在用户态和内核态中来回切换,会消耗一定的内核资源,因此一个系统支持的轻量级进程是有限的。

使用用户线程实现

广义上的用户线程指内核线程之外的线程,狭义上的用户线程指完全建立在用户空间的线程库上,系统内核不能感知,建立、同步、销毁和调度完全在用户态中完成,不需要内核的帮助的线程。

如果程序实现得当,这种线程不需要切换到内核态,因此可以非常快速且低消耗,也可以支持更大规模的线程数量。

在这里插入图片描述

但也因为没有系统内核的支援,实现起来异常复杂且困难,因此现在使用用户线程的程序越来越少了,Java 也放弃了它。

使用用户线程加轻量级进程混合实现

在这种混合实现下,用户线程还是完全建立在用户空间中,因此线程的创建、切换、析构等操作依然廉价,并且可以支持大规模的用户线程并发,同时,可以使用内核提供的线程调度功能及处理器映射,大大降低了进程阻塞的风险。

在这里插入图片描述

Java 线程的实现

Java 在 JDK 1.2 之前是使用用户线程实现的,目前的 JDK 版本中,虚拟机规范并未规定 Java 需要使用的线程模型。对于 Sun JDK 来说,Window、Linux 版本都是使用一对一的线程模型实现的。

Java 线程调度

线程调度是指系统为线程分配处理器使用权的过程,主要有协同式线程调度、抢占式线程调度两种。

对于协同式线程调度,线程的执行时间是由线程本身控制的,线程把自己的工作完成之后就主动通知系统切换到另一条线程上。这种方式的最大好处是实现简单,切换操作对于线程自己是可知的,没有线程同步的问题;坏处是执行时间不可控制,可能会由于某个线程编写有问题而导致整个程序一直阻塞在那里。

对于抢占式线程调度,将由系统来分配线程的执行时间,线程的切换不由线程本身来决定(Java 中的 Thread.yield() 可以让出执行时间,但没有取得执行时间的方法)。在这种方式下,线程的执行时间是系统可控的,不会有一个线程导致整个进程阻塞的问题,Java 使用的就是这种。

Java 可以为线程分配优先级来建议系统给某些线程多分配一些时间,不过这种方式不是太靠谱,因为线程调度最终取决于操作系统,而操作系统提供的优先级的概念不一定和 Java 提供的相对应,而且优先级可能会被系统自行改变。

状态转换

Java 定义了 5 种线程状态:

在这里插入图片描述

总结

思维导图

在这里插入图片描述

猜你喜欢

转载自blog.csdn.net/u011330638/article/details/82817459
今日推荐