java并发编程:可见性、原子性、有序性三大特性详解


可见性

内存可见性,指的是线程之间的可见性,当一个线程修改了共享变量时,另一个线程可以读取到这个修改后的值。

导致可见性的原因

线程交叉执行

线程交叉执行多数情况是由于线程切换导致的,例如下图中的线程A在执行过程中切换到线程B执行完成后,再切换回线程A执行剩下的操作;此时线程B对变量的修改不能对线程A立即可见,这就导致了计算结果和理想结果不一致的情况。

img

重排序结合线程交叉执行

例如下面这段代码

int a = 0;    //行1
int b = 0;    //行2
a = b + 10;   //行3
b = a + 9;    //行4

如果行1和行2在编译的时候改变顺序,执行结果不会受到影响;

如果将行3和行4在变异的时候交换顺序,执行结果就会受到影响,因为b的值得不到预期的19;

img

由图知:由于编译时改变了执行顺序,导致结果不一致;而两个线程的交叉执行又导致线程改变后的结果也不是预期值,简直雪上加霜!

共享变量更新后没有及时更新

因为主线程对共享变量的修改没有及时更新,子线程中不能立即得到最新值,导致程序不能按照预期结果执行。

例如下面这段代码:

public class Visibility {

    // 状态标识flag
    private static boolean flag = true;

    public static void main(String[] args) throws InterruptedException {
        System.out.println(LocalDateTime.now() + "主线程启动计数子线程");
        new CountThread().start();

        Thread.sleep(100);
        // 设置flag为false,使上面启动的子线程跳出while循环,结束运行
        Visibility.flag = false;
        System.out.println(LocalDateTime.now() + "主线程将状态标识flag被置为false了");
    }

    static class CountThread extends Thread {
        @Override
        public void run() {
            System.out.println(LocalDateTime.now() + "计数子线程start计数");
            int i = 0;
            while (Visibility.flag) {
                i++;
            }
            System.out.println(LocalDateTime.now() + "计数子线程end计数,运行结束:i的值是" + i);
        }
    }

}

运行结果是:

在这里插入图片描述

从控制台的打印结果可以看出,因为主线程对flag的修改,对计数子线程没有立即可见,所以导致了计数子线程久久不能跳出while循环,结束子线程。

如何解决可见性问题

1、volatile关键字

volatile关键字能保证可见性,但也只能保证可见性,在此处就能保证flag的修改能立即被计数子线程获取到。

此时纠正上面例子出现的问题,只需在定义全局变量的时候加上volatile关键字

// 状态标识flag    
private static volatile boolean flag = true;

2、Atomic相关类

将标识状态flag在定义的时候使用Atomic相关类来进行定义的话,就能很好的保证flag属性的可见性以及原子性。

此时纠正上面例子出现的问题,只需在定义全局变量的时候将变量定义成Atomic相关类

// 状态标识flag    
private static AtomicBoolean flag = new AtomicBoolean(true); 

不过值得注意的一点是,此时原子类相关的方法设置新值和得到值的放的是有点变化,如下:

// 设置flag的值   
VisibilityDemo.flag.set(false);        
// 获取flag的值    
VisibilityDemo.flag.get() 

3、锁

此处我们使用的是Java常见的synchronized关键字。

此时纠正上面例子出现的问题,只需在为计数操作i++添加synchronized关键字修饰。

synchronized (this) {    
    i++;    
} 

通过上面三种方式,都得到类似如下的期望结果:

在这里插入图片描述

原子性

一个或者多个操作在 CPU 执行的过程中不被中断的特性。

出现原子性问题的原因

导致共享变量在线程之间出现原子性问题的原因是上下文切换。

那么接下来,我们通过一个例子来重现原子性问题。

package td;

import java.util.ArrayList;
import java.util.List;

/**
 * 演示:原子性问题 -> 指当一个线程对共享变量操作到一半时,另外一个线程也有可能来操作共享变量,干扰了第一个线程的操作
 */
public class Atomicity {

    //定义一个共享变量
    private static int number = 0;

    public static void addNumber(){
        number++;
    }

    public static void main(String[] args) throws InterruptedException {
        //对number进行1000的++
        Runnable runnable = () -> {
            for (int i = 0; i < 1000; i++) {
                addNumber();
            }
        };

        List<Thread> list = new ArrayList<>();
        //使用10个线程来进行操作
        for (int i = 0; i < 10; i++) {
            Thread t = new Thread(runnable);
            t.start();
            list.add(t);
        }

        for (Thread t : list) {
            //t.join()方法只会使主线程进入等待池并等待t线程执行完毕后才会被唤醒。并不影响同一时刻处在运行状态的其他线程
            t.join();
        }

        System.out.println("number = " + number);
    }
}

多次运行上面的程序,也有我们期望的结果 number = 10000,当时会出现不是我们想要的结果。

出现上面情况的原因就是因为:

 public static void addNumber(){
        number++;
    }

这段代码并不是原子操作,其中的number是一个共享变量。在多线程环境下可能会被打断。就这样原子性问题就赤裸裸的出现了。

如何解决原子性问题

1、synchronized关键字

synchronized既可以保证操作的可见性,也可以保证操作结果的原子性。

所以,此处我们只需要将addNumber()方法设置成synchronized的就能保证原子性了。

public synchronized static void addNumber(){
    number++;
}

2、Lock锁

static Lock lock = new ReentrantLock();

public static void addNumber(){
    lock.lock();//加锁
    try{
        number++;
    }finally {
        lock.unlock();//释放锁
    }
}

Lock锁保证原子性的原理和synchronized类似

3、原子操作类型

JDK提供了很多原子操作类来保证操作的原子性。比如最常见的基本类型:

AtomicBoolean
AtomicLong
AtomicDouble
AtomicInteger

这些原子操作类的底层是使用CAS机制的,这个机制保证了整个赋值操作是原子的不能被打断的,从而保证了最终结果的正确性。

和synchronized相比,原子操作类型相当于是从微观上保证原子性,而synchronized是从宏观上保证原子性。

public class Atomicity {

    //定义一个共享变量
    private static AtomicInteger number = new AtomicInteger();

    public static void add(){
        number.incrementAndGet();
    }

    public static int get(){
        return number.get();
    }

    public static void main(String[] args) throws InterruptedException {
        //对number进行1000的++
        Runnable runnable = () -> {
            for (int i = 0; i < 1000; i++) {
                add();
            }
        };

        List<Thread> list = new ArrayList<>();
        //使用5个线程来进行操作
        for (int i = 0; i < 10; i++) {
            Thread t = new Thread(runnable);
            t.start();
            list.add(t);
        }

        for (Thread t : list) {
            //t.join()方法只会使主线程进入等待池并等待t线程执行完毕后才会被唤醒。并不影响同一时刻处在运行状态的其他线程
            t.join();
        }

        System.out.println("number = " + get());
    }
}

有序性

有序性问题 指的是在多线程环境下(多核),由于执行语句重排序后,重排序的这一部分没有一起执行完,就切换到了其它线程,导致的结果与预期不符的问题。这就是编译器的编译优化给并发编程带来的程序有序性问题。

用图示就是:

img

导致有序性的原因

如果一个线程写入值到字段 a,然后写入值到字段 b ,而且b的值不依赖于 a 的值,那么,处理器就能够自由的调整它们的执行顺序,而且缓冲区能够在 a 之前刷新b的值到主内存。此时就可能会出现有序性问题。

例子:

public class Order {

    static int value = 1;
    
    private static boolean flag = false;

    public static void main(String[] args) throws InterruptedException {
        for (int i = 0; i < 500; i++) {
            value = 1;
            flag = false;
            Thread thread1 = new DisplayThread();
            Thread thread2 = new CountThread();
            thread1.start();
            thread2.start();
            System.out.println("=========================================================");
            Thread.sleep(4000);
        }
    }

    static class DisplayThread extends Thread {
        @Override
        public void run() {
            System.out.println(Thread.currentThread().getName() + " DisplayThread begin, time:" + LocalDateTime.now());
            value = 1024;
            System.out.println(Thread.currentThread().getName() + " change flag, time:" + LocalDateTime.now());
            flag = true;
            System.out.println(Thread.currentThread().getName() + " DisplayThread end, time:" + LocalDateTime.now());
        }
    }

    static class CountThread extends Thread {
        @Override
        public void run() {
            if (flag) {
                System.out.println(Thread.currentThread().getName() + " value的值是:" + value + ", time:" + LocalDateTime.now());
                System.out.println(Thread.currentThread().getName() + " CountThread flag is true,  time:" + LocalDateTime.now());
            } else {
                System.out.println(Thread.currentThread().getName() + " value的值是:" + value + ", time:" + LocalDateTime.now());
                System.out.println(Thread.currentThread().getName() + " CountThread flag is false, time:" + LocalDateTime.now());
            }
        }
    }
}

运行结果:

在这里插入图片描述

从打印的可以看出:在 DisplayThread 线程执行的时候肯定是发生了重排序,导致先为 flag 赋值,然后切换到 CountThread 线程,这才出现了打印的 value 值是1,falg 值是 true 的情况,再为 value 赋值;不过出现这种情况的原因就是这两个赋值语句之间没有联系,所以编译器在进行代码编译的时候就可能进行指令重排序。

用图示,则为:

img

如何解决有序性问题

1、volatile关键字

volatile 的底层是使用内存屏障来保证有序性的(让一个Cpu缓存中的状态(变量)对其他Cpu缓存可见的一种技术)。

volatile 变量有条规则是指对一个 volatile 变量的写操作, Happens-Before 于后续对这个 volatile 变量的读操作。并且这个规则具有传递性,也就是说:

使用 volatile 修饰flag就可以避免重排序和内存可见性问题。写 volatile 变量时,可以确保 volatile 写之前的操作不会被编译器重排序到 volatile 写之后。读 volatile 变量时,可以确保 volatile 读之后的操作不会被编译器重排序到 volatile 读之前。

img

此时,我们定义变量 flag 时使用 volatile 关键字修饰,如:

    private static volatile boolean flag = false; 

此时,变量的含义是这样子的:

img

也就是说,只要读取到 flag=true; 就能读取到 value=1024;否则就是读取到 flag=false;value=1 的还没被修改过的初始状态;

在这里插入图片描述

但也有可能会出现线程切换带来的原子性问题,就是读取到 flag=false;value=1024 的情况。

在这里插入图片描述

2、加锁

此处我们直接采用Java语言内置的关键字 synchronized,为可能会重排序的部分加锁,让其在宏观上或者说执行结果上看起来没有发生重排序。

代码修改也很简单,只需用 synchronized 关键字修饰run方法即可,代码如下:

public synchronized void run() {
    value = 1024;
    flag = true;
}

总结

最后,简单总结下几种解决方案之间的区别:

特性Atomic变量volatile关键字Lock接口synchronized关键字
原子性可以保障无法保障可以保障可以保障
可见性可以保障可以保障可以保障可以保障
有序性无法保障一定程度保障可以保障可以保障
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值