并发编程的目标与挑战

If I had only one hour to save the worlds,I would spend fifty-five minutes defining the problem,and only five minutes finding the solution.

如果我只有1小时拯救世界,我将花55分钟定义这个问题而只花分钟去寻找解决方案 ——Albert Einstein

本文讲解的将是多线程的一些重要概念,为接下来自己以及读者更好的理解并发编程做个铺垫。

之后会讲解volatile关键字,CAS , AQS 等等,总之概念是实践的基石

1.1 竞态

多线程编程中经常遇到一个问题就是对于同样的输入,程序的输出有时候是正确的,而有时候却是错误的。这种一个计算结果的正确性与时间有关的现象就被称为竞态(Race Condition)。

java核心技术-多线程基础 中 1.1 (2)

public class Ticket implements Runnable{

    private int ticket = 100;

    @Override
    public void run() {
        while(ticket > 0){
            System.out.println(Thread.currentThread().getName() + "=" + --ticket);
        }

    }
}
public class TestThread2 {
    public static void main(String[] args) {

        Ticket ticket = new Ticket();

        //虽然是实现了Runnable接口 本质上只是实现了线程执行体 启动工作还是需要Thread类来进行
        Thread t1 = new Thread(ticket,"售票窗口一");
        t1.start();

        Thread t2 = new Thread(ticket,"售票窗口二");
        t2.start();

        Thread t3 = new Thread(ticket,"售票窗口三");
        t3.start();
    }
}

卖票的CASE,此案例中竞态导致的结果是不同业务的线程可能拿到了重复的ticket(票),且可能出现ticket为负数的情况。

可见 while(ticket > 0) 以及 --ticket 这两个操作 是祸端之源。

进一步来说,导致竞态的常见因素是多个线程 在没有采取任何控制措施的情况下,并发地更新、读取同一个共享变量

有朋友可能会说:--ticket 操作 是一个操作啊 你怎么能说是祸端之源

其实不是的,只是看起来像是一个操作而已,它实际上 相当于如下伪代码所表示的三个指令

load(ticket,r1); //指令①:将变量ticket 的值从内存读到寄存器r1
decrement(r1); //指令②:将寄存器r1的值减少1
store(ticket,r1);//指令③:将寄存器r1的内容写入变量ticket所对应的内存空间

而 ①②③并不能保证是一个原子操作,两个业务线程可能在同一时刻读取到ticket的同一个值,一个业务线程对ticket所做的更新也可能"覆盖"其他线程对该变量做的更新,所以,问题不言而喻.....

1.2 竞态的模式与竞态产生的条件

从上述竞态的典型实例中,我们可以提炼出竞态的两种模式:

① read-modify-write(读改写)

② check-then-act (检测而后行动)

read-modify-write(读改写)操作可以被细分为这样几个步骤:读取一个共享变量的值(read),然后根据该值做一些计算(modify),接着更新该共享变量的值。例如 --ticket

check-then-act (检测而后行动) ,该操作可以被细分为这样几个步骤:读取某个共享变量的值,根据该共享变量的值决定下一步的动作是什么。while(ticket > 0) --ticket

但是对于局部变量(包括形式参数和方法体内定义的变量),由于不同的线程各自访问的各自访问的是各自的那一份局部变量,因此局部变量的使用不会导致竞态,如下例

public class NoRaceCondition {

    public int nextSequence(int sequence){
        if(sequence >= 999){
            sequence = 0;
        }else{
            sequence++;
        }
        return sequence;
    }

}

1.3 线程安全性

一般而言,如果一个类在单线程环境下能够正常运行,并且在多线程环境下,在其使用方不必为其做任何改变的情况下也能正常运行,那么我们就称其是线程安全的,相应的我们称这个类具有线程安全性,反之亦然。而一个类如果是线程安全的,那么它就不会导致竞态。

线程安全问题概括来说表现为3个方面: 原子性、可见性、有序性

1.3.1 原子性

原子(Atomic) 的字面意思是不可分割的。其含义简单的来说就是,访问(读、写)某个共享变量的操作从执行线程以外的任何线程来看,该操作要么已经执行结束,要么尚未发生,即其他线程不会"看到"该操作线程执行了部分的中间效果

在生活中我们可以找到的一个原子操作的例子就是人们从 ATM 机提取现金; 尽管从ATM软件的角度来说,一笔交易涉及扣减主账户余额、吐钞器吐出钞票、新增交易记录等一系列操作,但是从用户的角度来看 ATM取款就是一个操作。 该操作要么成功了,我们拿到了现金。要么失败了,我们没有拿到现金。

理解原子操作要注意以下两点:

  • 原子操作是针对访问共享变量的操作而言的
  • 原子操作是从该操作的执行线程以外的线程来描述的

总的来说,Java 中有两种方式来实现原子性。

一种是使用锁(Lock)。锁具有排他性,即它能保证一个共享变量在任意时刻只能够被一个线程访问。这就排除了多个线程在同一时刻访问通一个共享变量而导致干扰与冲突的可能,即消除了竞态。

另一种是利用处理器处理器专门提供的 CAS(Compare-and-Swap)指令 ,CAS 指令实现原子性的方式与锁实现原子性的方式实质上相同的,差别在于锁通常是在软件这一层次实现的,而CAS 是直接在硬件(处理器和内存) 这一层次实现的,它可以被看作"硬件锁"

在Java 语言中,long型 和 double型 以外的任何基础类型的变量的写操作 都是原子操作。

对 long/double 型变量的写操作 由于 Java语言规范并不保障其具有原子性,因此多个线程并发访问同 一 long/double型变量的情况下,一个线程可能会读取到其他线程更新该变量的"中间结果"(64位的虚拟机应该不会出现这个问题);

注:使用32位虚拟机 用对个线程对long,double型数据进行操作 会有低32位 高32位的问题,尽管如此可以使用volatile关键字进行解决,它可以保证变量写操作的原子性,即线程共享变量 刷新到主存这个动作是原子的

1.3.2 可见性

在多线程环境下,一个线程对某个共享变量进行更新后,后续访问该变量的线程可能无法立刻读取到这个更新的结果,甚至永远无法读取到这个更新的结果。这就是线程安全问题的另外一个表现形式:可见性

下面我们来一个Demo吧

public class ThreadVolatile{
    public static void main(String[] args) {
        ThreadDemo td = new ThreadDemo(); //01
        new Thread(td).start();//02

        while(true){
            if(td.isFlag()){//03
                System.out.println("-----------------");
                break;
            }
        }
    }
}

class ThreadDemo implements Runnable{

    private boolean flag = false;

    @Override
    public void run() {
        //此处的目的 是让main线程 从主存那 先获取flag等于false的值
        try {
            Thread.sleep(200);
        } catch (Exception e) {
        }
        flag = true;//04
        System.out.println("flag=" + flag);
    }

    public boolean isFlag(){
        return flag;
    }

    public void setFlag(boolean flag){
        this.flag = flag;
    }

}

运行结果:

? 打印flag=true, 但循环无法终止

在解释原因之前先说几个概念:(很重要)

  • 栈:线程独有,保存其运行状态以及局部自动变量,操作系统在切换线程的时候会自动切换栈,也就是切换寄存器
  • 堆:保存对象的实体以及全局变量,可以把堆内存 约看成 主内存

01-初始化完ThreadDemo 内存空间:

02.子线程ThreadDemo启动 获取到flag=false的值 开始睡觉

03.main线程获得了flag=false的值 在循环体中跑了若干次

04.由于03步骤main线程获得了flag=flase,虽然主存变了,但是由于while(true)执行效率太高,根本没有时间让主存中的数据同步到main线程中去,所以main线程一直在死循环

那么,在Java平台中 如何保证可见性呢?

对于上例Demo,我们只需将其flag的声明添加一个volatile关键字即可,即

private volatile boolean flag = false;

这里,volatile关键字所起到的一个作用就是,提示JIT编译器被修饰的变量可能被多个线程共享,以组织JIT编译器做出可能导致运行不正常的优化 (重排序)。另外一个作用就是 读取一个volatile关键字所修饰的变量会使相应的处理器执行刷新处理器缓存的动作

1.3.3 有序性

有序性 指在什么情况下一个处理器上的运行的一个线程所执行的内存访问操作在另外一个处理器上运行的其他线程看来是乱序的。(某书定义)

我的理解:程序运行顺序要与代码逻辑顺序保持基本一致,避免多线程情况由于重排导致的错误

所谓乱序,是指内存访问操作的顺序看起来像是发生了变化。在进一步介绍有序性概念之前,我们需要介绍重排序的概念

重排序:是指编译器和处理器为了优化程序性能而对指令序列进行重新排序的一种手段

  • 指令重排序:源代码顺序与程序顺序不一致,或者程序顺序与执行顺序不一致的情况下 (编译器,处理器)
  • 存储子系统重排:源代码顺序、程序顺序和执行顺序这三者保持一致,但是感知顺序与执行顺序不一致 (高速缓存,写缓冲器)

注:这一块建议了解编译原理 以及汇编

as-if-serial语义:编译器和处理器不会对存在数据依赖关系的操作做重排序,因为这种重排序会改变程序执行结果。但是,如果操作之间不存在数据依赖关系,这些操作就可以被编译器和处理器重排序。

示例:

double pi = 3.14;  // A
double r = 1.0;     //B
double area = pi * r * r; //C

分析:A与C之间存在数据依赖关系,所以C不能排到A的前面,同时B与C之间也存在数据依赖关系,所以,C也不能排到B的前面,但是A与B之间是不存在数据依赖关系的,所以A与B之间是可以进行重排序的。

程序顺序规则:

根据happens-before的程序规则,上面的计算圆的示例代码存在3个happens-before关系:

A happens-before B ; B happens-before C; A happens-before C;

重排序对多线程的影响:

class RecorderExample{
    int a = 0;
    boolean flag = false;
    public void writer(){
        a = 1; // 1
        flag = true; // 2
}
    public void reader(){
        if(flag){          // 3
            int i = a * a;  // 4
             ......
    }
}
}

flag是一个变量,用来表示变量a是否已被写入。这里假设有两个线程A和B ,A线程首先执行writer方法,随后线程B执行reader方法。线程B在执行操作4的时候,能否看到线程A在操作共享变量a的写入呢?

答案是:在多线程的情况下,不一定能看到;

由于操作1和操作2没有数据依赖的关系,编译器和处理器可以对这两个操作进行重排序,操作3和操作4没有数据依赖关系,编译器和处理器也可以对其进行重排序,下面我们看一下可能的执行情况的示意图:

如上所示,操作1 和操作2 进行了重排序。程序执行时,线程A首先写标记变量flag,随后线程B读这个变量。由于判断条件为真,线程B将读取变量a。此时,变量a还没有被线程A写入,所以在这里,多项层程序的语义就被重排序破坏了。

下面在看一下操作3和操作4重排序会发生什么效果:

在程序中,操作3和操作4存在控制依赖关系。当代码中存在控制依赖行时,会影响指令序列执行的并行度。为此,编译器和处理器会采用猜测执行来克服控制相关性对并行度的影响。以处理器的猜测执行为例,执行线程B的处理器可以提前读取并计算a*a,然后把计算结果临时保存到一个名为重排序缓冲的硬件缓存中。当操作3的条件判断为真的时候,就把该结算结果写入到变量i中。

从上图我们可以看出,猜测执行实质上是对操作3和操作4进行了重排序,重排序在这里破坏了多线程程序的语义。

在单线程程序中,对存在控制依赖的操作进行重排序,不会改变执行结果(这也是as-if-serial 语义允许对存在控制依赖的操作做重排序的原因),但是在多线程的程序中,对存在控制依赖的操作重排序,可能会改变程序的执行结果。

原文地址:https://www.cnblogs.com/dwlovelife/p/9944085.html

时间: 2024-10-17 06:14:49

并发编程的目标与挑战的相关文章

OC中并发编程的相关API和面临的挑战

OC中并发编程的相关API和面临的挑战(1) 小引 http://www.objc.io/站点主要以杂志的形式,深入挖掘在OC中的最佳编程实践和高级技术,每个月探讨一个主题,每个主题都会有几篇相关的文章出炉,2013年7月份的主题是并发编程,今天挑选其中的第2篇文章(Concurrent Programming: APIs and Challenges)进行翻译,与大家分享一下主要内容.由于内容比较多,我将分两部分翻译(API和难点)完成,翻译中,如有错误,还请指正. 目录 1.介绍 2.OS

JAVA并发编程艺术 一(并发编程的挑战)

从今天起开始java并发编程艺术的学习,每一章学习完以后再这里记录下内容的重点,做个笔记,加深印象. 并发编程的目的是为了让程序运行的更快,但是,并不是启动更多的线程就能让程序最大限度地并发执行.在进行并发是,如果希望通过多现场执行任务让程序运行得更快,会面临非常多的挑战,比如上下文切换的问题,死锁的问题,以及受限于硬件和软件的资源限制问题,本章会介绍几种并发编程的挑战以及解决方案 1.上下问切换 即使是单核处理器也支持多线程执行代码,cpu通过给每个线程分配cpu时间片来实现这个机制.时间片是

那些年读过的书《Java并发编程的艺术》一、并发编程的挑战和并发机制的底层实现原理

一.并发编程的挑战 1.上下文切换 (1)上下文切换的问题 在处理器上提供了强大的并行性就使得程序的并发成为了可能.处理器通过给不同的线程分配不同的时间片以实现线程执行的自动调度和切换,实现了程序并行的假象. 在单线程中:线程保存串行的执行,线程间的上下文切换不会造成很大的性能开销. 而在多线程中:线程之间频繁的调度需要进行上下文切换以保存当前执行线程的上下文信息和加载将要执行线程的上下文信息,而上下文切换时需要底层处理器.操作系统.Java虚拟机提供支持的会消耗很多的性能开 销.如果频繁的进行

Java并发编程的挑战

并发编程的目的是为了让程序运行得更快,但是,并不是线程启动的越多,就能让程序最大限度地并发执行.并发编程时,会面临非常多的挑战,比如上下文切换的问题,死锁的问题,以及受限于各种硬件和软件的资源限制问题. CPU通过时间片分配算法来循环执行任务,当前任务执行一个时间后会切换到下一个任务.但是,在切换前会保存上一个任务的状态,以便下次切换回这个任务时,可以再加载到这个任务的状态.从任务保存到再加载的过程就是一次上下文切换. vmstat 1#vmstat可以测试上下文切换的次数.输出结果里的CS(C

第一章 并发编程的挑战

挑战一:上下文切换 多线程一定比单线程快么? public class ConcurrencyTest { private static final long count = 10001; public static void main(String[] args) throws InterruptedException { concurrency(); serial(); } private static void concurrency() throws InterruptedExcepti

第1章 并发编程的挑战

并发编程的目的是为了让程序运行的更快,但是并不是启动更多的线程就能让程序最大限度的并发执行.会面临非常多的挑战,比如上下文切换的问题.死锁的问题,以及软件硬件资源的问题等. 1.1 上下文切换 即使是单核处理器也支持多线程执行代码,CPU通过分配CPU时间片来实现这个机制.时间片是CPU分配给各个线程的时间,因为时间片非常短,所以CPU通过不停的切换线程执行,让我们感觉多个线程是同时执行的,时间片一般是几十毫秒(ms).在切换前会保存上一个任务的状态,以便下次切换回这个任务时,可以再加载这个任务

1.并发编程挑战-上下文切换

并行和串行执行 package com.qdb.thinkv.thread.base; /** * 问题:多线程一定快吗?不一定,通过调整 count分别设置 比如1w 和 1亿 进行比较 * 思考:如何减少上下文切换? */ public class ConcurrencyTest { public static final long count=1000000000L; public static void main(String[] args) throws InterruptedExce

[书籍翻译] 《JavaScript并发编程》 第二章 JavaScript运行模型

本文是我翻译<JavaScript Concurrency>书籍的第二章 JavaScript运行模型,该书主要以Promises.Generator.Web workers等技术来讲解JavaScript并发编程方面的实践. 完整书籍翻译地址:https://github.com/yzsunlei/javascript_concurrency_translation .由于能力有限,肯定存在翻译不清楚甚至翻译错误的地方,欢迎朋友们提issue指出,感谢. 本书第一章我们探讨了JavaScri

[书籍翻译] 《JavaScript并发编程》第五章 使用Web Workers

本文是我翻译<JavaScript Concurrency>书籍的第五章 使用Web Workers,该书主要以Promises.Generator.Web workers等技术来讲解JavaScript并发编程方面的实践. 完整书籍翻译地址:https://github.com/yzsunlei/javascript_concurrency_translation .由于能力有限,肯定存在翻译不清楚甚至翻译错误的地方,欢迎朋友们提issue指出,感谢. Web workers在Web浏览器中