一、什么是并发编程?
近些年,我们的CPU、内存、I/O设备都在不断迭代,计算机技术进步迅速,但是由于CPU的发展速度远快于内存和I/O设备,导致了CPU常常处于等待状态,不能充分利用计算机的性能。而并发编程正是一种解决这个问题的方法,它能够让计算机多个任务同时执行,充分利用CPU的计算资源,提高程序的执行效率和响应速度。并且在处理大量并发请求的业务方面也具有重要的作用,能够提高系统的吞吐量和性能。
学习并发编程还可以帮助我们更好地理解多线程编程的本质和原理,提高我们的编程技能和代码质量。另外,并发编程也是当今软件开发行业的一个重要趋势,是每一位程序员必须要了解和掌握的技能之一。
二、并发编程要解决什么问题?
1、缓存导致的可见性问题
一个线程对共享变量的修改,另外一个线程能够立刻看到,我们称为可见性。
在单核时代,所有的线程都是在一颗CPU上执行,多个线程同时操作一个cpu中的变量,一个线程对缓存的写,对另外一个线程来说一定是可见的。
在多核时代,多个线程在不同的CPU上执行时,这些线程操作的是不同的CPU缓存。线程A在CPU-A上执行,线程B在CPU-B上执行,这时候线程A操作变量S时,对于线程B就不具备可见性了。
public class DemoOne {
private int x = 0;
public static void main(String[] args) throws InterruptedException {
DemoOne demoOne = new DemoOne();
Thread threadOne = new Thread(() -> {
demoOne.addx();
});
Thread threadTwo = new Thread(() -> {
demoOne.addx();
});
threadOne.start();
threadTwo.start();
threadOne.join();
threadTwo.join();
demoOne.soutX();
}
private void addx(){
int idx = 0;
while(idx++ < 10000) {
x += 1;
}
}
private void soutX(){
System.out.println(this.x);
}
}
print:14973
直观来看,两个线程都调用addx方法,最后返回结果x应该是20000,其实不然,假设两个线程同时执行,将x=0的读入各自的CPU中,同时加1,再将结果写回内存,我们会发现内存中x是1,而不是我们期望的2,这就是缓存导致的可见性问题。
2、线程切换带来的原子性问题
众所周知,我们的计算机是可以同时执行多个任务的,你可以一边听歌一边写代码,是因为计算机操作系统用来实现多任务调度的时间片轮转调度技术,操作系统给多个程序分配一个时间片,时间片通常为几十毫秒或几百毫秒,每个时间片执行完后,操作系统会将当前程序挂起,同时调度另外一个程序运行,由于时间片很短,因此用户感受不到多个程序之间的切换。这同时也给我们带来了原子性问题。
public class DemoOne {
private volatile int x = 0;
public static void main(String[] args) throws InterruptedException {
DemoOne demoOne = new DemoOne();
Thread threadOne = new Thread(() -> {
demoOne.addx();
});
Thread threadTwo = new Thread(() -> {
demoOne.addx();
});
threadOne.start();
threadTwo.start();
threadOne.join();
threadTwo.join();
demoOne.soutX();
}
private void addx(){
int idx = 0;
while(idx++ < 10000) {
x += 1;
}
}
private void soutX(){
System.out.println(this.x);
}
}
print:18944
上述代码和缓存导致的可见性问题的示例代码基本一致,对x变量使用volatile关键字修饰,禁用cpu缓存,执行会发现结果仍不是我们期望的20000,就是因为当一个线程在执行一个操作时,如果在操作还没有完成时,该线程被切换到其他线程,而其他线程对于同一个操作也进行了修改,则会导致原子性问题。
3、编译优化带来的有序性问题
有序性。顾名思义,有序性指的是程序按照代码的先后顺序执行。
编译器为了优化性能,有时候会改变程序中语句的先后顺序,例如程序中:“a=1;b=2;”编译器优化后可能变成“b=2;a=1;”,有时候编译器及解释器的优化可能导致意想不到的Bug。
public class DemoThree {
private static boolean flag = false;
private static int value = 0;
public static void main(String[] args) throws InterruptedException {
Thread one = new Thread(() -> {
value = 1;
flag = true;
});
Thread two = new Thread(() -> {
while (!flag) {
// 如果flag为false,线程一可能还没启动完毕,所以让出CPU时间片
Thread.yield();
}
// 由于volatile可以保证可见性,所以这里flag肯定是true,value肯定为1;但编译器可能会对代码进行指令重排序,从而导致出现异常结果
if (value == 0) {
System.out.println("发生了乘0异常");
}
});
one.start();
two.start();
one.join();
two.join();
}
}
在主线程中,创建了两个线程,线程one先执行,将value变量赋值为1,然后将flag标志位设为true,线程two执行的时候,不断循环直到flag变量为true,在循环结束后判断value变量是否为0。在这个示例中,如果编译器对代码进行了优化,可能会将执行flag变量改为true和执行value变量赋值的这两步操作互换顺序,这样在线程two中判断value的值时可能仍然为0,从而造成一个未预期的异常结果。
总结
以上总结了并发编程及其要解决的问题,提到缓存、线程、编译优化,其实它们的目的和我们写并发程序的目的是相同的,都是提高程序性能。但是技术在解决一个问题的同时,必然会带来另外一个问题,所以在采用一项技术的同时,一定要清楚它带来的问题是什么,以及如何规避。