并发三大特性

并发三大特性

  1. 可见性
  2. 原子性
  3. 有序性

可见性

可见性问题是基于CPU位置出现的,CPU处理速度非常快,相对CPU来说,去主内存获取数据这个事情太慢了,CPU就提供了L1L2L3的三级缓存,每次去主内存拿完数据后,就会存储到CPU的三级缓存,每次去三级缓存拿数据,效率可定会提升
这就带来了问题,现在CPU都是多核,每个线程的工作内存(CPU三级缓存)都是独立的,会告知每个线程中做修改时,只改自己的工作内存,没有及时的同步到主内存,导致数据不一致问题

数据不一致问题示例

private static boolean flag = true;

/**
 * 主线程和t1线程中使用的成员变量flag虽然是同一个,但在多核CPU内部,
 * 每个线程在调用其CPU都缓存了一份变量值在自己的三级缓存中,其缓存的值的更新互不影响,
 * 这里主线程的flag更新后,无法影响t1三级缓存的flag
 *
 * @throws InterruptedException e
 */
public static void reproduceTheVisibilityIssue() throws InterruptedException {
    Thread t1 = new Thread(() -> {
        //循环不会结束
        while (flag) {
            //do something
        }
        System.out.println("t1 thread end.");
    });
    t1.start();
    Thread.sleep(10); //此处睡10ms的目的为:防止t1线程还未启动,主线程就执行了flag=false导致while循环进不去
    flag = false;
    System.out.println("main thread update flag : " + flag);
}

t1会一直死循环,程序会一直执行

解决可见性问题的几种方式

  1. volatile
  2. synchronized
  3. Lock
  4. final
volatile

volatile是一个关键字,用来修饰成员变量。如果属性被volatile修饰,相当于告诉CPU,对当前属性的操作,不允许使用CPU的缓存,必须去和主内存操作

volatile的内存语义:

  1. volatile属性被写:当写一个volatile变量,JMM会将当前线程对应的CPU缓存及时的刷新到主内存中
  2. volatile属性被读:当读一个volatile变量,JMM会将对应的eCPU缓存中的内存设置为无效,必须去主内存中重新读取共享变量

其实加了volatile就是告诉CPU,对当前属性的读写操作,不允许使用CPU缓存,加了volatile修饰的属性,会在转为汇编指令之后,追加一个lock指令前缀,CPU执行这个指令时,如果带有lock前缀会做两个事情:

  1. 将当前处理器缓存行的数据写回到主内存
  2. 这个写回的数据,在其他的CPU内核的缓存中,直接无效

总结volatile就是让CPU每次操作这个数据时,必须立即同步到主内存,以及从主内存读取数据

private static volatile boolean flag = true;

public static void volatileSolveTheVisibilityIssue() throws InterruptedException {
    Thread t1 = new Thread(() -> {
        //flag在赋值false后立即结束
        while (flag) {
            //do something
        }
        System.out.println("t1 thread end.");
    });
    t1.start();
    Thread.sleep(10); //此处睡10ms的目的为:防止t1线程还未启动,主线程就执行了flag=false导致while循环进不去
    flag = false;
    System.out.println("main thread update flag : " + flag);
}
synchronized

synchronized也是可以解决可见性问题的,synchronized的内存语义:

如果涉及到了synchronized的同步代码块或者是同步方法,获取锁资源之后,将内部涉及到的变量从CPU缓存中移除,必须去主内存中重新拿数据,而且在释放锁之后,会立即将CPU缓存中的数据同步到主内存

private static boolean tag = true;

public static void synchronizedSolveTheVisibilityIssue() throws InterruptedException {
    Thread t1 = new Thread(() -> {
        while (tag) {
            //必须写在获取变量之后,synchronized获取到锁后会立刻将内部涉及到的变量CPU缓存中移除,再重主内存中重新拿数据
            //如果synchronized在加载变量之前,这时tag变量压根就还没加载到CPU缓存中,后面加载的数据还是主内存中获取tag=true
            synchronized (SolveVisibility.class) {
                //do something
            }
        }
        System.out.println("t1 thread end.");
    });
    t1.start();
    Thread.sleep(10); //此处睡10ms的目的为:防止t1线程还未启动,主线程就执行了flag=false导致while循环进不去
    tag = false;
    System.out.println("main thread update flag : " + tag);
}
Lock

Lock锁保证可见性的方式和synchronized完全不同,synchronized基于他的内存语义,在获取锁和释放锁时,对CPU缓存做一个同步到内存的操作

Lock锁是基于volatile实现的。Lock锁内部在进行加锁和释放锁时,会对一个由volatile修饰的state属性进行加减操作

如果volatile修饰的属性进行写操作,CPU会执行带有lock前缀的指令,CPU会将修改的数据,从CPU缓存立即同步到主内存,同时也会将其他的属性也立即同步到主内存中。还会将其他CPU缓存行中的这个数据设置为无效,必须重新从主内存中拉取

private static Lock lock = new ReentrantLock(); //实际底层源码使用了一个volatile修饰的变量state
//等同于
private static volatile int i;
public static void lockSolveTheVisibilityIssue() throws InterruptedException {
    Thread t1 = new Thread(() -> {
        while (tag) {
            /*lock.lock();
            try {
                //....
            } finally {
                lock.unlock();
            }*/
            //等同于
            i++;
            //do something
        }
        System.out.println("t1 thread end.");
    });
    t1.start();
    Thread.sleep(10); //此处睡10ms的目的为:防止t1线程还未启动,主线程就执行了flag=false导致while循环进不去
    tag = false;
    System.out.println("main thread update flag : " + tag);
}
final

final修饰的属性,在运行期间是不允许修改的,这样一来,就间接保证了可见性,所有多线程读取final属性,值肯定是一样的

final并不是说每次读取数据从主内存读取,他没有这个必要,而且finalvolatile是不允许同时修饰一个属性的

final修饰的内容已经不允许再次被写了,而volatile是保证每次读写数据去主内存读取,并且volatile会影响一定的性能,就不需要同时修饰

private static final boolean tg = true;

原子性

问题的产生

多线程对共享变量的操作会出现与结果不一致的情况,此种情况未保证证原子性

private static int count;

public static void testMuchThreadIncrement() throws InterruptedException {
    Thread t1 = new Thread(() -> {
        for (int i = 0; i < 100; i++) {
            count++;
        }
    });
    Thread t2 = new Thread(() -> {
        for (int i = 0; i < 100; i++) {
            count++;
        }
    });
    t1.start();
    t2.start();
    t1.join();
    t2.join();
    System.out.println(count); //理论输出为200,结果每次都会小于200
}

在线程1拿到count变量的值为1的时候线程2也拿到count的值为1他们同时对count++操作然后在赋值给count,此时count的结果实际上就做了一次++

如何保证原子性

  1. synchronized
  2. CAS
  3. Lock
  4. ThreadLocal
synchronized

使用同步代码块或同步方法,保证多线程的操作同一变量的原子性,底层原理就是将临界资源加锁,始终只有一个线程在同一CPU时刻操作该临界资源

private static int count;

public static void increment() {
    synchronized (ThreadAtomicity.class) {
        count++;
        try {
            Thread.sleep(10);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
    }
}

public static void testMuchThreadIncrement() throws InterruptedException {
    Thread t1 = new Thread(() -> {
        for (int i = 0; i < 100; i++) {
            increment();
        }
    });
    Thread t2 = new Thread(() -> {
        for (int i = 0; i < 100; i++) {
            increment();
        }
    });
    t1.start();
    t2.start();
    t1.join();
    t2.join();
    System.out.println(count); 
}
CAS

Compare and Swap比较和交换,它是一条CPU的并发原语(synchronized对程序的性能较CAS逊色)

它在替换内存的某个位置时,首先会查看内存中的值与预期值是否一致,如果一致,执行替换操作。这个操作是一个原子性操作

Java中基于Unsafe的类提供了对CAS的操作的方法,JVM会帮助我们将方法实现CAS汇编指令,但是要清楚CAS只是比较和交换,在获取原值的这个操作上,需要你自己实现

缺点

  • CAS只能保证对一个变量的操作是原子性的,无法实现对多行代码实现原子性

CAS的问题

  • ABA问题:三个线程同时去更新一个变量,由于CPU的调度问题,执行顺序无法保证,但是CAS可以让CPU一个一个进行对比交换

    • 首先:第一个线程 -> 比较变量值A对其进行赋值B

    • 其次:第二个线程 -> 比较变量值B对其进行赋值A

    • 最后:第三个线程 -> 比较变量值A对其进行赋值B

    最终可以发现最终的变量结果是没有变化的,如果要知道变量被改变的次数就需要加入版本号来解决该问题

    ABA的问题,如果只考虑数据的总量合理,那可以不关注ABA问题。如果业务关心CAS的操作次数,例如一个开关,每次操作是从true改为false,也有从false改为true,如果没有ABA问题,可能连续的开关了很多次,业务还能跑,而你看恰巧要关注这种开关的次数,此时ABA问题就需要考虑进去

    Java中提供了一个类在CAS时,针对各个版本追加版本号的操作 -> AtomicStampedReference(在CAS时,不但会判断原值,还会比较版本信息)

  • 自旋时间过长问题

    • 可以指定CAS一共循环多少此,如果超过这个次数,直接失败/或挂起线程(自旋锁,自适应自旋锁)
    • 可以在CAS一次失败后,将这个操作暂存起来,后面需要获取结果时,将暂存的操作全部执行,再返回最后的结果(如:LongAdder

示例

class CAS {

    public static void main(String[] args) throws InterruptedException {
        testMuchThreadIncrement();

        //通过AtomicStampedReference解决ABA问题
        AtomicStampedReference<String> reference = new AtomicStampedReference<>("A", 1);
        String oldVal = reference.getReference();
        int oldVersion = reference.getStamp();
        boolean b = reference.compareAndSet(oldVal, "B", oldVersion, oldVersion + 1);
        System.out.println("第一次修改:" + b); //true

        //由于第二次修改时他的老版本应该是oldVersion + 1 = 2而不是1所以更新会失败
        boolean c = reference.compareAndSet("B", "C", 1, 1 + 1);
        System.out.println("第二次修改:" + c); //false

        System.out.println("value:"+reference.getReference()+", version:"+reference.getStamp()); //value:B, version:2
    }

    static AtomicInteger count = new AtomicInteger(0);

    public static void testMuchThreadIncrement() throws InterruptedException {
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 100; i++) {
                count.getAndIncrement();
            }
        });
        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 100; i++) {
                count.getAndIncrement();
            }
        });
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println(count); //使用AtomicInteger中基于Unsafe的getAndIncrement()方法通过CAS保证了共享变量的原子性
    }

}
Lock锁

Lock锁是再JDK1.5由Doug Lea研发的,他的性能相比synchronized在JDK1.5的时期,性能好了很多,但是在JDK.16对synchronized优化之后,性能相差不大,但是如果涉及并发比较多时,推荐ReentrantLock锁,性能会更好

ReentrantLock可以直接对比synchronized,在功能上来说,都时锁,但是ReentrantLock的功能性相比synchronized更丰富

ReentrantLock底层是基于AQS实现的,有一个基于CAS维护的state变量来实现锁的操作

示例·

class Lock {

    public static void main(String[] args) throws InterruptedException {
        testMuchThreadIncrement();
    }

    static ReentrantLock lock = new ReentrantLock();

    static int count;

    public static void increment() {
        try {
            lock.lock();
            count++;
            Thread.sleep(10);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        } finally {
            //使用finally解锁不论业务中出现什么错误,防止锁未释放情况
            lock.unlock();
        }
    }

    public static void testMuchThreadIncrement() throws InterruptedException {
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 100; i++) {
                increment();
            }
        });
        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 100; i++) {
                increment();
            }
        });
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println(count); //理论输出为200,结果每次都会小于200
    }

}
ThreadLocal

ThreadLocal保证原子性的方式,是不让多线程去操作临界资源,让每个线程去操作属于自己的数据

实现原理

  1. 每个Thread中都存储着一个成员变量,ThreadLocalMap
  2. ThreadLocal本身不存储数据,像是一个工具类,基于ThreadLocal去操作ThreadLocalMap
  3. ThreadLocalMap本身就是基于Entry[]实现的,因为一个线程可以绑定多个ThreadLocal,这样以来,可能需要存储多个数据,所以采用Entry[]的形式实现
  4. 每一个线程都有自己独立的ThreadLocalMap,再基于ThreadLocal对象本身作为key,对value进行存取
  5. ThreadLocalMapkey是一个弱引用,弱引用的特点是,即使由弱引用,在GC时,也必须被回收,这里是为了在ThreadLocal对象失去引用后,如果key的引用是强引用,会导致ThreadLocal对象无法被回收

ThreadLocal内存泄露问题

  • 如果ThreadLocal引用丢失,key因为弱引用会被GC回收掉,如果同时线程还没有被回收,就会导致内存泄漏,内存中的value无法被回收,同时也无法被获取到

只需要在使用完毕ThreadLocal对象之后,及时的调用remove(),移除Entry即可

示例

class ThreadLocalExp {

    static ThreadLocal<String> tl1 = new ThreadLocal<>();
    static ThreadLocal<String> tl2 = new ThreadLocal<>();

    public static void main(String[] args) {
        //在主线程中set的值在t1线程中是拿不到的,如果需要在t1线程中获取就需要在t1线程中进行赋值
        tl1.set("123");
        tl2.set("456");
        Thread t1 = new Thread(() -> {
            tl1.set("abc");
            tl2.set("efg");
            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());
    }

}

有序性

在Java中,.java文件中内容会被编译,在执行前需要再次转换为CPU可以识别的指令,CPU在执行这些指令时,为了提升执行效率,在不影响最终结果的前提下(满足一些要求),会对指令进行重排

指令乱序执行的原因,是为了尽可能的发挥CPU的性能

Java中的程序是乱序执行的

验证乱序执行

public class ThreadOrderliness {

    public static void main(String[] args) throws InterruptedException {
        verifyOutOfOrderExecution();
    }

    static int a,b,x,y;
    /**
     * 验证乱序执行
     */
    public static void verifyOutOfOrderExecution() throws InterruptedException {
        for (int i = 0; i < Integer.MAX_VALUE; i++) {
            a = 0;
            b = 0;
            x = 0;
            y = 0;
            Thread t1 = new Thread(() -> {
                a = 1;
                x = b;
            });
            Thread t2 = new Thread(() -> {
                b = 1;
                y = a;
            });
            t1.start();
            t2.start();
            t1.join();
            t2.join();

            if (x == 0 && y == 0) {
                System.out.println("循环第" + i + "次,x=" + x + ",y=" + y);
            }
        }
    }
}

单例模式由于指令重排序可能会出现问题

线程可能会拿到没有初始化的对象,导致在使用时,可能由于内部属性为默认值,导致出现一些不必要问题

解决方案

对单例返回的对象使用volatile修饰

class SingletonPatternInstructionRearrangementProblem {

    private static volatile SingletonPatternInstructionRearrangementProblem INSTANCE;

    private SingletonPatternInstructionRearrangementProblem() {

    }

    public static SingletonPatternInstructionRearrangementProblem getInstance() {
        //B
        if (INSTANCE == null) {
            synchronized (SingletonPatternInstructionRearrangementProblem.class) {
                if (INSTANCE == null) {
                    //A 开辟空间,INSTANCE指向地址,初始化(没有volatile修饰)
                    //A 开辟空间,初始化,INSTANCE指向地址(volatile修饰INSTANCE)
                    INSTANCE = new SingletonPatternInstructionRearrangementProblem();
                }
            }
        }
        return INSTANCE;
    }
}

as-if-serial(CPU级别)

as-if-serial语义

  • 不论指定如何重排序,需要保证单线程的程序执行结果是不变的
  • 而且如果存在依赖关系,那么也不可以做指令重排
public static void main(String[] args) {
    //这种情况肯定不能做指令重排序
    int i = 0;
    i++;

    //这种情况肯定不能做指令重排序
    int j = 200;
    j *= 100;
    j += 100;
    //这里即使出现了指令重排,也不可影响最终结果,20100
    System.out.println(j);
}

happens-before(虚拟机级别)

具体规则:

    1. 单线程happen-before原则:在同一个线程中,书写在前面的操作happen-b
    2. 锁的happen-before原则:同一个锁的unlock操作lock操作      
    3. volatile的happen-before原则:对一个volatile变量的写操作h
    4. happen-before的传递性原则:如果A操作happen-before B操作,
    5. 线程启动的happen-before原则:同一个线程的start方法happen-be
    6. 线程中断的happen-before原则:对线程interrupt方法的调用happe
    7. 线程终结的happen-before原则:线程中所有操作都happen-before线
    8. 对象创建的happen-before原则:一个对象的初始化完成先于他的finalize

JMM只有在不出现上述8种情况时,才不会触发指令重排效果

作为开发,不需要过分的关注happen-before原则,只需要可以写出线程安全的代码就可以了

volatile

如果需要让程序对某一个属性的操作不出现指令重排,除了满足happen-before原则之
从而对这个属性的操作,就不会出现指令重排的问题了

volatile如何实现的禁止指令重排?

内存屏障概念。将内存屏障看成一条指令

会在两个操作之间,添加上一道指令,这个指令就可以避免上下执行的其他指令进行重排序

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值