Java - 线程(基础版)
一、Thread 类创建线程的写法
1. 最基本的创建线程的写法
class MyThread extends Thread {
@Override
public void run() {
System.out.println("hello thread");
}
}
public class Test {
public static void main(String[] args) {
Thread t = new MyThread();
t.start();
}
}
2. 创建一个类 实现 Runnable 接口,再创建 Runnable 实例 传给Thread实例
:::info
通过 Runnable 来描述任务的内容,再把描述好的任务交给 Thread 实例。
:::
// 这里的 Runnable 就是在描述一个 “任务“
class MyRunnable implements Runnable {
@Override
public void run() {
System.out.println("Hello");
}
}
public class Test {
public static void main(String[] args) {
// 需要注意的是 这里 我们实例化是 Thread 类本身,
// 只不过构造方法里给指定了 MyRunnable 实例
Thread t = new Thread(new MyRunnable());
t.start();
}
}
3. 两种方法:使用匿名内部类
- 写法三:
public class Test3 {
public static void main(String[] args) {
Thread t = new Thread() {
@Override
public void run() {
System.out.println("hello Thread");
}
};
t.start();
}
}
- 写法四:
public class Test4 {
public static void main(String[] args) {
Thread t = new Thread(new Runnable() {
@Override
public void run() {
System.out.println("Hello Thread");
}
});
t.start();
}
}
4. 小结
通过上面的例子,我们知道了 Thread 方法 和 Runnable 方法。那么这两种方法,哪个更好呢?
通常认为 Runnable 方法更好!!
它能够做到让 线程 和 线程 执行的任务,更好的进行 解耦(解除耦合)。
我们写代码一般希望:高内聚(同一类功能的代码放在一起),低耦合(不同功能的模块之间不互相关联)。在我们使用 Runnable 方法来创建线程的时候,就把当前的线程要执行的任务与 整个线程的概念给分开了。
换句话说,Runnable 只是单纯的去描述一个任务,至于这个任务怎么去执行,它不关心,Runnable 只是关系里面的代码内容。
5. 写法5:使用 lambda表达式
:::info
使用 lambda 表达式 代替 Runnable
:::
public class Test5 {
public static void main(String[] args) {
// () 表示无参数的 run 方法
// -> 表示这是个 lambda 表达式
// {lambda 表达式里面的内容}
Thread t = new Thread(() -> {
System.out.println("Hello Thread");
});
t.start();
}
}
二、多线程的优势
1. 实践
:::tips
现在有两个整数,分别对整数进行自增 10 亿次。
分别使用 一个线程 和 两个线程来执行看看效果。
:::
程序:
package ThreadTest;
public class TestDemo7 {
public static final long count = 10_0000_0000;
public static void serial() {
// 记录程序自增开始的时间
long begin = System.currentTimeMillis();
long a = 0;
for (long i = 0; i < count; i++) {
a++;
}
long b = 0;
for (long i = 0; i < count; i++) {
b++;
}
// 记录程序自增的结束时间
long end = System.currentTimeMillis();
System.out.println("花费时间: " + (end - begin) + "ms");
}
public static void concurrency() throws InterruptedException {
long begin = System.currentTimeMillis();
Thread t1 = new Thread(() -> {
long a = 0;
for (long i = 0; i < count; i++) {
a++;
}
});
t1.start();
Thread t2 = new Thread(() -> {
long b = 0;
for (long i = 0; i < count; i++) {
b++;
}
});
t2.start();
// join的效果就是 等待线程结束
t1.join(); // 让 main 线程等待 t1 线程执行结束
t2.join(); // 让 main 线程等待 t2 线程执行结束
long end = System.currentTimeMillis();
System.out.println("花费时间: " + (end-begin) + "ms");
}
public static void main(String[] args) throws InterruptedException {
serial();
concurrency();
}
}
:::tips
可以看出来 多线程 比 单线程 的执行速度快了不少(当数据量更大的时候,效果更明显)。
但是,t1 和 t2 线程在底层执行的时候,是并发执行,还是并行执行,这是不确定的!!
多线程在真正并行执行的时候,效率才会有明显的提升!!
多线程在数据量庞大的时候,效率提升得才最显著!反而数据量较少的情况下,效率会有所降低。因为创建线程也是需要开销的!
:::
当然,多线程也不能乱用,还是要看使用的场景!!!
多线程特别适合 CPU 密集型程序:程序需要大量的计算,使用多线程就可以充分利用 CPU 的多核资源。
使用多线程来提升程序效率的前提是:这个任务是由CPU来完成的,并且我们需要大量的计算,让计算机的所有核心都工作起来。
三、Thread 类常见方法
1. Thread 类的常见构造方法
方法 | 说明 |
---|---|
Thread () | 创建线程对象 |
Thread (Runnable target) | 使用 Runnable 对象创建线程对象 |
Thread (String name) | 创建线程对象,并命名 |
Thread (Runnable target, String name) | 使用 Runnable 对象创建线程对象,并命名 |
【了解】Thread (ThreadGroup group, Runnable target) | 线程可以被用来分组管理,分好的组即为线程组,了解即可。 |
2. Thread(String name) 创建线程对象,并命名
:::info
此构造方法是给线程(Thread对象)起一个名字。
注意:名字不会影响线程的执行,但是取名字要贴合使用场景,方便后面程序员的调试。
因为程序员在调试的时候,可以借助一些工具看到每个线程以及名字,很容易在调试中对线程做出区分。
:::
我们可以使用 jconsole 来观察线程的名字(线程要在运行的时候):
在安装JDK的目录里可以看到这个工具,我们点击进入它:
四、Thread 的常见属性
属性 | 获取方法 |
---|---|
ID(身份标识) | getId() |
名称(线程的名字) | getName() |
状态(线程状态) | getState() |
线程优先级 | getPriority() |
是否有后台线程 | isDaemon() |
线程是否存活 | isAlive() |
线程是否被中断 | isInterrupted() |
1. 是否存在后台线程 isDaemon()
:::info
- 如果线程是后台线程,就不会影响进程的退出。
- 如果线程是前台线程,就会影响线程的退出。
前面程序中创建的 t1 和 t2 就是前台线程,即使 main 执行完,进程也不能退出,必须等 t1 和 t2 都执行完毕后,整个程序才能够退出。
若 t1 和 t2 是后台线程,当 main 执行完毕,整个程序就会直接退出,t1 和 t2 被强行终止。
:::
2. 线程是否存活 isAlive()
:::info
判断操作系统中对应的线程是否正常运行。
Thread t 对象的生命周期 和 内核中对应的线程的生命周期并不完全一致。
因为创建 t 对象之后,在调用 start() 之前,系统是没有对应线程的(还未创建)。
在 run 方法执行完毕后,系统中的线程就销毁了,但是 t 对象可能还在。
所以我们可以通过 isAlive 来判断当前系统线程的运行情况。
- 如果调用 start() 之后,run 执行完之前,isAlive 返回 true。
- 如果调用 start() 之前,或 run 执行完后,isAlive 返回 false。
:::
五、Thread 中一些重要方法
1. start 方法,启动线程
:::info
start 方法会创建线程,run 方法不会创建线程,只是执行里面的内容。
:::
交错输出结果:
使用 run:
顺序执行:
经典面试题:start 和 run 方法的区别
:::danger
start 操作就是在创建新的线程,run 就是一个普通方法,描述一个任务的内容。
:::
2. 中断一个线程
方法1:手动设置一个标志位(自己创建一个变量,boolean和int都行),来控制线程是否要执行。
结论:在其他线程中控制某个标志位,就能使线程提前终止。
因为此处多个线程共用一个虚拟地址空间!因此main线程修改的 isQuit 和 t 线程判定的 isQuit是同一个值。
但是,如果是在进程的情况下,在不同的虚拟地址的情况下,这种写法就会失效。
方法2:使用 Thread 中内置的一个标志位来进行判断(比第一种方法更好)
:::tips
第一种写法还存在一个问题:标志位的写法不够严谨。
这只能保证在当前的程序中有效,但在其他的程序中就失效了。
这时候就需要使用第二种方法:使用 Thread 中内置的一个标志位来进行判断:
- Thread.interrupted(); 【这是一个静态方法】
- Thread.currentThread().isInterrupted() 【这是一个实例方法,其中 currentThread能够获取当前线程的实例】
:::
package ThreadTest;
public class Test10 {
public static void main(String[] args) {
Thread t = new Thread(() -> {
// Thread.currentThread() 获取目前所在线程 t
// isInterrupted() 判断 线程 t 是否中断
// 中断返回 true,再根据 !取反,为 false,跳出循环,从而结束 run任务,致使线程t中断结束执行
// 执行中返回 false,,再根据 !取反,为 true,执行 run 的 输出语句。
while (!Thread.currentThread().isInterrupted()) {
System.out.println("hello Thread");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});
t.start();
try {
Thread.sleep(5000); // 在 main 线程中,5s之后,执行下面的代码t.interrupt()
} catch (InterruptedException e) {
e.printStackTrace();
}
// 在 main线程中,调用 interrupt 来中断 t 线程
t.interrupt();
}
}
:::tips
但是结果却不是我们想的那样:
期望:5s 后,线程 t 被中断
实际:5s 后,编译器抛出一个异常,线程 t 继续执行,线程 t 没有终止。
也就是说,t.interrupt() 不仅仅是针对 while 循环的条件(标记位)进行操作,它还可以触发一个异常。
:::
:::tips
Thread 内部包含了一个 boolean 类型的变量作为线程是否被中断的标记:
public boolean isInterrupted() :判断对象关联的线程的标志位是否设置,调用后不清除标志位【实际开发中,常用的写法】
:::
六、线程等待
:::info
有时,我们需要等待一个线程完成它的工作后,才能进行自己的下一步工作。这时我们需要一个方法明确等待线程的结束。
:::
:::tips
调用 join 的时候,哪个线程调用的 join,那个线程就会阻塞等待。
等到对应的线程执行完毕为止(对应线程的 run 执行完)。
但是这种等法是死等,如果run方法内是个死循环,则main线程会一直等待。
提供了另一种 join 的版本,如 t.join(1000),代表等待1s,如果1s后 t 线程还未结束,则退出来,执行 main 线程。
:::
七、获取当前线程引用
方法 | 说明 |
---|---|
public static Thread currentThread(); | 返回当前线程对象的引用(Thread实例的引用) |
:::tips
哪个线程调用这个 currentThread(),就获得哪个线程的实例。
由于 currentThread() 是获取当前实例,下面两个语句是等价的:
通过这个代码来看,通过 继承Thread 类的方式来创建线程(匿名内部类继承了Thread类),此时在 run 方法中,直接通过 this 来得到当前 Thread 实例。
:::
:::tips
但是!如果是 Runnable 的话,情况则完全不同
编译不会通过。
不能通过 this 来获取线程的名字。
因为此处的 this 不是指向 Thread 类,而是指向 Runnable,而 Runnable 只是单纯的任务,里面没有 name 属性的!!
只能 Thread.currentThread().getName() 来获取线程的名字。
:::
八、线程休眠
:::info
回顾:
进程是通过 PCB 来描述的。
进程是通过 双向链表 来组织的。
如果一个进程有多个线程,此时每个线程都有一个 PCB,更为准确的说:一个进程对应的就是 这一组 PCB。
PCB 上有一个字段 tgroupId ,这个 id 相当于进程的 id,同一个进程中的若干个线程的 tgroupId 是相同的。
那么 PCB - Process Control Block 进程控制块 和 线程 有什么关系?
其实在 Linux系统中,内核是不区分进程和线程的。
只是程序员在写应用程序的时候,弄出来的词。
实际上 Linux内核 只认 PCB!!
在内核里 Linux 把线程称为 轻量级进程。
:::
九、线程的状态
1. NEW:创建状态
2. TERMINATED:终止态(工作完成了)
:::tips
OS中的线程已经执行完毕,销毁了。
但是 Thread 对象还在,此时获取的状态就是 terminated。
:::
3. RUNNABLE:可工作的,又可分为正在工作和准备工作
:::info
RUNNABLE 状态就是 就绪态,处于这个状态的线程,处于就绪队列。
对 就绪状态 的线程,有两种情况:
- 正在被执行。
- 还没有执行,但是随时可以调度它。
如果代码中没有进行 sleep,也没有进行其他的可能导致线程阻塞的操作。代码大概率都是出于 RUNNABLE 状态。
:::
4. TIMED_WAITING:这几个都表示排队等着其他事情
:::tips
代码中调用了 sleep、join(超时),就会进入到 TIMED_WAITING。
意思就是:当前的线程在一定时间内是阻塞状态。
:::
5. BLOCKED:这几个都表示排队等着其他事情
:::info
当前线程在等待 锁,导致进入了 阻塞状态。
一般在我们使用 synchronized 来加锁的时候,可能会触发这种状态。
:::
6. WAITING:这几个都表示排队等着其他事情
:::info
当前线程在等待 唤醒,导致进入了 阻塞状态。
一般是在我们使用 wait 来等待唤醒的时候,可能会触发这种状态。
:::
7. 线程状态和状态转移的意义
十、线程安全问题 - 最重要的
1. 一个线程不安全的案例
:::tips
使用两个线程 t1,t2 ,对同一个变量count分别递增 50000 次,我们最后想打印的结果应为 100000 ,但是下面的代码却不如意。
:::
package ThreadTest;
class Count {
public int count; // count 变量为两个线程要自增的变量
public void increase() {
++count;
}
}
public class Test15 {
private static Count count = new Count();
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
for (int i = 0; i < 5_0000; i++) {
count.increase();
}
});
Thread t2 = new Thread(()-> {
for (int i = 0; i < 5_0000; i++) {
count.increase();
}
});
t1.start();
t2.start();
// 为了保证得到的count的结果,是两个线程执行完毕后的结果
// 我们需要使用 join 来等待 线程 执行结束
// 这样在 main 线程中,打印的 count 的 结果,才是两个线程对 count 的 自增最终结果
// 因为 三个线程(t1,t2,main)之间关系 为 并发关系。
// 如果不使用 join, main线程压根就不会等 t1 和 t2 自增完,直接输出count。
// 使用 join 之后,只有 t1 和 t2 线程都结束了之后,main线程才能结束。
t1.join(); // 先执行 t1.join,然后等待 t1 结束
t2.join(); // 与 t1.join同理,再等待 t2 结束。
System.out.println(count.count);
}
}
结果为随机性结果:
分析:
:::info
++ 操作不是原子的,里面包含了三个步骤:
- LOAD
- ADD
- SAVE
线程之间的调度顺序完全不可预期(抢占式执行),具体两个线程之间这三个指令按照啥样的顺序来执行也是不清楚的。
总结:
(a)抢占式执行(万恶之源)
(b)两个线程修改了同一个变量
(c)原子性
(d)内存可见性(与编译器优化相关):是指一个线程对共享变量值的修改,能够及时地被其他线程看到。
- 线程之间的共享变量存在 主内存(Main Memory)
- 每一个线程都有自己的 “工作内存” (Working Memory)
- 当线程要读取一个共享变量的时候,会先把变量从主内存拷贝到工作内存,再从工作内存读取数据。
- 当线程要修改一个共享变量的时候,也会先修改工作内存中的副本,再同步回主内存。
由于每个线程都有自己的工作内存,这些工作内存中的内容相当于同一个共享变量的 ”副本“ 。此时修改线程1的工作内存中的值,线程2的工作内存不一定会及时变化。
(e)指令重排序(也和编译器优化相关):编译器会自动调整执行指令的顺序,以达到提高执行效率的效果。调整的前提是,保证指令的最终结果是不变的。(如果当前的逻辑只是在单线程下运行,编译器判定顺序是否影响结果,就很容易。如果当前的逻辑可能在多线程下运行,编译器判定顺序是否影响结果,就可能出错)
:::
2. 如何解决线程安全问题
:::info
最普适的方法,就是通过 “原子性” 这样的切入点来解决问题。
:::
加锁 - synchronized
:::danger
synchronized 本质:把 “并发” 变成 “串行”。
(适当的牺牲下速度,但是换来的结果更加准确,有时候是很有必要的)
:::
在上面的自增代码中加入 synchronized 后,就可以实现互斥。
:::info
如果两个线程同时并发的尝试调用这个 synchronized 修饰的方法,此时一个线程会先执行这个方法,另外一个线程会等待,等到第一个线程方法执行完之后,第二个线程才会继续执行~
就相当于 “加锁” 和 “解锁”
进入 synchronized 修饰的方法,就相当于 加锁。
出了 synchronized 修饰的方法,就相当于 解锁。
(如果当前是已经加锁的状态,其他线程就无法执行这里的逻辑,就只能阻塞等待~)
:::
运行结果:
3. 解析 - synchronized 关键字 - 监视器锁 monitor lock
3.1 synchronized 的特性
(1)互斥
(2)刷新内存(解决内存可见性问题)
:::info
synchronized 的工作过程:
- 获得互斥锁
- 从主内存拷贝变量的最新副本到工作内存中
- 执行代码
- 将更改后的共享变量的值刷新到主内存中
- 释放互斥锁
所以,synchronized 可以解决内存可见性的问题。
:::
(3) 可重入
:::info
同一个线程针对同一个锁,连续加锁两次。
如果出现了死锁,就是不可重入,如果不会死锁,就是可重入。
:::
3.2 synchronized 的使用方法
(a) 直接修饰普通方法
(b)修饰代码块
(c)修饰静态方法
:::info
相对于针对当前类的类对象进行加锁。
如,Count.class(反射)反射
:::
:::info
“静态方法” 更为严谨的叫做 “类方法” 【通过类来调用】
“普通方法” 应叫做 “实例方法” 【通过 new 实例化才能访问】
那么,既然静态方法没有 this 的,通过 synchronized 修饰的一个静态方法,就是在针对 类对象 加锁。
由于类对象是单例的,两个线程并发调用该方法一定会触发锁竞争。
:::
:::warning
小扩展:什么是 类对象?
类对象就是我们在运行程序的时候的 **.class **文件被加载到 JVM 内存中的模样。
一个对象里包含的属性,属性名,属性类型,方法,方法名,参数列表等,这些信息来自于 **.class **文件(.java 被编译生成的二进制字节码)。
类名 **.class **就得到了这个类对象。
特点:每个类的类对象都是单例的。
:::
4. Java 标准库中的线程安全类
:::info
Java 标准库中很多都是线程不安全的,这些类可能会涉及到多线程修改共享数据,又没有任何加锁措施。
- ArrayList
- LinkedList
- HashMap
- TreeMap
- HashSet
- TreeSet
- StringBuilder
但是还有一些是线程安全的,使用了一些锁机制来控制。
- Vector(不推荐使用)
- HashTabel(不推荐使用)
- ConcurrentHashMap
- StringBuffer
- String
前四个类是线程安全的,是因为在一些关键方法上都有 synchronized 修饰/加锁。有了这个操作,就可以保证在多线程环境下,修改同一个对象而没什么大问题。
Vector对标的是 ArrayList。
HashTable 和 ConcurrentHashMap 对标的是 HahsMap
【更推荐使用 ConcurrentHashMap,因为 HashTable 存在一些性能上的问题】
StringBuffer 和 StringBuilder 功能上都一样的。只是 StringBuffer 加上了 synchronized,适用于多线程,而StringBuilder 没有synchronized,适用于单线程。
String虽然也是线程安全的,但是与前四个类不同,它没有 synchronized。
String 是 不可变对象,因此无法在多个线程中同时改动同一个String。
哪怕在单线程中也无法更改 String【要想改变只能创建一个新的,来代替旧的】
:::
:::warning
拓展:
不可变对象 和 常量/ final 之间没有必然联系。
不可变对象之所以不可变,是因为对象中没有提供修饰属性的操作。
:::
5. volatile 关键字
volatile 能保证内存可见性
:::info
volatile 修饰的变量,能够保证 “内存可见性”。(即,禁止编译器优化,保证内存可见性。)
:::
例子:通过外部手动输入更改flag的值,来使某个线程跳出循环:
package ThreadTest;
import java.util.Scanner;
public class Test16 {
static class Counter {
public int flag = 0;
}
public static void main(String[] args) {
Counter counter = new Counter();
Thread t1 = new Thread() {
@Override
public void run() {
while (counter.flag == 0) {
// 假设要执行一些操作
}
System.out.println("循环结束");
}
};
t1.start();
Thread t2 = new Thread() {
@Override
public void run() {
//让用书输入一个整数来替换 counter.flag 的值
Scanner scanner = new Scanner(System.in);
System.out.println("请输入一个整数:");
counter.flag = scanner.nextInt();
}
};
t2.start();
}
}
但是,当我们输入1时,程序并没有反应:
分析:
:::info
:::
如何解决:
:::info
在变量前加 volatile 关键字:
运行结果:
volatile 的用法比较单一,只能修饰一个具体的属性。
此时代码中针对这个属性的读写操作就一定会涉及到内存操作了。
:::
volatile 不保证原子性
synchronized 也能保证内存可见性
:::info
synchronized 既能保证原子性,也能保证内存可见性。
对上面的代码进行调整:
- 去掉 flag 的 volatile
- 给 t1 的循环内部加上 synchronized,并借助 counter 对象加锁
:::
package ThreadTest;
import java.util.Scanner;
public class Test16 {
static class Counter {
public int flag = 0;
}
public static void main(String[] args) {
Counter counter = new Counter();
Thread t1 = new Thread() {
@Override
public void run() {
while (true) {
synchronized (counter) {
//加上 synchronized 之后,此时针对 flag 的操作,也会读写内存了。
if (counter.flag != 0) {
break;
}
}
}
System.out.println("循环结束");
}
};
t1.start();
Thread t2 = new Thread() {
@Override
public void run() {
//让用户输入一个整数来替换 counter.flag 的值
Scanner scanner = new Scanner(System.in);
System.out.println("请输入一个整数:");
counter.flag = scanner.nextInt();
}
};
t2.start();
}
}
运行结果:
JMM 内存模型
:::info
volatile 这里涉及到一个重要的知识点,JMM(java memory model)内存模型。
:::
volatile 和 synchronized 的区别 - 面试会问到
:::info
synchronized 能够保证原子性, volatile 保证的是内存可见性
synchronized 既能保证原子性, 也能保证内存可见性.
:::
6. wait 和 notify
:::info
由于线程之间是抢占式执行的,因此线程之间执行的先后顺序难以预知。
但是实际开发中有时候我们希望合理的协调多个线程之间的执行顺序。
完成这个协调工作,主要涉及到三个算法:
- wait() / wait(long timeout):让当前线程进入等待状态。
- notify() / notifyAll():唤醒在当前对象上等待的线程。
注意:wait, notify, notifyAll 都是 Object 类的方法。
:::
6.1 wait() 方法
:::info
wait 做了三件事:
- 让当前线程阻塞等待。(让这个线程的 PCB 从就绪队列拿到等待队列中,并准备接受通知)(需要原子性)
- 释放当前锁。(要想使用 wait / notify ,必须搭配 synchronized,需要先获取到锁,才有资格谈 wait)(需要原子性)
- 满足一定的条件被唤醒时,重新尝试获取到这个锁。
:::
:::info
wait 结束等待的条件:
- 其他线程调用该对象的 notify 方法。
- wait 等待时间超时(wait 方法提供一个带有 timeout 参数的版本,来指定等待时间)
- 其他线程调用该等待线程的 interrupted 方法,导致 wait 抛出
InterruptedException
异常。
:::
代码示例:
package ThreadTest;
public class Test17 {
public static void main(String[] args) throws InterruptedException {
Object object = new Object();
synchronized (object) {
System.out.println("wait 之前");
object.wait();
System.out.println("wait 之后");
}
}
}
运行结果,可以发现,执行到 object.wait() 之后就一直等待下去,需要使用另一个方法 notify() 来唤醒:
我们可以调用 JDK,bin目录下的 jconsole.exe,可以观察到:
但是如果我们的 wait 没有放在 synchronized 下,就会抛出异常:
6.2 notify() 方法
:::info
关于 notify 的使用:
- 也是要放到 synchronized 中使用
- notify 操作时一次唤醒一个线程,如果有多个线程都在等待中,调用 notify 相当于随机唤醒一个,其他线程保持原状。
- 调用 notify 这是通知对方被唤醒,但是调用 notify 本身的线程并不是立即释放锁,而是要等待当前的 synchronized 代码块执行完才能释放锁。(notify 本身不会释放锁)
:::
代码示例:
package ThreadTest;
public class Test18 {
static class WaitTask implements Runnable {
private Object locker = null;
public WaitTask(Object l) {
this.locker = l;
}
@Override
public void run() {
synchronized (locker) {
// 进行 wait 的线程
System.out.println("wait 开始");
try {
// 直接调用 wait,相当于 this.wait(),也就是针对 WaitTask 对象来进行等待
// 但是我们一会在 NotifyTask 中要求得针对同一个对象来进行通知,然而,在 NotifyTask
// 并没有那么容易拿到 WaitTask 实例
locker.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("wait 结束");
}
}
}
static class NotifyTask implements Runnable {
private Object locker = null;
public NotifyTask(Object l) {
this.locker = l;
}
@Override
public void run() {
// 进行 notify 的线程
synchronized (locker) {
System.out.println("notify 开始");
locker.notify();
System.out.println("notify 结束");
}
}
}
public static void main(String[] args) throws InterruptedException {
// 为了解决刚才的问题,专门创建一个对象,去负责进行加锁/通知操作
Object locker = new Object();
Thread t1 = new Thread(new WaitTask(locker));
Thread t2 = new Thread(new NotifyTask(locker));
t1.start();
Thread.sleep(3000);
t2.start();
}
}
但是如果有多个线程执行 wait 操作的话,一个 notify 执行只能唤醒一个线程:
如果使用 notifyAll() 的话,就可以全部唤醒:
虽然是同时唤醒3个线程,但是这3个线程需要竞争锁,所以并不是同时执行,而仍然是有先有后的执行。
十一、多线程案例(里面的代码要写熟练)
设计模式
设计模式通俗点说就是 “套路”,在围棋界也可以叫做 “棋谱”。即为固定套路。
至少在校招中,有两种设计模式经常提问:
- 单例模式
- 工厂模式
1. 单例模式
:::info
某个类只应该有唯一实例,就应该使用单例模式。
使用单例模式就是限制了这个类只能有唯一实例。
单例模式又分为两类:
- 饿汉模式:static 在类加载阶段就把实例创建出来。
- 懒汉模式:通过 getInstance 方法来获取到实例。首次调用该方法的时候,才能正在创建实例(懒加载 / 延时加载)
:::
1.1 饿汉模式
代码示例:
package ThreadTest;
public class Test19 {
// 饿汉模式
static class Singleton {
// 将构造方法设为 private,防止在类外调用构造方法,也就禁止了调用者在其他地方创建实例的机会
private Singleton() {
}
private static Singleton instance = new Singleton();
public static Singleton getInstance() {
return instance;
}
}
}
1.2 懒汉模式
代码示例:
package ThreadTest;
public class Test20 {
static class Singleton {
private Singleton() {}
private static Singleton instance = null;
public static Singleton getInstance() {
if (instance == null) {
instance = new Singleton();
}
return instance;
}
}
public static void main(String[] args) {
// 通过这个方法来获取实例,就能保证只有唯一实例
Singleton instance = Singleton.getInstance();
}
}
对于懒汉模式,线程是不安全的!
分析:
如何解决 懒汉模式 的线程不安全的问题呢?
加锁!!
下面是错误的示范:
下面是科学写法(保证读和写的原子性),但是也会引入新的问题:
改进写法:
首次调用的时候,就加锁。
后续调用的时候,不加锁。
但是上面的代码还存在这内存可见性的问题:
此时我们需要给 instance 变量加上 volatile:
终极改进后的代码(最后之作):
总结:
- 要在 intance 变量上加 volatile 关键字,保证内存可见性。
- 保证 判断instance是否为空 和 new 的写操作 的原子性。
- 在外层加一个 instance 是否为空的判断语句,防止后续有实例的线程进行加锁操作,从而降低程序的运行速度。
2. 阻塞队列
什么是阻塞队列
:::info
阻塞队列是一种特殊的队列,也遵守 “先进先出” 的原则。
阻塞队列是一种线程安全的数据结构,并且具有以下特性:
- 当队列满的时候,继续入队列就会阻塞,直到有其他线程从队列中取走元素。
- 当队列空的时候,继续出队列也会阻塞,直到有其他线程往队列中插入元素。
阻塞队列的一个典型应用场景就是 “生产者消费者模型”。这是一种非常经典的开发模型。
:::
生产者消费者模型
:::info
生产者消费者模式就是通过一个容器来解决生产者和消费者的强耦合问题。
生产者和消费者彼此之间不直接通讯,而通过阻塞队列来进行通讯,所以生产者生产完数据之后不用等待消费者处理,直接扔给阻塞队列,消费者不找生产者要数据,而是直接从阻塞队列里取。
- 阻塞队列相当于一个缓冲区,平衡了生产者和消费者的处理能力。
比如在 "秒杀" 场景下, 服务器同一时刻可能会收到大量的支付请求. 如果直接处理这些支付请求, 服务器可能扛不住(每个支付请求的处理都需要比较复杂的流程). 这个时候就可以把这些请求都放 到一个阻塞队列中, 然后再由消费者线程慢慢的来处理每个支付请求
- 阻塞队列也能使生产者和消费者之间 解耦。
比如过年一家人一起包饺子. 一般都是有明确分工, 比如一个人负责擀饺子皮, 其他人负责包. 擀饺 子皮的人就是 "生产者", 包饺子的人就是 "消费者". 擀饺子皮的人不关心包饺子的人是谁(能包就行, 无论是手工包, 借助工具, 还是机器包), 包饺子的人 也不关心擀饺子皮的人是谁(有饺子皮就行, 无论是用擀面杖擀的, 还是拿罐头瓶擀, 还是直接从超市买的)。
:::
实现阻塞队列
package ThreadTest;
public class Test23 {
static class BlockingQueue {
// 1000 就相当于队列的最大容量了,此处暂时不考虑扩容的问题
private int[] items = new int[1000];
private int head = 0;
private int tail = 0;
private int size = 0;
// put 用来入队列
public void put(int item) throws InterruptedException {
synchronized (this) {
// 入队列,就把新的元素给放到 tail 位置上
// 此处的条件最好写成 while,而不是 if
// 如果是有多个线程阻塞等待的时候,万一同时唤醒了多个线程,
// 就有可能出现:第一个线程放入元素之后(队列满了),第二个线程继续放入元素,队列已经满了。
// 虽然当前 take 代码中使用的是 notify,一次只唤醒一个等待的线程,用 if 也不算错,
// 但是,使用 while 更稳健些,
// 使用 while 的意思就是,保证 wait 被唤醒的时候能够再确认一次队列确实不满。
while (size == items.length) {
// 队列已经满了
// 对于阻塞队列来说就要阻塞
wait();
}
items[tail] = item;
tail++;
// 如果到达末尾,就回到起始位置
if (tail >= items.length) {
tail = 0;
}
size++;
notify();
}
}
// take 用来出队列
public int take() throws InterruptedException {
int ret = 0;
synchronized (this) {
while (size == 0) {
// 对于阻塞队列来书,如果队列为空,再尝试读取元素,就要阻塞
wait();
}
ret = items[head];
head++;
if (head >= items.length) {
head = 0;
}
size--;
// 此处的 notify 用来唤醒 put 中的 wait
notify();
}
return ret;
}
}
public static void main(String[] args) throws InterruptedException {
BlockingQueue queue = new BlockingQueue();
// 消费者线程
Thread customer = new Thread() {
@Override
public void run() {
while (true) {
try {
int elem = queue.take();
System.out.println("消费元素:" + elem);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
};
customer.start();
// 生产者线程
Thread producer = new Thread() {
@Override
public void run() {
while (true) {
for (int i = 1; i < 10000; i++) {
System.out.println("生产元素:" + i);
try {
queue.put(i);
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
};
producer.start();
customer.join();
producer.join();
}
}
- 关于加锁方式:
- 对整个方法进行加锁,如
- 对代码块进行加锁,上述代码
- 创建专门的锁对象进行加锁,如
- 小结:
拓展:
如果这里有三个线程都是使用同一个 锁对象,notify 是不可能实现精准唤醒 指定 wait 的。
notify 只能唤醒随机的一个等待线程,不能做到精准。
如果想要精准,就必须使用不同的 锁对象:
- 想精准唤醒 t1,就必须专门为它创建一个锁对象 locker1,让 t1 调用 locker1.wait。再对其进行 locker1.notify 才能唤醒。
3. 定时器
什么是定时器
定时器是软件开发中的一个重要的组件,类似于一个 “闹钟”,达到一个设定的时间之后,就执行某个指定好的代码。
定时器是一种实际开发中非常常用的组件。
比如网络通信中,如果对方 500 ms内没有返回数据,则断开连接尝试重连。
比如一个 Map,希望里面的某个 key 在 3s 之后自动删除,等等。
标准库中的定时器
- 标准库中提供了一个 Timer 类,Timer 类的核心方法为
schedule
。 schedule
包含两个参数,第一个参数指定即将要执行的任务代码,第二个参数指定多长时间之后执行(单位为毫秒)
package ThreadTest;
import java.util.Timer;
import java.util.TimerTask;
public class Test24 {
public static void main(String[] args) {
Timer timer = new Timer();
System.out.println("开始执行代码!");
timer.schedule(new TimerTask() {
@Override
public void run() {
System.out.println("触发定时器!");
}
},3000);
}
}
实现定时器
:::info
一个定时器面是可以安排很多任务的~
这些任务就会按照时间,谁先到了时间,就先执行谁~
描述任务:可以直接使用 Runnable 来描述这个任务。
组织任务:需要一个数据结构,把很多的任务给放到一起。此处的需求是需要能够在一大堆任务中,找到最先到点的任务(优先队列,为了线程安全,此处最好使用带阻塞功能的优先队列)
提供一个 schedule
方法,这个方法就是往阻塞队列中插入元素就行了~
还需要让 timer 内部有一个线程,这个线程一直去扫描队首元素,看看队首元素是不是已经到点了。如果到点了,就执行这个任务!!如果没到点,就把这个队首元素塞回队列中,继续扫描....
:::
1. 描述任务
class Task {
// command 表示这个任务是啥
private Runnable command;
// time 表示这个任务啥时候到时间
// 这里的 time 使用 ms 级的时间戳来表示
private long time;
// 约定参数 time 是一个时间差(类似于 3000)
// 希望 this.time 来保存一个绝对的时间(毫秒级时间戳)
public Task(Runnable command,long time) {
this.command = command;
this.time = System.currentTimeMillis() + time;
}
public void run() {
command.run();
}
}
2. 组织任务

3. 执行到点的任务
需要优先执行时间最靠前的任务。就跟排队一样,先排队的人先处理。
但是,当前的我们无法判断这个时间,所以我们需要用一个线程去不断的检查当前优先队列的首元素,看看当前最靠前的这个任务是不是到时间去执行了。
通过 自己构造的 Timer 计时器类的构造方法。
创建一个线程,帮助我们来进行一个检查。
4. 出现的问题1:
但是上述代码还有个问题:在扫描线程里面,它这个扫描的速度非常快,如果当前的时间与任务执行的时间点相差过大(比如,现在7:00,但是8:00才执行任务),那么中间的这段时间,扫描线程就会出现忙等的状态。(会一直浪费CPU的资源)
为了解决此问题,我们需要让线程等待:
- 先定义个锁对象
- 对 wait 进行加锁
- 在每次插入任务的时候,尝试唤醒等待的线程
5. 出现的问题2:
当我们运行程序时,会出现下面的情况:
这就需要我们为 Task 类制定一个比较规则:
解决问题后,看下代码运行效果:
6. 总程序
package ThreadTest;
import java.sql.Time;
import java.util.concurrent.PriorityBlockingQueue;
public class Test25 {
static class Task implements Comparable<Task>{
// command 表示这个任务是啥
private Runnable command;
// time 表示这个任务啥时候到时间
// 这里的 time 使用 ms 级的时间戳来表示
private long time;
// 约定参数 time 是一个时间差(类似于 3000)
// 希望 this.time 来保存一个绝对的时间(毫秒级时间戳)
public Task(Runnable command,long time) {
this.command = command;
this.time = System.currentTimeMillis() + time;
}
public void run() {
command.run();
}
@Override
public int compareTo(Task o) {
return (int)(o.time - this.time);
}
}
static class Timer {
// 使用这个带优先级版本的阻塞队列来组织这些任务
private PriorityBlockingQueue<Task> queue = new PriorityBlockingQueue<>();
// 使用这个 locker 对象来解决忙等问题
private Object locker = new Object();
public void schedule(Runnable command,long delay) {
Task task = new Task(command,delay);
queue.put(task);
// 每次插入新的任务都要唤醒扫描线程,让扫描线程能够重新计算 wait 的时间,保证新的任务也不会错过
synchronized (locker) {
locker.notify();
}
}
public Timer() {
// 创建一个扫描线程,这个扫描线程就来判定当前的任务,看看是不是已经到时间能执行了
Thread t = new Thread() {
@Override
public void run() {
while (true) {
try {
Task task = queue.take();
long curTime = System.currentTimeMillis();
if (task.time > curTime) {
// 时间还没到,暂时不执行
// 前面的 take 操作会把队首元素给删除掉
// 但是此时队首元素的任务还没有执行呢,需要重新插回队列
queue.put(task);
// 根据时间差,来进行一个等待
synchronized (locker) {
locker.wait(task.time - curTime);
}
} else {
// 时间到了
task.run();
}
} catch (InterruptedException e) {
e.printStackTrace();
// 如果出现了interrupt方法,就能够退出线程
break;
}
}
}
};
t.start();
}
}
public static void main(String[] args) {
Timer timer = new Timer();
timer.schedule(new Runnable() {
@Override
public void run() {
System.out.println("hello");
}
}, 3000);
System.out.println("main");
}
}
4. 线程池
4.1 经典面试题:谈谈线程池是干啥的?
本来多线程就是解决并发编程的方案,但是进程有点太重量了(创建和销毁,开销比较大)
因此引入了线程,线程比进程要轻量很多。
即使如此,如果某些场景中,需要频繁的创建销毁线程,此时,线程的创建销毁的开销,也就无法忽视了。
:::info
为了解决这样的问题:
- 引入协程(协程可以理解成是一个轻量级线程,此处暂时不考虑)
- 引入线程池:
使用线程的时候,不是说用的时候才创建,而是提前创建好,放到一个 “池子” 里。(相当于字符串常量池)
当我们需要使用线程的时候,直接从池子里取一个线程过来。
当我们不需要这个线程的时候,就把这个线程还回池子中。
(此时我们的操作就会比创建销毁线程效率更高)
如果是真的创建/销毁线程,涉及到用户态和内核态的切换。(切换到内核态,然后创建出对应的 PCB 来,低效的)
如果不是真的创建销毁线程,只是放到池子里,就相当于全在用户态进行操作(高效的)
:::
4.2 Java 标准库中的线程池
Java 标准库中,线程池 对应的类 叫 ThreadPoolExecutor。
我们可以通过 Java文档 来查看 ThreadPoolExecutor:
Executors 创建线程池的几种方式
标准库中还提供了简化版本的线程池:Executors
Executors:本质是针对 ThreadPoolExecutor 进行了封装,提供了一些默认参数。
- newFixedThreadPool: 创建固定线程数的线程池。
- newCachedThreadPool: 创建线程数目动态增长的线程池.
- newSingleThreadExecutor:创建只包含单个线程的线程池
- newScheduledThreadPool:设定延迟时间后执行命令,或者定期执行命令,是进阶版的 TImer
下面是 Executors 的使用:
下面使用 newFixedThreadPool 方法来实现一个任务:
4.3 模拟实现一个线程池 - 基于 Executors
- 核心操作为 submit,将任务加入线程池中。
- 使用 Worker 类描述一个工作线程,使用 Runnable 描述一个任务。
- 使用一个 BlockingQueue 组织所有的任务。
- 每个 worker 线程要做的事:不停的从 BlockingQueue 中取任务并执行。
- 指定以下线程池中的最大线程数 maxWorkerCount ,当当前线程数超过这个最大值时,就不再新增线程了。
package ThreadTest;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.LinkedBlockingQueue;
public class Test27 {
static class Worker extends Thread{
private BlockingQueue<Runnable> queue = null;
public Worker(BlockingQueue<Runnable> queue) {
this.queue = queue;
}
@Override
public void run() {
// 工作线程具体的逻辑
// 需要从阻塞队列中取任务
while (true) {
try {
Runnable command = queue.take();
// 通过 run 来执行这个具体的任务
command.run();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
static class MyThreadPool {
// 包含一个阻塞队列,用来组织任务
private BlockingQueue<Runnable> queue = new LinkedBlockingQueue<>();
private static final int maxWorkerCount = 10;
// 这个list用来存放当前的工作线程
private List<Thread> workers = new ArrayList<>();
// 通过这个方法,把任务加入到线程池中。
// submit 不光可以把任务放到阻塞队列,同时也可以负责创建线程。
public void submit(Runnable command) throws InterruptedException {
if (workers.size() < maxWorkerCount) {
// 如果当前工作线程的数量不足线程数目上限,就创建出新的线程
// 工作线程专门搞一个类来完成
// Worker 内部要能够取到队列的内容,就需要把这个队列实例通过 Worker 的构造方法传过去。
Worker worker = new Worker(queue);
worker.start();
workers.add(worker);
}
// 如果工作线程的数量超过上限,则只添加任务,不创建新的线程了
queue.put(command);
}
}
public static void main(String[] args) throws InterruptedException {
MyThreadPool myThreadPool = new MyThreadPool();
// 加入10个任务
for (int i = 0; i < 10; i++) {
myThreadPool.submit(new Runnable() {
@Override
public void run() {
System.out.println("hello");
}
});
}
}
}
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· TypeScript + Deepseek 打造卜卦网站:技术与玄学的结合
· Manus的开源复刻OpenManus初探
· AI 智能体引爆开源社区「GitHub 热点速览」
· 三行代码完成国际化适配,妙~啊~
· .NET Core 中如何实现缓存的预热?