Java 内存模型与线程

这篇文章是我之前看书时做的笔记,内容都是基于【深入理解 JVM 虚拟机】(周志明)这本书中介绍的东西,觉得有一定的价值所以拿出来分享。并且个人时间总是有限的,无法保证大量的产出,因此拿点之前的东西来充个数。

硬件的效率与一致性

由于计算的存储设备与处理器的运算速度有几个数量级的差距,所以现代计算机系统都不得不加入一层读写速度尽可能接近处理器运算速度的高速缓存(Cache)来作为内存与处理器之间的缓冲。

但是它引入了一个新的问题:缓存一致性(Cache Coherence)。在多处理器系统中,每个处理器都有自己的高速缓存,而他们又共享同一主内存(Main Memory),当多个处理器的运算任务都涉及同一块主内存区域时,将可能导致各自的缓存数据不一致。

Java 内存模型

主内存与工作内存

Java 内存模型主要目标是定义程序中各个变量的访问规则,即在虚拟机中将变量存储到内存和从内存中取出变量这样的底层细节。此处的变量(Variables)与 Java 编程中所说的变量有所区别,它包括了实例字段、静态字段和构成数组对象的元素,但不包括局部变量与方法参数,因为后者是线程私有的,不会被共享,自然就不会存在竞争问题。

Java 内存模型规定了所有的变量都存储在主内存(Main Memory)中,每条线程还有自己的工作内存(Working Memory),线程的工作内存中保存了被该线程使用到的变量的主内存拷贝,线程对变量的所有操作(读取、赋值)都必须在工作内存中进行,线程间传递变量需要通过主内存来完成。

内存间交互操作

Java 内存模型中定义了以下 8 种操作来完成,虚拟机实现必须保证下面提到的每一种操作都是原子的、不可再分的。

  • lock(锁定):作用于主内存的变量,它把每一个变量标识为一条线程独占的状态。
  • unlock(解锁):作用于主内存的变量,他把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定。
  • read(读取):作用于主内存的变量,他把一个变量的值从主内存传输到线程工作的内存中,以便随后的 load 动作使用。
  • load(载入):作用于工作内存的变量,它把 read 操作从主内存中得到的变量值放入工作内存的变量副本中。
  • use(使用):作用于工作内存的变量,它把工作内存中一个变量的值传递给执行引擎,每当虚拟机遇到一个需要使用到变量的值的字节码指令时将会执行这个操作。
  • assign(赋值):作用于工作内存的变量,他把一个从执行引擎接收到的值赋给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作。
  • store(存储):作用于工作内存的变量,它把工作内存中的一个变量的值传送到主内存中,以便随后的 write操作使用。
  • write(写入):作用于主内存的变量,它把 store 操作从工作内存中得到的变量的值放入主内存的变量中。

如果要把一个变量从主内存复制到工作内存,那就要顺序的执行 read 和 load 操作,如果要把变量从工作内存同步回主内存,就要顺序的执行 store 和 write 操作。注意,Java 内存模型只要求上述两个操作必须按顺序执行,而没有保证是连续执行。也就是说,read 与 load 之间、store 与 write 之间是可插入其他指令的。

对于 volatile 型变量的特殊处理

当一个变量定义为 volatile 之后,它将具备两种特性,第一是保证此变量对所有线程的可见性,这里的“可见性”是指当一条线程修改了这个变量的值,新值对于其他线程来说是可以立即知晓的。而普通变量不能做到这一点,普通变量的值在线程间传递均需要通过主内存来完成,例如,线程 A 修改了一个普通变量的值,然后向主内存进行回写,另外一条线程 B 在线程 A 回写完成之后欧再从主内存进行读取操作,新变量的值才会对线程 B 可见。

但是 Java 里的运算并不是原子操作,导致 volatile 变量的运算在并发下一样是不安全的。

由于 volatile 变量只能保证可见性,在不符合一下两条规则的运算场景中,我们仍然要通过加锁(使用 synchronized 或 java.util.concurrent 中的原子类)来保证原子性。

  • 运算结果并不依赖于当前值,或者能够确保只有单一的线程修改变量的值。
  • 变量不需要与其他的状态变量共同参与不变约束。

使用 volatile 变量的第二个语义是禁止指令重排序优化,普通的变量仅仅会保证在该方法的执行过程中所有依赖赋值结果的地方都能获取到正确的结果,而不能保证变量赋值操作的顺序与程序代码的执行顺序一致。因为在一个线程的方法执行过程中无法感知到这点,这也就是 Java 内存模型中描述的所谓的“线程内表现为串行的语义”(WithinThread As-If-Serial Semantics)。

普通的 double check 模式单例类有一定概率会因为指令重排导致空指针异常,可使用 volatile 修饰变量禁止重排。

volatile 屏蔽指令重排序的语义在 JDK 1.5 中才被完全修复,此前的 JDK 中即使将变量声明为 volatile 也仍然不能完全避免重排序所导致的问题(主要是 volatile 变量前后的代码仍然存在指令重排序问题),这点也是在JDK1.5之前的 Java 中无法安全使用DCL(双锁检测)来实现单例类的原因。

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

JDK中定义了一条相对宽松的规定:允许虚拟机将没有被 volatile 修饰的 64 位数据的读写操作划分为两次 32 位 的操作来进行,即允许虚拟机实现选择可以不保证 64 位数据类型的 load、store、read 和 write 这 4 个操作的原子性,这点就是所谓的 long 和 double 的非原子性协定(Nonatomic Treatment of double and long Variables)。

原子性、可见性与有序性

  • 原子性(Atomicity):由 Java 内存模型来直接保证的原子性变量操作包括:read、load、assign、use、store 和 write。
    如果应用场景需要一个更大范围的原子性保证(经常会遇到),Java 内存模型还提供了 lock 和 unlock 操作来满足这种需求,尽管虚拟机未把 lock 和 unlock 操作直接开放给用户使用,但是却提供了更高层次的字节码指令 monitorenter 和 monitorexit 来隐式地使用这两个操作,这两个字节码指令反映到 Java 代码中就是同步块——synchronize 关键字,因此在 synchronize 块之间的操作也具备原子性。

  • 可见性(Visibility):可见性是指当一个线程修改了共享变量的值,其他线程能够立即得知这个修改。
    volatile 保证了多线程操作时变量的可见性。
    除此之外,Java 还有两个关键字能实现可见性,即 synchronize 和 final。

  • 有序性(Ordering):Java 程序中天然的有序性可以总结为一句话:如果在本线程内观察,所有的操作都是有序的,如果在一个线程中观察另一个线程,所有的操作都是无序的。前半句是指“线程内表现为串行的语义”(Within-Thread As-If-Seial Semantics),后半句是指“指令重排”现象和“工作内存与主内存同步延迟”现象。
    Java 语言提供了 volatile 和 synchronize 两个关键字来保证线程之间操作的有序性,volatile 关键字本身就包含了禁止指令重排的语义,而 synchronize 则是由“一个变量在同一个时刻只允许一条线程对其进行 lock 操作”这条规则获得的,这条规则决定了持有同一个锁的两个同步块只能串行进入。

先行发生原则

这个原则非常重要,他是判断数据是否存在竞争、线程是否安全的主要依据。先行发生是 Java 内存模型中定义的两项操作之间的偏序关系,如果说操作 A 先行发生于操作 B,其实就是说在发生操作 B 之前,操作 A 产生的影响能被 B 观察到,“影响”包括修改了内存中共享变量的值,发送了消息,调用了方法等。

Java 线程

线程的实现

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

1 使用内核线程实现
内核线程(Kernel-Level Thread,KLT)就是直接由操作系统内核(Kernel,下称内核)支持的线程,这种线程由内核来完成该线程切换,内核通过操纵调度器(Scheduler)对线程进行调度,并负责将线程的任务映射到各个处理器上。

程序一般不会直接去使用内核线程,而是去使用内核线程的一种高级接口——轻量级进程(Light Weight Process, LWP),轻量级进程就是我们通常意义上所讲的线程,由于每个轻量级进程都由一个内核线程支持,因此只有先支持内核线程,才能有轻量级进程。这种轻量级进程与内核线程之间 1:1 的关系称为一对一的线程模型。

2 使用用户线程实现
从广义上来讲,一个线程只要不是内核线程,就可以认为是用户线程(User Thread,UT),而狭义上的用户线程指的是完全建立在用户空间的线程库上,系统内核不能感知线程存在的实现。用户线程的建立、同步、销毁和调度完全在用户态中完成,不需要内核的帮助。

3 使用用户线程加轻量级进程混合实现
将内核线程与用户线程一起使用的实现方式。
在这种混合实现下,即存在用户线程,也存在轻量级进程。
在这种混合模式中,用户线程与轻量级进程的数量比是不定的,即为 N:M 的关系。

Java 线程调度

线程调度是指系统为线程分配处理器使用权的过程,主要调度方式有两种,分别是协同式线程调度(Cooperative Threads-Scheduling)和抢占式线程调度(Preemptive Threads=Scheduling)。

3.3 状态转换

Java 语言定义了 5 种线程状态,在任意一个时间点,一个线程只能有且只有其中的一种状态,如下:

  • 新建(New):创建后尚未启动的线程出于这种状态;

  • 运行(Runnable):Runnable 包括了操作系统线程状态中的 Running 和 Ready,也就是出于此状态的线程有可能正在执行,也有可能正在等待着 CPU 为它分配执行时间;

  • 无限期等待(Waiting):处于这种状态的线程不会被分配 CPU 执行时间,他们要等待被其它线程显示的唤醒。一下方法会让线程陷入无限期的等待状态:

    • 没有设置 Timeout 参数的 Object.wait() 方法;
    • 没有设置 Timeout 参数的 Thread.join() 方法;
    • LockSupport.park() 方法。
  • 限期等待(Timed Waiting):出于这种状态的线程也不会被分配 CPU 执行时间,不过无须等待被其它线程显示的唤醒,在一定时间之后他们会由系统自动唤醒。以下方法会让线程进入等待限期状态:

    • Thread.sleep()方法;
    • 设置了 Timeout 参数的 Object.wait() 方法;
    • 设置了 Timeout 参数的 Thread.join() 方法;
    • LockSupport.parkNanos() 方法;
    • LockSupport.parkUntil() 方法。
  • 阻塞(Blocked):线程被阻塞了,“阻塞状态”与“等待状态”的区别是:“阻塞状态”在等待着获取到一个排他锁,这个事件将在另外一个线程放弃这个锁的时候发;而“等待状态”则是在等待一段时间,或者唤醒动作的发生。在程序等待进入同步区域的时候,线程进入这种状态。

  • 结束(Terminated):已终止线程的线程状态,线程已经结束执行。

参考文献

[1]深入理解Java虚拟机[M].周志明.机械工业出版社,2013:433.

你可能感兴趣的:(Java 内存模型与线程)