java多线程3:原子性,可见性,有序性

概念

  在了解线程安全问题之前,必须先知道为什么需要并发,并发给我们带来什么问题。

       为什么需要并发,多线程?

  1. 时代的召唤,为了更充分的利用多核CPU的计算能力,多个线程程序可通过提高处理器的资源利用率来提升程序性能。
  2. 方便业务拆分,异步处理业务,提高应用性能。

   多线程并发产生的问题?

  1. 大量的线程让CPU频繁上下文切换带来的系统开销。
  2. 临界资源线程安全问题(共享,可变)。
  3. 容易造成死锁。

注意:当多个线程执行一个方法时,该方法内部的局部变量并不是临界资源,因为这些局部变量是在每个线程的私有栈中,因此不具有共享性质,不会导致线程安全问题。

可见性

 多线程访问同一个变量时,如果有一个线程修改了这个变量的值,其他线程能够立即看得到修改的值。这是因为为了保证多个CPU之间的高速缓存是一致的,操作系统会有一个缓存一致性协议,volatile就是通过OS的缓存一致性协议策略来保证了共享变量在多个线程之间的可见性。

public class ThreadDemo2 {

    private static boolean flag = false;

    public void thread_1(){
        flag = true;
        System.out.println("线程1已对flag做出改变");
    }

    public void thread_2(){
        while (!flag){
        }
        System.out.println("线程2->flag已被修改,成功打断循环");
    }

    public static void main(String[] args) {
        ThreadDemo2 threadDemo2 = new ThreadDemo2();
        Thread thread2 = new Thread(()->{
            threadDemo2.thread_2();
        });
        Thread thread1= new Thread(()->{
            threadDemo2.thread_1();
        });
        thread2.start();
        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        thread1.start();
    }
}

执行结果

线程1已对flag做出改变

代码无论执行多少次,线程2的输出语句都不会被打印。为flag添加volatile修饰后执行,线程2执行的语句被打印

执行结果

线程1已对flag做出改变
线程2->flag已被修改,成功打断循环

局限:volatile只是保证共享变量的可见性,无法保证其原子性。多个线程并发时,执行共享变量i的i++操作<==> i = i + 1,这是分两步执行,并不是一个原子性操作。根据缓存一致性协议,多个线程读取i并对i进行改变时,其中一个线程抢先独占i进行修改,会通知其他CPU我已经对i进行修改,把你们高速缓存的值设为无效并重新读取,在并发情况下是可能出现数据丢失的情况的。

public class ThreadDemo3 {
    private volatile static int count = 0;
    public static void main(String[] args) {
        for (int i = 0; i < 10; ++i){
            Thread thread = new Thread(()->{
                for (int j = 0; j < 1000; ++j){
                    count++;
                }
            });
            thread.start();
        }
        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println("count执行的结果为->" + count);
    }
}

执行结果

count执行的结果为->9561

注意:这个结果是不固定的,有时10000,有时少于10000。

原子性

就像恋人一样同生共死,表现在多线程代码中程序一旦开始执行,就不会被其他线程干扰要嘛一起成功,要嘛一起失败,一个操作不可被中断。在上文的例子中,为什么执行结果不一定等于10000,就是因为在count++是多个操作,1.读取count值,2.对count进行加1操作,3.计算的结果再赋值给count。这几个操作无法构成原子操作的,在一个线程读取完count值时,另一个线程也读取他并给它赋值,根据缓存一致性协议通知其他线程把本次读取的值置为无效,所以本次循环操作是无效的,我们看到的值不一定等于10000,如何进行更正---->synchronized关键字

public class ThreadDemo3 {
    private volatile static int count = 0;
    private static Object object = new Object();
    public static void main(String[] args) {
        for (int i = 0; i < 10; ++i){
            Thread thread = new Thread(()->{
                for (int j = 0; j < 1000; ++j){
                    synchronized (object){
                        count++;
                    }
                }
            });
            thread.start();
        }
        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println("count执行的结果为->" + count);
    }
}

执行结果

count执行的结果为->10000

加锁后,线程在争夺执行权就必须获取到锁,当前线程就不会被其他线程所干扰,保证了count++的原子性,至于synchronized为什么能保证原子性,篇幅有限,下一篇在介绍。

有序性

jmm内存模型允许编译器和CPU在单线程执行结果不变的情况下,会对代码进行指令重排(遵守规则的前提下)。但在多线程的情况下却会影响到并发执行的正确性。

public class ThreadDemo4 {
    private static int x = 0,y = 0;
    private static int a = 0,b = 0;
    private static int i = 0;
    public static void main(String[] args) throws InterruptedException {
        for (;;){
            i++;
            x = 0;y = 0;
            a = 0;b = 0;
            Thread thread1 = new Thread(new Runnable() {
                @Override
                public void run() {
                    waitTime(10000);
                    a = 1;
                    x = b;
                }
            });
            Thread thread2 = new Thread(new Runnable() {
                @Override
                public void run() {
                    b = 1;
                    y = a;
                }
            });
            thread1.start();
            thread2.start();
            thread1.join();
            thread2.join();
            System.out.println("第" + i + "次执行结果(" + x + "," + y + ")");
            if (x == 0 && y == 0){
                System.out.println("在第" + i + "次发生指令重排,(" + x + "," + y + ")");
                break;
            }
        }
    }
    public static void waitTime(int time){
        long start = System.nanoTime();
        long end;
        do {
            end = System.nanoTime();
        }while (start + time >= end);
    }

}

执行结果

第1次执行结果(0,1)
第2次执行结果(1,0)
....
第35012次执行结果(0,1)
第35013次执行结果(0,0)
在第35013次发生指令重排,(0,0)

如何解决上诉问题哪?volatile的另一个作用就是禁止指令重排优化,它的底层是内存屏障,其实就是一个CPU指令,一个标识,告诉CPU和编译器,禁止在这个标识前后的指令执行重排序优化。内存屏障的作用有两个,一个就是上文所讲的保证变量的内存可见性,第二个保证特定操作的执行顺序。

补充

 指令重排序:Java语言规范规定JVM线程内部维持顺序化语义,程序的最终结果与它顺序化情况的结果相等,那么指令的执行顺序可以和代码顺序不一致。JVM根据处理器特性,适当的堆机器指令进行重排序,使机器指令更符号CPU的执行特性,最大限度发挥机器性能。

as-if-serial语义:不管怎么重排序,单线程程序的执行结果不能被改变,编译器和处理器都必须遵守这个原则。

happens-before原则:辅助保证程序执行的原子性,可见性和有序性的问题,判断数据是否存在竞争,线程是否安全的依据(JDK5)

1. 程序顺序原则,即在一个线程内必须保证语义串行性,也就是说按照代码顺序执行。

2. 锁规则 解锁(unlock)操作必然发生在后续的同一个锁的加锁(lock)之前,也就是说, 如果对于一个锁解锁后,再加锁,那么加锁的动作必须在解锁动作之后(同一个锁)。

3. volatile规则 volatile变量的写,先发生于读,这保证了volatile变量的可见性,简单 的理解就是,volatile变量在每次被线程访问时,都强迫从主内存中读该变量的值,而当 该变量发生变化时,又会强迫将最新的值刷新到主内存,任何时刻,不同的线程总是能 够看到该变量的最新值。

4. 线程启动规则 线程的start()方法先于它的每一个动作,即如果线程A在执行线程B的 start方法之前修改了共享变量的值,那么当线程B执行start方法时,线程A对共享变量 的修改对线程B可见

5. 传递性 A先于B ,B先于C 那么A必然先于C

6. 线程终止规则 线程的所有操作先于线程的终结,Thread.join()方法的作用是等待当前 执行的线程终止。假设在线程B终止之前,修改了共享变量,线程A从线程B的join方法 成功返回后,线程B对共享变量的修改将对线程A可见。

7. 线程中断规则 对线程 interrupt()方法的调用先行发生于被中断线程的代码检测到中 断事件的发生,可以通过Thread.interrupted()方法检测线程是否中断。

 

猜你喜欢

转载自www.cnblogs.com/dslx/p/12690366.html