一线大厂架构师整理:java并发编程实践教程
并发编程是Java语言的重要特性之一, 在Java平台上提供了许多基本的并发功能来辅助开发多线程应用程序。然而,这些相对底层的并发功能与上层应用程序的并发语义之间并不存在一种简单而直观的映射关系。因此,如何在Java并发应用程序中正确且高效地使用这些功能就成了Java开发人员的关注重点。所以节课程将邀请一线大厂的Java架构师总结了一个java并发编程实践教程,欢迎一起来来阅读
教程目录
1、Java内存模型
2、MESI缓存一致性协议与缓存行
3、并发常见问题
4、volatile
5、锁机制
一、Java内存模型
内存模型
内存模型可以理解为在特定的操作协议(缓存一致性协议)下,对特定的内存和高速缓存进行读写访问的抽象。不同的物理机器,可能有着不同的“内存模型”。Java虚拟机中定义的内存模型可以屏蔽不同的硬件内存模型,这样就可以保证Java程序在各个平台都能达到一致的内存访问效果,也就是常说的一次编写到处运行,因为内存模型为我们屏蔽掉了不同硬件平台之间的差异。
CPU缓存
一级缓存和二级缓存:cpu各个core私有
三级缓存:cpu多core共享
CPU与内存交互流程
读取流程:寄存器——>Load Buffer——>高速缓存(一级缓存——>二级缓存——>三级缓存)——> 内存
写入流程:寄存器——>Store Buffer——>高速缓存(一级缓存——>二级缓存——>三级缓存)—— >内存
高速缓存和内存之间通过内存一致性协议(比如MESI协议)保证数据一致性(可见性) CPU和高速缓存直接通过Load Buffer(Store Buffer)减少堵塞时间,提高性能.
原子操作
Java内存模型为主内存和工作内存间的变量拷贝及同步定义8种原子性操作指令
lock(锁定):
作用于主内存的变量,把一个变量标识为一条线程独占状态。
unlock(解锁):
作用于主内存变量,把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定。
read(读取):
作用于主内存变量,把一个变量值从主内存传输到线程的工作内存中,以便随后的load动作使用。
load(载入):
作用于工作内存的变量,它把通过read操作从主内存中得到的变量值放入工作内存的变量副本中。
use(使用):
作用于工作内存的变量,把工作内存中的一个变量值传递给执行引擎,每当虚拟机遇到一个需要使用变量的值的字节码指令时将会执行这个操作。assign(赋值):作用于工作内存的变量它把一个从执行引擎接收到的值赋值给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作。
store(存储):
作用于工作内存的变量,把工作内存中的一个变量的值传送到主内存中,以便随后的write的操作使用。
write(写入):
作用于主内存的变量,它把通过store操作从工作内存中得到的变量的值放入主内存的变量中。
二、MESI缓存一致性协议与缓存行
MESI缓存一致性协议
MESI 是指4种状态的首字母。每个Cache line有4个状态,可用2个bit表示,它们分别是:
MESI状态转换
触发事件:
MESI状态迁移过程:
MESI性能优化
缓存行数据修改,传递Invalidate失效消息给其他cpu缓存,但需要等待Invalidate Acknowledge确认消息,cpu堵塞,降低性能。
cpu存储缓存store bufffferes用来解决堵塞问题。缓存行修改时,只需要写到store bufffferes中,传递Invalidate失效消息给其他cpu缓存,然后执行其他逻辑。
别的CPU收到Invalidate消息时,把这个操作加入Invalidate Queues无效队列,然后快速返回Invalidate Acknowledge消息,让发起者做后续操作
cpu收到Invalidate Acknowledge确认消息后,再把Store Bufffferes消息写回缓存,修改状态为(M)
其它CPU处理无效队列里的无效消息时,让缓存数据失效
其它CPU来取数据时,当前cpu将数据刷新到内存,状态变为S。
缓存行
每个缓存里面都是由缓存行(cache line)组成的。缓存行是2的整数幂个连续字节,一般为32-256个字节。最常见的缓存行大小是64个字节
伪共享问题
当多线程修改互相独立的变量时,如果这些变量共享同一个缓存行,就会无意中影响彼此的性能。出现在频繁修改的场景中。
Core1运行的线程想更新变量X,同时Core2的线程想要更新变量Y。这两个变量在同一个缓存行中。每个线程都要去竞争缓存行的所有权来更新变量。
如果Core1获得了所有权,缓存子系统将会使Core2中对应的缓存行失效。当Core2获得了所有权然后执行更新操作,Core1就要使自己对应的缓存行失效。这样来来回回的经过L3缓存,大大影响了性能。如果互相竞争的核心位于不同的插槽,就要额外横跨插槽连接,问题可能更加严重。
避免伪共享
解决伪共享最直接的方法就是填充(padding)。例如下面的VolatileLong,一个long占8个字节,Java的对象头占用8个字节(32位系统)或者12字节(64位系统,默认开启对象头压缩,不开启占16字节)。一个缓存行64字节,那么我们可以填充6个long(6*8=48 个字节)。这样就能避免多个VolatileLong共享缓存行。
public class VolatileLong {
private volatile long v;
// private long v0, v1, v2, v3, v4, v5 // 去掉注释,开启填充,避免缓存行共享
}
Java 8中引入了一个更加简单的解决方案:@Contended 注解:
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.FIELD, ElementType.TYPE})
public @interface Contended {
String value() default "";
}
Contended注解可以用于类型上和属性上,加上这个注解之后虚拟机会自动进行填充,从而避免伪共享。这个注解在Java8 ConcurrentHashMap、ForkJoinPool和Thread等类中都有应用。
JVM启动参数:-XX:-RestrictContended
三、并发常见问题
可见性
线程一使用flflag变量进行逻辑处理;线程二修改flflag变量。
线程一嗅探不到flflag的改变。
public class VisibilityDemo {
private static boolean flag = false;
public static void main(String[] args) throws InterruptedException {
new Thread(new Runnable() {
@Override
public void run() {
while (!flag) {
}
System.out.println(Thread.currentThread().getName() + " 线程嗅探到
flag的修改");
}
}).start();
Thread.sleep(2000);
new Thread(new Runnable() {
@Override
public void run() {
System.out.println(Thread.currentThread().getName() + "开始修改
flag");
flag = true;
System .out.println(Thread.currentThread().getName() + "将flag修
改为false");
}
}).start();
}
}
关于Thread.sleep(1000);的说明:
主线程中的Thread.sleep(1000);经过多次调整sleep时间大小(本地win10测试,1ms时偶尔无法正常结 束,大于1ms无法正常结束)
结论:
cpu读取数据流程:主内存--->工作内存(高速缓存)--->寄存器
while经过多次调用,返回一致的结果flag=false,cpu会进行优化,只取寄存器中的数据,不会再去调用 缓存和主内存。时间<=1ms,cpu会经过寄存器刷高速缓存和内存;时间>1ms,cpu只是访问寄存器。
有序性
public class OrderlyDemo {
private static int x = 0,y = 0;
private static int a = 0,b = 0;
private static int i = 0;
public static void main(String[] args) throws InterruptedException {
for (;;){
i++;
x = 0;y = 0;
a = 0;b = 0;
Thread thread1 = new Thread(new Runnable() {
@Override
public void run() {
waitTime(10000);
a = 1;
x = b;
}
});
Thread thread2 = new Thread(new Runnable() {
@Override
public void run() {
b = 1;
y = a;
}
});
thread1.start();
thread2.start();
thread1.join();
thread2.join();
System.out.println("第" + i + "次执行结果(" + x + "," + y + ")");
if (x == 0 && y == 0){
System.out.println("在第" + i + "次发生指令重排,(" + x + "," + y +
")");
break;
}
}
}
public static void waitTime(int time){
long start = System.nanoTime();
long end;
do {
end = System.nanoTime();
}while (start + time >= end);
}
}
原子性
执行5个线程,每个线程对number变量累加1000次。
import java.util.ArrayList;
import java.util.List;
public class AtomicityDemo {
private static int number = 0;
public static void main(String[] args) throws InterruptedException
{ List<Thread> list = new ArrayList<>();
for (int i=0; i<5; i++) {
Thread t = new Thread(new Runnable()
{ @Override
public void run() {
for (int i=0; i<1000; i++) { number++;
}
}
});
t.start();
list.add(t);
}
for (Thread t : list)
{ t.join();
}
System.out.println("number=" + number);
}
}
四、volatile
使用volatile关键字,可解决可见性和有序性问题。
程序修改
可见性
private volatile static boolean flag = false;
有序性
private volatile static int i = 0;
volatile指令查看
将下载的hsdis-amd64.dll工具包解压,复制到jdk安装目录的jre\bin\server目录中
配置idea,在 VM options 选项中输入
-server -Xcomp -XX:+UnlockDiagnosticVMOptions -XX:+PrintAssembly -
XX:CompileCommand=compileonly,*类名.方法名
volatile作用
volatile使用lock指令锁定缓存行,将缓存行的数据立即写回到主内存中。
volatile使用内存屏障禁止指令重排序来保证有序性
指令重排
指令重排序场景
编译器( JIT编译器)重排序。编译器在不改变单线程语义(as-if-serial)的前提下,重新安排语句的执行顺序。
as-if-serial语义:不管怎么重排序,单线程下,程序的执行结果不能被改变。编译和处 理器都必须遵循as-if-serial语义。如果操作之间没有数据依赖关系,就可以被重排序。
指令级并行的重排序。现代处理器采用了指令并行技术。如果不存在数据依赖性,处理器可 以改变语句对应机器指令的执行顺序。
内存系统的重排序。由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能 是乱序执行。(处理器的重排序)
内存屏障
内存屏障(Memory Barrier)与内存栅栏(Memory Fence)是同一个概念,不同的叫法。内存屏障是解决硬件层面的可见性与重排序问题。内存屏障是happen-before原则的实现。
指令 |
描述 |
Store |
将处理器缓存的数据刷新到内存中 |
Load |
将内存存储的数据拷贝到处理器的缓存中 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
StoreLoad Barriers同时具备其他三个屏障的效果,因此也称之为全能屏障(mfence),是目前大多数处理器所支持的;但是相对其他屏障,该屏障的开销相对昂贵。
x86架构cpu的内存屏障
x86架构并没有实现全部的内存屏障。Store Barrier
sfence
指令实现了Store Barrier,相当于StoreStore Barriers。
Load Barrier
lfence指令实现了Load Barrier,相当于LoadLoad Barriers。
Full Barrier
mfence指令实现了Full Barrier,相当于StoreLoad Barriers。
强制所有在mfence指令之前的store/load指令,都在该mfence指令执行之前被执行;所有在mfence指令之后的store/load指令,都在该mfence指令执行之后被执行。
volatile如何解决内存可见性与重排序问题
在编译器层面,仅将volatile作为标记使用,取消编译层面的缓存和重排序。
如果硬件架构本身已经保证了内存可见性(如单核处理器、一致性足够的内存模型等),那么volatile就是一个空标记,不会插入相关语义的内存屏障。
如果硬件架构本身不进行处理器重排序、有更强的重排序语义(能够分析多核间的数据依赖)、或 在单核处理器上重排序,那么volatile就是一个空标记,不会插入相关语义的内存屏障。
如果不保证,以x86架构为例,JVM对volatile变量的处理如下:
在写volatile变量v之后,插入一个sfence。这样,sfence之前的所有store(包括写v)不会 被重排序到sfence之后,sfence之后的所有store不会被重排序到sfence之前,禁用跨sfence 的store重排序;且sfence之前修改的值都会被写回缓存,并标记其他CPU中的缓存失效。
在读volatile变量v之前,插入一个lfence。这样,lfence之后的load(包括读v)不会被重排 序到lfence之前,lfence之前的load不会被重排序到lfence之后,禁用跨lfence的load重排 序;且lfence之后,会首先刷新无效缓存,从而得到最新的修改值,与sfence配合保证内存 可见性
在另外一些平台上,JVM使用mfence代替sfence与lfence,实现更强的语义。
DCL(双重检查加锁)
public class Singleton {
private static volatile Singleton instance;
private Singleton() {}
public static Singleton getInstance() {
if(instance==null) {
synchronized(Singleton.class) {
if(instance==null) {
instance = new Singleton();
}
}
}
return instance;
}
}
volatile作用:禁止instance = new Singleton()这条语句指令重排序,避免getInstance()方法第一个if判断为false,但instance实例没有初始化完成的问题。
instance = new Singleton()执行过程
在堆中分配对象内存
填充对象必要信息+具体数据初始化+末位填充
第二步和第三步可能重排序,导致instance引用不为null,但是实例没初始化完成。
happen-before原则
hb原则是对单线程/多线程环境下数据一致性进行的约束。hb规定了禁止编译器和处理器重排序的8大原则。
hb的含义 |
前面的操作对后面的操作是可见状态。 |
如果前后操作没关联,可直接重排序,不受约束。 |
如果前后有关联,重排序后结果不一致,就是不可见状态,不能重排序。 |
单线程hb原则:在同一线程中,写在前面的操作hb后面的操作锁的hb原则:同一个锁的unlock操作hb此锁的lock操作
volatile的hb原则:对volatile变量的写操作hb对此变量的任意操作hb传递性原则:A hb B,B hb C,那么A hb C
线程启动hb原则:同一线程的start方法 hb 此线程的其它方法
线程中断hb原则:对线程interrupt方法的调用 hb 被中断线程的检测到中断状态线程终结hb原则:线程所有操作 hb 线程的终止检测
对象创建hb原则:一个对象的初始化完成 hb finalize方法调用
相关阅读
1、
3、
给大家整理了一个系列的教程Java架构师系列的教程,包含了系统架构、Java相关、编码规范、消息队列、Maven、Nginx、Redis、MySQL、TomCat相关、Git等系列的电子书,回复关键词就可以下载哦
同时还有精彩教程就、更多视频+代码资料文档等你挖掘
回复关键词
Elasticsearch 分布式限流 消息队列 alibaba JVM性能调优
看更多精彩教程
喜欢本文,记得点击个在看,或者分享给朋友哦!