14. Java内存模型与线程

一、硬件的效率与一致性

1.1 为什么需要高速缓存

绝大多数的运算任务都不可能只靠CPU就能完成,至少要和内存交互,如读取运算数据,存储运算结果等,这个I/O操作是很难消除的,而CPU和存储设备的运算速度有着几个数量级的差距,所以现代计算机系统加入了一层或多层**读写速度尽可能接近CPU运算速度的高速缓存(cache)**来作为内存与处理器之间的缓冲:将运算需要使用的数据复制到缓存中,让运算能快速进行,当运算结束后再从缓存同步回内存之中,这样处理器就无须等待缓慢的内存读写了。

1.2 高速缓存带来的问题

高速缓存很好的解决了CPU和内存速度之间的矛盾,但是也带来了新的问题:缓存一致性。在多路处理器中,每个CPU都有自己的高速缓存,而它们又共享同一个主内存,这种系统共享内存多核系统,如下图所示。当多个处理器的运算任务都涉及到同一块主内存区域时,将可能导致各自的缓存数据不一致,在发生这种情况时,那同步回主内存时该以谁的魂村数据为准呢?为了解决一致性问题,需各个处理器访问缓存时都遵循一些协议,如MSI、MESI、MOSI、Synapse、Firefly等。

在这里插入图片描述

从本章开始,我们将会频繁见到**“内存模型”一词,它可以理解为在特定的操作协议下,对特定的内存或高速缓存进行读写访问的过程抽象**。不同架构的物理机器可以拥有不一样的内存模型,而Java虚拟机也有自己的内存模型,并且与这里介绍的内存访问操作及硬 件的缓存访问操作具有高度的可类比性。

二、Java内存模型

2.1 主内存与工作内存

Java内存模型的主要目的是定义程序内各种变量的访问规则,包括实例字段、静态字段和构成数组对象的元素,但不包括局部变量和方法参数,因为后者时线程私有的,自然不会存在竞争问题。

  • Java内存模型规定了所有的变量都存储在主内存中,此处的主内存可以和上面的主内存类比,但是物理上它只是虚拟机内存的一部分。
  • 每条线程还有自己的工作内存,可与上面的高速缓存类比,线程的工作内存中保存了被该线程使用的变量的主内存副本。线程对变量的所有操作,都必须在工作内存中进行,而不能直接读写主内存中的数据。
  • 不同的线程之间也无法直接访问对方工作内存中的变量,线程间变量值的传递均需要通过主内存来完成。

在这里插入图片描述

2.2 内存间交互操作

关于主内存与工作内存之间具体的交互协议,即一个变量如何从主内存拷贝到工作内存、如何从工作内存同步回主内存这一类的实现细节,Java内存模型中定义了一下8种操作来完成。具体的操作略。

2.3 volatile型变量解决的问题

2.3.1 可见性问题

**定义:**当一个变量被定义成volatile之后,可以保证此变量对所有线程的可见性,这里的可见性是指当一条线程修改了这个变量的值,新值对于其他线程是可以立即得知的(我理解是线程A修改变量n后,会把n同步到主存,然后计算机执行某个指令,强行将其他线程里的n修改成当前值;而普通变量,没有强制执行这一操作,需要等其他线程再次读取变量时,才会拿到新值)。

**常见误解:**volatile变量对所有线程是立即可见的,对volatile变量所有的写操作都能立刻反应到其他线程之中。换句话说,volatile变量在各个线程中是一致的,所以基于volatile变量的运算在并发下线程安全的。

这句话的论据部分没有错,但是由其论据不能得出 “基于volatile变量的运算在并发下时线程安全的” 这样的结论。因为如果Java里面的运算操作符并不是原子操作,一样会导致volatile变量的运算在并发下是不安全的。

如下代码,发起了20个线程,每个线程对race变量进行10000次自增操作,如果这段代码能够正确并 发的话,最后输出的结果应该是200000。但是实际结果每次运行都不一样,而且都是一个小于200000的数字。

问题就出在自增运算“race++”之中,我们用Javap反编译这段代码后会得到代码清单12-2所示,发 现只有一行代码的increase()方法在Class文件中是由4条字节码指令构成(return指令不是由race++产生 的,这条指令可以不计算),从字节码层面上已经很容易分析出并发失败的原因了:当getstatic指令把 race的值取到操作栈顶时,volatile关键字保证了race的值在此时是正确的,但是在执行iconst_1、iadd这 些指令的时候,其他线程可能已经把race的值改变了,而操作栈顶的值就变成了过期的数据,所以 putstatic指令执行后就可能把较小的race值同步回主内存之中。

public class VolatileTest {
    public static volatile int race = 0;

    public static void increase() {
        race++;
    }

    private static final int THREADS_COUNT = 20;

    public static void main(String[] args) {
        Thread[] threads = new Thread[THREADS_COUNT];
        for (int i = 0; i < THREADS_COUNT; i++) {
            threads[i] = new Thread(new Runnable() {
                @Override
                public void run() {
                    for (int i = 0; i < 10000; i++) {
                        increase();
                    }
                }
            });
            threads[i].start();
        }
        // 等待所有累加线程都结束
        while (Thread.activeCount() > 2)
            Thread.yield();
        System.out.println(race);
    }
}
public static void increase(); 
	Code:
		Stack=2, Locals=0, Args_size=0 
		0: getstatic #13; //Field race:I 
		3: iconst_1 
		4: iadd 
		5: putstatic #13; //Field race:I 
		8: return 
	LineNumberTable: 
		line 14: 0 
		line 15: 8

由于volatile变量只能保证可见性,在不符合以下两条规则的运算场景时,我们仍然要通过加锁来保证原子性:

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

2.3.2 指令重排问题

关于乱序执行,参考:https://zhuanlan.zhihu.com/p/413889872

使用volatile变量的第二个语义是禁止指令重排序优化,普通的变量仅会保证在该方法的执行过程 中所有依赖赋值结果的地方都能获取到正确的结果,而不能保证变量赋值操作的顺序与程序代码中的 执行顺序一致。因为在同一个线程的方法执行过程中无法感知到这点,这就是Java内存模型中描述的 所谓“线程内表现为串行的语义”

2.4 先行发生原则

目前看不懂,后续需要继续研究。

三、Java与线程

3.1 线程在计算机中的实现方式

实现线程主要由三种方式:使用内核线程实现、使用用户线程实现、使用用户线程加轻量级进程混合实现

3.1.1 内核线程(1:1)

**原理:内核线程(Kernel-Level Thread, KLT)就是直接由操作系统内核支持的线程,这种线程由内核来完成线程切换,内核通过操纵调度器对线程进行调度,并负责将线程的任务映射到各个处理器上。**程序一般不会直接使用内核线程,而是使用内核线程的一种高级接口——轻量级进程(Light Weight Process, LWP),轻量级进程就是我们通常意义上所讲的线程,由于每个轻量级进程都由一个内核线程支持,因此只有先支持内核线程,才能有轻量级进程。这种轻量级进程与内核线程之间1:1的关系成为一对一模型。

在这里插入图片描述

**优点:**由于内核线程的支持,每个轻量级进程都成为一个独立的调度单元,即使其中某一个轻量级进程在系统调用中被阻塞了,也不会影响整个进程继续工作。

缺点:

  1. 由于是基于内核线程实现的,所以各种线程操作,如创建、析构及同步,都需要进行系统调用。而系统调用的代价相对较高,需要在用户态和内核态中来回切换(主要是用户态切换内核态时,需要保存上下文)。
  2. 每个轻量级进程都需要一个内核线程的支持,因此轻量级进程要消耗一定的内核资源,因此一个系统支持的轻量级进程的数量是有限的。

3.1.2 用户线程(1:N)

**原理:用户线程是指完全建立在用户空间的线程库上,系统内核不能感知到用户线程的存在及如何实现的。用户线程的建立、同步、销毁和调度完全在用户态中完成,不需要内核的帮助。**这种进程与用户线程之间1:N的关系成为一对多的线程模型。

在这里插入图片描述

**优点:**用户线程不需要切换到内核态,因此操作是非常快速且低消耗的,也能够支持规模更大的线程数量。

**缺点:**用户线程的优势在于不需要系统内核的支援,劣势也在于没有系统内核的支援,所有的线程操作都需要由用户自己去处理。线程的创建、销毁、切换和调度都是用户必须考虑的问题,而且由于操作系统只把处理器分配到进程,那诸如“阻塞如何处理”“多处理器系统中如何将线程映射到其他处理器上” 这类问题解决起来将会异常困难,甚至有些不可能实现。

3.1.3 混合实现(N:M)

原理:线程除了依赖内核线程实现和完全由用户程序自己实现外,还有一种将内核线程与用户线程一起使用的实现方式,成为N:M实现。

在这里插入图片描述

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

3.2 Java中的实现

Java虚拟机规范并没有规定Java线程如何实现,所以这是一个与具体虚拟机相关的话题。

Java线程在早期的Classic虚拟机上,是基于一种被称为“绿色线程”的用户线程实现的,但从JDK 1.3 起,“主流”平台上的“主流”商用虚拟机的线程模型都普遍被替换为基于操作系统原生线程模型来实现,即采用1:1线程模型。

以HotSpot为例,它的每一个Java线程都是直接映射到一个操作系统原生线程来实现的,而且中间没有额外的间接结构,所以HotSpot自己是不会去干涉线程调度的(可以设置线程优先级给操作系统提供调度建议),全权交给底下的操作系统去处理,所以何时冻结或唤醒线程、该给线程分配多少处理器执行时间、该把线程安排给哪个处理器核心去执行等,都是由操作系统完成的,也都是由操作系统全权决定的。

3.3 Java线程调度

线程调度是指系统为线程分配处理器使用权的过程,调度主要方式有两种,分别是协同式 (Cooperative Threads-Scheduling)线程调度和抢占式(Preemptive Threads-Scheduling)线程调度。

3.3.1 协同式调度

**定义:**线程的执行时间由线程本身来控制,线程把自己的工作执行完了之后,要主动通知系统切换到另外一个线程上去。

**优点:**协同式多线程的最大好处是实现简单,而且由 于线程要把自己的事情干完后才会进行线程切换,切换操作对线程自己是可知的,所以一般没有什么 线程同步的问题。

**缺点:**程执行时间不可控制,甚至如果一个线程的代码编写有问题,一直不告知系统进行线程切换,那么程序就会一直阻塞在那里。

3.3.2 抢占式调度

**定义:**如果使用抢占式调度的多线程系统,那么每个线程将由系统来分配执行时间,线程的切换不由线程本身来决定。

3.3.3 Java的线程调度

书中说Java采用的是抢占式调度,但是3.2不是说了Java线程完全由操作系统决定吗???

四、Java与协程

4.1 内核线程的局限

1:1的内核模型是如今Java虚拟机线程实现的主流选择,但是这种映射到操作系统上的线程天然的缺陷是切换、调度成本高昂,系统能容纳的线程数量也有限。以前处理一个请求可以允许花费很长时间在单体应用中,具有这种线程切换的成本也是无伤大雅的,但现在在每个请求本身的执行时间变得很短、数量变得很多的前提下, 用户线程切换的开销甚至可能会接近用于计算本身的开销,这就会造成严重的浪费。

线程切换的成本主要来自于用户态与核心态之间的状态转换,而这两种状态转换的开销主要来自于响应中断、保护和恢复执行现场的成本,而如果这部分工作交到用户手上,就会有很多新新花样来减少开销。所以用户线程又复苏起来。

4.2 协程

**定义:**由于最初多数的用户线程是被设计成协同式调度 (Cooperative Scheduling)的,所以它有了一个别名——“协程”。注意:后来的协程并不都是协同式调度的,也有非协同式、自定义调度的协程。

**优点:**协程的主要优势是轻量,无论是有栈协程还是无栈协程,都要比传统内核线程要轻量得多。如果进行量化的话,那么如果不显式设置-Xss或-XX:ThreadStackSize,则在64位Linux上HotSpot的线程栈容量默认是1MB,此外内核数据结构(Kernel Data Structures)还会额外消耗16KB内存。与之相对的,一个协程的栈通常在几百个字节到几KB之间,所以Java虚拟机里线程池容量达到两百就已经不算小了,而很多支持协程的应用中,同时并存的协程数量可数以十万计。

**缺点:**需要在应用层面实现的内容(调用栈、调度器这些)特别多。

4.3 Java中协程的实现

Java中实现协程使用纤程来实现的。

在这里插入图片描述

总结

关于“ 高效并发”这个话题,在本章中主要介绍了虚拟机如何实现“ 并发”,在下一章中,我们的主 要关注点将是虚拟机如何实现“ 高效”,以及虚拟机对我们编写的并发代码􏰀供了什么样的优化手段。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值