Java多线程

Java 多线程

多线程是如何执行的

并发和并行同时进行的

多线程的创建方式

  • 继承Thread类
  • 实现Runnable接口
  • 实现Callable接口
  • 使用线程池
  • 匿名类

继承Thread类

缺点:已经继承Thread类,无法再继承其他类,不利于功能的扩展

流程
alt text

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public static void main(String[] args) {
// 目的:认识线程
// 4. 创建一个线程对象
MyThread t1 = new MyThread();

// 5. 调用 start 方法,启动线程
t1.start();

}
// 1. 定义一个子类继承 Thread 类,成为一个线程类
class MyThread extends Thread{

// 2. 并重写 run 方法
@Override
public void run() {
// 3. 在run方法中写线程要干的活
}
}

注意事项

  1. 不要把主线程任务放到子线程之前,否则会先把主线程全部执行完

实现Runnable接口

流程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
package com.cslb.demo1create;

public class Test1 {
public static void main(String[] args) {
// 创建线程任务类对象代表一个线程任务
MyRunnable runnable = new MyRunnable();

//把线程任务对象交给一个线程对象来处理
Thread thread1 = new Thread(runnable);

thread1.start();

}
}
//可以使用匿名内部类来简化代码
class MyRunnable implements Runnable{

@Override
public void run() {

}
}

缺点
需要多创建一个Runnable 任务对象

实现Callable接口

上述的那两种方法都会有一个问题,假如线程执行完有数据要返回,它们的run方法无法返回数据,因为run方法的返回值只能是void。

而Callable接口重写的是call方法,call方法的返回值可以自己指定

实现流程:
利用 CollCollable接口FutureTask类 来实现

alt text

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
public class ThreadCallable{
public static void main(String[] args) throws ExecutionException, InterruptedException {
//创建Callable对象(表示多线程要执行的任务)
MyCallable myCallable=new MyCallable();

//创建FutureTask对象(作用管理多线程运行的结果)
FutureTask<Integer> ft = new FutureTask<>(myCallable);

//创建线程的对象
Thread thread = new Thread(ft);

//启动线程
thread.start();

//获取多线程结果
Integer res = ft.get();
System.out.println(res);

}
}
class MyCallable implements Callable<Integer> {
@Override
public Integer call() throws Exception {
return 10+20;
}
}

匿名类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
//方式一
Thread thread = new Thread(new Runnable() {
@Override
public void run() {
// 线程需要执行的任务代码
System.out.println("子线程开始启动....");
for (int i = 0; i < 30; i++) {
System.out.println("run i:" + i);
}
}
});
thread.start();

//方式二
new Thread(() -> {
System.out.println(Thread.currentThread().getName() + "\t上完自习,离开教室");
}, "MyThread").start();

线程常用方法

alt text

多线程协调运行

多线程协调运行的原则就是:当条件不满足时,线程进入等待状态;当条件满足时,线程被唤醒,继续执行任务。

线程等待和唤醒

wait() : 当前线程等待,直到被其他线程唤醒
notify() : 随机唤醒单个线程
notifyAll() : 唤醒所有进程

线程进入等待状态 : wait方法

  1. 必须在synchronized块中才能调用wait()方法

因为wait()方法调用时,会释放线程获得的锁,wait()方法返回时,线程又会重新试图获得锁。

我们在while()循环中调用wait(),而不是if语句:
因为线程被唤醒时,需要再次获取this锁。多个线程被唤醒后,只有一个线程能获取this锁,此刻,该线程执行queue.remove()可以获取到队列的元素,然而,剩下的线程如果获取this锁后执行queue.remove(),此刻队列可能已经没有任何元素了,所以,要始终在while循环中wait(),并且每次被唤醒后拿到this锁就必须再次判断:

1
2
3
4
5
6
7
8
9
public synchronized String getTask() {
while (queue.isEmpty()) {
// 释放this锁:
this.wait();
// 重新获取this锁
}
return queue.remove();
}

  1. 只能在锁对象上调用wait()方法

线程唤醒 :notifyAll()
如何让等待的线程被重新唤醒,然后从wait()方法返回?答案是在相同的锁对象上调用notify()方法

内部调用了this.notifyAll()而不是this.notify()

使用notifyAll()将唤醒所有当前正在this锁等待的线程,而notify()只会唤醒其中一个(具体哪个依赖操作系统,有一定的随机性)。这是因为可能有多个线程正在getTask()方法内部的wait()中等待,使用notifyAll()将一次性全部唤醒。通常来说,notifyAll()更安全。有些时候,如果我们的代码逻辑考虑不周,用notify()会导致只唤醒了一个线程,而其他线程可能永远等待下去醒不过来了。

中断线程

中断线程

  1. interrupt
    main线程通过调用t.interrupt()方法中断t线程,但是要注意,interrupt()方法仅仅向t线程发出了“中断请求”,至于t线程是否能立刻响应,要看具体代码。而t线程的while循环会检测isInterrupted()

  2. 设置标志位中断线程
    线程间共享变量需要使用volatile关键字标记,确保每个线程都能读取到更新后的变量值。

  3. 中断休眠中的线程,会提前结束睡眠

线程7大状态

alt text

  • NEW 新建状态
    尚未启动的线程,使用new语句创建的线程处于新建状态(new Thread),仅仅在堆上分配了内存

  • RUNNABLE 运行状态(包括就绪态和执行态)
    在Java虚拟机中执行的线程处于此状态

  • BLOCKED 阻塞状态
    当线程由于缺少响应的资源而导致程序无法继续执行,就会从运行状态进入到阻塞状态比 如:锁资源,IO资源

  • WAITING 等待状态
    当线程调用了wait方法就会进入到WAITING状态,该状态只有notify等操作才能唤醒线程进入下一个状态

  • TIMED_WAITING 睡眠状态
    如果线程执行了sleep(long)/join(long)/wait(long),会触发线程进入到Time_waiting状态,只有到达设定的时间,才会脱离阻塞状态

  • TERMINATED 终止状态
    已退出的线程处于此状态,该线程结束生命周期

线程安全问题

守护线程

Java程序入口就是由JVM启动main线程,main线程又可以启动其他线程。当所有线程都运行结束时,JVM退出,进程结束。

守护线程是指为其他线程服务的线程。在JVM中,所有非守护线程都执行完毕后,无论有没有守护线程,虚拟机都会自动退出。

因此,JVM退出时,不必关心守护线程是否已结束。

1
2
3
Thread t = new MyThread();
t.setDaemon(true);
t.start();

守护线程不能持有任何需要关闭的资源,例如打开文件等,因为虚拟机退出时,守护线程没有任何机会来关闭文件,这会导致数据丢失。

出让线程和插入线程

Thread.yield():出让线程
t1.join():插入线程

线程死锁

可重入锁

JVM允许同一个线程重复获取同一个锁,这种能被同一个线程反复获取的锁,就叫做可重入锁。

由于Java的线程锁是可重入锁,所以,获取锁的时候,不但要判断是否是第一次获取,还要记录这是第几次获取。每获取一次锁,记录+1,每退出synchronized块,记录-1,减到0的时候,才会真正释放锁。

一个线程可以获取一个锁后,再继续获取另一个锁

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public void add(int m) {
synchronized(lockA) { // 获得lockA的锁
this.value += m;
synchronized(lockB) { // 获得lockB的锁
this.another += m;
} // 释放lockB的锁
} // 释放lockA的锁
}

public void dec(int m) {
synchronized(lockB) { // 获得lockB的锁
this.another -= m;
synchronized(lockA) { // 获得lockA的锁
this.value -= m;
} // 释放lockA的锁
} // 释放lockB的锁
}

死锁发生后,没有任何机制能解除死锁,只能强制结束JVM进程。

避免死锁:线程获取锁的顺序要一致

1
2
3
4
5
6
7
8
9
//严格按照先获取lockA,再获取lockB的顺序,改写dec()方法如下:
public void dec(int m) {
synchronized(lockA) { // 获得lockA的锁
this.value -= m;
synchronized(lockB) { // 获得lockB的锁
this.another -= m;
} // 释放lockB的锁
} // 释放lockA的锁
}

线程同步

为了解决线程安全问题,就进行线程同步

  • 同步代码块
  • 同步方法
  • lock锁

同步代码块

原理:把访问共享资源的 核心代码 上锁,以此保证线程安全

线程同步的常见方案:
加锁: 每次只允许一个线程加锁,加锁后才能进入访问,访问完毕后自动解锁,然后其他线程才能加锁进来。

锁对象规范:

  • 建议使用共享资源作为锁对象,对于实例方法建议使用this作为锁对象
  • 对于静态方法建议使用字节码(类名.class)对象作为锁对象

锁住一个代码块:

1
2
3
4
5
6
7
8
9
public class SynchronizedBlockExample {
private Object lock = new Object();
private int count = 0;
public void increment() {
synchronized (lock) {
count++;
}
}
}

alt text

1
2
3
4
5
6
7
8
9
10
synchronized (this) {
if(this.money >= money){
System.out.println((name + "取钱成功,取出了" + money + "元钱"));
this.money -= money;
System.out.println((name + "取钱成功,账户还剩下" + this.money + "元钱"));
}
else{
System.out.println((name+"取钱失败,余额不足,当前账户余额为" + this.money));
}
}

同步方法

作用:把访问共享资源的 方法 进行上锁,以此保证线程安全

alt text

alt text

释放锁

以下操作会释放锁

  1. 当前线程的同步方法、同步代码块执行结束

  2. 当前线程在同步代码块、同步方法中遇到break、return

  3. 当前线程在同步代码块、同步方法中出现了未处理的Error或Exception,导致异常结束

  4. 当前线程当前线程在同步代码块、同步方法中执行了wait(),当前线程暂停,并释放锁

以下操作不会释放锁

  1. 线程执行同步方法、同步代码块时,程序调用Thread.sleep()、Thread.yield()方法暂停当前线程执行,不会释放锁

2.线程执行同步代码块时,其他线程调用了该线程的suspend()方法将该线程挂起,该线程不会释放锁(不推荐使用suspend()方法控制线程)

常用方法跟锁的关系
1.sleep会使当前线程睡眠指定时间,不释放锁
2.yield会使当前线程重回到可执行状态,等待cpu的调度,不释放锁
3.wait会使当前线程回到线程池中等待,释放锁,当被其他线程使用notify,notifyAll唤醒时进入可执行状态
4.当前线程调用 某线程.join()时会使当前线程等待某线程执行完毕再结束,底层调用了wait,释放锁

lock锁

Lock锁是一个接口,可以用实现类ReentrantLock来构建锁对象,Lock中提供了手动加锁和释放锁的方法

alt text

和synchronized不同的是,ReentrantLock可以尝试获取锁,因此更安全

1
2
3
4
5
6
7
8
//尝试获取锁,最多等待1秒。如果1秒后仍未获取到锁,tryLock()返回false,程序就可以做一些额外处理,而不是无限等待下去。
if (lock.tryLock(1, TimeUnit.SECONDS)) {
try {
...
} finally {
lock.unlock();
}
}

线程池

线程池是一个可以复用线程的技术

不使用线程池的问题:用户没发起一个请求,后台就需要创建一个新线程来处理。开销过大

线程池工作原理

下图工作线程类似于服务员,任务队列类似于饭店的客人。

alt text

创建线程池

  • 方式一:使用ExecutorService的实现类ThreadPoolExecutor 自创建一个线程池对象
  • 方式二:使用Executors(线程池的工具类)调用方法返回不同特点的线程池对象。

Executors

  1. 获取线程池对象
    ExecutorService pol = Executors.newCachedThreadPool(int nThreads);

因为ExecutorService只是接口,Java标准库提供的几个常用实现类有:

  • FixedThreadPool:线程数固定的线程池;
  • CachedThreadPool:线程数根据任务动态调整的线程池;
  • SingleThreadExecutor:仅单线程执行的线程池。
  1. 提交任务
    pol.submit(new MyRunnable());

  2. 销毁线程池
    pol.shutdown();

线程池在程序结束的时候要关闭。使用shutdown()方法关闭线程池的时候,它会等待正在执行的任务先完成,然后再关闭。shutdownNow()会立刻停止正在执行的任务,awaitTermination()则会等待指定的时间让线程池关闭。

ThreadPoolExecutor 线程对象参数

alt text

什么时候开始创建临时进程
新任务提交时,发现核心线程都在忙,任务队列也满了,并且还可以创建临时进程,此时才会创建临时进程

什么时候会拒绝新任务
核心线程和临时线程都在忙,任务队列也满了,新的任务过来的时候才会开始拒绝任务

任务拒绝策略
alt text

处理Runnable任务/Callable任务

ExecutorService常用方法

alt text

通过Executors 创建线程池

强烈不建议使用这种方法去创建线程池
alt text

通过工具类的静态方法返回不同特点的线程对象

alt text

线程池多大合适

  1. CPU密集型运算
    最大并行数 + 1

  2. I/O密集型运算

虚拟线程

Java 19引入的一种轻量级线程,它在很多其他语言中被称为协程、纤程、绿色线程、用户态线程


Java多线程
https://cs-lb.github.io/2024/10/17/Java/Java多线程/
作者
Liu Bo
发布于
2024年10月17日
许可协议