java内存模型和线程

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

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

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

1 Java内存模型

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

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

1.1 主内存和工做内存

java规定全部的变量都存储在主内存。每条线程有本身的工做内存面试

线程的工做内存中的变量是主内存中该变量的副本,线程对变量的全部操做都必须在工做内存中进行,而不能直接读写主内存中的变量。不一样线程间也没法直接访问对方工做内存中的变量,线程间变量值的传递须要经过主内存来完成。编程

1.2 内存之间的交互

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

  • 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这种语言,从出生到如今几十年了,基本上每次软件技术的革命都没有落下,每次都以为要死的时候,突然间柳暗花明,枯木逢春。咳咳,扯远了。

相关文章
相关标签/搜索