JavaSE 进阶:多线程核心知识点(线程创建 vs 线程安全 + 线程池优化 + 实战案例

前言

大家好!今天我们学习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 返回的线程池对象的弊端如下:

  1. FixedThreadPool 和 SingleThreadPool:

    允许的请求队列长度为 Integer.MAX_VALUE,可能会堆积大量的请求,从而导致 OOM。

  2. 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 查看线程状态)。本文覆盖了面试和开发的核心知识点,掌握这些内容,应对大部分多线程面试题和实际开发场景都足够了。

相关推荐
萝卜青今天也要开心1 小时前
2025年下半年系统架构设计师考后分享
java·数据库·redis·笔记·学习·系统架构
Unstoppable221 小时前
八股训练营第 39 天 | Bean 的作用域?Bean 的生命周期?Spring 循环依赖是怎么解决的?Spring 中用到了那些设计模式?
java·spring·设计模式
阿伟*rui1 小时前
互联网大厂Java面试:音视频场景技术攻防与系统设计深度解析
java·redis·websocket·面试·音视频·高并发·后端架构
Java天梯之路1 小时前
Spring AOP:面向切面编程的优雅解耦之道
java·spring·面试
qq_348231851 小时前
Spring AI核心知识点
java·人工智能·spring
关于不上作者榜就原神启动那件事1 小时前
【java后端开发问题合集】
java·开发语言
徐_三岁2 小时前
Python 入门学习
java·python·学习
500842 小时前
鸿蒙 Flutter 接入鸿蒙系统能力:通知(本地 / 推送)与后台任务
java·flutter·华为·性能优化·架构
脸大是真的好~2 小时前
尚硅谷-Kafka02-主题创建-生产数据
java