Java内存模型与线程
1. 内存模型
1.1 缓存一致性
我们知道,由于存储器与CPU之间的速度严重不匹配,有着几个数量级的差距,所以现代计算机系统都不得不加入一层或多层读写速度尽可能接近处理器运算速度的高度缓存(Cache
)作为内存与处理器之间的缓冲:将运算需要使用的数据赋值到缓冲中,让运算能快速进行,当运算结束后再从缓存同步回内存之中,这样处理器就无须等待缓慢的内存读写了。
在多路处理器系统中,每个处理器都有自己的高速缓存,而它们又共享同一主内存,这种系统称为共享内存多核系统,如下图所示。
缓存可以解决CPU与存储器之间速度不匹配的矛盾,但同样带来一个问题:缓存一致性。即当多个处理器的运算任务都涉及同一块主内存区域时,将可能导致各自的缓存数据不一致,此时需要以谁的缓存数据为准,同步回到主内存之中。
为了解决该问题,需要各个处理器访问缓存时都需要遵循一些协议,在读写时要根据协议进行操作。
除了增加缓存外,为了使处理器内部的运算单元被充分利用,处理器可能对输入代码进行乱序执行,会保证该结果与顺序执行的结果时一致的,但并不保证程序的各个语句的先后顺序与输入代码中的顺序一致。
1.2 Java 内存模型
Java
内存模型用来屏蔽各种硬件和操作系统的内存访问差异,以实现让Java
程序在各种平台下都能达到一致的内存访问效果。
1.2.1 主内存与工作内存
Java
内存模型规定了所有的变量都存储在主内存中,每条线程还有自己的工作内存,线程的工作内存保存了该线程使用的变量的主内存副本,线程对变量的所有操作都必须在工作内存中进行,而不能直接读写主内存的数据。不同的线程之间也无法直接访问对方工作内存中的变量,线程间变量值的传递均通过主内存来完成。
1.2.2 内存间交互操作
一个变量如何从主内存拷贝到工作内存、如何从工作内存同步回到主内存操作,被Java
内存模型定义了 8 中操作来完成。JVM
保证以下每种操作都是原子的、不可再分的。
lock
(锁定):作用于主内存变量,把一个变量标识为一条线程的独占状态。unlock
(解锁):作用于主内存变量,把一个处于锁定状态的变量释放,以便让其他线程锁定。read
(读取):作用于主内存变量,把一个变量的值从主内存传输到线程的工作内存中,以便随后的load
。load
(载入):作用于工作内存的变量,把read
操作从主内存得到的变量值放入工作内存的变量副本中。use
(使用):作用于工作内存的变量,把工作内存中的变量值传递给执行引擎,每当虚拟机需要使用到变量的值的字节码指令都会执行该操作。assign
(赋值):作用于工作内存的变量,将执行引擎接收到的值赋给工作内存的变量,每当虚拟机需要使用到变量赋值的字节码指令都会执行该操作。store
(存储):作用于工作内存的变量,把工作内存中一个变量的值传送到主内存中。write
(写入):作用于主内存变量,把store
操作从工作内存中得到的变量的值放入主内存的变量中。
如果要把一个变量从主内存拷贝到工作内存,则按顺序执行read
和load
操作。
如果要把一个变量从工作内存同步到主内存,则按顺序执行store
和write
操作。
Java
内存模型只要求上述两个操作顺序执行,但不要求连续执行。除此之外,Java
内存模型还规定了以上 8 种基本操作必须满足如下规则:
- 不允许
read
和load
、store
和write
中的一个操作单独出现。即不允许从主内存读取变量后工作内存不接受,或者从工作内存回写主内存不接受。 - 不允许一个线程丢弃它的最后一次
assign
操作,即变量在工作内存中改变后必须将该变化同步到主内存。 - 不允许线程在没有进行
assign
操作,便将数据同步回主内存。 - 不允许在工作内存中直接使用一个未被初始化(
load
或assign
)的变量,即对一个变量实施use
、store
操作之前,必须执行过assign
和load
操作。 - 一个变量在同一时刻只允许一个线程对其进行
lock
操作,但lock
操作可以被同一个线程重复执行多次。 - 如果对一个变量执行
lock
操作,那将会清空工作内存中此变量的值,在执行引擎使用这个变量前,需要重新执行load
或assign
操作初始化变量的值。 unlock
执行前,该变量必须被lock
,同时不允许unlock
被其他线程锁定的变量。unlock
前,必须把此变量同步回主内存中。
1.2.3 volatile 变量的特殊规则
Java
内存模型为volatile
专门定义了一些特殊的访问规则,一个变量被定义成volatile
之后,它将具备两项特性:
- 保证此变量对所有线程的可见性。指当一条线程修改了这个变量的值,这种变化可以立即被其他线程感知到。但是
Java
里面的运算操作符并非原子操作,这导致volatile
变量在并发下一样是不安全的。
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
/**
* @author wangzhao
* @date 2019/8/7 22:32
*/
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) throws InterruptedException {
ExecutorService exec = Executors.newCachedThreadPool();
for(int i=0;i<THREADS_COUNT;i++){
exec.execute(new Runnable() {
@Override
public void run() {
for(int i=0;i<10000;i++)
increase();
}
});
}
exec.shutdown();
TimeUnit.SECONDS.sleep(10);
System.out.println(race);
}
}
173032
输出的结果应该是200000,但每次输出的结构都不一样且小于20000。
当getstatic
指令把race
的值去到操作数栈顶时,volatile
关键字保证了race
的值此时是正确的。但在执行iconst_1
、iadd
指令时,其他线程可能已经把race
的值增加了,此时操作数栈顶的值过期了。putstatic
指令执行的时候,可能把操作数栈顶已经过期的值同步回主内存中。
- 禁止指令重排序。
Map configOptions;
char[] configTest;
// 此变量必须定义为volatile
volatile boolean initialized = false;
// 假设以下代码在线程A中执行
// 模拟读取配置信息,当读取完成后将initialized设置为true以通知其他线程配置可用
configOption = new HashMap();
configTest = readConfigFile(fileName);
processConfigOptions(configTest,configOptions);
initialized = true;
// 假设一下代码在线程B中执行
// 等待initialized为true,代表线程A已经把配置信息初始化完成
while(!initialized){
sleep();
}
// 使用线程A中初始化好的配置信息
doSomethingWithConfig();
倘若initialized
没有被volatile
修饰,那么可能就存在initialized = true
先于processConfigOptions(configTest,configOptions);
之前执行的情况,这便导致线程B并没有等待线程A将配置信息初始化完全执行,可能会产生异常。
/**
* @author wangzhao
* @date 2019/8/7 23:06
*/
public class Singleton {
private static Singleton instance;
public static Singleton getInstance(){
if(instance == null){
synchronized (Singleton.class){
if(instance == null)
instance = new Singleton();
}
}
return instance;
}
public static void main(String[] args) {
Singleton.getInstance();
}
}
上述代码汇编后,多执行了一个lock add1 $0x0,(%esp)
操作,该操作的作用相当于一个内存屏障,即重排序时,不能将后面的指令排序到内存屏障之前。
lock
前缀指令的作用是将本处理器的缓存写入内存,该写入工作也会引起别的处理器或者别的内核无效化其缓存,当别的线程进行读写该变量是,需要重新从主内存读入。
1.2.4 原子性、可见性与有序性
- 原子性
对基本数据类型的访问、读写都是具备原子性的(long
、double
除外),sunchronized
之间的操作也具备原子性。
- 可见性
可见性指当一个线程修改了共享变量的值时,其他线程能够立即得知这个修改。除了volatile
能实现可见性,synchronized
和final
同样能实现。同步块的可见性是由“对一个变量执行unlock
操作之前,必须先把此变量同步回主内存中”这条规则获得的。final
关键字的可见性是指:被final
修饰的字段再构造器中一旦初始化完成,并且构造器没有把“this
”的引用传递出去,那么在其他线程中就能看见final
字段的值。
public statin final int i;
public final int j;
static{
i = 0;
}
{
j = 0;
}
i
和 j
具备可见性,无需同步就能被其他线程正确访问。
- 有序性
Java
程序中的有序性可以总结为一句话:如果在本线程内观察,所有操作都是有序的;如果在一个线程中观察另一个线程,所有的操作都是无序的。
Java
语言提供了volatile
和sunchronized
两个关键字来保证线程之间操作的有序性,volatile
关键字本身就包含了禁止指令重排序的语义,synchronized
则是由“一个变量在同一时刻只允许一条线程对其进行lock
操作”这条规则获得的,这个规则决定了持有同一个锁的两个同步块只能串行地进入。
1.2.5 先行发生原则
先行发生原则是用来判断数据是否存在竞争、线程是否安全的主要依据,通过先行发生规则可以判断并发环境下两个操作之间是否可能存在冲突。
先行发生指内存模型中两个操作之间的偏序关系,如果操作A先行发生于操作B,也就是说发生操作B之前,操作A产生的影响能被操作B观察到。“影响”包括修改了内存中共享变量的值、发送了消息、调用了方法等。
// 以下操作线程A执行
i = 1;
// 以下操作线程B执行
j = i;
// 以下操作线程C执行
i = 2;
假设线程 A 中的操作 “i = 1
” 先行发生于线程 B 的操作 “j = i
”,那么就可以确定在线程 B 的操作执行后,变量 j
的值一定是 1,得出该结论的依据如下:
一、根据先行发生原则, “i = 1
” 的结果可以被观察到。
二、线程 C 还没登场,线程 A 操作结束之后,没有其他线程修改变量 i 的值。
我们依然保持线程A
和B
之间的先行关系,而线程 C
出现在线程 A
和 B
的操作之间,但是C
与B
没有先行发生关系,那么j
的值会是多少?
答案是不确定,1 和 2 都有可能,因为线程 C
对变量 i
的影响可能被线程 B
观察到,也可能不会。
如果两个操作之间的关系不在下面的规则中,它们就没有顺序性保证,虚拟机可以对他们随意地重排序:
- 程序次序规则:在一个线程内,按照程控制流顺序,书写在前面的操作先行于书写在后面的操作。
- 管程锁定规则:一个
unlock
操作先行于后面对同一个锁的lock
操作。 volatile
变量规则:对一个volatile
变量的写操作先行发生于后面对这个变量的读操作。- 线程启动规则:
Thread
对象的start()
方法先行发生于此线程的每一个动作。 - 线程终止规则:线程中所有操作都先行发生于此线程的终止检测。
- 线程中断规则:对线程
interrupt()
方法的调用先行发生于被中断线程的代码检测到中断事件的发生。 - 对象终结规则:一个对象的初始化完成先行发生于它的
finalize()
方法的开始。 - 传递性:如果操作A先行发生于操作B,操作B先行发生于操作C,那就可以得出操作A先行发生于操作C。
时间先后顺序与先行发生原则之间没有因果关系,所以我们衡量并发安全我呢提的时候不要受时间顺序的干扰,一切必须以先行发生原则为准。
2. Java 与线程
2.1 线程的实现
在操作系统中我们学习中,线程是比进程更轻量级的调度执行单元。线程的引入,可以把一个进程的资源分配和执行调度分开,各个线程既可以共享进程资源(内存地址、文件I/0等),又可以独立调度(线程是CPU调度的基本单位)。
实现线程主要有三种方式:使用内核级线程实现(1:1实现),使用用户线程实现(1:N实现),使用用户线程加轻量级进程混合实现(N:M实现)。
2.1.1 内核线程实现
内核线程就是直接由操作系统内核支持的线程,这种线程由内核完成切换工作,内核通过操纵调度器(Thread Scheduler)对线程进行调度,并负责将线程的任务映射到各个处理器上。
程序一般直接使用轻量级进程(LWP)(即我们通常意义上所讲的线程)去和内核线程一对一调用。
在内核线程的支持下,每个轻量级进程都成为一个独立的调度单元,即使有一个轻量级进程在系统调用中阻塞,也不会影响整个个进程继续工作。
轻量级进程的局限性
由于基于内核线程实现,所以线程的创建、析构和同步,都需要进行系统调用。而系统调用的代价相对较高,需要在用户态和内核态来回切换。
每个轻量级进程都需要有一个内核线程的支持,因此轻量级进程要消耗一定的内核资源,所以轻量级进程的数量是有限的。
2.1.2 用户线程实现
广义上讲:一个线程只要不是内核线程,就可以认为是用户线程,所以轻量级进程也属于用户线程,但轻量级进程的实现始终建立在内核之上,需要操作都要进行系统调用,效率会受到限制,并不具备通常意义上的用户线程的优点。
狭义上讲:用户线程指的是完全建立在用户空间的线程库,系统内核不能感知到用户线程的存在及如何实现的。用户线程的建立、同步、销毁和调度完全在用户态中完成,不需要内核的帮助。如果程序实现得当,这种线程不需要切换到内核态,因此操作非常快速。
优势:不需要系统内核支援,操作快速且低消耗的。
劣势:没有系统内核的支持,所有线程操作都需要用户程序自己处理,需要考虑线程的创建、切换和调度。
2.1.3 混合实现
将内核线程与用户线程一起使用的实现方式,用户线程还是完全建立在用户空间中,因此用户线程的创建、切换、析构等操作依然廉价,并且支持大规模的用户线程并发。而操作系统所提供支持的轻量级进程则作为用户线程和内核线程之间的桥梁,可以使用内核提供的线程调度功能及处理器映射,同时用户线程的调用通过轻量级进程完成,大大降低了整个进程被完全阻塞的风险。
2.1.4 Java 线程的实现
JDK 1.3
起,主流平台上的主流商用Java
虚拟机的线程模型普遍都被替换为基于操作系统原生线程模型来实现,即采用1:1
的线程模型。
以HotSpot
为例,它的每一个Java
线程都是直接映射到一个操作i同原生线程来实现的,而且中间没有额外的间接结构,所以HotSpot
自己是不会去干涉线程调度的,全权交给操作系统去处理,所以何时冻结或唤醒线程、给线程分配多少处理器执行时间、该把线程安排给哪个处理器核心去执行等,都是由操作系统完成的,也都是由操作系统全权决定的。
2.2 Java 线程调度
系统为线程分配处理器使用权的方式分为两种:协同式线程调度和抢占式线程调度。
- 协同式线程调度
线程的执行时间由线程本身来控制,线程把自己的工作执行完之后,需要主动通知系统切换到另外一个线程上。
好处:
实现简单,线程要把自己的事情做完才会进行线程切换,切换操作对线程是可知的,所以没有线程同步问题。
坏处:
线程执行时间不可控制,如果一个线程不告知系统进行线程切换,那么程序就会一直阻塞在那里。
- 抢占式线程调度
每个线程由系统分配执行时间,线程的切换不由线程本身决定。
好处:
执行的执行时间式系统控制的,不会有一个线程导致整个进程阻塞的问题。
坏处:
需要进行同步控制,线程合作时。
2.3 状态转换
Java
语言定义了 6 中线程状态,在任意一个时间点中,一个线程只能有且只有其中的一种状态,并且可以通过特定的方法在不同状态之间转换。
public enum State {
NEW,
RUNNABLE,
BLOCKED,
WAITING,
TIMED_WAITING,
TERMINATED;
}
- 新建(
New
):创建后尚未启动的线程处于这种状态 - 运行(
Runnable
):包括操作系统线程状态中的Running
和Ready
,也就是处于此状态的线程有可能正在执行,也有可能正在等待着操作系统为它分配执行时间。 - 无限期等待(
Waiting
):处于这种状态的线程不会被分配处理器执行时间,它们要等待被其他线程显式唤醒。以下方法会让线程陷入无限期的等待状态:
- 没有设置
Timeout
参数的Object::wait()
方法; - 没有设置
Timeout
参数的Thread::join()
方法; LockSupport::park()
方法。
- 限期等待(
Timed Waiting
):处于这种状态的线程也不会被分配处理器执行时间,不过无须等待被其他线程显式唤醒,在一定时间之后它们会由系统自动唤醒。以下方法,会让线程进入限期等待状态:
Thread::sleep()
方法;- 设置了
Timeout
参数的Object::wait()
方法; - 设置了
Timeout
参数的Thread::join()
方法; LockSupport::parkNanos()
方法。LockSupport::parkUntil()
方法。
- 阻塞(
Blocked
):线程被阻塞了,“阻塞状态”与“等待状态”的区别是“阻塞状态”在等待获取到一个排他锁,这个是汉奸将在另外一个线程放弃这个锁的时候发生;而“等待状态”则是在等待一段时间,或者唤醒动作的发生。 - 结束(
Terminated
):已终止线程的线程状态,线程已经执行结束。