【并发编程】并发编程的三大特性

并发编程的书籍都会讲到并发编程的三大特性,这是并发编程中所有问题的根源,我们只有深刻理解了这三大特性,才不会编写出漏洞百出的并发程序。

基本概念

1、原子性,所有操作要么全部成功,要么全部失败。

2、可见性,一个线程对变量进行了修改,另外一个线程能够立刻读取到此变量的最新值。

3、有序性,代码在运行期间保证按照编写的顺序。

为什么会有并发编程特性?

线程切换导致了原子性问题

对于变量a来说,我们对其执行以下代码

a++;

此时需要分三步执行:

(1)读取a的值

(2)将a的值加1

(3)将加1后的值赋给a

在执行以上三步过程中,如果另一个线程B对a进行了操作,那么就不能保证原子性了。

要保证原子性,可以通过为原子操作加锁或者使用原子变量来解决,如synchronized或者atomic

缓存一致性导致了可见性问题

最开始的电脑是单核cpu,也就意味着是单个CPU缓存缓存。但是随着大多数电脑是多核,也就是说cpu缓存不在是单纯一个,是多个。

线程的修改一个变量的值大致需要分为三步:

1.从内存中获取到值存入cpu缓存

2.在cpu中执行加一操作

3.将修改后的值放入缓存,之后再更新到内存中

 下面我们举例来说明一下这个情况:

public class visibility {
    private static class ShowVisibility implements Runnable{
        public static Object o = new Object();
        private Boolean flag = false; 
        @Override
        public void run() {
            while (true) {
                if (flag) {
                    System.out.println(Thread.currentThread().getName()+":"+flag);
                }
            }
        }
    }

    public static void main(String[] args) throws InterruptedException {
        ShowVisibility showVisibility = new ShowVisibility();
        Thread blindThread = new Thread(showVisibility);
         blindThread.start();
        //给线程启动的时间
        Thread.sleep(500);
        //更新flag
        showVisibility.flag=true;
        System.out.println("flag is true, thread should print");
        Thread.sleep(1000);
        System.out.println("I have slept 1 seconds. I guess there was nothing printed ");
    }
}

代码说明:

 ShowVisibility 实现 Runnable 接口,在 run 方法中判断成员变量 flag 值为 true 时进行打印。main 方法中通过 showVisibility 对象启动一个线程。主线程等待 0.5 秒后,改变 showVisibility 中 flag 的值为 true。按正常思路,此时 blindThread 应该开始打印。但是,实际情况并非如此。

flag 改为 true 后,blindThread 没有任何打印。也就是说 blindThread 并没有观察到到 flag 的值变化

编译优化带来有序性问题

处理器为了提高程序运行效率,可能会对输入代码进行优化,它不保证程序中各个语句的执行先后顺序同代码中的顺序一致,但是它会保证程序最终执行结果和代码顺序执行的结果是一致的,叫做指令重排序。

指令重排序并不是毫无约束的随意改变代码执行顺序,而是需要符合指令间的依赖关系,否则会造成程序执行结果错误。

接下来我们来思考一个问题,为什么会有编译优化,或者说这有什么必要性吗?下面我举个例子给大家展示一下

我们去超市买菜,需要胡萝卜,白菜和茄子。超市的布局如下。

如果到了超市你会先去买胡萝卜,再去买白菜,然后再去买茄子吗,这样的路线肯定不是最好的。浪费你的时间,机器也是如此,在不影响运算结果的情况下,会对指令进行重排序,会规划更为合理的执行方式,确保程序运行正确的情况下,提高效率。

我们再来看一个不能重排序的例子。还是去超市采购,你妈和你说,如果买不到白菜,才买胡萝卜。那么买白菜和胡萝卜这两个步骤就不能改变。否则假如我们先去了胡萝卜货架,发现自己没买到白菜,就会买胡萝卜,然后又执行了买白菜。最后的结果就是错误的 ---- 我们既买了白菜也买了胡萝卜。

指令重排序的优化,仅仅对单线程程序确保安全。如果在并发的情况下,程序没能保证有序性,程序的执行结果往往会出乎我们的意料。另外注意,指令重排序,并不是代码重排序。我们的代码被编译后,一行代码可能会对应多条指令,所以指令重排序更为细粒度。

下面我们来看一下单例模式遇到的有序行问题
 

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

 这种单例模式实现,看着没有什么问题,但是不然。 instance = new Singleton (); 这一行代码会被编译为三条指令,正常指令顺序如下:

  • 1.为 instance 分配一块内存 A
  • 2.在分配的内存 A 上初始化 instance 实例
  • 3.把内存 A 的地址赋值给 instance 变量

编译优化后可能变成

  • 为 instance 分配一块内存 A(之前第一步)
  • 把内存 A 的地址赋值给 instance 变量(之前第三步)
  • 在分配的内存 A 上初始化 instance 实例(之前第二步)

解析问题:

可以看出在优化后第 2 和第 3 步调换了位置。调换后单线程运行是没有问题的。但是换做多线程,假如线程 A 正在初始化 instance,此时执行完第 2 步,正在执行第三步。而线程 B 执行到 if (instance == null) 的判断,那么线程 B 就会直接得到未初始化好的 instance,而此时线程 B 使用此 instance 显然是有问题的。

不要以写单线程程序的思路来开发多线程。处理好这三大特性,多线程开发的大部分问题都会得以解决。下一节我们会来学习 Java 内存模型,其实所有的线程安全性都来自于它,看看他是如何解决的。

 

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值