1、前言
该篇内容主要介绍JVM如何实现多线程,多线程间由于共享和竞争数据而导致的一系列问题以及解决方案。
2、内存模型(JMM)
Java内存模型(Java Memory Model,简称JMM)的主要目的是定义程序中各种变量的访问规则,即关注在虚拟机中把变量存储到内存和从内存中取出变量值的底层细节。
2.1、主内存与工作内存
我们在《JVM学习 01.JVM内存模型》中讲到了JVM的内存模型。那么这里又讲到了Java的内存模型。那么这两者又什么关联呢?
首先,JVM内存模型分为:方法区,堆,虚拟机栈,本地方法栈,程序计数器;而Java内存模型分为:主内存、工作内存。
对应关系为:
Java主内存 = JVM堆 + JVM方法区。规定了所有的变量都存储在主内存。
Java工作内存 = 虚拟机栈 + 本地方法栈 + 程序计数器。存储了每条线程所使用的变量的主内存副本。
- 线程堆变量的所有操作(读取、赋值等)都必须在工作内存中进行,而不能直接读写主内存中的数据。
- 不同线程之间也无法直接访问对方工作内存中的变量(即线程间相互隔离)
- 线程间变量值的传递均需要通过主内存完成。
线程,主内存,工作内存三者的交互如下图:
关于主内存和工作内存之间的交互协议有如下8种操作:
- lock,锁定操作。作用于主内存变量,把变量标识为线程独占的状态。
- unlock,释放锁。作用于主内存变量,把一个锁定的变量释放出来,释放后其他线程才能使用。
- read,读取。作用于主内存变量,把一个变量从主内存传输到线程的工作内存中。以便load使用。
- load,载入。作用于工作内存变量,把read操作传输到工作内存中的变量值放入到变量副本中。
- use,使用。作用于工作内存变量,把工作内存中一个变量值传递给执行引擎。
- assign,赋值。作用于工作内存变量,把一个执行引擎接收的值赋给工作内存变量。
- store,存储。作用于工作内存变量,把工作内存中一个变量值传到主内存中,以便write操作使用。
- write,写入。作用于主内存变量,把store操作得到的变量值放入主内存变量中。
2.2、volatile
volatile是JVM提供的最轻量级的同步机制,但是它并不容易被正确,完整的理解。通常遇到多线程资源竞争问题的时候,一律使用synchronized来进行同步。
volatile有两项特性:可见性,防止指令重排。
2.2.1、可见性
volatile可见性是指被volatile修饰的变量对所有线程可见,这里的可见性是指当一个线程修改了这个变量值,新值对其他线程来说是可以立即得知的。
思考:volatile修饰的变量对所有线程立即可见,那么对该变量所有的写操作都会立刻反应到其他线程中。也就是说这个变量对所有线程来说都是一致的,那么是不是说明它在并发运算下是线程安全的?
答案是否!!!
看一段代码:
/** * @author Shamee loop * @date 2023/3/24 */ public class VolatileDemo { public static volatile int num = 0; public static void add(){ for (int i = 0; i < 20; i++) { new Thread(() -> { for (int j = 0; j < 1000; j++) { num++; } }).start(); } while (Thread.activeCount() > 1) { Thread.yield(); } // 如果是线程安全,那么num输出的值应该是20000. System.out.println(num); } public static void main(String[] args) { for (int i = 0; i < 10; i++) { num = 0; add(); } } }
这里模拟执行了10次。如果线程安全,那么执行10次的结果应该都是20000。但是实际上却是:
volatile变量在各个线程的工作内存中是不存在一致性问题的,但Java中的运算符并非原子性操作,这就导致了volatile变量的运算在并发下一样是不安全的。
由于volatile变量只能保证可见性,如果遇到以下两条规则的运算场景中,还是需要通过枷锁来保证原子性:
- 运算结果并不依赖变量的当前值,或者能够确保只有单一线程修改变量的值
- 变量不需要与其他的状态变量共同参与不变约束
上述代码加锁后:
for (int i = 0; i < 20; i++) { new Thread(() -> { for (int j = 0; j < 1000; j++) { synchronized (VolatileDemo.class) { num++; } } }).start(); }
执行结果:
2.2.2、禁止指令重排
普通变量仅会保证在该方法的执行过程中所有依赖赋值结果的地方都额能获取到正确的结果,而不能保证变量赋值操作的顺序和程序代码真正执行的顺序一致。
指令排序是指处理器采用了允许将多条指令不按程序规定的顺序分开发送给各个相应的电路单元进行处理。但并不是所有的指令都会被重新排序。比如:
- 指令1,把变量A+1;
- 指令2,把变量A*10;
- 指令3,把变量B-1;
由于指令1和指令2是相互依赖的,因此在处理过程中会严格按照指定顺序。但是指令3就不一定了。处理器只要能够保障返回的是正确结果即可。
那么为什么又要禁止指令重排序呢?
显然重排序是处理器对指令处理的优化处理。如果是单个处理器访问时,当然不会出现问题。但是如果多个处理器并发访问同一块内存,这个时候就需要内存屏障来保障一致性。而volatile关键字,会在指令的操作中加上lock修饰,lock指令把修改同步到内存时,意味着所有之前的操作都已经执行完成,这样便形成了“指令重排序无法越过的内存屏障”的效果。
2.3、原子性、可见性、有序性
原子性:
原子性指一个操作或者多个操作,要么全部执行,并且执行的过程不会被任何因素打断,要么就都不执行。
Java中的基本类型的访问和读写都是具备原子性的。通常我们所使用的同步块(synchronized关键字修饰)之间的操作也是具备原子性的
可见性:
可见性就是指当一个线程修改了共享变量时,其他线程能够立即得知这个修改动作。上面讲到的volatile就是这个。
除了volatile以外,synchronized和final也可以实现可见性。
synchronized实现可见性是对一个变量执行unlock操作前,必须先把变量同步回主内存中(执行store,write操作)。
final实现可见性是指被final修饰的字段在构造器中一旦被初始化完成,并且构造器没有把this引用传递出去,那么在其他线程中就能看见final字段值。
有序性:
有序性是指如果在本线程内观察,所有操作都是有序的(指线程内似表现为串行);如果在一个线程中观察另一个线程,所有操作都是无序的(值指令重排序和工作内存同主内存同步延迟现象)。
volatile和synchronized保证了线程间操作的有序性。
volatile前面介绍了本身包含了禁止指令重排序的语义。
synchronized则保证了一个变量在同一时刻只允许一条线程对其进行lock操作。
可以发现,synchronized关键字能同时保证原子性,可见性,有序性。但往往越是这样的并发控制,如果被滥用,通常就会伴随着很大的性能问题。
3、Java与线程
关于线程的实现有三种方式:内核线程实现(1:1),用户线程实现(1:N),使用用户线程+轻量级进程混合实现(N:M)。
3.1、线程实现
3.1.1、内核线程
内核线程(KLT)实现的方式就是直接由操作系统内核支持的线程。每个内核线程可以视为内核得到一个分身,这样操作系统就有能力同时处理多件事情。一般程序不会直接使用内核线程,而是使用一种高级接口:“轻量级进程(LWP)”,其实就是我们通常意义上说的线程。
实现模型如下图(网上借的图):
用户进程中,通过 LWP 使用系统的 内核线程 。由于其一对一的关系,又称为1:1实现。
由于 用户线程 与 LWP 一一对应,LWP 是独立的调度单元,因此某个LWP在 用户进程调用过程中 发生阻塞,以及在 系统调用中 发生了阻塞,都不会影响整个进程的执行。
缺点是LWP会消耗一定的内核资源,且仅能支持的数量较少。
3.1.2、用户线程
用户线程(UT)指的是完全建立在用户空间的线程库上,系统内核不能感知到用户线程的存在以及实现。用户线程的所有状态由用户程序自己处理。
区别于内核线程模型,用户线程的调度不依赖于内核,占用内核资源极少,可以突破量的限制,并且减少切换时间损耗。
但难以利用多核CPU的优势,一旦发生系统调用中断,其他线程也会被中断。
实现模型如下图(网上借的图):
3.1.3、混合实现
结合了内核线程和用户线程的优点。既存在用户线程,也存在轻量级进程。用户线程还是建立在用户空间,所以可以支持大规模的用户线程并发;而轻量级进程则作为用户线程和内核线程的桥梁,提供线程调度功能和处理器映射。
优点是大大降低了整个进程被完全阻塞的风险。
实现模型如下图(网上借的图):
3.2、线程调度
调度方式有两种:协同式、抢占式。
协同式:这种方式是原始方式,线程的执行时间由线程本身控制,一个线程执行完后主动通知另一个线程。该方式最大的好处就是实现简单(Lua的协同例程就是采用这种方式),目前已经很少使用,很容易造成阻塞。
抢占式:每个线程由系统来分配执行时间,线程切换不由线程本身决定。如Java中Thread::yield()可以主动让出执行时间,但是如果想要主动获取执行时间,线程本身加缪没办法了。
目前Java所使用的线程调度方式就是抢占式调度。虽然Java线程调度由系统自动完成,但用户可以设置线程优先级(Thread.MIN_PRIORITY至Thread.MAX_PRIORITY)来”引导“操作系统给某些线程多分配一些时间来处理,另一些线程可以少点时间。当两个线程同时处于ready状态,优先级越高越容易被系统优先执行。
Java语言有10个级别的线程优先级,其实在代码中就是1 - 10 十个int常量(默认5),通过setPriority(int x)来设置。
3.3、线程状态
Java语言定义了6中线程状态。任意一个时间点,一个线程有且只有一种状态,并且可以通过特定方法切换不同状态。
- 新建(New):创建后尚未启用
- 运行(Runnable):包括了Running和Ready,此状态的线程由可能正在执行,也有可能正在等待操作系统分配执行时间
- 无限期等待(Waiting):该状态线程不会被分配执行时间,要等待被显式唤醒。以下几种情况下线程会处于该状态:
- 没设置Timeout参数的Object::wait()方法;
- 没设置Timeout参数的Object::join()方法;
- LockSupport::park()方法
- 限期等待(Timed Waiting):该状态线程不会被分配执行时间,不过无需等待被其他线程显式唤醒,在一定时间后由系统自动唤醒。以下几种情况下线程会处于该状态:
- Thread::sleep()方法。
- 设置了Timeout参数的Object::wait()方法。
- 设置了Timeout参数的Thread::join()方法。
- LockSupport::parkNanos()方法。
- LockSupport::parkUntil()方法。
- 阻塞(Blocked):线程被阻塞了。其中由阻塞状态和等待状态。
- 阻塞状态:在等待获取到一个排他锁,这个时间将在另一个线程放弃这个锁的时候发生;
- 等待状态:等待一段时间,或者唤醒动作的发生。在程序等待进入同步区域的时候,线程将进入这种状态。
- 结束(Terminated):种植线程状态,线程结束运行。
状态的转换关系如下图:
4、Java与协程
上面我们讲到了Java线程的实现和调度方式,到现在,Java中依然采用这种并发编程方式和同步机制运作,但是在某些场景下,也显现出了疲态。
比如面对如今的Web应用的服务要求,其请求数量和计算量急速增长。现在的B/S系统中,一次对外业务请求的响应,往往需要分布在不同服务器上的大量服务共同协作完成(微服务)。这之后Java目前的并发机制就出现了一些些问题,如今的1:1内核线程模型是Java虚拟机线程实现的主流选择方式,但是这种方式的缺陷是切换,调度成本高,且系统能容纳的线程数量有限。现在每个请求本身的执行时间变得很短,数量很多的前提下,可能用户线程切换的开销会接近于计算本身的开销,这就有点得不偿失了。
因此Java研究了新的解决方案,便是协程。可以理解为线程中的模拟线程,之所以叫做协程,是起初用户线程模型演化过来,而最初用户线程模型是被设计成协同式调度,因此后来就成为协程。
协程的主要优势就是轻量。通常虚拟机不显示设置-Xss或-XX:ThreadStackSize下,在64位Linux上HotSpot线程栈容量默认1M,此外内核数据结构大概16KB。如果式一个协程,栈通常在几百个字节到几KB之间。一个JVM中线程池容量能达到几百算很大了,但是支持协程应用中,同时并存的数量可以达到万或十万级别。
但是目前Java协程还未成熟,相应的Go中就有了比较成熟的虚拟线程的方式。还挺期待Java的应用。
5、小结
本篇整理了Java内存模型与一些线程模型相关的知识点。希望对于Java程序在JVM内的执行有了更深层次的了解。后续还会努力更新中......一起加油学习吧。