编程语言
首页 > 编程语言> > Java并发编程 - 并发问题的源头

Java并发编程 - 并发问题的源头

作者:互联网

文章目录

线程与进程

进程(Process)

是计算机中的程序关于某数据集合上的一次运行活动,是系统进行资源分配和调度的基本单位,是操作系统结构的基础。

(进程的理解:操作系统上的一块独立的区域,每个进程都是独立运行的,资源相互是不共享的。)


线程(Thread)

是操作系统能够进行运算调度的最小单位。一个进程能够有多条线程,线程与线程之间是能够资源共享的。一个进程中可以并发多个线程,每条线程并行执行不同的任务。


CPU时间分片

进程是资源分配单位,线程是CPU调度单位。如果线程数不多于CPU核心数,会把各个线程都分配一个核心。不需分片,而当线程数多于CPU核心数就会分片。

多个线程在操作时,如果系统只有一个CPU,CPU会把运行时间划分成若干个时间片,分配给各个线程执行,在一个时间段的线程代码运行时,其它线程处于挂起状态。

并发表示同时发生了多件事情,通过时间片切换,哪怕只有单一的核心,也可以实现“同时做多件事情”这个效果。


并发是什么

顺序:上个任务执行完,当前任务才能开始
并发(concurrent):不管上个任务是否执行完,当前任务都可以开始

串行:只有一个厕所,上厕所只能一个一个地排队
并行(parallel):有多个厕所,可以同时多个人上厕所

5个线程,每个线程能处理100万个任务。(5为并行度,100万为并发量)


资源共享是什么

反向思维:哪些资源是线程私有的?

进程地址空间划分:栈区、堆区、代码区(源代码编译后的机器指令存放区域)、数据区(全集变量、static变量存放)

线程运行的本质其实就是函数的执行。函数的执行总会有一个源头,这个源头就是所谓的入口函数,CPU从入口函数开始执行从而形成一个执行流,只不过我们人为的给执行流起一个名字,这个名字就叫线程。
线程的栈区、程序计数器、栈指针以及函数运行使用的寄存器是线程私有的。这些资源统称为线程上下文(thread context)。

所以说,共享的资源有:堆区、代码区、数据区

资源共享相关文章:线程间到底共享了哪些进程资源?看完这篇你就懂了~


并发编程

并发编程:为了程序运行更快,让多个线程分别完成不同任务。

但是多线程同时也带来了新的问题 - 并发问题(共享资源的竞争问题)


并发问题的源头

  1. 线程切换带来的原子性问题

  2. 缓存导致的可见性问题

  3. 编译优化带来的有序性问题


原子性问题

原子性:一个线程的操作是不能被其他线程打断,同一时间只有一个线程对一个变量进行操作。

出现原子性问题的原因:Java是门高级语言里的一条语句往往需要多条CPU指令完成。CPU做任务切换可以发生在任何一条CPU指令执行完之后,而不是高级语言里的一条语句。


为什么线程操作会被打断

线程是CPU调度单位。在线程数超过CPU核心数的时候,CPU就会通过时间分片来将时间分给每个线程,例如50毫秒,线程执行50毫秒之后就会切换到另一个线程。在时间片内的线程拥有CPU的使用权,其他线程会挂起。这种切换可以称为任务切换(也可以叫线程切换)。

在这里插入图片描述


原子性问题例子

下面以i++为例:

//G.java
public class G {
    private int i;

    public void test(){
        i++;
    }
}

//javap后看看字节码
Compiled from "G.java"
public class com.llk.kt.G {
  public com.llk.kt.G();
    Code:
       0: aload_0
       1: invokespecial #1                  // Method java/lang/Object."<init>":()V
       4: return

  public void test();
    Code:
       0: aload_0
       1: dup
       2: getfield      #2 //获取对象字段的值
       5: iconst_1				 //1(int)值入栈
       6: iadd						 //将栈顶两int类型数相加,结果入栈
       7: putfield      #2 //给对象字段赋值
      10: return
}

/*
i++分为了三步
step1 将i内存加载到CPU的寄存器 
step2 在寄存器中执行+1操作
step3 将结果写入内存(缓存机制导致可能写入的是CPU缓存而不是内存) 
上边每一个步骤执行完,CPU都有可能切换任务。

例如:
线程A与线程B同时执行test()方法,线程A得到CPU的使用权后进行操作,执行完step1后任务切换到了线程B。
然后线程B执行,线程B比较幸运直接执行完了test(),并且成功将自增完后的值写回到内存中,i这时候变成了1。
任务切换回线程A,由于线程A在经过step1后已经i的值缓存到了寄存器,继续执行step2,直接用i的旧值0来做自增,完事后将结果写回到内存中。
i的预期值本应为2的,最后由于任务切换,导致了i的值出现了异常。
*/

可见性问题

可见性:指某个线程修改了某一个共享变量的值,而其他线程是否可以看见该共享变量修改后的值。

出现可见性问题的原因:CPU为了解决内存io操作速度慢的问题,CPU自身会有高速缓存。多核CPU中,每个CPU都有自己独立的高速缓存。线程是CPU调度单位,所以线程拥有这高速缓存空间。线程在操作共享内存中的变量时,都会先将其拷贝一份到自己的缓存空间中。在并发环境下,无法保证CPU的缓存一致性,就会导致可见性问题的发生。


CPU的缓存一致性
缓存一致性

指保证存储在多个缓存中的共享资源数据相同的机制。

缓存不一致

是指相同数据在不同的缓存中呈现出不同的表现。缓存不一致的问题,在多核CPU的系统中,比较容易出现。

如果保证CPU的缓存一致性

缓存一致性协议有多,主流的是"嗅探(snooping)"协议,它的基本思想是:所有内存的传输都发生在一条共享的总线上,而所有的CPU都能看到这条总线。

缓存本身是独立的,但是内存是共享资源,所有的内存访问都要经过仲裁(同一个指令周期中,只有一个CPU缓存可以读写内存)。

CPU缓存不仅仅在做内存传输的时候才与总线打交道,而是不停在嗅探总线上发生的数据交换,跟踪其他缓存在做什么。所以当一个缓存代表它所属的CPU去读写内存时,其它处理器都会得到通知,它们以此来使自己的缓存保持同步。只要某个CPU一写内存,其它CPU马上知道这块内存在它们的缓存段中已失效。

MESI协议是当前最主流的缓存一致性协议。(感兴趣自行了解)


可见性问题例子
public class V {
    private static boolean bool = false;
    public static void b_test(){
        new Thread(new Runnable() {//线程1
            @Override
            public void run() {
                System.out.println("11111");
                while (!bool){ }
                System.out.println("22222");
            }
        }).start();

        try {
            Thread.sleep(10);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        new Thread(new Runnable() { //线程2
            @Override
            public void run() {
                System.out.println("33333");
                bool = true;
                System.out.println("44444");
            }
        }).start();
    }

    public static void main(String[] args) {
        b_test();
    }
  
  /* 输出 ->
       11111
       33333
       44444
       
       线程2明明已经将bool设置为true了,为什么线程1没有结束循环呢?
       因为线程有自己的缓存区域,会先把共享内存中的变量拷贝到自己的缓存区域中。所以这就导致了线程2刷新了共享内存中的bool值,但线程1依旧使用自己缓存的bool值,最终导致线程1一直无法退出。
   */
}

有序性问题

有序性:在代码顺序结构中,我们可以直观的指定代码的执行顺序, 即从上到下按序执行。

出现有序性问题的原因:编译器和CPU处理器会根据自己的决策,对代码的执行顺序进行重新排序。优化指令的执行顺序,提升程序的性能和执行速度,使语句执行顺序发生改变,出现重排序,在单核CPU最终结果看起来没什么变化。

但是在多线程环境下(多核CPU),由于执行语句重排序后,重排序的这一部分没有一起执行完,就切换到了其它线程,导致的结果与预期不符的问题。


有序性问题例子
public class V {
    private static V instance;
    private V(){}
    public static V getInstance() {
        if (instance==null){ //首次检查
            synchronized (V.class){ //通过synchronized保证了代码块跟上下文的可见性、原子性、有序性
                if (instance == null){ //二次检查
                    instance = new V();
                }
            }
        }
        return instance;
    }
}

上面是一个经典的单例实现方式,双重检查锁(Double Check Lock)单例。但是这个单例并不完美,在多线程模式下getInstance()仍然可能出现问题,会由于指令重排出现有序性问题。

不是synchronized就能保证了有序性了吗,为什么还会出现有序性问题?synchronized只能保证受保护的代码块跟与上下文的有序性,而不能保证代码块内的有序性。

//原因出在:
instance = new V();

//这一行代码并不是原子操作,而是由三个操作完成的
//1、为instance开辟一块内存空间
//2、初始化对象
//3、instance指向刚分配的内存地址

//由于编译器优化,出现指令重排,变成了
//1、为instance开辟一块内存空间
//3、instance指向刚分配的内存地址
//2、初始化对象

//假设线程A执行到了“instance指向刚分配的内存地址”这一步,那么instance就不为空了。
//这个时候线程B正好执行getInstance()的首次检查,发现instance不为空直接返回了。
//但是这个instance对象可能并未初始化完成,如果我们这个时候访问instance的成员变量就可能触发空指针异常。

想要解决上边例子中出现的有序性问题,只需要给instance变量加上volatile关键字即可。


Java内存模型

JMM(Java Memory Model)也称Java内存模型

JMM是用来定义一个一致的、跨平台的内存模型,是缓存一致性协议,用来定义数据读写的规则。

JMM最核心的概念是Happens-Before。


Happens-Before规则

Happens-Before规则:前面一个操作的结果对后续操作是可见的

1. 顺序性规则

一个线程中,按照程序顺序,前面的操作 Happens-Before 于后续的任意操作

int a = 1; //1
int b = 2; //2
int c = a + b; //3

2. volatile变量规则

对一个volatile变量的写操作,Happens-Before于后续任意对这个volatile变量的读操作

volatile int i = 0;

//线程A执行
i = 10; //写操作

//线程执行
int b = i; //读操作

/*
线程A先执行,紧接着线程B执行
由于volatile修饰了i,保证了可见性线程B立马能够获取到i最新的值
*/

3. 传递性规则

如果 A Happens-Before B,且 B Happens-Before C,那么 A Happens-Before C

int i = 0;
volatile boolean b;

void set(){
  i = 10;
  b = true;
}

void get(){
  if(b){
    int g = i;
  }
}

/*
线程A调用了set()后,紧接着线程B调用了get()。
i = 10	Happens-Before	b = true  ---> 顺序性规则
b写操作	Happens-Before	b读操作 		---> volatile变量规则
最后根据传递性规则,i = 10	Happens-Before	int g = i 这里的g获取到i值已经是最新的值10
*/

4. 管程中锁的规则

对一个锁的解锁 Happens-Before 于后续对这个锁的加锁

管程是一种通用的同步原语,synchronized是Java里对管程的实现。

int i = 5;

void set(){
  synchronized (this) { //加锁
    if (i < 10) {
      i = 99; 
    }  
	} //解锁
}

/*
线程A、线程B同时调用了set(),线程A率先进入代码块,线程B进入等待
当线程A自动释放锁后,线程B进入了代码块。线程B读取的i值已经是最新的值99。
*/

5. 线程start()规则

主线程A启动子线程B后,子线程B能够看到主线程在启动子线程B前的操作

换句话说:线程B.start()前的操作 Happens-Before 于线程B内的任意操作

int i = 0; //共享变量

Thread B = new Thread(()->{
  System.out.println("i=" + i); //输出-> i=10
});
i = 10;
B.start();

/*
i = 10 是线程B启动前的操作,所以在B线程内i的值是最新值
*/

6. 线程join()规则

主线程A等待子线程B完成(主线程A调用子线程B的join()方法),当子线程B执行完成后(主线程A中join()方法返回),主线程能够看到子线程对共享变量的操作。

换句话说:线程B内任何操作 Happens-Before 于线程B.join()后的操作

int i = 0; //共享变量

Thread B = new Thread(()->{
  i = 10;
}).start();
System.out.println("i=" + i); //输出-> i=0
B.join();
System.out.println("i=" + i); //输出-> i=10

/*
线程B中的 i = 10 操作,在B.join()后是可见的
*/

标签:Happens,缓存,Java,instance,编程,并发,线程,CPU,Before
来源: https://blog.csdn.net/yudan505/article/details/117841171