第二章 多线程编程的目标与挑战--《java多线程编程实战指南》

2.1 串行、并发与并行

并发就是在一段时间内以交替的方式去完成多个任务,而并行就是以齐头并进的方式去完成多个任务。

从硬件的角度来说,在一个处理器一次只能够运行一个线程的情况下,由于处理器可以使用时间片分配的技术来实现在同一段时间内运行多个线程,因此一个处理器就可以实现并发。而并行则需要靠多个处理器在同一时刻各自运行一个线程来实现。多线程编程的实质就是将任务的处理方式由串行改为并发,即实现并发化。

2.2 竟态

竟态是指计算的正确性依赖于相对时间顺序或者线程的交错。竟态往往伴随着读取脏数据问题,即线程读取到一个过时的数据、丢失更新问题,即一个线程对数据所做的更新没有体现在后续其他线程对该数据的读取上。

竟态的两种模式:read-modify-write(读-改-写)和check-then-act(检测而后行动)

read-modify-write(读-改-写):读取一个共享变量的值(read),然后根据该值做一些计算(modify),接着更新该共享变量的值(write)。

check-then-act(检测而后行动):读取某个共享变量的值,根据变量的值决定下一步的动作是什么。

package JavaCoreThreadPatten.capter02;

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

/**
 * Request ID生成器:最后3位000-999循环递增生成
 */
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 synchronized short nextSequence(){
        if(sequence >=SEQ_UPPER_LIMIT){
            sequence = 0;
        }else {
            sequence++;
        }
        return sequence;
    }

    public String nextID(){
        SimpleDateFormat simpleDateFormat = new SimpleDateFormat("yyMMddHHmmss");
        String timestamp = simpleDateFormat.format(new Date());
        DecimalFormat decimalFormat = new DecimalFormat("000");
        //生成请求序列号
        short sequenceNo = nextSequence();
        return "0049"+timestamp+decimalFormat.format(sequenceNo);
    }

    public static RequestIdGenerator getInstance(){
        return INSTANCE;
    }

    public static void main(String[] args){
        System.out.println(RequestIdGenerator.getInstance().nextID());
    }
}
package JavaCoreThreadPatten.capter02;

import java.util.Random;
import java.util.concurrent.TimeUnit;

/**
 * 模拟RequestIDGenerator在实际环境多线程使用
 */
public class RaceConditionDemo {
    public static void main(String[] args){
        //客户端线程数
        Thread[] threads = new Thread[Runtime.getRuntime().availableProcessors()*2];
        for(int i=0;i<threads.length;i++){
            threads[i] = new WorkThread(i,10);
        }
        /**
         * 启动所有线程
         */
        for(Thread t:threads){
            t.start();
        }
    }

    /**
     * 模拟业务线程
     */
    static class WorkThread extends Thread{
        private final int requestCount;

        public WorkThread(int id,int requestCount) {
            super("worker--"+id);
            this.requestCount = requestCount;
        }

        @Override
        public void run() {
            int i = requestCount;
            String requestID;
            RequestIdGenerator requestIdGenerator = RequestIdGenerator.getInstance();
            while (i-- > 0){
                //生成request ID
                requestID = requestIdGenerator.nextID();
                processRequest(requestID);
            }
        }
        //模拟请求处理
        private void processRequest(String requestId){
            //模拟请求处理耗时
            try {
                TimeUnit.MILLISECONDS.sleep(new Random().nextInt(1000));
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.printf("%s get requestId:%s %n",Thread.currentThread().getName(),requestId);
        }
    }
}

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

2.3 原子性

原子性:对于涉及共享变量访问的操作,若该操作从其执行线程以外的任意线程来看是不可分割的,那么该操作就是原子操作,该操作具有原子性。原子性是不可分割的,是指访问某个共享变量的操作从其执行线程以外的任何线程来看,该操作要么已经执行结束要么尚未发生,即其他线程不会看到该操作执行了部分的中间效果。

package JavaCoreThreadPatten.capter02;

public class AtomicityExample {

    private ServerInfo serverInfo;
    public void updateHostInfo(String ip,String port){
        synchronized (serverInfo){
            serverInfo.setIp(ip);
            serverInfo.setPort(port);
        }
    }

    public void connectionToHost(){
        connect(serverInfo.getIp(),serverInfo.getPort());
    }

    private void connect(String ip,String port){

    }

    public static class ServerInfo{
        private String ip;
        private String port;

        public String getIp() {
            return ip;
        }

        public void setIp(String ip) {
            this.ip = ip;
        }

        public String getPort() {
            return port;
        }

        public void setPort(String port) {
            this.port = port;
        }
    }
}

原子操作是针对多线程环境下的一个概念,首先原子操作是针对共享变量的操作而言;其次原子操作是从该操作的执行线程以外的线程来描述,也就是说他只有在多线程环境下有意义。

java中有两种方式来实现原子性。一种使使用锁,锁具有排他性,即它能够保障一个共享变量在任意一个时刻只能够被一个线程访问;另一种是利用处理器提供的专门CAS指令,CAS指令实现原子性的方式与锁实现原子性的方式实质上是相同的,差别在于锁通常是在软件这一层实现的,而CAS是直接在硬件这一层实现的。

2.4 可见性

可见性就是指一个线程对共享变量的更新的结果对于读取相应共享变量的线程而言是否可见的问题。

package JavaCoreThreadPatten;

import java.util.concurrent.TimeUnit;

public class VisibilityDemo {
    public static void main(String[] args){
        TimeConsumingTask timeConsumingTask = new TimeConsumingTask();
        Thread thread = new Thread(timeConsumingTask);
        thread.start();
        try {
            TimeUnit.SECONDS.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        timeConsumingTask.cancel();
    }
}
class TimeConsumingTask implements Runnable{

    private boolean toCancel = false;

    @Override
    public void run() {
        while (!toCancel){
            System.out.println(1);
            if(doExecute()){
                break;
            }
        }
        if(toCancel){
            System.out.println("Task was canceled..");
        }else {
            System.out.println("Task done");
        }
    }

    private boolean doExecute(){
        System.out.println("executing...");
        try {
            TimeUnit.MILLISECONDS.sleep(50);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        return false;
    }

    public void cancel(){
        toCancel = true;
        System.out.println(this + "cencel() method");
    }
}

可见性问题也与计算机的存储系统有关:

程序中的变量可能会被分配到寄存器而不是主内存中进行存储。每个处理器都有其寄存器,而一个处理器无法读取另外一个处理器上的寄存器中的内容。因此,如果两个线程分别运行在不同的处理器上,而这两个线程所共享的变量却被分配到寄存器上进行存储,那么可见性问题就会产生。另外,即便某个共享变量是被分配到内存中进行存储的,也不能保证该变量的可见性。这是因为处理器对主内存的访问并不是直接访问,而是通过其高速缓存子系统进行的。一个处理器上运行的线程对变量的更新可能只是更新到该处理器的写缓冲器中,还没有到达该处理器的高速缓存中,更不用说到主内存中了。而一个处理器的写缓冲器中的内容无法被另外一个处理器读取,因此运行在另外一个处理器上的线程无法看到这个线程对其某个共享变量的更新。即便一个处理器上运行的线程对共享变量的更新结果被写入到该处理器的高速缓存,由于该处理器将这个变量更新的结果通知给其他处理器的时候,其他处理器可能仅仅将这个更新通知的内容存入无效化队列中,而没有直接根据更新通知的内容更新其高速缓存的相应内容,这就导致了其他处理器上运行的其他线程后续在读取相应共享变量时,从相应处理器的高速缓存中读取到的变量是一个过时的值。

处理器并不是直接与主内存打交道而执行内存的读、写操作,而是通过寄存器、高度缓存、写缓冲器和无效化队列等部件执行内存的读、写操作的。

虽然一个处理器的高速缓存中的内容不能被另外一个处理器直接读取,但是一个处理器可以通过缓存一致性协议来读取其他处理器的高速缓存中的数据,并将读到的数据更新到该处理器的高速缓存中。这种一个处理器从其自身处理器缓存以外的其他存储部件中读取到数据并将结果反映(更新)到该处理器的高速缓存的过程,我们称之为缓存同步。相应的,我们称这些存储部件的内容是可同步的,这些存储部件包括处理器的高速缓存、主内存。缓存同步使得一个处理器(上运行的线程)可以读取到另外一个处理器(上运行的线程)对共享变量所做的更新,即保障了可见性。因此,为了保障可见性,我们必须使一个处理器对共享变量所做的更新最终被写入该处理器的高速缓存或者主内存中(而不是始终停留在其写缓冲器中),这个过程被称为冲刷处理器缓存。并且,一个处理器在读取共享变量的时候,如果其他处理器在此之前已经更新了该变量,那么该处理器必须从其他处理器的高速缓存或者主内存中对相应的变量进行缓存同步。这个过程被称为刷新处理器缓存。因此,可见性的保障是通过使更新共享变量的处理器执行冲刷处理器缓存的动作,并使读取共享变量的处理器执行刷新处理器缓存的动作来实现的。

2.5 有序性

重排序:编译器可能改变两个操作的先后顺序;处理器可能不是完全依照程序的目标代码所指定的顺序执行指令;一个处理器上执行的多个操作,从其他处理器的角度来看其顺序可能与目标代码所指定的顺序不一致。

重排序分为指令重排序和存储子系统重排序

指令重排序

编译器处于性能的考虑,在其认为不影响程序(单线程程序)正确性的情况下可能会对源代码顺序进行调整,从而造成程序顺序与相应的源代码顺序不一致。java静态编译器(javac)基本上不会执行指令重排序,而JIT编译器则可能执行指令重排序。

现代处理器为了提高指令执行效率,往往不是按照程序顺序注意执行指令的,而是动态调整指令的顺序,做到哪条指令就绪就先执行哪条指令,这就是处理器的乱序执行。在乱序执行的处理器中,指令是一条一条按照程序顺序被处理器读取的,然后这些指令中哪条就绪了哪条就会先被执行,而不是完全按照程序顺序执行。这些指令执行的结果会被先存入重排序缓冲器,而不是直接被写入寄存器或者主内存。重排序缓冲器会将各个指令的执行结果按照相应的指令被处理器读取的顺序提交到寄存器或者内存中去。在乱序执行的情况下,尽管指令的执行顺序可能没有完全按照程序顺序,但是由于指令的执行结果的提交仍然是按照程序的顺序来的,因此处理器的指令重排序并不会对单线程程序的正确性产生影响。

存储子系统重排序

主内存与处理器之间有高速缓冲器和写缓冲器,处理器通过高速缓冲器访问主内存,使用写缓冲器提高写主内存的效率,写缓冲器和高速缓冲器统称为存储子系统。

即使在处理器严格按照程序顺序执行两个内存访问操作的情况下,在存储子系统的作用下其他处理器对这两个操作的感知顺序仍然可能与程序顺序不一致,这种线程就是存储子系统重排序。

指令重排序重排序对象是指令,实实在在地对指令的顺序进行调整,而存储子系统重排序是一种现象而不是一种动作,它并没有真正对指令执行顺序进行调整,而只是造成了一种指令的执行顺序像是被调整过的一样,其重排序的对象是内存操作的结果。

保证内存访问的顺序性

编译器、处理器都会遵守一定的规则,从而给单线程程序创造一种假象--指令是按照源代码顺序执行的,这种假象就是貌似串行语义

有序性的保障可以理解为通过某些措施使得貌似串行语义扩展到多线程程序,即重排序要么不发生,要么即使发生了也不会影响多线程程序的正确性。有序性的保障可以理解为从逻辑上部分禁止重排序,禁止重排序是通过调用处理器提供相应的指令(内存屏障)来实现的。

2.6 上下文切换

上下文切换在某种程度上可以看做多个线程共享一个处理器的产物。

单处理器上的多线程是通过时间片分配的方式实现的。时间片决定了一个线程可以连续占用处理器运行的时间长度。当一个进程中的一个线程由于其时间片用完或者其自身的原因被迫或者主动暂停其运行时,另一个线程可以被操作系统选中占用处理器开始或者继续运行。这种一个线程被暂停,即被剥夺处理器的使用权,另一个线程被选中开始或者继续运行的过程就叫做线程上下文切换。

连续运行的线程实际上是以断断续续运行的方式使其任务进展的,意味着在切出和切入的时候操作系统需要保存和恢复相应线程的进度信息,即切入和切出那一刻相应线程所执行的任务进行到什么程度了,这个进度信息被称为上下文。它一般包括通用寄存器的内容和程序计数器的内容。在切出时,操作系统需要将上下文保存到内存中,以便被切出的线程稍后占用处理器继续其运行时能够在此基础上进展。在切入时,操作系统需要从内存中加载被选中线程的上下文,以在之前运行的基础上继续进展。

java应用中线程从RUNNABLE状态转为非RUNNABLE状态称为被暂停,反之被唤醒。

上下文切换分为自发性上下文切换和非自发性上下文切换:

自发性上下文切换指线程由于其自身因素导致的切出或者发起I/O操作或者等待其他线程持有的锁,如

非自发性上下文切换指线程由于线程调度器的原因被迫切出,如java虚拟机在进行Full GC时会stop the world,暂停所有的其他线程

上下文切换开销包括直接开销和间接开销:

直接开销:1.操作系统保存和恢复上下文所需的开销;2.线程调度器进行线程调度的开销;

间接开销:1.处理器高速缓存重新加载的开销;上下文切换也可能导致整个一级高速缓存中的内容被冲刷,即一级高速缓存中的内容会被写入下一级高速缓存或者主内存中。

多线程编程中使用的线程数量越多,程序的计算效率可能反而越低。

2.7 线程的活性故障

  • 死锁
  • 锁死
  • 活锁
  • 饥饿

2.8 资源争用与调度

资源的调度分为公平和不公平两种:公平调度简单来说就是按照顺序调度,优点是不会出现饥饿现象;非公平调度的优点就是吞吐率较高。

非公平调度吞吐率高的原因是资源的持有线程释放该资源的时候等待队列中的一个线程会被唤醒,而该线程从被唤醒到其继续运行可能需要一段时间。在该时间内,新来的活跃线程可以先被授予该资源的独占权。如果这个新来的线程占用该资源的时间不长,那么它完全有可能在被唤醒的线程继续其运行前释放相应的资源,从而不影响该被唤醒的线程申请资源,这种情况下,非公平调度策略带来一个好处--可能减少上下文切换的次数。

非公平调度策略是我们多数情况下的首选资源调度策略。其优点是吞吐率较大;缺点是自愿申请者申请资源所需的时间偏差可能较大,并可能导致饥饿现象。公平调度策略适合在资源的持有线程占用资源的时间相对长或资源的平均申请时间间隔相对长的情况下,或者对资源申请所需的时间偏差有所要求的情况下使用。其优点是线程申请资源所需的时间偏差较小,并且不会导致饥饿现象;缺点是吞吐率小。

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