深入理解JVM第十二章笔记

深入理解JVM第十二章笔记

背景

为了充分压榨计算机处理器的性能,多任务处理在现代计算机操作系统中已经是一项必备技能了。

另外由于大部分的计算任务都不可能只靠处理器来单独“计算”完成,处理器需要与内存交互,如读取运算数据,存储运算结果等,这个IO操作是很难消除的。而如今的计算机的存储设备与处理器的运算速度有几个数量级的差距,所以需要在处理器与内存之间加入一层—高速缓存,用来将运算需要使用到的数据复制到缓存中,让运算能快速进行,当运算结束后再从缓存同步回内存之中,这样处理器就可以无需等待缓慢的内存读写了。

高速缓存的引入也带来了一个新的问题:缓存一致性:

多处理器系统,每个处理器都有自己的高速缓存,它们又共享同一个主存:

图1.png

当多个处理器的运算任务都涉及同一块主内存区域时,将可能导致各自的缓存数据不一致

为了解决一致性问题,需要各个处理器访问缓存时都遵循一些协议,在读写时根据协议来进行操作,这类协议有:MSI,MESI等等

所谓的“内存模型”,可以理解为在特定的操作协议下,对特定的内存或高速缓存进行读写访问的过程抽象,不同的物理机器有不一样的内存模型,JVM也有属于自己的内存模型。

Java内存模型

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

主内存与工作内存

JMM的主要目标是定义程序中各个变量的访问规则,即在虚拟机中将变量存储到内存和从内存中取出变量这样的底层细节,此处的变量与Java编程中所说的变量有所区别,它包括:

  • 实例字段

  • 静态字段

  • 构成数组的元素

但不包括局部变量与方法参数,因为它们属于线程私有的。

JMM规定了所有变量都存储在主内存中,每条线程还有自己的工作内存,线程的工作内存中保存了被该线程使用到的变量的主内存副本拷贝,线性对变量的所有操作(读取,赋值等)都必须在工作内存进行,不能直接读写主内存中的变量

线程,主内存,工作内存三者的交互关系:

图2.png

内存间交互操作

对于主内存与工作内存之间具体的交互协议,即一个变量如何从主内存拷贝到工作内存,如何从工作内存同步回主内存之类的实现细节,Java内存模型定义了8种操作来完成主内存与工作内存的读写交互,虚拟机实现保证每一种操作都是原子的,不可再分的

  • Lock锁定 作用于主内存变量,将变量标志为一条线程所独占

  • Unlock解锁 作用于主内存变量,将处于锁定的变量释放出来

  • Read读取 作用于主内存变量,它将一个变量的值从主内存传输到线程的工作内存中

  • Load载入 作用于工作内存变量,它把从主内存读取的变量值放入工作内存的副本中

  • Use使用 作用于工作内存变量,将工作内存变量值传递给执行引擎

  • Assgin赋值 作用于工作内存变量,将执行引擎的值传递给工作内存的变量

  • Store存储 作用于工作内存变量,它把工作内存变量传递到主内存中

  • Write写入 作用于主内存变量,把Store操作从工作内存得到的变量值放入主内存变量中

如果要把一个变量从主内存复制到工作内存,那就要顺序的执行read和load操作

如果要把变量从工作内存同步回主内存,就要顺序执行store和write操作

Java内存模型这2个操作必须顺序执行,但不保证连续执行,即在指令之间可以插入其它指令

但是Java内存模型规定了一些必要的规则

  • 不允许read load    store write单独出现,即不允许一个变量读取到工作内存,但没有变量接收的情况

  • 不允许一个线程丢弃它的assign操作,即变量在工作内存改变必须同步回主内存

  • 不允许一个线程无原因(没有发生assgin赋值操作)把数据从线程的工作内存同步会主内存

  • 一个新的变量只能在主内存中诞生,不允许在工作内存中直接使用未被初始化的变量

  • 一个变量同一时刻只允许一条线程对其进行Lock锁定,但Lock操作可以被同一线程重复执行

  • 如果对一个变量执行Lock锁定,会清空工作内存中该副本的值,即执行引擎使用该值会重新load assgin操作初始化该值

  • 如果一个变量事先没有被Lock锁定,那就不允许进行Unlock操作,也不允许Unlock其它线程锁定的变量

  • 对一个变量执行Unlock操作,必须先把此变量值同步回主内存(store write操作)

对于volatile型变量的特殊规则

当一个变量定义为volatile之后,它将具备两种特性:

  • 保证此变量对所有线程的可见性,这里的“可见性”是指当一条线程修改了这个变量的值,新值对于其他线程来说是可以立即得知的,而普通变量不能做到这一点。普通变量的值在线程间的传递需要通过主存来完成:线程A修改一个普通变量的值,然后向主内存进行回写,另外一条线程B在线程A回写完成之后再从主内存进行读取操作,新变量值才会对线程B可见
    但是volatile 不能保证线程是安全的,因为java里面的运算并非原子操作

  • 禁止指令重排序优化

原子性 可见性 有序性

  • 原子性

大致可以认为基本数据类型的访问读写是具备原子性的

  • 可见性

当一个线程修改了共享变量的值,其他线程能够立即得知这个修改

除了volatile,能够保证可见性的还有:

  • synchronized

  • final

  • 有序性

Java提供了volatile和synchronized保证有序性

先行发生原则

先行发生是指JMM中定义的两项操作直接的偏序关系,如果说操作A先行发生于操作B,其实就是说在发生操作B之前,操作A产生的影响能被操作B观察到,“影响”包括了:

  • 修改了内存中共享变量的值

  • 发送了消息

  • 调用了方法

等等

例子:



i = 1 // 线程A中执行


j = i // 线程B执行

i = 2 // 线程C执行

假设A中操作" i = 1"先行发生于B的操作" j = i",那么可以确定在B的操作执行后,变量j的值一定等于1,得出这个结论的依据:

  • 根据先行发生原则,“ i=1”的结果可以被观察到

  • C还没考虑进来,A操作结束之后没有线程会修改i的值了

现在把C考虑进去:

依旧保持A和B之间的先行发生关系,C出现在A和B之间,但C和B没有先行发生关系,那j的值就会出现不确定的情况,1或2都有可能:因为C对变量i的影响可能会被B观察到,也可能不会

下面是JMM中一些“天然的”先行发生关系:

  • 程序次序规则:

在一个线程内,按照程序代码顺序,书写在前面的操作先行发生于书写在后面的操作。准确地说是控制流顺序而不是程序代码顺序,因为要考虑分支、循环等结构。

  • 管程锁定规则

一个unlock操作先行发生于后面对同一个锁的lock操作。这里必须强调的是同一个锁,这里的“后面”指的是时间上的先后顺序。

  • volatile变量规则

对一个volatile变量的写操作先行发生于后面对这个变量的读操作,这里的“后面”指的是时间上的先后顺序

  • 线程启动规则

Thread对象的start()方法先行发生于此线程的每一个动作。

  • 线程终止规则

线程中的所有操作都先行发生于对此线程的终止检测,我们可以通过Thread.join()方法结束、Thread.isAlive()的返回值等手段检测到线程已经终止执行。

  • 线程中断规则

对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生(即先中断,后发现被中断),可以通过Thread.interrupted()方法检测到是否有中断发生。

  • 对象终结规则

一个对象的初始化完成(构造函数执行结束)先行发生于它的finalize()方法的开始。

  • 传递性

若操作A先行发生于操作B,操作B先行发生于操作C,那么就可以得出操作A先行发生于操作C。

以上规则是Java语言“天然”存在的规则,无需同步手段(例:加synchronized)就能保证先行发生。

例子:



  private int value = 0;


    public int getValue() {
        return value;
    }

    public void setValue(int value) {
        this.value = value;
    }

假设:

有线程A和B,A先调用了setValue(1),然后B调用了getValue()

那B收到的返回值是什么?

依次分析先行发生原则中的各项规则:

  • 两个方法分别由两个线程调用,不在一个线程中,所以程序次序规则在这里不适用

  • 没有同步块,自然不会发生lock和unlock操作,管程锁定规则不适用

  • value变量没有volatile修饰,volatile变量规则不适用

  • 后续的线程启动,中止,中断规则和对象终结规则也和这里无关系

没有一个适用的先行发生原则,传递性也无从谈起,所以这里的操作不是线程安全的

Java与线程

线程的实现

线程的引入可以把一个进程的资源分配和执行调度分开,线程既可共享进程资源(内存地址、文件I/O等),也可独立调度(线程是CPU调度的基本单位)

实现线程有三种方式:

  • 1 使用内核线程实现

内核线程(Kernel-Level Thread,KLT)就是直接由操作系统内核(Kernel,下称内核)支持的线程,这种线图3.png
程由内核来完成线程切换,内核通过操纵调度器(Scheduler)对线程进行调度,并负责将线程的任务映射到各个处理器上。 每个内核线程可以视为内核的一个分身,这样操作系统就有能力同时处理多件事情,支持多线程的内核就叫做多线程内核(MultiThreads Kernel)。

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

由于内核线程的支持,每个轻量级进程都成为一个独立的调度单元,即使有一个轻量级进程在系统调用中阻塞了,也不会影响整个进程继续工作,但是轻量级进程具有它的局限性:首先,由于是基于内核线程实现的,所以各种线程操作,如创建、 析构及同步,都需要进行系统调用。 而系统调用的代价相对较高,需要在用户态(User Mode)和内核态(Kernel Mode)中来回切换。 其次,每个轻量级进程都需要有一个内核线程的支持,因此轻量级进程要消耗一定的内核资源(如内核线程的栈空间),因此一个系统支持轻量级进程的数量是有限的。

  • 2 使用用户线程实现

从广义上来讲,一个线程只要不是内核线程,就可以认为是用户线程(UserThread,UT),因此,从这个定义上来讲,轻量级进程也属于用户线程,但轻量级进程的实现始终是建立在内核之上的,许多操作都要进行系统调用,效率会受到限制。

而狭义上的用户线程指的是完全建立在用户空间的线程库上,系统内核不能感知线程存在的实现。 用户线程的建立、 同步、 销毁和调度完全在用户态中完成,不需要内核的帮助。如果程序实现得当,这种线程不需要切换到内核态,因此操作可以是非常快速且低消耗的,也可以支持规模更大的线程数量,部分高性能数据库中的多线程就是由用户线程实现的。 这种进程与用户线程之间1:N的关系称为一对多的线程模型,如下图所示:

图4.png

使用用户线程的优势在于不需要系统内核支援,劣势也在于没有系统内核的支援,所有的线程操作都需要用户程序自己处理。 线程的创建、 切换和调度都是需要考虑的问题,而且由于操作系统只把处理器资源分配到进程,那诸如“阻塞如何处理”、 “多处理器系统中如何将线程映射到其他处理器上”这类问题解决起来将会异常困难,甚至不可能完成。 因而使用用户线程实现的程序一般都比较复杂,除了以前在不支持多线程的操作系统中(如DOS)的多线程程序与少数有特殊需求的程序外,现在使用用户线程的程序越来越少了,Java、Ruby等语言都曾经使用过用户线程,最终又都放弃使用它。

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

线程除了依赖内核线程实现和完全由用户程序自己实现之外,还有一种将内核线程与用户线程一起使用的实现方式。 在这种混合实现下,既存在用户线程,也存在轻量级进程。 用户线程还是完全建立在用户空间中,因此用户线程的创建、 切换、 析构等操作依然廉价,并且可以支持大规模的用户线程并发。 而操作系统提供支持的轻量级进程则作为用户线程和内核线程之间的桥梁,这样可以使用内核提供的线程调度功能及处理器映射,并且用户线程的系统调用要通过轻量级线程来完成,大大降低了整个进程被完全阻塞的风险。 在这种混合模式中,用户线程与轻量级进程的数量比是不定的,即为N:M的关系,如下图所示,这种

图5.png

参考资料

<<深入理解Java虚拟机>>

发布了229 篇原创文章 · 获赞 62 · 访问量 19万+

猜你喜欢

转载自blog.csdn.net/Coder_py/article/details/104927147