多线程可以充分的利用CPU硬件来给程序带来更高的处理效率和性能,但是在多线程运用过程中也会带来一些并发问题---可见性、原子性、有序性
可见性
可见性问题是并发问题的一种,由于可见性而造成的错误往往是违背我们的直觉的,因此在遇到这种问题的时候 我们很难发现。所谓可见性问题,就是在使用多线程过程中,一个线程修改资源对另外的一个线程不可见
为了更好的理解可见性问题,我们先来看一段程序
public class NoVisibility {
public static boolean flag = false;
public static void main(String[] args) {
Thread thread = new Thread(() -> {
while (!flag){
}
System.out.println("========子线程执行完毕");
}) ;
thread.start();
try {
Thread.sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();
}
flag = true;
System.out.println("======主线程执行完毕");
}
}
这段程序相当于是启动了两个线程,一个主线程和一个子线程,子线程是处于一个循环的状态,按正常来讲,当主线程把 flag 改为 true 的时候,子线程应该会退出循环,但是实际上并不会,而是会一直循环下去,我们可以看下运行结果
主线程虽然把 flag 变量改为了 true ,但是因为对于子线程来说这种修改是不可见的,因此子线程会一直循环下去
为什么会造成这样的问题呢,这就需要来了解下CPU架构了
到了现在为止,CPU一般都会有多个核,像我们的电脑,现在比较常见的配置应该是4核8G了,这个CPU的核数就是代表同一时刻可以处理多少个线程,比如4核就是同一时刻可以处理4个线程,CPU的每个核都会有各自的缓存,一共有三级,分别是L1、L2、L3,我们可以打开电脑的任务管理器就可以看得到三个缓存的大小
对应的CPU架构如图所示
而我们的可见性问题正是由于这种CPU缓存造成的,为什么会这么说呢,我们随着程序的执行来看下CPU是怎么执行的
当两个线程同时启动的时候,CPU中的其中两个核就会开始运作起来,子线程在读取变量 flag 的时候,首先会判断自己的缓存中有没有 flag 变量,如果没有的话,就会从内存中将 flag=false 读到缓存中,然后再去做判断,而主线程同样也会判断自己的缓存中有没有 flag 变量,如果没有的话也会从内存中读取,如图所示
主线程把 flag 变量修改为 true 之后,首先会修改自己缓 存中的数据,然后再将自己修改后的数据同步到内存中,如果此时子线程不从内存中再一次读取的话,那么子线程缓存中的值永远都是失效的值,而我们在上面使用的while循环中,如果不在循环体中做任何操作的话,那么子线程永远不会从内存中读取最新的数据,因此子线程就看不到主线程修改的 flag 变量,如图所示
那么如何解决可见性问题呢,我们可以通过对 flag 变量加 volatile 关键字来修饰
public static volatile boolean flag = false;
加完之后,我们再来看看运行的结果
主线程在改完 flag 变量后,子线程立马就退出了循环
原子性
所谓原子性问题,就是当程序执行的时候,其运行结果不符合我们的预期,原子性问题也是往往违背我们直觉的
为了更好的说明原子性问题,我们先来看一段程序
public class Atom {
public static int a = 0;
public static void main(String[] args) {
for (int i = 0; i < 100000; i++) {
Thread thread = new Thread(() -> {
a++;
});
thread.start();
}
try {
Thread.sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(a);
}
}
在上面的程序中,我启动了 100000 个线程对变量 a 做自加操作,正常来讲 a 变量输出的值是 100000 ,但是实际会大于 100000 或者小于 100000 ,不会等于 100000
为什么会造成这个问题呢,答案是线程间的切换
我们的操作系统在对进程调度是采用分时复用的方式来进行调度的,操作系统允许某个进程执行一小段时间,例如 50 毫秒,过了 50 毫秒操作系统就会重新选择一个进程来执行,这个 50 毫秒称为“时间片”。
上面程序中的线程对于 a 变量的读写,我们可以归结为以下的过程:对 a 变量的读取 --> 对 a 变量的修改 --> 对 a 变量的写入
假设此时有两个线程执行了这个过程,那么由于线程切换就会有如图所示的执行过程
可以看到正是由于这种线程切换导致的运算结果不符合预期
如何解决呢,对于原子性这种问题我们可以使用加锁的方式来解决,为什么不用 volatile 呢,这是因为 volatile 虽然可以解决可见性问题,但是无法解决原子性问题
在java中我们可以使用 synchronized 内置锁来锁住 a++ 这块代码,这样就能保证同一时刻只有一个线程来修改 a 变量,这种我们称为 "互斥"
synchronized (Atom.class){
a++;
}
除了这种内置锁之外,还可以使用JUC并发包里的工具类,比如针对变量自加的线程安全类 AtomicInteger ,还有JUC包中的锁Lock等
有序性
这里的有序性,是指程序按照代码执行的顺序,我们的程序最终执行的时候可能不是按照我们写的代码顺序来执行的,我们的编译器为了优化性能会将程序代码的顺序做调整,比如在程序中定义变量 “a=7; b=9;”,经过编译器优化后就会变成“b=9 ; a=7;” ,虽然这种顺序的调整在最终的执行过程中并不会影响结果,但是有些时候可能会有产生意想不到的问题
在java中有一个经典的双重检查创建单例对象的案例,如下代码
public class DoubleCheckedLocking {
private static DoubleCheckedLocking instance;
static DoubleCheckedLocking getInstance(){
if (instance == null) {
synchronized(DoubleCheckedLocking.class) {
if (instance == null){
instance = new DoubleCheckedLocking();
}
}
}
return instance;
}
}
在通过 getInstance 方法获取 DoubleCheckedLocking 实例的时候首先会判断 instance 变量是否为空,如果是空则在通过加锁的方式来初始化 DoubleCheckedLocking ,这段程序虽然看起来是非常的完美,但其实并不是那么完美
对于 new Singleton 这行代码来说,它在执行的时候主要有以下过程:
1、分配一块内存M
2、在内存M上初始化 DoubleCheckedLocking 对象
3、然后M的地址赋值给instance 变量
如果编译器把这个过程优化成了这样:
1、分配一块内存M
2、然后M的地址赋值给instance 变量
3、在内存M上初始化 DoubleCheckedLocking 对象
这种优化最终会导致什么问题呢,假如A线程 new DoubleCheckedLocking 在执行完第二个步骤之后,发生了线程切换,切换到B线程,B线程调用 getInstance 方法判断 instance 变量就不会为空了,那么此时线程B获取到的是一个还未被初始化的对象,当要去使用这个对象里的变量的时候,就有可能引发空指针异常
那么如果解决这种重排的问题呢,在上面的讲的 volatile 关键字除了可以保证可见性之外,还可以禁止指令重排,因此我们可以加一个 volatile 关键字来修饰 instance 变量就行了
private volatile static DoubleCheckedLocking instance;
Java内存模型
Java内存模型跟我们所说的JVM内存模式是完全不一样的,这里需要做一下区分,Java内存模型是一个抽象的概念,也是一种规范
为什么会有Java内存模型呢
在上面我们讲了CPU的架构及CPU是如何操作一个数据的,但是每种CPU架构的设计是不一样的,JVM为了屏蔽这种差异,因此就提出了Java内存模型
Java内存模型是通过各种操作来定义的,包括对变量的读/写操作,监视器的加锁和释放操作,以及线程的自动和合并操作。JMM为程序中所有的操作定义了一个关系,称之为 Happens-Before。要想保证执行操作B的线程看到操作A的结果,那么在A和B之间必须满足 Happens-Before 关系。如果两个操作之前缺乏Happens-Before关系,那么JVM就可以对它们任意地重排序
Happens-Before规则包括:
程序顺序规则,如果程序中操作A在操作B之前,那么在线程中A操作讲在B操作之前执行。
监视器锁规则,在监视器锁上的解锁操作必须在同一个监视器锁上的加锁操作之前执行
volatile变量规则,对volatile变量的写入操作必须在对该变量的读操作之前执行
线程启动规则,在线程上对Thread.start的调用必须在该线程中执行任何操作之前执行
线程结束规则,线程中的任何操作都必须在其他线程检测到该线程已经结束之前执行,或者从Thread.join中成功返回,或者在调用Thread.isAlive时返回false
中断规则,当一个线程在另一个线程上调用interrupt时,必须在被中断线程检测到interrupt调用之前执行
终结器规则,对象的构造函数必须在启动该对象的终结器之前执行完成
传递性,如果操作A在操作B之前执行,并且操作B在操作C之前执行,那么操作A必须在操作C之前执行
总结就一句话,要像写好并发程序并保证程序不出任何问题,就需要满足以上规则