【Java多线程编程实战】多线程编程的目标与挑战

多线程编程的目标与挑战

串行、并发与并行

串行(Sequential)实际上是顺序逐一完成多个任务;并发(Concurrent)实际上是以交替的方式利用等待某个任务完成的时间来执行其他任务,即一段时间内可以处理或完成更多的任务;并行(Parallel)是串行的反面,是一种更严格、更理想的并发,即并行可以被看做并发的一个特例。
从软件的角度来说,并发就是在一段时间内以交替的方式取完成多个任务,而并行就行以齐头并进的方式去完成多个任务。
从硬件的角度来说,在一个处理器一次只能运行一个线程的情况下,由于处理器可以使用时间片(Time-slice)分配的技术来实现在同一段时间内运行多个线程,因此处理器就可以实现并发。而并行则需要靠多个处理器在同一时刻各自运行一个线程来实现。
多线程编程的实质就是将任务的处理方式由从串行改为并发,即实现并行化。如果一个任务的处理方式可以由串行改为并发(或者并行),那么这个任务是可并发化(或者并行化)的。

竞态

多线程编程中经常遇到一个问题就是对于同样的输入,程序的输出有时候是正确的,而有时候却是错误的。这种一个计算结果的正确性与时间有关的线程就被称为竞态(Race Condition)。
一个竞态实例:某系统为了便于跟踪对其接收到的HTTP请求的处理,会为其收到的每个HTTP请求分配一个唯一编号(Request ID).Resuest ID是一个固定长度的编码字符串,其中最后3位是一个在0~999循环递增的序列号。Request ID生成器RequestIDGenerator代码如下:

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

public class RequestIDGenerator implements CircularSeqGenerator{
    /*
    * 保存该类的唯一实例
     */
    private final static RequestIDGenerator INSTANCE = new RequestIDGenerator();
    private final static short SEQ_UPPER_LIMIT = 999;
    private short sequence = -1;
    //私有构造器
    private RequestIDGenerator(){
        //什么也不做
    }
    /**
     * 生成循环递增序列号
     * @ruturn
     */
    @Override
    public short nextSequence() {
        if (sequence>=SEQ_UPPER_LIMIT){
            sequence=0;
        }else {
            sequence++;
        }
        return sequence;
    }
    /**
     * 生成一个新的Request ID
     */
    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;
    }
}

如下代码模拟了实际环境中的使用情况:每个业务线程(请求处理线程)在处理其接收到的请求前都要先为该请求去申请一个RequestID.

import util.Tools;

public class HTTPRequestId {
    public static void main(String[] args) throws Exception{
        //客户端线程数
        int numberOfThread = args.length>0?Short.valueOf(args[0]):Runtime.getRuntime().availableProcessors();
        Thread[] workerThreads = new Thread[numberOfThread];
        for (int i = 0; i <numberOfThread ; 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 requestIDGen = RequestIDGenerator.getInstance();
            while (i-->0){
                //生成Request ID
                requestID = requestIDGen.nextID();
                processRequest(requestID);
            }
        }
        //模拟请求处理
        private void processRequest(String requestID){
            //模拟请求处理耗时
            Tools.randomPause(50);
            System.out.printf("%s got requestID:%s %n",Thread.currentThread().getName(),requestID);
        }
    }
}

运行结果如下:
竞态Demo运行结果

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

  • 状态变量:即类的实例变量、静态变量
  • 共享变量:即可以被多个线程共同访问的变量。

导致竞态的常见因素是多个线程在没有采取任何控制措施的情况下并发地更新、读取同一个共享变量,因此产生了读取脏数据的问题。
竞态(Race Condition)是指计算的正确性依赖于相对时间顺序(Relative Timing)或者线程的交错(Interleaving)。根据这个定义可知,竞态不一定就导致计算结果的不正确,它只是不排除计算结果时而正确时而错误的可能。

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

竞态的两种模式:read-modify-write和check-then-act

  • read-modify-write(读-改-写)操作。读取一个共享变量的值(read),然后根据该值做一些计算(modify),接着更新该共享变量(write)。
  • check-then-act(检查而后行动)操作。读取某个共享变量的值,根据该变量的值决定下一步的动作是什么。

竞态可以看做访问(读取、更新)同一组共享变量的多个线程所执行的操作相互交错(Interleave)。
对于局部变量(包括形式参数和方法体内定义的变量),由于不同的线程各自访问的是各自的那一份局部变量,因此局部变量的使用不会导致竞态!
一个解决方法就是在RequestIDGenerator.nextSequence()的声明中添加一个synchronized关键字:

public class SafeCircularSeqGenerator implements CircularSeqGenerator{
   private short sequence = -1;
   public synchronized short nextSequence(){
       if (sequence>=999){
           sequence = 0;
       }else {
           sequence++;
       }
       return sequence;
   }
}

synchronized关键字会使其修饰的方法在任一时刻只能够被一个线程执行,这使得该方法涉及的共享变量在任一时刻只能有一个线程访问(读、写)。

线程安全性

一般而言,如果一个类在单线程环境下能够运作正常,并且在多线程环境下,在其使用方法不必为其做任何改变的情况下也能运行正常,就称其是线程安全的(Thread-safe)。相应地,称这个类具有线程安全性(Thread Safety)。
反之,如果一个类在单线程环境下运作正常而在多线程环境下则无法正常运作,那么就是非线程安全的
因此一个类能够导致竞态,它就是非线程安全的,如果一个类是线程安全的,那么就不会导致竞态。

一个类如果不是线程安全的,就说它在多线程环境下直接使用存在线程安全问题。线程安全问题表现为3个方面:原子性、可见性和有序性。

原子性

原子性:该操作要么已经执行结束要么尚未发生,也就是说,其他线程不会“看到”该操作执行了部分的中间结果。需要注意的是:

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

总的来说,Java中有两种方式来实现原子性。一种是使用锁(Lock)。锁具有排他性,即它能够保障一个共享变量在任意一个时刻只能够被一个线程访问。另一种是利用处理器提供的专门CAS(Compare-and-Swap)指令,CAS指令实现原子性的方式与锁实现原子性的方式实质上是相同的,差别在于锁通常是在软件这一层次实现的,而CAS是直接在硬件(处理器和内存)这一层次实现的。可以被看做“硬件锁”。
在java中,long型和double型以外的任何类型的变量的写操作都是原子操作。尽管如此,Java语言规范特别地对于volatile关键字修饰的long/double型变量的写操作具有原子性。注意,volatile关键字仅能够保证变量写操作的原子性。
在Java语言中,针对任何变量的读操作都是原子操作。“原子操作+原子操作”所得到复合操作并非原子操作。

可见性

如果一个线程对某个共享变量进行更新之后,后续访问该变量的线程可以读取到该更新的结果,那么我们就成这个线程对该共享变量的更新对其他线程可见,否则不可见。可见性就是指一个线程对共享变量的更新的结果对于读取相应共享变量的线程而言是否可见的问题。
可见性问题Demo:

import util.Tools;

public class VisibilityDemo {
    public static void main(String[] args) throws InterruptedException{
        TimeConsumingTask timeConsumingTask = new TimeConsumingTask();
        Thread thread = new Thread(new TimeConsumingTask());
        thread.start();
        //指定的时间内任务没有执行结束的话,就将其取消
        Thread.sleep(10000);
        timeConsumingTask.cancel();
    }
}
class TimeConsumingTask implements Runnable{
    private boolean toCancel = false;

    @Override
    public void run(){
        while (!toCancel){
            if (doExecute()){
                break;
            }
        }
        if (toCancel){
            System.out.println("Task was canceled.");
        }else{
            System.out.println("Task done.");
        }
    }
    private boolean doExecute(){
        boolean isDone = false;
        System.out.println("executing...");
        //模拟实际操作的时间消耗
        Tools.randomPause(50);
        //省略其他代码
        return isDone;
    }
    public void cancel(){
        toCancel = true;
        System.out.println(this+"cancled.");
    }
}

另一方面,可见性问题与计算机的存储系统有关。程序中的变量可能会被分配到寄存器(Register)而不是主内存中进行存储。每个处理器都有其寄存器,而一个处理器无法读取另一个处理器上的寄存器中的内容。因此,如果两个线程分别运行在不同的处理器上,而这两个线程所共享的变量却被分配到寄存器上进行存储,那么可见性问题就会产生。另外,即便某个共享变量是被分配到主内存中进行存储的,也不能保证该变量的可见性。因为处理器对主内存的访问并不是直接访问,而是通过其高速缓存(Cache)子系统进行的。

处理器并不是直接与主内存(RAM)打交道而执行内存的读、写操作,而是通过寄存器(Register)、高速缓存(Cache)、写缓冲器(Store Buffer, 也称Write Buffer)和无效化队列(Invalidate Queue)等部分执行内存的读、写操作的。从这个角度来看,这些部件相当于主内存的副本,统称为处理器对主内存的缓存,简称处理器缓存。

一个处理器可以通过缓存一致性协议(Cache Coherence Protocol)来读取其他处理器的高速缓存中的数据,并将读到的数据更新到该处理器的高速缓存中。这种一个处理器从其自身处理器缓存以外的其他存储部件中读取数据并将其反映(更新)到该处理器的高速缓存的过程,称为缓存同步
因此,为了保障可见性,必须使一个处理器对共享变量所作的更新最终被写入该处理器的高速缓存或者主内存中(而不是始终停留在其写缓冲器中),这个过程被称为冲刷处理器缓存。并且一个处理器在读取共享变量的时候,如果其他处理器在此之前已经更新了该变量,那么该处理器必须从其他处理器的高速缓存或者主内存中对相应的变量进行缓存同步,这个过程称为刷新处理器缓存

因此,可见性的保障是通过使更新共享变量的处理器执行冲刷处理器缓存的动作,并使读取共享变量的处理器执行刷新处理器缓存的动作来实现的。

在Java平台中如何保证可见性?变量的声明中添加一个volatile关键字即可。volatile关键字所起的一个作用就是提示JIT编译器被修饰的变量可能被多个线程共享,以阻止JIT编译器做出可能导致程序运行不正常的优化。另一个作用就是读取一个volatile关键字修饰的变量会使相应的处理器执行刷新处理器缓存的动作,写一个volatile关键字修饰的变量会使相应的处理器执行冲刷处理器缓存的动作,从而保证可见性。
可见性得以保障,并不意味着一个线程能够看到另一个线程更新的所有变量的值。可见性的保障仅仅意味着一个线程能够读取到共享变量的相对新值,而不能保障该线程能够读取到相应变量的最新值。

对于同一个共享变量而言,一个线程更新了该变量的值之后,其他线程能够读取到这个更新后的值,那么这个值就被称为该变量的相对新值
如果读取这个共享变量的线程在读取并使用该变量的时候其他线程无法更新该变量的值,那么该线程读取到的相对新值就被称为该变量的最新值
单处理器系统是否存在可见性问题?存在。在单处理器环境下,多线程的并发执行实际上是通过时间片(Time Slice)分配实现的。此时,虽然多个线程是运行在同一个处理器上的,但是由于在发生上下文切换(Context Switch)的时候,一个线程对寄存器(Register)变量的修改会被作为该线程的线程上下文保存起来,这导致另一个线程无法“看到”该线程对这个变量的修改,

可见性与原子性的联系与区别?原子性描述的是一个线程对共享变量的更新,要么完成了,要么尚未发生,因此保证一个线程所读取到的共享变量的值要么是该变量的初始值要么是该变量的相对新值。可见性描述了一个线程对共享变量的更新对于另外一个线程而言是否可见的问题。保障可见性意味着一个线程可以读取到相应共享变量的相对新值。

Java语言规范保证父线程在启动子线程之前对共享变量的更新对于子线程来说是可见的。代码如下:

import util.Tools;

public class ThreadStartVisibility {
    // 线程间的共享变量
    static int data = 0;
    public static void main(String[] args){
        Thread thread = new Thread(){
            @Override
            public void run() {
                //使当前线程休眠R毫秒(R的值为随机数)
                Tools.randomPause(50);
                //读取并打印变量data的值
                System.out.println(data);
            }
        };
        //在子线程thread启动前更新变量data的值
        data = 1; //语句①
        thread.start();
        //使当前线程休眠R毫秒(R的值为随机数)
        Tools.randomPause(50);
        //在子线程thread启动后更新变量data的值
        data = 2; //语句②
    }
}

类似地,Java语言规范保证一个线程终止后该线程对共享变量的更新对于调用该线程的join方法的线程而言是可见的。示例代码如下:

import util.Tools;

public class ThreadJoinVisibility {
    // 线程间的共享变量
    static int data = 0;
    public static void main(String[] args){
        Thread thread = new Thread(){
            @Override
            public void run() {
               //使当前线程休眠R毫秒(R的值为随机数)
                Tools.randomPause(50);

                //更新data的值
                data =1;
            }
        };
        thread.start();
        //等待线程thread结束后,main线程才能继续运行
        try {
            thread.join();
        }catch (InterruptedException e){
            e.printStackTrace();
        }
        //读取并打印变量data的值
        System.out.println(data);
    }
}

有序性

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

重排序

处理器可能不是完全依照程序的目标代码所指定的顺序执行指令;另外,一个处理器上执行的多个操作,从其他处理器的角度来看其顺序可能与目标代码所指定的顺序不一致。这种现象就是重排序(Recording)。
重排序是对内存访问有关的操作(读和写)所作的一种优化。

定义几个与内存操作顺序有关的术语:

  • 源代码顺序(Source Code):源代码中所指的内存访问操作顺序
  • 程序顺序(Program Order):在给定处理器上运行的目标代码(Object Code)所指定的内存访问操作顺序。
  • 执行顺序(Eexcution Order):内存访问操作在给定处理器上的实际执行顺序
  • 感知顺序(Perceived Order):给定处理器所感知到(看到)的该处理器及其他处理器的内存访问操作发生的顺序。

在此基础上,将重排序划分为指令重排序(Instruction Reorder)和存储子系统重排序两种。

指令重排序

在源代码顺序与程序顺序不一致,或者程序顺序与执行顺序不一致的情况下,就认为发生了指令重排序。指令重排序是一种动作,它确确实实对指令的顺序做了调整,其重排序的对象是指令。
在Java平台中,静态编译器(javac)基本上不会执行指令重排序,而JIT编译器则可能执行指令重排序,示例代码如下:

//JIT编译器指令重排序
import util.stf.*;

@ConcurrencyTest(iterations = 200000)
public class JITReorderingDemo {
    private int externalDate = 1;
    private Helper helper;

    static class Helper {
        int payloadA;
        int payloadB;
        int payloadC;
        int payloadD;

        public Helper(int externalDate) {
            this.payloadA = externalDate;
            this.payloadB = externalDate;
            this.payloadC = externalDate;
            this.payloadD = externalDate;
        }
    }

    @Actor
    public void createHelper() {
        helper = new Helper(externalDate);
    }

    @Observer({
            @Expect(desc = "Helper is null", expected = -1),
            @Expect(desc = "Helper is not null, but it is not initialized", expected = 0),
            @Expect(desc = "Only 1 field of Helper instance was initialized", expected = 1),
            @Expect(desc = "Only 2 field of Helper instance were initialized", expected = 2),
            @Expect(desc = "Only 3 field of Helper instance were initialized", expected = 3),
            @Expect(desc = "Helper instance was fully initialized", expected = 4)
    })
    public int consume() {
        int sum = 0;
        /**
         * 没有对共享变量helper进行任何处理,因此存在可见性问题,即当前线程读取到的变量值可能为null
         */
        final Helper observedHelper = helper;
        if (null == observedHelper) {
            sum = -1;
        } else {
            sum = observedHelper.payloadA + observedHelper.payloadB + observedHelper.payloadC + observedHelper.payloadD;
        }
        return sum;
    }

    public static void main(String[] args) throws InstantiationException, IllegalAccessException {
        //调用测试工具运行测试代码
        TestRunner.runTest(com.company.ch2.JITReorderingDemo.class);
    }
}

重排序所具有的两个特征:

  • 重排序可能导致线程安全问题。
  • 重排序不是必然出现的。

处理器也可能执行指令重排序,这使得执行顺序与程序顺序不一致。处理器对指令进行重排序也被称为处理器的乱序执行(Out-of-order Execution)。现代处理器不是按照程序顺序逐一执行指令的,而是动态调整指令的顺序,做到哪条指令就绪就先执行哪条指令。这些指令的执行结果(要进行写寄存器或者对内存的操作)会被先存入重排序缓冲器(ROB, Reorder Buffer),而不是直接被写入寄存器或者主内存。重排序缓冲器会将各个指令的执行结果按照相应指令被处理器读取的顺序提交(Commit,即写入)到寄存器或者内存中去(顺序提交)。
处理器的乱序执行还采用了一种被称为猜测执行(Speculation)的技术。例如:

//猜测执行代码示例
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;
        //此处包含写内存的操作
        ready = true;
    }

    public int reader() {
        int sum = 0;
        int[] snapshot;
        if (ready) {
            snapshot = data;
            for (int i = 0; i < snapshot.length; i++) {
                sum += snapshot[i];
            }
        }
        return sum;
    }
}

处理器的指令重排序并不会对单线程程序的正确性产生影响,但是它可能导致多线程程序出现非预期的结果。

存储子系统重排序

处理器并不是直接访问主内存,而是通过高速缓存(Cache)访问主内存的。在此基础上,现代处理器还引入了写缓冲器(Store Buffer,也成为Write Buffer)以提高写高速缓冲操作(以实现写主内存)的效率。将写缓存器和高速缓存统称为存储子系统
存储子系统重排序是一种现象而不是一种动作,它并没有真正对指令执行顺序进行调整,而只是造成了一种指令的执行顺序像是被调整过一样的现象,其重排序的对象是内存操作的结果。

从处理器的角度看,读内存操作的实质是从指定的RAM地址加载数据(通过高速缓存加载)到寄存器,因此读内存操作通常被称为Load。写内存操作的实质是将数据(可能作为操作数直接存储到指令中,也可能存储在寄存器中)存储到指定地址表示的RAM存储单元中,因此写内存操作通常被称为Store。所以,内存重排序实际上只有4种可能,即LoadLoad重排序、StoreStore重排序、LoadStore重排序、StoreLoad重排序。
另外,内存重排序与具体的处理器架构有关,基于不同微架构的处理器所允许或者支持的内存重排序是不同的。内存重排序还可能导致线程安全问题。

貌似串行语义

重排序并非杂乱无章的排序或顺序调整,而是遵循一定的规则。编译器、处理器都会遵循这些规则,从而给单线程程序创造一种假象(Illusion)——指令是按照源代码顺序执行的。这种假象称为貌似串行语义(As-if-serial Semantics)。貌似串行语义只是从单线程程序的角度保证重排序后的运行结果不影响程序的正确性,它并不保证多线程环境下程序的正确性。
为了保证貌似串行语义,存在数据依赖关系的语句不会被重排序,只有不存在数据依赖关系的语句才会被重排序。如果两个操作(指令)访问同一个变量(地址),且其中一个操作(指令)为写操作,那么这两个操作之间就存在数据依赖关系(Data Dependency)。
另外,存在控制依赖关系的语句是可以允许被重排序的。如果一条语句(指令)的执行结果会决定另外一个语句(指令)能否被执行,那么这两条语句(指令)之间就存在控制依赖关系(Control Dependency)。存在控制依赖关系的语句会影响处理器对指令序列执行的并行程度。

注意:单处理器上实现的多线程实际上是通过分配时间片(Time Slice)实现的。单处理器的系统也存在重排序现象。那么单处理器上运行的一个线程上发生的重排序对这个处理器上运行的其他线程而言,其正确性是否会收到影响呢?

  • 编译器重排序。即静态编译器(javac)造成的重排序会对运行在单处理器上的多个线程产生影响。
  • 运行期重排序,包括存储子系统造成的重排序、JIT编译器造成的重排序以及处理器的乱序执行所导致的重排序,并不会对单处理器上运行的多线程产生影响。即在这些线程看来处理器像是按照程序顺序执行指令。

保证内存访问的顺序性

如何避免重排序导致的线程安全问题呢?实质上就是如何保证感知顺序与源代码顺序一致,即有序性。
有序性的保障可以理解为从逻辑上部分禁止重排序。
从底层的角度来看,禁止重排序是通过调用处理器提供相应的指令(内存屏障)来实现的。

可见性与有序性的联系与区别:可见性是有序性的基础;有序性影响可见性,由于重排序的作用,一个线程对共享变量的更新对于另外一个线程而言可能变得不可见。

上下文切换

上下文切换及其产生原因

单处理器上的多线程其实就是通过这种时间片分配的方式实现的。时间片决定了一个线程可以连续占用处理器运行的时间长度。当一个进程中的一个线程由于其时间片用完或者其自身的原因被迫或者主动暂停其运行时,另外一个线程可以被操作系统(线程调度器)选择占用处理器开始或者继续其运行。这种一个线程被暂停,即被剥夺处理器的使用权,另外一个线程被选中开始或者继续运行的过程叫做线程上下文切换
一个线程被剥夺处理器的使用权而被暂停运行就被称为切出(Switch Out);一个线程被操作系统选中占用处理器开始或者继续其运行就被成为切入(Switch In)。在切出和切入的时候操作系统需要保存和恢复相应线程的进度信息,这个进度信息就被称为上下文(Context)。它一般包括通用寄存器(General Purpose Register)的内容和程序计数器(Program Counter)的内容。

从Java应用的角度看,一个线程的生命周期状态在RUNNABLE状态与非RUNNABLE状态(包括BLOCKED、WAITING和TIMED_WAITING中的任意一个子状态)之间切换的过程就是一个上下文切换的过程。当一个线程的生命周期状态由RUNNABLE转换为非RUNNABLE时,称这个线程被暂停。而一个线程的生命周期状态由非RUNNABLE状态进入RUNNABLE状态时,称为这个线程被唤醒(Wakeup)。注意,一个线程被唤醒仅代表该线程获得了一个继续运行的机会,而并不代表其立刻可以占用处理器运行

上下文切换的分类及具体原因

按照导致上下文切换的因素划分:自发性上下文切换(Voluntary Context Switch)和非自发性上下文切换(Involuntary Context Switch)。
自发性上下文切换指线程由于其自身因素导致的切出。从Java平台的角度看,一个线程在其运行过程中执行下列任意一个方法都会引起自发性上下文切换。

  • Thread.sleep
  • Object.wait()
  • Thread.yield()
  • Thread.join()
  • LockSupport.park()

另外,线程发起I/O操作或者等待其他线程持有的锁也会导致自发性上下文切换。
非自发性上下文切换指线程由于线程调度器的原因被迫切出。导致非自发性上下文切换的常见因素包括被切出线程的时间片用完或者有一个比被切出线程优先级更高的线程需要被运行。从Java平台的角度看,Java虚拟机的垃圾回收(Garbage Collect)动作也可能导致非自发性上下文切换。

上下文切换的开销和测量

从定性的角度看,上下文切换的开销包括直接开销间接开销。其中,直接开销包括:

  • 操作系统保存和恢复上下文所需的开销,主要是处理器时间的开销;
  • 线程调度器进行线程调度的开销

间接开销包括:

  • 处理器高速缓存重新加载的开销;
  • 上下文切换也可能导致整个一级高速缓存中的内存内冲刷(Flush),即一级高速缓存中的内存会被写入下一级高速缓存(如二级高速缓存)或者主内存(RAM)中。

从定量的角度来看,一次上下文切换的时间消耗是微秒级的。
在Linux平台下,可以使用Linux内核提供的perf命令来监视Java程序运行中的上下文切换的次数和频率。在Windows平台,可以使用Windows自带的工具perfmon来监视Java程序运行中的上下文切换情况。
多线程编程相比单线程编程意味着更多的上下文切换,因此多线程编程不一定就比单线程编程的计算效率更高。

线程的活性故障

由于资源稀缺性或者程序自身的问题和缺陷导致线程一直处于非RUNNABLE状态,或者线程虽然处理RUNNABLE状态但是其要执行的任务却一直无法进展的线程被称为线程活性故障(Liveness Failure)。
常见的活性故障包括以下几种:

  • 死锁(Deadlock)
  • 锁死(Lockout)
  • 活锁(Livelock):线程可能处于RUNNABLE状态,但是线程所要执行的任务却丝毫没有进展,即线程可能一直在做无用功。
  • 饥饿(Starvation):饥饿就是线程因无法获取其所需的资源而使得任务执行无法进展的现象。

资源争用与调度

一次只能够被一个线程占用的资源被称为排他性(Exclusive)资源。如处理器、数据库连接、文件等。在一个线程占用一个排他性资源进行访问(读、写操作)而未释放其对资源所有权的时候,其他线程试图访问该资源的现象被称为资源争用(Resource Contention)。根据数量多少可以分为高争用和低争用。
同一时间内,处于运行状态(即生命周期状态为RUNNABLE的RUNNING子状态的线程)的线程数量越多,就称并发的程度越高,简称高并发。
虽然高并发增加了争用的概率,但是高并发并不意味着高争用。理想状态是高并发,低争用。
而多个线程共享同一个资源又会带来新的问题。即资源的调度问题。获得资源的独占权而又未释放其独占权的线程被称为该资源的持有线程。资源调度策略的一个常见特性就是它能否保持公平性。所谓公平性(Fairness)是指资源的申请者(线程)是否按照其申请(请求)资源的顺序而被授予资源的独占权。如果资源的任何一个先申请者总是能够比任何一个后申请者先获得该资源的独占权,那么相应的资源调度策略就是公平的(Fair)。
资源调度的一种常见策略就是排队。
一般来说,非公平调度策略的吞吐率较高,即单位时间内它可以为更多的申请者调配资源。公平调度策略的吞吐率较低。非公平调度策略的好处之一是可能减少上下文切换的次数。默认采用非公平调度策略。

发布了243 篇原创文章 · 获赞 140 · 访问量 25万+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章