Java内存模型与线程

Java内存模型与线程

并发处理的广泛应用是使得Amdahl定律代替摩尔定律成为计算机性能发展源动力的根本原因,也是人类"压榨"计算机运算能力的最有力武器.

  • Amdahl定律通过系统中并行化和串行化的比重来描述多处理器系统能获得的运算加速能力,摩尔定律则用于描述处理器晶体管数量与运行效率之间的发展关系.

每秒事务处理数TPS是衡量一个服务性能好坏的重要指标之一,它代表着一秒内服务端平均能响应的请求总数,而TPS值与程序的并发能力又有非常密切的关系.

物理机的并发问题

基于高速缓存的存储交互很好地解决了处理器与内存的速度矛盾,但是它也引入一个新的问题:缓存一致性,在多处理器系统中,每个处理器都有自己的高速缓存,而它们又共享同一主内存,当多个处理器的运算任务都涉及同一块主内存区域时,将可能导致各自的数据不一致.

为了使得处理器内部的运算单元能尽量被充分利用,处理器可能会对输入代码进行乱序执行优化,类似的,Java虚拟机的即时编译器中也有类似的指令重排序优化.

Java内存模型

Java内存模型的主要目标是定义程序中各个变量的访问规则,此处的变量与Java编程中的所说的变量有所区别,它包括了实例字段,静态字段和构成数组对象的元素.

Java内存模型规定了所有的变量都存储在主内存(可以类比物理内存,但仅仅是虚拟机内存的一部分)中,每条线程还有自己的工作内存(可以类比处理器高速缓存),线程的工作内存中保存了被该线程使用到的变量的主内存副本拷贝,线程对变量的所有操作都必须在工作内存中进行,而不能直接读写主内存中的变量.不同的线程之间也无法直接访问对方工作内存中的变量,线程间变量值的传递均需要通过主内存来完成.(volatile变量依然有工作内存的拷贝,但是由于它的顺序性规定,所以看起来如同直接在主内存中读写访问一般.)

Java内存模型中定义了以下8种操作来完成,虚拟机实现时必须保证下面提及的每一种操作都是原子的,不可再分的.

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

执行上述8种基本操作时必须满足如下规则:

  1. 不允许read和load,store和write操作之一单独出现.
  2. 不允许一个线程丢弃它的最近的assign操作.
  3. 不允许一个线程无原因(没有进行任何assign操作)地把数据从线程的工作内存同步回主内存中.
  4. 对一个变量实施use,store操作之前,必须先执行过了assign和load操作.
  5. 一个变量在同一时刻只允许一条线程对其进行lock操作,但lock操作可以被同一条线程重复执行多次,多次执行lock后,只有执行相同次数的unlock操作,变量才会被解锁.
  6. 如果对一个变量执行lock操作,那将会清空工作内存中此变量的值,在执行引擎使用这个变量前,需要重写执行load或assign操作初始化变量的值.
  7. 如果一个变量事先没有被lock操作锁定,那就不允许对它执行unlock操作,也不允许去unlock一个被其他线程锁定住的变量.
  8. 对一个变量执行lock操作之前,必须先把此变量同步回主内存中(执行store,write操作).

与以上规则等价的先行发生原则,用于确定一个访问在并发环境下是否安全.

Java内存模型对volatile专门定义了一些特殊的访问规则.当一个变量定义为volatile之后,它将具备两种特性,第一是保证此变量对所有线程的可见性,第二个语义是禁止指令重排序优化.

常见误解:volatile变量对所有线程是立即可见的,对volatile变量所有的写操作都能立刻反应到其他线程之中,所以基于volatile变量的运算在并发下是安全的.

volatile变量在各个线程的工作内存中不存在一致性问题(每次使用之前都要先刷新),但是Java里面的运算并非原子操作,导致volatile变量的运算在并发下一样是不安全的.

由于volatile变量只能保证可见性,在不符合以下两条规则的运算场景中,我们仍然要通过加锁(使用synchronized或java.util.concurrent中的原子类)来保证原子性.

  • 运算结果并不依赖变量的当前值,或者能够确保只有单一的线程修改变量的值.
  • 变量不需要与其他的状态变量共同参与不变约束.

普通的变量只能保证在该方法的执行过程中所有依赖赋值结果的地方都能获取到正确的结果,而不能保证变量赋值操作的顺序与程序代码中的执行顺序一致.而volatile关键字则可以避免指令重排序.

volatile变量的操作,在多个CPU访问内存时,需要使用内存屏障,内存屏障的指令会引起别的CPU或者别的内核无效化其Cache,使得volatile变量的修改对其他CPU立即可见.

volatile变量读操作的性能消耗与普通变量几乎没有什么差别,但是写操作可能会慢一些.

long和double是64位的数据类型,如果有多个线程共享一个并为声明为volatil的long或double类型的变量,并且同时对它们进行读取和修改操作,那么某些线程可能会读取到一个即非原值,也不是其他线程修改值的代表了"半个变量"的数值.(在目前商用Java虚拟机中不会出现)

Java内存模型是围绕着在并发过程中如何处理原子性,可见性有序性这3个特征来建立的.

  1. 原子性:大致认为基本数据类型的访问读写是具备原子性的.synchronized块之间的操作也具备原子性.
  2. 可见性:可见性是指当一个线程修改了共享变量的值,其他线程能够立即得知这个修改.除了volatile之外,synchronized和final也能实现可见性.final的可见性是指:被final修饰的字段在构造器中一旦初始化完成,并且构造器没有把this的引用传递出去,那在其他线程中就能看见final字段的值.
  3. 有序性:如果在本线程内观察,所有操作都是有序的(线程内表现为串行);如果在一个线程中观察另一个线程,所有操作都是无序的(指令重排序,工作内存与主内存同步延迟).volatile和synchronized可以保证线程之间操作的有序性.

先行发生:如果说操作A先行发生于操作B,其实就是说在发生操作B之前,操作A产生的影响能被操作B观,"影响"包括修改了内存中共享变量的值,发送了消息,调用了方法等.

Java内存模型下一些"天然的"先行发生关系:

  • 程序次序规则:在一个线程内,安装控制流顺序,书写在前的操作先行发生于书写在后面的操作.
  • 管程锁定规则:一个unlock操作先行发生于后面(时间上)对同一个锁的lock操作
  • volatile变量规则:对一个volatile变量的写操作先行发生于后面(时间上)对这个变量的读操作.
  • 线程启动规则:Thread对象的start()方法先行发生于此线程的每一个动作.
  • 线程终止规则:线程中的所有操作都先行发生于对此线程的终止检测.
  • 线程中断规则:对线interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生.
  • 对象终结规则:一个对象的初始化完成(构造函数执行结束)先行发生于它的finalize()方法的开始.
  • 传递性:如果操作A先行发生于操作B,操作B先行发生于操作C,那就可以得出操作A先行发生于操作C的结论.

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

Java与线程

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

实现线程主要有3种方式:

  1. 使用内核线程实现.
  2. 使用用户线程实现.
  3. 使用用户线程轻量级进程混合实现.

使用内核线程实现

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

程序一般不会直接去使用内核线程,而是去使用内核线程的一种高级接口----轻量级进程(LWP),轻量级进程就是我们通常意义上所讲的线程.由于每个轻量级进程都由一个内核线程支持,因此只有先支持内核线程,才能有轻量级进程.

轻量级进程与内核线程之间1:1的关系:

优点:即使有一个轻量级进程在系统调用中阻塞了,也不会影响整个进程继续工作.

缺点:基于内核线程实现,所以各种操作(创建,析构,同步),都需要进行系统调用,而系统调用的代价较高,需要在用户态和内核态中来回切换.每个轻量级进程都需要有一个内核线程的支持,因此轻量级进程要消耗一定的内核资源(如内核线程的栈空间),因此一个系统支持轻量级进程的数量是有限的.

使用用户线程实现

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

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

进程与用户线程之间的1:N的关系:

优点:不需要内核支持,无需系统调用,无需从用户态切换至核心态.快速且低消耗,能支持规模更大的线程数量

缺点:缺少内核支持,一个用户线程如果阻塞在系统调用中,整个进程都将会被阻塞,且多处理器系统中几乎无法将线程映射到其他处理器上.实现复杂.

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

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

Java线程的实现

Java中的线程模型基于操作系统原生线程模型来实现的.对于Sun JDK来说,它的Windows版Linux版都是使用一对一的线程模型实现的,一条Java线程就映射到一条轻量级进程之中,因为Windows和Linux系统提供的线程模型就是一对一的.

  • Windows下有纤程包,Linux也有NGPT来实现N:M模型,但是它们都没有成为主流.

而在Solaris平台中可以同时支持一对一多对多的线程模型,因此在Solaris版的JDK中也对应提供了两个平台专有的虚拟机参数来明确指定虚拟机使用哪种线程模型.

Java线程调度

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

使用协同式调度的多线程系统,线程的执行时间由线程本身来控制,线程把自己的工作执行完成之后,要主动通知系统切换到另一个线程上,这种方式基本没有什么线程同步问题,缺点是线程执行时间不可控制,程序可能会一直阻塞在那里.

如果使用抢占式调度的多线程系统,那么每个线程将由系统来分配执行时间,线程的切换不由线程本身来决定.也不会有一个线程导致整个进程阻塞的问题.

Java语言一共设置了10个级别线程优先级,在多个线程同时处于Ready状态时,优先级越高的线程越容易被系统选择执行.

状态转换

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

新建(New):创建后尚未启动的线程处于这种状态.

运行(Runable):Runable包括了操作系统进程状态中的新建和就绪,也就是处于此状态的线程有可能正在执行,也有可能正在等待CPU为它分配执行时间.

无限期等待(Waiting):处于这种状态的线程不会被分配CPU执行时间,他们要等待被其他线程显式地唤醒.以下方法会让线程陷入无限期的等待状态:

  • 没有设置Timeout参数的Object.wait()方法.
  • 没有设置Timeout参数的Thread.join()方法.
  • LockSupport.park()方法.

限期等待(Timed Waiting):处于这种状态的线程也不会被分配CPU执行时间,不过无须等待被其他线程显式地唤醒,在一定时间之后它们会由系统自动唤醒.以下方法会让线程进入限期等待状态:

  • Thread.sleep()方法.
  • 设置了Timeout参数的Object.wait()方法.
  • 设置了Timeout参数的Thread.join()方法.
  • LockSupport.parkNanos()方法.
  • LockSupport.parkUntil()方法.

阻塞(Blocked):线程被阻塞了,阻塞状态和等待状态的区别是:阻塞状态在等待着获取到一个排他锁,这个事件将在另一个线程放弃这个锁的时候发生;而等待状态则是在等待一段时间,或者唤醒动作的发生.在程序等待进入同步区域的时候,线程将进入这种状态.

结束(Terminated):已终止线程的线程状态,线程已经结束执行.

  • 注意与操作系统中进程三态模型五态模型区分.

进程的三态模型:

  1. 就绪
  2. 运行
  3. 阻塞

进程的五态模型:

  1. 新建
  2. 就绪
  3. 运行
  4. 阻塞
  5. 终止

发布了71 篇原创文章 · 获赞 201 · 访问量 3万+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章