并发编程的目标与挑战

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 语义允许对存在控制依赖的操作做重排序的原因),但是在多线程的程序中,对存在控制依赖的操作重排序,可能会改变程序的执行结果。

posted @ 2019-01-23 23:22  丁可乐  阅读(632)  评论(0编辑  收藏  举报