目录
一、认识线程
1.1 进程
进程是操作系统中正在运行的程序,其组织为链表。
① 进程是系统进行资源分配的基本单位。
② 每个进程的内存空间彼此独立,互不干扰。
③ 进程间的切换会有较大的开销。
④ 一个进程至少包含一个线程。
多进程:操作系统中能够同时运行多个任务。
1.2 线程
线程,也称作"轻量级进程"。线程是进程中的单个顺序控制流,可以理解为一条执行路径。
① 线程是系统进行调度执行的基本单位。
② 同一进程中的线程共享内存空间。
③ 线程间切换的开销较小。
④ 进程中第一个创建的线程即为主线程。
多线程:一个进程中有多个顺序流在执行(并发执行)。
【线程与进程的其他区别】
1、在一些需要频繁的创建和销毁进程的场景下,若使用多进程编程,系统开销就会很大,此时就需要使用多线程编程,减少系统开销。
2、同一进程中的多个线程共享同一份系统资源,这样在创建线程时,就省去了申请资源开销;销毁线程时,也省去了释放资源的开销。
3、一个进程挂了一般不会影响到其他进程;但一个线程挂了,可能把同进程内的其他线程一起带走(整个进程崩溃)。
二、线程的创建
1、 继承 Thread 类
(1) 创建一个继承 Thread 类的线程类 MyThread。
(2) 创建 MyThread 实例。
(3) 调用 start() 方法启动线程。
//继承 Thread创建线程类
class MyThread extends Thread {
//线程入口方法 run(),每个线程启动后都必须执行的方法。
@Override
public void run() {
System.out.println("Thread run");
}
}
public class ThreadDemo {
public static void main(String[] args) {
//创建实例
Thread t = new MyThread();
//启动线程
//start()方法的作用:启动一个分支线程,在 JVM中开辟一个新的栈空间,
//只要新的栈空间开出来后,start()方法就结束了,线程就启动成功。
t.start();
//启动成功的线程会自动调用 run()方法。
}
}
2、实现 Runnable 接口
(1) 创建一个实现 Runnable 接口的线程类 MyThread。
(2) 创建 Thread 类实例,调用 Thread 的构造方法时将 Runnable 对象作为参数。
(3) 调用 start() 方法启动线程。
//实现 Runnable接口
//Runnable可以理解为"可执行的",
//通过该接口,可以抽象表示出一段可以被其他实体执行的代码
class MyThread implements Runnable {
//线程入口
@Override
public void run() {
System.out.println("Thread run");
}
}
public class ThreadDemo {
public static void main(String[] args) {
//创建 Thread实例,Runnable对象作为参数
//这样可以把 线程 和 要执行的任务 进行解耦合操作
Thread t = new Thread(new MyThread());
//启动线程
t.start();
}
}
3、匿名内部类、Thread
(1) 使用匿名内部类创建 Thread 子类对象。(匿名内部类:没有名字,只用一次。)
(2) 调用 start() 方法启动线程。
public class ThreadDemo {
public static void main(String[] args) {
//使用匿名内部类创建 Thread子类对象
Thread t = new Thread() {
@Override
public void run() {
System.out.println("Thread run");
}
};
//启动线程
t.start();
}
}
4、匿名内部类、 Runnable
(1) 使用匿名内部类创建 Runnable 子类对象,其作为 Thread 构造方法的参数。
(2) 调用 start() 方法启动线程。
public class ThreadDemo {
public static void main(String[] args) {
//使用匿名内部类创建 Runnable子类对象
Thread t = new Thread(new Runnable() {
@Override
public void run() {
System.out.println("Thread run");
}
});
//启动线程
t.start();
}
}
5、lambda 表达式(推荐)
(1) 用 lambda 表达式创建 Runnable 子类对象,其作为 Thread 构造方法的参数。
(2) 调用 start() 方法启动线程。
public class ThreadDemo {
public static void main(String[] args) {
//用 lambda 表达式创建 Runnable 子类对象
Thread t = new Thread(() -> {
System.out.println("Thread run");
});
//启动线程
t.start();
}
}
【Thread 和 Runnable】
若一个类继承 Thread 类,则不适合资源共享;若实现 Runnable 接口,则很容易实现资源共享。
实现 Runnable 接口还可以避免 Java 中单继承的限制。
三、Thread 类
3.1 run() 和 start()
Thread 类可以使用 start() 方法来启动一个线程,启动线程后,系统会自动调用 run() 方法,而对于同一个 Thread 对象来说,start() 方法只能调用一次。所以如果要启动更多线程,就需要创建新的对象。
若直接调用 run() 是无法启动线程的,就不会分配新的分支栈,还会导致无法多线程并发。
上文中重写 run() 方法,类似于提供给线程一个指令清单,而调用 start() 方法,才开始执行指令,即开始执行 run() 方法。所以调用 start() 方法,才能真正在操作系统的内核中创建出一个线程。
3.2 构造方法
方法 | 说明 |
Thread() | 创建线程对象 |
Thread(Runnable target) | 使用 Runnable 对象创建线程对象 |
Thread(String name) | 创建线程对象,并命名 |
Thread(Runnable target, String name) | 使用 Runnable 对象创建线程对象,并命名 |
3.3 属性
属性 | 获取方法 |
ID | getId() |
名称 | getName() |
状态 | getState() |
优先级 | getPriority() |
是否后台线程 | isDaemon() |
是否存活 | isAlive() |
是否被终止 | isInterrupted() |
注: 获取当前线程引用可以使用 Thread.currentThread()。线程休眠可以使用 Thread.sleep(long millis)。
- getId():ID 是 JVM 自动分配给线程的身份表示,保证唯一性。
- getName():名称用于各种调试工具。
- getState():状态表示线程当前所处情况,例如就绪状态、阻塞状态等。
- getPriority():优先级高的线程理论上更容易被调度,但由于系统的随机调度,可能不准确。
- isDaemon():JVM 会在一个进程的所有非后台线程结束后,才会结束运行。
- isAlive():内核中线程是否还存在。
- isInterrupted():线程 run() 方法是否被终止。
注:操作系统的"内核",即最核心的部分。内核中有一个"调度机"模块,实现方式类似于"随机调度",执行方式为"抢占式执行"。
3.4 后台线程
在 Java 中,我们可以利用 setDaemon() 方法设置线程为后台线程,与后台线程相对的还有前台线程。前台线程的运行会阻止进程结束;后台线程的运行不会阻止进程结束。而我们代码创建的线程默认就是前台线程,会阻止进程结束,只要前台线程没执行完,进程就不会结束,即使 main(主线程)已经执行完毕。
public class ThreadDemo {
public static void main(String[] args) {
Thread t = new Thread(() -> {
while (true) {
System.out.println("Thread run");
try {
//线程休眠 2 秒
Thread.sleep(2000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
},"MyThread");//给线程命名
//设置线程为后台线程(不能在 start之后)
t.setDaemon(true); //设置为 true为后台,不设置为 true为前台
//启动线程
t.start();
}
}
3.5 线程存活
在 Java 中,isAlive() 方法可以让我们知道内核中的线程是否还存在,当我们创建出线程对象(Thread)实例时,这个对象本身的生命周期和内核中的线程生命周期是完全不一样的。
1、创建出 Thread 对象时,尽管有了对象,但内核中的线程还未创建,故 isAlive 为 false。
2、当调用 start() 方法时,系统在内核中创建出线程,此时 isAlive 为 true。
3、当线程的 run() 方法执行结束,内核中的线程也就释放了,此时 isAlive 为 false。
3.6 线程终止
终止一个线程,即让线程的 run() 方法执行结束。故要想让线程提前终止,核心就是让 run() 方法提前结束。如果 main 线程想让 t 线程提前结束,而 t 线程里的代码没有配合,main 线程是无法让 t 线程提前结束的,所以说代码配合是让线程结束的前提。
例如下方代码,如果想让 t 线程提前结束,此时就需要引入标志位,在 t 线程 run() 方法的 while 循环条件中放入 Thread 类自带的标志位 isInterrupted()(该标志位为blooean类型),后续在 main 线程中利用 interrupt() 改变标志位的值,令循环结束,从而实现终止 t 线程。
public class ThreadDemo {
public static void main(String[] args) throws InterruptedException {
Thread t = new Thread(() -> {
//标志位放入 while 循环的条件中
while (!Thread.currentThread().isInterrupted()) {
System.out.println("Thread run");
//线程休眠 1 秒
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
break;
}
}
System.out.println("线程释放");
});
//启动线程
t.start();
//休眠 3 秒
Thread.sleep(3000);
//利用 interrupt() 修改标志位的值
System.out.println("让 t 线程结束");
t.interrupt();
}
}
3.7 线程等待
在多线程中,由于系统的随机调度和抢占式执行,多个线程的执行顺序是无法确定的,这时就可以利用一些 API 来影响线程执行顺序。在 Thread 类中,可以使用 join() 方法实现等待线程(哪个线程调用 join() 哪个线程就阻塞等待)。
例如,在 main 线程中调用 t.join(),此时若 t 线程正在运行,main 线程就会进入阻塞状态,等待 t 线程运行结束;若 t 线程运行结束,main 线程就会从阻塞中恢复,继续往下执行。这样就成功让 t 线程先执行,main 线程后执行,影响了线程的执行顺序。
/**
* 让主线程创建一个新线程,
* 由新线程完成一系列运算(例如,1+2+3+...+100),
* 最后由主线程负责获取到最终结果。
*/
public class ThreadDemo {
static int sum = 0;
public static void main(String[] args) throws InterruptedException {
Thread t = new Thread(() -> {
for (int i = 1; i <= 100; i++) {
sum += i;
}
});
//启动线程
t.start();
//等待 t 线程运算结束,才能确保得到完整的运算结果
t.join();
//若不等待 t 线程运算结束,则结果可能并不完整
System.out.println(sum);
}
}
注:推荐使用 join(long millis) 方法实现线程等待,即最多等 millis 毫秒就结束等待,比 join() 方法的死等安全。
四、线程状态
【Java 中的线程状态】
① NEW:Thread 对象创建好了,但还未调用 start() 创建线程。
② RUNNABLE:就绪状态,表示这个线程正在 CPU 上执行,或准备就绪随时可以去 CPU 上执行。
③ TERMINATED:Thread 对象仍然存在,但是系统内部的线程已经执行结束了。
④ WAITING:不带时间的阻塞 (死等),必须要满足一定条件,才会解除阻塞。使用 join() 或 wait() 都会进入此状态。
⑤ TIMED_WAITING:指定时间的阻塞,到达一定时间会自动解除阻塞。使用 sleep() 或 join(long millis) 都会进入此状态。
⑥ BLOCKED:锁竞争引起的阻塞。
public class ThreadDemo {
public static void main(String[] args) throws InterruptedException {
Thread t = new Thread(() -> {
for (int i = 0; i < 5; i++) {
System.out.println("Thread run");
//休眠 1 秒
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
});
System.out.println(t.getState());//NEW:线程启动之前
t.start();//启动线程
System.out.println(t.getState());//RUNNABLE:线程启动之后
Thread.sleep(500);//线程休眠 0.5秒
System.out.println(t.getState());//TIMED_WAITING:线程休眠
t.join();//WAITING:等待线程
System.out.println(t.getState());//TERMINATED:线程执行结束
}
}
注:在线程运行过程中,可以前往 JDK 中 bin 目录下 jconsole.exe 中查看进程状态。
五、线程安全
5.1 认识线程安全
在 Java 中引入多线程的目的是为了能够实现"并发编程",若只使用多线程难免不会遇到安全问题。例如,我们创建两个线程 t1 和 t2,每个线程分别进行自增10000次的操作,最后由 main 线程获取到最终结果。
public class ThreadDemo {
private static int count = 0;
public static void main(String[] args) throws InterruptedException {
//线程 1
Thread t1 = new Thread(() -> {
for (int i = 0; i < 10000; i++) {
count++;
}
});
//线程 2
Thread t2 = new Thread(() -> {
for (int i = 0; i < 10000; i++) {
count++;
}
});
//线程启动
t1.start();
t2.start();
//线程等待
t1.join();
t2.join();
//预期结果为 20000
System.out.println("count = " + count);
}
}
上述代码预期结果为 20000,但运行多次就会发现,无法确保每次都得到 20000,这种情况就是线程存在安全问题。发生这种情况是因为系统在运行 count++ 这个操作时,实际上有三条指令:① load(读取)、② add(执行)、③ save(保存),而由于两个线程并发执行,有可能在 t1 load 后,还未来得及 add,t2 就开始进行 load 等操作,此时就有可能产生本应该自增 2 次甚至 3 次,结果只自增了 1 次的情况,故无法保证每次结果都达到预期。
此时就可以发现,导致线程不安全的根本原因是操作系统上的线程是"随机调度"和"抢占式执行",直接原因是进行自增操作的指令不具备"原子性"。所以我们要确保在 t1 完成 save 后,t2 才会进行 load 操作,这样才能保证线程安全。
5.2 synchronized
【用法】
1、修饰代码块:明确锁哪个对象
(1)锁任意对象
public class SynchronizedDemo {
Object locker = new Object();
public void method() {
//可以理解为 "{"上锁,"}"解锁
synchronized (locker) {
}
}
}
(2) 锁当前对象
public class SynchronizedDemo {
public void method() {
//可以理解为 "{"上锁,"}"解锁
synchronized (this) {
}
}
}
2、 修饰普通方法:相当于给 this 加锁
public class SynchronizedDemo {
public synchronized void method() {
}
}
3、 修饰静态方法:相当于给类对象 (.class) 加锁
public class SynchronizedDemo {
public synchronized static void method() {
}
}
【两个特性】
① 互斥性:一个线程给一个对象上锁后,若其他线程也要给同一个对象上锁,此时其他线程就会阻塞等待 (BLOCKED),需等上锁的线程解锁后才能上锁。
② 可重入性:synchronized 对同一线程来说是可重入的,即同一线程可以对同一对象多次上锁,但一个锁对象只会有一把锁。
注:对于可重入锁来说,内部有两个信息:① 当前这个锁是哪个线程的;② 加锁次数的计数器。
【实例】
鉴于上述的线程不安全问题,此时我们就可以使用 synchronized 关键字给自增操作中的指令上锁,将三个指令打包成一个原子的操作,以维护线程安全。
public class ThreadDemo {
private static int count = 0;
public static void main(String[] args) throws InterruptedException {
//实例化一个对象
Object locker = new Object();
//线程 1
Thread t1 = new Thread(() -> {
for (int i = 0; i < 10000; i++) {
synchronized (locker) {
count++;
}
}
});
//线程 2
Thread t2 = new Thread(() -> {
for (int i = 0; i < 10000; i++) {
synchronized (locker) {
count++;
}
}
});
//线程启动
t1.start();
t2.start();
//线程等待
t1.join();
t2.join();
System.out.println("count = " + count);
}
}
上述代码首先实例化一个对象 locker,然后分别将两个线程都对 locker 上锁,此时就利用到了 synchronized 的互斥性,实现了锁竞争,成功维护线程安全。
5.3 死锁
【四个必要条件】
① 互斥使用:获取锁的过程是互斥的,一个线程拿到一把锁,另一个线程也想拿这把锁,此时就需要阻塞等待。
② 请求保持:一个线程拿到锁 A 后,在持有锁 A 的前提下,尝试获取锁 B。
③ 不可抢占:一个线程拿到一把锁后,只能主动解锁,不能让别的线程强行把锁抢走。
④ 循环等待:发生死锁时,必然存在一个线程—资源的循环链。
【三种典型场景】
1、一个线程一把锁
上文中说到,一个线程可以给一把可重入锁多次上锁,不会出现死锁的情况。故如果锁是不可重入锁,此时一个线程对这把锁加锁两次,就会出现死锁。
2、两个线程两把锁
线程 t1 获取到锁 A,线程 t2 获取到锁 B,然后线程 t1 尝试获取锁 B,线程 t2 尝试获取锁 A,此时就会出现死锁。
public class ThreadDemo {
public static void main(String[] args) throws InterruptedException {
Object A = new Object();
Object B = new Object();
Thread t1 = new Thread(() -> {
//获取锁 A
synchronized (A) {
System.out.println("t1 获得锁 A");
//休眠一秒,保证 t2 获得锁 B
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
//尝试获取锁 B
synchronized (B) {
System.out.println("t1 获得全部锁");
}
}
});
Thread t2 = new Thread(() -> {
//获取锁 B
synchronized (B) {
System.out.println("t2 获得锁 B");
//休眠一秒,保证 t1 获得锁 A
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
//尝试获取锁 A
synchronized (A) {
System.out.println("t2 获得全部锁");
}
}
});
//线程启动
t1.start();
t2.start();
}
}
3、N 个线程 N 把锁
这种场景就是最经典的哲学家进餐问题,该问题是描述有五个哲学家共用一张圆桌,分别坐在周围的五张椅子上,在圆桌上有五个碗和五只筷子,他们的生活方式是交替地进行思考和进餐。平时,一个哲学家进行思考,饥饿时便试图取用其左右最靠近他的筷子,只有在他拿到两只筷子时才能进餐。进餐毕,放下筷子继续思考。
实际在这种场景下,大部分情况都是可以正常运行的,但难免存在一些极端情况,例如,在同一时刻,五个哲学家都饿了,都拿起了左边的筷子,此时他们尝试拿起右边的筷子,但是右边的筷子已经没有了,没人进餐,也没人放下筷子,就出现了死锁。
【解决死锁】
解决死锁的关键就在于破坏死锁的四个必要条件,其中循环等待最好破坏,只需要制定一些规则即可有效避免循环等待。例如,引入加锁顺序的规则,我们给每根筷子进行编号,规定到每位哲学家必须先拿编号小的,才能再拿编号大的,若编号小的被拿了,直接进入阻塞等待,这样就可以成功避免死锁。上文中场景二的死锁也可以用同样的办法解决,规定 t1 和 t2 都是先拿到锁 A,才能拿锁 B,就可以避免死锁。
public class ThreadDemo {
public static void main(String[] args) throws InterruptedException {
Object A = new Object();
Object B = new Object();
Thread t1 = new Thread(() -> {
//获取锁 A
synchronized (A) {
System.out.println("t1 获得锁 A");
//尝试获取锁 B
synchronized (B) {
System.out.println("t1 获得全部锁");
}
}
});
Thread t2 = new Thread(() -> {
//获取锁 A
synchronized (A) {
System.out.println("t2 获得锁 A");
//尝试获取锁 B
synchronized (B) {
System.out.println("t2 获得全部锁");
}
}
});
//线程启动
t1.start();
t2.start();
}
}
5.4 volatile
volatile 关键字一个功能是强制读取内存,保证内存可见性;另一个功能是禁止指令重排序。例如,创建一个标志位 flag,再创建两个线程,t1 将 flag 是否等于 0 作为循环条件放入 while,t2 负责输入 flag 的值,预期结果是 t2 中输入除 0 以外的数字,t1 线程都可以结束。
import java.util.Scanner;
public class ThreadDemo {
private static int flag = 0;
public static void main(String[] args) {
//线程 1
Thread t1 = new Thread(() -> {
while (flag == 0) {
//循环体没有内容
}
System.out.println("t1 线程结束");
});
//线程 2
Thread t2 = new Thread(() -> {
System.out.println("请输入 flag 的值:");
Scanner sc = new Scanner(System.in);
flag = sc.nextInt();
});
//线程启动
t1.start();
t2.start();
}
}
运行后我们会发现,输入除 0 以外的数字,t1 线程并不会结束,这相当于 t2 修改了内存,但是 t1 没有看见内存的变化,这就称为 "内存可见性" 引起的线程安全问题。具体原因是在 t1 线程的 while 循环判定条件时,核心指令有两条:① load(读取 flag 的值到内存中);② 拿着寄存器的值和 0 进行比较。此时由于系统的循环执行速度非常快,在不停的执行两条指令,可每次执行 load 指令的结果都是一样的,由于 load 指令的开销大,此时 JVM 就会觉得这条 load 指令没有存在的必要,就会进行代码优化,将 "主内存" 中 flag 的值复制到 "工作内存" 中,后续都是读取寄存器中的值,以减少开销,这就导致无论后续输入什么值,load 读取 flag 的值都为 0。
故 "内存可见性" 问题实际上是个高度依赖编译器优化的问题,什么时候会出现这个问题是不确定的。此时为了能够确保不出现这种问题。Java 就引入了 volatile 关键字,它可以确保每次 while 循环都会重新从内存中读取数据,即给成员变量 flag 加上 volatile 修饰,就可以解决问题。
【线程不安全总结】
① 操作系统的线程是 "抢占式执行"、"随机调度" 的;
② 多线程中的操作不具备 "原子性";
③ 内存可见性问题;
④ 指令重排序问题;
⑤ 代码结构。例如:多个线程同时修改一个变量。
5.5 wait() 和 notify()
wait():让当前持有 Object 对象锁的线程进入阻塞等待状态,并释放持有的 Object 对象锁。
notify():唤醒正在 Object 对象上等待的线程,若有多个正在等待的线程,就随机唤醒一个。
wait() 和 notify() 需要一个统一的 Object 对象进行加锁,而且两个方法都必须建立在 synchronized 线程同步的基础上。
例如,首先创建一个统一的对象来调用 wait() 和 notify() 方法,然后创建两个线程,此时 t1 获取到 locker 锁,然后调用 wait() 进入阻塞等待,此时由于调用了 wait(),所以 locker 锁已被释放,t2 获得 locker 锁后,调用 notify() 将 t1 唤醒,可由于 t2 尚未释放锁,所以 t1 被唤醒后,还会有一小段锁竞争引起的阻塞等待,等 t2 执行完,释放锁后,t1 方可继续执行。
public class ThreadDemo {
public static void main(String[] args) {
//wait()和 notify()需要一个统一的对象进行加锁
Object locker = new Object();
//线程 1
Thread t1 = new Thread(() -> {
//获取 locker 锁
synchronized (locker) {
System.out.println("t1 wait 之前");
//t1 wait
try {
locker.wait();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
System.out.println("t1 wait 之后");
}
System.out.println("t1 执行结束");
});
//线程 2
Thread t2 = new Thread(() -> {
//休眠 3 秒,保证 t1 获取到 locker 锁
try {
Thread.sleep(3000);
//获取 locker 锁
synchronized (locker) {
System.out.println("t2 notify 之前");
//t2 notify
locker.notify();
System.out.println("t2 notify 之后");
}
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
System.out.println("t2 执行结束");
});
//线程启动
t1.start();
t2.start();
}
}
【其他方法】
wait(long timeoutMillis):表示进入 wait 后,最多等待 timeoutMillis ms,若这个时间内没有收到 notify,就自行唤醒。
notifyAll():可以唤醒所有在 Object 对象上等待的线程。
【wait() 和 sleep()】
1、wait() 需要搭配 synchronized 使用,而 sleep() 不需要。
2、wait() 是 Object 的方法,而 sleep() 是 Thread 的静态方法。
总结
1、进程是系统分配资源的基本单位,线程是系统调度执行的基本单位。
2、进程是包含线程的,每个进程至少有一个线程存在,即主线程。
3、进程和进程之间不共享内存空间,同一个进程的线程之间共享同一个内存空间。
4、若直接调用 run() 方法无法启动线程,就不会分配新的分支栈,会导致无法多线程并发。
5、对于同一个 Thread 对象来说,start() 方法只能调用一次。
6、前台线程的运行会阻止进程结束;后台线程的运行不会阻止进程结束。
7、在线程运行过程中,可以前往 JDK 中 bin 目录下 jconsole.exe 中查看进程状态。
8、在 Java 中,任何一个对象都可以作为锁对象。
9、解决死锁的关键就在于破坏死锁的四个必要条件。
10、volatile 关键字一个功能是强制读取内存,保证内存可见性;另一个功能是禁止指令重排序。
11、wait() 和 notify() 需要一个统一的 Object 对象进行加锁,而且两个方法都必须建立在 synchronized 线程同步的基础上。