文章目录
1. 进程与线程
1.1 概念
进程
进程是程序的一次执行过程,是系统运行程序的基本单位,因此进程是动态的。系统运行一个程序即是一个进程从创建,运行到消亡的过程。
在 Java 中,当我们启动 main 函数时其实就是启动了一个 JVM 的进程,而 main 函数所在的线程就是这个进程中的一个线程,也称主线程。
线程
线程与进程相似,但线程是一个比进程更小的执行单位。一个进程在其执行的过程中可以产生多个线程。
与进程不同的是同类的多个线程共享进程的堆和方法区资源,但每个线程有自己的程序计数器、虚拟机栈和本地方法栈,所以系统在产生一个线程,或是在各个线程之间作切换工作时,负担要比进程小得多,也正因为如此,线程也被称为轻量级进程。
进程线程之我见
实际上,进程无非就是内存中一块区域内的指令,进程执行,就是将入口函数(main函数)的第一条机器指令地址写入PC寄存器,这样程序就运行起来了。但是一个程序中所有指令并不完全具有执行顺序要求,指令的并行能够显著提供进程运行速度,那么我们可以将其他任意一段指令(函数)放入PC寄存器中执行——线程诞生了,因此线程也被称为轻量级进程。
1.2 死锁
线程死锁描述的是这样一种情况:多个线程同时被阻塞,它们中的一个或者全部都在等待某个资源被释放。由于线程被无限期地阻塞,因此程序不可能正常终止。
死锁必须具备以下四个条件:
- 互斥条件:该资源任意一个时刻只由一个线程占用。
- 请求与保持条件:一个进程因请求资源而阻塞时,对已获得的资源保持不放。
- 不剥夺条件:线程已获得的资源在未使用完之前不能被其他线程强行剥夺,只有自己使用完毕后才释放资源。
- 循环等待条件:若干进程之间形成一种头尾相接的循环等待资源关系。
预防死锁
- 破坏请求与保持条件 :进程执行前,一次性申请所有的资源。
- 破坏不剥夺条件 :占用部分资源的线程进一步申请其他资源时,如果申请不到,可以主动释放它占有的资源。
- 破坏循环等待条件 :靠按序申请资源来预防。按某一顺序申请资源,释放资源则反序释放。破坏循环等待条件。
避免死锁
避免死锁就是在资源分配时,借助于算法(比如银行家算法)对资源分配进行计算评估,使其进入安全状态。
2. JMM内存模型
在学习Java并发之前,了解Java内存模型是十分重要的。
这里要注意,我们常说的JVM内存模型指的是JVM的内存分区,而Java内存模型则是一种虚拟机规范。
Java虚拟机规范中定义了Java内存模型,用于屏蔽掉各种硬件和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致的并发效果,JMM规范了Java虚拟机与计算机内存是如何协同工作的:规定了一个线程如何和何时可以看到由其他线程修改过后的共享变量的值,以及在必须时如何同步的访问共享变量。
硬件架构
我们知道,JVM虚拟机内存是计算机分配给Java进程的一块内存区域,指令的执行还是发生在PC寄存器中,在现代计算机架构中,为了提高指令执行速度,而引入了多机缓存机制,见下图:

通常情况下,当一个CPU需要读取主存时,它会将主存的部分先读到CPU缓存中。它甚至可能将缓存中的部分内容读到它的内部寄存器中,然后在寄存器中执行操作。当CPU需要将结果写回到主存中去时,它会将内部寄存器的值刷新到缓存中,然后在某个时间点将值刷新回主存。
JMM内存模型
当程序开始运行时,虚拟的JVM内存模型与真实的硬件内存模型必然存在某种关系,相对应的关系。
对于硬件,所有的线程栈和堆都分布在主内存中。部分线程栈和堆可能有时候会出现在CPU缓存中和CPU内部的寄存器中。

从抽象的角度来看,JMM定义了线程和主内存之间的抽象关系:
- 线程之间的共享变量存储在主内存(Main Memory)中
- 每个线程都有一个私有的本地内存(Local Memory),本地内存是JMM的一个抽象概念,并不真实存在,它涵盖了缓存、写缓冲区、寄存器以及其他的硬件和编译器优化。本地内存中存储了该线程以读/写共享变量的拷贝副本。
- 从更低的层次来说,主内存就是硬件的内存,而为了获取更好的运行速度,虚拟机及硬件系统可能会让工作内存优先存储于寄存器和高速缓存中。
- Java内存模型中的线程的工作内存(working memory)是
cpu的寄存器和高速缓存的抽象描述。而JVM的静态内存储模型(JVM内存模型)只是一种对内存的物理划分而已,它只局限在内存,而且只局限在JVM的内存。
主内存与工作内存
现在我们知道,无论是堆、栈还是直接内存,永远都存在主内存与工作内存的区分,而对于一个变量如何从主内存拷贝到工作内存、如何从工作内存同步到主内存之间的实现细节,Java内存模型定义了以下八种操作来完成:
- lock(锁定):作用于主内存的变量,把一个变量标识为一条线程独占状态。
- unlock(解锁):作用于主内存变量,把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定。
- read(读取):作用于主内存变量,把一个变量值从主内存传输到线程的工作内存中,以便随后的load动作使用
- load(载入):作用于工作内存的变量,它把read操作从主内存中得到的变量值放入工作内存的变量副本中。
- use(使用):作用于工作内存的变量,把工作内存中的一个变量值传递给执行引擎,每当虚拟机遇到一个需要使用变量的值的字节码指令时将会执行这个操作。
- assign(赋值):作用于工作内存的变量,它把一个从执行引擎接收到的值赋值给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作。
- store(存储):作用于工作内存的变量,把工作内存中的一个变量的值传送到主内存中,以便随后的write的操作。
- write(写入):作用于主内存的变量,它把store操作从工作内存中一个变量的值传送到主内存的变量中。
3. 多线程与JMM
现在,我们知道了,一段程序的执行仅仅是将入口函数的机器码地址放入PC寄存器,而多线程下可同时执行多条指令,而寄存器需要到到内存中取机器指令并操作内存中的变量,现代计算机体系架构又引入了多级缓存,也就是每个线程都有自己的缓存,而它们又操作同一片内存。
当操作化为指令在寄存器中执行时,我们人为地使用三个特性描述一个操作:
- 原子性:
cpu寄存器执行一个操作时不可以在中途暂停然后再调度,即不被中断操作,要不执行完成,要不就不执行。 - 可见性:当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值。
- 有序性:寄存器在指令指令时为了提升执行速度,会进行指令顺序的重排,而有序性是指指令实际执行的顺序按照代码的先后顺序执行。
3.1 原子性
代码层面的一个操作在底层可能被编译为多条机器码指令,在单线程下,此操作的指令执行不可能被其他指令打断,因为线程是cpu调度的基本单位,单个线程当然不存在CPU的调度和上下文切换。
在Java中,一般通过以下三种方案来实现操作的原子性。
- synchronized同步代码块
- cas原子类工具
- lock锁机制
3.2 可见性
在多处理器系统中,每个处理器都有自己的高速缓存,而它们又共享同一主内存。
当多个处理器的运算任务都涉及同一块主内存区域时,将可能导致各自的缓存数据不一致的情况,如果真的发生这种情况,那同步回到主内存时以谁的缓存数据为准呢?这就是缓存一致性问题,也就是前面讲到的可见性问题。
为了解决一致性的问题,需要各个处理器访问缓存时都遵循一些协议,在读写时要根据协议来进行操作,这类协议有MSI、MESI(IllinoisProtocol)、MOSI、Synapse、Firefly及DragonProtocol,等等,但最重要的还是要在代码逻辑中进行控制,避免出现取到脏数据。
在Java中,一般通过以下三种方案来实现操作的可见性。
- volatile
- synchronized同步代码块
- lock锁机制
3.3 有序性
有序性是指线程执行的顺序按照代码的先后顺序执行,但是在JVM中并不如此,处理器为了提高程序运行效率,可能会对输入代码进行优化,也就是对各个语句的执行顺序发生调整,但保证程序最终执行结果不会有误。
但是在执行程序时为了提高性能,提高并行度,编译器和处理器常常会对指令做重排序。
3.3.1 指令重排
在执行程序时为了提高性能,提高并行度,编译器和处理器常常会对指令做重排序。讲的更细一点就是指CPU采用了允许将多条指令不按程序规定的顺序分开发送给各相应电路单元处理。
重排序分三种类型:
- 编译器优化的重排序。 编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
- 指令级并行的重排序。现代处理器采用了指令级并行技术(Instruction-Level Parallelism, ILP)来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
- 内存系统的重排序。由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。
数据依赖性
如果两个操作访问同一个变量,且这两个操作中有一个为写操作,此时这两个操作之间就存在数据依赖性。比如写后读、写后写、读后写,更改任意两个操作都会发生错误。
重排序举例:
处理器对内存的读/写操作的执行顺序,不一定与内存实际发生的读/写操作顺序一致,导致重排序导致内存可见性问题。比如处理器使用写缓冲区来临时保存向内存写入的数据,这样其他线程就无法读取最新的数据了。
避免指令重排
-
JMM通过禁止特定类型的编译器重排序和处理器重排序,为程序员提供一致的内存可见性保证
-
JMM的编译器重排序规则会禁止特定类型的编译器重排序
-
java编译器在生成指令序列时,插入特定类型的内存屏障指令来禁止特定类型的处理器重排序
下面是两个概念,
- as-if-serial语义:不管怎么重排序(编译器和处理器为了提高并行度),(单线程)程序的执行结果不能被改变。(编译器、runtime和处理器都必须遵守as-if-serial语义)
- happend before规则:如果一个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须要存在happens-before关系。
3.3.2 Happend-before原则解决编译器重排序
前面提到了指令重排,其也要遵循一定规则,例如下面提到的Happend-before规则(前一个操作的结果会被后续操作获取)。
因为JVM会对代码进行编译优化,会出现指令重排序情况,为了避免编译优化对并发编程安全性的影响,需要happens-before规则定义一些禁止编译优化的场景,保证并发编程的正确性。
Happend before关系:
- 程序顺序规则:一个线程中的每个操作,happens-before于该线程中的任意后续操作。
- 监视器锁规则:对一个锁的解锁,happens-before于随后对这个锁的加锁。
- volatile变量规则:对一个volatile域的写,happens-before于任意后续对这个volatile域的读。
- 传递性:如果A happens-before B,且B happens-before C,那么A happens-before C。
有哪些happens-before规则
- 程序次序规则: 在一个线程内一段代码的执行结果是有序的。就是还会指令重排,但是随便它怎么排,结果是按照我们代码的顺序生成的不会变。
- 管程锁定规则: 就是无论是在单线程环境还是多线程环境,对于同一个锁来说,一个线程对这个锁解锁之后,另一个线程获取了这个锁都能看到前一个线程的操作结果!(管程是一种通用的同步原语,synchronized就是管程的实现)
- volatile变量规则: 就是如果一个线程先去写一个volatile变量,然后一个线程去读这个变量,那么这个写操作的结果一定对读的这个线程可见。
- 线程启动规则: 在主线程A执行过程中,启动子线程B,那么线程A在启动子线程B之前对共享变量的修改结果对线程B可见。
- 线程终止规则: 在主线程A执行过程中,子线程B终止,那么线程B在终止之前对共享变量的修改结果在线程A中可见。也称线程join()规则。
- 线程中断规则:对线程interrupt()方法的调用先行发生于被中断线程代码检测到中断事件的发生,可以通过Thread.interrupted()检测到是否发生中断。
- 传递性规则: 这个简单的,就是happens-before原则具有传递性,即hb(A, B) , hb(B, C),那么hb(A, C)。
- 对象终结规则: 这个也简单的,就是一个对象的初始化的完成,也就是构造函数执行的结束一定 happens-before它的finalize()方法。
3. 小结
前面提到了Java并发编程下的一些简单术语,以及简单的介绍了Java中禁止指令重排的一些原理。对于可见性和原子性,将在后面的篇章中详细介绍。

535

被折叠的 条评论
为什么被折叠?



