进程、线程与任务

什么是进程

进程是程序运行的实例。运行一个java程序的实质是启动一个Java虚拟机进程,也就是说一个运行的Java程序就是一个Java虚拟机进程。进程是程序向操作系统申请资源的基本单位。

什么是线程

线程是进程中可以独立执行的最小单位。一个进程可以包含多个线程。同一个进程中的所有线程共享该进程中的资源。

什么是任务

线程所要完成的计算就被称为任务,特定的线程总是在执行着特定的任务。

Java线程API简介

Thread类或其子类的一个实例就是一个线程。

线程的创建、启动和运行

运行一个线程实际上就是让虚拟机运行该线程的run方法,从而使相应的线程任务处理逻辑代码得以执行。我们可以使用Thread类的start方法启动线程。当这个线程何时能够运行是由线程调度器决定的。因此调用了start方法,并不代表线程就立刻运行了。

以下是创建线程的几种方法:

1
2
3
4
5
6
7
8
9
10
11
public class ThreadDemo extends Thread{
//重写run方法
public void run(){
System.out.println("线程执行");
}

public static void main(String[] args){
//创建该子类的实例,并启动该线程
new ThreadDemo().start();
}
}
1
2
3
4
5
6
7
8
9
10
    public class threadDemo implements Runnable{
@Override
public void run() {
System.out.println("线程运行");
}

public static void main(String[] args){
new Thread(new threadDemo()).start();
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22

public class threadDemo implements Callable<Integer> {

@Override
public Integer call() throws Exception {
System.out.println("线程运行");
return 666;
}

public static void main(String[] args){
FutureTask futureTask=new FutureTask(new threadDemo());

new Thread(futureTask).start();
try {
System.out.println("子线程执行结束后返回了:"+futureTask.get());
} catch (InterruptedException e) {
e.printStackTrace();
} catch (ExecutionException e) {
e.printStackTrace();
}
}

一旦线程的run方法执行结束,相应的线程运行也结束了。并且一个线程只能被调用一次start方法。在Java中一个线程就是一个对象。

线程的属性

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

  1. 编号:是一个只读的long类型的在虚拟机中具有唯一性的编号。每个线程拥有不同的编号。
  2. 名称:名称是一个可读可写的string类型的变量,他是面向人的一种标识,人们可以随意设置.
  3. 线程的名称(甚至两个线程的名称可以重复)。
  4. 线程类别:线程可分为守护线程和用户线程。线程类别属性是一个可读可写的boolean类型的值,如果为false则表示为用户线程,否则为守护线程。该属性的默认值与线程的父线程一致。并且对该属性的设置应该在线程启动前进行。守护线程是为用户线程服务的。
  5. 线程的优先级:是一个可读可写的int型数据。它为线程调度器提供提示。Java定义了1-10的10个优先级,默认值一般为5.对于一个具体的线程,其优先级与父线程保持一致。设置了优先级并不能保证线程按照优先级的高低严格执行。

守护线程和用户线程

守护线程和用户线程是按照线程是否会影响Java虚拟机运行来进行分类的。用户线程会影响Java虚拟机的运行,Java虚拟机只有所有的用户线程正常运行时才能正常运行的。而守护线程不会影响Java虚拟机的正常工作。

Thread类的常用方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
//返回当前线程,即当前代码的执行线程
static Thread currentThread();

//用于实现线程的任务
void run();

//启动相应的线程
void start();

//等待相应的线程运行结束,如果A线程中调用B线程的join方法
//A线程会一直等待,B线程运行结束
void join();

//使当前线程主动放弃其对处理器的占用
//该方法是不可靠的,调用之后线程仍可继续运行
static void yield();

//使当前线程休眠指定的时间
static void sleep(long millis);

Java中任何一段代码总是执行在某个线程之中。

线程的层次关系

Java平台中的线程不是孤立的,线程之间存在层次关系。假设线程A锁执行的代码创建了线程B,那么我们称线程B为线程A的子线程。A为B的父线程。子线程、父线程只是一个相对的称呼。
nnL4UI.png
搞清线程的层次关系非常的重要。因为线程的默认优先级是和父线程一致的,线程的属性(用户线程或守护线程)默认也是和父线程一致的。

线程的生命周期

nnOVaR.png

线程的状态包括以下几种:

  1. new:已经创建但为启动。一个线程只能有一次机会处于该状态。
  2. runnable:它有两种子状态。ready和running。前者表示线程已经准备就是可以被线程调度器调度而处于running状态。另一种是running状态,表示线程正在运行。
  3. blocked:阻塞状态,比如发起了一个阻塞式的IO,或申请了一个其它线程持有的锁。处于该状态的线程不会占用处理器资源。
  4. waiting:一个线程执行某些方法后,等待其它线程执行另一些特定的操作。
  5. timed_waiting:一个线程有时间限制的等待其它线程完成某些特定的操作。
  6. terminated:已经结束了的线程处于该状态。一个线程只有一次机会处于该状态(一个线程只能被启动一次)

多线程编程的优势和风险

优势:

  1. 提高系统吞吐率
  2. 提高响应性
  3. 充分利用多核处理器资源
  4. 最小化对系统资源的使用
  5. 简化程序的结构

风险:

  1. 线程安全问题
  2. 线程活跃性问题
  3. 上下文切换的开销
  4. 可靠性

原子性

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

理解原子操作概念的注意点:

  1. 原子操作是针对访问共享变量的操作而言的。
  2. 原子操作是从该操作的执行线程之外的线程来描述的。

Java中实现原子性的两种方式:

  1. 使用锁,锁具有排他性,即它能保障一个共享变量在任意时刻只能被同一个线程访问。这就排除了多个线程在同一时刻访问同一个共享变量而导致干扰与冲突。
  2. 利用处理器提供的CAS(compare-and-swap)指令。cas指令实现原子性的方式与锁实现原子性的方式实质上是相同的,差别在于锁通常是在软件层次实现的,而cas是直接在硬件层次实现的。

在Java中除了对long,double型以外的任何类型的变量的写操作都是原子操作。尽管如此Java语言可以使用volatile关键字修饰long/double型变量的写操作具有原子性。volatile关键字只能保障变量写操作的原子性,它并不能保障其它操作(如 read-modify-write和check-then-act)的原子性。
原子操作+原子操作!=原子操作

可见性

在多线程环境下,一个线程对某个共享变量进行更新后,后续访问该变量的线程可能无法立即读取到这个更新的结果。这就是线程安全的可见性问题。
多线程在可见性上存在问题,意味着某些线程读到的是旧数据,这可能会导致程序出现我们所不期望的结果。

导致可见性缺陷的原因可能来自两个方面:

  1. Java编译器的错误优化;
  2. 因为计算机存储系统而导致的。程序中的变量可能会被分配到寄存器中,每个处理器都有自己的寄存器,而一个处理器无法读取另外一个处理器上的寄存器中的内容。因此,如果两个线程运行在不同的核心上,且这两个线程所共享的变量被分配到了寄存器上进行存储,那么可见性问题就会产生。即使某个共享变量是被分配主存中进行存储的,也会因为高速缓存的存在而产生同样的问题。

为了确保可见性,我们必须使一个处理器对共享变量所做的更新最终被写入该处理器的高速缓存或者主内存中(而不是停留在其写缓冲器中)。这个过程被称为冲刷处理器缓存一个处理器在读取共享变量的时候,如果其它处理器在此之前已经更新了该变量,那么该处理器必须等待其它处理器的高速缓存或者主内存中对相应的变量进行缓存同步。这个过程被称为刷新处理器缓存

使用volatile关键字

volatile关键字的作用:

  1. 提示JIT编译器被修饰的变量可能会被多个线程共享,以阻止JIT编译器做出可能导致程序运行不正常的优化。
  2. 使得读取一个volatile关键字会使相应的处理器执行刷新处理器缓存的动作。写一个volatile关键字会使得相应的处理器执行冲刷处理器缓存的动作。

可保证可见性,不可保证原子性

相对新值和最新值

  1. 相对新值:对于同一个共享变量,一个线程更新了该变量的值后,其它线程能够读取到这个更新后的值,那个这个值就被称为该变量的相对新值。
  2. 最新值:如果读取整个共享变量的线程在读取并使用该变量时其它线程无法更新该变量的值,那么该线程读取到的相对最新值称为该变量的最新值。

有序性

重排序的概念

编译器可能改变两个操作的先后顺序;处理器可能不是完全按照程序的目标代码所指定的顺序执行指令;另外,一个处理器上执行的多个操作,从其它处理器的角度来看其顺序可能与目标代码所指定的顺序不一致,这种现象叫做重排序。

重排序是对内存访问有关的操作所做的一种优化。它可以在不影响单线程程序的正确性的情况下提升程序的性能。

指令重排序

Java平台的两种编译器:

  1. 静态编译器(javac)。负责将Java源代码编译为字节码。(基本不会进行指令重排序)
  2. 动态编译器(JIT)。运行时负责将字节码动态的翻译为Java虚拟机宿主主机的本地代码(机器码)。

重排序的特征:

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

上下文切换

上下文切换及其产生原因

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

线程的上下文切换意味着在切出和切入的时候操作系统需要保存和恢复相应线程的进度信息,这个进度信息被称为上下文(Context)。它一般包括通用寄存器的内容和程序计数器的内容。

分类和具体诱因

按照导致上下文切换的因素划分,可以将上下文切换分为:

  1. 自发性上下文切换:由于自身因素导致。
  2. 非自发性上下文切换:线程由于线程调度器的原因被迫切出。

上下文切换的开销

开销:

  1. 操作系统保存和恢复上下文所需的开销。
  2. 线程调度器进行线程调度的开销
  3. 处理器高速缓存重新加载的开销
  4. 上下文切换导致整个一级高速缓存中的内容被冲刷。

线程的活性故障

线程是为任务而生的。线程因为资源的稀缺性或者程序自身的问题和缺陷导致线程一直处于非Runnable状态,或者线程处于Runnable状态但任务无法进展的现象称为线程活性故障

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

  1. 死锁(鹬蚌相争)
  2. 锁死 (睡美人,如果没有王子的亲吻则永远无法醒来)
  3. 活锁 (小猫咬自己的尾巴)
  4. 饥饿 (健壮的雏鸟总是抢走老鸟的食物)

实现一个简单的死锁:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
public class ThreadStudy02 {
private final Object obj1=new Object();
private final Object obj2=new Object();

public static void main(String[] args) {
ThreadStudy02 threadStudy02 = new ThreadStudy02();
threadStudy02.testDeadlock();
}

private void testDeadlock() {
Thread thread1=new Thread(()->{
synchronized (obj1){
sleep();
synchronized (obj2){
sleep();
}
}
});

Thread thread2=new Thread(()->{
synchronized (obj2){
sleep();
synchronized (obj1){
sleep();
}
}
});

thread1.start();
thread2.start();
}

private void sleep(){
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}