• 【JVM.11】Java内存模型与线程


      鲁迅曾经说过“并发处理的广泛应用是使得Amdahl定律代替摩尔定律成为计算机性能发展源动力的根本原因,也是人类‘压榨‘ 计算机运行能力的最有力武器。”

    一.概述

      多任务处理在现代计算机操作系统中几乎已是一项必备的功能了。在许多情况下让计算机同时去做几件事情,不仅是因为计算机的运算能力强大了,还有一个重要的原因是计算机的运算速度与它的存储和通信子系统的差距太大,大量的时间都花费在磁盘 I/O 、网络通信或者数据库访问上。如果不希望处理器在大部分时间里都是处于等待其他资源的状态,就必须使用一些手段去把处理器的运算能力“压榨”出来。

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

      “高效并发”是本节讲解Java虚拟机的最后一部分,将会向读者介绍虚拟机如何实现多线程、多线程之间由于共享和竞争数据而导致德一系列问题及解决方案。

    二.硬件的效率与一致性

      在正式讲解Java虚拟机并发相关的知识之前,我们先花费一点时间去了解一下物理计算机中的并发问题。

      计算机运行时,都不可能只靠处理器“计算”就能完成,处理器至少要与内存交互,如读取运算数据、存储运算结果等,这个I/O 操作是很难消除的。

      由于计算机的存储设备与处理器的运算速度有几个数量级的差距,所以现代计算机系统都不得不加入一层读写速度尽可能接近处理器运算速度的高速缓存来作为内存与处理器之间的缓冲:将运算需要使用到的数据复制到缓存中,让运算能快速进行,当运算结束后再从缓存同步回内存之中,这样处理器就无须等待缓慢的内存读写了。

      基于高速缓存的存储交互很好地解决了处理器与内存的速度矛盾,但是也为计算机系统带来了更高的复杂度,因为它引入了一个新的问题:缓存一致性(Cache Coherence)。当多个处理器的运算任务都涉及同一主内存区域时,将可能导致各自的缓存数据不一致,如果真的发生这样的情况,那同步到主内存时以谁的缓存数据为准呢?为了解决一致性的问题,需要各个处理器访问缓存是都遵循一些协议,在读写时要根据协议来进行操作,这类协议有MSI、MESI、MOSI、Synapse等。在本节中将会多次提到“内存模型”一词,可以理解为在特定的操作协议下,对特定的内存或高速缓存进行读写访问的过程抽象。

      

      除了增加高速缓存之外,为了使得处理器内部的运算单元能尽量被充分利用,处理器可能会对输入代码进行乱序执行(Out Of Order Execution)优化,处理器会在计算之后将乱序执行的结果重组,保证该结果与顺序执行的结果是一致的,但并不保证程序中各个语句计算的先后顺序与输入代码中的顺序一致,与处理器的乱序执行优化类似,Java虚拟机的即时编译器中也有类似的指令重排序优(Instruction Reorder)化。

    三.Java内存模型

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

    1.  主内存与工作内容

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

      不同的线程之间也无法直接访问对方工作内存中的变量,线程间变量值的传递均需要通过主内存来完成。

      主内存主要对应于Java堆中的对象实例数据部分,而工作内存则对应于虚拟机栈中的部分区域。从更低层次上说,主内存就直接对应于物理硬件的内存,而为了获取更好的运行速度,虚拟机可能会让工作内存优先存储于寄存器和高速缓存中,因为程序运行时主要访问读写的是工作内存。

    2.  内存间交互操作

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

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

      如果要把一个变量从主内存复制到工作内存,那就要顺序执行 read 和 load 操作,如果要把一个变量从工作内存同步到主内存,就要顺序执行 store 和 write 操作。Java内存模型还规定了在执行上述8种基本操作时必须满足如下规则:

    • 不允许 read 和 load、store 和 write 操作之一单独出现,即不允许一个变量从主内存读取了但工作内存不接受,或者从工作内存发起回写了但主内存不接受的情况出现。
    • 不允许一个线程丢弃它的最近的 assign 操作,即变量在工作内存中改变了之后必须把该变化同步回主内存。
    • 不允许一个线程无原因地把数据从线程的工作内存同步回主内存中。
    • 一个新的变量只能在主内存中“诞生”,不允许在工作内存中直接使用一个未被初始化(load 或 assign)的变量,换句话说,就是对一个变量实施use、store 操作之前,必须先执行过了 assign 和 load 操作。
    • 一个变量在同一时刻只允许一天线程对其进行lock 操作,但 lock 操作可以被同一天线程重复执行多次,多次执行lock 后,只有执行相同次数的 unlock 操作,变量才会被解锁。
    • 如果对一个变量执行lock 操作,那将会清空工作内存中此变量的值,在执行引擎使用这个变量前,需要重新执行 load 或 assign 操作初始化变量的值。
    • 如果一个变量实现没有被 lock 操作锁定,那就不允许对它执行unlock操作,也不允许去 unlock 一个被其他线程锁定住的变量。
    • 对一个变量执行 unlock 操作之前,必须先把此变量同步回主内存中(执行 store、write操作)

    3.  对于volatile型变量的特殊规则

      关键字 volatile 可以说是Java虚拟机提供的最轻量级的同步机制,但是他并不容易完全被正确、完整地理解,以至于许多程序员都习惯不去使用它,遇到需要处理多线程数据竞争问题的时候一律使用 synchronized 来进行同步。

      在本节我们将花费一些时间去弄清楚 volatile 的语义到底是什么。

      关于 volatile 的详细可以看我的另一篇文章  volatile关键字解析 ,这个还是比较重要。

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

      。。。(忽略)

    5.  原子性、可见性与有序性

      介绍完Java 内存模型的相关操作和规则,我们再整体回顾一下这个模型的特征。主要的3个特征:

      原子性(Atomicity):由Java内存模型来直接保证的原子性变量操作包括 read、load、assign、use、store和 write,我们大致可以认为基本数据类型的访问读写时具备原子性的。如果应用场景需要更大范围的原子性保证(经常会遇到),Java内存模型还提供了 lock 和 unlock 操作来满足这种需求,尽管虚拟机未把 lock 和 unlock 操作直接开放给用户使用,但是却提供了更高层次的字节码指令 monitorenter 和 monitorexit 来隐式地使用这两个操作,这两个字节码反应到Java代码中就是同步块-----synchronized 关键字。

      可见性(Visibility):可见性是指当一个线程修改了共享变量的值,其他线程能够立即得知这个修改。上文讲解的 volatile 变量就详细讨论过这一点。Java内存模型是通过再变量修改后将新值同步回主内存,在变量读取前从主内存刷新变量值这种依赖主内存作为传递媒介的方式来实现可见性的,无论是普通变量还是volatile 变量都是如此,只是说 volatile 保证了多线程操作时变量的可见性,普通变量不能保证这一点。除了 volatile 之外,Java 还有两个关键字可以实现可见性,及 synchronizedfinal。同步块的可见性是由“对一个变量执行 unlock 操作之前,必须先把此变量同步回主内存中(执行 store、write操作)

      有序性(Ordering):Java语言提供了 volatilesynchronized 两个关键字来保证线程之间操作的有序性, volatile 关键字本身就包含了禁止指定重排序的语义,而 synchronized 则是由“一个变量在同一时刻只允许一天线程对其进行lock操作”这条规则获得的,这条规则决定了持有同一个锁的两个同步块只能串行地进入。

    6.  先行发生原则

      如果Java 内存模型中所有的有序性都仅仅靠 volatile 和 synchronized 来完成,那么有一些操作将会变得很繁琐,但是我们编写Java 代码是没有这种感觉,这是因为Java 语言中有一个“先行发生”(happen-before) 的原则。这个原则非常重要,它是判断数据是否存在竞争、线程是否安全的主要依据,依靠这个原则,我们可以通过几条规则一揽子地解决并发环境下两个操作之间是否存在冲突的所有问题。

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

      下面是Java 内存模型下一些“默认的”先行发生关系。

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

    四.Java与线程

    1.  线程的实现

      我们知道,线程是比进程更轻量级的调度执行单位,线程的引入,可以把一个进程的资源分配和执行调度分开,各个线程既可以共享进程资源,又可以独立调度。

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

      (1).使用内核线程实现

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

      程序一般不会直接去使用内核线程,而是去使用内核线程的一种高级接口----轻量级进程(Light Weight Process,LWP),轻量级进程就是我们通常意义上讲的线程。如图:

      

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

       (2).使用用户线程实现

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

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

      

      使用用户线程的优势在于不需要系统内核支援,劣势也在于没有系统内核的支援,所有的线程操作都需要用户程序自己处理,诸如“阻塞如何处理”、“多处理器系统中如何将线程映射到其他处理器上”这类问题解决起来将会异常困难,甚至不可能完成。

      现在使用用户线程的程序越来越少了,Java、Ruby等语言都曾经使用过用户线程,最终都放弃使用它。

      (3).使用用户线程加轻量级进程混合实现

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

      

      (4).Java 线程的实现

      在目前的JDK 版本中,操作系统支持怎样的线程模型,在很大程度上决定了 Java虚拟机的线程是怎样映射的。

      对于 Sun JDK 来说,他的 Windows 版本与 Linux 版本都是使用一对一的线程模型实现的,一个Java线程就映射到一条轻量级进程之中。

    2.  Java 线程调度

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

      协同式线程调度:线程的执行时间由线程本身来控制,线程把自己的工作执行完成后,主动通知系统切换到另外一个线程。协同式多线程实现简单,而且不会有线程同步问题,但是 线程执行时间不可控制,如果线程出错,很可能程序会一致阻塞在那里。

      抢占式线程调度:每个线程将由系统来分配执行时间,线程切换不由线程本身来决定。这种方式下,线程的执行时间是系统可控的,也不会有一个线程导致整个进程阻塞的问题,Java就是采用抢占式调度。

      虽然Java线程调度室系统自动完成,但是我们可以“建议”系统给某些线程多分配一点执行时间,另外的一些线程则可以少分配一点,可以通过设置线程优先级来完成。Java 语言一共设置了10 个级别的线程优先级,在两个线程同时处于 Ready 状态时,优先级越高的线程越容易被系统选择执行。

      

    3.  状态转换

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

    • 新建(New):创建后尚未启动的线程处于这种状态。
    • 运行(Runable):Runable 包括了操作系统线程状态中的 Running 和 Ready也及时处于此状态的线程有可能正在执行,也有可能在等待着CPU为它分配执行时间。
    • 无限期等待(Waiting):处于这种状态的线程不会被分配CPU 执行时间,它们要等待其他线程显式唤醒。以下方法会让线程陷入无限期等待状态:
      •   没有设置 Timeout 参数的 Object.wait() 方法。
      •   没有设置 Timeout 参数的 Thread.join() 方法。
      •   LockSupport.park() 方法。
    • 限期等待(Timed Waiting):处于这种状态的线程不会被分配CPU 执行时间,不过无需等待其他线程显式唤醒,在一定时间之后它们会由系统自动唤醒。以下方法会让线程陷入限期等待状态:
      •   设置了 Timeout 参数的 Object.wait() 方法。
      •   设置了 Timeout 参数的 Thread.join() 方法。
      •   Thread.sleep() 方法。
      •   LockSupport.parkNanos() 方法。
      •   LockSupport.parkUntil() 方法
    • 阻塞(Blocked):线程被阻塞了,“阻塞状态”与“等待状态”的区别是:“阻塞状态”在等待着获取到一个排他锁,这个事件将在另外一个线程放弃这个锁的时候发生;而“等待状态”则是在等待一段时间,或者唤醒动作的发生。在程序进入同步区域的时候,线程将进入这种状态。
    • 结束(Terminated):已终止线程的线程状态,线程已经结束执行。

      

  • 相关阅读:
    JDBC的一些代码
    mysql
    【转载】如何简单地理解Python中的if __name__ == '__main__'
    【转载】用Scikit-Learn构建K-近邻算法,分类MNIST数据集
    数据科学入门---可视化数据
    Sum It Up
    Blue Jeans
    Zball in Tina Town
    Island Transport
    CD
  • 原文地址:https://www.cnblogs.com/zhaww/p/9199415.html
Copyright © 2020-2023  润新知