02、并发编程的三大特性

并发编程有三大特性分别是,原子性,可见性,有序性。会产生这些特性的根本原因是现在的服务器都是多CPU多核心数的,每个CPU都有自己单独的一套缓存和pc系统,而且程序在运行时按照JMM的规范,它们是需要先把数据从主内存中读取到工作内存(也就是运行这个线程的CPU的缓存中),对工作内存中的数据进行修改之后再写回主内存,在对自己工作内存数据的操作对其他CPU是不可见的,这才会导致并发编程会有以上三种特性。下面就三种特性的概念,产生的问题,和解决方案进行阐述

image.png

1、原子性

1.1、原子性的定义

在并发编程中,会出现多个线程同时修改同一个变量的情况,此时对应的临界区域(多个线程都运行的代码区域)不做任何的限制的话,就会出现,a线程把i变量由1修改成2了,还没有来得及给i赋值2的时候,b线程也对i进行操作,但是它拿到的是原来的1,这样a,b两个线程都+1可结果还是2,为了解决这个问题,就需要保证a,b线程在对i修改之后把i写回主内存这一系列的操作,没有其他线程进行干扰,一次性全部完成之后,然后其他的线程再对i操作,由此就引出了并发编程中的原子性的定义

  • 原子性:保证多线程运行过程中,临界区域的代码在运行的时候,是不可分割不可被打断的,其间也不会有其他的线程对其进行干扰。

1.2、原子性的解决方案

1.2.1、通过synchronizd锁的方案
    private static int count;
    //通过synchronized实现
    public static synchronized void increment(){
        count++;
    }
    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 100000; i++) {
                increment();
            }
        });
        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 1000000; i++) {
                increment();
            }
        });
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println(count);
    }
}

通过查看上面带synchronized底层编译的JVM操作指令可以发现,synchronized在JVM指令操作上在临界区域加上了monitorenter操作,直到临界区域完成才会有monitorexit(异常也会有一个monitorexit操作),退出操作

image.png

1.2.2、通过lock锁的方案
    private static int count;
    private static ReentrantLock lock = new ReentrantLock();
    public static void increment()  {
        //通过lock的方式实现
        lock.lock();
        try {
            count++;
        } finally {
            lock.unlock();
        }


    }
    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 100000; i++) {
                increment();
            }
        });
        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 100000; i++) {
                increment();
            }
        });
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println(count);
    }

ReentrantLock的底层是基于AQS实现的,它是通过CAS的方式维护一个state变量来实现锁的操作

  • CAS(compare and swap)
    CAS是一条CPU层级就支持的原子操作,根据CAS的定义:比较和交换,底层的实现原理是,当它要置换内存中某个位置的值时,它会先去比较下是否和预期的值一致,如果一致它才置换。
  • CAS的缺点
    它只能保证对一个变量的操作是原子性的,不能实现对多行代码实现原子性。
  • CAS的问题
    • ABA问题:一个变量一开始是A,经过修改之后由A变成B,之后又变成A,此时对于那些引用类型是有问题的,因为引用类型虽然变回了A,但是它引用的指向的地址里的具体内容很可能已经发生了变化。
    • ABA问题的解决方案:在比较的时候不仅比较预期的值,还需要比较对应的版本号,这样在由A->B,又B->A的过程中,版本号肯定跟原先的不一致,由此可以判断出已经修改不能进行置换操作
1.2.3、通过ThreadLocal的方案
static ThreadLocal tl1 = new ThreadLocal();
static ThreadLocal tl2 = new ThreadLocal();

public static void main(String[] args) {
    tl1.set("123");
    tl2.set("456");
    Thread t1 = new Thread(() -> {
        System.out.println("t1:" + tl1.get());
        System.out.println("t1:" + tl2.get());
    });
    t1.start();

    System.out.println("main:" + tl1.get());
    System.out.println("main:" + tl2.get());
}

ThreadLocal解决线程之间的原子方案是通过线程隔离实现的,直接把共享变量分配给每个线程,每个线程只能操作自己的这个变量,把共享变量变成线程私有的变量

  • ThreadLocal的底层实现
    1、每个线程都有一个ThreadLocalMap对象作为Thread的成员变量
    2、调用ThreadLocal的set方法时会初始化对应Thread的ThreadLocalMap变量
    3、把当前的ThreadLocal对象作为key,把对应要存的数据作为value存储
    image.png
  • ThreadLocal会产生内存泄露问题

由于一般情况在创建ThreadLocal的时候都会把它设置成Static,所以就算是a线程运行结束了,a线程的对应的ThreadLocal对象由于Static指向着所有不会释放内存,但是由于线程运行结束,线程产生的独有资源,ThreadLocalMap中的key和value应该释放掉,如果不释放新的线程不断产生,会不断的消耗系统内存,从而导致内存泄露

  • 解决方案

上述ThreadLocalMap的key和value内存泄露问题,其中key的内存泄露,系统已经帮我们做好了

  • 系统通过把key设置成WeakReference类型,能做到当这个线程运行完成,GC回收的时候就会把ThreadLocal对象回收。因为弱引用的特点是只要碰到GC回收,它就会被回收。
  • 至于value的回收,需要我们在代码里手动的调用ThreadLocal中的remove方法把value释放掉

2、可见性

2.1、可见性定义

导致可见性问题的原因是多CPU独立运行时,只会修改各自的工作内存也就是CPU缓存数据,而且多个CPU之间是有独立的缓存和PC系统。

  • 可见性:多线程在运行的时候一个线程修改了公共变量,其他的线程不能及时的见到

2.2、可见性的解决方案

2.2.1、volatile方案
    //通过volatile修饰,控制t1线程的停止
    private volatile static boolean flag = true;
    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(() -> {
            while (flag) {
                // ....
            }
            System.out.println("t1线程结束");
        });

        t1.start();
        Thread.sleep(10);
        flag = false;
        System.out.println("主线程将flag改为false");
    }

上述代码中如果没有volatile修饰,flag在主线程里被修改成false,t1线程是不会感知到的,那volatile底层是怎么实现的呢?

  • volatile实现细节
    • volatile在JVM层面是通过带lock前缀的指令实现。
    • JVM的指令在CPU层面是通过缓存一致性协议,如MESI协议(inter的协议)实现。
    • volatile变量在写操作时,JVM会及时的把对应CPU的缓存行刷到主内存中,在volatile变量被修改之后,根据MESI协议,所有CPU中对应这行缓存行数据都会失效,得重新去主内存中读取。
2.2.2、synchronized或是lock方案

锁的方案是可以实现多线程间变量的可见性的。锁的定义就是让单个线程去修改共享变量,修改之后其他线程再去读取,这个时候其他线程读到的数据肯定是上个线程修改的最新数据

2.2.3、final方案

final修饰的变量初始化之后,就不能被修改,所有的线程拿到的都是同一个值,也是间接的实现了线程之间的可见性

3、有序性

3.1、有序性定义

由于CPU的运行速度和读取数据的速度相差好几个数量级的关系,所以现代CPU为了追求效率,会进行“乱序执行”,在运行到需要去内存中读数据的指令时,可能要花很长时间等待读取数据,在这些时间的等待中,在保证程序结果的最终一致性之后,CPU就进行指令重排,以提高效率,但是有些时候这中乱序是不被允许的。

  • 有序性:让程序中的指令集按照顺序执行,不让CPU进行指令重排

3.2、有序性解决方案

3.2.1、volatile方案
//通过volatile关键词解决
private static volatile MiTest test;
private MiTest(){}
public static MiTest getInstance(){
    // B
    if(test  == null){
        synchronized (MiTest.class){

            if(test == null){
                // A   ,  开辟空间,test指向地址,初始化
                test = new MiTest();
            }
        }
    }
    return test;
}

上述是一个单例模式的样例代码,如果不加volatile关键词,在new MiTest这个共享对象的时候,就会出现问题,对象的new过程在底层一共有三个操作指令,分别是,给对象开辟空间,给对象初始化,把地址引用赋值给变量。如果此时发生了指令的重排,顺序变成了,给对象开辟空间,把地址引用赋值给变量,给对象初始化,那么B线程此时就会拿到没有进行初始化好的对象使用,就会发生问题,通过volatile防止指令重排就可以解决这个问题。

  • volatile防止指令重排的细节
    它是通过内存屏障来实现的,内存屏障相当于是一条指令,在这条指令的前后操作指令不能重排,在JVM层面有对应的读写屏障,在CPU底层也有对应的读写屏障来具体支持
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值