《Java多线程编程实战指南(核心篇)》阅读笔记

时间:2023-02-07 00:24:49

《Java多线程编程实战指南(核心篇)》阅读笔记

*/-->

《Java多线程编程实战指南(核心篇)》阅读笔记

1 线程概念

1.1 进程、线程

进程
程序向操作系统申请资源(如内存空间和文件句柄)的基本单位
线程
进程中可独立执行的最小单位

一个进程可以包含多个线程,同一个进程中的所有线程共享该进程中的资源,如内存空间、文件句柄等。

1.2 Java中线程的创建

在Java平台中创建一个线程就是创建一个Thread类(或其子类)的实例;运行一个线程实际上就是让Java虚拟机执行其run方法,
使相应的任务处理逻辑代码得以执行,Thread类的start方法的作用是启动相应的线程。

start方法调用结束并不意味着相应线程已经开始运行,这个线程可能稍后才被运行,甚至也可能永远不会被运行。
因为启动一个线程的实质是请求Java虚拟机运行相应的线程,而这个线程具体何时能够运行是由线程调度器(Scheduler)决定的。

创建Java线程
Thread类的两个常用构造器是:Thread和Thread(Runnable target),Thread是线程的抽象,Runnable可以看作是对任务的抽象。
new Thread(){
@Override
public void run() {
// 执行任务
}
};
new Thread(new Runnable() {
@Override
public void run() {
// 执行任务
}
});
线程不可重复使用
线程属于“一次性用品”,我们不能通过重新调用一个已经运行结束的线程的start方法来使其重新运行。
事实上,多次调用同一个Thread实例的start方法会导致其抛出IllegalThreadStateException异常。
内存空间分配
首先在Java中,一个线程就是一个对象,对象的创建需要内存空间的分配。
与创建其他类型的Java对象所不同的是,Java虚拟机会为每个线程分配调用栈(Call Stack)所需的内存空间。
调用栈用于跟踪方法间的调用关系以及Java代码对本地代码(Native Code,通常是C代码)的调用。
另外,Java平台中的每个线程可能还有一个内核线程(具体与Java虚拟机的实现有关)与之对应。
因此相对来说,创建线程对象比创建其他类型的对象的成本要高一些。
执行线程与当前线程
Java中的任意一段代码(比如一个方法)总是由确定的线程负责执行的,这个线程就相应地被称为这段代码的执行线程;
任意一段代码都可以通过调用Thread.currentThread()来获取这段代码的执行线程,这个线程就被称为当前线程。

1.3 线程(Thread)的属性

线程的属性包括线程的编号(Id)、名称(Name)、线程类别(Daemon)和优先级(Priority)。

  属性      类型                  用途                只读                                 说明                                
编号(ID)  
          
          
          
          
long   
       
       
       
       
用于标识不同的线程,不同的线程拥有
不同的编号                        
                                  
                                  
                                  
是   
     
     
     
     
某个编号的线程运行结束后,该编号可能被后续创建的线程使用。不同线程 
拥有的编号虽然不同,但是这种编号的唯一性只在Java虚拟机的一次运行有 
效。也就是说重启个Java虚拟机(如重启Web服务器)后,某些线程的编号可能
与上次Java虚拟机运行的某个线程的编号一样,因此该属性的值不适合用作 
某种唯一标识,特别是作为数据库中的唯一标识(如主键)                 
名称      
(Name)    
          
String 
       
       
用于区分不同的线程,默认值与线程的
编号有关,默认值的格式为:“Thread- 
线程编号”,如“Thread-0”           
否   
     
     
Java并不禁止我们将不同的线程的名称属性设置为相同的值,尽管如此,设 
置线程的名称属性有助于代码调试和问题定位                           
                                                                   
线程类别  
(Daemon)  
          
          
boolean
       
       
       
值为tnue表示相应的线程为守护线程,
否则表示相应的线程为用户线程。该属
性的默认值与相应线程的父线程的该属
性的值相同                        
否   
     
     
     
该属性必须在相应线程启动之前设置,即对setDaemon方法的调用必须在对  
start方法的调用之前,否则setDaemon方法会抛出                       
IllegalThreadStateException异常。负责一些关键任务处理的线程不适宜设
置为守护线程                                                       
优化级    
(Priority)
          
          
          
          
          
int    
       
       
       
       
       
       
该属性本质上是给线程调度器的提示,
用于表示应用程序希望哪个线程能够优
先得以运行。Java定义了1~10的10个优
先级,默认值一般为5(表示普通优先级
)。对于具体的一个线程而言,其优先 
级的默认值与其父线程(创建该线程的 
线程)的优先级值相等。             
否   
     
     
     
     
     
     
一般使用默认优先级即可,不恰当地设置该属性值可能导致严重的问题(线程
饥饿)                                                              
                                                                   
                                                                   
                                                                   
                                                                   
                                                                   
线程属性的使用
线程的属性除了编号外,其他属性都是可读写的属性,即Thread类提供了相应的get方法和set方法用于读取或者设置相应的属性。
Thread.currentThread().getName();
优先级的设定
Java线程的优先级属性本质上只是一个给线程调度器的提示信息,以便于线程调度器决定优先调度哪些线程运行。
它并不能保证线程按照其优先级高低的顺序运行。另外,Java线程的优先级使用不当或者滥用则可能导致某些线程永远无法得到运行,
即产生了线程饥钱(Thread Starvation)。因此,线程的优先级并不是设置得越高越好;
一般情况下使用普通优先级即可,即不必设置线程的优先级属性。
用户线程与守护线程
按照线程是否会阻止Java虚拟机正常停止,我们可以将Java中的线程分为守护线程(Daemon Thread)和用户线程
(User Thread,也称非守护线程)。其中用户线程会阻止Java虚拟机的正常停止,
即一个Java虚拟机只有在其所有用户线程都运行结束的情况下才能正常停止;
而守护线程则不会影响Java虚拟机的正常停止,即应用程序中有守护线程在运行也不影响虚拟机的正常停止。
因此,守护线程通常用于执行一些重要性不是很高的任务,例如用于监视其他线程的运行情况。

1.4 Thread类的常用方法

       方法                          功能                                                   备注                                
static Thread     
currentThread()   
返回当前线程,即当前代码的执行线程(对象)
                                        
同一段代码在不同时刻对Thread.currentThread的调用,其返回值可能不同   
                                                                    
void run()         线程的任务处理逻辑                       该方法是由Java虚拟机直接调用的,一般情况下应用程序不应该调用该方法  
void start()      
                  
启动线程                                
                                        
该方法的返回并不代表相应的线程已经被启动;一个Thread实例的start方法 
只能够被调用一次,多次调用会抛出异常                                
void join()        等待线程运行结束                         线程A调用线程B的join方法,那么线程A的运行会被暂停,直到线程B运行结束
static void       
yield()           
使当前线程主动放弃其对处理器的占用,这可
能导致当前线程被暂停                    
这个方法是不可靠的,该方法被调用时当前线程可能仍然继续运行(视系统当 
前的运行状况而定)                                                   
static void       
sleep(long millis)
使当前线程休眠(暂停运行)指定的时间      
                                        
                                                                    
                                                                    

1.5 线程的层次关系

父线程与子线程
Java平台中的线程不是孤立的,线程与线程之间总是存在一些联系。假设线程A所执行的代码创建了线程B,
那么,习惯上我们称线程B为线程A的子线程,相应地线程A就被称为线程B的父线程。不过Java平台中并没有API用于获取一个线程的父线程,
或者获取一个线程的所有子线程。
父子线程的Daemon值
默认情况下,父线程是守护线程,则子线程也是守护线程,父线程是用户线程,则子线程也是用户线程。
另外,父线程在创建子线程后启动子线程之前可以调用该线程的setDaemon方法,将相应的线程设置为守护线程(或者用户线程)。
父子线程的优先级
一个线程的优先级默认值为该线程的父线程的优先级,即如果我们没有设置或者更改一个线程的优先级,
那么这个线程的优先级的值与父线程的优先级的值相等。
父子线程的生命周期
父线程和子线程之间的生命周期也没有必然的联系。比如父线程运行结束后,子线程可以继续运行,
子线程运行结束也不妨碍其父线程继续运行。
工作者线程
习惯上,我们也称某些子线程为エ作者线程(Worker Thread)或者后台线程(Background Thread)。
工作者线程通常是其父线程创建来用于专门负责某项特定任务的执行的。
例如,Java虚拟机中对内存进行回收的线程通常被称为GC工作者线程。

1.6 线程的生命周期

《Java多线程编程实战指南(核心篇)》阅读笔记

Figure 1: Java线程的状态

Java线程的状态可以使用监控工具査看,也可以通过Thread.getState()调用来获取。
Thread.getState()的返回值类型Thread.State是一个枚举类型,其定义的线程状态包括以下几种:

NEW
一个已创建而未启动的线程处于该状态。由于一个线程实例只能够被启动次,因此一个线程只可能有一次处于该状态。
RUNNABLE
该状态可以被看成一个复合状态,它包括两个子状态:READY和RUNNING。
前者表示处于该状态的线程可以被线程调度器(Scheduler)进行调度而使之处于RUNNING状态;
后者表示处于该状态的线程正在运行,即相应线程对象的run方法所对应的指令正在由处理器执行。
执行Thread.yield()的线程,其状态可能会由RUNNING转换为READY。处于READY子状态的线程也被称为活跃线程。
BLOCKED
一个线程发起一个阻塞式I/O(Blocking I/O)操作后,或者申请一个由其他线程持有的独占资源(比如锁)时,相应的线程会处于该状态,
处于Blocked状态的线程并不会占用处理器资源。当阻塞式1O操作完成后,或者线程获得了其申请的资源,
该线程的状态又可以转换为RUNNABLE。
Waiting
一个线程执行了某些特定方法之后,就会处于这种等待其他线程执行另外一些特定操作的状态。
能够使其执行线程变更为WAITING状态的方法包括:Object.wait()、Thread.join()和LockSupport.park(Object)。
能够使相应线程从WAITING变更为RUNNABLE的对应方法包括:Object.notify()、notifyAll()和LockSupport.unpark(Objec)
TIMEDWAITING
该状态和WAITING类似,差别在于处于该状态的线程并非无限制地等待其他线程执行特定操作,而是处于带有时间限制的等待状态。
当其他线程没有在指定时间内执行该线程所期望的特定操作时,该线程的状态自动转换为RUNNABLE。
TERMINATED
已经执行结束的线程处于该状态。由于一个线程实例只能够被启动一次,因此一个线程也只可能有一次处于该状态。
Thread.run()正常返回或者由于抛出异常而提前终止都会导致相应线程处于该状态。

2 多线程编程

2.1 串行、并发与并行

《Java多线程编程实战指南(核心篇)》阅读笔记

Figure 2: 串行、并发与并行示意图

串行(Sequential)
先开始做事情A,待其完成之后再开始做事情B,依次类推,直到事情C完成。这实际上顺序逐一完成几件事情,只需要投入一个人。
在这种方式下3件事情总共耗时35(15+10+10)分钟。
并发(Concurrent)
这种方式也可以只投入一个人,这个人先开始做事情A,事情A的准备活动做好后(此时消耗了5分钟),
在等待事情A完成的这段时间内他开始做事情B,为事情B的准备活动花了2分钟之后,在等待事情B完成的这段时间内他开始做事情C,
直到10分钟之后事情C完成。这整个过程实际上是以交替的方式利用等待某件事情完成的时间来做其他事情,
在这种方式下3件事情总共耗时17(5+2+10)分钟,这比串行方式节约了一半多的时间。
并行(Parallel)
这种方式需要投入3个人,每个人负责完成其中一件事情,这3个人在同一时刻开始齐头并进地完成这些事情。
在这种方式下3件事情总共耗时15分钟(取决于耗时最长的那件事情所需的时间),比并发的方式节约了2分钟的时间。

并发往往带有部分串行,而并发的极致就是并行。从软件的角度来说,并发就是在一段时间内以交替的方式去完成多个任务,
而并行就是以齐头并进的方式去完成多个任务。

从软件角度讲,要以并发的方式去完成几个任务往往需要借助多个线程(而不是一个线程)。
从硬件的角度来说,在一个处理器一次只能够运行一个线程的情况下,由于处理器可以使用时间片(Time-slice)分配的技术在同一段时间内运行多个线程,
因此一个处理器就可以实现并发。而并行则需要靠多个处理器在同一时刻各自运行一个线程来实现。

多线程编程的实质就是将任务的处理方式由串行改为并发,即实现并发化,以发挥并发的优势。如果一个任务的处理方式可以由串行改为并发(或者并行),
那么我们就称这个任务是可并发化(或者可并行化)的。

2.2 多线程编程中存在的问题

2.2.1 竞态(Race Condition)

概念
竞态是指计算的正确性依赖于相对时间顺序或者线程的交错。竞态往往伴随着读取脏数据问题(即读取到一个过时的数据),
以及丢失更新问题(即一个线程对数据所做的更新没有体现在后续其他线程对该数据的读取上)。
  1. 竞态的出现
    public class Main {
    static class IndexGen {
    private int index; int nextIndex() {
    return index++;
    }
    } public static void main(String[] args) throws InterruptedException {
    IndexGen indexGen = new IndexGen();
    final int num = 100;
    final int[] visited = new int[num];
    final List<Thread> threads = new ArrayList<>();
    for (int i = 0; i < num; i++) {
    threads.add(new Thread(() -> visited[indexGen.nextIndex()] ++));
    }
    threads.forEach(Thread::start);
    for (Thread thread : threads) {
    thread.join();
    }
    for (int i = 0; i < 10; i++) {
    for (int j = 0; j < 10; j++) {
    System.out.printf("%d\t", visited[i * 10 + j]);
    }
    System.out.println();
    }
    }
    }

    参考上述程序,我们创建一百个线程,每次通过IndexGen获取下一个index,并将其访问次数加一。我们期望的结果是每个index都只被访问一次,
    但多次运行后,却发现可能出现下面的结果,其中,0,62,85被访问了两次,导致97,98,99未被访问:

    2	1	1	1	1	1	1	1	1	1
    1 1 1 1 1 1 1 1 1 1
    1 1 1 1 1 1 1 1 1 1
    1 1 1 1 1 1 1 1 1 1
    1 1 1 1 1 1 1 1 1 1
    1 1 2 1 1 1 1 1 1 1
    1 1 1 1 1 1 1 1 1 1
    1 1 1 1 1 2 1 1 1 1
    1 1 1 1 1 1 1 1 1 1
    1 1 1 1 1 1 1 0 0 0

    上述例子中,依照nextIndex()方法实现的逻辑,下标总是递增的,因此不同的线程它们所“拿到”的index也不应该相同才对。
    但从结果来看,不同线程却“拿到”了重复的index,即nextIndex()所返回的下标值重复了。且如果我们多次重复运行代码,所得到的结果也不完全相同。
    这个现象说明,当多个线程在没有采取任何控制措施的情况下并发地更新、读取同一个共享变量时,是不安全的,可能出现意料之外的结果。

  2. 出现竞态的原因

    上述例子中的 index++ 看起来像是一个操作,实际上相当于如下3个指令:

    1oad(index,r1);  //指令①:将变量index的值从内存读到寄存器r1
    increment(r1); //指令②:将寄存器r1的值增加1
    store(index,r1); /指令③:将奇存器r1的内容写入变量index所对应的内存空间

    多个线程在执行上述指定时,可能交错运行上述三个指令,导致三个线程读取到的index值重复,如下表如示:

      thread-0 thread-1 thread-2
    t1 未运行 执行其他操作 执行其他操作
    t2 执行其他操作 [index=0]执行指令① [index=0]执行指令①
    t3 执行其他操作 [r1=0]执行指令② [r1=0]执行指令②
    t4 [index=0]执行指令① [r1=1][index=0]执行指令③ [r1=1][index=0]执行指令③
    t5 [r1=0]执行指令② [index=1]执行其他操作 [index=1]执行其他操作
    t6 [r1=1][index=0]执行指令③ 运行结束 运行结束
    t7 [index=1]执行其他操作 运行结束 运行结束

    从上述竞态典型实例中我们可以提炼出竞态的两种模式:read-modify-write(读-改-写)和check-then-act(检测-执行)。

    read-modify-write
    读取个共享变量的值(read),然后根据该值做一些计算(modify),接着更新该共享变量的值(write)
    check-then-act
    读取(read)某个共享变量的值,根据该变量的值(如if语句)决定下一步的动作(act)是什么。

2.2.2 线程安全性(Thread Safety)

定义
一般而言,如果一个类在单线程环境下能够运作正常,并且在多线程环境下,如果使用方不必做任何改变的情况下也能运作正常,
那么我们就称其是线程安全的,相应地我们称这个类具有线程安全性

线程安全问题概括来说表现为三个方面:原子性、可见性和有序性。

2.2.3 原子性(Automicity)

定义
原子的字面意思是不可分割的。对于涉及共享变量访问的操作,若该操作从其执行线程以外的任意线程来看是不可分割的,
那么该操作就是原子操作,相应地我们称该操作具有原子性。
不可分割(Indivisible)
其中一个含义是指访问(读、写)某个共享变量的操作从其执行线程以外的任何线程来看,
该操作要么已经执行结束要么尚未发生,即其他线程不会“看到”该操作执行了部分的中间效果。
另一个含义是,如果T1和T2是访问共享变量V的两个原子操作,如果这两个操作并非都是读操作,
那么个线程执行T1期间(开始执行而未执行完毕),其他线程无法执行T2。也就是说,访问同一组共享变量的原子操作是不能够被交错的,
这就排除了一个线程执行一个操作期间另外一个线程读取或者更新该操作所访问的共享变量而导致的干扰(读脏数据)和冲突(丢失更新)的可能。

总的来说,Java中有两种方式来实现原子性:锁(Lock)和CAS(Compare-And-Swap)指令。

锁具有排他性,即它能够保障一个共享变量在任意一个时刻只能够被一个线程访问,
这就排除了多个线程在同一时刻访问同一个共享变量而导致干扰与冲突的可能,即消除了竞态。
CAS指令
CAS指令实现原子性的方式与锁实现原子性的方式实质上是相同的,差别在于锁通常是在软件这一层次实现的,
而CAS是直接在硬件(处理器和内存)这一层次实现的,它可以被看作“硬件锁”。

在Java语言中,long型和double型以外的任何类型的变量的写操作都是原子操作,包括:byte、boolean、short、char、foat、int和引用型变量。

对long/double型变量的写操作由于Java语言规范并不保障其具有原子性,因此在多个线程并发访问同一long/double型变量的情况下,
一个线程可能会读取到其他线程更新该变量的“中间结果”。这是因为Java中的long/double型变量会占用64位的存储空间,
而32位的Java虚拟机对这种变量的写操作可能会被分解为两个步骤来实施,比如先写低32位,再写高32位。
那么,在多个线程试图共享同一个这样的变量时就可能出现一个线程在写高32位的时候,另外一个线程正在写低32位的情形。

2.2.4 可见性(Visibility)

定义
在多线程环境下,一个线程对某个共享变量进行更新之后,后续访问该变量的线程可能无法立刻读取到这个更新的结果,
甚至永远也无法读取到这个更新的结果。如果一个线程对某个共享变量进行更新之后,后续访问该变量的线程可以读取到该更新的结果,
那么我们就称这个线程对该共享变量的更新对其他线程可见,否则我们就称这个线程对该共享变量的更新对其他线程不可见。

多线程程序在可见性方面存在问题意味着某些线程读取到了旧数据(Stale Data),而这可能导致程序出现我们所不期望的结果。

  1. 寄存器和高速缓存带导致的不可见

    程序中的变量可能会被分配到寄存器(Register)而不是主内存中进行存储,每个处理器都有其自己的寄存器,
    而一个处理器无法读取另外一个处理器上的寄存器中的内容。因此,如果两个线程分别运行在不同的处理器上,
    且这两个线程所共享的变量却被分配到寄存器上进行存储,那么可见性问题就会产生。

    另外,即便某个共享变量是被分配到主内存中进行存储的,也不能保证该变量的可见性。这是因为处理器对主内存的访问并不是直接访问,
    而是通过其高速缓存子系统进行的。一个处理器上运行的线程对变量的更新可能只是更新到该处理器的写缓冲器中,还没有到达该处理器的高速缓存中,
    更不用说到主内存中了。而一个处理器的写缓冲器中的内容无法被另外一个处理器读取,
    因此运行在另外一个处理器上的线程无法看到这个线程对某个共享变量的更新。

    缓存一致性协议(Cache Coherence Protocol)
    通过缓存一致性协议可以让一个处理器来读取其他处理器的高速缓存中的数据,

    并将读到的数据更新到该处理器的高速缓存中。这种一个处理器从其自身处理器缓存以外的其他存储部件中读取数据,
    并将其更新到该处理器的高速缓存的过程,我们称之为缓存同步。相应地,我们称这些存储部件的内容是可同步的,
    可同步的存储部件包括处理器的高速缓存、主内存。

    缓存同步使得一个处理器上运行的线程,可以读取到另外一个处理器上运行的线程对共享变量所做的更新,这样在硬件层次上即保障了可见性。

    冲刷处理器缓存
    为了保障可见性,我们必须使一个处理器对共享变量所做的更新最终被写入该处理器的高速缓存或者主内存中

    而不是始终停留在其写缓冲器中,这个过程被称为冲刷处理器缓存。

    刷新处理器缓存
    同样,为了保障可见性,一个处理器在读取共享变量的时候,如果其他处理器在此之前已经更新了该变量,

    那么该处理器必须从其他处理器的高速缓存或者主内存中,对相应的变量进行缓存同步,这个过程被称为刷新处理器缓存。

  2. JIT优化导致的不可见
    public class Main {
    static /**volatile**/ boolean isCanceled = false; public static void main(String[] args) throws InterruptedException {
    new Thread(() -> {
    while (!isCanceled) {
    // System.out.println("hello");
    }
    }).start();
    System.out.println("come here");
    Thread.sleep(1000);
    isCanceled = true;
    }
    }

    如果我们运行上述代码,我们会发现主程序永远不会结束,但是如果我们去掉对volatile的注释,那么程序将只运行一秒。

    这是JIT优化导致变量不可见的一个十分典型的例子,JIT在优化代码时,可能将isCanceled当做非共享变量处理,
    认为isCanceled在当前线程不会发生改变,进而直接使用false代替。另外比较有趣的一点,就是如果我们保持将volatile注释掉的状态,
    但是在每次循环中打印一条"hello"语句,那么程序将同样只运行一秒,这从另一个角度JIT优化将为不安全的多线程程序的带来难以预测的结果。

    volatile
    该关键字所起到的一个作用就是提示JIT编译器被修饰的变量可能被多个线程共享,

    以阻止JT编译器做出可能导致程序运行不正常的优化;另外一个作用就是读取一个volatile关键字修饰的变量会使相应的处理器执行刷新处理器缓存的动作,
    写个 volatile关键字修饰的变量会使相应的处理器执行冲刷处理器缓存的动作,从而保障了可见性。

  3. Java语言规范(Java Language Specification)中的可见性保证
    • Java语言规范保证,父线程在启动子线程之前对共享变量的更新对于子线程来说是可见的
    • Java语言规范保证,一个线程终止后,该线程对共享变量的更新对于调用该线程的join方法的线程而言是可见的
  4. 需要注意的几个问题
    1. 可见性得以保障,并不意味着一个线程能够看到另外一个线程更新的所有变量的值。如果一个线程在某个时刻更新了多个共享变量的值,
      那么此后其他线程再来读取这些变量时,这些线程所读取到的变量值有些是其他线程更新过的值,而有些则可能仍然是其他线程更新之前的值(旧值)。
    2. 另一方面,可见性的保障仅仅意味着一个线程能够读取到共享变量的相对新值,而不能保障该线程能够读取到相应变量的最新值。

      相对新值
      对于一个共享变量而言,一个线程更新了该变量的值之后,其他线程能够读取到这个更新后的值,那么这个值就被称为该变量的相对新值。
      最新值
      如果读取共享变量的线程,在读取并使用该变量的时候其他线程无法更新该变量的值,那么该线程读取到的相对新值就被称为该变量的最新值。

      相对新值描述的是一个时效性问题,即如果线程A在成功读取到共享变量x当前最新值的同时,线程B更新了共享变量x,由于读取动作已发生,
      线程A获取的值只能是相对新值,而x的最新值只有在线程A下一次读取x时才会被获取到。

    3. 可见性问题是多线程衍生出来的问题,它与程序的目标运行环境是单核处理器还是多核处理器无关。
      也就是说,单处理器系统中实现的多线程编程也可能出现可见性问题:在目标运行环境是单处理器的情况下,
      多线程的并发执行实际上是通过时间片分配实现的。此时,虽然多个线程是运行在同一个处理器上的,
      但是由于在发生上下文切換的时候,一个线程对寄存器变量的修改会被作为该线程的线程上下文保存起来,
      这导致另外一个线程无法“看到”该线程对这个变量的修政,因此,单处理器系统中实现的多线程编程也可能出现可见性问题。

2.2.5 有序性(Ordering)

  1. 重排序
    重排序(Reordering)
    在多核处理器的环境下,代码的执行顺序可能是没有保障的:编译器可能改变两个操作的先后顺序;
    工处理器可能不是完全依照程序的目标代码所指定的顺序执行指令;另外,在一个处理器上执行的多个操作,
    从其他处理器的角度来看其顺序可能与目标代码所指定的顺序不一致。这种现象就叫作重排序。

    重排序是对内存访问有关的操作(读和写)所做的一种优化,它可以在不影响单线程程序正确性的情况下提升程序的性能。
    但是,它可能对多线程程序的正确性产生影响,即它可能导致线程安全问题。

    与可见性问题类似,重排序也不是必然出现的。重排序的潜在来源有许多,包括编译器(在Java中指的是JIT编译器)、
    处理器和存储子系统(包括写缓冲器、高速缓存)。

    为了理解重排序,我们先定义几个顺序:

    源代码顺序
    源代码中所指定的内存访问操作顺序。
    程序顺序
    在给定处理器上运行的目标代码所指定的内存访问操作顺序。尽管Java虚拟机执行代码有两种方式:
    解释执行(被执行的是字节码)和编译执行(被执行的是机器码),里的目标代码是指字节码。
    执行顺序
    内存访问操作在给定处理器上的实际执行顺序。
    感知顺序
    给定处理器所感知到(看到)的该处理器及其他处理器的内存访问操作发生的顺序。

    在此基础上,我们可以将重排序可以分为两类:指令重排序和存储子系统重排序。

     重排序类型                重排序表现                    重排序来源     
    指令重排序  
                
                
    程序顺序与源代码顺序不一致             JIT编译器           
    执行顺序与程序顺序不一致               JIT编译器、处理器   
    存储子系统重
    排序        
    源代码顺序、程序顺序和执行顺序这三者保
    持一致,但是感知顺序与执行顺序不一致   
    高速缓存、写缓冲器  
                        
  2. 指令重排序(Instruction Reorder)
    定义
    在源代码顺序与程序顺序不一致,或者程序顺序与执行顺序不一致的情况下,我们就说发生了指令重排序。
    指令重排序是一种动作,它确确实实地对指令的顺序做了调整,其重排序的对象是指令。
    public class Main {
    private int shardValue = 1;
    private Helper helper; private void createHelper() {
    this.helper = new Helper(shardValue);
    } public static void main(String[] args) throws InterruptedException {
    final ExecutorService executorService = Executors.newCachedThreadPool();
    final Semaphore semaphore = new Semaphore(Runtime.getRuntime().availableProcessors());
    Map<Integer, AtomicInteger> sumToTimes = new ConcurrentHashMap<>(16);
    Main main = new Main();
    final int size = 200000;
    for (int i = 0; i < size; i++) {
    executorService.submit(() -> {
    try {
    semaphore.acquire();
    main.createHelper();
    } catch (InterruptedException e) {
    e.printStackTrace();
    } finally {
    semaphore.release();
    }
    });
    executorService.submit(() -> {
    try {
    try {
    semaphore.acquire();
    } catch (InterruptedException e) {
    e.printStackTrace();
    }
    main.sum(sumToTimes);
    } finally {
    semaphore.release();
    }
    });
    }
    executorService.shutdown();
    executorService.awaitTermination(2000, TimeUnit.MINUTES);
    sumToTimes.forEach((sum, times) -> System.out.printf("%d : %d times %n", sum, times.intValue()));
    } private void sum(Map<Integer, AtomicInteger> sumToTimes) {
    int sum;
    final Helper observed = this.helper;
    if (observed == null) {
    sum = -1;
    } else {
    sum = observed.a + observed.b + observed.c + observed.d;
    }
    AtomicInteger atomicInteger = sumToTimes.putIfAbsent(sum, new AtomicInteger(1));
    if (atomicInteger != null) {
    sumToTimes.get(sum).incrementAndGet();
    }
    } static class Helper {
    int a;
    int b;
    int c;
    int d; Helper(int value) {
    a = b = c = d = value;
    }
    }
    }

    在上述例子中,我们创建了一个Main对象,并不停的给它的成员变量helper赋值,并计算helper的sum值。
    代码很简单,我们可以预测到输出结果大多都为4,只有在helper被赋值之前才可能出现-1,
    除这两种外不存在其他输出。但如果我们多次运行程序(需要添加JVM参数: -server -XX:-UseCompressedOops),
    就会发现实际的输出结果包含了 -1~4 之间的所有值。

    ==== 运行结果一
    0 : 1 times
    2 : 3 times
    3 : 3 times
    4 : 199993 times ==== 运行结果二
    1 : 1 times
    3 : 2 times
    4 : 199997 times ==== 运行结果三
    -1 : 2 times
    3 : 1 times
    4 : 199997 times

    如果我用使用常量 1 代替 this.helper = new Helper(shardValue); 中的 shardValue 变量,
    会发现运行结果只有0和-4两种。这是因为该语句可分为下列几个子操作(伪代码):

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

    如果我们打印出JIT编译出的汇编代码(须添加hsdis-amd64库,并在JVM参数中加入
    -XX:+UnlockDiagnosticVMOptions -XX:+PrintAssembly -XX:+LogCompilation
    -XX:PrintAssemblyOptions=intel),会发现执行顺序与伪代码中的不一致,
    汇编指令将操作三重排序到操作一和操作二的中间:

    x00007fe6a51e3788: mov    QWORD PTR [rax+0x18],0x0  ;*new
    ; - com.thunisoft.zjzy.toolbox.Main::createHelper@1 (line 18)
    ; - com.thunisoft.zjzy.toolbox.Main::lambda$main$0@5 (line 31)
    ; - com.thunisoft.zjzy.toolbox.Main$$Lambda$1/1456208737::run@8
    ; - java.util.concurrent.Executors$RunnableAdapter::call@-1 (line 511) 0x00007fe6a51e3790: mov QWORD PTR [r10+0x18],rax ;*putfield helper
    ; - com.thunisoft.zjzy.toolbox.Main::createHelper@12 (line 18)
    ; - com.thunisoft.zjzy.toolbox.Main::lambda$main$0@5 (line 31)
    ; - com.thunisoft.zjzy.toolbox.Main$$Lambda$1/1456208737::run@8
    ; - java.util.concurrent.Executors$RunnableAdapter::call@-1 (line 511) 0x00007fe6a51e3794: mov r8d,DWORD PTR [r10+0x10] ;*getfield shardValue
    ; - com.thunisoft.zjzy.toolbox.Main::createHelper@6 (line 18)
    ; - com.thunisoft.zjzy.toolbox.Main::lambda$main$0@5 (line 31)
    ; - com.thunisoft.zjzy.toolbox.Main$$Lambda$1/1456208737::run@8
    ; - java.util.concurrent.Executors$RunnableAdapter::call@-1 (line 511) 0x00007fe6a51e3798: mov DWORD PTR [rax+0x10],r8d ;*putfield a
    ; - com.thunisoft.zjzy.toolbox.Main$Helper::<init>@21 (line 77)
    ; - com.thunisoft.zjzy.toolbox.Main::createHelper@9 (line 18)
    ; - com.thunisoft.zjzy.toolbox.Main::lambda$main$0@5 (line 31)
    ; - com.thunisoft.zjzy.toolbox.Main$$Lambda$1/1456208737::run@8
    ; - java.util.concurrent.Executors$RunnableAdapter::call@-1 (line 511) 0x00007fe6a51e379c: mov DWORD PTR [rax+0x1c],r8d ;*putfield d
    ; - com.thunisoft.zjzy.toolbox.Main$Helper::<init>@10 (line 77)
    ; - com.thunisoft.zjzy.toolbox.Main::createHelper@9 (line 18)
    ; - com.thunisoft.zjzy.toolbox.Main::lambda$main$0@5 (line 31)
    ; - com.thunisoft.zjzy.toolbox.Main$$Lambda$1/1456208737::run@8
    ; - java.util.concurrent.Executors$RunnableAdapter::call@-1 (line 511) 0x00007fe6a51e37a0: mov DWORD PTR [rax+0x18],r8d ;*putfield c
    ; - com.thunisoft.zjzy.toolbox.Main$Helper::<init>@14 (line 77)
    ; - com.thunisoft.zjzy.toolbox.Main::createHelper@9 (line 18)
    ; - com.thunisoft.zjzy.toolbox.Main::lambda$main$0@5 (line 31)
    ; - com.thunisoft.zjzy.toolbox.Main$$Lambda$1/1456208737::run@8
    ; - java.util.concurrent.Executors$RunnableAdapter::call@-1 (line 511) 0x00007fe6a51e37a4: mov DWORD PTR [rax+0x14],r8d ;*putfield b
    ; - com.thunisoft.zjzy.toolbox.Main$Helper::<init>@18 (line 77)
    ; - com.thunisoft.zjzy.toolbox.Main::createHelper@9 (line 18)
    ; - com.thunisoft.zjzy.toolbox.Main::lambda$main$0@5 (line 31)
    ; - com.thunisoft.zjzy.toolbox.Main$$Lambda$1/1456208737::run@8
    ; - java.util.concurrent.Executors$RunnableAdapter::call@-1 (line 511) 0x00007fe6a51e37a8: mov rdx,QWORD PTR [rcx+0x10] ;*getfield sync
    ; - java.util.concurrent.Semaphore::release@1 (line 426)
    ; - com.thunisoft.zjzy.toolbox.Main::lambda$main$0@9 (line 35)
    ; - com.thunisoft.zjzy.toolbox.Main$$Lambda$1/1456208737::run@8
    ; - java.util.concurrent.Executors$RunnableAdapter::call@-1 (line 511)

    该Demo展示了重排序具有的两个特征:

    1. 重排序可能导致线程安全问题:在本Demo中,重排序使得sum方法的返回值可能既不是-1(此时helper实例为null),也不是4。
      当然,这并不表示重排序本身是错误的,而是说我们的程序本身有问题──我们的程序没有使用或者没有正确地使用线程同步机制。
    2. 重排序不是必然出现的:本Demo运行时重复调用createHelper和sum共200000次才出现7次重排序,比率为0.035%。
  3. 处理器乱序执行(Out-of-order Execution)

    处理器也可能执行指令重排序,这使得执行顺序与程序顺序不一致。这是因为现代处理器为了提高指令执行效率,
    往往不是按照程序顺序逐一执行指令的,而是动态调整指令的顺序,做到哪条指令就绪就先执行哪条指令,
    这就是处理器处理器对指令进行的重排序,也被称为处理器的乱序执行。

    乱序执行的机制
    在乱序执行的处理器中,指令是一条一条按照程序顺序被处理器读取的(即“顺序读取”),
    然后这些指令中哪条就绪了哪条就会先被执行,而不是完全按照程序顺序执行(即“乱序执行”)。
    这些指令执行的结果会被先存入重排序缓冲器,而不是直接被写入寄存器或者主内存。
    重排序缓冲器会将各个指令的执行结果按照相应指令被处理器读取的顺序提交到寄存器或者内存中去(即“顺序提交”)。
    在乱序执行的情况下,尽管指令的执行顺序可能没有完全依照程序顺序,
    但是由于指令的执行结果的提交(即反映到寄存器和内存中)仍然是按照程序顺序来的,
    因此处理器的指令重排序并不会对单线程程序的正确性产生影响。
    猜测执行(Speculation)
    猜测执行技术就好比没有卫星导航时代在陌生地方开车遇到岔路口的情形:
    虽然我们不确定其中哪条路能够通往目的地,但是我们可以凭猜测走其中一条路,
    万一猜错了(前路不通)可以掉头重新走另外一条路。猜测执行能够造if语句的语句体先于其条件语句被执行的效果,
    从而可能导致指令重排序现象。

    下面是一个简单的示例程序片段,在应用猜测执行技术时,可能会执行C2和C3,然后再执行C1。
    此时如果flag值为真,则将C2的计算结果代入C4进行计算,抛弃C3的计算结果;否则将C3的运算结果代入C4计算,
    抛弃C2的运算结果。

    if(flag){ // C1
    a = b + c; // C2
    }else{
    a = b - c; // C3
    }
    rst = a + 1; // C4
  4. 存储子系统重排序
    存储子系统
    主内存(RAM)相对于处理器是一个慢速设备,为了避免其拖后腿,处理器并不是直接访问主内存,
    而是通过高速缓存访问主内存的。在此基础上,现代处理器还引人了写缓冲器(Store Buffer,也称Write Buffer),
    以提高写高速缓存操作的效率。有的处理器(如Intel的x86处理器)对所有的写主内存的操作都是通过写缓冲器进行的。
    这里,我们将写缓冲器和高速缓存统称为存储子系统。需要注意的是,存储子系统并不是内存的子系统,而是处理器的子系统。
    内存重排序(Memory Ordering)
    即使在处理器严格依照程序顺序执行两个内存访问操作的情况下,
    在存储子系统的作用下,其他处理器对这两个操作的感知顺序仍然可能与程序顺序不一致,
    即这两个操作的执行顺序看起来像是发生了变化。这种现象就是存储子系统重排序,也被称为内存重排序。
    与指令重排序的区别
    指令重排序的重排序对象是指令,它实实在在地对指令的顺序进行调整,
    而存储子系统重排序是一种现象而不是一种动作,它并没有真正对指令执行顺序进行调整,
    而只是造成了一种指令的执行顺序像是被调整过一样的现象,其重排序的对象是内存操作的结果。
    指令重排序的类型
    从处理器的角度来说,读内存操作的实质,是从指定的RAM地址(通过高速缓存)加载数据到寄存器,
    因此读内存操作通常被称为Load操作;写内存操作的实质,
    是将数据(指令中的操作数或寄存器中的值)存储到指定地址表示的RAM存储单元中,因此写内存操作通常被称为Store。
    这样,我们可以把内存重排序分为以下4种。

               重排序类型                                                   含义                                        
    LoadLoad重排序                  
                                    
    (Loads reordered after loads)   
    该重排序指一个处理器上先后执行两个读内存操作L1和L2,其他处理器对这两个内存操作的感 
    知顺序可能是L2→L1,即L1被重排序到L2之后                                            
                                                                                       
    Store Store重排序               
                                    
    (Stores reordered after stores) 
                                    
    该重排序指一个处理器上先后执行两个写内存操作W1和W2,其他处理器对这两个内存操作的感 
    知顺序可能是W2→W1,即W1被重排序到W2之后                                            
                                                                                       
                                                                                       
    Load Store重排序                
                                    
    (Loads reordered after stores)  
    该重排序指一个处理器上先后执行读内存操作L1和写内存操作W2,其他处理器对这两个内存操 
    作的感知顺序可能是W2→L1,即L1被重排序到W2之后                                      
                                                                                       
    Store Load重排序                
                                    
    (Stores reordered after loads)  
    该重排序指一个处理器上先后执行写内存操作W1和读内存操作L2,其他处理器对这两个内存操 
    作的感知顺序可能是L2→W1,即W1被重排序到L2之后                                      
                                                                                       

    内在重排序与具体的自理器微架构有关,基于不同射到架构的处理器所允许/支持的内在重排序是不同的。

  5. 貌似串行语义(As-if-serial Semantics)
    定义
    重排序并非随意地对指令、内存操作的结果进行杂乱无章的排序或者顺序调整,而是遵循一定的规则。
    编译器(主要是JIT编译器)、处理器(包括其存储子系统)都会遵守这些规则,
    从而给单线程程序创造一种假象──指令是按照源代码顺序执行的。这种假象就被称为貌似串行语义。
    貌似串行语义只是从单线程程序的角度保证重排序后的运行结果不影响程序的正确性,它并不保证多线程环境下程序的正确性。
    数据依赖关系(Data Dependency)
    为了保证貌似串行语义,存在数据依赖关系的语句不会被重排序,只有不存在数据依赖关系的语句才会被重排序。
    如果两个操作(指令)访问同一个变量(地址),且其中一个操作(指令)为写操作,
    那么这两个操作之间就存在数据依赖关系,这些操作包括:写后读(WAR)、读后写(RAW)、写后写(WAW)三种操作。
    控制依赖关系(Control Dependency)
    如果一条语句(指令)的执行结果会决定另外一条语句(指令)能否被执行,
    那么这两条语句(指令)之间就存在控制依赖关系。存在控制依赖关系的语句是可以允许被重排序的,
    存在控制依赖关系的语句最典型的就是if语句中的条件表达式和相应的语句体。
    允许这种重排序意味着处理器可能先执行f语句体所涉及的内存访问操作,然后再执行相应的条件判断。
    允许对存在控制依赖关系的语句进行重排序同样也是出于性能考虑,
    这是因为存在控制依赖关系的语句(如if语句)会影响处理器对指令序列执行的并行程度。
  6. 保障内存访问的顺序

    貌似串行语义只是保障重排序不影响单线程程序的正确性,从这个角度出发,
    多线程程序的有序性的保障可以理解为通过某些措施使得貌似串行语义扩展到多线程程序。即重排序要么不发生,
    要么即使发生了也不会影响多线程程序的正确性,这样有序性的保障也可以理解为从逻辑上部分禁止重排序。

    从底层的角度来说,禁止重排序是通过调用处理器提供相应的指令(内存屏障)来实现的。
    当然,Java作为一个跨平台的语言,它会替我们与这类指令打交道,而我们只需要使用语言本身提供的机制即可。

  7. 可见性与有序性
    可见性是有序性的基础
    可见性描述的是一个线程对共享变量的更新对于另外一个线程是否可见,
    或者说什么情况下可见的问题。有序性描述的是,一个处理器上运行的线程对共享变量所做的更新,
    在其他处理器上运行的其他线程看来,这些线程是以什么样的顺序观察到这些更新的问题。
    因此,可见性是有序性的基础。另一方面,二者又是相互区分的。

2.2.6 线程上下文切换

描述
当一个进程中的一个线程由于其时间片用完,或者因其自身原因(比如稍后再继续运行)*或者主动暂停其运行时,
另外一个线程(可能是同一个进程或者其他进程中的一个线程)可以被操作系统(线程调度器)选中,
占用处理器开始或者继续其运行。这种一个线程被暂停,另外一个线程被选中开始或者继续运行的过程就叫作线程上下文切换。
也可简单地称为上下文切换。
线程的切入(Switch In)与切出(Switch Out)
一个线程被剥夺处理器的使用权而被暂停运行就被称为切出,
一个线程被操作系统选中占用处理器开始或者继续其运行就被称为切入。
上下文(Context)
切出和切入的时候,操作系统需要保存和恢复相应线程的进度信息,
即切入和切出那一刻相应线程所执行的任务状态信息(如计算的中间结果以及执行到了哪条指令)。
这个进度信息就被称为上下文。
它一般包括通用寄存器(General Purpose Register)和程序计数器(Program Counter)中的内容。
Java中线程的暂停与唤醒
一个线程的生命周期状态在RUNNABLE状态与非RUNNABLE状态之间切换的过程就是一个上下文切换的过程。
当一个线程的生命周期状态由RUNNABLE转换为非RUNNABLE
(包括BLOCKED、WAITING和TIMED_ WAITING中的任意一状态)时,我们称这个线程被暂停。
而一个线程的生命周期状态由非RUNNABLE状态进入RUNNABLE状态时,我们就称这个线程被唤醒。
一个线程被唤醒仅代表该线程获得了一个继续运行的机会,而并不代表其立刻可以占用处理器运行。
当被唤醒的线程被操作系统选中占用处理器继续其运行的时候,操作系统会恢复之前为该线程保存的上下文,
以便其在此基础上进展。
  1. 上下文切换的分类

    按照导致上下文切换的因素划分,我们可以将上下文切换分为自发性上下文切换和非自发性上下文切换。

    自发性上下文切换(Voluntary Context Switch)
    自发性上下文切换指线程由于其自身因素导致的切出。
    比如当前运行的线程发起了I/O操作(如读取文件)或者等待其他线程持有的锁,或在其运行过程中执行下列任意一个方法。
    Thread. sleep(long millis);
    Object.wait();
    Object.wait(long timeout);
    Object.wait(long timeout, int nanos);
    Thread.yield();
    Thread.join();
    Thread.join(long timeout);
    LockSupport.park()
    自发性上下文切换(Involuntary Context Switch)
    线程由于线程调度器的原因*切出。
    导致非自发性上下文切换的常见因素包括:被切出线程的时间片用完、有一个比被切出线程优先级更高的线程需要被运行。

    从Java平台的角度来看,Java虚拟机的垃圾回收(Garbage Collect)动作也可能导致非自发性上下文切换。
    这是因为垃圾回收器在执行垃圾回收的过程中,可能需要暂停所有应用线程才能完成其工作,
    比如在主要回收(Major Collection)过程中,垃圾回收器在对Java虚拟机堆内存区域进行整理的时候需要先停止所有应用线程。

  2. 上下文切换的开销

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

    ️直接开销:

    • 操作系统保存和恢复上下文所需的开销,这主要是处理器时间开销。
    • ️线程调度器进行线程调度的开销:比如,按照一定的规则决定哪个线程会占用处理器运行。

    间接开销:

    • ️处理器高速缓存重新加载的开销:一个被切出的线程可能稍后在另外一个处理器上被切入继续运行。
      由于这个处理器之前可能未运行过该线程,那么这个线程在其继续运行过程中需访问的变量,
      仍然需要被该处理器重新从主内存或者通过缓存致性协议从其他处理器加载到高速缓存之中,这是有一定时间消耗的。
    • 高速缓存内容冲刷(Flush)的开销:️上下文切换也可能导致整个一级高速缓存中的内容被冲刷,
      即一级高速缓存中的内容会被写入下一级高速缓存(如二级高速缓存),或者主内存(RAM)中

    线程的数量越多,可能导致的上下文切换的开销也就可能越大。也就是说,多线程编程中使用的线程数量越多,
    程序的计算效率可能反而越低。因此,在设计多线程程序的时候,减少上下文切换也是一个重要的考量因素。

2.2.7 线程的活性故障(Liveness Failure)

描述
事实上,线程并不是一直处于RUNNABLE状态,导致一个线程可能处于非RUNNABLE状态的因素,
除了资源(主要是处理器资源有限而导致的上下文切换)限制之外,还有程序自身的错误和缺陷。
由资源稀缺性或者程序自身的问题和缺陷导致线程一直处于非RUNNABLE状态,
或线程虽然处于RUNNABLE状态,但是其要执行的任务却一直无法进展,这种现象被称为线程活性故障。

常见的线程活性故障包括以下几种:

死锁(Deadlock)
死锁只会出现在一组线程集合中,如果集合中的每一个线程都持有其他线程需要的资源,
导致所有线程因等待资源而被永暂停,这种现象就称之为死锁。
死锁产生的典型场景是线程X持有资源A的时候等待线程Y释放资源B,
同时线程Y在持有资源B的时候却等待线程X释放资源A,这就好比鹬蚌相争故事中的情形。
锁死(Lockout)
锁死与死锁类似,锁死是指线程在等待一个永远不会发生的事件;与死锁不同的是,
锁死的线程可能不持有任何资源。一个较典型的例子就是信号丢失导致的锁死,
比如对 CountDownLatch.countDown() 方法的调用没有放在 finally 块中时,
可能因为异常抛出导致执行 CountDownLatch.await() 的线程永远处于等待状态。
活锁(Livelock)
指线程一直处于运行状态,但是其任务却一直无法进展的一种活性故障。
活锁的一个重要特征就是线程一直处于运行状态,区别于死锁、锁死的线程处于等待状态。
同样以鹬蚌相争故事为例,不同的是两者商量好如果同时咬住对方,则两者都松开口,
但松口后两者又同时咬住了对方,于是两者在不停的咬住与松口,直至累死。
饥饿(Starvation)
线程一直无法获得其所需的资源而导致其任务直无法进展的一种活性故障。
比如由于当前线程的优先级极低,导致资源一直被其他线程抢占。

2.2.8 资源争用与调度

线程间的资源共享
由于资源的稀缺性(例如有限的处理器资源)及资源本身的特性
(例如打印机一次 只能打印一个文件),往往需要在多个线程间共享同一个资源。
排他性资源
一次只能够被一个线程占用的资源被称为排他性资源,
常见的排他性资源包括处理器、数据库连接、文件等。
资源争用(Resource Contention)
在一个线程占用一个排他性资源进行访问(读、写操作),
而未释放其对资源所有权的时候,其他线程试图访问该资源的现象就被称为资源争用,
简称争用。显然,争用是在并发环境下产生的一种现象。
争用程度
同时试图访问同个已经被其他线程占用的资源的线程数量越多,争用的程度就越高,
反之争用的程度就越低。相应的争用就被分别称为高争用和低争用。
资源调度
在多个线程申请同一个排他性资源的情况下,决定哪个线程会被授予该资源的独占权,
即选择哪个申请者占用该资源的过程就是资源的调度。
获得资源的独占权而又未释放其独占权的线程就被称为该资源的持有线程。
  1. 资源调度策略

    资源调度的一种常见策略就是排队。资源调度器内部维护一个等待队列,在存在资源争用的情况下,
    申请失败的线程会被存入该队列。通常,被存入等待队列的线程会被暂停。当相应的资源被其持有线程释放时,
    等待队列中的一个线程会被选中并被唤醒而获得再次申请资源的机会。
    被唤醒的线程如果申请到资源的独占权,那么该线程会从等待队列中移除;
    否则,该线程仍然会停留在等待队列中等待再次申请的机会,即该线程会再次被暂停。
    因此,等待队列中的等待线程可能经历若干次暂停与唤醒才获得相应资源的独占权。可见,资源的调度可能导致上下文切换。

    1. 资源调度的公平性

      资源调度策略的一个常见特性就是它能否保证公平性。

      所谓公平性,是指资源的申请者(线程),是否按照其申请(请求)资源的顺序而被授予资源的独占权。
      如果资源的任何一个先申请者,总是能够比任何一个后申请者先获得该资源的独占权,
      那么相应的资源调度策略就被称为是公平的;
      如果资源的后申请者可能比先申请者先获得该资源的独占权,那么相应的资源调度策略就被称为是非公平的。

      需要注意的是,非公平的资源调度策略往往只是说明它并不保证资源调度的公平性,
      即它允许不公平的资源调度的出现,而不是表示它刻意造就不公平的资源调度。

    2. 公平的调度策略

      公平的调度策略不允许插队现象的出现,即只有在资源未被其他任何线程占用,
      并且没有其他活跃线程申请该资源情况下,队列中的线程才被允许被唤醒,抢占相应资源的独占权。
      其中,抢占成功的申请者获得相应资源的独占权,而抢占失败的申请者会进入等待队列。
      因此,公平调度策略中的资源申请者总是按照先来后到的顺序来获得资源的独占权。

    3. 非公平的调度策略

      而非公平的调度策略则允许插队现象,即一个线程释放其资源独占权的时候,
      等待队列中的一个线程会被唤醒申请相应的资源。而在这个过程中,
      可能存在另一个活跃线程与这个被唤醒的线程共同参与相应资源的抢占。
      因此,非公平调度策略中被唤醒的线程不一定就能够成功申请到资源。因此,在极端的情况下,
      非公平调度策略可能导致等待队列中的线程永远无法获得其所需的资源,即出现饥饿现象。

    4. 对比

      从申请者个体的角度来看:使用公平调度策略时,申请者获得相应资源的独占权所需时间的偏差可能比较小,
      即每个申请者成功申请到资源所需的时间基本相同;而使用非公平的调度策略时,
      申请者获得相应资源的独占权所需时间的偏差可能比较大,有的线程很快就申请到资源,
      而有的线程则要经历若干次暂停与唤醒才成功申请到资源。

      从效率上看:在非公平调度策略中,资源的持有线程释放该资源的时候,等待队列中的一个线程会被唤醒,
      而该线程从被唤醒到其继续运行可能需要一段时间。在该时间内,如果使用非公平的调度策略,
      新来的线程(活跃线程)可以先被授予该资源的独占权,如果这个新来的线程占用该资源的时间不长,
      那么它完全有可能在被唤醒的线程继续其运行前释放相应的资源,从而不影响该被唤醒的线程申请资源。
      这种情形下,非公平调度策略可以减少上下文切换的次数。
      但是,如果多数(甚至每个)线程占用资源的时间相当长,那么允许新来的线程抢占资源不会带来任何好处,
      反而会导致被唤醒的线程需要再次经历暂停和唤醒,从而增加了上下文切換。
      因此,多数线程占用资源的时间相当长的情况下不适合使用非公平调度策略。

      综上,在没有特别需要的情况下,我们默认选择非公平调度策略即可。
      在资源的持有线程占用资源的时间相对长,或线程申请资源的平均间隔时间相对长,
      或对资源申请所需的时间偏差有所要求(即时间偏差较小)的情况下可以考虑使用公平调度策略。

2.3 多线程编程的优势和风险

2.3.1 优势

提高系统的吞吐率(Throughput)
多线程编程使得一个进程中可以有多个并发(Concurrent,即同时进行的)的操作。
例如,当一个线程因为I/O操作而处于等待时,其他线程仍然可以执行其操作。
提高响应性(Responsiveness)
在使用多线程编程的情况下,对于GUI软件(如桌面应用程序)而言,
一个慢的操作(比如从服务器上下载大文件)并不会导致软件的界面出现被“冻住”而无法响应用户的其他操作的现象;
对于Web应用程序而言,一个请求的处理慢了并不会影响其他请求的处理。
充分利用多核(Multicore)处理器资源
如今多核处理器的设备越来越普及,就算是手机这样的消费类设备也普遍使用多核处理器。
实施恰当的多线程编程有助于我们充分利用设备的多核处理器资源,从而避免了资源浪费。
最小化对系统资源的使用
一个进程中的多个线程可以共享其所在进程所申请的资源(如内存空间),
因此使用多个线程相比于使用多个进程进行编程来说,节约了对系统资源的使用。
简化程序的结构
线程可以简化复杂应用程序的结构。

2.3.2 风险

线程安全(Thread Safe)问题
多个线程共享数据的时候,如果没有采取相应的并发访问控制措施,那么就可能产生数据一致性问题,
如读取脏数据(过期数据)、丢失更新(某些线程所做的更新被其他线程所做的更新覆盖)等。
线程活性(Thread Liveness)问题
一个线程从其创建到运行结束的整个生命周期会经历若干状态。
从单个线程的角度来看,RUNNABLE状态是我们所期望的状态,但实际上,
代码编写不当可能导致某些线程一直处于等待其他线程释放锁的状态(BLOCKED状态),即产生了死锁。
另外,线程是一种稀缺的计算资源,某些情况下可能出现线程饥饿问题。
上下文切换(Context Switch)
由于处理器资源的稀缺性,因此上下文切换可以被看作多线程编程的必然副产物,
它増加了系统的消耗,不利于系统的吞吐率。
可靠性
多线程编程一方面可以有利于可靠性,例如某个线程意外提前终止了,但这并不影响其他线程继续其处理。
另一方面,线程是进程的一个组件,它总是存在于特定的进程中的,如果这个进程由于某种原因意外提前终止,
比如某个Java进程由于内存泄漏导致Java虚拟机崩溃而意外终止,那么该进程中所有的线程也就随之而无法继续运行。
因此,从提高软件可靠性的角度来看,某些情况下可能要考虑多进程多线程的编程方式,而非简单的单进程多线程方式。

Date: 2019-09-03 Tus

Author: C.Wong

Created: 2019-09-20 五 00:55

Validate