JavaSE第7篇并发与多线程

基本概念

在计算机中,我们把一个任务称为一个进程,浏览器就是一个进程,视频播放器是另一个进程,类似的,音乐播放器和Word都是进程。某些进程内部还需要同时执行多个子任务。例如,我们在使用Word时,Word可以让我们一边打字,一边进行拼写检查,同时还可以在后台进行打印,我们把子任务称为线程。进程和线程的关系就是:一个进程可以包含一个或多个线程,但至少会有一个线程。进程和线程是包含关系,但是多任务既可以由多进程实现,也可以由单进程内的多线程实现,还可以混合多进程+多线程

多进程的缺点

  • 创建进程比创建线程开销大,尤其是在Windows系统上
  • 进程间通信比线程间通信要慢,因为线程间通信就是读写同一个变量,速度很快

多进程的优点
多进程稳定性比多线程高,因为在多进程的情况下,一个进程崩溃不会影响其他进程,而在多线程的情况下,任何一个线程崩溃会直接导致整个进程崩溃

线程创建

Java语言内置了多线程支持。当Java程序启动的时候,实际上是启动了一个JVM进程,然后,JVM启动主线程来执行main()方法。在main()方法中,我们又可以启动其他线程。通常可以继承Thread或实现Runnable来自定义多线程类

直接调用run()方法,相当于调用了一个普通的Java方法,当前线程并没有任何改变,也不会启动新线程。上述代码实际上是在main()方法内部又调用了run()方法,打印hello语句是在main线程中执行的,没有任何新线程被创建。

必须调用Thread实例的start()方法才能启动新线程,如果我们查看Thread类的源代码,会看到start()方法内部调用了一个private native void start0()方法,native修饰符表示这个方法是由JVM虚拟机内部的C代码实现的,不是由Java代码实现的

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
import java.io.*;

public class Main {
public static void main(String[] args) throws IOException {
//继承Thread的方法
Thread t1 = new Thread(new MyThread());
t1.start();

//实现Runnable接口的方法
Thread t2 = new Thread(new MyRunnable());
t2.start();

//Lambda表达式的方法
Thread t = new Thread(() -> {
System.out.println("start new thread!");
});
t.start(); // 启动新线程
}
}

class MyThread extends Thread {
@Override
public void run() {
System.out.println("Thread start new thread!");
}
}

class MyRunnable implements Runnable {
@Override
public void run() {
System.out.println("Runnable start new thread!");
}
}

Java用Thread对象表示一个线程,通过调用start()启动一个新线程

  • 一个线程对象只能调用一次start()方法
  • 线程的执行代码写在run()方法中
  • 线程调度由操作系统决定,程序本身无法决定调度顺序
  • Thread.sleep()可以把当前线程暂停一段时间

线程的优先级
Thread.setPriority(int n) // 1~10, 默认值5

JVM自动把1(低)~10(高)的优先级映射到操作系统实际优先级上(不同操作系统有不同的优先级数量)。优先级高的线程被操作系统调度的优先级较高,操作系统对高优先级线程可能调度更频繁,但我们决不能通过设置优先级来确保高优先级的线程一定会先执行

线程的状态

Java线程的状态有以下几种

  • New:新创建的线程,尚未执行;
  • Runnable:运行中的线程,正在执行run()方法的Java代码
  • Blocked:运行中的线程,因为某些操作被阻塞而挂起
  • Waiting:运行中的线程,因为某些操作在等待中
  • Timed Waiting:运行中的线程,因为执行sleep()方法正在计时等待
  • Terminated:线程已终止,因为run()方法执行完毕

线程终止的原因有:

  • 线程正常终止:run()方法执行到return语句返回;
  • 线程意外终止:run()方法因为未捕获的异常导致线程终止;
  • 对某个线程的Thread实例调用stop()方法强制终止(强烈不推荐使用)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
import java.io.*;

public class Main {
public static void main(String[] args) throws IOException, InterruptedException {
//main线程在启动t线程后,可以通过t.join()等待t线程结束后再继续运行
Thread t = new Thread(() -> {
System.out.println("hello");
});
System.out.println("start");
t.start();
t.join();
System.out.println("end");
}
}

中断线程

如果线程需要执行一个长时间任务,就可能需要能中断线程。中断线程就是其他线程给该线程发一个信号,该线程收到信号后结束执行run()方法,使得自身线程能立刻结束运行

调用interruptf方法

main线程通过调用t.interrupt()从而通知t线程中断,而此时t线程正位于hello.join()的等待中,此方法会立刻结束等待并抛出InterruptedException。由于我们在t线程中捕获了InterruptedException,因此,就可以准备结束该线程。在t线程结束前,对hello线程也进行了interrupt()调用通知其中断。如果去掉这一行代码,可以发现hello线程仍然会继续运行,且JVM不会退出

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
public class Main {
public static void main(String[] args) throws InterruptedException {
Thread t = new MyThread();
t.start();
Thread.sleep(1000);
t.interrupt(); // 中断t线程
t.join(); // 等待t线程结束
System.out.println("end");
}
}

class MyThread extends Thread {
public void run() {
Thread hello = new HelloThread();
hello.start(); // 启动hello线程
try {
hello.join(); // 等待hello线程结束
} catch (InterruptedException e) {
System.out.println("interrupted!");
}
hello.interrupt();
}
}

class HelloThread extends Thread {
public void run() {
int n = 0;
while (!isInterrupted()) {
n++;
System.out.println(n + " hello!");
try {
Thread.sleep(100);
} catch (InterruptedException e) {
break;
}
}
}
}
设置标志位

我们通常会用一个running标志位来标识线程是否应该继续运行,在外部线程中,通过把HelloThread.running置为false,就可以让线程结束

要对线程间共享的变量用关键字volatile声明。这涉及到Java的内存模型。在Java虚拟机中,变量的值保存在主内存中,但是,当线程访问变量时,它会先获取一个副本,并保存在自己的工作内存中。如果线程修改了变量的值,虚拟机会在某个时刻把修改后的值回写到主内存,但是,这个时间是不确定的

volatile关键字的目的是告诉虚拟机:

  • 每次访问变量时,总是获取主内存的最新值
  • 每次修改变量后,立刻回写到主内存

volatile关键字解决的是可见性问题:当一个线程修改了某个共享变量的值,其他线程能够立刻看到修改后的值

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public class Main {
public static void main(String[] args) throws InterruptedException {
HelloThread t = new HelloThread();
t.start();
Thread.sleep(1);
t.running = false; // 标志位置为false
}
}

class HelloThread extends Thread {
public volatile boolean running = true;

public void run() {
int n = 0;
while (running) {
n++;
System.out.println(n + " hello!");
}
System.out.println("end!");
}
}

守护线程

守护线程是指为其他线程服务的线程。在JVM中,所有非守护线程都执行完毕后,无论有没有守护线程,虚拟机都会自动退出。因此,JVM退出时,不必关心守护线程是否已结束

在守护线程中,编写代码要注意:守护线程不能持有任何需要关闭的资源,例如打开文件等,因为虚拟机退出时,守护线程没有任何机会来关闭文件,这会导致数据丢失。创建守护线程的方法和普通线程一样,只是在调用start()方法前,调用setDaemon(true)把该线程标记为守护线程

线程同步

当多个线程同时运行时,线程的调度由操作系统决定,程序本身无法决定。因此,任何一个线程都有可能在任何指令处被操作系统暂停,然后在某个时间段后继续执行。这个时候,有个单线程模型下不存在的问题就来了:如果多个线程同时读写共享变量,会出现数据不一致的问题。对变量进行读取和写入时,结果要正确,必须保证是原子操作。原子操作是指不能被中断的一个或一系列操作。

通过加锁和解锁的操作,就能保证3条指令总是在一个线程执行期间,不会有其他线程会进入此指令区间。即使在执行期线程被操作系统中断执行,其他线程也会因为无法获得锁导致无法进入此指令区间。只有执行线程将锁释放后,其他线程才有机会获得锁并执行。这种加锁和解锁之间的代码块我们称之为临界区(Critical Section),任何时候临界区最多只有一个线程能执行。保证一段代码的原子性就是通过加锁和解锁实现的。Java程序使用synchronized关键字对一个对象进行加锁

同步变量

使用synchronized:

  • 找出修改共享变量的线程代码块
  • 选择一个共享实例作为锁
  • 使用synchronized(lockObject) { … }

原子操作

  • 基本类型(long和double除外)赋值,例如:int n = m;
  • 引用类型赋值,例如:Listlist = anotherList
  • 在x64平台的JVM是把long和double的赋值作为原子操作实现的
  • 如果是多行赋值语句,就必须保证是同步操作
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
public class Main {
static int i;
public static final Object lock = new Object();
public static void main(String[] args) throws Exception {
Thread t1 = new Thread(() -> {
for (int i = 1; i <= 1000; i++) {
increment();
}
});
Thread t2 = new Thread(() -> {
for (int i = 1; i <= 1000; i++) {
increment();
}
});

t1.start();
t2.start();
try {
t1.join();
t2.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("计算值为:" + i);
}

static void increment() {
synchronized (lock) {
i++;
}
}
}
同步方法

如果一个类被设计为允许多线程正确访问,我们就说这个类就是“线程安全”的(thread-safe),上面的Counter类就是线程安全的。Java标准库的java.lang.StringBuffer也是线程安全的。还有一些不变类,例如String,Integer,LocalDate,它们的所有成员变量都是final,多线程同时访问时只能读不能写,这些不变类也是线程安全的。

最后,类似Math这些只提供静态方法,没有成员变量的类,也是线程安全的。除了上述几种少数情况,大部分类,例如ArrayList,都是非线程安全的类,我们不能在多线程中修改它们。但是,如果所有线程都只读取,不写入,那么ArrayList是可以安全地在线程间共享的

对于static方法,是没有this实例的,因为static方法是针对类而不是实例。但是我们注意到任何一个类都有一个由JVM自动创建的Class实例,因此,对static方法添加synchronized,锁住的是该类的Class实例

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
public class Main {
public static void main(String[] args) throws InterruptedException {
Counter counter = new Counter();
Thread t1 = new Thread(() -> {
counter.add(10);
});
Thread t2 = new Thread(() -> {
counter.dec(10);
});

t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(counter.get());
}
}

class Counter {
private int count = 0;
//其中this表示当前方法,与方法上加synchronized是等价的
public void add(int n) {
synchronized (this) {
count += n;
}
}

public void dec(int n) {
synchronized (this) {
count -= n;
}
}

public int get() {
return count;
}
}
死锁

Java的synchronized锁是可重入锁。死锁产生的条件是多线程各自持有不同的锁,并互相试图获取对方已持有的锁,导致无限等待。避免死锁的方法是多线程获取锁的顺序要一致

死锁的四个必要条件:
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
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
public class Main {
public static void main(String[] args) throws InterruptedException {
MakeUp g1 = new MakeUp(0, "灰姑娘");
MakeUp g2 = new MakeUp(1, "白雪公主");

g1.start();
g2.start();
}
}

class Lipstick {

}

class Mirror {

}

class MakeUp extends Thread {
static Lipstick lipstick = new Lipstick();
static Mirror mirror = new Mirror();

int choice;
String girlName;


MakeUp(int choice, String girlName) {
this.choice = choice;
this.girlName = girlName;
}

@Override
public void run() {
try {
makeup();
} catch (InterruptedException e) {
e.printStackTrace();
}
}

private void makeup() throws InterruptedException {
if (choice == 0) {
synchronized (lipstick) {
System.out.println(this.girlName + "获得口红的锁");
Thread.sleep(1000);
//本段代码产生死锁
/*synchronized (mirror) {
System.out.println(this.girlName + "获得镜子的锁");
Thread.sleep(2000);
}*/
}
synchronized (mirror) {
System.out.println(this.girlName + "获得镜子的锁");
}
} else {
synchronized (mirror) {
System.out.println(this.girlName + "获得镜子的锁");
Thread.sleep(2000);
//本段代码产生死锁
/*synchronized (lipstick) {
System.out.println(this.girlName + "获得口红的锁");
}*/
}
synchronized (lipstick) {
System.out.println(this.girlName + "获得口红的锁");
}
}

}
}

wait和notify方法

对象的wait()方法会暂时使得此线程进入等待状态,同时会释放当前代码块持有的锁,这时其他线程可以获取到此对象的锁,当其他线程调用对象的notify()方法后,会唤醒刚才变成等待状态的线程(这时并没有立即释放锁)。注意,必须是在持有锁(同步代码块内部)的情况下使用,否则会抛出异常!

notifyAll其实和notify一样,也是用于唤醒,但是前者是唤醒所有调用wait()后处于等待的线程,而后者是看运气随机选择一个

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
public class Main {
public static void main(String[] args) throws InterruptedException {
Object o1 = new Object();
Thread t1 = new Thread(() -> {
synchronized (o1) {
try {
System.out.println("开始等待");
o1.wait(); //进入等待状态并释放锁
System.out.println("等待结束!");
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});
Thread t2 = new Thread(() -> {
synchronized (o1) {
System.out.println("开始唤醒!");
o1.notify(); //唤醒处于等待状态的线程
for (int i = 0; i < 5; i++) {
System.out.println(i);
}
//唤醒后依然需要等待这里的锁释放之前等待的线程才能继续
}
});
t1.start();
Thread.sleep(1000);
t2.start();
}
}

ThreadLocal

使用ThreadLocal类,来创建工作内存中的变量,它将我们的变量值存储在内部(只能存储一个变量),不同的线程访问到ThreadLocal对象时,都只能获取到当前线程所属的变量

即使线程2重新设定了值,也没有影响到线程1存放的值,所以说,不同线程向ThreadLocal存放数据,只会存放在线程自己的工作空间中,而不会直接存放到主内存中,因此各个线程直接存放的内容互不干扰

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public class Main {
public static void main(String[] args) throws InterruptedException {
ThreadLocal<String> local = new ThreadLocal<>(); //注意这是一个泛型类,存储类型为我们要存放的变量类型
Thread t1 = new Thread(() -> {
local.set("lbwnb"); //将变量的值给予ThreadLocal
System.out.println("线程1变量值已设定!");
try {
Thread.sleep(2000); //间隔2秒
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("线程1读取变量值:");
System.out.println(local.get()); //尝试获取ThreadLocal中存放的变量
});
Thread t2 = new Thread(() -> {
local.set("yyds"); //将变量的值给予ThreadLocal
System.out.println("线程2变量值已设定!");
});
t1.start();
Thread.sleep(1000); //间隔1秒
t2.start();
}
}

在线程中创建的子线程,无法获得父线程工作内存中的变量。可以使用InheritableThreadLocal来解决。在InheritableThreadLocal存放的内容,会自动向子线程传递。


JavaSE第7篇并发与多线程
https://www.eldpepar.com/coding/64242/
作者
EldPepar
发布于
2022年10月23日
许可协议