Java多线程编程实战指南学习(二)

多线程编程的目标与挑战

  • 1.串行、并发与并行
  • 2.竞态
    • 2.1 二维表分析法:解释竞态的结果
    • 2.2 竞态的模式与产生的条件
  • 3.线程安全性
  • 4.原子性
  • 5.可见性
    • 线程的启动、停止与可见性
  • 6.有序性
    • 6.1重排序的概念
    • 6.2指令重排序
    • 6.3存储子系统重排序
    • 6.4貌似串行语义
  • 7.上下文切换
    • 7.1上下文切换及其产生原因
    • 7.2上下文切换的分类及原因
    • 7.3上下文切换的开销及测量
  • 8.线程的活性故障
  • 9.资源的争用与调度

1.串行、并发与并行

假设有三件事A、B、C需要完成,其中完成事情A需实际投入5分钟并等待10分钟,完成事情B需要实际投入2分钟并等待8分钟,完成事情C需实际投入10分钟但不需要等待。那有以下三种方式来完成这几件事:
串行:先做事情A,待其完成再做事情B,以此类推。这种方式共需投入1个人,35分钟。
并发:这种方式也可以只投入一个人,其先把A的准备活动做好(实际投入的五分钟),再把B的准备活动做好,最后完成C。这种方式只需要17分钟。
并行:这种方式需要三个人同时做A、B、C,共需15分钟。
从软件的角度来说,并发就是在一段时间内以交替的方式去完成多个任务,而并行计算以齐头并进的方式去完成一个任务。多线程编程的实质就是将任务的处理方式从串行改为并发,实现并发化,发挥并发的优势。如果一个任务可以由串行改为并发(或串行)我们就称之为可并发化的(或可并行化的)。

2.竞态

多线程编程经常遇到的一个问题就是对于同样的输入,程序的输出有时是正确的有时是错误的。这样一个计算结果正确性与时间有关的现象称为竞态。
如下举一个竞态实例,某系统为了便于跟踪其接收到的HTTP请求的处理,会为收到的每个HTTP请求分配一个唯一编号Request ID。一个Request ID生成器的源码示例如下:

import java.text.DecimalFormat;
import java.text.SimpleDateFormat;
import java.util.Date;

public class RequestIDGenerator{
    private final static RequestIDGenerator INSTANCE = new RequestIDGenerator();
    private final static short SEQ_UPPER_LIMIT = 999;
    private short sequence = -1;
    private RequestIDGenerator()    {

    }
    public short nextSequence(){
        if (sequence >= SEQ_UPPER_LIMIT)
        {
            sequence = 0;
        }
        else {
            sequence++;
        }
        return sequence;
    }
    public String nextID()
    {
        SimpleDateFormat sdf = new SimpleDateFormat("yyMMddHHmmss");
        String timestamp = sdf.format(new Date());
        DecimalFormat df = new DecimalFormat("000");
        short sequenceNo = nextSequence();
        return "0049"+timestamp+df.format(sequenceNo);
    }
    public static RequestIDGenerator getInstance()
    {
        return INSTANCE;
    }
}

一个可能产生竞态的模拟业务如下:


import java.util.Random;
import java.util.Scanner;

public class RaceConditionDemo {
    public static void main(String[] args) throws Exception{
        Scanner in = new Scanner(System.in);
        int numberofThreads = in.nextInt();
        Thread[] workerThreads = new Thread[numberofThreads];
        for (int i = 0; i < numberofThreads; i++)
        {
            workerThreads[i] = new WorkerThread(i,10);
        }
        for (Thread ct : workerThreads)
        {
            ct.start();
        }
    }
    static class WorkerThread extends Thread{
        private final int requestCount;
        public WorkerThread(int id, int requestCount)
        {
            super("worker-"+id);
            this.requestCount = requestCount;
        }
        @Override
        public void run()
        {
            int i = requestCount;
            String requestID;
            RequestIDGenerator requestIDGenerator = RequestIDGenerator.getInstance();
            try {
                while (i-- > 0)
                {
                    requestID = requestIDGenerator.nextID();
                    processRequest(requestID);
                }
            }
            catch (Exception e)
            {
                System.out.println(e.getMessage());
            }
        }
        private void processRequest(String requestID) throws Exception
        {
            int a = new Random().nextInt(80);
            Thread.currentThread().sleep(a);
            System.out.printf("%s got requestID: %s %n",Thread.currentThread().getName(),requestID);
        }
    }
}

实际运行后会发现上述程序的输出有时是正确的有时是错误的,也就是产生了竞态。

2.1 二维表分析法:解释竞态的结果

术语定义:
状态变量:类的实例变量、静态变量。
共享变量:可以被多个线程共同访问的变量。共享变量中的“共享”强调的是“可以被共享”的可能性,因此称呼一个变量为共享变量并不表示该变量一定被多个线程访问。
上述例子中竞态导致的结果是不同业务线程“拿到”了重复的RequestID。这个结果说明RequestIDGenerator中的nextSequence()所返回的序列号重复了。可见,nextSequence()是导致上述竞态的直接因素。进一步来说,导致竞态的常见因素是多个线程在没有采取任何控制措施的情况下并发更新、读取同一个变量。
nextSequence()中的语句"sequence++”看起来像是一个操作,但是它实际上相当于以下伪代码表示的3个指令:
load(sequence,r1);//指令①:将变量sequence的值从内存读取到寄存器r1
increment(r1);//指令②:将寄存器r1的值增加1
store(sequence,re);//指令③:将寄存器r1的内容写入写入变量sequence所对应的内存空间
因此,上述例子中的4个线程有可能如下表所示的顺序交错进行。下表为竞态的二维表分析法示例:

时刻\线程 worker-0 worker-2 worker-3 worker-1
t1 <未运行> 执行其他操作 执行其他操作 执行指令③
t2 执行其他操作 [sequence==0]执行指令① [sequence==0]执行指令① [sequence==0]执行其他操作
t3 执行其他操作 [r1==0]执行指令② [r1==0]执行指令② 执行其他操作
t4 [sequence==0]执行指令① [r1== 1] [sequence==0]执行指令③ [r1== 1] [sequence==0]执行指令③ <运行结束>
t5 [r1==0]执行指令② [sequence==1]执行其他操作 [sequence==1]执行其他操作 <运行结束>
t6 [r1==1]执行指令③ <运行结束> <运行结束> <运行结束>
t7 [sequence==1]执行其他操作 <运行结束> <运行结束> <运行结束>

可见,worker-0,worker-2,worker-3最终拿到的序号都是“001”,从而导致这些线程最终获得的RequestID也是重复的。这个重复是读取脏数据(发生在t5时刻)、丢失更新(发生在t6和t7时刻)导致的。
根据上述分析可以进一步定义竞态:竞态是指计算的正确性依赖于时间顺序或者线程的交错。竞态不一定导致计算结果的不正确,它只是不排除计算结果时而正确时而错误的可能。
竞态往往伴随着读取脏数据的问题,即线程读取到一个过时的数据、修饰更新问题,即一个线程对数据所做的更新没有体现在后续其他线程对该数据的读取上。

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

上述示例中科院提炼出竞态产生的两种模式:read-modify-write(读-改-写)和check-then-act(检测而后行动)。
读-改-写操作可以被细分为以下几个步骤:读取一个共享变量的值,根据该值做以下计算,接着更新该共享变量的值。如“sequence++”计算读-改-写类型的一个实例,去相当于如下伪代码表示的几个指令的组合:
load(sequence,r1);//指令①:将变量sequence的值从内存读取到寄存器r1(读取共享变量的值)
increment(r1);//指令②:将寄存器r1的值增加1(根据共享变量的值做一些计算)
store(sequence,re);//指令③:将寄存器r1的内容写入写入变量sequence所对应的内存空间(更新共享变量的值)
一个线程在执行完指令①之后到开始执行指令②的这段时间内其他线程可能已经更新了共享变量的值,这就导致该线程执行指令②的过程中使用的是共享变量的旧值(读脏数据)。接着,该线程把根据旧值计算出的结果更新到共享变量,而这又是的其他线程对该共享变量的所做的更新被“覆盖”,即造成了更新丢失。
检测而后行动操作可以被细分为这样几个步骤:读取某个共享变量的值,根据该变量的值决定下一步的动作是什么。RequestIDGenerator类中的一个示例如下:

if (sequence >= 999)//子操作①check:检测共享变量的值
        {
            sequence = 0;//子操作②act:下一步的操作
        }
        else {
            sequence++;
        }

执行完子操作①到开始执行子操作②的这段时间内,其他线程可能已经更新了共享变量的值使得if语句中的条件变为不成立,那么此时该线程仍然会执行子操作②,尽管这个子操作所需的前提实际上并不成立。
从上述分析可以看出产生竞态的一般条件。三个O1和O2是并发访问共享变量V的两个操作,并且这两个操作并非都是读操作。如果一个线程在执行O1期间(开始执行而未执行结束)另外一个线程在执行O2,那么无论O2是在读取还是更新V都会导致竞态。通过这个角度看,竞态可以被看做访问(读取、更新)同一组共享变量的多个线程所执行的操作相互交错 。
对于局部变量(包括形式参数和方法体内定义的变量),由于不同的线程各自访问的是各自的那一份局部变量,因此局部变量的使用不会产生竞态。
对于之前所示Demo中产生的竞态问题,其中一个解决方法就是在RequestIDGenerator.nextSequence()的声明中添加一个synchronized关键字。如下所示

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

synchronized关键字会使其修饰的方法在任一时刻只能被一个线程执行,这是的该方法涉及的共享变量在任一时刻只能由一个线程访问(读、写),从而避免了这个方法的交错执行而导致的干扰,这样就消除了竞态。

3.线程安全性

一般而言,如果一个类在单线程环境下能够运作正常,并且在多线程环境下,在其使用方不必为其做任何改变的情况下也能运作正常,那么我们就称其是线程安全的,相应的我们称这个类具有线程安全性。反之这个类就是非线程安全的。
使用一个类的时候必须弄清这个类是否是线程安全的。因为这关系到我们如何正确的使用这些类。一个类如果不是线程安全的,我们称其在多线程环境下有线程安全问题。线程安全问题概括来说表现为三个方面:原子性、可见性和有序性。

4.原子性

原子的字面意思是不可分割的。对于涉及共享变量访问的操作,若该操作从其执行线程以外的任意线程来看都是不可分割的,那么该操作就是原子操作,相应的我们称该操作具有原子性。所谓“不可分割”,其中一个含义是指访问(读、写)某个共享变量的操作从其执行线程以外的任何线程来看,该操作要么已经执行结束,要么尚未发生,即其他线程不会“看到”该线程执行了部分的中间结果。
一个出现原子性问题的示例如下所示。假设一个线程T1在更新主机信息,而另一个线程T2在读取主机信息并据此与相应的主机建立网络连接,但更新主机信息的函数如下:

public void updateHostInfo(String ip, int port)
{
	hostInfo.setIp(ip);//语句一
	hostInfo.setPort(port);//语句二
}

其中主机信息hostInfo为共享变量。假设hostInfo的初始值是ip地址为"192.168.1.101"、端口号为8081的主机,而T1要将其更新为ip地址为"192.168.1.100"、端口号为8080的主机。可能会T1在执行完语句一而未开始执行语句二的时候,T2就读取了主机信息并与相应的主机建立了网络,从而试图与ip地址为"192.168.1.100"端口号为8081的一个不存在的主机建立网络,导致错误发生。这里的错误是由于updateHostInfo方法中的操作不是原子操作而使其他线程读取了脏数据导致的。
设O1和O2访问共享变量V的两个原子操作,这两个操作并非都是读操作。那么一个线程执行O1期间(开始执行而未执行完毕),其他线程无法执行O2.也就是说,访问同一组共享变量的原子操作是不能被交替的,这就排除了一个线程执行一个操作期间另外一个线程读取或者更新该操作所访问的共享变量所导致的干扰和冲突的可能。这就是“不可分割”的第二个含义。使一个操作举杯原子性也消除了这个操作导致竞态的可能。

  • 原子操作是针对访问贡献变量的操作而言的,仅涉及局部变量访问的操作无所谓是否原子的,或者可以认为都是原子的。
  • 原子操作是从该操作执行线程以外的线程来描述的,也就是说它只有在多线程环境下才有意义
    总的来说,Java有两种方式来实现原子性。一种是使用锁。锁具有排他性,即它能够保障一个共享变量在任意一个时刻只能被一个线程访问。另一种是利用处理器提供的专门CAS指令,CAS指令实现原子性的方式与锁实现原子性的方式实质上是相同的,差别在于CAS通常在硬件层次上实现而锁通常在软件层次上实现。
    在Java语言中,long型和double型以外的基础类型的变量和引用型变量的写操作都是原子的。long型和double型变量的写操作并不保证原子性,因此在多线程并发访问同一long/double型变量的情况下,一个线程可能读取到其他线程更新该变量的中间结果。设一个long型变量初始值为0,有两个线程并发的将其更新为-1和0,另外一个线程读取该变量的值。其读取到的值可能既不是0也不是-1,而是4294967295(0x00000000ffffffff)或者-4294967296(0xffffffff00000000)。这是一个线程对value变量的低32位更新和另一个线程对value变量的高32位更新混合出来的结果。
    尽管如此,Java语言规范规定了对于volatile关键字修饰的long/double型变量的写操作具有原子性。如下所示的变量value具有写操作的原子性。
volatile long value;

volatile关键字仅能够保障变量写操作的原子性,它不能保障其他操作的(比如读-改-写操作和检查而后行动操作)原子性。
Java中针对任何变量的读操作都是原子操作。
从原子操作的“不可分割”特性可知,使一个操作具有原子性就可以消除该操作导致竞态的可能性。

5.可见性

在多线程环境想,一个线程对某个共享变量更新之后,后续访问该变量的线程可能无法立刻读取到这个更新的结果,甚至可能永远也无法读取到这个更新的结果。这就是线程安全问题的另一个表现形式:可见性。
如果一个线程对某个共享变量更新后,后续访问该变量的线程可以读取到该更新的结果,则称这个线程对该共享变量的更新对其他线程可见,否则就撑该共享变量的更新对其他线程不可见。可见性指的就是一个线程对共享变量的更新对于读取相应共享变量的线程是否可见的问题。多线程程序在可见性的方面存在问题意味着某些线程读到了旧数据从而导致期望之外的结果。
可见性问题Demo如下。

import java.util.Random;

public class VisibilityDemo {
    public static void main(String[] args) throws InterruptedException
    {
        TimeConsumingTask timeConsumingTask = new TimeConsumingTask();
        Thread thread = new Thread(timeConsumingTask);
        thread.start();
        Thread.sleep(10000);
        timeConsumingTask.cancel();
    }

}
class TimeConsumingTask implements Runnable{
    private boolean toCancel = false;
    @Override
    public void run()
    {
        try {
            while (!toCancel)
            {
                if (doExecute())
                {
                    break;
                }
            }
            if (toCancel)
            {
                System.out.println("Task was canceled,");
            }
            else {
                System.out.println("Task done.");
            }
        }
        catch (Exception e)
        {
            System.out.println(e.getMessage());
        }
    }
    private boolean doExecute() throws Exception
    {
        boolean isDone = false;
        System.out.println("executing...");
        int a = new Random().nextInt(80);
        Thread.currentThread().sleep(a);
        return isDone;
    }
    public void cancel()
    {
        toCancel = true;
        System.out.println(this + " canceled.");
    }
}

上述例子如果使用JIT编译器,则会一直运行下去,因为代码没有给JIT编译器足够的提示而使其认为只有一个线程对变量toCancel进行访问,使其优化成如下代码:

if(!toCancel)
{
	while(true)
	{
		if(doExecute())
		{
			break;
		}
	}
}

另一方面,可见性与计算机的存储系统也有关。程序中的变量可能被分配到寄存器而非主内存中进行存储。每个处理器都有其寄存器,而一个处理器无法读取其他处理器寄存器上的内容。因此,如果两个线程分别运行在不同的处理器上,而这两个线程所共享的变量却被分配到寄存器上进行存储,那么可见性问题就会产生。即便某个共享变量是被分配到主内存中进行存储的,也不能保证该变量的可见性。因为处理器对于主内存的访问并不是直接访问,而是通过高速缓存子系统进行的。一个处理器上运行的线程对变量的更新可能只是更新到该处理器中的写缓冲器中,还没有到达该处理器的高速缓存中,更不用说主内存中了。而一个处理器的写缓冲器内容无法被另外一个处理器读取,因此运行在另外一个处理器上的线程无法看到这个线程对某个共享变量的更新。即便一个处理器上运行的线程对共享变量的更新结果被写入该处理器的高速缓存,由于该处理器将这个变量的更新结果通知给其他处理器的时候,其他处理器可能仅仅将这个更新通知的内容存入无效化队列,而没有直接根据通知内容更新高速缓存中相应内容,这就导致了其他处理器上运行的其他线程后续再读取相应共享变量时,从相应处理器上的高速缓存中读取到的变量值是一个过时的值。
虽然一个处理器的高速缓存中的内容无法直接被其他处理器读取,但是一个处理器可以通过缓存一致性协议来读取其他处理器高速缓存的值,并将读取到的数据更新到该处理器的高速缓存中。这种一个处理器从自身处理器缓存以外的其他存储部件中读取数据并将其反映(更新)到该处理器的高速缓存的过程,我们称之为缓存同步。相应的,我们称这些存储部件的内容是可同步的,这些存储部件包括处理器的高速缓存、主内存等。缓存同步使得一个处理器(上运行的线程)可以读取到另外一个处理器(上运行的线程)对共享变量所做的更新,即保障了可见性。因此,为了保障可见性,我们必须使一个处理器对于共享变量所做的更新最终被写入该处理器的高速缓存或者主内存中(而不是停留在写缓冲器中),这个过程被称为冲刷处理器缓存。并且,一个处理器在读取共享变量的时候,如果其他处理器在此之前已经更新了该变量,那么该处理器必须从其他处理器的高速缓存或者主内存中对相应的变量进行缓存同步。这个过程被称为刷新处理器缓存。可见性的保障是通过使更新共享变量的处理器执行冲刷处理器缓存的操作,并使读取共享变量的处理器执行刷新处理器缓存的动作来实现的。
在之前的代码中,只要在toCancel声明中添加volatile关键字即可。即改为以下声明:

private volatile boolean toCancel = false;

这里,volatile关键字的作用是提醒JIT编译器被修饰的变量是一个共享变量,以阻止编译器做出可能导致程序运行不正常的优化。其另一个作用是读取volatile关键字修饰的变量会执行相应处理器的刷新处理器缓存的动作,学一个volatile关键字修饰的变量会使相应的处理器执行冲刷处理器缓存的操作,以保证线程的可见性。
可见性得以保障,并不意味着一个线程能看到另一个线程更新的所有变量的值。如果一个线程在同一时刻更新了多个变量的值,那么此后其他线程再来读取这些变量时,这些线程读到的变量值可能是其他线程更新过的值,游戏则仍然可能是修改之前的值(旧值)。
对于一个共享变量而言,一个线程更新了该变量的值后,其他线程能读取到这个更新后的值,这个值就被称为该变量的相对新值。如果读取这个变量的线程在读取并使用该变量的时候其他线程无法更新该变量的值,那么该线程读取到的相对新值就被称为该变量的最新值。
可见性的保障仅仅意味着能够读取共享变量的相对新值,而不能保障该线程能够读取到共享变量的最新值。
在单处理器环境下,多线程的并发执行实际上是通过时间片分配来实现的。虽然多个线程是运行在同一处理器上的,但是由于发生上下文切换的时候,一个线程对寄存器变量的修改会被保存作为该线程的线程上下文,导致另外一个线程无法看到该线程对变量的修改,因此,但处理器系统中实现的多线程编程扔可能存在可见性问题。

线程的启动、停止与可见性

Java语言规范保证,父线程在启动子线程之前对共享变量的更新对子线程可见。示例如下:

import java.util.Random;

public class ThreadStartVisbility {
    static int data = 0;
    public static void main(String[] args)
    {
        Thread thread = new Thread()
        {
            @Override
            public void run()
            {
                int time = new Random().nextInt(50);
                try
                {
                    Thread.sleep(time);
                }
                catch (Exception e)
                {
                    System.out.println(e.getMessage());
                }
                System.out.println(data);
            }
        };
        data = 1;
        thread.start();
        int time = new Random().nextInt(50);
        try
        {
            Thread.sleep(time);
        }
        catch (Exception e)
        {
            System.out.println(e.getMessage());
        }
        data = 2;
    }
}

如果把上述程序中的"data = 2"注释掉,由于main线程在启动子线程thread之前将共享变量data的值更新为1,因此子线程thread所读取到的共享变量data值一定为1。这是因为父线程在子线程启动前对共享变量所做的更新对子线程的可见性是有保障的。如果没有将"data = 2"注释掉,由于父线程在子线程启动后对共享变量的更新对子线程的可见性没有保障,因此子线程thread读到的共享变量data的值可能为2也可能为1。
Java语言规范保证一个线程终止后该线程对共享变量的更新对于调用该线程的join方法的线程而言是可见的。

import java.util.Random;

public class ThreadJoinVisibility {
    static int data = 0;
    public static void main(String[] args)
    {
        Thread thread = new Thread(){
          @Override
          public void run()
          {
              int time = new Random().nextInt(50);
              try
              {
                  Thread.sleep(time);
              }
              catch (Exception e)
              {
                  System.out.println(e.getMessage());
              }
              data = 1;
          }
        };
        thread.start();
        try {
            thread.join();
        }
        catch (Exception e)
        {
            e.printStackTrace();
        }
        System.out.println(data);
    }
}

上述程序中,线程thread运行时将共享变量data的值更新为1,因此main线程对线程thread的join方法调用结束后,该线程读取到的共享变量data值为1这一点是有保证的。

6.有序性

有序性指在什么情况下一个处理器运行的一个线程所执行的内存访问操作在另外一个处理器上运行的其他线程看起来是乱序的。所谓乱序,是指内存访问操作的顺序看起来像是发生了变化。

6.1重排序的概念

顺序结构是结构化编程中的一种基本结构,它表示我们希望某个操作必须先于另一个操作得以执行。两个操作即便可以用任意一种方式进行执行,但是反应在代码上也总是有先后关系的。在多核处理器的环境下,这种操作执行顺序可能是没用保障的。编译器可能改变两个操作的先后顺序,处理器可能不是完全依照程序的目标代码所指定的顺序执行指令。另外,一个处理器上执行的多个操作,从其他处理器的角度来看其顺序可能与目标代码所指定的顺序不一致。这种现象就叫做重排序。
重排序是对内存访问有关的操作(读和写)所做的一种优化。它可以在不影响单线程程序正确性的情况下提升程序的性能。重排序的潜在来源有很多,包括编译器、处理器和存储子系统。
术语定义:

  • 源代码顺序:源代码中所指定的内存访问操作顺序
  • 程序顺序:在给定处理器上运行的目标代码所指定的内存访问操作顺序
  • 执行顺序:内存访问操作在给定处理器上的实际执行顺序。
  • 感知顺序:给定处理器所感知到的该处理器及其他处理器的内存访问操作发生的顺序。
重排序类型 重排序表现 重排序来源(主体)
指令重排序 程序顺序与源代码顺序不一致 编译器
指令重排序 执行顺序与程序顺序不一致 JIT编译器、处理器
存储子系统重排序 感知顺序与执行顺序不一致 高速缓存。写缓冲器

6.2指令重排序

在源代码顺序与程序顺序不一致,或者程序顺序与执行顺序不一致的情况下,我们就称之为发生了指令重排序。在Java平台中,静态编译器基本上不会执行指令重排序,而JIT编译器则可能执行指令重排序。例如,一个创建实例的操作如下:

helper = new Helper(externalData);

其可以分为以下几个子操作

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

JIT编译器并不是每次都按照上面上述源代码顺序生成相应的机器码,其可能把子操作3重排序到子操作2之前,即在初始化Helper实例之前可能已经将该实例的引用写入helper实例变量,导致其他线程看到helper实例变量的时候,该变量引用的对象尚未初始化完毕。
所以重排序可能导致线程安全问题。但同时重排序也不是必然出现的。JIT编译器在执行上述代码的时候不进行重排序的情况更加常见。
处理器也可能执行指令重排序,这使得执行顺序与程序顺序不一致。处理器对指令进行重排序也成为处理器的乱序执行。现代处理器为了提高执行效率,并不是按照程序顺序逐一执行指令的,而是动态调整指令的执行顺序,做到哪条指令就绪就先执行哪条指令。在乱序执行的处理器中,指令会按照程序顺序被处理器读取,然后这些指令中哪条就绪了就会被先执行。这些指令执行的结果会被先存入重排序缓冲器,重排序缓冲器会将各个指令的执行结果按照被处理器读取的顺序提交到寄存器或者内存中。在乱序执行的情况下,指令执行结果的提交仍然是按照顺序提交,因此在单线程情况下不会存在正确性的问题。
处理器的乱序执行还采用了一种猜测执行的技术。一个猜测执行的示例代码如下:

public class SpeculativeLoadExample {
    private boolean ready = false;
    private int[] data = new int[]{1,2,3,4,5,6,7,8};
    public void writer()
    {
        int[] newData = new int[]{1,2,3,4,5,6,7,8};
        for (int i = 0;i<newData.length;i++)
        {
            newData[i] = newData[i]-i;
        }
        data = newData;//语句1
        ready = true;//语句2
    }
    public int reader()
    {
        int sum = 0;
        int[] snapshot;
        if(ready)//语句3
        {
            snapshot = data;
            for (int i = 0; i<snapshot.length;i++)//语句4
            {
                sum += snapshot[i];//语句5
            }
        }
        return sum;
    }
}

在上述代码中,语句3和语句5,处理器可能会先逐一读取数组data中的各个元素,并计算这些元素的和sum,将其临时存放到重排序缓冲器中,接着再去读取变量ready的值。如果ready的值为false,则丢弃重排序缓冲器中的sum值,否则则提交执行结果。在多线程环境下,一个线程执行writer(),一个线程执行reader(),这可能导致语句5先于语句3执行,data数组的值被提前读取,此时数组的值可能是初始值,而在读取ready变量的时候此时ready变量变为true,从而导致sum为数组初始值的和而不是预期值的和。
可见,处理器的指令重排序可能导致多线程程序出现非预期结果。

6.3存储子系统重排序

主内存相对于处理器是一个慢速设备。处理器并不会直接访问主内存,而是通过访问高速缓存而访问主内存。现代处理器还引入了写缓冲器,以提高写高速缓存操作的效率。我们将写缓冲器和高速缓存统称为存储子系统。
即使在处理器严格依照程序顺序执行的两个内存访问操作的情况下,在存储子系统的作用下其他处理器对这两个操作的感知顺序仍然可能与程序顺序不一致,这种现象就是存储子系统重排序,也被称为内存重排序。
指令重排序的重排序对象是指令,它实实在在的对指令进行了调整。而存储子系统重排序是没有真正改变指令的执行顺序,只是造成了一种指令的执行顺序像是被调整过一样的现象。其重排序的对象是内存操作的结果。
为表述方便,在论及内存重排序的时候,仍然会采用指令重排序的方式表述。内存重排序只有以下四种:

重排序类型 含义
LoadLoad重排序 该重排序指在处理器上先后执行两个读内存操作L1和L2,其他处理器对这两个内存操作的感知顺序可能是先L2后L1
StoreStore重排序 该重排序指在处理器上先后执行两个写内存操作W1和W2,其他处理器对这两个内存操作的感知顺序可能是先W2后W1
LoadStore重排序 该重排序指在处理器上先后执行读内存操作L1和写内存操作W2,其他处理器对这两个内存操作的感知顺序可能是先W2后L1
StoreLoad重排序 该重排序指在处理器上先后执行写内存操作W1和读内存操作L2,其他处理器对这两个内存操作的感知顺序可能是先L2后W1

内存重排序也可能导致线程安全问题。以如下表格为例:

处理器1 处理器2
data = 1;ready = true;
while(!ready){;}System.out.println(data);

假设处理器1依照顺序先后执行两个语句,那么这两个语句的结果会被先后写入写缓冲器中。但是由于某些处理器的写缓冲器为了提高将其中的内容写入高速缓存的效率而不保证写操作结果先进先出的顺序,即晚到达写缓冲器的写操作结果可能更早被写入高速缓存,因此"ready = true "可能先被写入高速缓存,从而导致处理器2读到ready的值为true的时候,处理器1的写缓冲器并未把data的值写入高速缓存,从而导致处理器2读到的data值为初始值0,造成线程安全问题。

6.4貌似串行语义

重排序并非随意的对指令、内存操作的结果进行杂乱无章的排序或者顺序调整,而是遵循一定的规则,从而给单线程程序造成一种假象——指令是按照源代码顺序执行的。这种假象就被称为貌似串行语义。貌似串行语义只是从单线程程序的角度保证重排序后的运行结果不影响程序的正确性,它并不保证多线程环境下程序的正确性。
为了保证貌似串行语义,只有不存在数据依赖关系的语句才会被重排序。如果两个操作访问同一个变量而且其中一个操作是写操作,那么这两个操作直接存在数据依赖关系。数据依赖关系类型如下表:

类型 代码示例 说明
写后读 x=1;y=x+1; 后一条语句的操作数包含前一条语句的执行结果
读后写 y=x;x=1; 前一条语句读取一个变量后,后一条语句更新该变量的内容
写后写 x=1;x=2; 两条语句同时对同一条语句进行写操作

另外,存在控制依赖关系的语句允许被重排序。如果一条语句的执行结果会决定另一条语句是否执行,称这两个语句之间存在控制依赖关系。

7.上下文切换

7.1上下文切换及其产生原因

在单处理器上也能以多线程的方式实现并发,即一个处理器可以在同一时间段内运行多个线程。单处理器上的多线程是通过时间片分配方式决定的。时间片决定了一个线程可以连续占用处理器运行的时间长度。当一个进程中的一个线程由于其时间片用完或者其自身的原因被迫或者主动暂停其运行时,另外一个线程可以被操作系统(线程调度器)选中占用处理器开始或者继续其运行。这种一个线程被暂停,另外一个线程被选中开始或者继续运行的过程就叫线程上下文切换。为了方便,将其称为上下文切换。一个线程被剥夺处理器使用权而被暂停运行就被称为切入,一个线程被操作系统选中占用处理器开始或者继续运行就被称为切入。
我们看着是连续运行的线程,实际上是以断断续续的方式使其任务进展的。这种方式意味着在切入和切出的时候操作系统需要保存和恢复相应的进度信息,即切入切出那一刻线程所执行的任务进行到什么程度了。这个进度就被称为上下文。它一般包括通用寄存器的内容和程序计数器的内容。在切出时,操作系统需要将上下文保存到内存中,以便被切出的线程稍后占用处理器继续其运行时能够在此基础上进展。在切入时,操作系统需要从内存中加载被选中的上下文,在之前运行的基础上继续进展。
一个线程的生命周期从RUNNABLE状态与非RUNNABLE状态之间的切换就是一个上下文转换的过程。当一个线程的生命周期状态从RUNNABLE转换W为非RUNNABLE状态时,我们称这个线程被暂停。线程的暂停就是相应线程被切出的过程,这里操作系统会保存相应线程的上下文。而一个线程的生命周期状态由非RUNNABLE状态进入RUNNABLE状态时,我们就称这个线程被唤醒。线程被唤醒仅仅代表这个线程获得了一个继续运行的机会,并不代表其立刻可以占用处理器运行。

7.2上下文切换的分类及原因

可以将上下文切换分为自发性上下文切换和非自发性上下文切换。
自发性上下文切换是线程自身因素导致的。从Java平台的角度看,一个线程在其运行过程中执行下列任一个方法都会引起自发性上下文切换。

  • Thread.sleep(long millis)
  • Object.wait()/wait(long timeout,int nanos)
  • Thread.yield()
  • Thread.join()/Thread.join(long timeout)
  • LockSupport.park()
    非自发性上下文切换指线程由于线程调度器的原因被切出。导致非自发性上下文切换的常见因素包括被切出线程的时间片用完或者有一个比被切出线程优先级更高的线程需要被运行。从Java平台的角度看,Java虚拟机的垃圾回收动作也可能导致非自发性上下文切换。

7.3上下文切换的开销及测量

一方面,上下文切换是必要的。另一方面,上下文切换又有不容小觑的开销。
上下文切换的开销包括直接开销和间接开销。直接开销包括:

  • 操作系统保存和恢复上下文的开销,这主要是处理器时间开销。
  • 线程调度器进行线程调度的开销
    间接开销包括:
  • 处理器高速缓存重新加载的开销。一个被切出的线程可能稍后在另外一个处理器上被切入继续运行。由于这个处理器之前可能未运行过该线程,那么这个线程在其运行过程中需访问的变量仍然需要被该处理器重新从主内存或者通过缓存一致性协议从其他处理器加载到告诉缓存中。
  • 上下文切换也看导致整个一级高速缓存中的内容被冲刷,即一级高速缓存中的内容被写入下一级高速缓存(如二级高速缓存)或者主内存。
    从定量的角度来说,一次上下文切换的时间消耗是微秒级别的。
    多线程编程相比于单线程编程来说,它意味着更多的上下文切换。因此,多线程编程不一定效率更高。

8.线程的活性故障

导致一个线程可能处于非RUNNABLE状态的因素除了资源限制以外,还有程序自身的错误和缺陷。这些由于资源稀缺性或者程序自身的问题和缺陷导致的线程一直处于非RUNNABLE状态,或者线程虽然处于RUNNABLE状态但是其所要执行的任务缺一直无法进展的线程就被称为线程的活性故障。
常见的活性故障如下:

  • 死锁:死锁的产生典型场景是一个线程X持有A资源时等待另外一个线程释放资源B,而另外一个线程Y持有资源B的时候却等待线程X释放资源A。死锁的外在表现是当事线程的生命周期永远处于非RUNNABLE状态从而使其任务一直无法进展。
  • 锁死:锁死就好比睡美人的故事中睡美人醒来的前提是她要得到王子的亲吻,但是如果王子无法亲吻她(比如王子“挂了”……),那么睡美人将一直沉睡。
  • 活锁:活锁的外在表现是线程可能处于RUNNABLE状态,但是线程所要执行的任务却丝毫没有进展,即线程可能一直在做无用功。
  • 饥饿:饥饿就是线程因无法获得其所需的资源而使得任务执行无法进展的现象。

9.资源的争用与调度

由于资源的稀缺性或者资源本身的特性,我们往往需要多个线程间共享同一个资源。一次只能够被一个线程占用的资源被称为排他性资源,在一个线程占用一个排他性资源进行访问而未释放其对资源的所有权的时候,其他线程试图访问该资源的现象就被称为资源争用,简称争用。试图访问同一个已经被其他线程占用的资源的线程数量越多,争用程度就越高,反正争用程度就越低。相应的争用就被称为高争用和低争用。
同一时间内,处于运行状态的线程数量越多,我们就称并发的程度越高,简称高并发。高并发是相对于低并发而言的。
虽然高并发增加了争用的概率,但是高并发未必意味着高争用。我们要达到的理想情况即死高并发低争用。
多个线程共享一个资源又会带来新的问题,即资源调度的问题。在多个线程申请同一个排他性资源的情况下,决定哪个线程会被授予该资源的独占权,即选择哪个申请者占有该资源的过程就是资源的调度。获得资源独占权而未释放其独占权的线程就被称为资源的持有线程。资源的调度策略的一个常见的特性就是它是否能保证公平性。所谓的公平性是指资源的申请者是否按照其申请资源的顺序而被授予资源的独占权。如果资源的任何一个先申请者总是会比后申请者先获得该资源的独占权,则相应的资源调度策略就是公平的,否则就是非公平的。非公平的资源调度策略只是允许不公平的资源调度出现,而非刻意造就不公平。
资源调度的一种常见策略就是排队。资源调度器内部维护一个等待队列,在存在资源争用的情况下,申请失败的资源申请者会被存入该队列。通常,被存入等待队列的线程会暂停。当相应的资源被其持有线程释放时,等待线程中的一个线程就会被选中并唤醒而获得再次申请的机会。被唤醒的线程如果获得资源的独占权,则会被从等待队列中移除。否则,该线程仍然会停留在等待队列中等待再次申请的机会,该线程会再次暂停。可见,资源的调度可能导致上下文切换。公平的调度策略不允许插队现象的出现,即只有在资源未被其他任何线程占用且等待队列为空,资源的申请者才被允许强占相应资源的独占权。抢占成功的获得资源的独占权,而抢占失败的则进入等待队列。非公平的调度策略则允许活跃线程(处于RUNNABLE状态的线程)在等待队列中线程被唤醒并申请相应资源的同时和该线程共同参与相应资源的抢占。非公平的调度策略被唤醒不一定就能够成功申请到资源。
一般而论,非公平调度策略的吞吐率较高,但从申请者个体的角度来看这些申请者获得相应资源的独占权所需时间偏差可能较大。公平调度策略吞吐率低,但从申请者个体的角度来看这些申请者获得相应资源的独占权所需时间偏差可能较小。在非公平调度策略中,资源的持有线程释放该资源的时候等待队列中的一个线程会被唤醒,而该线程从被唤醒到期继续运行可能需要一段时间。在该时间内新来的线程(活跃线程)可以先被授予该资源的独占权。如果这个新来的线程占用资源的时间不长,它完全可能在被唤醒的线程继续运行前释放相应的资源,从而不影响被唤醒的线程申请资源。这种情况下,非公平的调度策略会减少上下文切换的次数。但如果多数线程占用资源的时间相当长,那么允许新来的线程抢先占用被释放的资源不仅不会有任何好处,还会因为被唤醒的线程再次经历暂停的唤醒而增加的上下文切换。因此,多数线程占用资源时间相当长的情况下适合使用公平的调度策略。没有特别需要的情况下,使用非公平调度策略即可。在资源持有线程时间相对长或者对资源申请所需的时间偏差有要求的情况下,可以考虑使用公平调度策略。

你可能感兴趣的:(Java多线程)