Java内存模型与线程(第十二章)

139 阅读14分钟

Java内存模型

主内存与工作内存

主内存 Java内存模型规定了所有变量都存储在主内存(Main Memory)中(此处的主内存与介绍物理硬件的主内存名字一样,两者可以互相类比,但此处仅是虚拟机内存的一部分)。

工作内存 每条线程都有自己的工作内存(Working Memory,又称本地内存,可与前面介绍的处理器高速缓存类比),线程的工作内存中保存了该线程使用到的变量的主内存中的共享变量的副本拷贝。工作内存是 JMM 的一个抽象概念,并不真实存在。它涵盖了缓存,写缓冲区,寄存器以及其他的硬件和编译器优化。

img

内存间交互操作

关于主内存与工作内存之间的具体的交互协议,及一个变量如何从主内存拷贝到工作内存、如何从工作内存同步回主内存这一类的实现袭击,Java内存模型中定义了以下8种操作来完成。Java虚拟机实现时必须保证下面提及的每一种操作都是原子的、不可再分的。

  • lock(锁定):作用于主内存的变量,它把一个标量标识为一条线程独占的状态。

  • unlock(解锁):作用于主内存的变量,它把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定。

  • read(读取):作用于主内存的变量,它把一个变量的值从主内存传输到线程的工作内存中,以便随后到load动作使用。

  • load(载入):作用于工作内存的变量,它把read操作从主内存中得到的变量值放入工作内存的变量副本中。

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

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

  • store(存储):作用于工作内存的变量,它把工作内存中一个变量的值传递到主内存中,以便随后的write操作使用。

  • write(写入):作用于主内存的变量,它把store操作从工作内存中得到的变量的值放入主内存的变量中。

    img

对于volatile型变量的特殊规则

img

可见性: 当一条线程修改了一个变量的值,新值对于其他线程来说是可以立即得知的。

  1. 操作use之前必须先执行read和load操作。
  2. 操作assign之后必须执行store和write操作。

由特性性保证了read、load和use的操作连续性,assign、store和write的操作连续性,从而达到工作内存读取前必须刷新主存最新值;工作内存写入后必须同步到主存中。读取的连续性和写入的连续性,看上去像线程直接操作了主存

有序性:禁止指令重排

指令重排.png

(1) 指令重排:为了提高性能,编译器和和处理器通常会对指令进行指令重排序。图中的三个重排位置可以调换的,根据系统优化需要进行重排。遵循的原则是单线程重排后的执行结果要与顺序执行结果相同

(2) 内存屏障指令:volatile在指令之间插入内存屏障,保证按照特定顺序执行和某些变量的可见性。volatile就是通过内存屏障通知cpu和编译器不做指令重排优化来维持有序性。

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

Java内存模型要求lock、unlock、read、load、assign、use、store、write这八种操作都具有原子性,但是对于64位的数据类型,在模型中特别定义了一条宽松的规定:允许虚拟机将没有被volatile修饰的64位数据的读写操作划分为两次32位的操作来进行,即允许虚拟机实现自行选择是否要保证64位数据类型的load、store、read和write这四个操作的原子性,这就是所谓的“long和double的非原子性协定”。

原子性、可见性与有序性

  • 原子性:由Java内存模型来直接保证的原子性变量操作包括read、load、assign、use、store和write这六个,大致可以认为,基本数据类型的访问、读写都具备原子性(例外就是long和double的非原子性协定)
  • 可见性:可见性就是指当一个线程修改了共享变量的值时,其他线程能够立即得知这个修改。普通变量于volatile变量的区别时,volatile的特殊规则保证了新值能立即同步到主内存,以及每次使用前立即从主内存刷新。因为我们可以说volatile保证了多线程操作时变量的可见性。除了volatile之外,synchronized和final也能保证可见性。同步快的可见性是由“对一个变量执行unlock操作之前,必须先把此变量同步回主内存中(执行store、write操作)”这条规则获得的。而final关键字的可见性是指:被final修饰的字段在构造器中一旦被初始化完成,而且构造器没有把this的引用传递出去(this引用逃逸可能造成某个线程通过这个引用访问到了“初始化一半”的对象),那么其他线程中就能看见final字段的值。
  • 有序性:如果在本线程内观察,所有的操作都是有序的;如果在一个线程中观察另外一个线程,所有的操作都是无序的。前半句是指“线程内似表现为串行的语义”,后半句是指“指令重排序”现象和“工作内存与主内存同步延迟”现象。Java语言提供了volatile和synchronized两个关键字来保证线程之间的有序性,volatile关键字本身就包含了禁止指令重排序的语义,而synchronized则是由“一个变量同一时刻只允许一条线程对其进行lock操作”这条规则获得的,这个规则决定了持有同一个锁的两个同步块只能串行地进入。

先行发生原则

先行发生时Java内存模型中定义的两项操作之间的偏序关系,比如说操作A先行发生于操作B,其实就是说在发生B之前,操作A产生的影响能被操作B观察到,“影响”包括修改了内存中共享变量的值、发送了消息、调用了方法等。

下面说Java内存模型下一些“天然的”先行发生关系,这些先行发生关系无需任何同步器协助就已经存在,可以在编码中直接使用。

  • 程序次序规则:在一个线程内,按照控制流顺序,书写在前面的操作先行发生于书写在后面的操作。注意,这里说的是控制流顺序而不是程序代码顺序,因为要考虑分支、循环等结构。
  • 管程锁定规则:一个unlock操作先行发生于后面对同一个锁的lock操作。这里必须强调的是“同一个锁”,而“后面”是指时间上的先后。
  • volatile变量规则:对一个volatile变量的鞋操作先行发生于后面对这个变量的读操作,这里的“后面”同样是指时间上的先后。
  • 线程启动规则:Thread对象的start()方法先行发生于此线程的每一个动作。
  • 线程终止规则:线程中的所有操作都先行发生于对此线程的终止检测,我们可以通过Thread::join()方法是否结束、Thread::isAlive()的返回值等手段检测线程是否终止执行。
  • 线程终端规则:对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生,可以通过Thread::interrupted()方法检测到是否有中断发生。
  • 对象终结规则:一个对象的初始化完成(构造函数执行结束)先行发生于它的finalize()方法的开始。
  • 传递性:如果操作A先行发生于操作B,操作B先行发生于操作C,那就可以得出操作A先行发生于操作C的结论。

时间先后顺序于先行发生原则之间基本没有因果关系,所有我们衡量并发安全问题的时候不要受时间顺序的干扰,一切必须以先行发生原则为准。

Java与线程

线程的实现

实现线程主要有三种方式:使用内核线程实现(1:1实现),使用用户线程实现(1:N实现),使用用户线程加轻量级进程混合实现(N:M实现)

内核线程实现

使用内核线程实现的方式也称为1:1实现。内核线程就是直接由操作系统内核支持的线程,这种线程由内核来完成线程切花,内核通过操作调度器对线程进行调度,并负责将线程的任务映射到各个处理器上。

程序一般不会直接使用内核线程,而是使用内核线程的一种高级接口--轻量级进程,轻量级进程就是我们通常意义上所讲的线程,由于每个轻量级进程都有一个内核线程支持,因此只有先支持内核线程,才能有轻量级进程。轻量级进程也具有它的局限性:首先,由于上机遇内核线程实现的,所以各种线程操作,如创建、同步,都需要进行系统切换。而系统调用的代价相对较高,需要在用户态和内核态中来回切换。其次,每个轻量级进程都需要有一个内核线程的支持,因此轻量级进程要消耗一定的内核资源,因此一个系统支持轻量级进程的数量上有限的。

用户线程的实现

使用用户线程实现的方式被称为1:N实现。用户线程指的是完全建立在用户空间的线程库上,系统内核不能感知用户线程的存在及如何实现的。用户线程的建立、同步、销毁和调度完全在用户态中完成,不需要内核的帮助。用户线程的优势在于不需要系统内核支援,劣势也在于没有哦系统内核的支援,所有的线程操作都需要由用户程序自己去处理。诸如“阻塞如何处理”“多处理器系统中如何既那个线程映射到其他处理器上”这类问题解决起来将会异常困难,且实现起来较为复杂。因此,除了由明确的需求外,一般的应用程序不倾向使用用户线程。

混合实现

混合实现是将内核线程与用户线程一起使用的实现方式,被称为N:M实现。用户线程用于支持大规模的用户线程并发,而操作系统支持的轻量级进程则作为用户线程与内核线程之间的桥梁,这样可以使用内核线程提供的线程调度功能及处理器映射,并且用户线程的的系统调用要通过轻量级进程来完成,这大大降低了整个进程被完全阻塞的风险。

Java线程的实现

以Hospot为例,它的每一个Java线程都是直接映射到一个操作系统原生线程来实现的,而且中间没有额外的间接结构,所以Hotspot自己是不会去干涉线程调度的,全权交给底下的操作系统去处理,所以合适冻结或唤醒线程、该给线程分配多少处理器执行时间、该把线程安排给哪个处理器核心去执行等,都是由操作系统完成的,也都是操作系统全权决定的。

Java线程调度

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

协同式调度:线程的执行时间由线程本身来控制,线程把自己的工作执行完了之后,要主动通知系统切换到另外一个线程上去。协同式多线程的最大好处是实现简单,切换操作对线程自己是可知的,所以一般没有什么线程同步的问题。它的坏处也很明显:线程执行时间不可控,如果代码编写有问题,可能导致线程一直阻塞。

抢占式调度:每个线程由系统来分配执行时间,线程的切换不由线程本身来决定。譬如在Java中,由Thread::yeild()方法可以主动让出执行时间,但是不能主动获取执行时间。在这中实现线程调度的方式下,线程的执行时间是系统可控的,也不会有一个线程导致整个进程甚至整个系统阻塞。Java使用的线程调度方式就是抢占式调度,虽说Java线程调度是系统自动完成的,但是我们仍然可以“建议”操作系统给某些线程多分配一点执行时间,另外一些线程分配少一点,不过,线程的优先级并不是一项稳定的调节手段,因为虚拟机的Java线程最终是被映射到系统的原生线程上来实现的,所以线程调度最终还是操作系统说了算

状态转换

Java语言定义了6中线程状态,在任意一个时间点内中,一个线程只能有且只有其中的一种状态,并且通过特定的方法在不同状态之间转换。这6种状态分别是:

  • 新建:创建后尚未启动的线程处于这种状态。

  • 运行:包括操作系统线程状态的Running和Ready,也就是处于此状态的线程有可能正字啊执行,也有可能正在等待着操作系统为它分配执行时间。

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

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

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

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

    img

Java与协程

内核线程的局限

协程的复苏

Java的解决方案

\