并发编程
多线程
在同一段时间内一台计算机的内部有多个线程正在运行,一台计算机一次可以处理多个任务。
多线程的优点
- 提高CPU的利用率:计算机中一个任务的执行不是一直都会使用CPU,也有可能在任务执行的过程中出现缺少资源的情况,这时CPU不会继续执行任务,而是会等待获取到资源之后才会继续往下执行,CPU在等待资源的过程中如果没有其他的任务需要执行,这时CPU就会处于一种忙等的状态,而如果此时计算机内还存在其他需要执行的任务,这时CPU就不会在那里干等着,而是会执行其他需要执行的任务,从而提高CPU的利用率。
多线程的缺点
- 线程安全问题:当多个线程访问计算机中的同一资源时可能会出现数据不一致的问题
- 线程调度问题:当多个线程都需要访问CPU的权限时,可能会出现某些线程长时间不被访问出现的线程饥饿问题
- 上下文切换开销较大:计算机在运行多个线程时为了公平性以及不被人所察觉程序运行的卡顿,因此计算机通常会采用时间片轮转的方式不停地切换CPU的执行权限,并且由于轮转的速度很快从而增加了上下文切换的开销
并行与并发
并行
同一时刻多个任务同时执行
并发
同一时间段内多个任务轮流执行
如今的计算机中并行和并发是同时执行的,如果计算机中只有一个CPU并且存在多个线程,那么计算机中只会存在并发,但是当计算机中存在多个CPU时,由于一个CPU在同一时刻只能执行一个任务,但是多个CPU在同一时刻就可以执行多个任务,于是并行和并发也就同时存在。
Java内存模型
计算机内存模型是为了解决计算机在多线程环境下可见性、原子性、有序性问题,从而提出的针对程序访问及修改数据的一种规范。而Java内存模型也是一种规范,它提出的规范也同样解决了多线程环境下的问题。
JVM的主内存与工作内存
Java内存模型中规定所有的变量都存放在主内存当中,当存在线程对变量进行操作时,会将变量进行复制,变为变量副本存储在工作内存当中,从而对工作内存中的变量副本进行操作,每个线程的工作内存是独立的。
并发线程核心问题
可见性
一个线程对共享变量进行修改后,其他线程可以立即得知这一操作或者这一操作的结果。其产生的原因是因为计算机中cpu的速度越来越快,而主存(内存)的速度跟不上cpu的速度,于是出现了缓存(高速缓冲存储器),位于cpu和主存之间,cpu与缓存进行交互将修改数据的结果存入缓存当中,再经过一段时间统一存入主存当中。一个cpu拥有的缓存是独立的,不能访问其他cpu的缓存,如果多个cpu访问同一变量,就可能会因为缓存的更新不及时而导致可见性问题。
public class BTest {
public static boolean flag = false;
public static void main(String[] args) throws IOException {
Thread thread1 = new Thread(() -> {
System.out.println("thread1 start...");
try {
Thread.sleep(120);
} catch (InterruptedException e) {
e.printStackTrace();
}
flag = true;
System.out.println("thread1 end...");
});
Thread thread2 = new Thread(() -> {
System.out.println("thread2 start...");
while (!flag) {
}
System.out.println("thread2 end...");
});
thread1.start();
thread2.start();
}
}
上面的代码运行的结果就是thread2线程永远都不会停下来,其原因就是thread2线程没有感知到thread1线程中的flag变量已经被修改。
要解决上面出现的问题,只需要给flag变量添加volatile修饰即可。
在单线程环境下不存在可见性问题,因为单线程环境下计算机只有一个cpu和缓存,其中的数据都是可见的。
有序性
在多线程环境下代码的执行顺序可能被编译器或者处理器进行指令重排,导致代码执行顺序与预期有所不同,而导致程序运行的结果与预期有所不同,从而导致有序性问题的出现。
public class Main {
private static boolean flag = false;// volatile
private static int x = 0;// volatile
public static void main(String[] args) {
Thread thread1 = new Thread(() -> {
x = 1;// 第一行
flag = true;// 第二行
});
Thread thread2 = new Thread(() -> {
while (!flag) {
// 等待flag变为true
}
System.out.println("x = " + x);
});
thread1.start();
thread2.start();
}
}
正常情况下不论是thread1先执行还是thread2先执行,程序都会输出1,假设第一行和第二行发生了指令重排,那么在多线程环境下,当执行到flag = true时时间片结束,此时由于x还没有来得及赋值,所以程序可能会输出0,此时就出现了有序性问题。
public class Singleton {
static Singleton instance;// volatile
static Singleton getInstance(){
if (instance == null) {
synchronized(Singleton.class) {
if (instance == null)
instance = new Singleton();
}
}
return instance;
}
}
正常情况下在synchronized代码块外层再添加一层判断可以在instance不为null的情况下提高代码的执行速度,但是如果在创建对象的时候发生了指令重排,instance对象还没有来得及初始化,但是其地址已经进行了赋值,此时instance不为null,这样其他线程在调用getInstance()方法时返回的对象可能是一个还未初始化完成的对象,从而出现问题。
指令重排发生的条件
编译器和处理器为了程序运行的速度更块,需要满足在单线程的条件下对指令的重排不会出现问题,但是在多线程的条件下就有可能出现问题,就比如上面提到的两个例子,当然指令重排也会存在cpu层面的重排。
原子性
在多线程环境下对共享资源的操作需要满足原子性,既要么全部执行,要么全部不执行,如果对共享资源的操作只执行一部分,就有可能导致最后计算的结果出现问题。
count = 0;
count++;
count = 0;// 第一步 获取count的值
count = count + 1;// 第二步 对count进行操作
count = 1;// 第三步 将结果进行覆盖
假设存在两个线程都需要进行count++操作,thread1执行到第一步之后时间片结束,thread2执行完此时count的值为1,然后thread1再执行最后的结果仍然为1,而正确的结果应该为2。
需要设置volatile并且加锁解决问题,如果只是单纯的加锁,那么如果两个线程不是在同一个cpu中运行,由于cpu缓存是独立的,就有可能导致多线程下的可见性问题。
并发线程核心问题的解决
可见性
Java中使用volatile关键字可以确保在多线程环境下共享数据的可见性,其原理是在对数据进行读操作时会先去主存当中读取数据,将数据读取到缓存当中,再从缓存中读取数据,写操作也是一样,会直接将数据的结果写入到缓存当中区,在从缓存将数据写入到主存当中,这种操作相当于牺牲了一部分缓存来换取共享数据的可见性。
有序性
Java中的volatile关键字可以禁止编译器的指令重排,但是不能完全禁止处理器的指令重排,编译器不会将使用volatile关键字修饰的变量的前后进行指令重排。
原子性
Java中可以使用同步机制(锁)+volatile解决大部分的多线程情况下的原子性问题
volatile关键字
指令重排在Java层面(Java字节码)没有重排
Hotspot(Java虚拟机)中gcc编译阶段进行了重排(JIT即时编译:代码优化的一种解决方案)
-
优化无效代码
-
进行简单运算
-
指令重排
cpu在进行乱序执行时需要满足一个语义:as-if-serial语义,意思是cpu在进行乱序执行后的结果要与单线程下运行的结果相同。
在超高并发情况下才会出现指令重排,有关cpu切换的问题
屏障:不想让代码执行本该执行的样子
编译屏障
编译屏障是编译器以及处理器主动使用的,当一段重排后的代码不满足as-if-serial语义时,编译器会主动添加内存屏障防止代码进行重排。
编译时防止某段代码被优化掉而添加的屏障,存在一段代码可能是无效的,但是却充当着内存屏障的作用,我们不希望在执行时被优化掉,于是添加编译屏障。
内存屏障
内存屏障是和volatile关键字相关的,当使用volatile关键字修饰变量时,Java中的编译器和虚拟机会添加内存屏障,用于实现指令重排的禁止以及强制读写数据。