Java并发编程学习笔记(1)基本概念

线程的生命周期状态

  • NEW:一个已创建而未启动的线程出于该状态。
  • RUNNABLE:该状态可以被看成一个复合状态,包含两个子状态:READY和RUNNING。前者表示线程可以被线程调度器(Scheduler)进行调度而使之处于RUNNING状态。后者表示该线程只在运行。执行Thread.yield()线程可能由RUNNING转换为READY。处于READY子状态的线程也被称为活跃线程。

    Thread.yield()方法是指一个线程告诉虚拟机它乐意让出正在使用的处理器。当然线程调度器可以自由地忽略这种暗示。一般当线程没有在做一些紧急事情的时候可以调用。

  • BLOCKED:一个线程发起一个阻塞式I/O(Blocking I/O)操作后,或者申请一个其他线程持有的独占资源时,相应的线程会处于该状态。此时线程不会占用处理器资源。当I/O结束或者获得独占资源时,线程可以转换为RUNNABLE状态。

  • WAITING:一个线程执行了某些特定的方法之后会处于这种等待其他线程执行另外一些特定操作的状态。这些特定方法包括:

    • Object.wait();
    • Thread.join();
    • LockSupport.park(Object);

    能够使相应线程从WAITING变更为RUNNABLE的相应方法包括:

    • Object.notify()/notifyAll();
    • LockSupport.unpark(Object);
  • TIMED_WAITING:该状态和WAITING类似,差别在于处于该状态的线程并非无限制地等待其他线程执行特定操作,而是处于带有时间限制的等待状态。当其他线程没有在指定时间内执行该线程所期望的特定操作时,该线程的状态自动转换为RUNNABLE。

  • TERMINATED:已经执行结束的线程处于该状态。Thread.run()正常返回或者由于抛出异常而提前终止都会导致线程出于该状态。

线程的状态图如下:
Java并发编程学习笔记(1)基本概念_第1张图片

区分BLOCKED与WAITING。前者是指线程正在等待获取锁或者等待I/O,被动卡住。后者是指线程主动卡住,在等待其他线程发来通知(notify),收到通知后会再次获取锁,获取成功则进入RUNNABLE状态,否则被阻塞(BLOCKED)。处于WAITING状态的线程会释放CPU执行权,并释放锁资源。


多线程的一些基础概念

竞态(Race Condition)

竞态指的是:计算的正确性依赖于相对时间顺序或者线程的交错顺序。一般有以下两种模式:
1. read-modify-write:
read-modify-write操作可以被细分为:读取一个共享变量的值(read),然后根据该值做一些计算(modify),接着更新该共享变量的值(write)。例如 i++; 就是raed-modify-write模式的一个实例。相当于如下伪代码:
1. load(i, r1);
2. increment(r1);
3. store(i, r1);

当一个线程在执行完指令①之后到开始执行指令②的这段时间内,其他线程可能已经更新了共享变量 i 的值,这就使得该线程在执行指令②的时候使用的是共享变量的旧值(读脏数据)。接着该线程把旧值计算出来的结果更新到共享变量中,又使得其他线程对该共享变量所做的更新被覆盖,导致了丢失更新和读脏数据的问题。
  1. check-then-act:
    check-then-act操作可以细分为以下几个步骤:读取某个共享变量的值,根据该共享变量的值决定下一步的动作是什么。例如:
    java
    if(i >= 99){ //子操作①check:检测共享变量的值
    //do something 子操作②act:下一步的操作
    } else {
    //do other things
    }

    问题出现在,当一个线程在执行完子操作①到开始执行子操作②的这段时间内,其他线程可能已经更新了共享变量的值,而使得if语句中的条件不成立,但此时该线程仍然会执行子操作②。

由上面两种模式可以总结产生竞态的一般条件是:设O1和O2是并发访问同一个共享变量V的两个操作,这两个操作并发都是读操作。如果一个线程在执行O1期间,另外一个线程正在执行O2,那么无论O2是在读取还是更新V都会导致静态。竞态可以被看做访问(读取、更新)同一组共享变量的多个线程所执行的操作相互交错。

保证操作的原子性可以消除竞态。


线程安全的三个问题

原子性(Atomicity)

概念

原子性指的是:对于设计共享变量访问的操作,若该操作从其执行线程以外的任意线程来看是不可分割的,那么该操作就是原子操作,相应地我们称该操作具有原子性。
对于原子操作这个概念还要注意两点:
1. 原子操作是针对访问共享变量的操作而言的。也就是说仅涉及局部变量访问的操作无所谓是否是原子的。
2. 原子操作是从该操作的执行线程以外的线程来描述的,它只有在多线程的环境下有意义。换言之,单线程环境下一个操作无所谓是否具有原子性,或者我们干脆把这一类操作都看成原子操作。

原子操作中的“不可分割”包含以下两层含义:
* 访问(读、写)某个共享变量的操作从其执行线程以外的任何线程来看,该操作要么已经执行结束要么尚未发生。即其他线程不会看到该操作执行了部分的中间效果
* 访问同一组共享变量的原子操作是不能够被交错的。

实现原子性

在Java中提供了两种方式来实现原子性:
1. 使用锁。锁具有排他性,能够保障一个共享变量在任意一个时刻只能够被一个线程访问,这就排除了多个线程在同一时刻访问同一个共享变量而导致干扰与冲突的可能,即消除了竞态。
2. 利用处理器提供的专门CAS(Compare-and-Swap)指令,CAS指令实现原子性的方式与锁实质上是相同的,差别在于锁通常是在软件这一层次实现的,而CAS是直接在硬件这一层次实现的,它可以被看做是“硬件锁”。

在Java中,long型和double型以外的任何基础类型(除long/double以外,即byte、boolean、short、char、float、int)的变量的写操作都是源自操作。这是由Java虚拟机规范规定的。而long/double型变量会占用64位(8字节)空间,而32位虚拟机对这种变量的写操作可能会被分解为两个步骤来进行,比如先写低32位,再写高32位,那么在多个线程视图共享同一个这样的变量时就可能出现一个线程在写低32位,另一个线程在写高32位的情况。对于long/double,我们可以通过volatile关键字来保证写操作的原子性。


可见性(Visibility)

概念

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

问题的原因

  1. JIT编译器的优化导致:
public class TimeTask implements Runnable {
    private boolean toCancel = false;

    @Override
    public void run() {
        while (!toCancel) {
            if (doExecute()) {
                break;
            }
        }
        //other codes.
    }

    private boolean doExecute() {
        boolean isDone = false;
        //do something

        return isDone;
    }
}

以上的代码,如果没有给JIT编译器足够的提示,如使用volatile修饰toCancel变量,导致JIT编译器认为toCancel只有一个线程对其进行访问,从而导致JIT编译器为了避免重复读取状态变量toCancel以提高代码运行效率,而将上面的代码的run方法中的while循环优化成如下的本地代码(机器码):

if(!toCancel){
    while(true){
    ....
    }
}

从而导致某个线程更新toCancel变量时,执行run方法的线程仍然无法结束循环,这种优化称为循环不变式外提(Loop-invariant Code Motion),也称为循环提升(Loop Hoisting)。


  1. 由计算机的存储系统导致的可见性问题:每个处理器都有其寄存器,而一个处理器无法读取另外一个处理器上的寄存器的内容。因此如果两个线程分别运行在不同的处理器上,其中一个处理器上运行线程对变量的更新可能只更新到了它的写缓冲器(Store Buffer),还没有更新到高速缓存中,更不用说主内存中,这样另外一个处理器就无法读取到这个共享变量的更新。

一个处理器可以通过缓存一致性协议(Cache Coherence Protocol)实现读取另一个处理器的高速缓存中的内容。

解决可见性问题

使用volatile关键字:volatile关键字起到的一个作用是提示JIT编译器被修饰的变量可能被多个线程共享,不要做出可能导致问题的优化。另一个作用是读取一个volatile关键字修饰的变量会使相应的处理器执行刷新处理器缓存的动作,写一个volatile关键字修饰的变量会使相应的处理器执行冲刷处理器缓存的动作。
另外,Java语言规范中保证:
* 父线程在启动子线程之前对共享变量的更新对于子线程来说是可见的。
* 一个线程终止后,该线程对共享变量的更新对于调用该线程的join方法的线程而言是可见的。

单处理器系统也会存在可见性问题,因为单处理器系统中多线程的并发是通过上下文切换实现的,一个线程对寄存器变量的修改会被作为该线程的线程上下文保存起来,另一个线程无法看到该线程对这个变量的修改,从而导致可见性问题。


有序性(Ordering)

概念

有序性程序的感知顺序与源代码顺序一致。

重排序

有序性问题出现的原因是重排序。重排序是对内存访问有关的操作所做的一种优化,它可以在不影响单线程程序正确性的情况下提升程序的性能,但是在多线程环境下可能导致线程安全的问题。首先了解一下内存操作顺序:
* 源代码顺序:源代码中所指定的内存访问顺序。
* 程序顺序:给定处理器上运行的目标代码所指定的内存访问操作顺序。
* 执行顺序:内存访问操作在给定处理器上的实际执行顺序。
* 感知顺序:给定处理器所感知到的该处理器及其他处理器的内存访问操作发生的顺序。

重排序根据内存操作顺序不同表现为两大类:
* 指令重排序:
* 由javac编译器导致的程序顺序与源代码顺序不一致。
* 由JIT编译器、处理器导致的执行顺序与程序顺序不一致。

  • 存储子系统重排序:由高速缓存、写缓冲器导致的源代码顺序、程序顺序和执行顺序保持一致,但是感知顺序与执行顺序不一致。
指令重排序

在Java平台上,javac编译器基本上不会执行指令重排序。而JIT编译器则可能执行重排序。典型的如Double Check Lock的单例模式中,缺少volatile修饰导致DCL失效的情况:

mInstance = new Singleton();

这条new语句实际可分为三步操作,用伪代码表示如下:

//子操作①:分配Singleton实例所需的内存空间,并获得一个指向该空间的引用。
objRef = allocate(Singleton.class); 
//子操作②:调用Helper类的构造器初始化objRef引用指向的Singleton实例
invokeConstructor(objRef);
//子操作③:将Singleton实例引用objRef赋值给实例变量mInstance
mInstance = objRef;

上述操作在运行过程中,JIT编译器可能会将子操作③相应的指令重排到子操作②之前,这就导致其他线程看到mInstance实例变量(不为null)的时候,该实例变量所引用的对象可能还没有初始化完毕,从而出现问题。

除此之外,处理器也可能执行指令重排序,处理器乱序执行指令,这些指令执行的结果会被先存入重排序缓冲器,而不是直接被写入寄存器或者主内存,而后重排序缓冲器会将各个指令的执行结果按照相应指令被处理器读取的顺序提交。

猜测执行(Speculation)技术:处理器的猜测执行技术会导致乱序执行。猜测执行能够造成if语句的语句体先于其条件语句执行。将语句体执行的结果暂存,再判断条件是否满足,满足则将结果写入主存,否则丢弃结果。

存储子系统重排序

存储子系统指的是写缓冲器和高速缓存,它们其实是处理器的子系统。对于以下两个操作:

data = 1; //S1
ready = true; // S2

假设处理器P0按顺序先后执行了S1和S2,由于某些处理器为了提高效率而重排序了写回顺序,即S2的操作结果先于S1写回高速缓存。这时若另一个处理器P1上执行以下代码:

while(!ready){} //L3
System.out.println(data); //L4

P1上读取到ready为true,但是data的值仍然是其初始值0。这样就出现了因存储子系统重排序导致的错误。

解决有序性问题

使用volatile、synchronized等能够解决指令重排序的问题。具体接下来的笔记中会详细记录。

你可能感兴趣的:(JAVA)