java内存模型和线程

并发不一定依赖多线程,但是在java里面谈论并发,大多与线程脱不开关系。

线程是大多是面试都会问到的问题。我们都知道,线程是比进程更轻量级的调度单位,线程之间可以共享内存。之前面试的时候,也是这样回答,迷迷糊糊,没有一个清晰的概念。

大学的学习的时候,写C和C++,自己都没有用过多线程,看过一个Windows编程的书,里面讲多线程的时候,一大堆大写的字母,看着一点都不爽,也是惭愧。后来的实习,写unity,unity的C#使用的是协程。只有在做了java后端之后,才知道线程到底是怎么用的。了解了java内存模型之后,仔细看了一些资料,对java线程有了更深入的认识,整理写成这篇文章,用来以后参考。

1 Java内存模型

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

java内存模型的主要目标是定义程序中各个变量的访问规则,即在虚拟机中将变量存储到内存和从内存中取出变量的底层细节。(这里所说的变量包括了实例字段、静态字段和数组等,但不包括局部变量与方法参数,因为这些是线程私有的,不被共享。)

1.1 主内存和工作内存

java规定所有的变量都存储在主内存。每条线程有自己的工作内存

线程的工作内存中的变量是主内存中该变量的副本,线程对变量的所有操作都必须在工作内存中进行,而不能直接读写主内存中的变量。不同线程间也无法直接访问对方工作内存中的变量,线程间变量值的传递需要通过主内存来完成。

1.2 内存之间的交互

关于主内存和工作内存之间的具体交互协议,java内存模型定义了8中操作来完成,虚拟机实现的时候必须保证每个操作都是原子的,不可分割的(对于long和double有例外)

  • lock锁定:作用于主内存变量,代表一个变量是一条线程独占。
  • unlock解锁:作用于主内存变量,把锁定的变量解锁。
  • read读取:作用于主内存变量,把变量值从主内存传到线程的工作内存中,供load使用。
  • load载入:作用工作内存变量,把上一个read到的值放入到工作内存中的变量中。
  • use使用:作用于工作内存变量,把工作内存中的一个变量的值传递给执行引擎。
  • assign:作用于工作内存变量,把执行引擎执行过的值赋给工作内存中的变量。
  • store存储:作用于工作内存变量,把工作内存中的变量值传给主内存,供write使用。

这些操作要满足一定的规则。

1.3 volatile

volatile可以说是java的最轻量级的同步机制。

当一个变量被定义为volatile之后,他就具备两种特性:

  • 保证此变量对所有线程都是可见的

    这里的可见性是指当一个线程修改了某变量的值,新值对于其他线程来讲是立即得知的。而普通变量做不到,因为普通变量需要传递到主内存中才可以做到这点。

  • 禁止指令重排

    对于普通变量来说,仅仅会保证在该方法的执行过程中所有依赖赋值结果的地方都能获取到正确的结果,而不能保证变量赋值操作的顺序与程序代码中的执性顺序一致。

    若用volatile修饰变量,在编译时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。

volatile对于单个的共享变量的读/写具有原子性,但是像num++这种复合操作,volatile无法保证其原子性。

1.4 long和double

long和double是一个64位的数据类型。

虚拟机允许将没有被volatile修饰的64位变量的读写操作分为两次32位的操作来进行。因此当多个线程操作一个没有声明为volatile的long或者double变量,可能出现操作半个变量的情况。

但是这种情况是罕见的,一般商用的虚拟机都是讲long和double的读写当成原子操作进行的,所以在写代码时不需要将long和double专门声明为volatile。

1.5 原子性、可见性和有序性

java的内存模型是围绕着在并发过程中如何处理原子性、可见性和有序性。

原子性

基本数据类型的访问读写是剧本原子性的。

如果需要一个更大范围的原子性保证,java提供了lock和unlock操作,对应于写代码时就是synchronized关键字,因此在synchronized块之间的操作也是具备原子性的。

可见性

可见性是指当一个线程修改到了一个共享变量的值,其他的线程能够立即得知这个修改。共享变量的读写都是通过主内存作为媒介来处理可见性的。

volatile的特殊规则保证了新值可以立即同步到主内存,每次使用前立即从主内存刷新。

synchronized同步块的可见性是由”对于一个变量unlock操作之前,必须先把此变量同步回内存中“来实现的。

final的可见性是指被final修饰的字段在构造器中一旦初始化完成,并且构造器没有把this的引用传递出去,那么在其他线程中就能看见final字段的值。

有序性

如果在本线程内观察,所有的操作都是有序的;如果在一个线程内观察另一个线程,所有的操作都是无序的。
volatile关键字本身就包含了禁止指令重排的语义,而synchronized则是由“一个变量在同一时刻只允许一条线程对其进行lock操作”这条规则来实现有序性的。

1.6 先行发生原则

如果java内存模型中的所有有序性都是靠着volatile和synchronized来完成,那有些操作将会变得很繁琐,但是我们在写java并发代码的时候没有感受到这一点,都是因为java有一个“先行发生”原则。

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

  • 程序次序规则

    在一个线程内,按程序代码控制流顺序执行。

  • 管程锁定规则

    unlock发生在后面时间同一个锁的lock操作。

  • volatile变量规则

    volatile变量的写操作发生在后面时间的读操作。

  • 线程启动规则
  • 线程终止规则
  • 线程中断规则
  • 对象终结规则

    一个对象的初始化完成在finalize方法之前。

  • 传递性

    如果A先行发生B,B先行发生C,那么A先行发生C。

由于指令重排的原因,所以一个操作的时间上的先发生,不代表这个操作就是先行发生;同样一个操作的先行发生,也不代表这个操作必定在时间上先发生。

2 Java线程

2.1 线程的实现

主流的操作系统都提供了线程的实现,java则是在不同的硬件和操作系统的平台下,对线程的操作提供了统一的处理,一个Thread类的实例就代表了一个线程。Thread类的关键方法都是native的,所以java的线程实现也都是依赖于平台相关的技术手段来实现的。

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

2.1.1 使用内核线程实现

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

程序一般不会直接去调用内核线程,而是使用内核线程的一个高级接口——轻量级进程(Light Weigh Process),LWP就是我们通常意义上所说的线程。

由于每个轻量级进程都由一个内核线程支持,这种轻量级进程与内核线程之间1:1的关系成为一对一线程模型。

局限性

虽然由于内核线程的支持,每个轻量级进程都成为了一个独立的调度单元,即使有一个阻塞,也不影响整个进程的工作,但是还是有一定的局限性:

  • 系统调用代价较高

    由于基于内核线程实现,所以各种线程的操作都要进行系统调用。而系统调用的代价比较高,需要在用户态和内核态来回切换。

  • 系统支持数量有限

    每个轻量级进程都需要一个内核线程支持,需要消耗一定的内核资源,所以支持的线程数量是有限的。

2.1.2 使用用户线程实现

指的是完全建立在用户空间的线程库上,系统内核不能感知线程存在的实现。用户线程的建立、同布、销毁和调度完全在用户态中完成,不需要内核帮助。

如果程序实现得当,则这些线程都不需要切换到内核态,操作非常快速消耗低,可以支持大规模线程数量。这种进程和用户线程之间1:N的关系成为一对多线程模型。

局限性

不需要系统内核的,既是优势也是劣势。由于没有系统内核支援,所有的操作都需要程序去处理,由于操作系统只是把处理器资源分给进程,那“阻塞如何处理”、“多处理器系统如何将线程映射到其他处理器上”这类问题的解决十分困难,所以现在使用用户线程的越来越少了。

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

在这种混合模式下,既存在用户线程,也存在轻量级进程。

用户线程还是完全建立在用户空间中,因此用户线程的创建、切换、析构等操作依然廉价,而且支持大规模用户线程并发、而操作系统提供支持的轻量级进程则作为用户线程和内核线程之间的桥梁,这样可以使用内核提供的线程调度和处理器映射,并且用户线程的系统调用要通过轻量级进程来完成,大大降低了整个进程被完全阻塞的风险。

在这种模式下,用户线程和轻量级进程数量比不固定N:M,这种模式就是多对多线程模型。

2.1.4 java线程的实现

目前的jdk版本中,操作系统支持怎样的线程模型,很大程度上就决定了jvm的线程是怎么映射的,这点在不同的平台没办法打成一致。线程模型只对线程的并发规模和操作成本产生影响,对编码和运行都没什么差异。

windows和linux都是一对一的线程模型。

2.2 线程调度

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

2.2.1 协同式线程调度

线程的执性时间由线程本身来控制,线程把自己的工作执性完了之后,要主动通知系统切换到另外一个线程上。Lua的协程就是这样。

好处

协同式多线程最大的好处就是实现简单。

由于线程要把自己的事情干完之后才进行线程切换,切换操作对线程是克制的,所以没有什么线程同步的问题。

坏处

坏处也很明显,线程执行时间不可控。甚至如果一个线程写的问题,一直不告诉系统切换,那程序就会一直阻塞。

2.2.2 抢占式线程调度

每个线程由系统分配执行时间,线程的切换不是又线程本身来决定。

使用yield方法是可以让出执行时间,但是要获取执行时间,线程本身是没有什么办法的。

在这种调度模式下,线程的执行时间是系统可控的,也就不会出现一个线程导致整个进程阻塞。

2.2.3 java线程调度

java使用的是抢占式线程调度。

虽然java的线程调度是系统来控制的,但是可以通过设置线程优先级的方式,让某些线程多分配一些时间,某些线程少分配一些时间。

不过线程优先级还是不太靠谱,原因就是java的线程是通过映射到系统的原生线程来实现的,所以线程的调度还是取决于操作系统,操作系统的线程优先级不一定和java的线程优先级一一对应。而且优先级还可能被系统自行改变。所以我们不能在程序中通过优先级来准确的判断先执行哪一个线程。

2.3 线程的状态转换

看到网上有好多种说法,不过大致也都是说5种状态:新建(new)、可运行(runnable)、运行(running)、阻塞(blocked)和死亡(dead)。

而深入理解jvm虚拟机中说java定义了5种线程状态,在任一时间点,一个线程只能有其中的一种状态:

  • 新建new
  • 运行runnable

    包括了操作系统线程状态的running和ready,也就是说处于此状态的线程可能正在执行,也可能正在等待cpu给分配执行时间。

  • 无限期等待waiting

    处于这种状态的线程不会被cpu分配执行时间,需要被其他线程显示唤醒,能够导致线程陷入无限期等待的方法有:

    • 没有设置timeout参数的wait方法。
    • 没有设置timeout参数的join方法。
    • LockSupport.park方法。
  • 限期等待timed waiting

    处于这种状态的线程也不会被cpu分配执行时间,不过不需要被其他线程显示唤醒,是经过一段时间之后,被操作系统自动唤醒。能够导致线程陷入限期等待的方法有:

    • sleep方法。
    • 设置timeout参数的wait方法。
    • 设置参数的join方法。
    • LockSupport.parkNanos方法。
    • LockSupport.parkUntil方法。
  • 阻塞blocked

    线程被阻塞了。在线程等待进入同步区域的时候是这个状态。

    阻塞和等待的区别是:阻塞是排队等待获取一个排他锁,而等待是指等一段时间或者一个唤醒动作。

  • 结束terminated

    已经终止的线程。

3 写在最后

并发处理的广泛应用是使得Amdahl定律代替摩尔定律成为计算机性能发展源动力的根本原因,也是人类压榨计算机运算能力的最有力武器。有些问题使用越多的资源就能越快地解决——越多的工人参与收割庄稼,那么就能越快地完成收获。但是另一些任务根本就是串行化的——增加更多的工人根本不可能提高收割速度。

我们使用线程的重要原因之一是为了支配多处理器的能力,我们必须保证问题被恰当地进行了并行化的分解,并且我们的程序有效地使用了这种并行的潜能。有时候良好的设计原则不得不向现实做出一些让步,我们必须让计算机正确无误的运行,首先保证并发的正确性,才能够在此基础上谈高效,所以线程的安全问题是一个很值得考虑的问题。

虽然一直说java不好,但是java带给我的影响确实最大的,从java这个平台里学到了很多有用的东西。现在golang,nodejs,python等语言,每个都是在一方面能秒java,可是java生态和java对软件行业的影响,是无法被超越的,java这种语言,从出生到现在几十年了,基本上每次软件技术的革命都没有落下,每次都觉得要死的时候,忽然间柳暗花明,枯木逢春。咳咳,扯远了。

    原文作者:java内存模型
    原文地址: https://segmentfault.com/a/1190000012267970
    本文转自网络文章,转载此文章仅为分享知识,如有侵权,请联系博主进行删除。
点赞