理解JVM(五):Java内存模型与线程

2,116

Java内存模型

JMM(Java Memory Model)是JVM定义的内存模型,用来屏蔽各种硬件和操作系统的内存访问差异。

  • 主内存:所有的变量都存储在主内存(Main Memory,类比物理内存)中。
  • 工作内存:每条线程有自己的工作内存(Working Memory,类比处理器高速缓存),线程的工作内存中保存了被该线程使用到的变量的主内存副本拷贝,线程对变量的所有操作(读取、赋值等)都必须在工作内存中进行,而不能直接读写主内存中的变量。不同的线程之间也无法直接访问对方工作内存中的变量,线程间变量值的传递均需要通过主内存来完成。

内存间的交互操作

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

其中read和load,store和write必须成对使用,顺序但补一定连续的执行。通俗的说,就是执行了read,后面一定会执行load,但不一定read之后立马load;store和write也一样。lock和unlock也是成对出现,一个变量在同一时间点只能有一个线程对其进行lock。

  1. 对于普通变量的操作: 创建变量,是在主内存中初始化。 线程用到的变量,会先从主内存中拷贝(read)出来,加载(load)到工作内存,然后引用(use)变量并运算赋值(assign)。然后存储(store)到工作内存,然后更新(write)掉原来的变量。

    普通变量的值在线程间传递均需要通过主内存来完成。例如,线程A修改一个普通变量的值,然后向主内存进行回写,另外一条线程B在线程A回写完成了之后再从主内存进行读取操作,新变量值才会对线程B可见。

  2. 对于volatile修饰的变量:过程和普通变量一样。但保证变量对所有线程的可见性,并且会禁止指令重排序的优化。

    volatile的特殊规则保证了新值能立即同步到主内存,以及每次使用前立即从主内存刷新。因此,可以说volatile保证了多线程操作时变量的可见性,而普通变量则不能保证这一点。

先行发生原则(happens-before)

它是判断数据是否存在竞争、线程是否安全的主要依据,依靠这个原则,我们可以通过几条规则一揽子地解决并发环境下两个操作之间是否可能存在冲突的所有问题。

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

线程

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

实现线程的3种方式:

  • 使用内核线程实现
    • 内核线程(Kernel-Level Thread,KLT)就是直接由操作系统内核(Kernel,下称内核)支持的线程,这种线程由内核来完成线程切换,内核通过操纵调度器(Scheduler)对线程进行调度,并负责将线程的任务映射到各个处理器上。每个内核线程可以视为内核的一个分身,这样操作系统就有能力同时处理多件事情,支持多线程的内核就叫做多线程内核(Multi-Threads Kernel)。
    • 程序一般不直接使用内核线程,而是轻量级进程(通俗意义上的线程)。此2者1:1对应关系。创建,调用同步等都由系统执行,代价较高(需要在内核态和用户态之间来回切换),每个轻量级进程会消耗一定的内核资源(如内核线程的栈空间),因此一个系统支持的轻量级进程时有限的。
  • 使用用户线程实现
    • 广义来说,一个线程只要不是内核线程,就可以认为是用户线程。因此,轻量级进程也属于用户线程,但轻量级进程的实现始终是建立在内核之上的,许多操作都要进行系统调用,效率会受到限制。
    • 狭义的说,用户线程指的是完全建立在用户空间的线程库上,系统内核不能感知线程存在的实现。用户线程的建立、同步、销毁和调度完全在用户态中完成,不需要内核的帮助。如果程序实现得当,这种线程不需要切换到内核态,因此操作可以是非常快速且低消耗的,也可以支持规模更大的线程数量,部分高性能数据库中的多线程就是由用户线程实现的。这种进程与用户线程之间1:N的关系称为一对多的线程模型。
    • 使用用户线程的优势在于不需要系统内核支援,劣势也是没有系统内核的支援。所有的线程操作都需要用户程序自己处理,实现会很复杂,所以现在很少使用了。
  • 使用用户线程加轻量级进程混合实现
    • 既存在用户线程,也存在轻量级进程。用户线程还是完全建立在用户空间中,因此用户线程的创建、切换、析构等操作依然廉价,并且可以支持大规模的用户线程并发。而操作系统提供支持的轻量级进程则作为用户线程和内核线程之间的桥梁,这样可以使用内核提供的线程调度功能及处理器映射,并且用户线程的系统调用要通过轻量级线程来完成,大大降低了整个进程被完全阻塞的风险。在这种混合模式中,用户线程与轻量级进程的数量比是不定的,即为N:M的关系,这种就是多对多的线程模型。

Java线程实现:JDK1.2之前是用户线程,1.2和之后的版本,使用操作系统原生线程模型(内核线程)。

Java线程调度

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

  • 协同式线程调度(Cooperative Threads-Scheduling):线程的执行时间由线程本身来控制,线程把自己的工作执行完了之后,要主动通知系统切换到另外一个线程上。
    • 好处:实现简单,而且由于线程要把自己的事情干完后才会进行线程切换,切换操作对线程自己是可知的,所以没有什么线程同步的问题。
    • 坏处:线程执行时间不可控制,甚至如果一个线程编写有问题,一直不告知系统进行线程切换,那么程序就会一直阻塞在那里。
  • 抢占式线程调度(Preemptive Threads-Scheduling):每个线程将由系统来分配执行时间,线程的切换不由线程本身来决定(在Java中,Thread.yield()可以让出执行时间,但是要获取执行时间的话,线程本身是没有什么办法的)。在这种实现线程调度的方式下,线程的执行时间是系统可控的,也不会有一个线程导致整个进程阻塞的问题,Java使用的线程调度方式就是抢占式调度 。

虽然Java线程调度是系统自动完成的,但是我们还是可以“建议”系统给某些线程多分配一点执行时间,另外的线程少分配一点——通过设置线程优先级的方式(两个线程同时处于Ready状态时,优先级越高的线程越容易被系统选择执行),不过这方法不是很可靠,因为系统线程优先级和Java的10种线程优先级不一定一一对应。

线程状态

在任意时间点,一个线程只有一种状态

  • 新建(New):创建后尚未启动
  • 运行(Runable):正在执行或正在等待CPU为它分配执行时间
  • 等待(Waiting):
    • 无限等待(Waiting):线程不会被分配CPU执行时间,等待被其他线程显式地唤醒。
    • 期限等待(Timed Waiting):线程不会被分配CPU执行时间,无须等待被其他线程显式地唤醒,在一定时间后它们会由系统自动唤醒。
  • 阻塞(Blocked):被阻塞
    • 阻塞和等待的区别:阻塞状态在等待着获取到一个排他锁,这个事件将在另外一个线程放弃这个锁的时候发生;而等待状态则是在等待一段时间,或者唤醒动作的发生。在程序等待进入同步区域的时候,线程将进入这种状态。
  • 结束(Terminated):已终止