前言
大家好!今天我们学习java的多线程。多线程是Java后端的核心难点,也是面试的"必考点"。所以在背诵面试题之前要先了解多线程的基本内容。
我把多线程的核心知识点、实战用法和踩坑点整理成了这篇文章,不仅讲清底层逻辑,还附了可直接运行的代码示例,新手跟着敲一遍就能掌握,面试时也能从容应对。
一、认识多线程
1.1 什么是多线程?
在讲技术之前,先理解核心概念:
- 进程:程序的一次运行实例(比如运行的软件),是操作系统分配资源的基本单位;
- 线程:进程内的执行单元(比如main方法就是一个线程),是CPU调度的基本单位;
- 多线程:一个进程内同时运行多个线程,实现 "并发执行",提升程序效率。
1.2 为什么要用多线程?
- 提升效率:单线程处理任务是"串行"(做完一个再做下一个),多线程"并行"处理,减少等待时间;
- 资源利用率:CPU在等待IO(比如文件读写、网络请求)时,可切换到其他线程工作,避免资源闲置;
- 业务场景:必须用多线程的场景(比如定时任务、异步通知、高并发接口)。
1.3 多线程的核心问题
多线程的优势背后,也带来了核心痛点:线程安全(多个线程操作共享数据时,可能出现数据错乱)。这也是本文后续重点要讲的内容。
二、线程的核心操作:创建、启动、状态
2.1 线程的创建方式
Java 创建线程有 3 种主流方式,每种都有适用场景,下面附完整代码示例:
2.1.1 继承 Thread 类(基础版)
- 定义一个子类MyThread继承线程类java.lang.Thread,重写run()方法
- 创建MyThread类的对象
- 调用线程对象的start()方法启动线程(启动后还是执行run方法的)
代码
Java
//1、定义一个子类MyThread继承线程类java.lang.Thread,重写run()方法
class MyThread extends Thread{
@Override
public void run() {
System.out.println("MyThread线程运行了");
}
}
public static void main(String[] args) {
//2、创建MyThread类的对象
MyThread thread = new MyThread();
//3、调用线程对象的start方法启动线程(启动后还是执行run方法的)
thread.start();//注意1:这里不可以手动调用myThread.run(),否则就不会创建线程运行就是一个普通对象方法运行
//注意2:件主线程的核心代码放到子线程下面,这样多线程一起运行
System.out.println("main线程运行了");
}
优缺点
- 优点:编码简单
- 缺点:线程类已经继承Thread,无法继承其他类,不利于功能的扩展。
创建线程的注意事项
1、启动线程必须是调用start方法,不是调用run方法。
- 直接调用run方法会当成普通方法执行,此时相当于还是单线程执行。
- 只有调用start方法才是启动一个新的线程执行。
2、不要把主线程任务放在启动子线程之前。
- 这样主线程一直是先跑完的,相当于是一个单线程的效果了。
2.1.2 方式2:实现Runnable接口(推荐)
- 定义一个线程任务类MyRunnable实现Runnable接口,重写run()方法
- 创建MyRunnable任务对象
- 把MyRunnable任务对象交给Thread处理。
- 调用线程对象的start()方法启动线程
代码
Java
public static void main(String[] args) {
//2、创建MyRunnable任务对象
MyRunnable runnable = new MyRunnable();
//3、把MyRunnable任务对象交给Thread处理。
Thread thread = new Thread(runnable);
//4、调用线程对象的start方法启动线程
thread.start();
//匿名内部类实现
new Thread(new Runnable() {
@Override
public void run() {
System.out.println("Runnable匿名内部类线程执行了");
}
}).start();
//lambda表达式
new Thread(() -> System.out.println("Runnable的lambda表达式线程执行了")).start();
System.out.println("main线程执行了");
}
}
//1、定义一个线程任务类MyRunnable实现Runnable接口,重写run()方法
class MyRunnable extends Thread{
@Override
public void run() {
System.out.println("MyRunnable线程执行了");
}
}
优缺点
- 优点:任务类只是实现接口,可以继续继承其他类、实现其他接口,扩展性强。
- 缺点:需要多一个Runnable对象。
2.1.3 方式三:实现Callable接口
创建任务对象
- 定义一个类实现Callable接口,重写call方法,封装要做的事情,和要返回的数据。
- 把Callable类型的对象封装成FutureTask(线程任务对象)。
- 把线程任务对象交给Thread对象。
- 调用Thread对象的start方法启动线程。
- 线程执行完毕后、通过FutureTask对象的的get方法去获取线程任务执行的结果。
Java
public static void main(String[] args) throws ExecutionException, InterruptedException {
// 2、创建Callable接口实现类对象
MyCallable callable = new MyCallable(1L,1000000000L);
//3、创建任务对象,把Callable类型的对象封装成FutureTask(线程任务对象)。
FutureTask<Long> futureTask = new FutureTask<>(callable);
//4、把线程任务对象交给Thread对象。
Thread thread = new Thread(futureTask);
//5、调用Thread对象的start方法启动线程。
thread.start();
System.out.println("main线程进行核心逻辑处理。。。");
//6、获取线程执行完毕后的结果
Long total = futureTask.get();
System.out.println("所有任务完成。。。计算结果:"+total);
}
}
//1、定义一个类实现Callable接口,重写call方法,封装要做的事情,和要返回的数据。
class MyCallable implements Callable<Long> {
private Long start;
private Long end;
public MyCallable() {}
public MyCallable(Long start, Long end) {
this.start = start;
this.end = end;
}
//任务处理方法:从指定开始值进行累加,累加到结束值,最后返回
@Override
public Long call() throws Exception {
Long total = 0L;
System.out.printf("callable线程核心逻辑从%d到%d累加计算。。。%n",start,end);
for (Long i = start; i < end; i++) {
total += i;
}
return total;
}
}
优缺点
- 优点:线程任务类只是实现接口,可以继续继承类和实现接口,扩展性强;可以在线程执行完毕后去获取线程执行的结果。
- 缺点:编码复杂一点。
总结
| 方式 | 优点 | 缺点 |
|---|---|---|
| 继承Thread类 | 优点:编码简单,可以直接使用Thread类中的方法 | 扩展性较差,不能再继承其他的类,不能返回线程执行的结果 |
| 实现Runnable接口 | 扩展性强,实现该接口的同时还可以继承其他的类。 | 编程相对复杂,不能返回线程执行的结果 |
| 实现Callable接口 | 扩展性强,实现该接口的同时还可以继承其他的类。可以得到线程执行的结果 | 编程相对复杂 |
2.2 线程的生命周期(6 种状态,面试必背)
线程从创建到销毁,会经历 6 种状态
- 新建状态:创建线程对象但未调用
start(); - 就绪状态:调用
start()后,等待 CPU 调度(不是立即执行); - 运行状态:CPU 调度后执行
run()方法; - 阻塞状态:等待锁(比如 synchronized)释放;
- 等待状态:调用
wait()/join()等方法,需被唤醒; - 超时等待状态:调用
sleep(ms)/wait(ms)等方法,超时自动唤醒; - 终止状态:
run()执行完成或异常终止。
三、线程安全问题:原因 + 解决方案(核心重点)
3.1 什么是线程安全问题?
多个线程同时操作共享数据时,会导致数据错乱。
举个例子
如果小明和小红有一个共同的银行卡账号(共享数据),由于系统是先判断账号上的钱是否满足钱数,再去吐钱,最后修改账户的余额。这样就可能导致如果小明和小红同时去取钱(两个线程),同时判断钱数足够可以取钱,最后账户上没有足够的两份钱却吐了两份钱出来。
代码
Java
public class Demo01 {
public static void main(String[] args) {
//创建一个共享账户
Account tgt = new Account("tgt", new BigDecimal("100000"));
//创建ATM机对象,并且启动线程(运行里面取钱的方法)
//小明线程:取钱执行流程
new Thread(new ATM(tgt, new BigDecimal("100000")),"小明").start();
//小红线程:取钱执行流程
new Thread(new ATM(tgt, new BigDecimal("100000")),"小红").start();
}
}
Java
@Data
@NoArgsConstructor
@AllArgsConstructor
public class ATM implements Runnable{
private Account account;
private BigDecimal drawMoney;
@Override
public void run() {
//调用取钱方法
account.drawMoney(drawMoney);
}
}
Java
@Data
@NoArgsConstructor
@AllArgsConstructor
public class Account {
private String cardID;
private BigDecimal money;//银行卡余额,,在金融领域(和钱相关)推荐使用BigDecimal类型,数据精准
/**
* 取钱方法
* @param drawMoney
*/
public void drawMoney(BigDecimal drawMoney){
//getMoney().compareTo(drawMoney) 返回整数 代表余额大
//getMoney().compareTo(drawMoney) 返回0 代表相等
//getMoney().compareTo(drawMoney) 返回负数 代表余额小
if (getMoney().compareTo(drawMoney) < 0){
//输出:线程名字:取钱失败,余额不足!
System.out.println(Thread.currentThread().getName() + ":取钱失败,余额不足!");
}else {
try {
//为了让另一个人余额判断成功,先不要立减更新余额,休眠一会
Thread.sleep(200);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
//余额充足,更新余额
BigDecimal balance = getMoney().subtract(drawMoney);//余额-取钱金额
setMoney(balance);
//打印:线程名字:取钱成功,最新余额xxx
System.out.println(Thread.currentThread().getName() + ":取钱成功,最新余额" + getMoney());
}
}
}
3.2 线程安全的解决方案
3.2.1 方案1:synchronized 关键字(最常用)
3.2.1.1 用法1:同步代码块
Java
public void drawMoney(BigDecimal drawMoney) {
//解决多线程并发安全问题方式1:同步代码块
//语法:synchronized (同步锁对象){存在不安全的代码}
//同步锁对象说明:执行竞争的线程的同步锁对象必须是共享的资源对象
// 方式1:特定对象, 例如new Object()对象,小红和小明不共享object,所以不适用当前场景
// 方式2:this, 调用当前方法的对象,这里就是Account,并且小红和小明共享同一个账户,推荐,锁的范围小
// 假设:小明和小红共享一个账户 Account1对象,在Account1这个锁里面小明和小红必须依次执行,但是小明和小张或小李是可以并发执行的
// 假设:小张和小李共享一个账户 Account2对象,在Account2这个所里面小张和小李必须依次执行,但是小张和小明或小红是可以并发执行的
// 方式3:类.class, 类字节码对象,全局只有一份,所有线程都共享这一个对象,都竞争这一个锁,锁范围大
// 如果使用字节码对象,上面4个人都不可以并发,小明和小张或小李不可以并发,小张和小明或小红部可以并发
synchronized (this) {
if (getMoney().compareTo(drawMoney) < 0) {
//输出:线程名字:取钱失败,余额不足!
System.out.println(Thread.currentThread().getName() + ":取钱失败,余额不足!");
} else {
try {
//为了让另一个人余额判断成功,先不要立减更新余额,休眠一会
Thread.sleep(200);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
//余额充足,更新余额
BigDecimal balance = getMoney().subtract(drawMoney);//余额-取钱金额
setMoney(balance);
//打印:线程名字:取钱成功,最新余额xxx
System.out.println(Thread.currentThread().getName() + ":取钱成功,最新余额" + getMoney());
}
}
}
3.2.1.2 用法 2:同步方法
Java
public synchronized void drawMoney(BigDecimal drawMoney) {
//解决多线程并发安全问题方式2:同步方法
//语法: public [static ] synchronized 方法名(形参列表){存在不安全的代码}
//同步锁说明:执行竞争的线程的同步锁必须是共享的资源对象(竞争线程操作的资源对象只有1个)
// synchronized放在实例方法, 同步锁对象就是this
// synchronized放在静态方法, 同步锁对象就是类对象(字节码对象),这里是ATM.class,全局只有一份,所有线程都竞争这一个锁,锁范围大
//Shift+Tab 向左缩进
if (getMoney().compareTo(drawMoney) < 0) {
//输出:线程名字:取钱失败,余额不足!
System.out.println(Thread.currentThread().getName() + ":取钱失败,余额不足!");
} else {
try {
//为了让另一个人余额判断成功,先不要立减更新余额,休眠一会
Thread.sleep(200);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
//余额充足,更新余额
BigDecimal balance = getMoney().subtract(drawMoney);//余额-取钱金额
setMoney(balance);
//打印:线程名字:取钱成功,最新余额xxx
System.out.println(Thread.currentThread().getName() + ":取钱成功,最新余额" + getMoney());
}
}
3.2.2 lock锁
Java
public void drawMoney(BigDecimal drawMoney) {
//解决多线程并发安全问题方式3:lock锁
//语法: 创建实例对象 Lock l = new ReentrantLock();
//语法: 创建静态实例对象 static Lock l = new ReentrantLock();
// try{
// l.lock();
// //线程不安全的代码
// }finally{
// l.unlock();
// }
//同步锁说明:执行竞争的线程的同步锁必须是共享的资源对象(竞争线程操作的资源对象只有1个)
// l是实例对象, 同步锁对象就是this
// l是静态对象, 同步锁对象就是类对象(字节码对象),这里是ATM.class,全局只有一份,所有线程都竞争这一个锁,锁范围大
//疑问: 3种方式到底使用哪个?
// 答: 如果简单使用推荐synchronized
// 如果想功能更加强大和复杂的处理推荐lock锁,lock锁功能更加强大(公平锁,锁中断,锁超时),注意lock锁一定要在finally里面收订释放锁,否则造成死锁
// synchronized和lock锁在jdk6以后性能差不多,在jdk6之前synchronized性能低下
// 公平锁: 根据先后获取锁的顺序依次让对应线程获取锁资源 (new ReentrantLock(true) 公平锁,如果没有参数就是非公平锁),synchronized没有这个功能
// 锁中断: 中断锁的目的是不希望线程阻塞等待太久可以进行中断,获取锁不用 l.lock()而是使用lock.lockInterruptibly();最后通过thread对象.interrupt();
// 锁超时: 获取锁成功或失败立刻返回,如果不成功可以执行不成功的逻辑(替代方案),获取锁不用 l.lock()而是使用lock.tryLock(10, TimeUnit.SECONDS)
// lock.tryLock(10,TimeUnit.SECONDS) 等待10秒获取锁,如果获取不到锁,则返回false,如果获取到锁,则返回true
// lock.tryLock() 立刻获取锁,如果获取不到锁,则返回false,如果获取到锁,则返回true
// if(lock.tryLock(10,TimeUnit.SECONDS)){
// try{
// //线程不安全的代码
// }finally{
// l.unlock();
// }
// }else{
// //替代方案的代码
// }
try {
lock.lock();//上锁
if (getMoney().compareTo(drawMoney) < 0) {
//输出:线程名字:取钱失败,余额不足!
System.out.println(Thread.currentThread().getName() + ":取钱失败,余额不足!");
} else {
try {
//为了让另一个人余额判断成功,先不要立减更新余额,休眠一会
Thread.sleep(200);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
//余额充足,更新余额
BigDecimal balance = getMoney().subtract(drawMoney);//余额-取钱金额
setMoney(balance);
//打印:线程名字:取钱成功,最新余额xxx
System.out.println(Thread.currentThread().getName() + ":取钱成功,最新余额" + getMoney());
}
} finally {
lock.unlock();//释放锁
}
}
四、实战场景:线程池(开发必用,面试高频)
4.1 什么是线程池?
线程池就是一个可以复用线程的技术。
4.2 不使用线程池的问题
用户每发起一个请求,后台就需要创建一个新线程来处理,下次新任务来了肯定又要创建新线程处理的, 创建新线程的开销是很大的,并且请求过多时,肯定会产生大量的线程出来,这样会严重影响系统的性能。
4.3 创建线程池
4.3.1 线程池的核心参数(ThreadPoolExecutor)
- 线程池创建的参数
- public ThreadPoolExecutor(int corePoolSize, int maximumPoolSize, long keepAliveTime, TimeUnit unit, ThreadFactory threadFactory, BlockingQueue workQueue, RejectedExecutionHandler handler)
- 参数一:corePoolSize:指定线程池的核心线程的数量。
- 参数二:maximumPoolSize:指定线程池的最大线程数量。
- 参数三:keepAliveTime:指定临时线程的存活时间。
- 参数四:unit:指定临时线程存活的时间单位(秒、分、时、天)
- 参数五:workQueue:指定线程池的任务队列。
- 参数六:threadFactory:指定线程池的线程工厂
- 参数七:handler:指定线程池的任务拒绝策略(线程都在忙,任务队列也满了的时候,新任务来了该怎么处理) 忙不过来咋办?
Java
/*
线程池执行流程(面试题)
1.当有第一个任务的时候,线程池会创建第一个核心线程来处理这个任务
2.继续添加任务,不管已有核心线程是否忙,只要核心线程没有满,都会创建新的核心线程处理,直到所有核心线程数都创建完成
4.核心线程都在忙,新来的任务就会加入工作队列
5.核心线程都在忙,工作队列存储的任务已满,再来的新任务会创建临时线程进行处理
6.核心线程都在忙,工作队列已满,临时线程都在忙,再来的新任务会触发决绝策略
* */
//创建线程池
ThreadPoolExecutor threadPoolExecutor = new ThreadPoolExecutor(
3,
5,
10,
TimeUnit.SECONDS,
new ArrayBlockingQueue<>(3),
Executors.defaultThreadFactory(),
new ThreadPoolExecutor.AbortPolicy()
);
//从线程池获取线程执行任务
threadPoolExecutor.submit(new MyRunnable(1)); //来的第1个任务会创建第1个核心线程执行
threadPoolExecutor.submit(new MyRunnable(2)); //来的第2个任务会创建第2个核心线程执行
threadPoolExecutor.submit(new MyRunnable(3)); //来的第3个任务会创建第3个核心线程执行
threadPoolExecutor.submit(new MyRunnable(4)); //来的第4个任务会复用核心线程执行,如果核心线程都在忙,并且队列没有满,这个任务加入队列
threadPoolExecutor.submit(new MyRunnable(5)); //来的第5个任务会复用核心线程执行,如果核心线程都在忙,并且队列没有满,这个任务加入队列
threadPoolExecutor.submit(new MyRunnable(6)); //来的第6个任务会复用核心线程执行,如果核心线程都在忙,并且队列没有满,这个任务加入队列
threadPoolExecutor.submit(new MyRunnable(7)); //来的第7个任务判断核心线程都在忙,队列也都满了,核心线程数<最大线程数,就会创建临时线程处理这个任务
threadPoolExecutor.submit(new MyRunnable(8)); //来的第8个任务判断核心线程都在忙,队列也都满了,核心线程数<最大线程数,就会创建临时线程处理这个任务
threadPoolExecutor.submit(new MyRunnable(9)); //来的第9个任务判断核心线程都在忙,队列也都满了,核心线程数==最大线程数,触发拒绝策略,默认拒绝是抛出异常
}
}
@Data
@NoArgsConstructor
@AllArgsConstructor
class MyRunnable implements Runnable{
private Integer index;
@Override
public void run() {
System.out.println(Thread.currentThread().getName() + "执行了任务序号:" + index);
try {
Thread.sleep(Integer.MAX_VALUE);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
}
4.3.2 常用线程池(Executors 工具类)
线程池尽量不使用 Executors 去创建,而是通过 ThreadPoolExecutor 的方式,这 样的处理方式让写的同学更加明确线程池的运行规则,规避资源耗尽的风险。
说明:Executors 返回的线程池对象的弊端如下:
-
FixedThreadPool 和 SingleThreadPool:
允许的请求队列长度为 Integer.MAX_VALUE,可能会堆积大量的请求,从而导致 OOM。
-
CachedThreadPool:
允许的创建线程数量为 Integer.MAX_VALUE,可能会创建大量的线程,从而导致 OOM。
Java
public static void main(String[] args) {
//目标:了解使用工具类Executors工具类创建线程池
//创建固定数量线程的线程池
ExecutorService executorService = Executors.newFixedThreadPool(5);
//不推荐,因为里面工作队列容量整型最大值,容易造成内存溢出
//创建1个线程数量的线程池
ExecutorService executorService1 = Executors.newSingleThreadExecutor();
//不推荐,因为里面工作队列容量整型最大值,容易造成内存溢出
//创建缓存线程池
ExecutorService executorService2 = Executors.newCachedThreadPool();
//不推荐,随着任务量变多,创建的线程也多,导致对线程数量没有限制,,容易造成内存溢出
}
最后
多线程是 Java 的重点也是难点,光看理论没用,一定要多敲代码、多调试(比如用 IDEA 的 Debug 查看线程状态)。本文覆盖了面试和开发的核心知识点,掌握这些内容,应对大部分多线程面试题和实际开发场景都足够了。