并发和高并发
并发包含了高并发,并发有可能发生在单个机器上,也有可能发生在分布式的场景下,发生在分布式场景下的一般就是高并发了。Java并发包下的java.util.concurrent的类都是解决在单机情况下的并发问题的,我们常见和常用的类包括:AtomicInteger、AtomicLong、ReentrantLock、ConcurrentHashMap以及ExecutorService等,这种问题相对简单,而我们在业务实现的时候大部分关注的是高并发的场景,即请求在有限的资源的前提下请求打在多台机器上的场景。应对高并发的已经不是一个类、一个技术甚至一种语言的问题了,高并发需要从网络、前端、后端、运维各个方面进行保障。这篇文章是前边架构系列文章的延伸,后续将讨论高并发场景下的具体应用,本文的重点梳理清楚并发和高并发的区别。
划重点:以下内容大部分来自这两篇知乎文章,大家尽量阅读原文
https://zhuanlan.zhihu.com/p/64988344
https://zhuanlan.zhihu.com/p/34805082
单机并发
这篇文章解释了单机并发的根源所在,concurrent并发包是如何解决这些问题的,可以自行研究源码。
问题根源之一:缓存导致的可见性问题
CPU的执行操作数据的过程一般是这样的,CPU首先会从内存把数据拷贝到CPU缓存区。
然后CPU再对缓存里面的数据进行更新等操作,最后CPU把缓存区里面的数据更新到内存。
磁盘、内存、CPU缓存会按如下形式协作。
缓存导致的可见性问题就是指我们在操作CPU缓存过程中,由于多个CPU缓存之间独立不可见的特性,导致共享变量的操作结果无法预期。
在单核CPU时代,因为只有一个核心控制器,所以只会有一个CPU缓存区,这时各个线程访问的CPU缓存也都是同一个,在这种情况一个线程把共享变量更新到CPU缓存后另外一个线程是可以马上看见的,因为他们操作的是同一个缓存,所以他们操作后的结果不存在可见性问题。
而随着CPU的发展,CPU逐渐发展成了多核,CPU可以同时使用多个核心控制器执行线程任务,当然CPU处理同时处理线程任务的速度也越来越快了,但随之也产生了一个问题,多核CPU每个核心控制器工作的时候都会有自己独立的CPU缓存,每个核心控制器都执行任务的时候都是操作的自己的CPU缓存,CPU1与CPU2它们之间的缓存是相互不可见的。
这种情况下多个线程操作共享变量就因为缓存不可见而带来问题,多线程的情况下线程并不一定是在同一个CUP上执行,它们如果同时操作一个共享变量,但因为在不同的CPU执行所以他们只能查看和更新自己CPU缓存里的变量值,线程各自的执行结果对于别的线程来说是不可见的,所以在并发的情况下会因为这种缓存不可见的情况会导致问题出现。
比如下面的程序:
两个线程同时调用addNumber() 方法对number属性进行+1 ,循环10W次,等两个线程执行结束后,我们的预期结果number的值应该是20000,可是我们在多核CPU的环境下执行结果并非我们预期的值。
public class TestCase {
private int number=0;
public void addNumber(){
for (int i=0;i<100000;i++){
number=number+1;
}
}
public static void main(String[] args) throws Exception {
TestCase testCase=new TestCase();
Thread threadA=new Thread(new Runnable() {
@Override
public void run() {
testCase.addNumber();
}
});
Thread threadB=new Thread(new Runnable() {
@Override
public void run() {
testCase.addNumber();
}
});
threadA.start();
threadB.start();
threadA.join();
threadB.join();
System.out.println("number="+testCase.number);
}
}
问题根源之二:CPU切换线程执导致的原子性问题
首先我们先理解什么叫原子性,原子性就指是把一个操作或者多个操作视为一个整体,在执行的过程不能被中断的特性叫原子性。
因为IO、内存、CPU缓存他们的操作速度有着巨大的差距,假如CPU需要把CPU缓存里的一个变量写入到磁盘里面,CPU可以马上发出一条对应的指令,但是指令发出后的很长时间CPU都在等待IO的结束,而在这个等待的过程中CPU是空闲的。
所以为了提升CPU的利用率,操作系统就有了进程和时间片的概念,同一个进程里的所有线程都共享一个内存空间,CPU每执行一个时间段就会切换到另外一个进程处理指令,而这执行的时间长度是是以时间片(比如每个时间片为1毫秒)为单位的,通过这种方式让CPU切换着不同的进程执行,让CPU更好的利用起来,同时也让我们不同的进程可以同时运行,我们可以一边操作word文档,一边用QQ聊天。
后来操作系统又在CPU切换进程执行的基础上做了进一步的优化,以更细的维度“线程”来切换任务执行,更加提高了CPU的利用率。但正是这种CPU可以在不同线程中切换执行的方式会使得我们程序执行的过程中产生原行性问题。
比如说我们以一个变量赋值为例:
语句1:Int number=0;
语句2:number=number+1;
在执行语句2的时候,我们的直觉number=number+1 是一个不可分割的整体,但是实际CPU操作过程中并非如此,我们的编译器会把number=number+1 拆分成多个指令交给CPU执行。
number=number+1的指令可能如下:
指令1:CPU把number从内存拷贝到CPU缓存。
指令2:把number进行+1的操作。
指令3:把number回写到内存。
在这个时候如果有多线程同时去操作number变量,就很有可能出现问题,因为CPU会在执行上面任何一个指令的时候切换线程执行指令,这个时候就可能出现执行结果与我们预期结果不符合的情况。
比如如果现在有两个线程都在执行number=number+1,结果CPU执行流程可能会如下:
执行细节:
1、CPU先执行线程A的执行,把number=0拷贝到CUP寄存器。
2、然后CPU切换到线程B执行指令。
3、线程B 把number=0拷贝到CUP寄存器。
4、线程B 执行number=number+1 操作得到number=1。
5、线程B把number执行结果回写到缓存里面。
6、然后CPU切换到线程A执行指令。
7、线程A执行number=number+1 操作得到numbe=1。
8、线程A把number执行结果回写到缓存里面。
9、最后内存里面number的值为1。
问题根源之三::编译器优化带来的指令重排序问题
熟悉单例模式的人应该有了解过 一个叫“双重检查锁”的问题,就是下面的代码这样
public class Singleton {
private Singleton() {}
private static Singleton sInstance;
public static Singleton getInstance() {
if (sInstance == null) { //第一次验证是否为null
synchronized (Singleton.class) { //加锁
if (sInstance == null) { //第二次验证是否为null
sInstance = new Singleton(); //创建对象
}
}
}
return sInstance;
}
}
这个代码在极低概率的情况下获得 Instance 为null的对象,其核心问题出在 Instance = new Singleton(); 这行代码上,当我们执行Instance = new Singleton();这行代码时会分解成三个指令执行。
1、为对象分配一个内存空间。
2、在分配的内存空间实例化对象。
3、把Instance 引用地址指向内存空间。
如果按正常的顺序执行,那么这个案例代码永远不会出问题,而问题就出在我们的编译器会自作聪明的优化指令顺序,就像上面的指令,它会也许优化成下面的顺序
1、为对象分配一个内存空间。
2、把instance 引用地址指向内存空间。
3、在分配的内存空间实例化对象。
如果nstance = new Singleton()指令优化成上面的顺序,当并发访问的时候,可能会出现这样的情况
1、A线程进入方法进行第1次instance == null判断。
2、此时A线程发现instance 为null 所以对Singleton.class加锁。
3、然后A线程进入方法进行第2次instance == null判断。
4、然后A线程发现instance 为null,开始进行对象实例化。
5、为对象分配一个内存空间。
6、把Instance 引用地址指向内存空间(而就在这个指令完成后,线程B进入了方法)。
7、B线程首先进入方法进行第1次instance == null判断。
8、B线程此时发现instance 不为null ,所以它会直接返回instance (而此时返回的instance 是A线程还没有初始化完成的对象)
最终线程B拿到的instance 是一个没有实例化对象的空内存地址,所以导致instance使用的过程中造成程序错误。
高并发
高并发向上看需要考虑网路、机器、限流、监控、扩容等各方面