并发编程: 进程与线程

张天宇 on 2020-06-04

Java并发编程第一篇,进程与线程。

准备依赖

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<!-- pom.xml -->
<properties>
<maven.compiler.source>1.8</maven.compiler.source>
<maven.compiler.target>1.8</maven.compiler.target>
</properties>
<dependencies>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.10</version>
</dependency>
<dependency>
<groupId>ch.qos.logback</groupId>
<artifactId>logback-classic</artifactId>
<version>1.2.3</version>
</dependency>
</dependencies>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<!-- logback.xml -->
<?xml version="1.0" encoding="UTF-8"?>
<configuration
xmlns="http://ch.qos.logback/xml/ns/logback"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://ch.qos.logback/xml/ns/logback logback.xsd">
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>%date{HH:mm:ss} [%t] %logger - %m%n</pattern>
</encoder>
</appender>
<logger name="c" level="debug" additivity="false">
<appender-ref ref="STDOUT"/>
</logger>
<root level="ERROR">
<appender-ref ref="STDOUT"/>
</root>
</configuration>

进程与线程

进程

  • 程序由指令和数据组成,但这些指令要运行,数据要读写,就必须将指令加载至CPU,数据加载至内存。在指令运行过程中还需要用到磁盘、网络等设备。进程就是用来加载指令、管理内存、管理IO的,资源管理最小单位。
  • 当一个程序被运行,从磁盘加载这个程序的代码至内存,这时就开启了一个进程。
  • 进程就可以视为程序的一个实例。大部分程序可以同时运行多个实例进程(例如记事本、画图、浏览器等),也有的程序只能启动一个实例进程(例如网易云音乐等)。

线程

  • 一个进程之内可以分为一到多个线程。
  • 一个线程就是一个指令流,将指令流中的一条条指令以一定的顺序交给CPU执行。
  • Java 中,线程作为最小调度单位,进程作为资源分配的最小单位。 在windows中进程是不活动的,只是作为线程的容器。

二者对比

  • 进程基本上相互独立的,而线程存在于进程内,是进程的一个子集。
  • 进程拥有共享的资源,如内存空间等,供其内部的线程共享。
  • 进程间通信较为复杂。
    • 同一台计算机的进程通信称为IPC(Inter-process communication)。
    • 不同计算机之间的进程通信,需要通过网络,并遵守共同的协议,例如HTTP。
  • 线程通信相对简单,因为它们共享进程内的内存,一个例子是多个线程可以访问同一个共享变量。
  • 线程更轻量,线程上下文切换(上下文切换就是从当前执行任务切换到另一个任务执行的过程。但是,为了确保下次能从正确的位置继续执行,在切换之前,会保存上一个任务的状态)成本一般上要比进程上下文切换低。

并行和并发

单核CPU下,线程实际还是串行执行的。操作系统有一个组件叫任务调度器,将CPU的时间片(Windows下时间片最小约为15毫秒)分为不同线程使用,只是由于CPU在线程间(时间片很短)的切换非常快,人类感觉是同时运行的。可以总结为,微观串行,宏观并行。

一般将这种线程轮流使用CPU的做法叫做并发,concurrent。

CPU 时间片1 时间片2 时间片3
Core 线程1 线程2 线程3

多核CPU下,每个核都可以调度运行线程,这时候线程可以是并行的,parallel。

CPU 时间片1 时间片2 时间片3
Core1 线程2 线程3 线程1
Core2 线程1 线程2 线程3

引用Rob Pike的描述:

  • 并发是同一时间应对(deal with)多件事情的能力。
  • 并行是同一时间动手做(doing)多件事情的能力。

应用

异步调用

从方法调用的角度来说,如果

  • 需要等待结果返回才能继续运行,就是同步
  • 不需要等待结果返回就能继续运行,就是异步

同步在多线程中还有另外一层意思,就是让多个线程步调一致。

  1. 设计

    多线程可以让方法执行变为异步的。比如读取磁盘文件时,假设操作花了5秒,如果没有线程调度机制,这5秒调用者什么都做不了,其他代码都得暂停。

  2. 结论

    • 在项目中,视频文件需要格式转换,比较耗时,这时候开一个新线程处理视频转换,避免阻塞主线程。
    • Tomcat的异步Servlet也是类似的目的,让用户线程处理耗时较长的操作,避免阻塞Tomcat的工作线程。
    • UI程序中,开线程执行其他操作,避免阻塞UI线程。
提高效率

假设执行三个计算求和,每个计算分别花费10ms、11ms、9ms,汇总需要1ms,那么总共花费需要31ms。

如果是4核CPU来执行,每个核心分配一个计算,那么三个计算就是并行的,花费时间只取决于最长的那个线程运行的时间,即花费12ms。(需要在多核CPU下才能提高效率,单核仍然是轮流执行。)

  • 单核CPU下,多线程并不能实际提高程序执行效率,只是为了能够在不同任务之间进行切换,不同线程轮流使用CPU,不至于一个线程总占用CPU,别的线程没法干活。
  • 多核CPU可以并行跑多个线程,但能否提高程序运行效率还是要分情况:
    • 有些任务经过精心设计,将任务拆分,并行执行,当然可以提高程序的运行效率,但不是所有计算任务都能拆分,参考阿姆达尔定律。
    • 也不是所有任务都需要拆分,任务的目的不同,谈拆分和效率没有意义。
  • IO操作不占用CPU,只是我们一般拷贝文件使用的是阻塞IO,这时相当于线程虽然不用CPU,但需要一直等待IO结束,没能充分利用线程,所以才有后面的非阻塞IO和异步IO优化。

线程切换和进程切换的区别

进程切换分两步
1.切换页目录以使用新的地址空间
2.切换内核栈和硬件上下文。

对于linux来说,线程和进程的最大区别就在于地址空间。
对于线程切换,第1步是不需要做的,第2是进程和线程切换都要做的。所以明显是进程切换代价大

线程上下文切换和进程上下问切换一个最主要的区别是线程的切换虚拟内存空间依然是相同的,但是进程切换是不同的。这两种上下文切换的处理都是通过操作系统内核来完成的。内核的这种切换过程伴随的最显著的性能损耗是将寄存器中的内容切换出。

另外一个隐藏的损耗是上下文的切换会扰乱处理器的缓存机制。简单的说,一旦去切换上下文,处理器中所有已经缓存的内存地址一瞬间都作废了。还有一个显著的区别是当你改变虚拟内存空间的时候,处理的页表缓冲(processor’s Translation Lookaside Buffer (TLB))或者相当的神马东西会被全部刷新,这将导致内存的访问在一段时间内相当的低效。但是在线程的切换中,不会出现这个问题。

进程调度

高级调度(作业调度):因内存有限,部分进程存放在外存中。高级调度按照一定的原则从外存中选择一个作业,分配内存、创建PCB。

中级调度:引入虚拟内存后,可以将暂时用不到的进程挂起到外存。中级调度决定将哪个挂起的进程重新调入内存。

低级调度(进程调度):从就绪队列中选择一个进程,分配时间片。

Java线程

创建和运行线程

1. 直接使用Thread
1
2
3
4
5
6
7
8
Thread t =new Thread(){
@Override
public void run() {
// todo
}
};
t.setName("T1"); // 起个名字
t.start();
2. 使用Runnable配合Thread

把线程和任务分开

  • Thread表示线程
  • Runnable表示可执行的任务,即线程要执行的代码
1
2
3
4
5
6
7
8
Runnable runnable = new Runnable() {
@Override
public void run() {
// todo
}
};
Thread t= new Thread(runnable, "name");
t.start();

Java 8以后可以使用lambda方法精简代码(查看Runnable源码,只有一个抽象方法的接口,加了一个@FunctionalInterface,表示可以被lambda方法简化)。

1
2
3
4
Runnable runnable = () -> log.debug("dfsaf");	// 在IDEA中点击Runnable类,使用Alt+Enter可以自动替换。
Thread t= new Thread(() -> {
// todo
}, "name"); // 写到一起

Thread和Runnable之间的关系:

  • 方法1是把线程和任务合并到了一起,方法2是把两者分开了
  • 用Runnable更容易与线程池等高级API配合
  • 用Runnable让任务脱离了Thread继承体系,更灵活
3. FutureTask配合Thread
1
2
3
4
5
6
7
8
9
10
11
FutureTask<Integer> task = new FutureTask<>(new Callable<Integer>() {
@Override
public Integer call() throws Exception {
Thread.sleep(1000);
return 115;
}
});
// FutureTask<Integer> task = new FutureTask<>(() -> 115);
Thread t =new Thread(task);
t.start();
log.debug("{}", task.get()); //使用占位符,可以在写多个{},并行执行,阻塞等待1秒后的结果
区别
区别 A继承Thread B实现Runnable
如何创建线程 实例化A就相当于创建了一个线程 实例化B后,还需要通过调用Thread类的Thread(Runnable run)或者Thread(Runnablerun,String name)构造方法创建线程
实例是否可以共享 否 一个实例只能建立一个线程,同一个实例不能让多个线程共享 是 一个实例可以让多个线程共享
适用场景 不需要共享资源 需要共享资源

查看进程线程

1. Windows
  • 任务管理器
  • tasklist 查看所有进程
    • tasklist | findstr <string>
  • taskkill 杀死进程
2. Linux
  • ps -fe 查看所有进程
  • ps -fT -p <PID> 查看某个进程的所有线程
  • kill 杀死进程
  • top 按大写 H 切换是否显示线程
  • top -H -p <PID> 查看某个进程的所有线程
3. Java
  • jps 命令查看所有Java进程
  • jstack PID 查看某个进程的所有线程状态
  • jconsole 来查看某个Java进程中线程的运行情况

线程运行原理

栈与栈帧

Java Virtual Machine Stacks(Java虚拟机栈),每个线程启动后,虚拟机都会为其分配一块栈内存。

  • 每个栈由多个栈帧Frame组成,对应着每次方法调用时所占用的内存
  • 每个线程只能有一个活动栈帧,对应着当前正在执行的那个方法
线程上下文切换

Thread Context Switch,因为以下一些原因导致CPU不再执行当前的线程,转而执行另一个线程的代码:

  • 线程的CPU时间片用完
  • 垃圾回收
  • 有更高优先级的线程需要运行
  • 线程自己调用了sleep、yield、wait、join、park、synchronized、lock等方法

当线程上下文切换发生时,需要由当前操作系统保存当前线程的状态,并恢复另一个线程的状态,Java中对应的概念就是程序计数器,它的作用是记住下一条JVM指令的执行地址,是线程私有的。

  • 状态包括程序计数器、虚拟机栈中每个栈帧的信息,如局部变量、操作数栈、返回地址等
  • 频繁发生上下文切换会影响性能

线程常见方法


1. Start与Run
1
2
3
4
5
6
7
8
9
public static void main(String[] args){
Thread t1 = new Thread("t1"){
@Override
public void run(){
// todo
}
};
t1.start(); // 直接调用Run方法其实还是在主线程执行,start才会启动多线程且不能多次调用
}
2. sleep与yield
  • sleep

    • 调用sleep会让当前线程从Running进入TimeWaiting阻塞状态

    • 其他线程可以使用interrupt方法打断正在睡眠的线程,这时睡眠的线程会抛出InterruptedException异常

    • 睡眠结束的线程未必会立刻得到执行(CPU可能在执行其他线程,需要等调度器分配时间片)

    • 建议使用TimeUnit的sleep代替Thread的sleep,其可读性较好

      1
      TimeUnit.SECONDS.sleep(1);
  • yield

    • 调用yield会让当前线程从Running进入Runnable就绪状态,然后调度执行其他线程
    • 具体的实现依赖于操作系统的任务调度器
  • 线程优先级

    • 线程优先级会提示(hint)调度器优先调度该线程,但它仅仅是一个提示,调度器可以忽略它
    • 如果CPU比较忙,那么优先级高的线程会获得更多的时间片,但CPU空闲时,优先级几乎没有作用
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 测试程序
Runnable task1 = () -> {
int count = 0;
for (;;) {
System.out.println("---->1 " + count++);
}
};
Runnable task2 = () -> {
int count = 0;
for (;;) {
// Thread.yield();
System.out.println(" ---->2 " + count++);
}
};
Thread t1 = new Thread(task1, "t1");Thread t2 = new Thread(task2, "t2");
// t1.setPriority(Thread.MIN_PRIORITY);
// t2.setPriority(Thread.MAX_PRIORITY);
t1.start();
t2.start();
// A. 两个线程的结果相差无几
// B. t2使用yield让出时间片时,结果会明显小于t1
// C. 使用优先级,t2结果会明显高于t1
  • 案例 防止CPU占用100%

    • sleep实现

      在没有利用CPU计算时,不要让while(true)空转浪费CPU,这时候可以用sleep或者yield来让出CPU的使用权给其他程序

      1
      2
      3
      4
      5
      6
      7
      while (true){
      try{
      Thread.sleep(50);
      } catch (InterruptedException e){
      e.printStackTrace();
      }
      }
      • 可以用wait或者条件变量来达到类似的效果
      • 不同的是,后两种都需要加锁,并且需要相应的唤醒操作,一般适用于要进行同步的场景
      • sleep适用于无需锁同步的场景
    • wait实现

3. join方法
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
static int r = 0;
public static void main(String[] args) throws InterruptedException {
test1();
}
private static void test1() throws InterruptedException {
log.debug("开始");
Thread t1 = new Thread(() -> {
log.debug("开始");
sleep(1);
log.debug("结束");
r = 10;
});
t1.start();
log.debug("结果为:{}", r);
log.debug("结束");
}
// 输出0
// 因为主线程和线程t1是并行执行的,t1线程需要1秒后才能算出r=0
// 而主线程一开始就要打印r的结果,所以只能打印出r=0

解决办法,使用join,多个线程使用多个join。

1
2
3
4
t1.start();
t1.join(); // 等待执行结束
log.debug("结果为:{}", r);
log.debug("结束");
有限时间的等待
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
static int r1 = 0;
static int r2 = 0;
public static void main(String[] args) throws InterruptedException {
test3();
}
public static void test3() throws InterruptedException {
Thread t1 = new Thread(() -> {
sleep(2);
r1 = 10;
});
long start = System.currentTimeMillis();
t1.start();
// 线程执行结束会导致 join 结束
t1.join(1500);
long end = System.currentTimeMillis();
log.debug("r1: {} r2: {} cost: {}", r1, r2, end - start);
}
// 没等够时间,20:52:15.623 [main] c.TestJoin - r1: 0 r2: 0 cost: 1502
4. interrupt方法
打断sleep,wait,join的阻塞状态线程

打断sleep的线程,会清空打断状态,以sleep为例

1
2
3
4
5
6
7
8
9
10
11
12
13
public static void main(String[] args) throws ExecutionException, InterruptedException {
Thread t1 = new Thread(()->{
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}, "t1");
t1.start();
Thread.sleep(500);
t1.interrupt();
log.debug(" 打断状态: {}", t1.isInterrupted());
}
1
2
3
4
5
6
7
8
java.lang.InterruptedException: sleep interrupted
at java.lang.Thread.sleep(Native Method)
at java.lang.Thread.sleep(Thread.java:340)
at java.util.concurrent.TimeUnit.sleep(TimeUnit.java:386)
at cn.itcast.n2.util.Sleeper.sleep(Sleeper.java:8)
at cn.itcast.n4.TestInterrupt.lambda$test1$3(TestInterrupt.java:59)
at java.lang.Thread.run(Thread.java:745)
21:18:10.374 [main] c.TestInterrupt - 打断状态: false
打断正常的线程
1
2
3
4
5
6
7
8
9
Thread t2 = new Thread(()->{
while(true) {
}
}, "t2");
t2.start();
Thread.sleep(500);
t2.interrupt();
log.debug("dsd");
// 有结果但程序未结束

使用打断标记跳出

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
private static void test2() throws InterruptedException {
Thread t2 = new Thread(()->{
while(true) {
Thread current = Thread.currentThread();
boolean interrupted = current.isInterrupted();
if(interrupted) {
log.debug(" 打断状态: {}", interrupted);
break;
}
}
}, "t2");
t2.start();
sleep(0.5);
t2.interrupt();
}
多线程设计模式——两阶段终止模式

Two Phase Termination

在一个线程T1中如何优雅的终止T2,给T2一个料理后事的机会?

  1. 错误思路

    • 使用线程对象的stop()方法停止线程。stop方法会真正杀死线程,如果这时线程锁住了共享资源,那么当它被杀死后就再也没有机会释放锁,其他线程永远无法获取锁。
    • 使用System.exit(int)方法停止线程。这种做法会让整个程序都停止。
  2. 两阶段终止模式

  3. 实现

    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
    42
    43
    44
    45
    46
    47
    @Slf4j
    public class Test {
    public static void main(String[] args) throws ExecutionException, InterruptedException {
    TwoPhaseTermination twoPhaseTermination = new TwoPhaseTermination();
    twoPhaseTermination.start();
    Thread.sleep(6500);
    twoPhaseTermination.stop();
    }
    }
    @Slf4j
    class TwoPhaseTermination {
    private Thread monitor;
    public void start() {
    monitor = new Thread(() -> {
    while (true) {
    Thread thread = Thread.currentThread();
    if (thread.isInterrupted()) {
    log.debug("料理后事");
    break;
    }
    try {
    Thread.sleep(2000); // 睡眠两秒
    log.debug("执行监控记录"); // 无异常执行监控记录
    } catch (InterruptedException e) {
    e.printStackTrace();
    thread.interrupt(); // 有异常,重新设置打断标记为true,否则的话无法进入下一次while
    }
    }
    });
    monitor.start();
    }
    public void stop() {
    monitor.interrupt();
    }
    }
    /**
    2020-06-01 21:25:53.065 DEBUG TwoPhaseTermination - 执行监控记录
    2020-06-01 21:25:55.068 DEBUG TwoPhaseTermination - 执行监控记录
    2020-06-01 21:25:57.069 DEBUG TwoPhaseTermination - 执行监控记录
    java.lang.InterruptedException: sleep interrupted
    at java.lang.Thread.sleep(Native Method)
    at TwoPhaseTermination.lambda$start$0(Test.java:34)
    at java.lang.Thread.run(Thread.java:745)
    2020-06-01 21:25:57.563 DEBUG TwoPhaseTermination - 料理后事

    Process finished with exit code 0
    **/
打断park线程

打断park线程不会清空打断状态

1
2
3
4
5
6
7
8
9
10
11
12
13
14
Thread t1 = new Thread(() -> {
log.debug("park...");
LockSupport.park();
log.debug("unpark...");
log.debug("打断状态:{}", Thread.currentThread().isInterrupted());
}, "t1");
t1.start();
sleep(0.5);
t1.interrupt();
/**
21:11:52.795 [t1] c.TestInterrupt - park...
21:11:53.295 [t1] c.TestInterrupt - unpark...
21:11:53.295 [t1] c.TestInterrupt - 打断状态:true
**/

如果打断标记已经是true,则park会失效

1
2
3
4
5
6
7
8
9
Thread t1 = new Thread(() -> {
log.debug("park...");
LockSupport.park();
log.debug("unpark...");
log.debug("打断状态:{}", Thread.currentThread().isInterrupted());
// 会停在这一行,解决办法使用能返回打断标记并顺便清除的方法interrupted()
LockSupport.park();
log.debug("unpark...");
}, "t1");
5. 不推荐使用的方法

这些方法已经过时,容易破坏同步代码块,容易造成死锁

  • stop() 停止线程运行,使用两阶段终止模式解决
  • suspend() 挂起暂停线程运行,使用wait()
  • resume() 恢复线程运行
6. 主线程和守护线程

默认情况下,Java进程需要等待所有线程都运行结束,才会结束。有一种特殊的线程叫做守护线程,只要其他非守护线程运行结束了,即使守护线程的代码没有执行完,也会强制结束。

1
2
3
4
5
6
7
8
9
10
11
12
13
Thread thread = new Thread(() -> {
while (true) {
if (Thread.currentThread().isInterrupted()) {
break;
}
}
log.debug("守护线程结束");
});
thread.setDaemon(true);
thread.start();
Thread.sleep(1000);
log.debug("主线程结束");
// 2020-06-01 21:55:49.849 DEBUG Test - 主线程结束
  • 垃圾回收器就是一种守护线程
  • Tomcat中的Acceptor和Poller线程都是守护线程,所以Tomcat接到Shutdown命令后,不会等他们处理完请求
线程停止方法
  1. 使用退出标志,使线程正常退出,也就是当 run() 方法完成后线程中止。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    public class ServerThread extends Thread {
    //volatile修饰符用来保证其它线程读取的总是该变量的最新的值
    public volatile boolean exit = false;
    @Override
    public void run() {
    ServerSocket serverSocket = new ServerSocket(8080);
    while(!exit){
    serverSocket.accept(); //阻塞等待客户端消息
    ...
    }
    }
    public static void main(String[] args) {
    ServerThread t = new ServerThread();
    t.start();
    ...
    t.exit = true; //修改标志位,退出线程
    }
    }
  2. 使用 stop() 方法强行终止线程,但是不推荐使用这个方法,该方法已被弃用。

    为什么弃用stop:

    1. 调用 stop() 方法会立刻停止 run() 方法中剩余的全部工作,包括在 catch 或 finally 语句中的,并抛出ThreadDeath异常(通常情况下此异常不需要显示的捕获),因此可能会导致一些清理性的工作的得不到完成,如文件,数据库等的关闭。
    2. 调用 stop() 方法会立即释放该线程所持有的所有的锁,导致数据得不到同步,出现数据不一致的问题。
  3. 使用 interrupt 方法中断线程。

    调用 interrupt() 方法仅仅是在当前线程中打一个停止的标记,并不是真的停止线程。也就是说,线程中断并不会立即终止线程,而是通知目标线程,有人希望你终止。至于目标线程收到通知后会如何处理,则完全由目标线程自行决定。这一点很重要,如果中断后,线程立即无条件退出,那么我们又会遇到 stop() 方法的老问题。

线程五种状态

  • 【初始状态】仅是在语言层面创建了线程对象,还未与操作系统线程关联
  • 【可运行状态】(就绪状态)指该线程已经被创建(与操作系统线程关联),可以由 CPU 调度执行
  • 【运行状态】指获取了 CPU 时间片运行中的状态
    • 当 CPU 时间片用完,会从【运行状态】转换至【可运行状态】,会导致线程的上下文切换
  • 【阻塞状态】
    • 如果调用了阻塞 API,如 BIO 读写文件,这时该线程实际不会用到 CPU,会导致线程上下文切换,进入【阻塞状态】
    • 等 BIO 操作完毕,会由操作系统唤醒阻塞的线程,转换至【可运行状态】
    • 与【可运行状态】的区别是,对【阻塞状态】的线程来说只要它们一直不唤醒,调度器就一直不会考虑
      调度它们
  • 【终止状态】表示线程已经执行完毕,生命周期已经结束,不会再转换为其它状态

线程六种状态

这是从Java API层面来描述的,从Thread.State枚举,分为六种状态。

  • 【NEW】线程刚被创建,但是还没有调用start()方法
  • 【RUNNABLE】当调用了start()方法之后,注意,Java API层面的【RUNNABLE】状态涵盖了操作系统层面的【可运行状态】、【运行状态】、【阻塞状态】(由于BIO导致的线程阻塞,在Java中无法区分,仍然认为是可运行)
  • 【BLOCKED】【WAITTING】【TIMED_WAITTING】都是Java API层面对【阻塞状态】的细分
  • 【TERMINATED】当前线程代码运行结束