资讯专栏INFORMATION COLUMN

浅谈并发及Java实现 (一) - 并发设计的三大原则

gecko23 / 3127人阅读

摘要:并发设计的三大原则原子性原子性对共享变量的操作相对于其他线程是不可干扰的,即其他线程的执行只能在该原子操作完成后或开始前执行。发现两个线程运行结束后的值为。这就是在多线程情况下要求程序执行的顺序按照代码的先后顺序执行的原因之一。

并发设计的三大原则 原子性

原子性:对共享变量的操作相对于其他线程是不可干扰的,即其他线程的执行只能在该原子操作完成后或开始前执行。

通过一个小例子理解

public class Main {

    private static Integer a = 0;

    public static void main(String[] args) {
        ExecutorService pool = Executors.newFixedThreadPool(50);
        for (int i = 0; i < 50; i++) {
            pool.submit(() -> {
                a = a + 1;
            });
        }
        pool.shutdown();
        
        //等待线程全部结束
        while(!pool.isTerminated());
        System.out.println(a);
    }
}

这里创建了一个包含50个线程的线程池,并让每个线程执行一次自增的操作,最后等待全部线程执行结束之后打印a的值。
理论上,这个a的值应该是50吧,但实际运行发现并不是如此,而且多次运行的结果不一样。

分析一下原因,在多线程的情况下,a = a + 1这一条语句是可能被多个线程同时执行或交替执行的,而这条语句本身分为3个步骤,读取a的值,a的值+1,写回a。
假设现在a的值为1,线程A和线程B正在执行。线程A读取a得值为1,并将a得值+1(线程A内a的值目前依旧为1),此时线程B读取a得值为1,将a值+1,写回a,此时a为2,线程A再次运行,将刚才+1后的a值(2)写回a。
发现两个线程运行结束后a的值为2。

以一个表格描述运行的过程。

线程A 线程B a
读取a 读取a 1
a + 1 a + 1,写回结果 2
写回结果 2

这一现象发生的原因,正是因为a = a + 1其实是由多个步骤所构成的,在一个线程操作的过程中,其他线程也可以进行操作,所以发生了非预期的错误结果。

因此,若能保证一个线程在执行操作共享变量的时候,其他线程不能操作,即不能干扰的情况下,就能保证程序正常的运行了,这就是原子性。

可见性

可见性:当一个线程修改了状态,其他的线程能够看到改变。

了解过计算机组成原理的应该知道,为了缓解CPU过高的执行速度和内存过低的读取速度的矛盾,CPU内置了缓存功能,能够存储近期访问过的数据,若需要再次操作这些数据,只需要从缓存中读取即可,大大减少了内存I/O的时间。

(此处应当有JVM的内存结构分析,待添加)

但此时就产生了一个问题,在多处理器的情况下,若对同一个内存区域进行操作,就会在多个处理器缓存中存在该内存区域的拷贝。但每个处理器对结果的操作并不能对其他处理器可见,因为各个处理器都在读取自己的缓存区域,这就造成了缓存不一致的情况。

同样以一个小例子理解

public class Main {
    private static Boolean ready = false;
    private static Integer number = 0;

    public static void main(String[] args) throws InterruptedException {
        new Thread(() -> {
            while (!ready) ;
            System.out.println(number);
        }).start();
        Thread.sleep(100);
        number = 42;
        ready = true;
        System.out.println("Main Thread Over !");
    }
}

这里ready初始化为false,创建一个线程,持续监测ready的值,直到为true后打印number的结果。
主线程则在创建完线程后给ready和number重新赋值。

运行之后发现,程序打印出了Main Thread Over !意味着主线程结束,此时ready和number应该已经被赋值,但等待很久之后发现还是没有正常打印出number的值。

因为这里在主线程让线程暂停了一段时间,保证子线程先运行,此时子线程读到的内存中的ready为false,并拷贝至自身的缓存,当主线程运行时,修改了ready的值,而子线程并不知道这一事件的发生,依旧在使用缓冲中的值。这正是因为多线程下缓存的不一致,即可见性问题。

如果有兴趣的朋友可以将Thread.sleep(100);这句取消,看看结果,分析一下原因。
有序性

有序性:程序执行的顺序按照代码的先后顺序执行。

可能有同学看到这一条不是很理解,而且这个相关的例子也很难给出,因为存在很大的随机性。
首先理解一下,为什么会有这一条,难道程序的执行顺序还不是按照我写的代码的顺序吗?

其实还真不一定是。

上面讲到,每个处理器都会有一个高速缓存,在程序运行中,更多次数的命中缓存,往往意味着更高效率的运行,而缓存的空间实际是很小的,可能时常需要让出空间为新变量使用。针对这一点,很多编译器内置了一个优化,通过不影响程序的运行结果,调整部分代码的位置,使得高速缓存的利用率提升。

例如

Integer a,b;
a = a + 1; //(1)
b = b - 3; //(2)
a = a + 1; //(3)

如果处理器的缓存空间很小,只能存下一个变量,那么将第(3)句放置(1),(2)句之间,是不是缓存多使用了一次,而且没有改变程序的运行结果。这就是重排序问题,当然重排序提升的不仅仅是缓存利用率,还有其他很多的方面。

到这里,可能会有疑问,不是说保证不影响程序运行结果才会有重排序发生吗,为什么还要考虑这一点。

重排序遵守一个happens-before原则,而这个原则实则并没有对多线程交替的情况进行考虑,因为这太复杂,考虑多线程的交替性还要进行重排序而不影响运行结果的最好办法,就是不排序 :-)

happens-before原则

同一个线程中的每个Action都happens-before于出现在其后的任何一个Action。

对一个监视器的解锁happens-before于每一个后续对同一个监视器的加锁。

对volatile字段的写入操作happens-before于每一个后续的同一个字段的读操作。

Thread.start()的调用会happens-before于启动线程里面的动作。

Thread中的所有动作都happens-before于其他线程检查到此线程结束或者Thread.join()中返回或者Thread.isAlive()==false。

一个线程A调用另一个另一个线程B的interrupt()都happens-before于线程A发现B被A中断(B抛出异常或者A检测到B的isInterrupted()或者interrupted())。

一个对象构造函数的结束happens-before与该对象的finalizer的开始

如果A动作happens-before于B动作,而B动作happens-before与C动作,那么A动作happens-before于C动作。

那么,多线程下的重排序会怎么样影响程序的结果呢?还是拿上一个例子来讲

public class Main {
    private static volatile Boolean ready = false;
    private static volatile Integer number = 0;

    public static void main(String[] args) throws InterruptedException {
        new Thread(() -> {
            while (!ready) ;
            System.out.println(number);
        }).start();
        number = 42; //(1)
        ready = true; //(2)
        System.out.println("Main Thread Over !");
    }
}

注意此处删除了线程休眠的代码。

这里我们假设理想的情况,现在整个程序已经满足了可见性(此处使用了volatile,具体原理可见续文),而此时发生了重排序,将(1)(2)两行的内容进行了交换,子线程开始了运行,并持续检测ready中。主线程执行,由于发生了重排序,(2)将先会执行,此时子线程看到ready变为了true,之后打印出number的值,此时,number的值为0,而预期的结果应该是42。

这就是在多线程情况下要求程序执行的顺序按照代码的先后顺序执行的原因之一。

文章版权归作者所有,未经允许请勿转载,若此文章存在违规行为,您可以联系管理员删除。

转载请注明本文地址:https://www.ucloud.cn/yun/73996.html

相关文章

  • 【推荐】最新200篇:技术文章整理

    摘要:作为面试官,我是如何甄别应聘者的包装程度语言和等其他语言的对比分析和主从复制的原理详解和持久化的原理是什么面试中经常被问到的持久化与恢复实现故障恢复自动化详解哨兵技术查漏补缺最易错过的技术要点大扫盲意外宕机不难解决,但你真的懂数据恢复吗每秒 作为面试官,我是如何甄别应聘者的包装程度Go语言和Java、python等其他语言的对比分析 Redis和MySQL Redis:主从复制的原理详...

    BicycleWarrior 评论0 收藏0
  • 【推荐】最新200篇:技术文章整理

    摘要:作为面试官,我是如何甄别应聘者的包装程度语言和等其他语言的对比分析和主从复制的原理详解和持久化的原理是什么面试中经常被问到的持久化与恢复实现故障恢复自动化详解哨兵技术查漏补缺最易错过的技术要点大扫盲意外宕机不难解决,但你真的懂数据恢复吗每秒 作为面试官,我是如何甄别应聘者的包装程度Go语言和Java、python等其他语言的对比分析 Redis和MySQL Redis:主从复制的原理详...

    tommego 评论0 收藏0
  • 后台开发常问面试题集锦(问题搬运工,附链接)

    摘要:基础问题的的性能及原理之区别详解备忘笔记深入理解流水线抽象关键字修饰符知识点总结必看篇中的关键字解析回调机制解读抽象类与三大特征时间和时间戳的相互转换为什么要使用内部类对象锁和类锁的区别,,优缺点及比较提高篇八详解内部类单例模式和 Java基础问题 String的+的性能及原理 java之yield(),sleep(),wait()区别详解-备忘笔记 深入理解Java Stream流水...

    spacewander 评论0 收藏0
  • 后台开发常问面试题集锦(问题搬运工,附链接)

    摘要:基础问题的的性能及原理之区别详解备忘笔记深入理解流水线抽象关键字修饰符知识点总结必看篇中的关键字解析回调机制解读抽象类与三大特征时间和时间戳的相互转换为什么要使用内部类对象锁和类锁的区别,,优缺点及比较提高篇八详解内部类单例模式和 Java基础问题 String的+的性能及原理 java之yield(),sleep(),wait()区别详解-备忘笔记 深入理解Java Stream流水...

    xfee 评论0 收藏0

发表评论

0条评论

gecko23

|高级讲师

TA的文章

阅读更多
最新活动
阅读需要支付1元查看
<