并发编程理论1.并发问题的由来

Posted shinyrou

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了并发编程理论1.并发问题的由来相关的知识,希望对你有一定的参考价值。

并发编程中问题的由来:

CPU、内存、I/O设备的速度存在巨大差异,程序的整体性能取决于最慢的操作——读取I/O设备,为了合理利用CPU性能,平衡三者的速度差异,计算机体系结构、操作系统、编译程序做出了以下改进。

  1. CPU增加了缓存
  2. 操作系统增加进程、线程分时复用CPU,进而均衡CPU与I/O设备的速度差异
  3. 编译程序优化指令执行次序,使得缓存能够更加合理地利用

由此引发出了以下问题:

1.可见性——CPU缓存导致

早期单核CPU时,CPU缓存的数据与内存的数据是保持一致的,
线程A,线程B在同一个核心上切换运行,A对缓存中的操作对于B是立即可见的,所以不存在可见性问题
技术图片

到了多核CPU时代,每个核心都有自己的CPU缓存(L1 cache,L2 cache).
线程A,线程B在执行不同的核心上执行时,先将数据从内存读取到各自的CPU缓存,此时线程A对于数据的操作,对于线程B是不可见的。
技术图片

写操作对于读操作 是立即可见的

2.原子性——线程切换导致

高级程序语言中的一条语句往往对应这操作系统中的多条指令,
线程在CPU的执行又是时间片轮转的方式执行,和可能在线程A将内存中的变量值V = 0 读取到寄存器中时,切换到线程B执行将V的值进行了更新 V=V+1
此时V = 0 更新到内存,切换到A线程,对寄存器中 V=0 进行V++,在更新到内存 V=1。
就会出现两个线程进行 V = V+1操作,结果却还是2的情况。
技术图片

一个或者多个操作在 CPU 执行的过程中不被中断的特性,称 为“原子性”

3.有序性——编译器优化导致

编译器为了优化性能,有时候会改变程序中语句的先后顺序,例如程序中:“a=6;b=7;”
编译器优化后可能变成“b=7;a=6;”,在这个例子中,编译器调整了语句的顺序,
但是不影响程序的最终结果。不过有时候编译器及解释器的优化可能导致意想不到的 Bug。

例如:双重校验单例模式

public class Singleton {
    static Singleton instance;
    static Singleton getInstance(){
        if (instance == null) {
            synchronized(Singleton.class) {
                if (instance == null)
                    instance = new Singleton();
            }
        }
        return instance;
    }
}

理想的执行过程:
假设线程A、线程B同时调用getInstance()方法,

  • 1.假设A先得到执行权对Singleton.class进行加锁
  • 2.此时instance = null 创建Singleton对象
  • 3.获取到单例对象,释放锁
  • 4.线程B调用getInstance(),instance不为null,获取到线程A初始化的instance

经过编译优化后对象的创建过程

  • 1.分配一块内存 M;
  • 2.将 M 的地址赋值给 instance 变量;
  • 3.最后在内存 M 上初始化 Singleton 对象。

当执行2后发生线程切换,线程B得到的instance应用地址并未初始化对象就会产生空指针。

Java内存模型如何解决并发问题

Java内存模型针对于
可见性问题 ——CPU缓存导致
有序性问题 ——编译优化导致
原子性问题 ——线程切换导致
提供的方案是按需禁用CPU缓存及编译优化
https://www.bilibili.com/video/av81008349

具体来说,这些方法包括 volatile、synchronized 和 final 三个关键字,以及Happens-Before 规则

volatile关键字:
被volatile修饰的变量能够保证每个线程能够获取该变量的最新值,从而避免出现数据脏读的现象。
虽然禁用了缓存,所有线程都要从内存中读取,但是不具备互斥性(即同一时间只有一个线程可以执行)
适用于一个线程写 另一个线程读 可以读到写入的最新值

synchronized关键字:
同步关键字,进行修饰的代码块或者方法,进行加锁,保证同时只有一个线程能够获取到锁。

Happens-Before 约束 了编译器的优化行为,虽允许编译器优化,但是要求编译器优化后一定遵守 HappensBefore 规则。
核心原则:前面一个操作的结果对后续操作是可见的。
六大规则:

  • 1.程序的顺序性规则
    指在一个线程中,按照程序顺序,前面的操作 Happens-Before 于后续的任意 操作。
    即程序前面对某个变量的修改一定是对后续操作可见的。
  • 2.volatile 变量规则
    指对一个 volatile 变量的写操作, Happens-Before 于后续对这个 volatile 变量的读操作。
  • 3.传递性
    指如果 A Happens-Before B,且 B Happens-Before C,那么 A HappensBefore C。
  • 4.管程中锁的规则
    管程是一种通用的同步原语,在 Java 中指的就是 synchronized,synchronized 是 Java 里对管程的实现。
    上一个加锁的线程中的操作释放锁后对下一个加锁线程可见。
  • 5.线程 start() 规则
    指主线程 A 启动子线程 B 后,子线程 B 能够看到主线程在 启动子线程 B 前的操作。
  • 6.线程 join() 规则
    指主线程 A 等待子线程 B 完成(主线程 A 通过调用子线程 B 的 join() 方法实现),当子线程 B 完成后(主线程 A 中 join() 方法返回),主线程能 够看到子线程的操作。当然所谓的“看到”,指的是对共享变量的操作。
/**
 * 可见性问题
 * 1.主线程中启动子线程 获取a的值 判断进行循环
 * 2.因为循环中是空方法,a一直会读取CPU缓存中的值 一直为true
 * 3.所以执行结果是 打印false后 子线程还在死循环执行
 */
public class Test {
    static boolean a = true;

    public static void main(String[] args) throws InterruptedException{
        new Thread(()->{
            while(a){

            }
        }).start();
        Thread.sleep(1000);
        a = false;
        System.out.println(a);
    }
}
/**
 * 解决可见性问题
 * volatile禁用CPU缓存
 * 并且使得线程的写操作对 读操作可见
 * 执行结果: 主线程打印false后 子线程读取到a的变更停止死循环
 */
public class Test {
    static volatile boolean a = true;

    public static void main(String[] args) throws InterruptedException{
        new Thread(()->{
            while(a){

            }
        }).start();
        Thread.sleep(1000);
        a = false;
        System.out.println(a);
    }
}
/**
 * 原子性问题
 * a++操作对应
 * 1.读取a的值
 * 2.a+1
 * 3.赋值
 * 下面两个线程可能出现 线程1 读取a = 0
 * 切换到 线程2 读取到 a = 0
 * 线程2 执行 a++  更新 a的值 a=1
 * 再次回到线程1 寄存器中 a = 0,执行a+1,更新a=1
 * 两个线程的操作 却没有 a = 2
 *
 * 执行结果 <=20000
 **/
public class Test {
    static  int a = 0;//此时使用volatile关键字修饰a 并没有保证原子性

    public static void main(String[] args) throws InterruptedException{
        for(int i=0;i<10000;i++){
            new Thread(()->{
                a++;
            }).start();
            new Thread(()->{
                a++;
            }).start();
        }
        Thread.sleep(2000);
        System.out.println(a);
    }
}
/**
 * 原子性问题
 * a++操作对应
 * 1.读取a的值
 * 2.a+1
 * 3.赋值
 * 
 * 解决原子性问题之使用原子类 
 * AtomicInteger 的 getAndAdd方法 在执行过程中 的读写是原子性的,不允许线程切换的
 * AtomicInteger内部基于volatile关键字
 * 
 * 执行结果:20000
 **/
public class Test {
    static AtomicInteger a = new AtomicInteger(0);
    
    public static void main(String[] args) throws InterruptedException{
        for(int i=0;i<10000;i++){
            new Thread(()->{
               a.getAndAdd(1);
            }).start();
            new Thread(()->{
                a.getAndAdd(1);
            }).start();
        }
        Thread.sleep(2000);
        System.out.println(a);
    }
}
/**
 * 原子性问题
 * a++操作对应
 * 1.读取a的值
 * 2.a+1
 * 3.赋值
 *
 * 解决原子性问题之使用synchronized关键字
 * 线程1、线程2 同一个时间只有一个线程能执行同步代码块,通过互斥性来保证原子性
 * 最为重量级的实现
 *
 * 执行结果:20000
 **/
public class Test {
    static int  a = 0;

    public static void main(String[] args) throws InterruptedException{
        for(int i=0;i<10000;i++){
            new Thread(()->{
                synchronized (Test.class){
                    a++;
                }
            }).start();
            new Thread(()->{
                synchronized (Test.class){
                    a++;
                }
            }).start();
        }
        Thread.sleep(2000);
        System.out.println(a);
    }


}

总结:

  • volatile 保证可见性,一个线程的写对另一个线程的读可见 无锁
  • AtomicInteger(原子类) 保证原子性,同时进行读写时的操作是原子性的. (基于CPU提供的CAS) 无锁
  • Synchrnoized 保证互斥性 最为重量级 同步代码块是原子性的,同时只有一个线程可以执行 加锁






























以上是关于并发编程理论1.并发问题的由来的主要内容,如果未能解决你的问题,请参考以下文章

锁的由来,并发三特性全解析

进程和线程之由来

全栈编程系列SpringBoot整合Shiro(含KickoutSessionControlFilter并发在线人数控制以及不生效问题配置启动异常No SecurityManager...)(代码片段

Java多线程基础:进程和线程之由来

Java 并发编程:核心理论

Java编程思想之二十 并发