Java并发
文章目录
堆的概念
堆是被所有线程所共享的资源,有虚拟机启动时创建,此内存其余的唯一目的就是存放对象的实例,Java中几乎所有的对象实例都在这里分配内存。
方法区与堆一样,也是各个线程共享的一块内存区域。它用于存储已被虚拟机加载的类型信息、常量、静态变量即时编译器编译后的代码缓存等数据。
并发的难点
- 原子性
- 可见性
- 有序性
原子性:
一个不可再被分割的颗粒。原子性指的是一个或多个操作要么全部执行成功要么全部执行失败。
- 操作系统做任务切换,可以发生在任何一条CPU指令执行完成后
- CPU能保证的原子操作是指令级别的,而不是高级语言的操作符
比如:n++不是原子的,底层是多条指令执行的,原子性得不到保障。
可见性:
当多个线程访问同一个变量时,如果其中一个线程对其作了修改,其他线程能立即获取到最新的值。
-
栈是线程隔离的,一般共享资源都是说的堆中的数据,当线程对变量进行修改的时候,并不能保证其他线程可以立刻获得到新的值。
-
线程内部的局部变量不需要做并发处理,因为是线程私有的变量。
有序性:
- 在执行程序时,为了提高性能通常编译器和处理器会对指令进行重排
- 重排序不会影响单线程的执行结果,但是在并发对的情况下,可能会出现bug
JMM
并发编程的关键目标
在并发编程中,需要处理两个关键问题:线程之间如何通信及线程之间如何同步(这里的线程是指并发执行的活动实体)。
- 通信是指线程之间以何种机制来交换信息。
- 同步是指程序中用于控制不同线程之间的操作发生的相对顺序的机制
在命令式编程中,线程之间的通信机制有两种:共享内存和消息传递。
Java用的是前者共享内存。
并发编程的内存模型
- 在共享内存的并发模型里,线程之间共享程序的公共状态,通过写-读内存中的公共状态 进行隐式通信。
- 在共享内存并发模型里,同步是显式进行的,程序员必须显式指定某个方法或某段代码需要在线程之间互斥执行。
内存模型JMM
Java线程之间的通信由Java内存模型(本文简称为JMM)控制,**JMM决定一个线程对共享变量的写入何时对另一个线程可见。**从抽象的角度来看,JMM定义了线程和主内存之间的抽象关系,通过控制主内存与每个本地内存之间的交互,来保证可见性。
线程之间的共享变量存储在主内存(Main Memory)中,每个线程都有一个私有的本地内存(Local Memory),本地内存中存储了该线程以读/写共享变量的副本。本地内存是JMM的 一个抽象概念,并不真实存在。它涵盖了缓存、写缓冲区、寄存器以及其他的硬件和编译器优化。
如果线程A与线程B之间要通信的话,必须要经历下面2个步骤。
- 线程A把本地内存A中更新过的共享变量刷新到主内存中去。
- 线程B到主内存中去读取线程A之前已更新过的共享变量。
源代码和指令间的重排
在执行程序时,为了提高性能,编译器和处理器常常会对指令做重排序。重排序分3种类型。其中后两种都是处理器重排,这些重排可能会导致多线程程序出现内存可见性的问题。
- 编译器优化的重排序。编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
- 指令级并行的重排序。现代处理器采用了指令级并行技术(Instruction-Level Parallelism,ILP)来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应 机器指令的执行顺序。
- 内存系统的重排序。由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。
处理器的执行循序是A1->A2,内存操作实际发生的顺序却是A2→A1。由于写缓冲区仅对自己的处理器可见,它会导致处理器执行内存操作的顺序可能与实际操作的顺序不一致,这就导致了错误的发生。
假设处理器A和处理器B按程序的顺序并行执行内存访问,最终可能得到x=y=0的结果,这是因为这里处理器A和处理器B可以同时把共享变量写入自己的写缓冲区(A1,B1),然后从内存中读取另一个共享变量(A2,B2),最后才把自己写缓存区中保存的脏数据刷新到内存中(A3, B3)。当以这种时序执行时,程序就可以得到x=y=0的结果。
如何解决重排序带来的问题
通过内存屏障指令来禁止特定类型的处理器重排序。
JMM的编译器重排序规则会禁止特定类型的编译器重排 序(不是所有的编译器重排序都要禁止)。对于处理器重排序,JMM的处理器重排序规则会要 求Java编译器在生成指令序列时,插入特定类型的内存屏障(Memory Barriers,Intel称之为 Memory Fence)指令,通过内存屏障指令来禁止特定类型的处理器重排序。
JMM保证了可见性
JMM把内存屏障指令分为4类:
StoreLoad Barriers是一个“全能型”的屏障,它同时具有其他3个屏障的效果。现代的多处 理器大多支持该屏障(其他类型的屏障不一定被所有处理器支持)。执行该屏障开销会很昂 贵,因为当前处理器通常要把写缓冲区中的数据全部刷新到内存中(Buffer Fully Flush)。
//内存屏障,禁止load操作重排序。屏障前的load操作不能被重排序到屏障后,屏障后的load操作不能被重排序到屏障前
public native void loadFence();
//内存屏障,禁止store操作重排序。屏障前的store操作不能被重排序到屏障后,屏障后的store操作不能被重排序到屏障前
public native void storeFence();
//内存屏障,禁止load、store操作重排序
public native void fullFence();
happens-before
JMM使用happens-before的概念来阐述操作之间的内存可见性。在JMM中,如果一 个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须要存在happens-before关系。这里提到的两个操作既可以是在一个线程之内,也可以是在不同线程之间。换句话来说如果A happens-before B,则意味着A的执行结果必须对B可见,也就是保证了跨线程的可见性。
与程序员密切相关的happens-before规则如下。
- 程序顺序规则:一个线程中的每个操作,happens-before于该线程中的任意后续操作。
- 监视器锁规则:对一个锁的解锁,happens-before于随后对这个锁的加锁。
- volatile变量规则:对一个volatile域的写,happens-before于任意后续对这个volatile域的读。
- 传递性:如果A happens-before B,且B happens-before C,那么A happens-before C。
注意:
两个操作之间具有happens-before关系,并不意味着前一个操作必须要在后一个 操作之前执行!happens-before仅仅要求前一个操作(执行的结果)对后一个操作可见,且前一 个操作按顺序排在第二个操作之前(the first is visible to and ordered before the second)。
class ReorderExample {
int a = 0;
boolean flag = false;
public void writer() {
a = 1; // 1
flag = true; // 2
}
Public void reader() {
if (f?lag) { // 3
int i = a * a; // 4
……
}
}
}
flag变量是个标记,用来标识变量a是否已被写入。
这里假设有两个线程A和B,A首先执行 writer()方法,随后B线程接着执行reader()方法。
线程B在执行操作4时,能否看到线程A在操作1对共享变量a的写入呢? 答案是:不一定能看到。
操作1和操作2做了重排序。程序执行时,线程A首先写标记变量flag,随后线程B读这个变量。由于条件判断为真,线程B将读取变量a。此时,变量a还没有被线程A写入,在 这里多线程程序的语义被重排序破坏了!
程序顺序规则
double pi = 3.14; // A
double r = 1.0; // B
double area = pi * r * r; // C
1)A happens-before B。
2)B happens-before C。
3)A happens-before C。
这里的第3个happens-before关系,是根据happens-before的传递性推导出来的。 这里A happens-before B,但实际执行时B却可以排在A之前执行(看上面的重排序后的执行顺序)。如果A happens-before B,JMM并不要求A一定要在B之前执行。JMM仅仅要求前一个 操作(执行的结果)对后一个操作可见,且前一个操作按顺序排在第二个操作之前。这里操作A 的执行结果不需要对操作B可见;而且重排序操作A和操作B后的执行结果,与操作A和操作B 按happens-before顺序执行的结果一致。在这种情况下,JMM会认为这种重排序并不非法(not illegal),JMM允许这种重排序。
Volatite
理解volatile特性的一个好方法是把对volatile变量的单个读/写,看成是使用同一个锁对这些单个读/写操作做了同步
volatile变量自身具有的特性
- 可见性。对一个volatile变量的读,总是能看到(任意线程)对这个volatile变量最后的写入。
- 原子性:对任意单个volatile变量的读/写具有原子性,但类似于volatile++这种复合操作不具有原子
volatile的内存语义
- 当写一个volatile变量时,JMM会把该线程对应的本地内存中的共享变量值刷新到主内存。
- 当读一个volatile变量时,JMM会把该线程对应的本地内存置为无效。线程接下来将从主 内存中读取共享变量
如果我们把volatile写和volatile读两个步骤综合起来看的话,在读线程B读一个volatile变量后,写线程A在写这个volatile变量之前所有可见的共享变量的值都将立即变得对读线程B可见。
线程A写一个volatile变量,实质上是线程A向接下来将要读这个volatile变量的某个线程发出了(其对共享变量所做修改的)消息。
线程B读一个volatile变量,实质上是线程B接收了之前某个线程发出的(在写这个volatile 变量之前对共享变量所做修改的)消息。
线程A写一个volatile变量,随后线程B读这个volatile变量,这个过程实质上是线程A通过主内存向线程B发送消息
volatile的实现机制
为了实现volatile的内存语义,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。
对于编译器来说,发现一个最优布置来最小化插入屏障的总数几乎不可能。为此,JMM采取保守策略。下面是基于保守策略的JMM内存屏障插入策略。
- 在每个volatile写操作的前面插入一个StoreStore屏障。
- 在每个volatile写操作的后面插入一个StoreLoad屏障。
- 在每个volatile读操作的后面插入一个LoadLoad屏障。
- 在每个volatile读操作的后面插入一个LoadStore屏障。
class VolatileBarrierExample {
int a;
volatile int v1 = 1;
volatile int v2 = 2;
void readAndWrite() {
int i = v1; // 第一个volatile读
int j = v2; // 第二个volatile读
a = i + j; // 普通写
v1 = i + 1; // 第一个volatile写
v2 = j * 2; // 第二个 volatile写
}
… // 其他方法
}
volatile仅仅保证对单个volatile变量的读/写具有原子性,而锁的互斥执行的特性可以 确保对整个临界区代码的执行具有原子性。在功能上,锁比volatile更强大;在可伸缩性和执行 性能上,volatile更有优势。
锁
锁是Java并发编程中最重要的同步机制。锁除了让临界区互斥执行外,还可以让释放锁的线程向获取同一个锁的线程发送消息。
锁的内存语义
- 当线程释放锁时,JMM会把该线程对应的本地内存中的共享变量刷新到主内存中。
- 当线程获取锁时,JMM会把该线程对应的本地内存置为无效。从而使得被监视器保护的临界区代码必须从主内存中读取共享变量。
对比锁释放-获取的内存语义与volatile写-读的内存语义可以看出:锁释放与volatile写有 相同的内存语义;锁获取与volatile读有相同的内存语义。
下面对锁释放和锁获取的内存语义做个总结。
- 线程A释放一个锁,实质上是线程A向接下来将要获取这个锁的某个线程发出了(线程A 对共享变量所做修改的)消息。
- 线程B获取一个锁,实质上是线程B接收了之前某个线程发出的(在释放这个锁之前对共 享变量所做修改的)消息。
- 线程A释放锁,随后线程B获取这个锁,这个过程实质上是线程A通过主内存向线程B发送消息。
锁内存语义的实现
- synchronized:采用CAS+Mark Word实现,存在锁升级的情况
- Lock:采用CAS+volatile实现,存在锁降级的情况,核心是AQS
程A释放一个锁,实质上是线程A向接下来将要获取这个锁的某个线程发出了(线程A 对共享变量所做修改的)消息。
- 线程B获取一个锁,实质上是线程B接收了之前某个线程发出的(在释放这个锁之前对共 享变量所做修改的)消息。
- 线程A释放锁,随后线程B获取这个锁,这个过程实质上是线程A通过主内存向线程B发送消息。
锁内存语义的实现
- synchronized:采用CAS+Mark Word实现,存在锁升级的情况
- Lock:采用CAS+volatile实现,存在锁降级的情况,核心是AQS
程序加入锁后:可见性和有序性保证的同时,原子性也能保证。